diff --git a/app/config.py b/app/config.py index 19e03bce..33532e1c 100644 --- a/app/config.py +++ b/app/config.py @@ -292,6 +292,8 @@ ALERT_REVERSE_ALIAS_UNKNOWN_MAILBOX = "reverse_alias_unknown_mailbox" # When a forwarding email is bounced ALERT_BOUNCE_EMAIL = "bounce" +ALERT_BOUNCE_EMAIL_REPLY_PHASE = "bounce-when-reply" + # When a forwarding email is detected as spam ALERT_SPAM_EMAIL = "spam" diff --git a/email_handler.py b/email_handler.py index d4872d13..f8505cb4 100644 --- a/email_handler.py +++ b/email_handler.py @@ -77,6 +77,7 @@ from app.config import ( ALERT_SEND_EMAIL_CYCLE, ALERT_MAILBOX_IS_ALIAS, PGP_SENDER_PRIVATE_KEY, + ALERT_BOUNCE_EMAIL_REPLY_PHASE, ) from app.email_utils import ( send_email, @@ -528,10 +529,10 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str LOG.d("alias %s cannot be created on-the-fly, return 550", address) return [(False, "550 SL E3 Email not exist")] - if alias.user.disabled: - LOG.warning( - "User %s disabled, disable forwarding emails for %s", alias.user, alias - ) + user = alias.user + + if user.disabled: + LOG.warning("User %s disabled, disable forwarding emails for %s", user, alias) return [(False, "550 SL E20 Account disabled")] mail_from = envelope.mail_from @@ -539,7 +540,7 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str # email send from a mailbox to alias if mb.email == mail_from: LOG.warning("cycle email sent from %s to %s", mb, alias) - handle_email_sent_to_ourself(alias, mb, msg, alias.user) + handle_email_sent_to_ourself(alias, mb, msg, user) return [(True, "250 Message accepted for delivery")] # bounce email initiated by Postfix @@ -547,14 +548,12 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str # in this case Postfix will send a bounce report to original sender, which is the alias if mail_from == "<>": LOG.exception("Bounce email sent to %s", alias) - # save the data for debugging - file_path = f"/tmp/{random_string(10)}.eml" - with open(file_path, "wb") as f: - f.write(msg.as_bytes()) - return [(False, "421 SL Retry later")] + handle_bounce_reply_phase(alias, msg, user) + return [(False, "550 SL E24 Email cannot be sent to contact")] contact = get_or_create_contact(msg["From"], envelope.mail_from, alias) + email_log = EmailLog.create(contact_id=contact.id, user_id=contact.user_id) db.session.commit() @@ -566,8 +565,6 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str # do not return 5** to allow user to receive emails later when alias is enabled return [(True, "250 Message accepted for delivery")] - user = alias.user - ret = [] mailboxes = alias.mailboxes @@ -939,6 +936,9 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str): # return 421 so the client can retry later return False, "421 SL E13 Retry later" + # save the email_log to DB + db.session.commit() + # make the email comes from alias from_header = alias.email # add alias name from alias @@ -962,11 +962,12 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str): _MESSAGE_ID, make_msgid(str(email_log.id), get_email_domain_part(alias.email)), ) - add_or_replace_header(msg, _EMAIL_LOG_ID_HEADER, str(email_log.id)) date_header = formatdate() msg["Date"] = date_header - add_or_replace_header(msg, _DIRECTION, "Reply") + msg[_DIRECTION] = "Reply" + msg[_MAILBOX_ID_HEADER] = str(mailbox.id) + msg[_EMAIL_LOG_ID_HEADER] = str(email_log.id) LOG.d( "send email from %s to %s, mail_options:%s,rcpt_options:%s", @@ -1012,7 +1013,7 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str): ) # return 250 even if error as user is already informed of the incident and can retry sending the email - db.session.commit() + return True, "250 Message accepted for delivery" @@ -1344,6 +1345,80 @@ def handle_bounce(contact: Contact, alias: Alias, msg: Message, user: User): ) +def handle_bounce_reply_phase(alias: Alias, msg: Message, user: User): + """ + Handle bounce that is sent to alias + Happens when an email cannot be sent from an alias to a contact + """ + # Store the bounced email + # generate a name for the email + random_name = str(uuid.uuid4()) + + full_report_path = f"refused-emails/full-{random_name}.eml" + s3.upload_email_from_bytesio(full_report_path, BytesIO(msg.as_bytes()), random_name) + + orig_msg = get_orig_message_from_bounce(msg) + + file_path = f"refused-emails/{random_name}.eml" + s3.upload_email_from_bytesio(file_path, BytesIO(orig_msg.as_bytes()), random_name) + + email_log_id = int(orig_msg[_EMAIL_LOG_ID_HEADER]) + email_log = EmailLog.get(email_log_id) + contact = email_log.contact + + try: + mailbox_id = int(orig_msg[_MAILBOX_ID_HEADER]) + except TypeError: + LOG.warning( + "cannot parse mailbox from original message header %s", + orig_msg[_MAILBOX_ID_HEADER], + ) + # fall back to the default mailbox + mailbox = alias.mailbox + else: + mailbox = Mailbox.get(mailbox_id) + email_log.bounced_mailbox_id = mailbox.id + + refused_email = RefusedEmail.create( + path=file_path, full_report_path=full_report_path, user_id=user.id + ) + db.session.flush() + LOG.d("Create refused email %s", refused_email) + + email_log.bounced = True + email_log.refused_email_id = refused_email.id + db.session.commit() + + refused_email_url = ( + URL + f"/dashboard/refused_email?highlight_id=" + str(email_log.id) + ) + + LOG.d( + "Inform user %s about bounced email sent by %s to %s", + user, + alias, + contact, + ) + send_email_with_rate_control( + user, + ALERT_BOUNCE_EMAIL_REPLY_PHASE, + mailbox.email, + f"Email cannot be sent to { contact.email } from your alias { alias.email }", + render( + "transactional/bounce-email-reply-phase.txt", + alias=alias, + contact=contact, + refused_email_url=refused_email_url, + ), + render( + "transactional/bounce-email-reply-phase.html", + alias=alias, + contact=contact, + refused_email_url=refused_email_url, + ), + ) + + def handle_spam( contact: Contact, alias: Alias, diff --git a/templates/emails/transactional/bounce-email-reply-phase.html b/templates/emails/transactional/bounce-email-reply-phase.html new file mode 100644 index 00000000..2c0b01bd --- /dev/null +++ b/templates/emails/transactional/bounce-email-reply-phase.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} + +{% block content %} + {% call text() %} +

+ Email cannot be sent to {{ contact.email }} from your alias {{ alias.email }} +

+ {% endcall %} + + {% call text() %} + This might mean {{ contact.email }} + + {% endcall %} + + {{ render_button("View the original email", refused_email_url) }} + + {% call text() %} + This email is automatically deleted in 7 days. + {% endcall %} + + {% call text() %} + Best,
+ SimpleLogin Team. + {% endcall %} + +{% endblock %} + + diff --git a/templates/emails/transactional/bounce-email-reply-phase.txt b/templates/emails/transactional/bounce-email-reply-phase.txt new file mode 100644 index 00000000..84a007be --- /dev/null +++ b/templates/emails/transactional/bounce-email-reply-phase.txt @@ -0,0 +1,16 @@ +Email cannot be sent to {{ contact.email }} from your alias {{ alias.email }} + +This might mean {{ contact.email }} +- is not a valid address, or +- doesn't exist, or +- its mail server refuses your email. + +You can view the email at {{refused_email_url}}. +This email is automatically deleted in 7 days. + +Best, +SimpleLogin Team. + + + +