diff --git a/app/app/alias_utils.py b/app/app/alias_utils.py index 99f291e..e0aeb6a 100644 --- a/app/app/alias_utils.py +++ b/app/app/alias_utils.py @@ -34,6 +34,7 @@ from app.events.generated.event_pb2 import ( from app.log import LOG from app.models import ( Alias, + AliasDeleteReason, CustomDomain, Directory, User, @@ -309,7 +310,9 @@ def try_auto_create_via_domain(address: str) -> Optional[Alias]: 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 Should be used instead of Alias.delete, DomainDeletedAlias.create, DeletedAlias.create @@ -324,6 +327,7 @@ def delete_alias(alias: Alias, user: User): user_id=user.id, email=alias.email, domain_id=alias.custom_domain_id, + reason=reason, ) Session.add(domain_deleted_alias) Session.commit() @@ -332,7 +336,7 @@ def delete_alias(alias: Alias, user: User): ) else: 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.commit() LOG.i(f"Moving {alias} to global trash {deleted_alias}") @@ -453,10 +457,12 @@ def transfer_alias(alias, new_user, new_mailboxes: [Mailbox]): f"Alias {alias.email} has been received", render( "transactional/alias-transferred.txt", + user=old_user, alias=alias, ), render( "transactional/alias-transferred.html", + user=old_user, alias=alias, ), ) diff --git a/app/app/api/views/alias.py b/app/app/api/views/alias.py index 50f455d..c2db060 100644 --- a/app/app/api/views/alias.py +++ b/app/app/api/views/alias.py @@ -26,7 +26,7 @@ from app.errors import ( ) from app.extensions import limiter from app.log import LOG -from app.models import Alias, Contact, Mailbox, AliasMailbox +from app.models import Alias, Contact, Mailbox, AliasMailbox, AliasDeleteReason @deprecated @@ -161,7 +161,7 @@ def delete_alias(alias_id): if not alias or alias.user_id != user.id: return jsonify(error="Forbidden"), 403 - alias_utils.delete_alias(alias, user) + alias_utils.delete_alias(alias, user, AliasDeleteReason.ManualAction) return jsonify(deleted=True), 200 diff --git a/app/app/api/views/auth.py b/app/app/api/views/auth.py index af180d8..a22b30d 100644 --- a/app/app/api/views/auth.py +++ b/app/app/api/views/auth.py @@ -129,8 +129,8 @@ def auth_register(): send_email( email, "Just one more step to join SimpleLogin", - render("transactional/code-activation.txt.jinja2", code=code), - render("transactional/code-activation.html", code=code), + render("transactional/code-activation.txt.jinja2", user=user, code=code), + render("transactional/code-activation.html", user=user, code=code), ) RegisterEvent(RegisterEvent.ActionType.success, RegisterEvent.Source.api).send() @@ -226,8 +226,8 @@ def auth_reactivate(): send_email( email, "Just one more step to join SimpleLogin", - render("transactional/code-activation.txt.jinja2", code=code), - render("transactional/code-activation.html", code=code), + render("transactional/code-activation.txt.jinja2", user=user, code=code), + render("transactional/code-activation.html", user=user, code=code), ) return jsonify(msg="User needs to confirm their account"), 200 diff --git a/app/app/auth/views/register.py b/app/app/auth/views/register.py index 7505303..6740e57 100644 --- a/app/app/auth/views/register.py +++ b/app/app/auth/views/register.py @@ -125,4 +125,4 @@ def send_activation_email(user, next_url): LOG.d("redirect user to %s after activation", 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) diff --git a/app/app/config.py b/app/app/config.py index 9a7fb62..0e86cd8 100644 --- a/app/app/config.py +++ b/app/app/config.py @@ -120,7 +120,7 @@ if POSTFIX_SUBMISSION_TLS: else: default_postfix_port = 25 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"] OTHER_ALIAS_DOMAINS = sl_getenv("OTHER_ALIAS_DOMAINS", list) diff --git a/app/app/dashboard/views/account_setting.py b/app/app/dashboard/views/account_setting.py index 5e3e058..28c0241 100644 --- a/app/app/dashboard/views/account_setting.py +++ b/app/app/dashboard/views/account_setting.py @@ -169,7 +169,7 @@ def send_reset_password_email(user): 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): @@ -179,7 +179,7 @@ def send_change_email_confirmation(user: User, email_change: EmailChange): 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"]) diff --git a/app/app/dashboard/views/index.py b/app/app/dashboard/views/index.py index a5eaed7..a9a4f26 100644 --- a/app/app/dashboard/views/index.py +++ b/app/app/dashboard/views/index.py @@ -12,6 +12,7 @@ from app.extensions import limiter from app.log import LOG from app.models import ( Alias, + AliasDeleteReason, AliasGeneratorEnum, User, EmailLog, @@ -143,7 +144,9 @@ def index(): if request.form.get("form-name") == "delete-alias": LOG.i(f"User {current_user} requested deletion of alias {alias}") 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") elif request.form.get("form-name") == "disable-alias": alias_utils.change_alias_status(alias, enabled=False) diff --git a/app/app/email_utils.py b/app/app/email_utils.py index 0ab5626..6048a99 100644 --- a/app/app/email_utils.py +++ b/app/app/email_utils.py @@ -33,6 +33,7 @@ from flanker.addresslib import address from flanker.addresslib.address import EmailAddress from jinja2 import Environment, FileSystemLoader from sqlalchemy import func +from flask_login import current_user from app import config from app.db import Session @@ -68,17 +69,27 @@ VERP_TIME_START = 1640995200 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") env = Environment(loader=FileSystemLoader(templates_dir)) 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( MAX_NB_EMAIL_FREE_PLAN=config.MAX_NB_EMAIL_FREE_PLAN, URL=config.URL, LANDING_PAGE_URL=config.LANDING_PAGE_URL, YEAR=arrow.now().year, + USE_PARTNER_TEMPLATE=use_partner_template, **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( - email, + user.email, "Just one more step to join SimpleLogin", render( "transactional/activation.txt", + user=user, activation_link=activation_link, - email=email, + email=user.email, ), render( "transactional/activation.html", + user=user, 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( - email, + user.email, "Reset your password on SimpleLogin", render( "transactional/reset-password.txt", + user=user, reset_password_link=reset_password_link, ), render( "transactional/reset-password.html", + user=user, 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( new_email, "Confirm email update on SimpleLogin", render( "transactional/change-email.txt", + user=user, link=link, new_email=new_email, - current_email=current_email, + current_email=user.email, ), render( "transactional/change-email.html", + user=user, link=link, 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", render( "transactional/invalid-totp-login.txt", + user=user, type=totp_type, ), render( "transactional/invalid-totp-login.html", + user=user, type=totp_type, ), 1, ) -def send_test_email_alias(email, name): +def send_test_email_alias(user: User, email: str): send_email( email, f"This email is sent to {email}", render( "transactional/test-email.txt", - name=name, + user=user, + name=user.name, alias=email, ), render( "transactional/test-email.html", - name=name, + user=user, + name=user.name, alias=email, ), ) @@ -206,11 +227,13 @@ def send_cannot_create_directory_alias(user, alias_address, directory_name): f"Alias {alias_address} cannot be created", render( "transactional/cannot-create-alias-directory.txt", + user=user, alias=alias_address, directory=directory_name, ), render( "transactional/cannot-create-alias-directory.html", + user=user, alias=alias_address, 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", render( "transactional/cannot-create-alias-directory-disabled.txt", + user=user, alias=alias_address, directory=directory_name, ), render( "transactional/cannot-create-alias-directory-disabled.html", + user=user, alias=alias_address, directory=directory_name, ), @@ -248,11 +273,13 @@ def send_cannot_create_domain_alias(user, alias, domain): f"Alias {alias} cannot be created", render( "transactional/cannot-create-alias-domain.txt", + user=user, alias=alias, domain=domain, ), render( "transactional/cannot-create-alias-domain.html", + user=user, alias=alias, domain=domain, ), @@ -919,10 +946,20 @@ def decode_text(text: str, encoding: EmailEncoding = EmailEncoding.NO) -> str: 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: html_header = text_header.replace("\n", "
") + 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() if content_type == "text/plain": 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", render( "transactional/spf-fail.txt", + user=user, alias=alias.email, ip=ip, mailbox_url=config.URL + f"/dashboard/mailbox/{mailbox.id}#spf", @@ -1262,6 +1300,7 @@ def spf_pass( ), render( "transactional/spf-fail.html", + user=user, ip=ip, mailbox_url=config.URL + f"/dashboard/mailbox/{mailbox.id}#spf", to_email=contact_email, diff --git a/app/app/handler/dmarc.py b/app/app/handler/dmarc.py index 895fa70..8d139b2 100644 --- a/app/app/handler/dmarc.py +++ b/app/app/handler/dmarc.py @@ -64,6 +64,7 @@ More info on https://simplelogin.io/docs/getting-started/anti-phishing/ msg, warning_plain_text, warning_html, + subject_prefix="[Possible phishing attempt]", ) return changed_msg, None @@ -76,6 +77,7 @@ More info on https://simplelogin.io/docs/getting-started/anti-phishing/ msg, warning_plain_text, warning_html, + subject_prefix="[Possible phishing attempt]", ) 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", render( "transactional/message-quarantine-dmarc.txt.jinja2", + user=user, from_header=from_header, alias=alias, refused_email_url=email_log.get_dashboard_url(), ), render( "transactional/message-quarantine-dmarc.html", + user=user, from_header=from_header, alias=alias, 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}", render( "transactional/spoof-reply.txt.jinja2", + user=alias_from.user, contact=contact_recipient, alias=alias_from, sender=envelope.mail_from, ), render( "transactional/spoof-reply.html", + user=alias_from.user, contact=contact_recipient, alias=alias_from, sender=envelope.mail_from, diff --git a/app/app/handler/provider_complaint.py b/app/app/handler/provider_complaint.py index 3a10ef7..7454d5c 100644 --- a/app/app/handler/provider_complaint.py +++ b/app/app/handler/provider_complaint.py @@ -319,11 +319,13 @@ def report_complaint_to_user_in_forward_phase( f"Abuse report from {capitalized_name}", render( "transactional/provider-complaint-forward-phase.txt.jinja2", + user=user, email=mailbox_email, provider=capitalized_name, ), render( "transactional/provider-complaint-forward-phase.html", + user=user, email=mailbox_email, provider=capitalized_name, ), diff --git a/app/app/jobs/export_user_data_job.py b/app/app/jobs/export_user_data_job.py index 4821930..665f9fd 100644 --- a/app/app/jobs/export_user_data_job.py +++ b/app/app/jobs/export_user_data_job.py @@ -137,7 +137,9 @@ class ExportUserDataJob: msg[headers.SUBJECT] = "Your SimpleLogin data" msg[headers.FROM] = f'"SimpleLogin (noreply)" <{config.NOREPLY}>' 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.add_header( "Content-Disposition", "attachment", filename="user_report.zip" diff --git a/app/app/models.py b/app/app/models.py index fe5d557..bfbc837 100644 --- a/app/app/models.py +++ b/app/app/models.py @@ -263,6 +263,15 @@ class UnsubscribeBehaviourEnum(EnumE): PreserveOriginal = 2 +class AliasDeleteReason(EnumE): + Unspecified = 0 + UserHasBeenDeleted = 1 + ManualAction = 2 + DirectoryDeleted = 3 + MailboxDeleted = 4 + CustomDomainDeleted = 5 + + class IntEnumType(sa.types.TypeDecorator): impl = sa.Integer @@ -330,6 +339,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle): FLAG_FREE_DISABLE_CREATE_ALIAS = 1 << 0 FLAG_CREATED_FROM_PARTNER = 1 << 1 FLAG_FREE_OLD_ALIAS_LIMIT = 1 << 2 + FLAG_CREATED_ALIAS_FROM_PARTNER = 1 << 3 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) 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) if commit: Session.commit() @@ -1153,6 +1169,13 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle): return True 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): return f"" @@ -1646,6 +1669,12 @@ class Alias(Base, ModelMixin): ) 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: Session.commit() @@ -2247,6 +2276,12 @@ class DeletedAlias(Base, ModelMixin): __tablename__ = "deleted_alias" 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 def create(cls, **kw): @@ -2434,6 +2469,13 @@ class CustomDomain(Base, ModelMixin): if obj.is_sl_subdomain: 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) @property @@ -2506,6 +2548,12 @@ class DomainDeletedAlias(Base, ModelMixin): domain = orm.relationship(CustomDomain) 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 def create(cls, **kw): @@ -2597,7 +2645,7 @@ class Directory(Base, ModelMixin): for alias in Alias.filter_by(directory_id=obj_id): from app import alias_utils - alias_utils.delete_alias(alias, user) + alias_utils.delete_alias(alias, user, AliasDeleteReason.DirectoryDeleted) DeletedDirectory.create(name=obj.name) cls.filter(cls.id == obj_id).delete() @@ -2725,7 +2773,7 @@ class Mailbox(Base, ModelMixin): from app import alias_utils # 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() cls.filter(cls.id == obj_id).delete() @@ -2971,11 +3019,7 @@ class RecoveryCode(Base, ModelMixin): @classmethod def find_by_user_code(cls, user: User, code: str): hashed_code = cls._hash_code(code) - # TODO: Only return hashed codes once there aren't unhashed codes in the db. - 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) + return cls.get_by(user_id=user.id, code=hashed_code) @classmethod def empty(cls, user): diff --git a/app/app/onboarding/views/final.py b/app/app/onboarding/views/final.py index 64c271c..d7f532b 100644 --- a/app/app/onboarding/views/final.py +++ b/app/app/onboarding/views/final.py @@ -20,7 +20,7 @@ def final(): if form.validate_on_submit(): alias = Alias.get_by(email=form.email.data) 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") return render_template( diff --git a/app/app/paddle_callback.py b/app/app/paddle_callback.py index 7d7402a..b0efee4 100644 --- a/app/app/paddle_callback.py +++ b/app/app/paddle_callback.py @@ -27,6 +27,7 @@ def failed_payment(sub: Subscription, subscription_id: str): "SimpleLogin - your subscription has failed to be renewed", render( "transactional/subscription-cancel.txt", + user=user, end_date=arrow.arrow.datetime.utcnow(), ), ) diff --git a/app/cron.py b/app/cron.py index 2f1e6c8..bfc150d 100644 --- a/app/cron.py +++ b/app/cron.py @@ -266,11 +266,13 @@ def notify_manual_sub_end(): "Your SimpleLogin subscription will end soon", render( "transactional/coinbase/reminder-subscription.txt", + user=user, coinbase_subscription=coinbase_subscription, extend_subscription_url=extend_subscription_url, ), render( "transactional/coinbase/reminder-subscription.html", + user=user, coinbase_subscription=coinbase_subscription, extend_subscription_url=extend_subscription_url, ), @@ -826,10 +828,12 @@ def check_mailbox_valid_domain(): f"Mailbox {mailbox.email} is disabled", render( "transactional/disable-mailbox-warning.txt.jinja2", + user=mailbox.user, mailbox=mailbox, ), render( "transactional/disable-mailbox-warning.html", + user=mailbox.user, mailbox=mailbox, ), retries=3, @@ -884,6 +888,7 @@ def check_mailbox_valid_pgp_keys(): f"Mailbox {mailbox.email}'s PGP Key is invalid", render( "transactional/invalid-mailbox-pgp-key.txt.jinja2", + user=mailbox.user, mailbox=mailbox, ), retries=3, @@ -924,6 +929,7 @@ def check_single_custom_domain(custom_domain): f"Please update {custom_domain.domain} DNS on SimpleLogin", render( "transactional/custom-domain-dns-issue.txt.jinja2", + user=user, custom_domain=custom_domain, domain_dns_url=domain_dns_url, ), diff --git a/app/email_handler.py b/app/email_handler.py index 0319cf4..2882e56 100644 --- a/app/email_handler.py +++ b/app/email_handler.py @@ -601,12 +601,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}", render( "transactional/cycle-email.txt.jinja2", + user=user, alias=alias, from_addr=from_addr, refused_email_url=refused_email_url, ), render( "transactional/cycle-email.html", + user=user, alias=alias, from_addr=from_addr, refused_email_url=refused_email_url, @@ -728,12 +730,14 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str f"Your mailbox {mailbox.email} is an alias", render( "transactional/mailbox-invalid.txt.jinja2", + user=mailbox.user, mailbox=mailbox, mailbox_url=mailbox_url, alias=alias, ), render( "transactional/mailbox-invalid.html", + user=mailbox.user, mailbox=mailbox, mailbox_url=mailbox_url, alias=alias, @@ -786,12 +790,14 @@ def forward_email_to_mailbox( f"Your mailbox {mailbox.email} and alias {alias.email} use the same domain", render( "transactional/mailbox-invalid.txt.jinja2", + user=mailbox.user, mailbox=mailbox, mailbox_url=mailbox_url, alias=alias, ), render( "transactional/mailbox-invalid.html", + user=mailbox.user, mailbox=mailbox, mailbox_url=mailbox_url, alias=alias, @@ -1276,6 +1282,7 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str): f"Email sent to {contact.email} contains non reverse-alias addresses", render( "transactional/non-reverse-alias-reply-phase.txt.jinja2", + user=alias.user, destination=contact.email, alias=alias.email, subject=msg[headers.SUBJECT], @@ -1497,6 +1504,7 @@ def handle_unknown_mailbox( f"Attempt to use your alias {alias.email} from {envelope.mail_from}", render( "transactional/reply-must-use-personal-email.txt", + user=user, alias=alias, sender=envelope.mail_from, authorize_address_link=authorize_address_link, @@ -1504,6 +1512,7 @@ def handle_unknown_mailbox( ), render( "transactional/reply-must-use-personal-email.html", + user=user, alias=alias, sender=envelope.mail_from, authorize_address_link=authorize_address_link, @@ -1604,12 +1613,14 @@ def handle_bounce_forward_phase(msg: Message, email_log: EmailLog): f"Alias {alias.email} has been disabled due to multiple bounces", render( "transactional/bounce/automatic-disable-alias.txt", + user=alias.user, alias=alias, refused_email_url=refused_email_url, mailbox_email=mailbox.email, ), render( "transactional/bounce/automatic-disable-alias.html", + user=alias.user, alias=alias, refused_email_url=refused_email_url, mailbox_email=mailbox.email, @@ -1648,6 +1659,7 @@ def handle_bounce_forward_phase(msg: Message, email_log: EmailLog): f"An email sent to {alias.email} cannot be delivered to your mailbox", render( "transactional/bounce/bounced-email.txt.jinja2", + user=alias.user, alias=alias, website_email=contact.website_email, disable_alias_link=disable_alias_link, @@ -1657,6 +1669,7 @@ def handle_bounce_forward_phase(msg: Message, email_log: EmailLog): ), render( "transactional/bounce/bounced-email.html", + user=alias.user, alias=alias, website_email=contact.website_email, disable_alias_link=disable_alias_link, @@ -1749,12 +1762,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 }", render( "transactional/bounce/bounce-email-reply-phase.txt", + user=user, alias=alias, contact=contact, refused_email_url=refused_email_url, ), render( "transactional/bounce/bounce-email-reply-phase.html", + user=user, alias=alias, contact=contact, refused_email_url=refused_email_url, @@ -1817,6 +1832,7 @@ def handle_spam( f"Email from {alias.email} to {contact.website_email} is detected as spam", render( "transactional/spam-email-reply-phase.txt", + user=user, alias=alias, website_email=contact.website_email, disable_alias_link=disable_alias_link, @@ -1824,6 +1840,7 @@ def handle_spam( ), render( "transactional/spam-email-reply-phase.html", + user=user, alias=alias, website_email=contact.website_email, disable_alias_link=disable_alias_link, @@ -1846,6 +1863,7 @@ def handle_spam( f"Email from {contact.website_email} to {alias.email} is detected as spam", render( "transactional/spam-email.txt", + user=user, alias=alias, website_email=contact.website_email, disable_alias_link=disable_alias_link, @@ -1853,6 +1871,7 @@ def handle_spam( ), render( "transactional/spam-email.html", + user=user, alias=alias, website_email=contact.website_email, disable_alias_link=disable_alias_link, @@ -2009,7 +2028,7 @@ def send_no_reply_response(mail_from: str, msg: Message): ALERT_TO_NOREPLY, mailbox.user.email, "Auto: {}".format(msg[headers.SUBJECT] or "No subject"), - render("transactional/noreply.text.jinja2"), + render("transactional/noreply.text.jinja2", user=mailbox.user), ) @@ -2091,6 +2110,7 @@ def handle(envelope: Envelope, msg: Message) -> str: "SimpleLogin shouldn't be used with another email forwarding system", render( "transactional/email-sent-from-reverse-alias.txt.jinja2", + user=user, ), ) diff --git a/app/job_runner.py b/app/job_runner.py index 90b018a..1c3fdd3 100644 --- a/app/job_runner.py +++ b/app/job_runner.py @@ -225,16 +225,15 @@ def process_job(job: Job): user_email = user.email LOG.w("Delete user %s", user) - User.delete(user.id) - Session.commit() - send_email( user_email, "Your SimpleLogin account has been deleted", - render("transactional/account-delete.txt"), - render("transactional/account-delete.html"), + render("transactional/account-delete.txt", user=user), + render("transactional/account-delete.html", user=user), retries=3, ) + User.delete(user.id) + Session.commit() elif job.name == config.JOB_DELETE_MAILBOX: delete_mailbox_job(job) diff --git a/app/migrations/versions/2024_070516_d608b8e48082_.py b/app/migrations/versions/2024_070516_d608b8e48082_.py new file mode 100644 index 0000000..0e46c9f --- /dev/null +++ b/app/migrations/versions/2024_070516_d608b8e48082_.py @@ -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 ### diff --git a/app/oneshot/recalculate_user_flag_alias_create_from_partner.py b/app/oneshot/recalculate_user_flag_alias_create_from_partner.py new file mode 100644 index 0000000..9f71f3e --- /dev/null +++ b/app/oneshot/recalculate_user_flag_alias_create_from_partner.py @@ -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("") diff --git a/app/server.py b/app/server.py index 57e9cf1..ecbe00a 100644 --- a/app/server.py +++ b/app/server.py @@ -542,6 +542,7 @@ def setup_paddle_callback(app: Flask): "SimpleLogin - your subscription is canceled", render( "transactional/subscription-cancel.txt", + user=user, end_date=request.form.get("cancellation_effective_date"), ), ) @@ -722,10 +723,12 @@ def handle_coinbase_event(event) -> bool: "Your SimpleLogin account has been upgraded", render( "transactional/coinbase/new-subscription.txt", + user=user, coinbase_subscription=coinbase_subscription, ), render( "transactional/coinbase/new-subscription.html", + user=user, coinbase_subscription=coinbase_subscription, ), ) @@ -746,10 +749,12 @@ def handle_coinbase_event(event) -> bool: "Your SimpleLogin account has been extended", render( "transactional/coinbase/extend-subscription.txt", + user=user, coinbase_subscription=coinbase_subscription, ), render( "transactional/coinbase/extend-subscription.html", + user=user, coinbase_subscription=coinbase_subscription, ), ) diff --git a/app/static/logo-proton.png b/app/static/logo-proton.png new file mode 100644 index 0000000..a213f55 Binary files /dev/null and b/app/static/logo-proton.png differ diff --git a/app/templates/emails/base.html b/app/templates/emails/base.html index 5ae5af9..aacac3b 100644 --- a/app/templates/emails/base.html +++ b/app/templates/emails/base.html @@ -1,623 +1,8 @@ -{% from "_emailhelpers.html" import render_text, text, render_button, raw_url, grey_section, section %} - - - - - - - - - - - - {{ pre_header }} - - - - - - - +{% endif %} diff --git a/app/templates/emails/base_partner.html b/app/templates/emails/base_partner.html new file mode 100644 index 0000000..d22a3b0 --- /dev/null +++ b/app/templates/emails/base_partner.html @@ -0,0 +1,646 @@ +{% from "_emailhelpers.html" import render_text, text, render_button, raw_url, grey_section, section %} + + + + + + + + + + + + + + + +
+ + + + +
+ + + + + + + + + +
+ + + + + + +
+ + + + + + +
+ + Proton + +
+
+
+ {% block greeting %}{% endblock %} + {% block content %}{% endblock %} + + {% block sub_copy %}{% endblock %} +
+ + +
+
+ + diff --git a/app/templates/emails/base_sl.html b/app/templates/emails/base_sl.html new file mode 100644 index 0000000..5ae5af9 --- /dev/null +++ b/app/templates/emails/base_sl.html @@ -0,0 +1,623 @@ +{% from "_emailhelpers.html" import render_text, text, render_button, raw_url, grey_section, section %} + + + + + + + + + + + + {{ pre_header }} + + + + + + + diff --git a/app/templates/emails/transactional/subscription-end.html b/app/templates/emails/transactional/subscription-end.html index deccfb5..324db2a 100644 --- a/app/templates/emails/transactional/subscription-end.html +++ b/app/templates/emails/transactional/subscription-end.html @@ -6,6 +6,7 @@ {{ render_text("Your subscription will end on " + next_bill_date + ".") }} {{ render_text("When the subscription ends:") }} {{ render_text("- All aliases/domains/directories you have created are kept and continue working normally.") }} + {{ render_text("- You cannot create new reverse aliases.") }} {% 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. {% endcall %} diff --git a/app/templates/emails/transactional/subscription-end.txt b/app/templates/emails/transactional/subscription-end.txt index 0d72abf..84e9281 100644 --- a/app/templates/emails/transactional/subscription-end.txt +++ b/app/templates/emails/transactional/subscription-end.txt @@ -9,6 +9,7 @@ When the subscription ends: - 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 reverse aliases. - 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. - You cannot add new domain or directory. diff --git a/app/templates/emails/transactional/trial-end.html b/app/templates/emails/transactional/trial-end.html index 3cf0170..6dc45f2 100644 --- a/app/templates/emails/transactional/trial-end.html +++ b/app/templates/emails/transactional/trial-end.html @@ -14,6 +14,7 @@ {{ render_text("- You cannot add new domain or directory.") }} {{ 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('You can upgrade today to continue using all these Premium features (and much more coming).') }} {{ render_button("Upgrade your account", URL ~ "/dashboard/pricing") }} diff --git a/app/templates/emails/transactional/trial-end.txt.jinja2 b/app/templates/emails/transactional/trial-end.txt.jinja2 index 789a33c..d2a827b 100644 --- a/app/templates/emails/transactional/trial-end.txt.jinja2 +++ b/app/templates/emails/transactional/trial-end.txt.jinja2 @@ -8,6 +8,7 @@ When the trial ends: - 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 add new domain or directory. +- You cannot create new reverse aliases. - You cannot add new mailbox. - If you enable PGP Encryption, forwarded emails are not encrypted anymore. diff --git a/app/tests/models/test_alias.py b/app/tests/models/test_alias.py index 3354b8b..c11b596 100644 --- a/app/tests/models/test_alias.py +++ b/app/tests/models/test_alias.py @@ -1,5 +1,5 @@ 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 @@ -15,3 +15,17 @@ def test_duplicated_mailbox_is_returned_only_once(): alias_mailbox_id = [mailbox.id for mailbox in alias_mailboxes] assert user.default_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 diff --git a/app/tests/test_email_utils.py b/app/tests/test_email_utils.py index 726ab97..7e133f1 100644 --- a/app/tests/test_email_utils.py +++ b/app/tests/test_email_utils.py @@ -9,6 +9,7 @@ import pytest from app import config from app.config import MAX_ALERT_24H, ROOT_DIR from app.db import Session +from app.email import headers from app.email_utils import ( get_email_domain_part, can_create_directory_for_address, @@ -354,6 +355,33 @@ def test_is_valid_email(): 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(): msg = email.message_from_string( """Content-Type: text/plain; charset=us-ascii