User partner no reply email for users with partner users

This commit is contained in:
Adrià Casajús
2026-04-01 17:31:07 +02:00
committed by Adrià Casajús
parent 8b998ebbd0
commit f7cb621d04
10 changed files with 237 additions and 154 deletions
+9 -3
View File
@@ -3,6 +3,7 @@ import random
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 @@ if PGP_SENDER_PRIVATE_KEY_PATH:
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)" <noreply@{EMAIL_DOMAIN}>')
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"
+59 -19
View File
@@ -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 import address
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 @@ from app.models import (
VerpType,
available_sl_email,
ForbiddenMxIp,
PartnerUser,
)
from app.utils import (
random_string,
@@ -71,6 +72,25 @@ VERP_TIME_START = 1640995200
VERP_HMAC_ALGO = "sha3-224"
def get_noreply_address(user=None) -> str:
"""Full RFC 5322 formatted noreply address, e.g. '"SimpleLogin (noreply)" <noreply@sl.io>'"""
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
+5 -5
View File
@@ -11,7 +11,6 @@ from typing import List, Dict, Optional
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 @@ from app.email_utils import (
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 @@ class ExportUserDataJob:
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 @@ class ExportUserDataJob:
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 @@ class ExportUserDataJob:
generate_verp_email(
VerpType.transactional,
transaction.id,
get_email_domain_part(config.NOREPLY),
get_noreply_domain(self._user),
),
to_email,
msg,
+4 -5
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import arrow
import base64
import dataclasses
import enum
@@ -8,11 +9,8 @@ import hmac
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 import deferred
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 @@ class Contact(Base, ModelMixin):
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))
+2 -2
View File
@@ -10,8 +10,8 @@ from app.models import Alias, Contact
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()
+7 -6
View File
@@ -3,15 +3,16 @@ import argparse
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 @@ updated = 0
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:
+69 -86
View File
@@ -31,29 +31,29 @@ It should contain the following info:
"""
from email import encoders
import argparse
import email
import newrelic.agent
import sentry_sdk
import time
import uuid
from email import encoders
from aiosmtpd.controller import Controller
from aiosmtpd.smtp import Envelope
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 (
@@ -61,35 +61,6 @@ from app.alias_utils import (
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,
BOUNCE_PREFIX,
BOUNCE_SUFFIX,
TRANSACTIONAL_BOUNCE_PREFIX,
TRANSACTIONAL_BOUNCE_SUFFIX,
ENABLE_SPAM_ASSASSIN,
BOUNCE_PREFIX_FOR_REPLY_PHASE,
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
@@ -450,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)
@@ -549,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(
@@ -716,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(
@@ -782,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(
@@ -819,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(
@@ -839,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
@@ -956,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
@@ -975,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 ",
@@ -1067,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")
@@ -1136,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
@@ -1154,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(
@@ -1171,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:
@@ -1494,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(
@@ -1588,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:
@@ -1611,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(
@@ -1638,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,
@@ -1657,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(
@@ -1740,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",
@@ -1761,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(
@@ -1818,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(
@@ -1832,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(
@@ -1863,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(
@@ -2023,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),
@@ -2114,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(
@@ -2126,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)
@@ -2136,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(
@@ -2155,8 +2138,8 @@ def handle(envelope: Envelope, msg: Message) -> str:
# 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)
and rcpt_tos[0].startswith(config.BOUNCE_PREFIX)
and rcpt_tos[0].endswith(config.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)
@@ -2175,7 +2158,7 @@ def handle(envelope: Envelope, msg: Message) -> str:
# 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}+")
and rcpt_tos[0].startswith(f"{config.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])
@@ -2200,8 +2183,8 @@ def handle(envelope: Envelope, msg: Message) -> str:
verp_info = get_verp_info_from_email(mail_from[0])
if (
len(rcpt_tos) == 1
and mail_from.startswith(BOUNCE_PREFIX)
and mail_from.endswith(BOUNCE_SUFFIX)
and mail_from.startswith(config.BOUNCE_PREFIX)
and mail_from.endswith(config.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)
@@ -2220,7 +2203,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")
@@ -2231,7 +2214,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")
@@ -2282,8 +2265,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
@@ -2500,7 +2483,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()
+19 -17
View File
@@ -1,20 +1,19 @@
import random
from email.message import EmailMessage
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
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 typing import List
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):
+54 -4
View File
@@ -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 @@ from app.email_utils import (
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 @@ from tests.utils import (
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 <leo@nimoy.com>"):
addr = get_noreply_address()
assert "Leo <leo@nimoy.com>" == 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)" <partner-noreply@partner.lan>'
):
addr = get_noreply_address(user)
assert addr == '"Partner (noreply)" <partner-noreply@partner.lan>'
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"
+9 -7
View File
@@ -4,7 +4,8 @@ from uuid import UUID
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():