Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)" <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"
Expand Down
78 changes: 59 additions & 19 deletions app/email_utils.py
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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
Expand All @@ -58,6 +58,7 @@
VerpType,
available_sl_email,
ForbiddenMxIp,
PartnerUser,
)
from app.utils import (
random_string,
Expand All @@ -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)" <noreply@sl.io>'"""
if user and user.created_by_partner:
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))
Expand Down Expand Up @@ -111,6 +131,7 @@ def send_welcome_email(user):
render("com/welcome.html", user=user, alias=alias),
unsubscribe_link,
via_email,
user=user,
)


Expand All @@ -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,
)


Expand All @@ -142,6 +164,7 @@ def send_activation_email(user: User, activation_link):
activation_link=activation_link,
email=user.email,
),
user=user,
)


Expand All @@ -161,6 +184,7 @@ def send_reset_password_email(user: User, reset_password_link):
user=user,
reset_password_link=reset_password_link,
),
user=user,
)


Expand All @@ -184,6 +208,7 @@ def send_change_email(user: User, new_email, link):
new_email=new_email,
current_email=user.email,
),
user=user,
)


Expand Down Expand Up @@ -227,6 +252,7 @@ def send_test_email_alias(user: User, email: str):
name=user.name,
alias=email,
),
user=user,
)


Expand All @@ -251,6 +277,7 @@ def send_cannot_create_directory_alias(user: User, alias_address, directory_name
alias=alias_address,
directory=directory_name,
),
user=user,
)


Expand Down Expand Up @@ -303,6 +330,7 @@ def send_cannot_create_domain_alias(user: User, alias, domain):
alias=alias,
domain=domain,
),
user=user,
)


Expand All @@ -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:
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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


Expand Down
10 changes: 5 additions & 5 deletions app/jobs/export_user_data_job.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,15 @@
import arrow
import sqlalchemy

from app import config
from app.constants import JobType
from app.db import Session
from app.email import headers
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 (
Expand Down Expand Up @@ -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")
Expand All @@ -152,15 +152,15 @@ 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)
sl_sendmail(
generate_verp_email(
VerpType.transactional,
transaction.id,
get_email_domain_part(config.NOREPLY),
get_noreply_domain(self._user),
),
to_email,
msg,
Expand Down
9 changes: 4 additions & 5 deletions app/models.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

import arrow
import base64
import dataclasses
import enum
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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))
Expand Down
4 changes: 2 additions & 2 deletions commands/emulate_dummy_load.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
13 changes: 7 additions & 6 deletions commands/replace_noreply_in_contacts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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:
Expand Down
Loading
Loading