From 0812a390322a72baa10907d68ee68bef54f44a0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Casaj=C3=BAs?= Date: Wed, 1 Apr 2026 17:31:07 +0200 Subject: [PATCH 1/4] User partner no reply email for users with partner users # Conflicts: # email_handler.py --- app/config.py | 12 +- app/email_utils.py | 78 +++++++++---- app/jobs/export_user_data_job.py | 10 +- app/models.py | 9 +- commands/emulate_dummy_load.py | 4 +- commands/replace_noreply_in_contacts.py | 13 ++- email_handler.py | 141 +++++++++++------------- tests/test_email_handler.py | 32 +++--- tests/test_email_utils.py | 58 +++++++++- tests/test_models.py | 16 +-- 10 files changed, 233 insertions(+), 140 deletions(-) diff --git a/app/config.py b/app/config.py index a30054b01..85cf6f1b5 100644 --- a/app/config.py +++ b/app/config.py @@ -3,6 +3,7 @@ import socket import string from ast import literal_eval +from email.utils import parseaddr as _parseaddr from typing import Callable, List, Optional from urllib.parse import urlparse @@ -446,10 +447,15 @@ def get_env_csv(env_var: str, default: Optional[str]) -> list[str]: PGP_SIGNER = os.environ.get("PGP_SIGNER") # emails that have empty From address is sent from this special reverse-alias -NOREPLY = os.environ.get("NOREPLY", f"noreply@{EMAIL_DOMAIN}") +NOREPLY = os.environ.get("NOREPLY", f'"SimpleLogin (noreply)" ') +PARTNER_NOREPLY = os.environ.get("PARTNER_NOREPLY", NOREPLY) -# list of no reply addresses -NOREPLIES = sl_getenv("NOREPLIES", list) or [NOREPLY] +# bare email addresses extracted from the full formatted addresses, used for routing +NOREPLY_EMAIL = _parseaddr(NOREPLY)[1] +PARTNER_NOREPLY_EMAIL = _parseaddr(PARTNER_NOREPLY)[1] + +# list of no reply addresses (bare emails for routing comparisons) +NOREPLIES = sl_getenv("NOREPLIES", list) or list({NOREPLY_EMAIL, PARTNER_NOREPLY_EMAIL}) ALIAS_LIMIT = os.environ.get("ALIAS_LIMIT") or "100/day;50/hour;5/minute" diff --git a/app/email_utils.py b/app/email_utils.py index c4f4fbcf3..c44b66327 100644 --- a/app/email_utils.py +++ b/app/email_utils.py @@ -1,30 +1,28 @@ +from email import policy, message_from_bytes, message_from_string + +import arrow import base64 +import binascii +import dkim import enum import hmac import json import os import quopri import random -import uuid -from copy import deepcopy -from email import policy, message_from_bytes, message_from_string -from email.header import decode_header, Header -from email.message import Message, EmailMessage -from email.mime.multipart import MIMEMultipart -from email.mime.text import MIMEText -from email.utils import make_msgid, formatdate, formataddr -from smtplib import SMTP, SMTPException -from typing import Tuple, List, Optional, Union - -import arrow -import binascii -import dkim import re2 as re import sentry_sdk import spf import time +import uuid from aiosmtpd.smtp import Envelope from cachetools import cached, TTLCache +from copy import deepcopy +from email.header import decode_header, Header +from email.message import Message, EmailMessage +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.utils import make_msgid, formatdate, formataddr, parseaddr from email_validator import ( validate_email, EmailNotValidError, @@ -34,7 +32,9 @@ from flanker.addresslib.address import EmailAddress from flask_login import current_user from jinja2 import Environment, FileSystemLoader +from smtplib import SMTP, SMTPException from sqlalchemy import func +from typing import Tuple, List, Optional, Union from app import config from app.db import Session @@ -58,6 +58,7 @@ VerpType, available_sl_email, ForbiddenMxIp, + PartnerUser, ) from app.utils import ( random_string, @@ -71,6 +72,25 @@ VERP_HMAC_ALGO = "sha3-224" +def get_noreply_address(user=None) -> str: + """Full RFC 5322 formatted noreply address, e.g. '"SimpleLogin (noreply)" '""" + if user and PartnerUser.get_by(user_id=user.id): + return config.PARTNER_NOREPLY + return config.NOREPLY + + +def get_noreply_email(user=None) -> str: + """Bare noreply email address, e.g. 'noreply@sl.io'""" + if user and PartnerUser.get_by(user_id=user.id): + return config.PARTNER_NOREPLY_EMAIL + return config.NOREPLY_EMAIL + + +def get_noreply_domain(user=None) -> str: + """Domain part of the noreply email, e.g. 'sl.io'""" + return get_email_domain_part(get_noreply_email(user)) + + 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)) @@ -111,6 +131,7 @@ def send_welcome_email(user): render("com/welcome.html", user=user, alias=alias), unsubscribe_link, via_email, + user=user, ) @@ -121,6 +142,7 @@ def send_trial_end_soon_email(user): render("transactional/trial-end.txt.jinja2", user=user), render("transactional/trial-end.html", user=user), ignore_smtp_error=True, + user=user, ) @@ -142,6 +164,7 @@ def send_activation_email(user: User, activation_link): activation_link=activation_link, email=user.email, ), + user=user, ) @@ -161,6 +184,7 @@ def send_reset_password_email(user: User, reset_password_link): user=user, reset_password_link=reset_password_link, ), + user=user, ) @@ -184,6 +208,7 @@ def send_change_email(user: User, new_email, link): new_email=new_email, current_email=user.email, ), + user=user, ) @@ -227,6 +252,7 @@ def send_test_email_alias(user: User, email: str): name=user.name, alias=email, ), + user=user, ) @@ -251,6 +277,7 @@ def send_cannot_create_directory_alias(user: User, alias_address, directory_name alias=alias_address, directory=directory_name, ), + user=user, ) @@ -303,6 +330,7 @@ def send_cannot_create_domain_alias(user: User, alias, domain): alias=alias, domain=domain, ), + user=user, ) @@ -318,13 +346,25 @@ def send_email( ignore_smtp_error=False, from_name=None, from_addr=None, + user=None, ): to_email = sanitize_email(to_email) LOG.d("send email to %s, subject '%s'", to_email, subject) - from_name = from_name or config.NOREPLY - from_addr = from_addr or config.NOREPLY + parsed_addr = parseaddr(get_noreply_address(user)) + if ( + not parsed_addr + or len(parsed_addr) < 2 + or not parsed_addr[0] + or not parsed_addr[1] + ): + default_email = get_noreply_email(user) + default_name = default_email + else: + (default_name, default_email) = parsed_addr + from_name = from_name or default_name + from_addr = from_addr or default_email from_domain = get_email_domain_part(from_addr) if html: @@ -412,11 +452,11 @@ def send_email_with_rate_control( if ignore_smtp_error: try: - send_email(to_email, subject, plaintext, html, retries=retries) + send_email(to_email, subject, plaintext, html, retries=retries, user=user) except SMTPException: LOG.w("Cannot send email to %s, subject %s", to_email, subject) else: - send_email(to_email, subject, plaintext, html, retries=retries) + send_email(to_email, subject, plaintext, html, retries=retries, user=user) return True @@ -450,7 +490,7 @@ def send_email_at_most_times( SentAlert.create(user_id=user.id, alert_type=alert_type, to_email=to_email) Session.commit() - send_email(to_email, subject, plaintext, html) + send_email(to_email, subject, plaintext, html, user=user) return True diff --git a/app/jobs/export_user_data_job.py b/app/jobs/export_user_data_job.py index 91057dad8..6bcd2dec0 100644 --- a/app/jobs/export_user_data_job.py +++ b/app/jobs/export_user_data_job.py @@ -11,7 +11,6 @@ import arrow import sqlalchemy -from app import config from app.constants import JobType from app.db import Session from app.email import headers @@ -19,7 +18,8 @@ generate_verp_email, render, add_dkim_signature, - get_email_domain_part, + get_noreply_address, + get_noreply_domain, ) from app.mail_sender import sl_sendmail from app.models import ( @@ -139,7 +139,7 @@ def run(self): msg = MIMEMultipart() msg[headers.SUBJECT] = "Your SimpleLogin data" - msg[headers.FROM] = f'"SimpleLogin (noreply)" <{config.NOREPLY}>' + msg[headers.FROM] = get_noreply_address(self._user) msg[headers.TO] = to_email msg.attach( MIMEText(render("transactional/user-report.html", user=self._user), "html") @@ -152,7 +152,7 @@ def run(self): msg.attach(attachment) # add DKIM - email_domain = config.NOREPLY[config.NOREPLY.find("@") + 1 :] + email_domain = get_noreply_domain(self._user) add_dkim_signature(msg, email_domain) transaction = TransactionalEmail.create(email=to_email, commit=True) @@ -160,7 +160,7 @@ def run(self): generate_verp_email( VerpType.transactional, transaction.id, - get_email_domain_part(config.NOREPLY), + get_noreply_domain(self._user), ), to_email, msg, diff --git a/app/models.py b/app/models.py index c0a1a3e83..5816e1b7a 100644 --- a/app/models.py +++ b/app/models.py @@ -1,5 +1,6 @@ from __future__ import annotations +import arrow import base64 import dataclasses import enum @@ -8,11 +9,8 @@ import os import random import secrets -import uuid -from typing import List, Tuple, Optional, Union - -import arrow import sqlalchemy as sa +import uuid from arrow import Arrow from email_validator import validate_email from flanker.addresslib import address @@ -28,6 +26,7 @@ from sqlalchemy.orm.exc import ObjectDeletedError from sqlalchemy.sql import and_ from sqlalchemy_utils import ArrowType +from typing import List, Tuple, Optional, Union from app import config, rate_limiter from app import s3 @@ -2113,7 +2112,7 @@ def create(cls, **kw): website_email = sanitize_email(website_email) # make sure contact.website_email isn't a reverse alias - if website_email != config.NOREPLY: + if website_email not in config.NOREPLIES: orig_contact = Contact.get_by(reply_email=website_email) if orig_contact: raise CannotCreateContactForReverseAlias(str(orig_contact)) diff --git a/commands/emulate_dummy_load.py b/commands/emulate_dummy_load.py index 8ab5f5ebb..a4aae9f3c 100644 --- a/commands/emulate_dummy_load.py +++ b/commands/emulate_dummy_load.py @@ -10,8 +10,8 @@ from app.db import Session parser = argparse.ArgumentParser( - prog=f"Replace {config.NOREPLY}", - description=f"Replace {config.NOREPLY} from contacts reply email", + prog=f"Replace {config.NOREPLY_EMAIL}", + description=f"Replace {config.NOREPLY_EMAIL} from contacts reply email", ) args = parser.parse_args() diff --git a/commands/replace_noreply_in_contacts.py b/commands/replace_noreply_in_contacts.py index 13e667a31..ddb3be1a0 100644 --- a/commands/replace_noreply_in_contacts.py +++ b/commands/replace_noreply_in_contacts.py @@ -3,15 +3,16 @@ import time -from app import config -from app.email_utils import generate_reply_email +from app.email_utils import generate_reply_email, get_noreply_email from app.email_validation import is_valid_email from app.models import Alias from app.db import Session +_noreply_email = get_noreply_email() + parser = argparse.ArgumentParser( - prog=f"Replace {config.NOREPLY}", - description=f"Replace {config.NOREPLY} from contacts reply email", + prog=f"Replace {_noreply_email}", + description=f"Replace {_noreply_email} from contacts reply email", ) args = parser.parse_args() @@ -21,10 +22,10 @@ start_time = time.time() step = 100 last_id = 0 -print(f"Replacing contacts with reply_email={config.NOREPLY}") +print(f"Replacing contacts with reply_email={_noreply_email}") while True: rows = Session.execute( - el_query, {"last_id": last_id, "reply_email": config.NOREPLY, "step": step} + el_query, {"last_id": last_id, "reply_email": _noreply_email, "step": step} ) loop_updated = 0 for row in rows: diff --git a/email_handler.py b/email_handler.py index a06463ada..27ee6a865 100644 --- a/email_handler.py +++ b/email_handler.py @@ -61,32 +61,6 @@ change_alias_status, get_alias_recipient_name, ) -from app.config import ( - EMAIL_DOMAIN, - URL, - UNSUBSCRIBER, - LOAD_PGP_EMAIL_HANDLER, - ENFORCE_SPF, - ALERT_REVERSE_ALIAS_UNKNOWN_MAILBOX, - ALERT_BOUNCE_EMAIL, - ALERT_SPAM_EMAIL, - SPAMASSASSIN_HOST, - MAX_SPAM_SCORE, - MAX_REPLY_PHASE_SPAM_SCORE, - ALERT_SEND_EMAIL_CYCLE, - ALERT_MAILBOX_IS_ALIAS, - PGP_SENDER_PRIVATE_KEY, - ALERT_BOUNCE_EMAIL_REPLY_PHASE, - NOREPLY, - TRANSACTIONAL_BOUNCE_PREFIX, - TRANSACTIONAL_BOUNCE_SUFFIX, - ENABLE_SPAM_ASSASSIN, - POSTMASTER, - OLD_UNSUBSCRIBER, - ALERT_FROM_ADDRESS_IS_REVERSE_ALIAS, - ALERT_TO_NOREPLY, - MAX_EMAIL_FORWARD_RECIPIENTS, -) from app.db import Session from app.email import status, headers from app.email.checks import check_recipient_limit @@ -447,7 +421,7 @@ def prepare_pgp_message( first.set_payload("Version: 1") msg.attach(first) - if can_sign and PGP_SENDER_PRIVATE_KEY: + if can_sign and config.PGP_SENDER_PRIVATE_KEY: LOG.d("Sign msg") clone_msg = sign_msg(clone_msg, ctx) @@ -546,7 +520,7 @@ def handle_email_sent_to_ourself(alias, from_addr: str, msg: Message, user): send_email_at_most_times( user, - ALERT_SEND_EMAIL_CYCLE, + config.ALERT_SEND_EMAIL_CYCLE, from_addr, f"Email sent to {alias.email} from its own mailbox {from_addr}", render( @@ -713,10 +687,10 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str ) mailbox.verified = False Session.commit() - mailbox_url = f"{URL}/dashboard/mailbox/{mailbox.id}/" + mailbox_url = f"{config.URL}/dashboard/mailbox/{mailbox.id}/" send_email_with_rate_control( user, - ALERT_MAILBOX_IS_ALIAS, + config.ALERT_MAILBOX_IS_ALIAS, user.email, f"Your mailbox {mailbox.email} is an alias", render( @@ -779,10 +753,10 @@ def forward_email_to_mailbox( alias, mailbox, ) - mailbox_url = f"{URL}/dashboard/mailbox/{mailbox.id}/" + mailbox_url = f"{config.URL}/dashboard/mailbox/{mailbox.id}/" send_email_with_rate_control( user, - ALERT_MAILBOX_IS_ALIAS, + config.ALERT_MAILBOX_IS_ALIAS, user.email, f"Your mailbox {mailbox.email} and alias {alias.email} use the same domain", render( @@ -816,12 +790,12 @@ def forward_email_to_mailbox( ) LOG.d("Create %s for %s, %s, %s", email_log, contact, user, mailbox) - if ENABLE_SPAM_ASSASSIN: + if config.ENABLE_SPAM_ASSASSIN: # Spam check spam_status = "" is_spam = False - if SPAMASSASSIN_HOST: + if config.SPAMASSASSIN_HOST: start = time.time() spam_score, spam_report = get_spam_score(msg, email_log) LOG.d( @@ -836,7 +810,7 @@ def forward_email_to_mailbox( Session.commit() if (user.max_spam_score and spam_score > user.max_spam_score) or ( - not user.max_spam_score and spam_score > MAX_SPAM_SCORE + not user.max_spam_score and spam_score > config.MAX_SPAM_SCORE ): is_spam = True # only set the spam report for spam @@ -953,7 +927,7 @@ def forward_email_to_mailbox( LOG.d("Reply-To header, new:%s, old:%s", new_reply_to_header, original_reply_to) # Check recipient limit - if not check_recipient_limit(msg, MAX_EMAIL_FORWARD_RECIPIENTS): + if not check_recipient_limit(msg, config.MAX_EMAIL_FORWARD_RECIPIENTS): return False, status.E526 # replace CC & To emails by reverse-alias for all emails that are not alias @@ -972,7 +946,7 @@ def forward_email_to_mailbox( # add List-Unsubscribe header msg = UnsubscribeGenerator().add_header_to_message(alias, contact, msg) - add_dkim_signature(msg, EMAIL_DOMAIN) + add_dkim_signature(msg, config.EMAIL_DOMAIN) LOG.d( "Forward mail from %s to %s, mail_options:%s, rcpt_options:%s ", @@ -1064,7 +1038,7 @@ def handle_reply( reply_domain = get_email_domain_part(reply_email) # reply_email must end with EMAIL_DOMAIN or a domain that can be used as reverse alias domain - if not reply_email.endswith(EMAIL_DOMAIN): + if not reply_email.endswith(config.EMAIL_DOMAIN): sl_domain: SLDomain = SLDomain.get_by(domain=reply_domain) if sl_domain is None: LOG.w(f"Reply email {reply_email} has wrong domain") @@ -1133,7 +1107,11 @@ def handle_reply( LOG.i(f"User {user} tried to send a mail from admin disabled mailbox {mailbox}") return False, status.E207 - if ENFORCE_SPF and mailbox.force_spf and not alias.disable_email_spoofing_check: + if ( + config.ENFORCE_SPF + and mailbox.force_spf + and not alias.disable_email_spoofing_check + ): if not spf_pass(envelope, mailbox, user, alias, contact.website_email, msg): # cannot use 4** here as sender will retry. # cannot use 5** because that generates bounce report @@ -1151,12 +1129,12 @@ def handle_reply( LOG.d("Create %s for %s, %s, %s", email_log, contact, user, mailbox) # Spam check - if ENABLE_SPAM_ASSASSIN: + if config.ENABLE_SPAM_ASSASSIN: spam_status = "" is_spam = False # do not use user.max_spam_score here - if SPAMASSASSIN_HOST: + if config.SPAMASSASSIN_HOST: start = time.time() spam_score, spam_report = get_spam_score(msg, email_log) LOG.d( @@ -1168,13 +1146,13 @@ def handle_reply( spam_report, ) email_log.spam_score = spam_score - if spam_score > MAX_REPLY_PHASE_SPAM_SCORE: + if spam_score > config.MAX_REPLY_PHASE_SPAM_SCORE: is_spam = True # only set the spam report for spam email_log.spam_report = spam_report else: is_spam, spam_status = get_spam_info( - msg, max_score=MAX_REPLY_PHASE_SPAM_SCORE + msg, max_score=config.MAX_REPLY_PHASE_SPAM_SCORE ) if is_spam: @@ -1491,12 +1469,12 @@ def handle_unknown_mailbox( ) authorize_address_link = ( - f"{URL}/dashboard/mailbox/{alias.mailbox_id}/#authorized-address" + f"{config.URL}/dashboard/mailbox/{alias.mailbox_id}/#authorized-address" ) mailbox_emails = [mailbox.email for mailbox in alias.mailboxes] send_email_with_rate_control( user, - ALERT_REVERSE_ALIAS_UNKNOWN_MAILBOX, + config.ALERT_REVERSE_ALIAS_UNKNOWN_MAILBOX, user.email, f"Attempt to use your alias {alias.email} from {envelope.mail_from}", render( @@ -1585,7 +1563,9 @@ def handle_bounce_forward_phase(msg: Message, email_log: EmailLog): email_log.bounced_mailbox_id = mailbox.id Session.commit() - refused_email_url = f"{URL}/dashboard/refused_email?highlight_id={email_log.id}" + refused_email_url = ( + f"{config.URL}/dashboard/refused_email?highlight_id={email_log.id}" + ) alias_will_be_disabled, reason = should_disable(alias) if alias_will_be_disabled: @@ -1608,7 +1588,7 @@ def handle_bounce_forward_phase(msg: Message, email_log: EmailLog): send_email_with_rate_control( user, - ALERT_BOUNCE_EMAIL, + config.ALERT_BOUNCE_EMAIL, user.email, f"Alias {alias.email} has been disabled due to multiple bounces", render( @@ -1635,8 +1615,8 @@ def handle_bounce_forward_phase(msg: Message, email_log: EmailLog): contact, alias, ) - disable_alias_link = f"{URL}/dashboard/unsubscribe/{alias.id}" - block_sender_link = f"{URL}/dashboard/alias_contact_manager/{alias.id}?highlight_contact_id={contact.id}" + disable_alias_link = f"{config.URL}/dashboard/unsubscribe/{alias.id}" + block_sender_link = f"{config.URL}/dashboard/alias_contact_manager/{alias.id}?highlight_contact_id={contact.id}" Notification.create( user_id=user.id, @@ -1654,7 +1634,7 @@ def handle_bounce_forward_phase(msg: Message, email_log: EmailLog): ) send_email_with_rate_control( user, - ALERT_BOUNCE_EMAIL, + config.ALERT_BOUNCE_EMAIL, user.email, f"An email sent to {alias.email} cannot be delivered to your mailbox", render( @@ -1737,7 +1717,9 @@ def handle_bounce_reply_phase(envelope, msg: Message, email_log: EmailLog): Session.commit() - refused_email_url = f"{URL}/dashboard/refused_email?highlight_id={email_log.id}" + refused_email_url = ( + f"{config.URL}/dashboard/refused_email?highlight_id={email_log.id}" + ) LOG.d( "Inform user %s about bounced email sent by %s to %s", @@ -1758,7 +1740,7 @@ def handle_bounce_reply_phase(envelope, msg: Message, email_log: EmailLog): ) send_email_with_rate_control( user, - ALERT_BOUNCE_EMAIL_REPLY_PHASE, + config.ALERT_BOUNCE_EMAIL_REPLY_PHASE, mailbox.email, f"Email cannot be sent to {contact.email} from your alias {alias.email}", render( @@ -1815,8 +1797,10 @@ def handle_spam( LOG.d("Create spam email %s", refused_email) - refused_email_url = f"{URL}/dashboard/refused_email?highlight_id={email_log.id}" - disable_alias_link = f"{URL}/dashboard/unsubscribe/{alias.id}" + refused_email_url = ( + f"{config.URL}/dashboard/refused_email?highlight_id={email_log.id}" + ) + disable_alias_link = f"{config.URL}/dashboard/unsubscribe/{alias.id}" if is_reply: LOG.d( @@ -1829,7 +1813,7 @@ def handle_spam( ) send_email_with_rate_control( user, - ALERT_SPAM_EMAIL, + config.ALERT_SPAM_EMAIL, mailbox.email, f"Email from {alias.email} to {contact.website_email} is detected as spam", render( @@ -1860,7 +1844,7 @@ def handle_spam( ) send_email_with_rate_control( user, - ALERT_SPAM_EMAIL, + config.ALERT_SPAM_EMAIL, mailbox.email, f"Email from {contact.website_email} to {alias.email} is detected as spam", render( @@ -2020,17 +2004,17 @@ def should_ignore(mail_from: str, rcpt_tos: List[str]) -> bool: return False -def send_no_reply_response(mail_from: str, msg: Message): +def send_no_reply_response(rcpt_to: str, mail_from: str, msg: Message): mailbox = Mailbox.get_by(email=mail_from) if not mailbox: - LOG.d("Unknown sender. Skipping reply from {}".format(NOREPLY)) + LOG.d("Unknown sender. Skipping reply from {}".format(rcpt_to)) return if not mailbox.user.is_active(): LOG.d(f"User {mailbox.user} is soft-deleted. Skipping sending reply response") return send_email_at_most_times( mailbox.user, - ALERT_TO_NOREPLY, + config.ALERT_TO_NOREPLY, mailbox.user.email, "Auto: {}".format(msg[headers.SUBJECT] or "No subject"), render("transactional/noreply.text.jinja2", user=mailbox.user), @@ -2111,7 +2095,7 @@ def handle(envelope: Envelope, msg: Message) -> str: user = contact.user send_email_at_most_times( user, - ALERT_FROM_ADDRESS_IS_REVERSE_ALIAS, + config.ALERT_FROM_ADDRESS_IS_REVERSE_ALIAS, user.email, "SimpleLogin shouldn't be used with another email forwarding system", render( @@ -2123,7 +2107,9 @@ def handle(envelope: Envelope, msg: Message) -> str: # endregion # unsubscribe request - if UNSUBSCRIBER and (rcpt_tos == [UNSUBSCRIBER] or rcpt_tos == [OLD_UNSUBSCRIBER]): + if config.UNSUBSCRIBER and ( + rcpt_tos == [config.UNSUBSCRIBER] or rcpt_tos == [config.OLD_UNSUBSCRIBER] + ): LOG.d("Handle unsubscribe request from %s", mail_from) return UnsubscribeHandler().handle_unsubscribe_from_message(envelope, msg) @@ -2133,8 +2119,8 @@ def handle(envelope: Envelope, msg: Message) -> str: # sent to transactional VERP. Either bounce emails or out-of-office if ( len(rcpt_tos) == 1 - and rcpt_tos[0].startswith(TRANSACTIONAL_BOUNCE_PREFIX) - and rcpt_tos[0].endswith(TRANSACTIONAL_BOUNCE_SUFFIX) + and rcpt_tos[0].startswith(config.TRANSACTIONAL_BOUNCE_PREFIX) + and rcpt_tos[0].endswith(config.TRANSACTIONAL_BOUNCE_SUFFIX) ) or (verp_info and verp_info[0] == VerpType.transactional): if is_bounce(envelope, msg): handle_transactional_bounce( @@ -2150,7 +2136,11 @@ def handle(envelope: Envelope, msg: Message) -> str: raise VERPTransactional # sent to forward VERP, can be either bounce or out-of-office - if verp_info and verp_info[0] == VerpType.bounce_forward: + if ( + len(rcpt_tos) == 1 + and rcpt_tos[0].startswith(BOUNCE_PREFIX) + and rcpt_tos[0].endswith(BOUNCE_SUFFIX) + ) or (verp_info and verp_info[0] == VerpType.bounce_forward): email_log_id = (verp_info and verp_info[1]) or parse_id_from_bounce(rcpt_tos[0]) email_log = EmailLog.get(email_log_id) @@ -2166,7 +2156,11 @@ def handle(envelope: Envelope, msg: Message) -> str: raise VERPForward # sent to reply VERP, can be either bounce or out-of-office - if len(rcpt_tos) == 1 and verp_info and verp_info[0] == VerpType.bounce_reply: + if ( + len(rcpt_tos) == 1 + and rcpt_tos[0].startswith(f"{BOUNCE_PREFIX_FOR_REPLY_PHASE}+") + or (verp_info and verp_info[0] == VerpType.bounce_reply) + ): email_log_id = (verp_info and verp_info[1]) or parse_id_from_bounce(rcpt_tos[0]) email_log = EmailLog.get(email_log_id) @@ -2188,10 +2182,9 @@ def handle(envelope: Envelope, msg: Message) -> str: verp_info = get_verp_info_from_email(mail_from) if ( len(rcpt_tos) == 1 - and verp_info - and verp_info[0] == VerpType.bounce_forward - and is_bounce(envelope, msg) - ): + and mail_from.startswith(BOUNCE_PREFIX) + and mail_from.endswith(BOUNCE_SUFFIX) + ) or (verp_info and verp_info[0] == VerpType.bounce_forward): email_log_id = (verp_info and verp_info[1]) or parse_id_from_bounce(mail_from) email_log = EmailLog.get(email_log_id) alias = Alias.get_by(email=rcpt_tos[0]) @@ -2209,7 +2202,7 @@ def handle(envelope: Envelope, msg: Message) -> str: if ( len(rcpt_tos) == 1 and mail_from == "staff@hotmail.com" - and rcpt_tos[0] == POSTMASTER + and rcpt_tos[0] == config.POSTMASTER ): LOG.w("Handle hotmail complaint") @@ -2220,7 +2213,7 @@ def handle(envelope: Envelope, msg: Message) -> str: if ( len(rcpt_tos) == 1 and mail_from == "feedback@arf.mail.yahoo.com" - and rcpt_tos[0] == POSTMASTER + and rcpt_tos[0] == config.POSTMASTER ): LOG.w("Handle yahoo complaint") @@ -2271,8 +2264,8 @@ def handle(envelope: Envelope, msg: Message) -> str: nb_rcpt_tos = len(rcpt_tos) for rcpt_index, rcpt_to in enumerate(rcpt_tos): if rcpt_to in config.NOREPLIES: - LOG.i("email sent to {} address from {}".format(NOREPLY, mail_from)) - send_no_reply_response(mail_from, msg) + LOG.i("email sent to {} address from {}".format(rcpt_to, mail_from)) + send_no_reply_response(rcpt_to, mail_from, msg) return status.E200 # create a copy of msg for each recipient except the last one @@ -2489,7 +2482,7 @@ def main(port: int): LOG.d("Start mail controller %s %s", controller.hostname, controller.port) send_version_event("email_handler") - if LOAD_PGP_EMAIL_HANDLER: + if config.LOAD_PGP_EMAIL_HANDLER: LOG.w("LOAD PGP keys") load_pgp_public_keys() diff --git a/tests/test_email_handler.py b/tests/test_email_handler.py index 1e37e0704..2280de3e3 100644 --- a/tests/test_email_handler.py +++ b/tests/test_email_handler.py @@ -1,20 +1,19 @@ +import arrow +import pytest import random +from aiosmtpd.smtp import Envelope from email.message import EmailMessage +from email.mime.base import MIMEBase from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText -from email.mime.base import MIMEBase from typing import List -import arrow -import pytest -from aiosmtpd.smtp import Envelope - import email_handler from app import config from app.config import EMAIL_DOMAIN, ALERT_DMARC_FAILED_REPLY_PHASE from app.db import Session from app.email import headers, status -from app.email_utils import generate_verp_email +from app.email_utils import generate_verp_email, get_noreply_email from app.mail_sender import mail_sender from app.models import ( Alias, @@ -220,7 +219,7 @@ def test_email_sent_to_noreply(flask_client): msg = EmailMessage() envelope = Envelope() envelope.mail_from = "from@domain.lan" - envelope.rcpt_tos = [config.NOREPLY] + envelope.rcpt_tos = [get_noreply_email()] result = email_handler.handle(envelope, msg) assert result == status.E200 @@ -229,16 +228,19 @@ def test_email_sent_to_noreplies(flask_client): msg = EmailMessage() envelope = Envelope() envelope.mail_from = "from@domain.lan" + original_noreplies = config.NOREPLIES config.NOREPLIES = ["other-no-reply@sl.lan"] + try: + envelope.rcpt_tos = ["other-no-reply@sl.lan"] + result = email_handler.handle(envelope, msg) + assert result == status.E200 - envelope.rcpt_tos = ["other-no-reply@sl.lan"] - result = email_handler.handle(envelope, msg) - assert result == status.E200 - - # NOREPLY isn't used anymore - envelope.rcpt_tos = [config.NOREPLY] - result = email_handler.handle(envelope, msg) - assert result == status.E515 + # default NOREPLY isn't in NOREPLIES anymore — should not be treated as noreply + envelope.rcpt_tos = [get_noreply_email()] + result = email_handler.handle(envelope, msg) + assert result != status.E200 + finally: + config.NOREPLIES = original_noreplies def test_references_header(flask_client): diff --git a/tests/test_email_utils.py b/tests/test_email_utils.py index 3ac205724..5354ce22a 100644 --- a/tests/test_email_utils.py +++ b/tests/test_email_utils.py @@ -1,13 +1,13 @@ +import arrow import email import os +import pytest from email.message import EmailMessage +from email.mime.base import MIMEBase from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText -from email.mime.base import MIMEBase from email.utils import formataddr - -import arrow -import pytest +from unittest.mock import patch from app import config from app.config import MAX_ALERT_24H, ROOT_DIR @@ -43,6 +43,9 @@ is_invalid_mailbox_domain, generate_verp_email, get_verp_info_from_email, + get_noreply_address, + get_noreply_email, + get_noreply_domain, sl_formataddr, ) from app.email_validation import is_valid_email, normalize_reply_email @@ -65,6 +68,7 @@ login, load_eml_file, create_new_user, + create_partner_linked_user, random_email, random_domain, random_token, @@ -1086,3 +1090,49 @@ def test_remove_sender_pgp_key_attachment_nested_multipart(): inner_result = result.get_payload()[0] assert len(inner_result.get_payload()) == 1 assert inner_result.get_payload()[0].get_content_type() == "text/plain" + + +def test_get_noreply_email_no_user(flask_client): + assert get_noreply_email() == config.NOREPLY_EMAIL + assert "@" in get_noreply_email() + assert "<" not in get_noreply_email() + + +def test_get_noreply_email_non_partner_user(flask_client): + user = create_new_user() + assert get_noreply_email(user) == config.NOREPLY_EMAIL + + +def test_get_noreply_email_partner_user_uses_partner_noreply(flask_client): + user, _ = create_partner_linked_user() + with patch.object(config, "PARTNER_NOREPLY_EMAIL", "partner-noreply@partner.lan"): + assert get_noreply_email(user) == "partner-noreply@partner.lan" + + +def test_get_noreply_address_returns_formatted(flask_client): + with patch.object(config, "NOREPLY", "Leo "): + addr = get_noreply_address() + assert "Leo " == addr + + +def test_get_noreply_domain_no_user(flask_client): + expected_domain = "asdf.asdf.asdf.acom" + with patch.object(config, "NOREPLY_EMAIL", f"aasd.saedf.asdf@{expected_domain}"): + domain = get_noreply_domain() + assert expected_domain == domain + + +def test_get_noreply_address_partner_user(flask_client): + user, _ = create_partner_linked_user() + with patch.object( + config, "PARTNER_NOREPLY", '"Partner (noreply)" ' + ): + addr = get_noreply_address(user) + assert addr == '"Partner (noreply)" ' + + +def test_get_noreply_domain_partner_user(flask_client): + user, _ = create_partner_linked_user() + with patch.object(config, "PARTNER_NOREPLY_EMAIL", "partner-noreply@partner.lan"): + domain = get_noreply_domain(user) + assert domain == "partner.lan" diff --git a/tests/test_models.py b/tests/test_models.py index 5ea11d963..c5536e2e0 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -4,7 +4,8 @@ import arrow import pytest -from app.config import EMAIL_DOMAIN, MAX_NB_EMAIL_FREE_PLAN, NOREPLY +from app.config import EMAIL_DOMAIN, MAX_NB_EMAIL_FREE_PLAN +from app.email_utils import get_noreply_email from app.db import Session from app.email_utils import parse_full_address, generate_reply_email from app.models import ( @@ -300,24 +301,25 @@ def test_user_get_subscription_grace_period(flask_client): def test_create_contact_for_noreply(flask_client): user = create_new_user() alias = Alias.filter(Alias.user_id == user.id).first() + noreply_email = get_noreply_email() - # create a contact with NOREPLY as reply_email + # create a contact with noreply as reply_email Contact.create( user_id=user.id, alias_id=alias.id, website_email=f"{random.random()}@contact.lan", - reply_email=NOREPLY, + reply_email=noreply_email, commit=True, ) - # create a contact for NOREPLY shouldn't raise CannotCreateContactForReverseAlias + # create a contact for noreply shouldn't raise CannotCreateContactForReverseAlias contact = Contact.create( user_id=user.id, alias_id=alias.id, - website_email=NOREPLY, - reply_email=generate_reply_email(NOREPLY, alias), + website_email=noreply_email, + reply_email=generate_reply_email(noreply_email, alias), ) - assert contact.website_email == NOREPLY + assert contact.website_email == noreply_email def test_user_can_send_receive(): From 88252702e8e113e7d3f9fcaf51852d6e9d39d9d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Casaj=C3=BAs?= Date: Thu, 2 Apr 2026 14:50:55 +0200 Subject: [PATCH 2/4] use created_by_partner --- app/email_utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/email_utils.py b/app/email_utils.py index c44b66327..bfd986760 100644 --- a/app/email_utils.py +++ b/app/email_utils.py @@ -74,7 +74,7 @@ def get_noreply_address(user=None) -> str: """Full RFC 5322 formatted noreply address, e.g. '"SimpleLogin (noreply)" '""" - if user and PartnerUser.get_by(user_id=user.id): + if user and user.created_by_partner: return config.PARTNER_NOREPLY return config.NOREPLY From eb76cf54ea3b65e2a98ae94f5ed9510ef6f57f1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Casaj=C3=BAs?= Date: Wed, 8 Apr 2026 14:06:28 +0200 Subject: [PATCH 3/4] Revert verp --- email_handler.py | 44 ++++++++++++++------------------------------ 1 file changed, 14 insertions(+), 30 deletions(-) diff --git a/email_handler.py b/email_handler.py index 27ee6a865..8c4f45ec4 100644 --- a/email_handler.py +++ b/email_handler.py @@ -31,29 +31,29 @@ """ -from email import encoders - import argparse import email -import newrelic.agent -import sentry_sdk -import time import uuid -from aiosmtpd.controller import Controller -from aiosmtpd.smtp import Envelope +from email import encoders from email.encoders import encode_noop from email.message import Message from email.mime.application import MIMEApplication from email.mime.multipart import MIMEMultipart from email.utils import make_msgid, formatdate, getaddresses +from io import BytesIO +from smtplib import SMTPRecipientsRefused, SMTPServerDisconnected +from typing import List, Tuple, Optional, Set + +import newrelic.agent +import sentry_sdk +import time +from aiosmtpd.controller import Controller +from aiosmtpd.smtp import Envelope from email_validator import validate_email, EmailNotValidError from flanker.addresslib import address from flanker.addresslib.address import EmailAddress -from io import BytesIO from sl_pgp import PgpContext -from smtplib import SMTPRecipientsRefused, SMTPServerDisconnected from sqlalchemy.exc import IntegrityError -from typing import List, Tuple, Optional, Set from app import pgp_utils, s3, config, contact_utils from app.alias_utils import ( @@ -2117,11 +2117,7 @@ def handle(envelope: Envelope, msg: Message) -> str: verp_info = get_verp_info_from_email(rcpt_tos[0]) # sent to transactional VERP. Either bounce emails or out-of-office - if ( - len(rcpt_tos) == 1 - and rcpt_tos[0].startswith(config.TRANSACTIONAL_BOUNCE_PREFIX) - and rcpt_tos[0].endswith(config.TRANSACTIONAL_BOUNCE_SUFFIX) - ) or (verp_info and verp_info[0] == VerpType.transactional): + if len(rcpt_tos) == 1 and verp_info and verp_info[0] == VerpType.transactional: if is_bounce(envelope, msg): handle_transactional_bounce( envelope, msg, rcpt_tos[0], verp_info and verp_info[1] @@ -2136,11 +2132,7 @@ def handle(envelope: Envelope, msg: Message) -> str: raise VERPTransactional # sent to forward VERP, can be either bounce or out-of-office - if ( - len(rcpt_tos) == 1 - and rcpt_tos[0].startswith(BOUNCE_PREFIX) - and rcpt_tos[0].endswith(BOUNCE_SUFFIX) - ) or (verp_info and verp_info[0] == VerpType.bounce_forward): + if len(rcpt_tos) == 1 and verp_info and verp_info[0] == VerpType.bounce_forward: email_log_id = (verp_info and verp_info[1]) or parse_id_from_bounce(rcpt_tos[0]) email_log = EmailLog.get(email_log_id) @@ -2156,11 +2148,7 @@ def handle(envelope: Envelope, msg: Message) -> str: raise VERPForward # sent to reply VERP, can be either bounce or out-of-office - if ( - len(rcpt_tos) == 1 - and rcpt_tos[0].startswith(f"{BOUNCE_PREFIX_FOR_REPLY_PHASE}+") - or (verp_info and verp_info[0] == VerpType.bounce_reply) - ): + if len(rcpt_tos) == 1 and verp_info and verp_info[0] == VerpType.bounce_reply: email_log_id = (verp_info and verp_info[1]) or parse_id_from_bounce(rcpt_tos[0]) email_log = EmailLog.get(email_log_id) @@ -2180,11 +2168,7 @@ def handle(envelope: Envelope, msg: Message) -> str: ) verp_info = get_verp_info_from_email(mail_from) - if ( - len(rcpt_tos) == 1 - and mail_from.startswith(BOUNCE_PREFIX) - and mail_from.endswith(BOUNCE_SUFFIX) - ) or (verp_info and verp_info[0] == VerpType.bounce_forward): + if len(rcpt_tos) == 1 and verp_info and verp_info[0] == VerpType.bounce_forward: email_log_id = (verp_info and verp_info[1]) or parse_id_from_bounce(mail_from) email_log = EmailLog.get(email_log_id) alias = Alias.get_by(email=rcpt_tos[0]) From 56a3522a3745c752e9d1ca9283d285df61af2936 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Casaj=C3=BAs?= Date: Wed, 8 Apr 2026 15:21:12 +0200 Subject: [PATCH 4/4] Fix test --- tests/test_email_utils.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/test_email_utils.py b/tests/test_email_utils.py index 5354ce22a..9bfab018d 100644 --- a/tests/test_email_utils.py +++ b/tests/test_email_utils.py @@ -1,7 +1,5 @@ -import arrow import email import os -import pytest from email.message import EmailMessage from email.mime.base import MIMEBase from email.mime.multipart import MIMEMultipart @@ -9,6 +7,9 @@ from email.utils import formataddr from unittest.mock import patch +import arrow +import pytest + from app import config from app.config import MAX_ALERT_24H, ROOT_DIR from app.db import Session @@ -61,6 +62,7 @@ SLDomain, Mailbox, ForbiddenMxIp, + User, ) # flake8: noqa: E101, W191 @@ -1124,6 +1126,8 @@ def test_get_noreply_domain_no_user(flask_client): def test_get_noreply_address_partner_user(flask_client): user, _ = create_partner_linked_user() + user.flags = user.flags | User.FLAG_CREATED_FROM_PARTNER + Session.flush() with patch.object( config, "PARTNER_NOREPLY", '"Partner (noreply)" ' ):