Compare commits

...

5 Commits

Author SHA1 Message Date
9002bbad09 4.46.2
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m7s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 4m35s
Build-Release-Image / Merge-Images (push) Successful in 21s
Build-Release-Image / Create-Release (push) Successful in 9s
Build-Release-Image / Notify (push) Successful in 3s
2024-07-11 12:00:06 +01:00
f51d31f431 4.46.0
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m41s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 4m54s
Build-Release-Image / Merge-Images (push) Successful in 19s
Build-Release-Image / Create-Release (push) Successful in 16s
Build-Release-Image / Notify (push) Successful in 19s
2024-07-09 12:00:06 +01:00
c67b97fe32 4.45.1
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m52s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 4m0s
Build-Release-Image / Merge-Images (push) Successful in 18s
Build-Release-Image / Create-Release (push) Successful in 14s
Build-Release-Image / Notify (push) Successful in 8s
2024-06-26 12:00:08 +01:00
bd414b1fc7 4.45.0
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m1s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 4m13s
Build-Release-Image / Merge-Images (push) Successful in 22s
Build-Release-Image / Create-Release (push) Successful in 9s
Build-Release-Image / Notify (push) Successful in 4s
2024-06-11 12:00:06 +01:00
0f73a14926 4.44.3 2024-05-24 12:00:06 +01:00
55 changed files with 1937 additions and 740 deletions

View File

@ -26,10 +26,15 @@ from app.email_utils import (
) )
from app.errors import AliasInTrashError from app.errors import AliasInTrashError
from app.events.event_dispatcher import EventDispatcher from app.events.event_dispatcher import EventDispatcher
from app.events.generated.event_pb2 import AliasDeleted, AliasStatusChange, EventContent from app.events.generated.event_pb2 import (
AliasDeleted,
AliasStatusChanged,
EventContent,
)
from app.log import LOG from app.log import LOG
from app.models import ( from app.models import (
Alias, Alias,
AliasDeleteReason,
CustomDomain, CustomDomain,
Directory, Directory,
User, User,
@ -305,7 +310,9 @@ def try_auto_create_via_domain(address: str) -> Optional[Alias]:
return None return None
def delete_alias(alias: Alias, user: User): def delete_alias(
alias: Alias, user: User, reason: AliasDeleteReason = AliasDeleteReason.Unspecified
):
""" """
Delete an alias and add it to either global or domain trash Delete an alias and add it to either global or domain trash
Should be used instead of Alias.delete, DomainDeletedAlias.create, DeletedAlias.create Should be used instead of Alias.delete, DomainDeletedAlias.create, DeletedAlias.create
@ -320,6 +327,7 @@ def delete_alias(alias: Alias, user: User):
user_id=user.id, user_id=user.id,
email=alias.email, email=alias.email,
domain_id=alias.custom_domain_id, domain_id=alias.custom_domain_id,
reason=reason,
) )
Session.add(domain_deleted_alias) Session.add(domain_deleted_alias)
Session.commit() Session.commit()
@ -328,7 +336,7 @@ def delete_alias(alias: Alias, user: User):
) )
else: else:
if not DeletedAlias.get_by(email=alias.email): if not DeletedAlias.get_by(email=alias.email):
deleted_alias = DeletedAlias(email=alias.email) deleted_alias = DeletedAlias(email=alias.email, reason=reason)
Session.add(deleted_alias) Session.add(deleted_alias)
Session.commit() Session.commit()
LOG.i(f"Moving {alias} to global trash {deleted_alias}") LOG.i(f"Moving {alias} to global trash {deleted_alias}")
@ -449,10 +457,12 @@ def transfer_alias(alias, new_user, new_mailboxes: [Mailbox]):
f"Alias {alias.email} has been received", f"Alias {alias.email} has been received",
render( render(
"transactional/alias-transferred.txt", "transactional/alias-transferred.txt",
user=old_user,
alias=alias, alias=alias,
), ),
render( render(
"transactional/alias-transferred.html", "transactional/alias-transferred.html",
user=old_user,
alias=alias, alias=alias,
), ),
) )
@ -468,9 +478,10 @@ def transfer_alias(alias, new_user, new_mailboxes: [Mailbox]):
def change_alias_status(alias: Alias, enabled: bool, commit: bool = False): def change_alias_status(alias: Alias, enabled: bool, commit: bool = False):
LOG.i(f"Changing alias {alias} enabled to {enabled}")
alias.enabled = enabled alias.enabled = enabled
event = AliasStatusChange( event = AliasStatusChanged(
alias_id=alias.id, alias_email=alias.email, enabled=enabled alias_id=alias.id, alias_email=alias.email, enabled=enabled
) )
EventDispatcher.send_event(alias.user, EventContent(alias_status_change=event)) EventDispatcher.send_event(alias.user, EventContent(alias_status_change=event))

View File

@ -5,6 +5,7 @@ import arrow
from flask import Blueprint, request, jsonify, g from flask import Blueprint, request, jsonify, g
from flask_login import current_user from flask_login import current_user
from app import constants
from app.db import Session from app.db import Session
from app.models import ApiKey from app.models import ApiKey
@ -18,7 +19,9 @@ def authorize_request() -> Optional[Tuple[str, int]]:
api_key = ApiKey.get_by(code=api_code) api_key = ApiKey.get_by(code=api_code)
if not api_key: if not api_key:
if current_user.is_authenticated: if current_user.is_authenticated and request.headers.get(
constants.HEADER_ALLOW_API_COOKIES
):
g.user = current_user g.user = current_user
else: else:
return jsonify(error="Wrong api key"), 401 return jsonify(error="Wrong api key"), 401

View File

@ -25,7 +25,8 @@ from app.errors import (
ErrAddressInvalid, ErrAddressInvalid,
) )
from app.extensions import limiter from app.extensions import limiter
from app.models import Alias, Contact, Mailbox, AliasMailbox from app.log import LOG
from app.models import Alias, Contact, Mailbox, AliasMailbox, AliasDeleteReason
@deprecated @deprecated
@ -160,7 +161,7 @@ def delete_alias(alias_id):
if not alias or alias.user_id != user.id: if not alias or alias.user_id != user.id:
return jsonify(error="Forbidden"), 403 return jsonify(error="Forbidden"), 403
alias_utils.delete_alias(alias, user) alias_utils.delete_alias(alias, user, AliasDeleteReason.ManualAction)
return jsonify(deleted=True), 200 return jsonify(deleted=True), 200
@ -185,6 +186,7 @@ def toggle_alias(alias_id):
return jsonify(error="Forbidden"), 403 return jsonify(error="Forbidden"), 403
alias_utils.change_alias_status(alias, enabled=not alias.enabled) alias_utils.change_alias_status(alias, enabled=not alias.enabled)
LOG.i(f"User {user} changed alias {alias} enabled status to {alias.enabled}")
Session.commit() Session.commit()
return jsonify(enabled=alias.enabled), 200 return jsonify(enabled=alias.enabled), 200

View File

@ -129,8 +129,8 @@ def auth_register():
send_email( send_email(
email, email,
"Just one more step to join SimpleLogin", "Just one more step to join SimpleLogin",
render("transactional/code-activation.txt.jinja2", code=code), render("transactional/code-activation.txt.jinja2", user=user, code=code),
render("transactional/code-activation.html", code=code), render("transactional/code-activation.html", user=user, code=code),
) )
RegisterEvent(RegisterEvent.ActionType.success, RegisterEvent.Source.api).send() RegisterEvent(RegisterEvent.ActionType.success, RegisterEvent.Source.api).send()
@ -226,8 +226,8 @@ def auth_reactivate():
send_email( send_email(
email, email,
"Just one more step to join SimpleLogin", "Just one more step to join SimpleLogin",
render("transactional/code-activation.txt.jinja2", code=code), render("transactional/code-activation.txt.jinja2", user=user, code=code),
render("transactional/code-activation.html", code=code), render("transactional/code-activation.html", user=user, code=code),
) )
return jsonify(msg="User needs to confirm their account"), 200 return jsonify(msg="User needs to confirm their account"), 200

View File

@ -125,4 +125,4 @@ def send_activation_email(user, next_url):
LOG.d("redirect user to %s after activation", next_url) LOG.d("redirect user to %s after activation", next_url)
activation_link = activation_link + "&next=" + encode_url(next_url) activation_link = activation_link + "&next=" + encode_url(next_url)
email_utils.send_activation_email(user.email, activation_link) email_utils.send_activation_email(user, activation_link)

View File

@ -120,7 +120,7 @@ if POSTFIX_SUBMISSION_TLS:
else: else:
default_postfix_port = 25 default_postfix_port = 25
POSTFIX_PORT = int(os.environ.get("POSTFIX_PORT", default_postfix_port)) POSTFIX_PORT = int(os.environ.get("POSTFIX_PORT", default_postfix_port))
POSTFIX_TIMEOUT = os.environ.get("POSTFIX_TIMEOUT", 3) POSTFIX_TIMEOUT = int(os.environ.get("POSTFIX_TIMEOUT", 3))
# ["domain1.com", "domain2.com"] # ["domain1.com", "domain2.com"]
OTHER_ALIAS_DOMAINS = sl_getenv("OTHER_ALIAS_DOMAINS", list) OTHER_ALIAS_DOMAINS = sl_getenv("OTHER_ALIAS_DOMAINS", list)
@ -281,6 +281,7 @@ JOB_DELETE_MAILBOX = "delete-mailbox"
JOB_DELETE_DOMAIN = "delete-domain" JOB_DELETE_DOMAIN = "delete-domain"
JOB_SEND_USER_REPORT = "send-user-report" JOB_SEND_USER_REPORT = "send-user-report"
JOB_SEND_PROTON_WELCOME_1 = "proton-welcome-1" JOB_SEND_PROTON_WELCOME_1 = "proton-welcome-1"
JOB_SEND_ALIAS_CREATION_EVENTS = "send-alias-creation-events"
# for pagination # for pagination
PAGE_LIMIT = 20 PAGE_LIMIT = 20
@ -583,3 +584,7 @@ UPCLOUD_DB_ID = os.environ.get("UPCLOUD_DB_ID", None)
STORE_TRANSACTIONAL_EMAILS = "STORE_TRANSACTIONAL_EMAILS" in os.environ STORE_TRANSACTIONAL_EMAILS = "STORE_TRANSACTIONAL_EMAILS" in os.environ
EVENT_WEBHOOK = os.environ.get("EVENT_WEBHOOK", None) EVENT_WEBHOOK = os.environ.get("EVENT_WEBHOOK", None)
# We want it disabled by default, so only skip if defined
EVENT_WEBHOOK_SKIP_VERIFY_SSL = "EVENT_WEBHOOK_SKIP_VERIFY_SSL" in os.environ
EVENT_WEBHOOK_DISABLE = "EVENT_WEBHOOK_DISABLE" in os.environ

1
app/app/constants.py Normal file
View File

@ -0,0 +1 @@
HEADER_ALLOW_API_COOKIES = "X-Sl-Allowcookies"

View File

@ -169,7 +169,7 @@ def send_reset_password_email(user):
reset_password_link = f"{URL}/auth/reset_password?code={reset_password_code.code}" reset_password_link = f"{URL}/auth/reset_password?code={reset_password_code.code}"
email_utils.send_reset_password_email(user.email, reset_password_link) email_utils.send_reset_password_email(user, reset_password_link)
def send_change_email_confirmation(user: User, email_change: EmailChange): def send_change_email_confirmation(user: User, email_change: EmailChange):
@ -179,7 +179,7 @@ def send_change_email_confirmation(user: User, email_change: EmailChange):
link = f"{URL}/auth/change_email?code={email_change.code}" link = f"{URL}/auth/change_email?code={email_change.code}"
email_utils.send_change_email(email_change.new_email, user.email, link) email_utils.send_change_email(user, email_change.new_email, link)
@dashboard_bp.route("/resend_email_change", methods=["GET", "POST"]) @dashboard_bp.route("/resend_email_change", methods=["GET", "POST"])

View File

@ -12,6 +12,7 @@ from app.extensions import limiter
from app.log import LOG from app.log import LOG
from app.models import ( from app.models import (
Alias, Alias,
AliasDeleteReason,
AliasGeneratorEnum, AliasGeneratorEnum,
User, User,
EmailLog, EmailLog,
@ -143,7 +144,9 @@ def index():
if request.form.get("form-name") == "delete-alias": if request.form.get("form-name") == "delete-alias":
LOG.i(f"User {current_user} requested deletion of alias {alias}") LOG.i(f"User {current_user} requested deletion of alias {alias}")
email = alias.email email = alias.email
alias_utils.delete_alias(alias, current_user) alias_utils.delete_alias(
alias, current_user, AliasDeleteReason.ManualAction
)
flash(f"Alias {email} has been deleted", "success") flash(f"Alias {email} has been deleted", "success")
elif request.form.get("form-name") == "disable-alias": elif request.form.get("form-name") == "disable-alias":
alias_utils.change_alias_status(alias, enabled=False) alias_utils.change_alias_status(alias, enabled=False)

View File

@ -33,6 +33,7 @@ from flanker.addresslib import address
from flanker.addresslib.address import EmailAddress from flanker.addresslib.address import EmailAddress
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from sqlalchemy import func from sqlalchemy import func
from flask_login import current_user
from app import config from app import config
from app.db import Session from app.db import Session
@ -68,17 +69,27 @@ VERP_TIME_START = 1640995200
VERP_HMAC_ALGO = "sha3-224" VERP_HMAC_ALGO = "sha3-224"
def render(template_name, **kwargs) -> str: def render(template_name: str, user: Optional[User], **kwargs) -> str:
templates_dir = os.path.join(config.ROOT_DIR, "templates", "emails") templates_dir = os.path.join(config.ROOT_DIR, "templates", "emails")
env = Environment(loader=FileSystemLoader(templates_dir)) env = Environment(loader=FileSystemLoader(templates_dir))
template = env.get_template(template_name) template = env.get_template(template_name)
if user is None:
if current_user and current_user.is_authenticated:
user = current_user
use_partner_template = False
if user:
use_partner_template = user.has_used_alias_from_partner()
kwargs["user"] = user
return template.render( return template.render(
MAX_NB_EMAIL_FREE_PLAN=config.MAX_NB_EMAIL_FREE_PLAN, MAX_NB_EMAIL_FREE_PLAN=config.MAX_NB_EMAIL_FREE_PLAN,
URL=config.URL, URL=config.URL,
LANDING_PAGE_URL=config.LANDING_PAGE_URL, LANDING_PAGE_URL=config.LANDING_PAGE_URL,
YEAR=arrow.now().year, YEAR=arrow.now().year,
USE_PARTNER_TEMPLATE=use_partner_template,
**kwargs, **kwargs,
) )
@ -111,53 +122,59 @@ def send_trial_end_soon_email(user):
) )
def send_activation_email(email, activation_link): def send_activation_email(user: User, activation_link):
send_email( send_email(
email, user.email,
"Just one more step to join SimpleLogin", "Just one more step to join SimpleLogin",
render( render(
"transactional/activation.txt", "transactional/activation.txt",
user=user,
activation_link=activation_link, activation_link=activation_link,
email=email, email=user.email,
), ),
render( render(
"transactional/activation.html", "transactional/activation.html",
user=user,
activation_link=activation_link, activation_link=activation_link,
email=email, email=user.email,
), ),
) )
def send_reset_password_email(email, reset_password_link): def send_reset_password_email(user: User, reset_password_link):
send_email( send_email(
email, user.email,
"Reset your password on SimpleLogin", "Reset your password on SimpleLogin",
render( render(
"transactional/reset-password.txt", "transactional/reset-password.txt",
user=user,
reset_password_link=reset_password_link, reset_password_link=reset_password_link,
), ),
render( render(
"transactional/reset-password.html", "transactional/reset-password.html",
user=user,
reset_password_link=reset_password_link, reset_password_link=reset_password_link,
), ),
) )
def send_change_email(new_email, current_email, link): def send_change_email(user: User, new_email, link):
send_email( send_email(
new_email, new_email,
"Confirm email update on SimpleLogin", "Confirm email update on SimpleLogin",
render( render(
"transactional/change-email.txt", "transactional/change-email.txt",
user=user,
link=link, link=link,
new_email=new_email, new_email=new_email,
current_email=current_email, current_email=user.email,
), ),
render( render(
"transactional/change-email.html", "transactional/change-email.html",
user=user,
link=link, link=link,
new_email=new_email, new_email=new_email,
current_email=current_email, current_email=user.email,
), ),
) )
@ -170,28 +187,32 @@ def send_invalid_totp_login_email(user, totp_type):
"Unsuccessful attempt to login to your SimpleLogin account", "Unsuccessful attempt to login to your SimpleLogin account",
render( render(
"transactional/invalid-totp-login.txt", "transactional/invalid-totp-login.txt",
user=user,
type=totp_type, type=totp_type,
), ),
render( render(
"transactional/invalid-totp-login.html", "transactional/invalid-totp-login.html",
user=user,
type=totp_type, type=totp_type,
), ),
1, 1,
) )
def send_test_email_alias(email, name): def send_test_email_alias(user: User, email: str):
send_email( send_email(
email, email,
f"This email is sent to {email}", f"This email is sent to {email}",
render( render(
"transactional/test-email.txt", "transactional/test-email.txt",
name=name, user=user,
name=user.name,
alias=email, alias=email,
), ),
render( render(
"transactional/test-email.html", "transactional/test-email.html",
name=name, user=user,
name=user.name,
alias=email, alias=email,
), ),
) )
@ -206,11 +227,13 @@ def send_cannot_create_directory_alias(user, alias_address, directory_name):
f"Alias {alias_address} cannot be created", f"Alias {alias_address} cannot be created",
render( render(
"transactional/cannot-create-alias-directory.txt", "transactional/cannot-create-alias-directory.txt",
user=user,
alias=alias_address, alias=alias_address,
directory=directory_name, directory=directory_name,
), ),
render( render(
"transactional/cannot-create-alias-directory.html", "transactional/cannot-create-alias-directory.html",
user=user,
alias=alias_address, alias=alias_address,
directory=directory_name, directory=directory_name,
), ),
@ -228,11 +251,13 @@ def send_cannot_create_directory_alias_disabled(user, alias_address, directory_n
f"Alias {alias_address} cannot be created", f"Alias {alias_address} cannot be created",
render( render(
"transactional/cannot-create-alias-directory-disabled.txt", "transactional/cannot-create-alias-directory-disabled.txt",
user=user,
alias=alias_address, alias=alias_address,
directory=directory_name, directory=directory_name,
), ),
render( render(
"transactional/cannot-create-alias-directory-disabled.html", "transactional/cannot-create-alias-directory-disabled.html",
user=user,
alias=alias_address, alias=alias_address,
directory=directory_name, directory=directory_name,
), ),
@ -248,11 +273,13 @@ def send_cannot_create_domain_alias(user, alias, domain):
f"Alias {alias} cannot be created", f"Alias {alias} cannot be created",
render( render(
"transactional/cannot-create-alias-domain.txt", "transactional/cannot-create-alias-domain.txt",
user=user,
alias=alias, alias=alias,
domain=domain, domain=domain,
), ),
render( render(
"transactional/cannot-create-alias-domain.html", "transactional/cannot-create-alias-domain.html",
user=user,
alias=alias, alias=alias,
domain=domain, domain=domain,
), ),
@ -919,10 +946,20 @@ def decode_text(text: str, encoding: EmailEncoding = EmailEncoding.NO) -> str:
return text return text
def add_header(msg: Message, text_header, html_header=None) -> Message: def add_header(
msg: Message, text_header, html_header=None, subject_prefix=None
) -> Message:
if not html_header: if not html_header:
html_header = text_header.replace("\n", "<br>") html_header = text_header.replace("\n", "<br>")
if subject_prefix is not None:
subject = msg[headers.SUBJECT]
if not subject:
msg.add_header(headers.SUBJECT, subject_prefix)
else:
subject = f"{subject_prefix} {subject}"
msg.replace_header(headers.SUBJECT, subject)
content_type = msg.get_content_type().lower() content_type = msg.get_content_type().lower()
if content_type == "text/plain": if content_type == "text/plain":
encoding = get_encoding(msg) encoding = get_encoding(msg)
@ -1253,6 +1290,7 @@ def spf_pass(
f"SimpleLogin Alert: attempt to send emails from your alias {alias.email} from unknown IP Address", f"SimpleLogin Alert: attempt to send emails from your alias {alias.email} from unknown IP Address",
render( render(
"transactional/spf-fail.txt", "transactional/spf-fail.txt",
user=user,
alias=alias.email, alias=alias.email,
ip=ip, ip=ip,
mailbox_url=config.URL + f"/dashboard/mailbox/{mailbox.id}#spf", mailbox_url=config.URL + f"/dashboard/mailbox/{mailbox.id}#spf",
@ -1262,6 +1300,7 @@ def spf_pass(
), ),
render( render(
"transactional/spf-fail.html", "transactional/spf-fail.html",
user=user,
ip=ip, ip=ip,
mailbox_url=config.URL + f"/dashboard/mailbox/{mailbox.id}#spf", mailbox_url=config.URL + f"/dashboard/mailbox/{mailbox.id}#spf",
to_email=contact_email, to_email=contact_email,

View File

@ -34,6 +34,9 @@ class EventDispatcher:
dispatcher: Dispatcher = PostgresDispatcher.get(), dispatcher: Dispatcher = PostgresDispatcher.get(),
skip_if_webhook_missing: bool = True, skip_if_webhook_missing: bool = True,
): ):
if config.EVENT_WEBHOOK_DISABLE:
return
if not config.EVENT_WEBHOOK and skip_if_webhook_missing: if not config.EVENT_WEBHOOK and skip_if_webhook_missing:
return return

View File

@ -1,12 +1,22 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT! # Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: event.proto # source: event.proto
# Protobuf Python Version: 5.26.1 # Protobuf Python Version: 5.27.0
"""Generated protocol buffer code.""" """Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
_runtime_version.Domain.PUBLIC,
5,
27,
0,
'',
'event.proto'
)
# @@protoc_insertion_point(imports) # @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default() _sym_db = _symbol_database.Default()
@ -14,25 +24,27 @@ _sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0b\x65vent.proto\x12\x12simplelogin_events\"\'\n\x0eUserPlanChange\x12\x15\n\rplan_end_time\x18\x01 \x01(\r\"\r\n\x0bUserDeleted\"Z\n\x0c\x41liasCreated\x12\x10\n\x08\x61lias_id\x18\x01 \x01(\r\x12\x13\n\x0b\x61lias_email\x18\x02 \x01(\t\x12\x12\n\nalias_note\x18\x03 \x01(\t\x12\x0f\n\x07\x65nabled\x18\x04 \x01(\x08\"K\n\x11\x41liasStatusChange\x12\x10\n\x08\x61lias_id\x18\x01 \x01(\r\x12\x13\n\x0b\x61lias_email\x18\x02 \x01(\t\x12\x0f\n\x07\x65nabled\x18\x03 \x01(\x08\"5\n\x0c\x41liasDeleted\x12\x10\n\x08\x61lias_id\x18\x01 \x01(\r\x12\x13\n\x0b\x61lias_email\x18\x02 \x01(\t\"\xce\x02\n\x0c\x45ventContent\x12>\n\x10user_plan_change\x18\x01 \x01(\x0b\x32\".simplelogin_events.UserPlanChangeH\x00\x12\x37\n\x0cuser_deleted\x18\x02 \x01(\x0b\x32\x1f.simplelogin_events.UserDeletedH\x00\x12\x39\n\ralias_created\x18\x03 \x01(\x0b\x32 .simplelogin_events.AliasCreatedH\x00\x12\x44\n\x13\x61lias_status_change\x18\x04 \x01(\x0b\x32%.simplelogin_events.AliasStatusChangeH\x00\x12\x39\n\ralias_deleted\x18\x05 \x01(\x0b\x32 .simplelogin_events.AliasDeletedH\x00\x42\t\n\x07\x63ontent\"y\n\x05\x45vent\x12\x0f\n\x07user_id\x18\x01 \x01(\r\x12\x18\n\x10\x65xternal_user_id\x18\x02 \x01(\t\x12\x12\n\npartner_id\x18\x03 \x01(\r\x12\x31\n\x07\x63ontent\x18\x04 \x01(\x0b\x32 .simplelogin_events.EventContentb\x06proto3') DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0b\x65vent.proto\x12\x12simplelogin_events\"(\n\x0fUserPlanChanged\x12\x15\n\rplan_end_time\x18\x01 \x01(\r\"\r\n\x0bUserDeleted\"Z\n\x0c\x41liasCreated\x12\x10\n\x08\x61lias_id\x18\x01 \x01(\r\x12\x13\n\x0b\x61lias_email\x18\x02 \x01(\t\x12\x12\n\nalias_note\x18\x03 \x01(\t\x12\x0f\n\x07\x65nabled\x18\x04 \x01(\x08\"L\n\x12\x41liasStatusChanged\x12\x10\n\x08\x61lias_id\x18\x01 \x01(\r\x12\x13\n\x0b\x61lias_email\x18\x02 \x01(\t\x12\x0f\n\x07\x65nabled\x18\x03 \x01(\x08\"5\n\x0c\x41liasDeleted\x12\x10\n\x08\x61lias_id\x18\x01 \x01(\r\x12\x13\n\x0b\x61lias_email\x18\x02 \x01(\t\"D\n\x10\x41liasCreatedList\x12\x30\n\x06\x65vents\x18\x01 \x03(\x0b\x32 .simplelogin_events.AliasCreated\"\x93\x03\n\x0c\x45ventContent\x12?\n\x10user_plan_change\x18\x01 \x01(\x0b\x32#.simplelogin_events.UserPlanChangedH\x00\x12\x37\n\x0cuser_deleted\x18\x02 \x01(\x0b\x32\x1f.simplelogin_events.UserDeletedH\x00\x12\x39\n\ralias_created\x18\x03 \x01(\x0b\x32 .simplelogin_events.AliasCreatedH\x00\x12\x45\n\x13\x61lias_status_change\x18\x04 \x01(\x0b\x32&.simplelogin_events.AliasStatusChangedH\x00\x12\x39\n\ralias_deleted\x18\x05 \x01(\x0b\x32 .simplelogin_events.AliasDeletedH\x00\x12\x41\n\x11\x61lias_create_list\x18\x06 \x01(\x0b\x32$.simplelogin_events.AliasCreatedListH\x00\x42\t\n\x07\x63ontent\"y\n\x05\x45vent\x12\x0f\n\x07user_id\x18\x01 \x01(\r\x12\x18\n\x10\x65xternal_user_id\x18\x02 \x01(\t\x12\x12\n\npartner_id\x18\x03 \x01(\r\x12\x31\n\x07\x63ontent\x18\x04 \x01(\x0b\x32 .simplelogin_events.EventContentb\x06proto3')
_globals = globals() _globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'event_pb2', _globals) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'event_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS: if not _descriptor._USE_C_DESCRIPTORS:
DESCRIPTOR._loaded_options = None DESCRIPTOR._loaded_options = None
_globals['_USERPLANCHANGE']._serialized_start=35 _globals['_USERPLANCHANGED']._serialized_start=35
_globals['_USERPLANCHANGE']._serialized_end=74 _globals['_USERPLANCHANGED']._serialized_end=75
_globals['_USERDELETED']._serialized_start=76 _globals['_USERDELETED']._serialized_start=77
_globals['_USERDELETED']._serialized_end=89 _globals['_USERDELETED']._serialized_end=90
_globals['_ALIASCREATED']._serialized_start=91 _globals['_ALIASCREATED']._serialized_start=92
_globals['_ALIASCREATED']._serialized_end=181 _globals['_ALIASCREATED']._serialized_end=182
_globals['_ALIASSTATUSCHANGE']._serialized_start=183 _globals['_ALIASSTATUSCHANGED']._serialized_start=184
_globals['_ALIASSTATUSCHANGE']._serialized_end=258 _globals['_ALIASSTATUSCHANGED']._serialized_end=260
_globals['_ALIASDELETED']._serialized_start=260 _globals['_ALIASDELETED']._serialized_start=262
_globals['_ALIASDELETED']._serialized_end=313 _globals['_ALIASDELETED']._serialized_end=315
_globals['_EVENTCONTENT']._serialized_start=316 _globals['_ALIASCREATEDLIST']._serialized_start=317
_globals['_EVENTCONTENT']._serialized_end=650 _globals['_ALIASCREATEDLIST']._serialized_end=385
_globals['_EVENT']._serialized_start=652 _globals['_EVENTCONTENT']._serialized_start=388
_globals['_EVENT']._serialized_end=773 _globals['_EVENTCONTENT']._serialized_end=791
_globals['_EVENT']._serialized_start=793
_globals['_EVENT']._serialized_end=914
# @@protoc_insertion_point(module_scope) # @@protoc_insertion_point(module_scope)

View File

@ -1,10 +1,11 @@
from google.protobuf.internal import containers as _containers
from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor as _descriptor
from google.protobuf import message as _message from google.protobuf import message as _message
from typing import ClassVar as _ClassVar, Mapping as _Mapping, Optional as _Optional, Union as _Union from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union
DESCRIPTOR: _descriptor.FileDescriptor DESCRIPTOR: _descriptor.FileDescriptor
class UserPlanChange(_message.Message): class UserPlanChanged(_message.Message):
__slots__ = ("plan_end_time",) __slots__ = ("plan_end_time",)
PLAN_END_TIME_FIELD_NUMBER: _ClassVar[int] PLAN_END_TIME_FIELD_NUMBER: _ClassVar[int]
plan_end_time: int plan_end_time: int
@ -26,7 +27,7 @@ class AliasCreated(_message.Message):
enabled: bool enabled: bool
def __init__(self, alias_id: _Optional[int] = ..., alias_email: _Optional[str] = ..., alias_note: _Optional[str] = ..., enabled: bool = ...) -> None: ... def __init__(self, alias_id: _Optional[int] = ..., alias_email: _Optional[str] = ..., alias_note: _Optional[str] = ..., enabled: bool = ...) -> None: ...
class AliasStatusChange(_message.Message): class AliasStatusChanged(_message.Message):
__slots__ = ("alias_id", "alias_email", "enabled") __slots__ = ("alias_id", "alias_email", "enabled")
ALIAS_ID_FIELD_NUMBER: _ClassVar[int] ALIAS_ID_FIELD_NUMBER: _ClassVar[int]
ALIAS_EMAIL_FIELD_NUMBER: _ClassVar[int] ALIAS_EMAIL_FIELD_NUMBER: _ClassVar[int]
@ -44,19 +45,27 @@ class AliasDeleted(_message.Message):
alias_email: str alias_email: str
def __init__(self, alias_id: _Optional[int] = ..., alias_email: _Optional[str] = ...) -> None: ... def __init__(self, alias_id: _Optional[int] = ..., alias_email: _Optional[str] = ...) -> None: ...
class AliasCreatedList(_message.Message):
__slots__ = ("events",)
EVENTS_FIELD_NUMBER: _ClassVar[int]
events: _containers.RepeatedCompositeFieldContainer[AliasCreated]
def __init__(self, events: _Optional[_Iterable[_Union[AliasCreated, _Mapping]]] = ...) -> None: ...
class EventContent(_message.Message): class EventContent(_message.Message):
__slots__ = ("user_plan_change", "user_deleted", "alias_created", "alias_status_change", "alias_deleted") __slots__ = ("user_plan_change", "user_deleted", "alias_created", "alias_status_change", "alias_deleted", "alias_create_list")
USER_PLAN_CHANGE_FIELD_NUMBER: _ClassVar[int] USER_PLAN_CHANGE_FIELD_NUMBER: _ClassVar[int]
USER_DELETED_FIELD_NUMBER: _ClassVar[int] USER_DELETED_FIELD_NUMBER: _ClassVar[int]
ALIAS_CREATED_FIELD_NUMBER: _ClassVar[int] ALIAS_CREATED_FIELD_NUMBER: _ClassVar[int]
ALIAS_STATUS_CHANGE_FIELD_NUMBER: _ClassVar[int] ALIAS_STATUS_CHANGE_FIELD_NUMBER: _ClassVar[int]
ALIAS_DELETED_FIELD_NUMBER: _ClassVar[int] ALIAS_DELETED_FIELD_NUMBER: _ClassVar[int]
user_plan_change: UserPlanChange ALIAS_CREATE_LIST_FIELD_NUMBER: _ClassVar[int]
user_plan_change: UserPlanChanged
user_deleted: UserDeleted user_deleted: UserDeleted
alias_created: AliasCreated alias_created: AliasCreated
alias_status_change: AliasStatusChange alias_status_change: AliasStatusChanged
alias_deleted: AliasDeleted alias_deleted: AliasDeleted
def __init__(self, user_plan_change: _Optional[_Union[UserPlanChange, _Mapping]] = ..., user_deleted: _Optional[_Union[UserDeleted, _Mapping]] = ..., alias_created: _Optional[_Union[AliasCreated, _Mapping]] = ..., alias_status_change: _Optional[_Union[AliasStatusChange, _Mapping]] = ..., alias_deleted: _Optional[_Union[AliasDeleted, _Mapping]] = ...) -> None: ... alias_create_list: AliasCreatedList
def __init__(self, user_plan_change: _Optional[_Union[UserPlanChanged, _Mapping]] = ..., user_deleted: _Optional[_Union[UserDeleted, _Mapping]] = ..., alias_created: _Optional[_Union[AliasCreated, _Mapping]] = ..., alias_status_change: _Optional[_Union[AliasStatusChanged, _Mapping]] = ..., alias_deleted: _Optional[_Union[AliasDeleted, _Mapping]] = ..., alias_create_list: _Optional[_Union[AliasCreatedList, _Mapping]] = ...) -> None: ...
class Event(_message.Message): class Event(_message.Message):
__slots__ = ("user_id", "external_user_id", "partner_id", "content") __slots__ = ("user_id", "external_user_id", "partner_id", "content")

View File

@ -64,6 +64,7 @@ More info on https://simplelogin.io/docs/getting-started/anti-phishing/
msg, msg,
warning_plain_text, warning_plain_text,
warning_html, warning_html,
subject_prefix="[Possible phishing attempt]",
) )
return changed_msg, None return changed_msg, None
@ -76,6 +77,7 @@ More info on https://simplelogin.io/docs/getting-started/anti-phishing/
msg, msg,
warning_plain_text, warning_plain_text,
warning_html, warning_html,
subject_prefix="[Possible phishing attempt]",
) )
return changed_msg, None return changed_msg, None
@ -104,12 +106,14 @@ More info on https://simplelogin.io/docs/getting-started/anti-phishing/
f"An email sent to {alias.email} has been quarantined", f"An email sent to {alias.email} has been quarantined",
render( render(
"transactional/message-quarantine-dmarc.txt.jinja2", "transactional/message-quarantine-dmarc.txt.jinja2",
user=user,
from_header=from_header, from_header=from_header,
alias=alias, alias=alias,
refused_email_url=email_log.get_dashboard_url(), refused_email_url=email_log.get_dashboard_url(),
), ),
render( render(
"transactional/message-quarantine-dmarc.html", "transactional/message-quarantine-dmarc.html",
user=user,
from_header=from_header, from_header=from_header,
alias=alias, alias=alias,
refused_email_url=email_log.get_dashboard_url(), refused_email_url=email_log.get_dashboard_url(),
@ -174,12 +178,14 @@ def apply_dmarc_policy_for_reply_phase(
f"Attempt to send an email to your contact {contact_recipient.email} from {envelope.mail_from}", f"Attempt to send an email to your contact {contact_recipient.email} from {envelope.mail_from}",
render( render(
"transactional/spoof-reply.txt.jinja2", "transactional/spoof-reply.txt.jinja2",
user=alias_from.user,
contact=contact_recipient, contact=contact_recipient,
alias=alias_from, alias=alias_from,
sender=envelope.mail_from, sender=envelope.mail_from,
), ),
render( render(
"transactional/spoof-reply.html", "transactional/spoof-reply.html",
user=alias_from.user,
contact=contact_recipient, contact=contact_recipient,
alias=alias_from, alias=alias_from,
sender=envelope.mail_from, sender=envelope.mail_from,

View File

@ -319,11 +319,13 @@ def report_complaint_to_user_in_forward_phase(
f"Abuse report from {capitalized_name}", f"Abuse report from {capitalized_name}",
render( render(
"transactional/provider-complaint-forward-phase.txt.jinja2", "transactional/provider-complaint-forward-phase.txt.jinja2",
user=user,
email=mailbox_email, email=mailbox_email,
provider=capitalized_name, provider=capitalized_name,
), ),
render( render(
"transactional/provider-complaint-forward-phase.html", "transactional/provider-complaint-forward-phase.html",
user=user,
email=mailbox_email, email=mailbox_email,
provider=capitalized_name, provider=capitalized_name,
), ),

View File

@ -102,6 +102,7 @@ class UnsubscribeHandler:
mailbox.email, alias mailbox.email, alias
): ):
return status.E509 return status.E509
LOG.i(f"User disabled alias {alias} via unsubscribe header")
alias_utils.change_alias_status(alias, enabled=False) alias_utils.change_alias_status(alias, enabled=False)
Session.commit() Session.commit()
enable_alias_url = config.URL + f"/dashboard/?highlight_alias_id={alias.id}" enable_alias_url = config.URL + f"/dashboard/?highlight_alias_id={alias.id}"

View File

@ -0,0 +1,40 @@
from app.events.event_dispatcher import EventDispatcher, Dispatcher
from app.events.generated.event_pb2 import EventContent, AliasCreated, AliasCreatedList
from app.log import LOG
from app.models import User, Alias
def send_alias_creation_events_for_user(
user: User, dispatcher: Dispatcher, chunk_size=50
):
if user.disabled:
LOG.i("User {user} is disabled. Skipping sending events for that user")
return
chunk_size = min(chunk_size, 50)
event_list = []
for alias in (
Alias.yield_per_query(chunk_size)
.filter_by(user_id=user.id)
.order_by(Alias.id.asc())
):
event_list.append(
AliasCreated(
alias_id=alias.id,
alias_email=alias.email,
alias_note=alias.note,
enabled=alias.enabled,
)
)
if len(event_list) >= chunk_size:
EventDispatcher.send_event(
user,
EventContent(alias_create_list=AliasCreatedList(events=event_list)),
dispatcher=dispatcher,
)
event_list = []
if len(event_list) > 0:
EventDispatcher.send_event(
user,
EventContent(alias_create_list=AliasCreatedList(events=event_list)),
dispatcher=dispatcher,
)

View File

@ -137,7 +137,9 @@ class ExportUserDataJob:
msg[headers.SUBJECT] = "Your SimpleLogin data" msg[headers.SUBJECT] = "Your SimpleLogin data"
msg[headers.FROM] = f'"SimpleLogin (noreply)" <{config.NOREPLY}>' msg[headers.FROM] = f'"SimpleLogin (noreply)" <{config.NOREPLY}>'
msg[headers.TO] = to_email msg[headers.TO] = to_email
msg.attach(MIMEText(render("transactional/user-report.html"), "html")) msg.attach(
MIMEText(render("transactional/user-report.html", user=self._user), "html")
)
attachment = MIMEApplication(zipped_contents.read()) attachment = MIMEApplication(zipped_contents.read())
attachment.add_header( attachment.add_header(
"Content-Disposition", "attachment", filename="user_report.zip" "Content-Disposition", "attachment", filename="user_report.zip"

View File

@ -76,7 +76,6 @@ class SendRequest:
file_path = os.path.join(config.SAVE_UNSENT_DIR, file_name) file_path = os.path.join(config.SAVE_UNSENT_DIR, file_name)
self.save_request_to_file(file_path) self.save_request_to_file(file_path)
@staticmethod
def save_request_to_failed_dir(self, prefix: str = "DeliveryRetryFail"): def save_request_to_failed_dir(self, prefix: str = "DeliveryRetryFail"):
file_name = ( file_name = (
f"{prefix}-{int(time.time())}-{uuid.uuid4()}.{SendRequest.SAVE_EXTENSION}" f"{prefix}-{int(time.time())}-{uuid.uuid4()}.{SendRequest.SAVE_EXTENSION}"

View File

@ -263,6 +263,15 @@ class UnsubscribeBehaviourEnum(EnumE):
PreserveOriginal = 2 PreserveOriginal = 2
class AliasDeleteReason(EnumE):
Unspecified = 0
UserHasBeenDeleted = 1
ManualAction = 2
DirectoryDeleted = 3
MailboxDeleted = 4
CustomDomainDeleted = 5
class IntEnumType(sa.types.TypeDecorator): class IntEnumType(sa.types.TypeDecorator):
impl = sa.Integer impl = sa.Integer
@ -330,6 +339,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
FLAG_FREE_DISABLE_CREATE_ALIAS = 1 << 0 FLAG_FREE_DISABLE_CREATE_ALIAS = 1 << 0
FLAG_CREATED_FROM_PARTNER = 1 << 1 FLAG_CREATED_FROM_PARTNER = 1 << 1
FLAG_FREE_OLD_ALIAS_LIMIT = 1 << 2 FLAG_FREE_OLD_ALIAS_LIMIT = 1 << 2
FLAG_CREATED_ALIAS_FROM_PARTNER = 1 << 3
email = sa.Column(sa.String(256), unique=True, nullable=False) email = sa.Column(sa.String(256), unique=True, nullable=False)
@ -666,6 +676,12 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
user: User = cls.get(obj_id) user: User = cls.get(obj_id)
EventDispatcher.send_event(user, EventContent(user_deleted=UserDeleted())) EventDispatcher.send_event(user, EventContent(user_deleted=UserDeleted()))
# Manually delete all aliases for the user that is about to be deleted
from app.alias_utils import delete_alias
for alias in Alias.filter_by(user_id=user.id):
delete_alias(alias, user, AliasDeleteReason.UserHasBeenDeleted)
res = super(User, cls).delete(obj_id) res = super(User, cls).delete(obj_id)
if commit: if commit:
Session.commit() Session.commit()
@ -1153,6 +1169,13 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
return True return True
return not config.DISABLE_CREATE_CONTACTS_FOR_FREE_USERS return not config.DISABLE_CREATE_CONTACTS_FOR_FREE_USERS
def has_used_alias_from_partner(self) -> bool:
return (
self.flags
& (User.FLAG_CREATED_ALIAS_FROM_PARTNER | User.FLAG_CREATED_FROM_PARTNER)
> 0
)
def __repr__(self): def __repr__(self):
return f"<User {self.id} {self.name} {self.email}>" return f"<User {self.id} {self.name} {self.email}>"
@ -1646,6 +1669,12 @@ class Alias(Base, ModelMixin):
) )
EventDispatcher.send_event(user, EventContent(alias_created=event)) EventDispatcher.send_event(user, EventContent(alias_created=event))
if (
new_alias.flags & cls.FLAG_PARTNER_CREATED > 0
and new_alias.user.flags & User.FLAG_CREATED_ALIAS_FROM_PARTNER == 0
):
user.flags = user.flags | User.FLAG_CREATED_ALIAS_FROM_PARTNER
if commit: if commit:
Session.commit() Session.commit()
@ -2247,6 +2276,12 @@ class DeletedAlias(Base, ModelMixin):
__tablename__ = "deleted_alias" __tablename__ = "deleted_alias"
email = sa.Column(sa.String(256), unique=True, nullable=False) email = sa.Column(sa.String(256), unique=True, nullable=False)
reason = sa.Column(
IntEnumType(AliasDeleteReason),
nullable=False,
default=AliasDeleteReason.Unspecified,
server_default=str(AliasDeleteReason.Unspecified.value),
)
@classmethod @classmethod
def create(cls, **kw): def create(cls, **kw):
@ -2434,6 +2469,13 @@ class CustomDomain(Base, ModelMixin):
if obj.is_sl_subdomain: if obj.is_sl_subdomain:
DeletedSubdomain.create(domain=obj.domain) DeletedSubdomain.create(domain=obj.domain)
from app import alias_utils
for alias in Alias.filter_by(custom_domain_id=obj_id):
alias_utils.delete_alias(
alias, obj.user, AliasDeleteReason.CustomDomainDeleted
)
return super(CustomDomain, cls).delete(obj_id) return super(CustomDomain, cls).delete(obj_id)
@property @property
@ -2506,6 +2548,12 @@ class DomainDeletedAlias(Base, ModelMixin):
domain = orm.relationship(CustomDomain) domain = orm.relationship(CustomDomain)
user = orm.relationship(User, foreign_keys=[user_id]) user = orm.relationship(User, foreign_keys=[user_id])
reason = sa.Column(
IntEnumType(AliasDeleteReason),
nullable=False,
default=AliasDeleteReason.Unspecified,
server_default=str(AliasDeleteReason.Unspecified.value),
)
@classmethod @classmethod
def create(cls, **kw): def create(cls, **kw):
@ -2597,7 +2645,7 @@ class Directory(Base, ModelMixin):
for alias in Alias.filter_by(directory_id=obj_id): for alias in Alias.filter_by(directory_id=obj_id):
from app import alias_utils from app import alias_utils
alias_utils.delete_alias(alias, user) alias_utils.delete_alias(alias, user, AliasDeleteReason.DirectoryDeleted)
DeletedDirectory.create(name=obj.name) DeletedDirectory.create(name=obj.name)
cls.filter(cls.id == obj_id).delete() cls.filter(cls.id == obj_id).delete()
@ -2725,7 +2773,7 @@ class Mailbox(Base, ModelMixin):
from app import alias_utils from app import alias_utils
# only put aliases that have mailbox as a single mailbox into trash # only put aliases that have mailbox as a single mailbox into trash
alias_utils.delete_alias(alias, user) alias_utils.delete_alias(alias, user, AliasDeleteReason.MailboxDeleted)
Session.commit() Session.commit()
cls.filter(cls.id == obj_id).delete() cls.filter(cls.id == obj_id).delete()
@ -2971,11 +3019,7 @@ class RecoveryCode(Base, ModelMixin):
@classmethod @classmethod
def find_by_user_code(cls, user: User, code: str): def find_by_user_code(cls, user: User, code: str):
hashed_code = cls._hash_code(code) hashed_code = cls._hash_code(code)
# TODO: Only return hashed codes once there aren't unhashed codes in the db. return cls.get_by(user_id=user.id, code=hashed_code)
found_code = cls.get_by(user_id=user.id, code=hashed_code)
if found_code:
return found_code
return cls.get_by(user_id=user.id, code=code)
@classmethod @classmethod
def empty(cls, user): def empty(cls, user):
@ -3699,7 +3743,10 @@ class SyncEvent(Base, ModelMixin):
AND taken_time IS NULL AND taken_time IS NULL
""" """
args = {"taken_time": arrow.now().datetime, "sync_event_id": self.id} args = {"taken_time": arrow.now().datetime, "sync_event_id": self.id}
res = Session.execute(sql, args) res = Session.execute(sql, args)
Session.commit()
return res.rowcount > 0 return res.rowcount > 0
@classmethod @classmethod

View File

@ -20,7 +20,7 @@ def final():
if form.validate_on_submit(): if form.validate_on_submit():
alias = Alias.get_by(email=form.email.data) alias = Alias.get_by(email=form.email.data)
if alias and alias.user_id == current_user.id: if alias and alias.user_id == current_user.id:
send_test_email_alias(alias.email, current_user.name) send_test_email_alias(current_user, alias.email)
flash("An email is sent to your alias", "success") flash("An email is sent to your alias", "success")
return render_template( return render_template(

View File

@ -27,6 +27,7 @@ def failed_payment(sub: Subscription, subscription_id: str):
"SimpleLogin - your subscription has failed to be renewed", "SimpleLogin - your subscription has failed to be renewed",
render( render(
"transactional/subscription-cancel.txt", "transactional/subscription-cancel.txt",
user=user,
end_date=arrow.arrow.datetime.utcnow(), end_date=arrow.arrow.datetime.utcnow(),
), ),
) )

View File

@ -2,6 +2,7 @@ from newrelic import agent
from typing import Optional from typing import Optional
from app.db import Session from app.db import Session
from app.log import LOG
from app.errors import ProtonPartnerNotSetUp from app.errors import ProtonPartnerNotSetUp
from app.models import Partner, PartnerUser, User from app.models import Partner, PartnerUser, User
@ -30,6 +31,7 @@ def perform_proton_account_unlink(current_user: User):
user_id=current_user.id, partner_id=proton_partner.id user_id=current_user.id, partner_id=proton_partner.id
) )
if partner_user is not None: if partner_user is not None:
LOG.info(f"User {current_user} has unlinked the account from {partner_user}")
PartnerUser.delete(partner_user.id) PartnerUser.delete(partner_user.id)
Session.commit() Session.commit()
agent.record_custom_event("AccountUnlinked", {"partner": proton_partner.name}) agent.record_custom_event("AccountUnlinked", {"partner": proton_partner.name})

View File

@ -87,6 +87,7 @@ class RedisSessionStore(SessionInterface):
httponly = self.get_cookie_httponly(app) httponly = self.get_cookie_httponly(app)
secure = self.get_cookie_secure(app) secure = self.get_cookie_secure(app)
expires = self.get_expiration_time(app, session) expires = self.get_expiration_time(app, session)
samesite = self.get_cookie_samesite(app)
val = pickle.dumps(dict(session)) val = pickle.dumps(dict(session))
ttl = int(app.permanent_session_lifetime.total_seconds()) ttl = int(app.permanent_session_lifetime.total_seconds())
# Only 5 minutes for non-authenticated sessions. # Only 5 minutes for non-authenticated sessions.
@ -109,6 +110,7 @@ class RedisSessionStore(SessionInterface):
domain=domain, domain=domain,
path=path, path=path,
secure=secure, secure=secure,
samesite=samesite,
) )

View File

@ -3,7 +3,7 @@ from requests import RequestException
from app import config from app import config
from app.events.event_dispatcher import EventDispatcher from app.events.event_dispatcher import EventDispatcher
from app.events.generated.event_pb2 import EventContent, UserPlanChange from app.events.generated.event_pb2 import EventContent, UserPlanChanged
from app.log import LOG from app.log import LOG
from app.models import User from app.models import User
@ -34,5 +34,5 @@ def execute_subscription_webhook(user: User):
except RequestException as e: except RequestException as e:
LOG.error(f"Subscription request exception: {e}") LOG.error(f"Subscription request exception: {e}")
event = UserPlanChange(plan_end_time=sl_subscription_end) event = UserPlanChanged(plan_end_time=sl_subscription_end)
EventDispatcher.send_event(user, EventContent(user_plan_change=event)) EventDispatcher.send_event(user, EventContent(user_plan_change=event))

View File

@ -266,11 +266,13 @@ def notify_manual_sub_end():
"Your SimpleLogin subscription will end soon", "Your SimpleLogin subscription will end soon",
render( render(
"transactional/coinbase/reminder-subscription.txt", "transactional/coinbase/reminder-subscription.txt",
user=user,
coinbase_subscription=coinbase_subscription, coinbase_subscription=coinbase_subscription,
extend_subscription_url=extend_subscription_url, extend_subscription_url=extend_subscription_url,
), ),
render( render(
"transactional/coinbase/reminder-subscription.html", "transactional/coinbase/reminder-subscription.html",
user=user,
coinbase_subscription=coinbase_subscription, coinbase_subscription=coinbase_subscription,
extend_subscription_url=extend_subscription_url, extend_subscription_url=extend_subscription_url,
), ),
@ -826,10 +828,12 @@ def check_mailbox_valid_domain():
f"Mailbox {mailbox.email} is disabled", f"Mailbox {mailbox.email} is disabled",
render( render(
"transactional/disable-mailbox-warning.txt.jinja2", "transactional/disable-mailbox-warning.txt.jinja2",
user=mailbox.user,
mailbox=mailbox, mailbox=mailbox,
), ),
render( render(
"transactional/disable-mailbox-warning.html", "transactional/disable-mailbox-warning.html",
user=mailbox.user,
mailbox=mailbox, mailbox=mailbox,
), ),
retries=3, retries=3,
@ -884,6 +888,7 @@ def check_mailbox_valid_pgp_keys():
f"Mailbox {mailbox.email}'s PGP Key is invalid", f"Mailbox {mailbox.email}'s PGP Key is invalid",
render( render(
"transactional/invalid-mailbox-pgp-key.txt.jinja2", "transactional/invalid-mailbox-pgp-key.txt.jinja2",
user=mailbox.user,
mailbox=mailbox, mailbox=mailbox,
), ),
retries=3, retries=3,
@ -924,6 +929,7 @@ def check_single_custom_domain(custom_domain):
f"Please update {custom_domain.domain} DNS on SimpleLogin", f"Please update {custom_domain.domain} DNS on SimpleLogin",
render( render(
"transactional/custom-domain-dns-issue.txt.jinja2", "transactional/custom-domain-dns-issue.txt.jinja2",
user=user,
custom_domain=custom_domain, custom_domain=custom_domain,
domain_dns_url=domain_dns_url, domain_dns_url=domain_dns_url,
), ),

View File

@ -235,13 +235,14 @@ def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Con
contact.mail_from = mail_from contact.mail_from = mail_from
Session.commit() Session.commit()
else: else:
alias_id = alias.id
try: try:
contact_email_for_reply = ( contact_email_for_reply = (
contact_email if is_valid_email(contact_email) else "" contact_email if is_valid_email(contact_email) else ""
) )
contact = Contact.create( contact = Contact.create(
user_id=alias.user_id, user_id=alias.user_id,
alias_id=alias.id, alias_id=alias_id,
website_email=contact_email, website_email=contact_email,
name=contact_name, name=contact_name,
mail_from=mail_from, mail_from=mail_from,
@ -261,9 +262,11 @@ def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Con
Session.commit() Session.commit()
except IntegrityError: except IntegrityError:
LOG.w(f"Contact with email {contact_email} for alias {alias} already exist") # No need to manually rollback, as IntegrityError already rolls back
Session.rollback() LOG.info(
contact = Contact.get_by(alias_id=alias.id, website_email=contact_email) f"Contact with email {contact_email} for alias_id {alias_id} already existed, fetching from DB"
)
contact = Contact.get_by(alias_id=alias_id, website_email=contact_email)
return contact return contact
@ -601,12 +604,14 @@ def handle_email_sent_to_ourself(alias, from_addr: str, msg: Message, user):
f"Email sent to {alias.email} from its own mailbox {from_addr}", f"Email sent to {alias.email} from its own mailbox {from_addr}",
render( render(
"transactional/cycle-email.txt.jinja2", "transactional/cycle-email.txt.jinja2",
user=user,
alias=alias, alias=alias,
from_addr=from_addr, from_addr=from_addr,
refused_email_url=refused_email_url, refused_email_url=refused_email_url,
), ),
render( render(
"transactional/cycle-email.html", "transactional/cycle-email.html",
user=user,
alias=alias, alias=alias,
from_addr=from_addr, from_addr=from_addr,
refused_email_url=refused_email_url, refused_email_url=refused_email_url,
@ -660,6 +665,9 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str
from_header = get_header_unicode(msg[headers.FROM]) from_header = get_header_unicode(msg[headers.FROM])
LOG.d("Create or get contact for from_header:%s", from_header) LOG.d("Create or get contact for from_header:%s", from_header)
contact = get_or_create_contact(from_header, envelope.mail_from, alias) contact = get_or_create_contact(from_header, envelope.mail_from, alias)
alias = (
contact.alias
) # In case the Session was closed in the get_or_create we re-fetch the alias
reply_to_contact = None reply_to_contact = None
if msg[headers.REPLY_TO]: if msg[headers.REPLY_TO]:
@ -728,12 +736,14 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str
f"Your mailbox {mailbox.email} is an alias", f"Your mailbox {mailbox.email} is an alias",
render( render(
"transactional/mailbox-invalid.txt.jinja2", "transactional/mailbox-invalid.txt.jinja2",
user=mailbox.user,
mailbox=mailbox, mailbox=mailbox,
mailbox_url=mailbox_url, mailbox_url=mailbox_url,
alias=alias, alias=alias,
), ),
render( render(
"transactional/mailbox-invalid.html", "transactional/mailbox-invalid.html",
user=mailbox.user,
mailbox=mailbox, mailbox=mailbox,
mailbox_url=mailbox_url, mailbox_url=mailbox_url,
alias=alias, alias=alias,
@ -786,12 +796,14 @@ def forward_email_to_mailbox(
f"Your mailbox {mailbox.email} and alias {alias.email} use the same domain", f"Your mailbox {mailbox.email} and alias {alias.email} use the same domain",
render( render(
"transactional/mailbox-invalid.txt.jinja2", "transactional/mailbox-invalid.txt.jinja2",
user=mailbox.user,
mailbox=mailbox, mailbox=mailbox,
mailbox_url=mailbox_url, mailbox_url=mailbox_url,
alias=alias, alias=alias,
), ),
render( render(
"transactional/mailbox-invalid.html", "transactional/mailbox-invalid.html",
user=mailbox.user,
mailbox=mailbox, mailbox=mailbox,
mailbox_url=mailbox_url, mailbox_url=mailbox_url,
alias=alias, alias=alias,
@ -1276,6 +1288,7 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str):
f"Email sent to {contact.email} contains non reverse-alias addresses", f"Email sent to {contact.email} contains non reverse-alias addresses",
render( render(
"transactional/non-reverse-alias-reply-phase.txt.jinja2", "transactional/non-reverse-alias-reply-phase.txt.jinja2",
user=alias.user,
destination=contact.email, destination=contact.email,
alias=alias.email, alias=alias.email,
subject=msg[headers.SUBJECT], subject=msg[headers.SUBJECT],
@ -1497,6 +1510,7 @@ def handle_unknown_mailbox(
f"Attempt to use your alias {alias.email} from {envelope.mail_from}", f"Attempt to use your alias {alias.email} from {envelope.mail_from}",
render( render(
"transactional/reply-must-use-personal-email.txt", "transactional/reply-must-use-personal-email.txt",
user=user,
alias=alias, alias=alias,
sender=envelope.mail_from, sender=envelope.mail_from,
authorize_address_link=authorize_address_link, authorize_address_link=authorize_address_link,
@ -1504,6 +1518,7 @@ def handle_unknown_mailbox(
), ),
render( render(
"transactional/reply-must-use-personal-email.html", "transactional/reply-must-use-personal-email.html",
user=user,
alias=alias, alias=alias,
sender=envelope.mail_from, sender=envelope.mail_from,
authorize_address_link=authorize_address_link, authorize_address_link=authorize_address_link,
@ -1604,12 +1619,14 @@ def handle_bounce_forward_phase(msg: Message, email_log: EmailLog):
f"Alias {alias.email} has been disabled due to multiple bounces", f"Alias {alias.email} has been disabled due to multiple bounces",
render( render(
"transactional/bounce/automatic-disable-alias.txt", "transactional/bounce/automatic-disable-alias.txt",
user=alias.user,
alias=alias, alias=alias,
refused_email_url=refused_email_url, refused_email_url=refused_email_url,
mailbox_email=mailbox.email, mailbox_email=mailbox.email,
), ),
render( render(
"transactional/bounce/automatic-disable-alias.html", "transactional/bounce/automatic-disable-alias.html",
user=alias.user,
alias=alias, alias=alias,
refused_email_url=refused_email_url, refused_email_url=refused_email_url,
mailbox_email=mailbox.email, mailbox_email=mailbox.email,
@ -1648,6 +1665,7 @@ def handle_bounce_forward_phase(msg: Message, email_log: EmailLog):
f"An email sent to {alias.email} cannot be delivered to your mailbox", f"An email sent to {alias.email} cannot be delivered to your mailbox",
render( render(
"transactional/bounce/bounced-email.txt.jinja2", "transactional/bounce/bounced-email.txt.jinja2",
user=alias.user,
alias=alias, alias=alias,
website_email=contact.website_email, website_email=contact.website_email,
disable_alias_link=disable_alias_link, disable_alias_link=disable_alias_link,
@ -1657,6 +1675,7 @@ def handle_bounce_forward_phase(msg: Message, email_log: EmailLog):
), ),
render( render(
"transactional/bounce/bounced-email.html", "transactional/bounce/bounced-email.html",
user=alias.user,
alias=alias, alias=alias,
website_email=contact.website_email, website_email=contact.website_email,
disable_alias_link=disable_alias_link, disable_alias_link=disable_alias_link,
@ -1749,12 +1768,14 @@ def handle_bounce_reply_phase(envelope, msg: Message, email_log: EmailLog):
f"Email cannot be sent to { contact.email } from your alias { alias.email }", f"Email cannot be sent to { contact.email } from your alias { alias.email }",
render( render(
"transactional/bounce/bounce-email-reply-phase.txt", "transactional/bounce/bounce-email-reply-phase.txt",
user=user,
alias=alias, alias=alias,
contact=contact, contact=contact,
refused_email_url=refused_email_url, refused_email_url=refused_email_url,
), ),
render( render(
"transactional/bounce/bounce-email-reply-phase.html", "transactional/bounce/bounce-email-reply-phase.html",
user=user,
alias=alias, alias=alias,
contact=contact, contact=contact,
refused_email_url=refused_email_url, refused_email_url=refused_email_url,
@ -1817,6 +1838,7 @@ def handle_spam(
f"Email from {alias.email} to {contact.website_email} is detected as spam", f"Email from {alias.email} to {contact.website_email} is detected as spam",
render( render(
"transactional/spam-email-reply-phase.txt", "transactional/spam-email-reply-phase.txt",
user=user,
alias=alias, alias=alias,
website_email=contact.website_email, website_email=contact.website_email,
disable_alias_link=disable_alias_link, disable_alias_link=disable_alias_link,
@ -1824,6 +1846,7 @@ def handle_spam(
), ),
render( render(
"transactional/spam-email-reply-phase.html", "transactional/spam-email-reply-phase.html",
user=user,
alias=alias, alias=alias,
website_email=contact.website_email, website_email=contact.website_email,
disable_alias_link=disable_alias_link, disable_alias_link=disable_alias_link,
@ -1846,6 +1869,7 @@ def handle_spam(
f"Email from {contact.website_email} to {alias.email} is detected as spam", f"Email from {contact.website_email} to {alias.email} is detected as spam",
render( render(
"transactional/spam-email.txt", "transactional/spam-email.txt",
user=user,
alias=alias, alias=alias,
website_email=contact.website_email, website_email=contact.website_email,
disable_alias_link=disable_alias_link, disable_alias_link=disable_alias_link,
@ -1853,6 +1877,7 @@ def handle_spam(
), ),
render( render(
"transactional/spam-email.html", "transactional/spam-email.html",
user=user,
alias=alias, alias=alias,
website_email=contact.website_email, website_email=contact.website_email,
disable_alias_link=disable_alias_link, disable_alias_link=disable_alias_link,
@ -2009,7 +2034,7 @@ def send_no_reply_response(mail_from: str, msg: Message):
ALERT_TO_NOREPLY, ALERT_TO_NOREPLY,
mailbox.user.email, mailbox.user.email,
"Auto: {}".format(msg[headers.SUBJECT] or "No subject"), "Auto: {}".format(msg[headers.SUBJECT] or "No subject"),
render("transactional/noreply.text.jinja2"), render("transactional/noreply.text.jinja2", user=mailbox.user),
) )
@ -2091,6 +2116,7 @@ def handle(envelope: Envelope, msg: Message) -> str:
"SimpleLogin shouldn't be used with another email forwarding system", "SimpleLogin shouldn't be used with another email forwarding system",
render( render(
"transactional/email-sent-from-reverse-alias.txt.jinja2", "transactional/email-sent-from-reverse-alias.txt.jinja2",
user=user,
), ),
) )

View File

@ -3,9 +3,10 @@ from enum import Enum
from sys import argv, exit from sys import argv, exit
from app.config import DB_URI from app.config import DB_URI
from app.log import LOG
from events.runner import Runner from events.runner import Runner
from events.event_source import DeadLetterEventSource, PostgresEventSource from events.event_source import DeadLetterEventSource, PostgresEventSource
from events.event_sink import ConsoleEventSink from events.event_sink import ConsoleEventSink, HttpEventSink
class Mode(Enum): class Mode(Enum):
@ -24,16 +25,20 @@ class Mode(Enum):
def main(mode: Mode, dry_run: bool): def main(mode: Mode, dry_run: bool):
if mode == Mode.DEAD_LETTER: if mode == Mode.DEAD_LETTER:
LOG.i("Using DeadLetterEventSource")
source = DeadLetterEventSource() source = DeadLetterEventSource()
elif mode == Mode.LISTENER: elif mode == Mode.LISTENER:
LOG.i("Using PostgresEventSource")
source = PostgresEventSource(DB_URI) source = PostgresEventSource(DB_URI)
else: else:
raise ValueError(f"Invalid mode: {mode}") raise ValueError(f"Invalid mode: {mode}")
if dry_run: if dry_run:
LOG.i("Starting with ConsoleEventSink")
sink = ConsoleEventSink() sink = ConsoleEventSink()
else: else:
sink = ConsoleEventSink() LOG.i("Starting with HttpEventSink")
sink = HttpEventSink()
runner = Runner(source=source, sink=sink) runner = Runner(source=source, sink=sink)
runner.run() runner.run()

View File

@ -1,19 +1,42 @@
import requests
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from app.config import EVENT_WEBHOOK, EVENT_WEBHOOK_SKIP_VERIFY_SSL
from app.log import LOG from app.log import LOG
from app.models import SyncEvent from app.models import SyncEvent
class EventSink(ABC): class EventSink(ABC):
@abstractmethod @abstractmethod
def process(self, event: SyncEvent): def process(self, event: SyncEvent) -> bool:
pass pass
class HttpEventSink(EventSink): class HttpEventSink(EventSink):
def process(self, event: SyncEvent): def process(self, event: SyncEvent) -> bool:
pass if not EVENT_WEBHOOK:
LOG.warning("Skipping sending event because there is no webhook configured")
return False
LOG.info(f"Sending event {event.id} to {EVENT_WEBHOOK}")
res = requests.post(
url=EVENT_WEBHOOK,
data=event.content,
headers={"Content-Type": "application/x-protobuf"},
verify=not EVENT_WEBHOOK_SKIP_VERIFY_SSL,
)
if res.status_code != 200:
LOG.warning(
f"Failed to send event to webhook: {res.status_code} {res.text}"
)
return False
else:
LOG.info(f"Event {event.id} sent successfully to webhook")
return True
class ConsoleEventSink(EventSink): class ConsoleEventSink(EventSink):
def process(self, event: SyncEvent): def process(self, event: SyncEvent) -> bool:
LOG.info(f"Handling event {event.id}") LOG.info(f"Handling event {event.id}")
return True

View File

@ -13,6 +13,8 @@ from typing import Callable, NoReturn
_DEAD_LETTER_THRESHOLD_MINUTES = 10 _DEAD_LETTER_THRESHOLD_MINUTES = 10
_DEAD_LETTER_INTERVAL_SECONDS = 30 _DEAD_LETTER_INTERVAL_SECONDS = 30
_POSTGRES_RECONNECT_INTERVAL_SECONDS = 5
class EventSource(ABC): class EventSource(ABC):
@abstractmethod @abstractmethod
@ -22,9 +24,19 @@ class EventSource(ABC):
class PostgresEventSource(EventSource): class PostgresEventSource(EventSource):
def __init__(self, connection_string: str): def __init__(self, connection_string: str):
self.__connection = psycopg2.connect(connection_string) self.__connection_string = connection_string
self.__connect()
def run(self, on_event: Callable[[SyncEvent], NoReturn]): def run(self, on_event: Callable[[SyncEvent], NoReturn]):
while True:
try:
self.__listen(on_event)
except Exception as e:
LOG.warn(f"Error listening to events: {e}")
sleep(_POSTGRES_RECONNECT_INTERVAL_SECONDS)
self.__connect()
def __listen(self, on_event: Callable[[SyncEvent], NoReturn]):
self.__connection.set_isolation_level( self.__connection.set_isolation_level(
psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT
) )
@ -44,12 +56,24 @@ class PostgresEventSource(EventSource):
webhook_id = int(notify.payload) webhook_id = int(notify.payload)
event = SyncEvent.get_by(id=webhook_id) event = SyncEvent.get_by(id=webhook_id)
if event is not None: if event is not None:
on_event(event) if event.mark_as_taken():
on_event(event)
else:
LOG.info(
f"Event {event.id} was handled by another runner"
)
else: else:
LOG.info(f"Could not find event with id={notify.payload}") LOG.info(f"Could not find event with id={notify.payload}")
except Exception as e: except Exception as e:
LOG.warn(f"Error getting event: {e}") LOG.warn(f"Error getting event: {e}")
def __connect(self):
self.__connection = psycopg2.connect(self.__connection_string)
from app.db import Session
Session.close()
class DeadLetterEventSource(EventSource): class DeadLetterEventSource(EventSource):
@newrelic.agent.background_task() @newrelic.agent.background_task()
@ -73,3 +97,4 @@ class DeadLetterEventSource(EventSource):
sleep(_DEAD_LETTER_INTERVAL_SECONDS) sleep(_DEAD_LETTER_INTERVAL_SECONDS)
except Exception as e: except Exception as e:
LOG.warn(f"Error getting dead letter event: {e}") LOG.warn(f"Error getting dead letter event: {e}")
sleep(_DEAD_LETTER_INTERVAL_SECONDS)

View File

@ -18,11 +18,10 @@ class Runner:
@newrelic.agent.background_task() @newrelic.agent.background_task()
def __on_event(self, event: SyncEvent): def __on_event(self, event: SyncEvent):
try: try:
can_process = event.mark_as_taken() event_created_at = event.created_at
if can_process: start_time = arrow.now()
event_created_at = event.created_at success = self.__sink.process(event)
start_time = arrow.now() if success:
self.__sink.process(event)
event_id = event.id event_id = event.id
SyncEvent.delete(event.id, commit=True) SyncEvent.delete(event.id, commit=True)
LOG.info(f"Marked {event_id} as done") LOG.info(f"Marked {event_id} as done")
@ -38,8 +37,6 @@ class Runner:
"Custom/sync_event_elapsed_time", "Custom/sync_event_elapsed_time",
time_between_taken_and_created.total_seconds(), time_between_taken_and_created.total_seconds(),
) )
else:
LOG.info(f"{event.id} was handled by another runner")
except Exception as e: except Exception as e:
LOG.warn(f"Exception processing event [id={event.id}]: {e}") LOG.warn(f"Exception processing event [id={event.id}]: {e}")
newrelic.agent.record_custom_metric("Custom/sync_event_failed", 1) newrelic.agent.record_custom_metric("Custom/sync_event_failed", 1)

View File

@ -15,6 +15,7 @@ from app.email_utils import (
render, render,
) )
from app.import_utils import handle_batch_import from app.import_utils import handle_batch_import
from app.jobs.event_jobs import send_alias_creation_events_for_user
from app.jobs.export_user_data_job import ExportUserDataJob from app.jobs.export_user_data_job import ExportUserDataJob
from app.log import LOG from app.log import LOG
from app.models import User, Job, BatchImport, Mailbox, CustomDomain, JobState from app.models import User, Job, BatchImport, Mailbox, CustomDomain, JobState
@ -197,13 +198,18 @@ def process_job(job: Job):
onboarding_mailbox(user) onboarding_mailbox(user)
elif job.name == config.JOB_ONBOARDING_4: elif job.name == config.JOB_ONBOARDING_4:
user_id = job.payload.get("user_id") user_id = job.payload.get("user_id")
user = User.get(user_id) user: User = User.get(user_id)
# user might delete their account in the meantime # user might delete their account in the meantime
# or disable the notification # or disable the notification
if user and user.notification and user.activated: if user and user.notification and user.activated:
LOG.d("send onboarding pgp email to user %s", user) # if user only has 1 mailbox which is Proton then do not send PGP onboarding email
onboarding_pgp(user) mailboxes = user.mailboxes()
if len(mailboxes) == 1 and mailboxes[0].is_proton():
LOG.d("Do not send onboarding PGP email to Proton mailbox")
else:
LOG.d("send onboarding pgp email to user %s", user)
onboarding_pgp(user)
elif job.name == config.JOB_BATCH_IMPORT: elif job.name == config.JOB_BATCH_IMPORT:
batch_import_id = job.payload.get("batch_import_id") batch_import_id = job.payload.get("batch_import_id")
@ -219,16 +225,15 @@ def process_job(job: Job):
user_email = user.email user_email = user.email
LOG.w("Delete user %s", user) LOG.w("Delete user %s", user)
User.delete(user.id)
Session.commit()
send_email( send_email(
user_email, user_email,
"Your SimpleLogin account has been deleted", "Your SimpleLogin account has been deleted",
render("transactional/account-delete.txt"), render("transactional/account-delete.txt", user=user),
render("transactional/account-delete.html"), render("transactional/account-delete.html", user=user),
retries=3, retries=3,
) )
User.delete(user.id)
Session.commit()
elif job.name == config.JOB_DELETE_MAILBOX: elif job.name == config.JOB_DELETE_MAILBOX:
delete_mailbox_job(job) delete_mailbox_job(job)
@ -264,8 +269,14 @@ SimpleLogin team.
user_id = job.payload.get("user_id") user_id = job.payload.get("user_id")
user = User.get(user_id) user = User.get(user_id)
if user and user.activated: if user and user.activated:
LOG.d("send proton welcome email to user %s", user) LOG.d("Send proton welcome email to user %s", user)
welcome_proton(user) welcome_proton(user)
elif job.name == config.JOB_SEND_ALIAS_CREATION_EVENTS:
user_id = job.payload.get("user_id")
user = User.get(user_id)
if user and user.activated:
LOG.d(f"Sending alias creation events for {user}")
send_alias_creation_events_for_user(user)
else: else:
LOG.e("Unknown job name %s", job.name) LOG.e("Unknown job name %s", job.name)

View File

@ -0,0 +1,31 @@
"""empty message
Revision ID: d608b8e48082
Revises: 06a9a7133445
Create Date: 2024-07-05 16:56:04.220173
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd608b8e48082'
down_revision = '06a9a7133445'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('deleted_alias', sa.Column('reason', sa.Integer(), default=0, server_default='0', nullable=False))
op.add_column('domain_deleted_alias', sa.Column('reason', sa.Integer(), default=0, server_default='0', nullable=False))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('domain_deleted_alias', 'reason')
op.drop_column('deleted_alias', 'reason')
# ### end Alembic commands ###

View File

@ -4,6 +4,7 @@ import subprocess
from time import sleep from time import sleep
from typing import List, Dict from typing import List, Dict
import arrow
import newrelic.agent import newrelic.agent
from app.db import Session from app.db import Session
@ -93,11 +94,44 @@ def log_nb_db_connection():
newrelic.agent.record_custom_metric("Custom/nb_db_connections", nb_connection) newrelic.agent.record_custom_metric("Custom/nb_db_connections", nb_connection)
@newrelic.agent.background_task()
def log_pending_to_process_events():
r = Session.execute("select count(*) from sync_event WHERE taken_time IS NULL;")
events_pending = list(r)[0][0]
LOG.d("number of events pending to process %s", events_pending)
newrelic.agent.record_custom_metric(
"Custom/sync_events_pending_to_process", events_pending
)
@newrelic.agent.background_task()
def log_events_pending_dead_letter():
since = arrow.now().shift(minutes=-10).datetime
r = Session.execute(
"""
SELECT COUNT(*)
FROM sync_event
WHERE (taken_time IS NOT NULL AND taken_time < :since)
OR (taken_time IS NULL AND created_at < :since)
""",
{"since": since},
)
events_pending = list(r)[0][0]
LOG.d("number of events pending dead letter %s", events_pending)
newrelic.agent.record_custom_metric(
"Custom/sync_events_pending_dead_letter", events_pending
)
if __name__ == "__main__": if __name__ == "__main__":
exporter = MetricExporter(get_newrelic_license()) exporter = MetricExporter(get_newrelic_license())
while True: while True:
log_postfix_metrics() log_postfix_metrics()
log_nb_db_connection() log_nb_db_connection()
log_pending_to_process_events()
log_events_pending_dead_letter()
Session.close() Session.close()
exporter.run() exporter.run()

View File

@ -0,0 +1,55 @@
#!/usr/bin/env python3
import argparse
import time
from sqlalchemy import func
from app.models import Alias, User
from app.db import Session
parser = argparse.ArgumentParser(
prog="Backfill alias", description="Backfill user flags for partner alias created"
)
parser.add_argument(
"-s", "--start_user_id", default=0, type=int, help="Initial user_id"
)
parser.add_argument("-e", "--end_user_id", default=0, type=int, help="Last user_id")
args = parser.parse_args()
user_id_start = args.start_user_id
max_user_id = args.end_user_id
if max_user_id == 0:
max_user_id = Session.query(func.max(User.id)).scalar()
print(f"Checking user {user_id_start} to {max_user_id}")
step = 1000
el_query = "SELECT user_id, count(id) from alias where user_id>=:start AND user_id < :end AND flags & :alias_flag > 0 GROUP BY user_id"
user_update_query = "UPDATE users set flags = flags | :user_flag where id = :user_id"
updated = 0
start_time = time.time()
for batch_start in range(user_id_start, max_user_id, step):
rows = Session.execute(
el_query,
{
"start": batch_start,
"end": batch_start + step,
"alias_flag": Alias.FLAG_PARTNER_CREATED,
},
)
for row in rows:
if row[1] > 0:
Session.execute(
user_update_query,
{"user_id": row[0], "user_flag": User.FLAG_CREATED_ALIAS_FROM_PARTNER},
)
Session.commit()
updated += 1
elapsed = time.time() - start_time
time_per_alias = elapsed / (updated + 1)
last_batch_id = batch_start + step
remaining = max_user_id - last_batch_id
time_remaining = (max_user_id - last_batch_id) * time_per_alias
hours_remaining = time_remaining / 3600.0
print(
f"\rUser {batch_start}/{max_user_id} {updated} {hours_remaining:.2f}hrs remaining"
)
print("")

28
app/poetry.lock generated
View File

@ -2150,24 +2150,22 @@ wcwidth = "*"
[[package]] [[package]]
name = "protobuf" name = "protobuf"
version = "4.24.3" version = "5.27.1"
description = "" description = ""
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.8"
files = [ files = [
{file = "protobuf-4.24.3-cp310-abi3-win32.whl", hash = "sha256:20651f11b6adc70c0f29efbe8f4a94a74caf61b6200472a9aea6e19898f9fcf4"}, {file = "protobuf-5.27.1-cp310-abi3-win32.whl", hash = "sha256:3adc15ec0ff35c5b2d0992f9345b04a540c1e73bfee3ff1643db43cc1d734333"},
{file = "protobuf-4.24.3-cp310-abi3-win_amd64.whl", hash = "sha256:3d42e9e4796a811478c783ef63dc85b5a104b44aaaca85d4864d5b886e4b05e3"}, {file = "protobuf-5.27.1-cp310-abi3-win_amd64.whl", hash = "sha256:25236b69ab4ce1bec413fd4b68a15ef8141794427e0b4dc173e9d5d9dffc3bcd"},
{file = "protobuf-4.24.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:6e514e8af0045be2b56e56ae1bb14f43ce7ffa0f68b1c793670ccbe2c4fc7d2b"}, {file = "protobuf-5.27.1-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4e38fc29d7df32e01a41cf118b5a968b1efd46b9c41ff515234e794011c78b17"},
{file = "protobuf-4.24.3-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:ba53c2f04798a326774f0e53b9c759eaef4f6a568ea7072ec6629851c8435959"}, {file = "protobuf-5.27.1-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:917ed03c3eb8a2d51c3496359f5b53b4e4b7e40edfbdd3d3f34336e0eef6825a"},
{file = "protobuf-4.24.3-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:f6ccbcf027761a2978c1406070c3788f6de4a4b2cc20800cc03d52df716ad675"}, {file = "protobuf-5.27.1-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:ee52874a9e69a30271649be88ecbe69d374232e8fd0b4e4b0aaaa87f429f1631"},
{file = "protobuf-4.24.3-cp37-cp37m-win32.whl", hash = "sha256:1b182c7181a2891e8f7f3a1b5242e4ec54d1f42582485a896e4de81aa17540c2"}, {file = "protobuf-5.27.1-cp38-cp38-win32.whl", hash = "sha256:7a97b9c5aed86b9ca289eb5148df6c208ab5bb6906930590961e08f097258107"},
{file = "protobuf-4.24.3-cp37-cp37m-win_amd64.whl", hash = "sha256:b0271a701e6782880d65a308ba42bc43874dabd1a0a0f41f72d2dac3b57f8e76"}, {file = "protobuf-5.27.1-cp38-cp38-win_amd64.whl", hash = "sha256:f6abd0f69968792da7460d3c2cfa7d94fd74e1c21df321eb6345b963f9ec3d8d"},
{file = "protobuf-4.24.3-cp38-cp38-win32.whl", hash = "sha256:e29d79c913f17a60cf17c626f1041e5288e9885c8579832580209de8b75f2a52"}, {file = "protobuf-5.27.1-cp39-cp39-win32.whl", hash = "sha256:dfddb7537f789002cc4eb00752c92e67885badcc7005566f2c5de9d969d3282d"},
{file = "protobuf-4.24.3-cp38-cp38-win_amd64.whl", hash = "sha256:067f750169bc644da2e1ef18c785e85071b7c296f14ac53e0900e605da588719"}, {file = "protobuf-5.27.1-cp39-cp39-win_amd64.whl", hash = "sha256:39309898b912ca6febb0084ea912e976482834f401be35840a008da12d189340"},
{file = "protobuf-4.24.3-cp39-cp39-win32.whl", hash = "sha256:2da777d34b4f4f7613cdf85c70eb9a90b1fbef9d36ae4a0ccfe014b0b07906f1"}, {file = "protobuf-5.27.1-py3-none-any.whl", hash = "sha256:4ac7249a1530a2ed50e24201d6630125ced04b30619262f06224616e0030b6cf"},
{file = "protobuf-4.24.3-cp39-cp39-win_amd64.whl", hash = "sha256:f631bb982c5478e0c1c70eab383af74a84be66945ebf5dd6b06fc90079668d0b"}, {file = "protobuf-5.27.1.tar.gz", hash = "sha256:df5e5b8e39b7d1c25b186ffdf9f44f40f810bbcc9d2b71d9d3156fee5a9adf15"},
{file = "protobuf-4.24.3-py3-none-any.whl", hash = "sha256:f6f8dc65625dadaad0c8545319c2e2f0424fede988368893ca3844261342c11a"},
{file = "protobuf-4.24.3.tar.gz", hash = "sha256:12e9ad2ec079b833176d2921be2cb24281fa591f0b119b208b788adc48c2561d"},
] ]
[[package]] [[package]]

View File

@ -2,7 +2,7 @@ syntax = "proto3";
package simplelogin_events; package simplelogin_events;
message UserPlanChange { message UserPlanChanged {
uint32 plan_end_time = 1; uint32 plan_end_time = 1;
} }
@ -16,7 +16,7 @@ message AliasCreated {
bool enabled = 4; bool enabled = 4;
} }
message AliasStatusChange { message AliasStatusChanged {
uint32 alias_id = 1; uint32 alias_id = 1;
string alias_email = 2; string alias_email = 2;
bool enabled = 3; bool enabled = 3;
@ -27,13 +27,18 @@ message AliasDeleted {
string alias_email = 2; string alias_email = 2;
} }
message AliasCreatedList {
repeated AliasCreated events = 1;
}
message EventContent { message EventContent {
oneof content { oneof content {
UserPlanChange user_plan_change = 1; UserPlanChanged user_plan_change = 1;
UserDeleted user_deleted = 2; UserDeleted user_deleted = 2;
AliasCreated alias_created = 3; AliasCreated alias_created = 3;
AliasStatusChange alias_status_change = 4; AliasStatusChanged alias_status_change = 4;
AliasDeleted alias_deleted = 5; AliasDeleted alias_deleted = 5;
AliasCreatedList alias_create_list = 6;
} }
} }
@ -42,4 +47,4 @@ message Event {
string external_user_id = 2; string external_user_id = 2;
uint32 partner_id = 3; uint32 partner_id = 3;
EventContent content = 4; EventContent content = 4;
} }

View File

@ -29,7 +29,7 @@ from sentry_sdk.integrations.flask import FlaskIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
from app import paddle_utils, config, paddle_callback from app import paddle_utils, config, paddle_callback, constants
from app.admin_model import ( from app.admin_model import (
SLAdminIndexView, SLAdminIndexView,
UserAdmin, UserAdmin,
@ -430,6 +430,7 @@ def jinja2_filter(app):
PAGE_LIMIT=PAGE_LIMIT, PAGE_LIMIT=PAGE_LIMIT,
ZENDESK_ENABLED=ZENDESK_ENABLED, ZENDESK_ENABLED=ZENDESK_ENABLED,
MAX_NB_EMAIL_FREE_PLAN=MAX_NB_EMAIL_FREE_PLAN, MAX_NB_EMAIL_FREE_PLAN=MAX_NB_EMAIL_FREE_PLAN,
HEADER_ALLOW_API_COOKIES=constants.HEADER_ALLOW_API_COOKIES,
) )
@ -542,6 +543,7 @@ def setup_paddle_callback(app: Flask):
"SimpleLogin - your subscription is canceled", "SimpleLogin - your subscription is canceled",
render( render(
"transactional/subscription-cancel.txt", "transactional/subscription-cancel.txt",
user=user,
end_date=request.form.get("cancellation_effective_date"), end_date=request.form.get("cancellation_effective_date"),
), ),
) )
@ -701,7 +703,12 @@ def setup_coinbase_commerce(app):
def handle_coinbase_event(event) -> bool: def handle_coinbase_event(event) -> bool:
user_id = int(event["data"]["metadata"]["user_id"]) server_user_id = event["data"]["metadata"]["user_id"]
try:
user_id = int(server_user_id)
except ValueError:
user_id = int(float(server_user_id))
code = event["data"]["code"] code = event["data"]["code"]
user = User.get(user_id) user = User.get(user_id)
if not user: if not user:
@ -722,10 +729,12 @@ def handle_coinbase_event(event) -> bool:
"Your SimpleLogin account has been upgraded", "Your SimpleLogin account has been upgraded",
render( render(
"transactional/coinbase/new-subscription.txt", "transactional/coinbase/new-subscription.txt",
user=user,
coinbase_subscription=coinbase_subscription, coinbase_subscription=coinbase_subscription,
), ),
render( render(
"transactional/coinbase/new-subscription.html", "transactional/coinbase/new-subscription.html",
user=user,
coinbase_subscription=coinbase_subscription, coinbase_subscription=coinbase_subscription,
), ),
) )
@ -746,10 +755,12 @@ def handle_coinbase_event(event) -> bool:
"Your SimpleLogin account has been extended", "Your SimpleLogin account has been extended",
render( render(
"transactional/coinbase/extend-subscription.txt", "transactional/coinbase/extend-subscription.txt",
user=user,
coinbase_subscription=coinbase_subscription, coinbase_subscription=coinbase_subscription,
), ),
render( render(
"transactional/coinbase/extend-subscription.html", "transactional/coinbase/extend-subscription.html",
user=user,
coinbase_subscription=coinbase_subscription, coinbase_subscription=coinbase_subscription,
), ),
) )

BIN
app/static/logo-proton.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -264,6 +264,7 @@
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
'{{HEADER_ALLOW_API_COOKIES}}': 'allow'
} }
}); });

View File

@ -80,7 +80,10 @@
}, },
methods: { methods: {
generateRandomAlias: async function (event) { generateRandomAlias: async function (event) {
let result = await fetch('/api/alias/random/new', {method: 'POST'}); let result = await fetch('/api/alias/random/new', {method: 'POST',
headers: {
'{{HEADER_ALLOW_API_COOKIES}}': 'allow'
}});
if (result.ok) { if (result.ok) {
let data = await result.json(); let data = await result.json();
this.ticket_email = data.alias; this.ticket_email = data.alias;

View File

@ -1,623 +1,8 @@
{% from "_emailhelpers.html" import render_text, text, render_button, raw_url, grey_section, section %} {% if USE_PARTNER_TEMPLATE %}
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="x-apple-disable-message-reformatting" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<style type="text/css" rel="stylesheet" media="all">
/* Base ------------------------------ */
body {
width: 100% !important;
height: 100%;
margin: 0;
-webkit-text-size-adjust: none;
line-height: 1.6;
}
img { {% extends "base_partner.html" %}
max-width: 100%;
}
a { {% else %}
color: #3869D4; {% extends "base_sl.html" %}
}
a img { {% endif %}
border: none;
}
td {
word-break: break-word;
}
.preheader {
display: none !important;
visibility: hidden;
mso-hide: all;
font-size: 1px;
line-height: 1px;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
}
/* Type ------------------------------ */
body,
td,
th {
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
}
h1 {
margin-top: 0;
color: #333333;
font-size: 22px;
font-weight: bold;
text-align: left;
}
h2 {
margin-top: 0;
color: #333333;
font-size: 16px;
font-weight: bold;
text-align: left;
}
h3 {
margin-top: 0;
color: #333333;
font-size: 14px;
font-weight: bold;
text-align: left;
}
td,
th {
font-size: 16px;
}
p,
ul,
ol,
blockquote {
margin: .4em 0 1.1875em;
font-size: 16px;
line-height: 1.625;
}
p.sub {
font-size: 13px;
}
/* Utilities ------------------------------ */
.align-right {
text-align: right;
}
.align-left {
text-align: left;
}
.align-center {
text-align: center;
}
/* Buttons ------------------------------ */
.button {
background-color: #3869D4;
border-top: 10px solid #3869D4;
border-right: 18px solid #3869D4;
border-bottom: 10px solid #3869D4;
border-left: 18px solid #3869D4;
display: inline-block;
color: #FFF;
text-decoration: none;
border-radius: 3px;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
-webkit-text-size-adjust: none;
box-sizing: border-box;
}
.button--green {
background-color: #22BC66;
border-top: 10px solid #22BC66;
border-right: 18px solid #22BC66;
border-bottom: 10px solid #22BC66;
border-left: 18px solid #22BC66;
}
.button--red {
background-color: #FF6136;
border-top: 10px solid #FF6136;
border-right: 18px solid #FF6136;
border-bottom: 10px solid #FF6136;
border-left: 18px solid #FF6136;
}
@media only screen and (max-width: 500px) {
.button {
width: 100% !important;
text-align: center !important;
}
}
/* Attribute list ------------------------------ */
.attributes {
margin: 0 0 21px;
}
.attributes_content {
background-color: #F4F4F7;
padding: 16px;
}
.attributes_item {
padding: 0;
}
/* Related Items ------------------------------ */
.related {
width: 100%;
margin: 0;
padding: 25px 0 0 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.related_item {
padding: 10px 0;
color: #CBCCCF;
font-size: 15px;
line-height: 18px;
}
.related_item-title {
display: block;
margin: .5em 0 0;
}
.related_item-thumb {
display: block;
padding-bottom: 10px;
}
.related_heading {
border-top: 1px solid #CBCCCF;
text-align: center;
padding: 25px 0 10px;
}
/* Discount Code ------------------------------ */
.discount {
width: 100%;
margin: 0;
padding: 24px;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #F4F4F7;
border: 2px dashed #CBCCCF;
}
.discount_heading {
text-align: center;
}
.discount_body {
text-align: center;
font-size: 15px;
}
/* Social Icons ------------------------------ */
.social {
width: auto;
}
.social td {
padding: 0;
width: auto;
}
.social_icon {
height: 20px;
margin: 0 8px 10px 8px;
padding: 0;
}
/* Data table ------------------------------ */
.purchase {
width: 100%;
margin: 0;
padding: 35px 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.purchase_content {
width: 100%;
margin: 0;
padding: 25px 0 0 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.purchase_item {
padding: 10px 0;
color: #51545E;
font-size: 15px;
line-height: 18px;
}
.purchase_heading {
padding-bottom: 8px;
border-bottom: 1px solid #EAEAEC;
}
.purchase_heading p {
margin: 0;
color: #85878E;
font-size: 12px;
}
.purchase_footer {
padding-top: 15px;
border-top: 1px solid #EAEAEC;
}
.purchase_total {
margin: 0;
text-align: right;
font-weight: bold;
color: #333333;
}
.purchase_total--label {
padding: 0 15px 0 0;
}
body {
background-color: #F2F4F6;
color: #51545E;
}
p {
color: #51545E;
}
.email-wrapper {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #F2F4F6;
}
.email-content {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
/* Masthead ----------------------- */
.email-masthead {
padding: 25px 0;
text-align: center;
}
.email-masthead_logo {
width: 94px;
}
.email-masthead_name {
font-size: 16px;
font-weight: bold;
color: #A8AAAF;
text-decoration: none;
text-shadow: 0 1px 0 white;
}
/* Body ------------------------------ */
.email-body {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.email-body_inner {
width: 750px;
margin: 0 auto;
padding: 0;
-premailer-width: 750px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #FFFFFF;
}
.email-footer {
width: 750px;
margin: 0 auto;
padding: 0;
-premailer-width: 750px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
}
.email-footer p {
color: #A8AAAF;
}
.body-action {
width: 100%;
margin: 30px auto;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
}
.body-sub {
margin-top: 25px;
padding-top: 25px;
border-top: 1px solid #EAEAEC;
}
.content-cell {
padding: 30px;
}
/*Media Queries ------------------------------ */
@media only screen and (max-width: 600px) {
.email-body_inner,
.email-footer {
width: 100% !important;
}
}
@media (prefers-color-scheme: dark) {
body,
.email-body,
.email-body_inner,
.email-content,
.email-wrapper,
.email-masthead,
.email-footer {
background-color: #333333 !important;
color: #FFF !important;
}
p,
ul,
ol,
blockquote,
h1,
h2,
h3 {
color: #FFF !important;
}
.attributes_content,
.discount {
background-color: #222 !important;
}
.email-masthead_name {
text-shadow: none !important;
}
}
</style>
<!--[if mso]>
<style type="text/css">
.f-fallback {
font-family: Arial, sans-serif;
}
</style>
<![endif]-->
<style type="text/css" rel="stylesheet" media="all">
body {
width: 100% !important;
height: 100%;
margin: 0;
-webkit-text-size-adjust: none;
}
body {
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
}
body {
background-color: #F2F4F6;
color: #51545E;
}
</style>
</head>
<body style="width: 100% !important;
height: 100%;
-webkit-text-size-adjust: none;
font-family: Helvetica, Arial, sans-serif;
background-color: #F2F4F6;
color: #51545E;
margin: 0;"
bgcolor="#F2F4F6">
<span class="preheader"
style="display: none !important;
visibility: hidden;
mso-hide: all;
font-size: 1px;
line-height: 1px;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;">{{ pre_header }}</span>
<table class="email-wrapper"
width="100%"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 100%;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #F2F4F6;
margin: 0;
padding: 0;"
bgcolor="#F2F4F6">
<tr>
<td align="center"
style="word-break: break-word;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;">
<table class="email-content"
width="100%"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 100%;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
margin: 0;
padding: 0;">
<tr>
<td class="email-masthead"
style="word-break: break-word;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;
text-align: center;
padding: 25px 0;"
align="center">
<a href="{{ LANDING_PAGE_URL }}"
class="f-fallback email-masthead_name"
style="color: #A8AAAF;
font-size: 16px;
font-weight: bold;
text-decoration: none;
text-shadow: 0 1px 0 white;">
{% block logo %}<img src="{{ URL }}/static/logo.png" style="width: 150px; margin: auto">{% endblock %}
</a>
</td>
</tr>
<!-- Email Body -->
<tr>
<td class="email-body"
width="750"
cellpadding="0"
cellspacing="0"
style="word-break: break-word;
margin: 0;
padding: 0;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;
width: 100%;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;">
<table class="email-body_inner"
align="center"
width="750"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 750px;
-premailer-width: 750px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #FFFFFF;
margin: 0 auto;
padding: 0;"
bgcolor="#FFFFFF">
<!-- Body content -->
<tr>
<td class="content-cell"
style="word-break: break-word;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;
padding: 30px;">
<div class="f-fallback">
{% block greeting %}{% endblock %}
{% block content %}{% endblock %}
<!-- Sub copy -->
{% block sub_copy %}{% endblock %}
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="word-break: break-word;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;">
<table class="email-footer"
align="center"
width="750"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 750px;
-premailer-width: 750px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
margin: 0 auto;
padding: 0;">
<tr>
<td class="content-cell"
align="center"
style="word-break: break-word;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;
padding: 30px;">
<p class="f-fallback sub align-center"
style="font-size: 13px;
line-height: 1.625;
text-align: center;
color: #A8AAAF;
margin: .4em 0 1.1875em;"
align="center">
© {{ YEAR }} SimpleLogin - a Proton product. All rights reserved.
<br />
{% block footer %}{% endblock %}
</p>
{% if unsubscribe_oneclick is defined %}
<p class="f-fallback sub align-center"
style="font-size: 13px;
line-height: 1.625;
text-align: center;
margin: .4em 0 1.1875em;">
<a href="{{ unsubscribe_oneclick }}">Unsubscribe from our newsletter</a>
</p>
{% endif %}
<p class="f-fallback sub align-center"
style="font-size: 13px;
line-height: 1.625;
text-align: center;
color: #A8AAAF;
margin: .4em 0 1.1875em;"
align="center">
<a href="https://app.simplelogin.io/dashboard/support">Do you have a question?</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,646 @@
{% from "_emailhelpers.html" import render_text, text, render_button, raw_url, grey_section, section %}
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<!-- NAME: 1 COLUMN -->
<!--[if gte mso 15]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<meta charset="UTF-8">
<meta name="x-apple-disable-message-reformatting">
<meta name="format-detection"
content="telephone=no, date=no, address=no, email=no, url=no">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="author" content="Proton">
<style type="text/css">
p {
margin: 12px 0;
padding: 0;
}
table {
border-collapse: collapse;
}
h1,
h2,
h3,
h4,
h5,
h6 {
display: block;
margin: 0;
padding: 0;
}
img,
a img {
border: 0;
height: auto;
outline: none;
text-decoration: none;
}
body,
#bodyTable,
#bodyCell {
height: 100%;
margin: 0;
padding: 0;
width: 100%;
}
.mcnPreviewText {
display: none !important;
}
#outlook a {
padding: 0;
}
img {
-ms-interpolation-mode: bicubic;
}
table {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
.ReadMsgBody {
width: 100%;
}
.ExternalClass {
width: 100%;
}
p,
a,
li,
td,
blockquote {
mso-line-height-rule: exactly;
}
a[href^=tel],
a[href^=sms] {
color: inherit;
cursor: default;
text-decoration: none;
}
p,
a,
li,
td,
body,
table,
blockquote {
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass td,
.ExternalClass div,
.ExternalClass span,
.ExternalClass font {
line-height: 100%;
}
.no-link a,
a[x-apple-data-detectors],
a[href^="x-apple-data-detectors:"] {
color: inherit !important;
text-decoration: none !important;
font-size: inherit !important;
font-family: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
}
#bodyCell {
padding: 10px;
}
.templateContainer {
max-width: 600px !important;
}
a.mcnButton {
display: block;
}
.mcnImage,
.mcnRetinaImage {
vertical-align: bottom;
}
.mcnTextContent {
word-break: break-word;
}
.mcnTextContent img {
height: auto !important;
}
.mcnDividerBlock {
table-layout: fixed !important;
}
.mcnHalfTextRight {
border: 1px solid red;
}
@media only screen and (min-width:768px) {
.templateContainer {
width: 600px !important;
}
}
@media only screen and (max-width: 480px) {
body,
table,
td,
p,
a,
li,
blockquote {
-webkit-text-size-adjust: none !important;
}
body {
width: 100% !important;
min-width: 100% !important;
}
.mcnRetinaImage {
max-width: 100% !important;
}
.mcnImage {
width: 100% !important;
}
.mcnCaptionLeftImageContent .mcnImage,
.mcnCaptionRightImageContent .mcnImage {
width: 176px !important;
}
.mcnHalfCaptionLeftImageContent .mcnImage,
.mcnHalfCaptionRightImageContent .mcnImage {
width: 268px !important;
}
.mcnBoxContentColumnBoxed {
padding: 8px !important;
margin: 0 !important;
}
.mcnButtonContentContainer,
.mcnCartContainer,
.mcnCaptionTopContent,
.mcnRecContentContainer,
.mcnCaptionBottomContent,
.mcnTextContentContainer,
.mcnBoxedTextContentContainer,
.mcnImageGroupContentContainer,
.mcnCaptionLeftTextContentContainer,
.mcnCaptionRightTextContentContainer,
.mcnCaptionLeftImageContentContainer,
.mcnCaptionRightImageContentContainer,
.mcnImageCardLeftTextContentContainer,
.mcnImageCardRightTextContentContainer,
.mcnImageCardLeftImageContentContainer,
.mcnImageCardRightImageContentContainer {
max-width: 100% !important;
width: 100% !important;
}
.mcnBoxedTextContentContainer {
min-width: 100% !important;
}
.mcnImageGroupContent {
padding: 16px !important;
}
.mcnCaptionLeftContentOuter .mcnTextContent,
.mcnCaptionRightContentOuter .mcnTextContent {
padding-top: 16px !important;
}
.mcnImageCardTopImageContent,
.mcnCaptionBottomContent:last-child .mcnCaptionBottomImageContent,
.mcnCaptionBlockInner .mcnCaptionTopContent:last-child .mcnTextContent {
padding-top: 32px !important;
}
.mcnHalfCaptionLeftImageContent,
.mcnHalfCaptionRightImageContent {
text-align: center;
}
.mcnImageCardBottomImageContent {
padding-bottom: 16px !important;
}
.mcnImageGroupBlockInner {
padding-top: 0 !important;
padding-bottom: 0 !important;
}
.mcnImageGroupBlockOuter {
padding-top: 16px !important;
padding-bottom: 16px !important;
}
.mcnTextContent,
.mcnBoxedTextContentColumn {
padding-right: 32px !important;
padding-left: 32px !important;
}
.mcnCaptionBottomContent .mcnTextContent {
padding-left: 16px !important;
padding-right: 16px !important;
}
.mcnCaptionLeftTextContentContainer .mcnTextContent,
.mcnCaptionRightTextContentContainer .mcnTextContent {
padding-right: 0 !important;
padding-left: 0 !important;
}
.mcnImageCardLeftImageContent,
.mcnImageCardRightImageContent {
padding-right: 32px !important;
padding-bottom: 0 !important;
padding-left: 32px !important;
}
.mcnTextContent ul,
.mcnTextContent ol {
padding-inline-start: 24px !important;
}
.mcnButtonContent {
padding-left: 24px !important;
padding-right: 24px !important;
}
.mcnButtonHint {
padding-left: 16px !important;
padding-right: 16px !important;
}
.mcnButtonHint,
.mcnButtonHint * {
text-align: center !important;
}
.mcpreview-image-uploader {
display: none !important;
width: 100% !important;
}
.hide-on-mobile {
display: none !important;
}
.flex-stack-on-mobile {
flex-direction: column;
}
.flex-stack-on-mobile .mcnCaptionBottomContent,
.flex-stack-on-mobile .mcnBoxContentContainer {
height: auto !important;
}
/*
@tab Mobile Styles
@section Heading 1
@tip Make the first-level headings larger in size for better readability on small screens.
*/
h1 {
font-size: 22px !important;
line-height: 1.25em !important;
}
/*
@tab Mobile Styles
@section Heading 2
@tip Make the second-level headings larger in size for better readability on small screens.
*/
h2 {
font-size: 20px !important;
line-height: 1.25em !important;
}
/*
@tab Mobile Styles
@section Heading 3
@tip Make the third-level headings larger in size for better readability on small screens.
*/
h3 {
font-size: 18px !important;
line-height: 1.25em !important;
}
/*
@tab Mobile Styles
@section Heading 4
@tip Make the fourth-level headings larger in size for better readability on small screens.
*/
h4 {
font-size: 16px !important;
line-height: 1.5em !important;
}
/*
@tab Mobile Styles
@section Boxed Text
@tip Make the boxed text larger in size for better readability on small screens. We recommend a font size of at least 16px.
*/
.mcnBoxedTextContentContainer .mcnTextContent,
.mcnBoxedTextContentContainer .mcnTextContent p {
font-size: 14px !important;
line-height: 1.5em !important;
}
/*
@tab Mobile Styles
@section Preheader Visibility
@tip Set the visibility of the email's preheader on small screens. You can hide it to save space.
*/
#templatePreheader {
display: block !important;
}
/*
@tab Mobile Styles
@section Preheader Text
@tip Make the preheader text larger in size for better readability on small screens.
*/
#templatePreheader .mcnTextContent,
#templatePreheader .mcnTextContent p,
#templateBody .templatePreheader .mcnTextContent,
#templateBody .templatePreheader .mcnTextContent p {
font-size: 13px !important;
line-height: 1.5em !important;
}
/*
@tab Mobile Styles
@section Header Text
@tip Make the header text larger in size for better readability on small screens.
*/
#templateHeader .mcnTextContent,
#templateHeader .mcnTextContent p {
font-size: 16px !important;
line-height: 1.5em !important;
}
/*
@tab Mobile Styles
@section Body Text
@tip Make the body text larger in size for better readability on small screens. We recommend a font size of at least 16px.
*/
#templateBody .mcnTextContent,
#templateBody .mcnTextContent p {
font-size: 16px !important;
line-height: 1.5em !important;
}
/*
@tab Mobile Styles
@section Body Caption Text
@tip Make the body text larger in size for better readability on small screens. We recommend a font size of at least 16px.
*/
#templateBody .templateBodyCaption,
#templateBody .templateBodyCaption p {
font-size: 14px !important;
line-height: 1.5em !important;
}
/*
@tab Mobile Styles
@section Footer Text
@tip Make the footer content text larger in size for better readability on small screens.
*/
#templateFooter .mcnTextContent,
#templateFooter .mcnTextContent p {
font-size: 14px !important;
line-height: 1.5em !important;
}
/*
@tab Mobile Styles
@section Footer Follow icons
@tip Reduce the spacing between the footer icons to avoid a line-break them on small screens.
*/
#templateFooter .mcnFollowContentItemContainer {
padding-left: 2px !important;
padding-right: 2px !important;
}
/*
@tab Mobile Styles
@section Footer Follow icons
@tip Reduce the spacing between the footer icons to avoid a line-break them on small screens.
*/
#templateFooter .mcnFollowContentItemContainerSmall {
padding-left: 8px !important;
padding-right: 8px !important;
}
/*
@text Mobile Style
*/
.mcnCaptionRightImageContent,
.mcnCaptionLeftImageContent {
text-align: center;
}
}
@media only screen and (max-width: 352px) {
/*
@tab Mobile Styles
@section Footer Follow icons
@tip Reduce the icon size on very small screens.
*/
.mcnFollowIconContent,
.mcnFollowIconContent img.social-icon {
width: 38px !important;
height: 38px !important;
}
/*
@tab Mobile Styles
@section Footer Follow icons
@tip Remove the spacing between the footer icons to avoid a line-break them on very small screens.
*/
#templateFooter .mcnFollowContentItemContainer {
padding-left: 0 !important;
padding-right: 0 !important;
}
}
</style>
</head>
<body style="height: 100%;
margin: 0;
padding: 0;
width: 100%;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;">
<!--[if !gte mso 9]><!----><span class="mcnPreviewText"
style="display:none;
font-size:0px;
line-height:0px;
max-height:0px;
max-width:0px;
opacity:0;
overflow:hidden;
visibility:hidden;
mso-hide:all;"></span><!--<![endif]-->
<center>
<table align="center"
border="0"
cellpadding="0"
cellspacing="0"
height="100%"
width="100%"
id="bodyTable"
style="border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
height: 100%;
margin: 0;
padding: 0;
width: 100%;">
<tr>
<td align="center"
valign="top"
id="bodyCell"
style="mso-line-height-rule: exactly;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
height: 100%;
margin: 0;
padding: 8px;
width: 100%;">
<!-- BEGIN TEMPLATE // -->
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600" style="width:600px;"><tr><td align="center" valign="top" width="600" style="width:600px;">
<![endif]-->
<table border="0"
cellpadding="0"
cellspacing="0"
width="100%"
class="templateContainer"
style="border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
max-width: 600px !important;">
<tr>
<td valign="top"
id="templateHeader"
style="mso-line-height-rule: exactly;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;">
<table border="0"
cellpadding="0"
cellspacing="0"
width="100%"
class="mcnImageBlock"
style="min-width: 100%;
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;">
<tbody class="mcnImageBlockOuter">
<tr>
<td valign="top"
style="padding: 16px;
mso-line-height-rule: exactly;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;"
class="mcnImageBlockInner">
<table align="left"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
class="mcnImageContentContainer"
style="min-width: 100%;
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;">
<tbody>
<tr>
<td class="mcnImageContent"
valign="top"
style="padding: 16px;
text-align: center;
mso-line-height-rule: exactly;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;">
<a href="https://proton.me/" target="_blank" style="">
<img align="center"
alt="Proton"
src="{{ URL }}/static/logo-proton.png"
width="190"
style="width:35.4477%; max-width: 380px; padding-bottom: 0; display: inline !important; vertical-align: bottom; border: 0; height: auto; outline: none; text-decoration: none; -ms-interpolation-mode: bicubic; ">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td valign="top"
id="templateBody"
style="mso-line-height-rule: exactly; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; ">
{% block greeting %}{% endblock %}
{% block content %}{% endblock %}
<!-- Sub copy -->
{% block sub_copy %}{% endblock %}
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
<!-- // END TEMPLATE -->
</td>
</tr>
</table>
</center>
</body>
</html>

View File

@ -0,0 +1,623 @@
{% from "_emailhelpers.html" import render_text, text, render_button, raw_url, grey_section, section %}
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="x-apple-disable-message-reformatting" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<style type="text/css" rel="stylesheet" media="all">
/* Base ------------------------------ */
body {
width: 100% !important;
height: 100%;
margin: 0;
-webkit-text-size-adjust: none;
line-height: 1.6;
}
img {
max-width: 100%;
}
a {
color: #3869D4;
}
a img {
border: none;
}
td {
word-break: break-word;
}
.preheader {
display: none !important;
visibility: hidden;
mso-hide: all;
font-size: 1px;
line-height: 1px;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
}
/* Type ------------------------------ */
body,
td,
th {
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
}
h1 {
margin-top: 0;
color: #333333;
font-size: 22px;
font-weight: bold;
text-align: left;
}
h2 {
margin-top: 0;
color: #333333;
font-size: 16px;
font-weight: bold;
text-align: left;
}
h3 {
margin-top: 0;
color: #333333;
font-size: 14px;
font-weight: bold;
text-align: left;
}
td,
th {
font-size: 16px;
}
p,
ul,
ol,
blockquote {
margin: .4em 0 1.1875em;
font-size: 16px;
line-height: 1.625;
}
p.sub {
font-size: 13px;
}
/* Utilities ------------------------------ */
.align-right {
text-align: right;
}
.align-left {
text-align: left;
}
.align-center {
text-align: center;
}
/* Buttons ------------------------------ */
.button {
background-color: #3869D4;
border-top: 10px solid #3869D4;
border-right: 18px solid #3869D4;
border-bottom: 10px solid #3869D4;
border-left: 18px solid #3869D4;
display: inline-block;
color: #FFF;
text-decoration: none;
border-radius: 3px;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
-webkit-text-size-adjust: none;
box-sizing: border-box;
}
.button--green {
background-color: #22BC66;
border-top: 10px solid #22BC66;
border-right: 18px solid #22BC66;
border-bottom: 10px solid #22BC66;
border-left: 18px solid #22BC66;
}
.button--red {
background-color: #FF6136;
border-top: 10px solid #FF6136;
border-right: 18px solid #FF6136;
border-bottom: 10px solid #FF6136;
border-left: 18px solid #FF6136;
}
@media only screen and (max-width: 500px) {
.button {
width: 100% !important;
text-align: center !important;
}
}
/* Attribute list ------------------------------ */
.attributes {
margin: 0 0 21px;
}
.attributes_content {
background-color: #F4F4F7;
padding: 16px;
}
.attributes_item {
padding: 0;
}
/* Related Items ------------------------------ */
.related {
width: 100%;
margin: 0;
padding: 25px 0 0 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.related_item {
padding: 10px 0;
color: #CBCCCF;
font-size: 15px;
line-height: 18px;
}
.related_item-title {
display: block;
margin: .5em 0 0;
}
.related_item-thumb {
display: block;
padding-bottom: 10px;
}
.related_heading {
border-top: 1px solid #CBCCCF;
text-align: center;
padding: 25px 0 10px;
}
/* Discount Code ------------------------------ */
.discount {
width: 100%;
margin: 0;
padding: 24px;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #F4F4F7;
border: 2px dashed #CBCCCF;
}
.discount_heading {
text-align: center;
}
.discount_body {
text-align: center;
font-size: 15px;
}
/* Social Icons ------------------------------ */
.social {
width: auto;
}
.social td {
padding: 0;
width: auto;
}
.social_icon {
height: 20px;
margin: 0 8px 10px 8px;
padding: 0;
}
/* Data table ------------------------------ */
.purchase {
width: 100%;
margin: 0;
padding: 35px 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.purchase_content {
width: 100%;
margin: 0;
padding: 25px 0 0 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.purchase_item {
padding: 10px 0;
color: #51545E;
font-size: 15px;
line-height: 18px;
}
.purchase_heading {
padding-bottom: 8px;
border-bottom: 1px solid #EAEAEC;
}
.purchase_heading p {
margin: 0;
color: #85878E;
font-size: 12px;
}
.purchase_footer {
padding-top: 15px;
border-top: 1px solid #EAEAEC;
}
.purchase_total {
margin: 0;
text-align: right;
font-weight: bold;
color: #333333;
}
.purchase_total--label {
padding: 0 15px 0 0;
}
body {
background-color: #F2F4F6;
color: #51545E;
}
p {
color: #51545E;
}
.email-wrapper {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #F2F4F6;
}
.email-content {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
/* Masthead ----------------------- */
.email-masthead {
padding: 25px 0;
text-align: center;
}
.email-masthead_logo {
width: 94px;
}
.email-masthead_name {
font-size: 16px;
font-weight: bold;
color: #A8AAAF;
text-decoration: none;
text-shadow: 0 1px 0 white;
}
/* Body ------------------------------ */
.email-body {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.email-body_inner {
width: 750px;
margin: 0 auto;
padding: 0;
-premailer-width: 750px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #FFFFFF;
}
.email-footer {
width: 750px;
margin: 0 auto;
padding: 0;
-premailer-width: 750px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
}
.email-footer p {
color: #A8AAAF;
}
.body-action {
width: 100%;
margin: 30px auto;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
}
.body-sub {
margin-top: 25px;
padding-top: 25px;
border-top: 1px solid #EAEAEC;
}
.content-cell {
padding: 30px;
}
/*Media Queries ------------------------------ */
@media only screen and (max-width: 600px) {
.email-body_inner,
.email-footer {
width: 100% !important;
}
}
@media (prefers-color-scheme: dark) {
body,
.email-body,
.email-body_inner,
.email-content,
.email-wrapper,
.email-masthead,
.email-footer {
background-color: #333333 !important;
color: #FFF !important;
}
p,
ul,
ol,
blockquote,
h1,
h2,
h3 {
color: #FFF !important;
}
.attributes_content,
.discount {
background-color: #222 !important;
}
.email-masthead_name {
text-shadow: none !important;
}
}
</style>
<!--[if mso]>
<style type="text/css">
.f-fallback {
font-family: Arial, sans-serif;
}
</style>
<![endif]-->
<style type="text/css" rel="stylesheet" media="all">
body {
width: 100% !important;
height: 100%;
margin: 0;
-webkit-text-size-adjust: none;
}
body {
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
}
body {
background-color: #F2F4F6;
color: #51545E;
}
</style>
</head>
<body style="width: 100% !important;
height: 100%;
-webkit-text-size-adjust: none;
font-family: Helvetica, Arial, sans-serif;
background-color: #F2F4F6;
color: #51545E;
margin: 0;"
bgcolor="#F2F4F6">
<span class="preheader"
style="display: none !important;
visibility: hidden;
mso-hide: all;
font-size: 1px;
line-height: 1px;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;">{{ pre_header }}</span>
<table class="email-wrapper"
width="100%"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 100%;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #F2F4F6;
margin: 0;
padding: 0;"
bgcolor="#F2F4F6">
<tr>
<td align="center"
style="word-break: break-word;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;">
<table class="email-content"
width="100%"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 100%;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
margin: 0;
padding: 0;">
<tr>
<td class="email-masthead"
style="word-break: break-word;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;
text-align: center;
padding: 25px 0;"
align="center">
<a href="{{ LANDING_PAGE_URL }}"
class="f-fallback email-masthead_name"
style="color: #A8AAAF;
font-size: 16px;
font-weight: bold;
text-decoration: none;
text-shadow: 0 1px 0 white;">
{% block logo %}<img src="{{ URL }}/static/logo.png" style="width: 150px; margin: auto">{% endblock %}
</a>
</td>
</tr>
<!-- Email Body -->
<tr>
<td class="email-body"
width="750"
cellpadding="0"
cellspacing="0"
style="word-break: break-word;
margin: 0;
padding: 0;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;
width: 100%;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;">
<table class="email-body_inner"
align="center"
width="750"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 750px;
-premailer-width: 750px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #FFFFFF;
margin: 0 auto;
padding: 0;"
bgcolor="#FFFFFF">
<!-- Body content -->
<tr>
<td class="content-cell"
style="word-break: break-word;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;
padding: 30px;">
<div class="f-fallback">
{% block greeting %}{% endblock %}
{% block content %}{% endblock %}
<!-- Sub copy -->
{% block sub_copy %}{% endblock %}
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="word-break: break-word;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;">
<table class="email-footer"
align="center"
width="750"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 750px;
-premailer-width: 750px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
margin: 0 auto;
padding: 0;">
<tr>
<td class="content-cell"
align="center"
style="word-break: break-word;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;
padding: 30px;">
<p class="f-fallback sub align-center"
style="font-size: 13px;
line-height: 1.625;
text-align: center;
color: #A8AAAF;
margin: .4em 0 1.1875em;"
align="center">
© {{ YEAR }} SimpleLogin - a Proton product. All rights reserved.
<br />
{% block footer %}{% endblock %}
</p>
{% if unsubscribe_oneclick is defined %}
<p class="f-fallback sub align-center"
style="font-size: 13px;
line-height: 1.625;
text-align: center;
margin: .4em 0 1.1875em;">
<a href="{{ unsubscribe_oneclick }}">Unsubscribe from our newsletter</a>
</p>
{% endif %}
<p class="f-fallback sub align-center"
style="font-size: 13px;
line-height: 1.625;
text-align: center;
color: #A8AAAF;
margin: .4em 0 1.1875em;"
align="center">
<a href="https://app.simplelogin.io/dashboard/support">Do you have a question?</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -6,6 +6,7 @@
{{ render_text("Your subscription will end on " + next_bill_date + ".") }} {{ render_text("Your subscription will end on " + next_bill_date + ".") }}
{{ render_text("When the subscription ends:") }} {{ render_text("When the subscription ends:") }}
{{ render_text("- All aliases/domains/directories you have created are <b>kept</b> and continue working normally.") }} {{ render_text("- All aliases/domains/directories you have created are <b>kept</b> and continue working normally.") }}
{{ render_text("- You cannot create new reverse aliases.") }}
{% call text() %} {% call text() %}
- You cannot create new aliases if you exceed the free plan limit, i.e. have more than {{ MAX_NB_EMAIL_FREE_PLAN }} aliases. - You cannot create new aliases if you exceed the free plan limit, i.e. have more than {{ MAX_NB_EMAIL_FREE_PLAN }} aliases.
{% endcall %} {% endcall %}

View File

@ -9,6 +9,7 @@ When the subscription ends:
- All aliases/domains/directories you have created are kept and continue working. - All aliases/domains/directories you have created are kept and continue working.
- You cannot create new aliases if you exceed the free plan limit, i.e. have more than {{MAX_NB_EMAIL_FREE_PLAN}} aliases. - You cannot create new aliases if you exceed the free plan limit, i.e. have more than {{MAX_NB_EMAIL_FREE_PLAN}} aliases.
- You cannot create new reverse aliases.
- As features like "catch-all" or "directory" allow you to create aliases on-the-fly, - As features like "catch-all" or "directory" allow you to create aliases on-the-fly,
those aliases cannot be automatically created if you have more than {{MAX_NB_EMAIL_FREE_PLAN}} aliases. those aliases cannot be automatically created if you have more than {{MAX_NB_EMAIL_FREE_PLAN}} aliases.
- You cannot add new domain or directory. - You cannot add new domain or directory.

View File

@ -14,6 +14,7 @@
{{ render_text("- You cannot add new domain or directory.") }} {{ render_text("- You cannot add new domain or directory.") }}
{{ render_text("- You cannot add new mailbox.") }} {{ render_text("- You cannot add new mailbox.") }}
{{ render_text("- You cannot create new reverse aliases.") }}
{{ render_text("- If you enable PGP Encryption, forwarded emails are not encrypted anymore.") }} {{ render_text("- If you enable PGP Encryption, forwarded emails are not encrypted anymore.") }}
{{ render_text('You can upgrade today to continue using all these Premium features (and much more coming).') }} {{ render_text('You can upgrade today to continue using all these Premium features (and much more coming).') }}
{{ render_button("Upgrade your account", URL ~ "/dashboard/pricing") }} {{ render_button("Upgrade your account", URL ~ "/dashboard/pricing") }}

View File

@ -8,6 +8,7 @@ When the trial ends:
- All aliases/domains/directories you have created are kept and continue working. - All aliases/domains/directories you have created are kept and continue working.
- You cannot create new aliases if you exceed the free plan limit, i.e. have more than {{MAX_NB_EMAIL_FREE_PLAN}} aliases. - You cannot create new aliases if you exceed the free plan limit, i.e. have more than {{MAX_NB_EMAIL_FREE_PLAN}} aliases.
- You cannot add new domain or directory. - You cannot add new domain or directory.
- You cannot create new reverse aliases.
- You cannot add new mailbox. - You cannot add new mailbox.
- If you enable PGP Encryption, forwarded emails are not encrypted anymore. - If you enable PGP Encryption, forwarded emails are not encrypted anymore.

View File

@ -216,6 +216,7 @@
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
'{{HEADER_ALLOW_API_COOKIES}}': 'allow'
} }
}); });
@ -232,6 +233,7 @@
method: "GET", method: "GET",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
'{{HEADER_ALLOW_API_COOKIES}}': 'allow'
} }
}); });
if (res.ok) { if (res.ok) {
@ -249,6 +251,7 @@
method: "GET", method: "GET",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
'{{HEADER_ALLOW_API_COOKIES}}': 'allow'
} }
}); });
if (res.ok) { if (res.ok) {

View File

@ -87,6 +87,7 @@
method: "GET", method: "GET",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
'{{HEADER_ALLOW_API_COOKIES}}': 'allow'
} }
}); });
if (res.ok) { if (res.ok) {

View File

@ -1,5 +1,7 @@
import os import os
from flask import testing
# use the tests/test.env config fle # use the tests/test.env config fle
# flake8: noqa: E402 # flake8: noqa: E402
@ -42,7 +44,16 @@ def flask_app():
yield app yield app
from app import config from app import config, constants
class CustomTestClient(testing.FlaskClient):
def open(self, *args, **kwargs):
if isinstance(args[0], str):
headers = kwargs.pop("headers", {})
headers.update({constants.HEADER_ALLOW_API_COOKIES: "allow"})
kwargs["headers"] = headers
return super().open(*args, **kwargs)
@pytest.fixture @pytest.fixture
@ -53,7 +64,9 @@ def flask_client():
# disable rate limit during test # disable rate limit during test
config.DISABLE_RATE_LIMIT = True config.DISABLE_RATE_LIMIT = True
try: try:
app.test_client_class = CustomTestClient
client = app.test_client() client = app.test_client()
client.environ_base[constants.HEADER_ALLOW_API_COOKIES] = "allow"
yield client yield client
finally: finally:
# disable rate limit again as some tests might enable rate limit # disable rate limit again as some tests might enable rate limit

View File

@ -0,0 +1,46 @@
from app import config
from app.db import Session
from app.events.event_dispatcher import Dispatcher
from app.events.generated import event_pb2
from app.jobs.event_jobs import send_alias_creation_events_for_user
from app.models import Alias
from tests.utils import create_partner_linked_user
class MemStoreDispatcher(Dispatcher):
def __init__(self):
self.events = []
def send(self, event: bytes):
self.events.append(event)
def setup_module():
config.EVENT_WEBHOOK = True
def teardown_module():
config.EVENT_WEBHOOK = False
def test_send_alias_creation_events():
[user, partner_user] = create_partner_linked_user()
aliases = [Alias.create_new_random(user) for i in range(2)]
Session.flush()
dispatcher = MemStoreDispatcher()
send_alias_creation_events_for_user(user, dispatcher=dispatcher, chunk_size=2)
# 2 batches. 1st newsletter + first alias. 2nd last alias
assert len(dispatcher.events) == 2
decoded_event = event_pb2.Event.FromString(dispatcher.events[0])
assert decoded_event.user_id == user.id
assert decoded_event.external_user_id == partner_user.external_user_id
event_list = decoded_event.content.alias_create_list.events
assert len(event_list) == 2
# 0 is newsletter alias
assert event_list[1].alias_id == aliases[0].id
decoded_event = event_pb2.Event.FromString(dispatcher.events[1])
assert decoded_event.user_id == user.id
assert decoded_event.external_user_id == partner_user.external_user_id
event_list = decoded_event.content.alias_create_list.events
assert len(event_list) == 1
assert event_list[0].alias_id == aliases[1].id

View File

@ -1,5 +1,5 @@
from app.db import Session from app.db import Session
from app.models import Alias, Mailbox, AliasMailbox from app.models import Alias, Mailbox, AliasMailbox, User
from tests.utils import create_new_user, random_email from tests.utils import create_new_user, random_email
@ -15,3 +15,17 @@ def test_duplicated_mailbox_is_returned_only_once():
alias_mailbox_id = [mailbox.id for mailbox in alias_mailboxes] alias_mailbox_id = [mailbox.id for mailbox in alias_mailboxes]
assert user.default_mailbox_id in alias_mailbox_id assert user.default_mailbox_id in alias_mailbox_id
assert other_mailbox.id in alias_mailbox_id assert other_mailbox.id in alias_mailbox_id
def test_alias_create_from_partner_flags_also_the_user():
user = create_new_user()
Session.flush()
email = random_email()
alias = Alias.create(
user_id=user.id,
email=email,
mailbox_id=user.default_mailbox_id,
flags=Alias.FLAG_PARTNER_CREATED,
flush=True,
)
assert alias.user.flags & User.FLAG_CREATED_ALIAS_FROM_PARTNER > 0

View File

@ -9,6 +9,7 @@ import pytest
from app import config from app import config
from app.config import MAX_ALERT_24H, ROOT_DIR from app.config import MAX_ALERT_24H, ROOT_DIR
from app.db import Session from app.db import Session
from app.email import headers
from app.email_utils import ( from app.email_utils import (
get_email_domain_part, get_email_domain_part,
can_create_directory_for_address, can_create_directory_for_address,
@ -354,6 +355,33 @@ def test_is_valid_email():
assert not is_valid_email("emoji👌@gmail.com") assert not is_valid_email("emoji👌@gmail.com")
def test_add_subject_prefix():
msg = email.message_from_string(
"""Subject: Potato
Content-Transfer-Encoding: 7bit
hello
"""
)
new_msg = add_header(msg, "text header", "html header", subject_prefix="[TEST]")
assert "text header" in new_msg.as_string()
assert "html header" not in new_msg.as_string()
assert new_msg[headers.SUBJECT] == "[TEST] Potato"
def test_add_subject_prefix_with_no_header():
msg = email.message_from_string(
"""Content-Transfer-Encoding: 7bit
hello
"""
)
new_msg = add_header(msg, "text header", "html header", subject_prefix="[TEST]")
assert "text header" in new_msg.as_string()
assert "html header" not in new_msg.as_string()
assert new_msg[headers.SUBJECT] == "[TEST]"
def test_add_header_plain_text(): def test_add_header_plain_text():
msg = email.message_from_string( msg = email.message_from_string(
"""Content-Type: text/plain; charset=us-ascii """Content-Type: text/plain; charset=us-ascii

View File

@ -9,7 +9,8 @@ from typing import Optional, Dict
import jinja2 import jinja2
from flask import url_for from flask import url_for
from app.models import User from app.models import User, PartnerUser
from app.proton.utils import get_proton_partner
from app.utils import random_string from app.utils import random_string
@ -30,6 +31,18 @@ def create_new_user(email: Optional[str] = None, name: Optional[str] = None) ->
return user return user
def create_partner_linked_user() -> tuple[User, PartnerUser]:
user = create_new_user()
partner_user = PartnerUser.create(
partner_id=get_proton_partner().id,
user_id=user.id,
external_user_id=random_token(10),
flush=True,
)
return user, partner_user
def login(flask_client, user: Optional[User] = None) -> User: def login(flask_client, user: Optional[User] = None) -> User:
if not user: if not user:
user = create_new_user() user = create_new_user()