mirror of
https://github.com/simple-login/app.git
synced 2026-04-07 19:27:34 +00:00
User partner no reply email for users with partner users
This commit is contained in:
committed by
Adrià Casajús
parent
8b998ebbd0
commit
f7cb621d04
+9
-3
@@ -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
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user