From 47430725a7e9e528c96e5be58e658aea7e93e720 Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sun, 3 May 2020 14:24:17 +0200 Subject: [PATCH 01/33] improve doc --- app/api/views/alias.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/api/views/alias.py b/app/api/views/alias.py index 962a6138..dae0e1a0 100644 --- a/app/api/views/alias.py +++ b/app/api/views/alias.py @@ -252,8 +252,9 @@ def update_alias(alias_id): Update alias note Input: alias_id: in url - note: in body - name: in body + note (optional): in body + name (optional): in body + mailbox_id (optional): in body Output: 200 """ From 605825750936f5dc93931b2a5962cf112f437df5 Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sun, 3 May 2020 15:46:35 +0200 Subject: [PATCH 02/33] add bootstrap-select --- static/package-lock.json | 5 +++++ static/package.json | 1 + templates/base.html | 7 +++++++ 3 files changed, 13 insertions(+) diff --git a/static/package-lock.json b/static/package-lock.json index 59d2b610..8f35e45a 100644 --- a/static/package-lock.json +++ b/static/package-lock.json @@ -76,6 +76,11 @@ "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.4.1.tgz", "integrity": "sha512-tbx5cHubwE6e2ZG7nqM3g/FZ5PQEDMWmMGNrCUBVRPHXTJaH7CBDdsLeu3eCh3B1tzAxTnAbtmrzvWEvT2NNEA==" }, + "bootstrap-select": { + "version": "1.13.16", + "resolved": "https://registry.npmjs.org/bootstrap-select/-/bootstrap-select-1.13.16.tgz", + "integrity": "sha512-59hUIrW/ldENJDNmliRH3Mzbbrn+XHH5bK0yAy/743Thf8nXA9afNjgr+j1j6tg7NYexEqtE2N8NodHfwMQ0+Q==" + }, "font-awesome": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/font-awesome/-/font-awesome-4.7.0.tgz", diff --git a/static/package.json b/static/package.json index 4dedbbae..3122bf2d 100644 --- a/static/package.json +++ b/static/package.json @@ -18,6 +18,7 @@ "dependencies": { "@sentry/browser": "^5.12.0", "bootbox": "^5.4.0", + "bootstrap-select": "^1.13.16", "font-awesome": "^4.7.0", "intro.js": "^2.9.3", "qrious": "^4.0.2", diff --git a/templates/base.html b/templates/base.html index c7539b7b..70adda75 100644 --- a/templates/base.html +++ b/templates/base.html @@ -60,6 +60,13 @@ + + + + + +{% endblock %} + diff --git a/app/dashboard/views/custom_alias.py b/app/dashboard/views/custom_alias.py index f9dc55a1..2828a23f 100644 --- a/app/dashboard/views/custom_alias.py +++ b/app/dashboard/views/custom_alias.py @@ -11,7 +11,7 @@ from app.dashboard.base import dashboard_bp from app.email_utils import email_belongs_to_alias_domains from app.extensions import db from app.log import LOG -from app.models import Alias, CustomDomain, DeletedAlias, Mailbox, User +from app.models import Alias, CustomDomain, DeletedAlias, Mailbox, User, AliasMailbox from app.utils import convert_to_id, random_word, word_exist signer = TimestampSigner(CUSTOM_ALIAS_SECRET) @@ -54,20 +54,30 @@ def custom_alias(): # List of (is_custom_domain, alias-suffix, time-signed alias-suffix) suffixes = available_suffixes(current_user) - mailboxes = [mb.email for mb in current_user.mailboxes()] + mailboxes = current_user.mailboxes() if request.method == "POST": alias_prefix = request.form.get("prefix") signed_suffix = request.form.get("suffix") - mailbox_email = request.form.get("mailbox") + mailbox_ids = request.form.getlist("mailboxes") alias_note = request.form.get("note") # check if mailbox is not tempered with - if mailbox_email != current_user.email: - mailbox = Mailbox.get_by(email=mailbox_email, user_id=current_user.id) - if not mailbox or mailbox.user_id != current_user.id: + mailboxes = [] + for mailbox_id in mailbox_ids: + mailbox = Mailbox.get(mailbox_id) + if ( + not mailbox + or mailbox.user_id != current_user.id + or not mailbox.verified + ): flash("Something went wrong, please retry", "warning") return redirect(url_for("dashboard.custom_alias")) + mailboxes.append(mailbox) + + if not mailboxes: + flash("At least one mailbox must be selected", "error") + return redirect(url_for("dashboard.custom_alias")) # hypothesis: user will click on the button in the 600 secs try: @@ -91,14 +101,20 @@ def custom_alias(): "warning", ) else: - mailbox = Mailbox.get_by(email=mailbox_email, user_id=current_user.id) - alias = Alias.create( user_id=current_user.id, email=full_alias, note=alias_note, - mailbox_id=mailbox.id, + mailbox_id=mailboxes[0].id, ) + db.session.flush() + + for i in range(1, len(mailboxes)): + AliasMailbox.create( + user_id=alias.user_id, + alias_id=alias.id, + mailbox_id=mailboxes[i].id, + ) # get the custom_domain_id if alias is created with a custom domain if alias_suffix.startswith("@"): diff --git a/tests/dashboard/test_custom_alias.py b/tests/dashboard/test_custom_alias.py index 3e189c3b..1708c9d7 100644 --- a/tests/dashboard/test_custom_alias.py +++ b/tests/dashboard/test_custom_alias.py @@ -7,7 +7,7 @@ from app.dashboard.views.custom_alias import ( available_suffixes, ) from app.extensions import db -from app.models import Mailbox, CustomDomain +from app.models import Mailbox, CustomDomain, Alias from app.utils import random_word from tests.utils import login @@ -20,15 +20,50 @@ def test_add_alias_success(flask_client): suffix = f".{word}@{EMAIL_DOMAIN}" suffix = signer.sign(suffix).decode() + # create with a single mailbox r = flask_client.post( url_for("dashboard.custom_alias"), - data={"prefix": "prefix", "suffix": suffix, "mailbox": user.email,}, + data={ + "prefix": "prefix", + "suffix": suffix, + "mailboxes": [user.default_mailbox_id], + }, follow_redirects=True, ) - assert r.status_code == 200 assert f"Alias prefix.{word}@{EMAIL_DOMAIN} has been created" in str(r.data) + alias = Alias.query.order_by(Alias.created_at.desc()).first() + assert not alias._mailboxes + + +def test_add_alias_multiple_mailboxes(flask_client): + user = login(flask_client) + db.session.commit() + + word = random_word() + suffix = f".{word}@{EMAIL_DOMAIN}" + suffix = signer.sign(suffix).decode() + + # create with a multiple mailboxes + mb1 = Mailbox.create(user_id=user.id, email="m1@example.com", verified=True) + db.session.commit() + + r = flask_client.post( + url_for("dashboard.custom_alias"), + data={ + "prefix": "prefix", + "suffix": suffix, + "mailboxes": [user.default_mailbox_id, mb1.id], + }, + follow_redirects=True, + ) + assert r.status_code == 200 + assert f"Alias prefix.{word}@{EMAIL_DOMAIN} has been created" in str(r.data) + + alias = Alias.query.order_by(Alias.created_at.desc()).first() + assert alias._mailboxes + def test_not_show_unverified_mailbox(flask_client): """make sure user unverified mailbox is not shown to user""" From e704497b0f9fd9738d8090b993cc261e10a2a738 Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sun, 10 May 2020 15:21:31 +0200 Subject: [PATCH 09/33] make sure prefix is not empty before submitting --- app/dashboard/templates/dashboard/custom_alias.html | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/dashboard/templates/dashboard/custom_alias.html b/app/dashboard/templates/dashboard/custom_alias.html index a4fed99a..e29bbc88 100644 --- a/app/dashboard/templates/dashboard/custom_alias.html +++ b/app/dashboard/templates/dashboard/custom_alias.html @@ -28,6 +28,7 @@
Date: Sun, 10 May 2020 16:27:27 +0200 Subject: [PATCH 10/33] optimize import email_handler --- email_handler.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/email_handler.py b/email_handler.py index 6c76b4db..603498a3 100644 --- a/email_handler.py +++ b/email_handler.py @@ -31,14 +31,8 @@ It should contain the following info: """ import email -import re - -import arrow -import spf import time import uuid -from aiosmtpd.controller import Controller -from aiosmtpd.smtp import Envelope from email import encoders from email.message import Message from email.mime.application import MIMEApplication @@ -47,6 +41,11 @@ from email.utils import parseaddr, formataddr from io import BytesIO from smtplib import SMTP +import arrow +import spf +from aiosmtpd.controller import Controller +from aiosmtpd.smtp import Envelope + from app import pgp_utils, s3 from app.alias_utils import try_auto_create from app.config import ( From b5e7f05bfcd10eee054845eaa5d4d2da8e1c1701 Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sun, 10 May 2020 16:28:56 +0200 Subject: [PATCH 11/33] allow user sends emails to his alias from his mailbox --- email_handler.py | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/email_handler.py b/email_handler.py index 603498a3..c8431b63 100644 --- a/email_handler.py +++ b/email_handler.py @@ -93,9 +93,6 @@ from app.utils import random_string from init_app import load_pgp_public_keys from server import create_app -# used when an alias receives email from its own mailbox -# can happen when user "Reply All" on some email clients -_SELF_FORWARDING_STATUS = "550 SL self-forward" _IP_HEADER = "X-SimpleLogin-Client-IP" @@ -331,13 +328,6 @@ def handle_forward(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, s mailbox_email = mailbox.email user = alias.user - # Sometimes when user clicks on "reply all" - # an email is sent to the same alias that the previous message is destined to - if envelope.mail_from == mailbox_email: - # nothing to do - LOG.d("Forward from %s to %s, nothing to do", envelope.mail_from, mailbox_email) - return False, _SELF_FORWARDING_STATUS - contact = get_or_create_contact(msg["From"], alias) email_log = EmailLog.create(contact_id=contact.id, user_id=contact.user_id) @@ -940,12 +930,6 @@ def handle(envelope: Envelope, smtp: SMTP) -> str: is_delivered, smtp_status = handle_forward(envelope, smtp, msg, rcpt_to) res.append((is_delivered, smtp_status)) - # special handling for self-forwarding - # just consider success delivery in this case - if len(res) == 1 and res[0][1] == _SELF_FORWARDING_STATUS: - LOG.d("Self-forwarding, ignore") - return "250 SL OK" - for (is_success, smtp_status) in res: # Consider all deliveries successful if 1 delivery is successful if is_success: From 59036972f1e046e6f912d8f9e3c1c43f44563ae8 Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sun, 10 May 2020 16:31:36 +0200 Subject: [PATCH 12/33] refactor handle_forward: move the disabled alias case to the beginning --- email_handler.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/email_handler.py b/email_handler.py index c8431b63..975aa905 100644 --- a/email_handler.py +++ b/email_handler.py @@ -324,10 +324,6 @@ def handle_forward(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, s LOG.d("alias %s cannot be created on-the-fly, return 550", address) return False, "550 SL E3" - mailbox = alias.mailbox - mailbox_email = mailbox.email - user = alias.user - contact = get_or_create_contact(msg["From"], alias) email_log = EmailLog.create(contact_id=contact.id, user_id=contact.user_id) @@ -336,8 +332,13 @@ def handle_forward(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, s email_log.blocked = True db.session.commit() + # do not return 5** to allow user to receive emails later when alias is enabled return True, "250 Message accepted for delivery" + mailbox = alias.mailbox + mailbox_email = mailbox.email + user = alias.user + spam_check = True # create PGP email if needed From 2755e67c31dfd4faeec2a7e42787ac3c5effb982 Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sun, 10 May 2020 16:32:54 +0200 Subject: [PATCH 13/33] simplify code: replace mailbox_email by mailbox.email --- email_handler.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/email_handler.py b/email_handler.py index 975aa905..44157c67 100644 --- a/email_handler.py +++ b/email_handler.py @@ -336,7 +336,6 @@ def handle_forward(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, s return True, "250 Message accepted for delivery" mailbox = alias.mailbox - mailbox_email = mailbox.email user = alias.user spam_check = True @@ -356,7 +355,7 @@ def handle_forward(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, s email_log.is_spam = True email_log.spam_status = spam_status - handle_spam(contact, alias, msg, user, mailbox_email, email_log) + handle_spam(contact, alias, msg, user, mailbox.email, email_log) return False, "550 SL E1" # add custom header @@ -406,7 +405,7 @@ def handle_forward(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, s LOG.d( "Forward mail from %s to %s, mail_options %s, rcpt_options %s ", contact.website_email, - mailbox_email, + mailbox.email, envelope.mail_options, envelope.rcpt_options, ) @@ -415,7 +414,7 @@ def handle_forward(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, s # encode message raw directly instead smtp.sendmail( contact.reply_email, - mailbox_email, + mailbox.email, msg.as_bytes(), envelope.mail_options, envelope.rcpt_options, From 7f6ba313fd94198629ccd2bb5a315eb6533b79c6 Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sun, 10 May 2020 16:33:54 +0200 Subject: [PATCH 14/33] add strip() to rcpt_to just in case --- email_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/email_handler.py b/email_handler.py index 44157c67..f3fee89d 100644 --- a/email_handler.py +++ b/email_handler.py @@ -314,7 +314,7 @@ def handle_forward(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, s """return whether an email has been delivered and the smtp status ("250 Message accepted", "550 Non-existent email address", etc) """ - address = rcpt_to.lower() # alias@SL + address = rcpt_to.lower().strip() # alias@SL alias = Alias.get_by(email=address) if not alias: From 4b479defa895e21d672e53bb4396273f63f9e0d9 Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sun, 10 May 2020 16:57:47 +0200 Subject: [PATCH 15/33] Support alias having multiple mailboxes in forward phase --- app/models.py | 8 ++++++++ email_handler.py | 38 ++++++++++++++++++++++++++++++++------ 2 files changed, 40 insertions(+), 6 deletions(-) diff --git a/app/models.py b/app/models.py index e18a0066..22e78a85 100644 --- a/app/models.py +++ b/app/models.py @@ -643,6 +643,14 @@ class Alias(db.Model, ModelMixin): user = db.relationship(User) mailbox = db.relationship("Mailbox") + @property + def mailboxes(self): + ret = [self.mailbox] + for m in self._mailboxes: + ret.append(m) + + return ret + @classmethod def create(cls, **kw): r = cls(**kw) diff --git a/email_handler.py b/email_handler.py index f3fee89d..ad1af564 100644 --- a/email_handler.py +++ b/email_handler.py @@ -40,6 +40,7 @@ from email.mime.multipart import MIMEMultipart from email.utils import parseaddr, formataddr from io import BytesIO from smtplib import SMTP +from typing import List, Tuple import arrow import spf @@ -310,7 +311,9 @@ def prepare_pgp_message(orig_msg: Message, pgp_fingerprint: str): return msg -def handle_forward(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, str): +def handle_forward( + envelope, smtp: SMTP, msg: Message, rcpt_to: str +) -> List[Tuple[bool, str]]: """return whether an email has been delivered and the smtp status ("250 Message accepted", "550 Non-existent email address", etc) """ @@ -322,7 +325,7 @@ def handle_forward(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, s alias = try_auto_create(address) if not alias: LOG.d("alias %s cannot be created on-the-fly, return 550", address) - return False, "550 SL E3" + return [(False, "550 SL E3")] contact = get_or_create_contact(msg["From"], alias) email_log = EmailLog.create(contact_id=contact.id, user_id=contact.user_id) @@ -333,11 +336,32 @@ def handle_forward(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, s db.session.commit() # do not return 5** to allow user to receive emails later when alias is enabled - return True, "250 Message accepted for delivery" + return [(True, "250 Message accepted for delivery")] - mailbox = alias.mailbox user = alias.user + ret = [] + for mailbox in alias.mailboxes: + ret.append( + forward_email_to_mailbox( + alias, msg, email_log, contact, envelope, smtp, mailbox, user + ) + ) + + return ret + + +def forward_email_to_mailbox( + alias, + msg: Message, + email_log: EmailLog, + contact: Contact, + envelope, + smtp: SMTP, + mailbox, + user, +) -> (bool, str): + LOG.d("Forward %s -> %s -> %s", contact, alias, mailbox) spam_check = True # create PGP email if needed @@ -927,8 +951,10 @@ def handle(envelope: Envelope, smtp: SMTP) -> str: res.append((is_delivered, smtp_status)) else: # Forward case LOG.debug(">>> Forward phase %s -> %s", envelope.mail_from, rcpt_to) - is_delivered, smtp_status = handle_forward(envelope, smtp, msg, rcpt_to) - res.append((is_delivered, smtp_status)) + for is_delivered, smtp_status in handle_forward( + envelope, smtp, msg, rcpt_to + ): + res.append((is_delivered, smtp_status)) for (is_success, smtp_status) in res: # Consider all deliveries successful if 1 delivery is successful From 97e1c334aff6945a771fc6548f3b99767fe41512 Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sun, 10 May 2020 16:58:18 +0200 Subject: [PATCH 16/33] call strip() on rcpt_to just to be sure --- email_handler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/email_handler.py b/email_handler.py index ad1af564..20921bba 100644 --- a/email_handler.py +++ b/email_handler.py @@ -453,7 +453,7 @@ def handle_reply(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, str return whether an email has been delivered and the smtp status ("250 Message accepted", "550 Non-existent email address", etc) """ - reply_email = rcpt_to.lower() + reply_email = rcpt_to.lower().strip() # reply_email must end with EMAIL_DOMAIN if not reply_email.endswith(EMAIL_DOMAIN): From 8d65175ac59e6c9eee7a41f9e8efe64b4af620c8 Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sun, 10 May 2020 17:52:07 +0200 Subject: [PATCH 17/33] set mailbox ID in X-SimpleLogin-Mailbox-ID header --- email_handler.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/email_handler.py b/email_handler.py index 20921bba..a826948b 100644 --- a/email_handler.py +++ b/email_handler.py @@ -96,7 +96,7 @@ from server import create_app _IP_HEADER = "X-SimpleLogin-Client-IP" - +_MAILBOX_ID_HEADER = "X-SimpleLogin-Mailbox-ID" # fix the database connection leak issue # use this method instead of create_app @@ -390,6 +390,7 @@ def forward_email_to_mailbox( delete_header(msg, "Sender") delete_header(msg, _IP_HEADER) + add_or_replace_header(msg, _MAILBOX_ID_HEADER, str(mailbox.id)) # change the from header so the sender comes from @SL # so it can pass DMARC check From 33d578c78e293941706be892dd1723abadd68e7f Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sun, 10 May 2020 17:53:37 +0200 Subject: [PATCH 18/33] parse _MAILBOX_ID_HEADER to handle bounce message --- email_handler.py | 40 +++++++++++++++++++++++++++------------- 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/email_handler.py b/email_handler.py index a826948b..469e5675 100644 --- a/email_handler.py +++ b/email_handler.py @@ -490,7 +490,7 @@ def handle_reply(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, str alias.user, ) - handle_bounce(contact, alias, msg, user, mailbox_email) + handle_bounce(contact, alias, msg, user) return False, "550 SL E6" mailbox: Mailbox = Mailbox.get_by(email=mailbox_email) @@ -688,9 +688,7 @@ def handle_unknown_mailbox( ) -def handle_bounce( - contact: Contact, alias: Alias, msg: Message, user: User, mailbox_email: str -): +def handle_bounce(contact: Contact, alias: Alias, msg: Message, user: User): address = alias.email email_log: EmailLog = EmailLog.create( contact_id=contact.id, bounced=True, user_id=contact.user_id @@ -708,12 +706,28 @@ def handle_bounce( full_report_path = f"refused-emails/full-{random_name}.eml" s3.upload_email_from_bytesio(full_report_path, BytesIO(msg.as_bytes()), random_name) - file_path = None - if orig_msg: - file_path = f"refused-emails/{random_name}.eml" - s3.upload_email_from_bytesio( - file_path, BytesIO(orig_msg.as_bytes()), random_name + if not orig_msg: + LOG.error( + "Cannot parse original message from bounce message %s %s %s", + alias, + user, + contact, ) + return + + file_path = f"refused-emails/{random_name}.eml" + s3.upload_email_from_bytesio(file_path, BytesIO(orig_msg.as_bytes()), random_name) + mailbox_id = int(orig_msg[_MAILBOX_ID_HEADER]) + mailbox = Mailbox.get(mailbox_id) + if not mailbox or mailbox.user_id != user.id: + LOG.error( + "Tampered message mailbox_id %s, %s, %s, %s", + mailbox_id, + user, + alias, + contact, + ) + return refused_email = RefusedEmail.create( path=file_path, full_report_path=full_report_path, user_id=user.id @@ -750,7 +764,7 @@ def handle_bounce( website_email=contact.website_email, disable_alias_link=disable_alias_link, refused_email_url=refused_email_url, - mailbox_email=mailbox_email, + mailbox_email=mailbox.email, ), render( "transactional/bounced-email.html", @@ -759,7 +773,7 @@ def handle_bounce( website_email=contact.website_email, disable_alias_link=disable_alias_link, refused_email_url=refused_email_url, - mailbox_email=mailbox_email, + mailbox_email=mailbox.email, ), # cannot include bounce email as it can contain spammy text # bounced_email=msg, @@ -786,7 +800,7 @@ def handle_bounce( alias=alias, website_email=contact.website_email, refused_email_url=refused_email_url, - mailbox_email=mailbox_email, + mailbox_email=mailbox.email, ), render( "transactional/automatic-disable-alias.html", @@ -794,7 +808,7 @@ def handle_bounce( alias=alias, website_email=contact.website_email, refused_email_url=refused_email_url, - mailbox_email=mailbox_email, + mailbox_email=mailbox.email, ), # cannot include bounce email as it can contain spammy text # bounced_email=msg, From 336bdb196d200ef794f86a16500109ed0f724f72 Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sun, 10 May 2020 18:19:29 +0200 Subject: [PATCH 19/33] Detect unknown mailbox using envelope mail_from --- email_handler.py | 39 ++++++++----------- .../reply-must-use-personal-email.html | 25 ++++++++++-- .../reply-must-use-personal-email.txt | 11 +++++- 3 files changed, 46 insertions(+), 29 deletions(-) diff --git a/email_handler.py b/email_handler.py index 469e5675..6673f0d8 100644 --- a/email_handler.py +++ b/email_handler.py @@ -476,24 +476,26 @@ def handle_reply(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, str return False, "550 SL E5" user = alias.user - mailbox_email = alias.mailbox_email() + mail_from = envelope.mail_from.lower().strip() # bounce email initiated by Postfix # can happen in case emails cannot be delivered to user-email # in this case Postfix will try to send a bounce report to original sender, which is # the "reply email" - if envelope.mail_from == "<>": + if mail_from == "<>": LOG.warning( - "Bounce when sending to alias %s from %s, user %s", - alias, - contact.website_email, - alias.user, + "Bounce when sending to alias %s from %s, user %s", alias, contact, user, ) handle_bounce(contact, alias, msg, user) return False, "550 SL E6" - mailbox: Mailbox = Mailbox.get_by(email=mailbox_email) + mailbox = Mailbox.get_by(email=mail_from, user_id=user.id) + if not mailbox or mailbox not in alias.mailboxes: + # only mailbox can send email to the reply-email + handle_unknown_mailbox(envelope, msg, reply_email, user, alias) + return False, "550 SL E7" + if ENFORCE_SPF and mailbox.force_spf: ip = msg[_IP_HEADER] if not spf_pass(ip, envelope, mailbox, user, alias, contact.website_email, msg): @@ -501,13 +503,7 @@ def handle_reply(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> (bool, str delete_header(msg, _IP_HEADER) - # only mailbox can send email to the reply-email - if envelope.mail_from.lower() != mailbox_email.lower(): - handle_unknown_mailbox(envelope, msg, mailbox, reply_email, user, alias) - return False, "550 SL E7" - delete_header(msg, "DKIM-Signature") - delete_header(msg, "Received") # make the email comes from alias @@ -636,36 +632,33 @@ def spf_pass( return True -def handle_unknown_mailbox( - envelope, msg, mailbox: Mailbox, reply_email: str, user: User, alias: Alias -): +def handle_unknown_mailbox(envelope, msg, reply_email: str, user: User, alias: Alias): LOG.warning( f"Reply email can only be used by mailbox. " - f"Actual mail_from: %s. msg from header: %s, Mailbox %s. reply_email %s", + f"Actual mail_from: %s. msg from header: %s, reverse-alias %s, %s %s", envelope.mail_from, msg["From"], - mailbox.email, reply_email, + alias, + user, ) send_email_with_rate_control( user, ALERT_REVERSE_ALIAS_UNKNOWN_MAILBOX, - mailbox.email, + user.email, f"Reply from your alias {alias.email} only works from your mailbox", render( "transactional/reply-must-use-personal-email.txt", name=user.name, - alias=alias.email, + alias=alias, sender=envelope.mail_from, - mailbox_email=mailbox.email, ), render( "transactional/reply-must-use-personal-email.html", name=user.name, - alias=alias.email, + alias=alias, sender=envelope.mail_from, - mailbox_email=mailbox.email, ), ) diff --git a/templates/emails/transactional/reply-must-use-personal-email.html b/templates/emails/transactional/reply-must-use-personal-email.html index 790782ad..356aa1aa 100644 --- a/templates/emails/transactional/reply-must-use-personal-email.html +++ b/templates/emails/transactional/reply-must-use-personal-email.html @@ -2,10 +2,27 @@ {% block content %} {{ render_text("Hi " + name) }} - {{ render_text("We have recorded an attempt to send an email from your alias "+ alias +" using " + sender + ".") }} - {{ render_text("Please note that sending from this alias only works from " + mailbox_email + ".") }} - {{ render_text("Indeed, only you (or the mailbox that owns " + alias + ") can send emails on behalf of this alias.") }} - {{ render_text('Thanks,
SimpleLogin Team.') }} + + {% call text() %} + We have recorded an attempt to send an email from your alias {{ alias.email }} using {{ sender }}> + {% endcall %} + + {% call text() %} + Please note that sending from this alias only works from one of these mailboxes:
+ {% for mailbox in alias.mailboxes %} + - {{ mailbox.email }}
+ {% endfor %} + {% endcall %} + + {% call text() %} + Indeed only you can send emails on behalf of your alias. + {% endcall %} + + {% call text() %} + Thanks,
+ SimpleLogin Team. + {% endcall %} + {% endblock %} diff --git a/templates/emails/transactional/reply-must-use-personal-email.txt b/templates/emails/transactional/reply-must-use-personal-email.txt index 1ee52f1f..21e4b7fe 100644 --- a/templates/emails/transactional/reply-must-use-personal-email.txt +++ b/templates/emails/transactional/reply-must-use-personal-email.txt @@ -1,8 +1,15 @@ Hi {{name}} -We have recorded an attempt to send an email from your alias {{ alias }} using {{ sender }}. +We have recorded an attempt to send an email from your alias {{ alias.email }} using {{ sender }}. -Please note that sending from this alias only works from {{mailbox_email}}: only you (i.e. no one else) can send emails on behalf of your alias. +Please note that sending from this alias only works from one of these mailboxes: + +{% for mailbox in alias.mailboxes %} +- {{mailbox.email}} +{% endfor %} + + +Indeed only you can send emails on behalf of your alias. Best, SimpleLogin team. From 5b71b34f9e62c07eefc01ff450b6db7413bcb0e4 Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sun, 10 May 2020 18:23:43 +0200 Subject: [PATCH 20/33] handle alias unsubscribe --- email_handler.py | 37 ++++++++++++++++++++----------------- 1 file changed, 20 insertions(+), 17 deletions(-) diff --git a/email_handler.py b/email_handler.py index 6673f0d8..50f4d36b 100644 --- a/email_handler.py +++ b/email_handler.py @@ -900,7 +900,9 @@ def handle_unsubscribe(envelope: Envelope): return "550 SL E9" # This sender cannot unsubscribe - if alias.mailbox_email() != envelope.mail_from: + mail_from = envelope.mail_from.lower().strip() + mailbox = Mailbox.get_by(user_id=alias.user_id, email=mail_from) + if not mailbox or mailbox not in alias.mailboxes: LOG.d("%s cannot disable alias %s", envelope.mail_from, alias) return "550 SL E10" @@ -910,22 +912,23 @@ def handle_unsubscribe(envelope: Envelope): user = alias.user enable_alias_url = URL + f"/dashboard/?highlight_alias_id={alias.id}" - send_email( - envelope.mail_from, - f"Alias {alias.email} has been disabled successfully", - render( - "transactional/unsubscribe-disable-alias.txt", - user=user, - alias=alias.email, - enable_alias_url=enable_alias_url, - ), - render( - "transactional/unsubscribe-disable-alias.html", - user=user, - alias=alias.email, - enable_alias_url=enable_alias_url, - ), - ) + for mailbox in alias.mailboxes: + send_email( + mailbox.email, + f"Alias {alias.email} has been disabled successfully", + render( + "transactional/unsubscribe-disable-alias.txt", + user=user, + alias=alias.email, + enable_alias_url=enable_alias_url, + ), + render( + "transactional/unsubscribe-disable-alias.html", + user=user, + alias=alias.email, + enable_alias_url=enable_alias_url, + ), + ) return "250 Unsubscribe request accepted" From 0f09ef681ca00b1060dd733188adeb76e5f0b455 Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sun, 10 May 2020 18:34:57 +0200 Subject: [PATCH 21/33] Add EmailLog.bounced_mailbox_id --- app/models.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/models.py b/app/models.py index 22e78a85..44cc4618 100644 --- a/app/models.py +++ b/app/models.py @@ -923,6 +923,12 @@ class EmailLog(db.Model, ModelMixin): db.ForeignKey("refused_email.id", ondelete="SET NULL"), nullable=True ) + # in case of bounce, record on what mailbox the email has been bounced + # useful when an alias has several mailboxes + bounced_mailbox_id = db.Column( + db.ForeignKey("mailbox.id", ondelete="cascade"), nullable=True + ) + refused_email = db.relationship("RefusedEmail") forward = db.relationship(Contact) From 0d117126db4ea3dcedcf65cc078a8c42fd79438b Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sun, 10 May 2020 18:35:13 +0200 Subject: [PATCH 22/33] save the mailbox that a bounce affects --- email_handler.py | 1 + 1 file changed, 1 insertion(+) diff --git a/email_handler.py b/email_handler.py index 50f4d36b..ffa819f3 100644 --- a/email_handler.py +++ b/email_handler.py @@ -728,6 +728,7 @@ def handle_bounce(contact: Contact, alias: Alias, msg: Message, user: User): db.session.flush() email_log.refused_email_id = refused_email.id + email_log.bounced_mailbox_id = mailbox.id db.session.commit() LOG.d("Create refused email %s", refused_email) From bc55b98e12d731df9780a0f0bc91a91979ac4d8f Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sun, 10 May 2020 18:41:22 +0200 Subject: [PATCH 23/33] display mailbox that a bounce affects --- app/dashboard/templates/dashboard/alias_log.html | 2 +- app/dashboard/views/alias_log.py | 5 ++--- app/models.py | 6 ++++++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/app/dashboard/templates/dashboard/alias_log.html b/app/dashboard/templates/dashboard/alias_log.html index 66cc21d3..40a3b6bb 100644 --- a/app/dashboard/templates/dashboard/alias_log.html +++ b/app/dashboard/templates/dashboard/alias_log.html @@ -129,7 +129,7 @@ {{ log.alias }} - {{ log.mailbox }} + {{ log.email_log.bounced_mailbox() }}
{% else %}
diff --git a/app/dashboard/views/alias_log.py b/app/dashboard/views/alias_log.py index e86a602b..56be2cb4 100644 --- a/app/dashboard/views/alias_log.py +++ b/app/dashboard/views/alias_log.py @@ -16,7 +16,7 @@ class AliasLog: is_reply: bool blocked: bool bounced: bool - mailbox: str + email_log: EmailLog def __init__(self, **kwargs): for k, v in kwargs.items(): @@ -63,7 +63,6 @@ def alias_log(alias_id, page_id): def get_alias_log(alias: Alias, page_id=0) -> [AliasLog]: logs: [AliasLog] = [] - mailbox = alias.mailbox_email() q = ( db.session.query(Contact, EmailLog) @@ -83,7 +82,7 @@ def get_alias_log(alias: Alias, page_id=0) -> [AliasLog]: is_reply=email_log.is_reply, blocked=email_log.blocked, bounced=email_log.bounced, - mailbox=mailbox, + email_log=email_log, ) logs.append(al) logs = sorted(logs, key=lambda l: l.when, reverse=True) diff --git a/app/models.py b/app/models.py index 44cc4618..aa8f3434 100644 --- a/app/models.py +++ b/app/models.py @@ -934,6 +934,12 @@ class EmailLog(db.Model, ModelMixin): contact = db.relationship(Contact) + def bounced_mailbox(self) -> str: + if self.bounced_mailbox_id: + return Mailbox.get(self.bounced_mailbox_id).email + # retro-compatibility + return self.contact.alias.mailboxes[0].email + def get_action(self) -> str: """return the action name: forward|reply|block|bounced""" if self.is_reply: From 8f35290a21280c852d6f5efeb7b07bfabcfb02ef Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sun, 10 May 2020 18:53:23 +0200 Subject: [PATCH 24/33] fix overflow error when there are several mailboxes --- app/dashboard/templates/dashboard/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/dashboard/templates/dashboard/index.html b/app/dashboard/templates/dashboard/index.html index a2febd52..66fcf271 100644 --- a/app/dashboard/templates/dashboard/index.html +++ b/app/dashboard/templates/dashboard/index.html @@ -306,7 +306,7 @@ {% if mailboxes|length > 1 %}
Current mailbox
-
+
+