async 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().strip() # reply_email must end with EMAIL_DOMAIN if not reply_email.endswith(EMAIL_DOMAIN): LOG.warning(f"Reply email {reply_email} has wrong domain") return False, "550 SL E2" contact = Contact.get_by(reply_email=reply_email) if not contact: LOG.warning(f"No such forward-email with {reply_email} as reply-email") return False, "550 SL E4 Email not exist" alias = contact.alias address: str = contact.alias.email alias_domain = address[address.find("@") + 1:] # alias must end with one of the ALIAS_DOMAINS or custom-domain if not email_belongs_to_alias_domains(alias.email): if not CustomDomain.get_by(domain=alias_domain): return False, "550 SL E5" user = alias.user 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 mail_from == "<>": LOG.warning( "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.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): # cannot use 4** here as sender will retry. 5** because that generates bounce report return True, "250 SL E11" email_log = EmailLog.create(contact_id=contact.id, is_reply=True, user_id=contact.user_id) # Spam check spam_status = "" is_spam = False # do not use user.max_spam_score here if SPAMASSASSIN_HOST: start = time.time() spam_score = await get_spam_score(msg) LOG.d( "%s -> %s - spam score %s in %s seconds", alias, contact, spam_score, time.time() - start, ) email_log.spam_score = spam_score if spam_score > MAX_REPLY_PHASE_SPAM_SCORE: is_spam = True spam_status = "Spam detected by SpamAssassin server" else: is_spam, spam_status = get_spam_info( msg, max_score=MAX_REPLY_PHASE_SPAM_SCORE) if is_spam: LOG.exception( "Reply phase - email sent from %s to %s detected as spam", alias, contact) email_log.is_spam = True email_log.spam_status = spam_status db.session.commit() handle_spam(contact, alias, msg, user, mailbox, email_log, is_reply=True) return False, "550 SL E15 Email detected as spam" delete_header(msg, _IP_HEADER) delete_header(msg, "DKIM-Signature") delete_header(msg, "Received") # make the email comes from alias from_header = alias.email # add alias name from alias if alias.name: LOG.d("Put alias name in from header") from_header = formataddr((alias.name, alias.email)) elif alias.custom_domain: LOG.d("Put domain default alias name in from header") # add alias name from domain if alias.custom_domain.name: from_header = formataddr((alias.custom_domain.name, alias.email)) add_or_replace_header(msg, "From", from_header) # some email providers like ProtonMail adds automatically the Reply-To field # make sure to delete it delete_header(msg, "Reply-To") # remove sender header if present as this could reveal user real email delete_header(msg, "Sender") delete_header(msg, "X-Sender") replace_header_when_reply(msg, alias, "To") replace_header_when_reply(msg, alias, "Cc") add_or_replace_header( msg, _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)) add_or_replace_header(msg, _DIRECTION, "Reply") # Received-SPF is injected by postfix-policyd-spf-python can reveal user original email delete_header(msg, "Received-SPF") LOG.d( "send email from %s to %s, mail_options:%s,rcpt_options:%s", alias.email, contact.website_email, envelope.mail_options, envelope.rcpt_options, ) # replace "*****@*****.**" by the contact email in the email body # as this is usually included when replying if user.replace_reverse_alias: if msg.is_multipart(): for part in msg.walk(): if part.get_content_maintype() != "text": continue part = replace_str_in_msg(part, reply_email, contact.website_email) else: msg = replace_str_in_msg(msg, reply_email, contact.website_email) if alias_domain in ALIAS_DOMAINS: add_dkim_signature(msg, alias_domain) # add DKIM-Signature for custom-domain alias else: custom_domain: CustomDomain = CustomDomain.get_by(domain=alias_domain) if custom_domain.dkim_verified: add_dkim_signature(msg, alias_domain) # create PGP email if needed if contact.pgp_finger_print and user.is_premium(): LOG.d("Encrypt message for contact %s", contact) try: msg = prepare_pgp_message(msg, contact.pgp_finger_print) except PGPException: LOG.exception("Cannot encrypt message %s -> %s. %s %s", alias, contact, mailbox, user) # to not save the email_log db.session.rollback() # return 421 so the client can retry later return False, "421 SL E13 Retry later" try: smtp.sendmail( alias.email, contact.website_email, msg.as_bytes(), envelope.mail_options, envelope.rcpt_options, ) except Exception: # to not save the email_log db.session.rollback() LOG.exception("Cannot send email from %s to %s", alias, contact) send_email( mailbox.email, f"Email cannot be sent to {contact.email} from {alias.email}", render( "transactional/reply-error.txt", user=user, alias=alias, contact=contact, contact_domain=get_email_domain_part(contact.email), ), render( "transactional/reply-error.html", user=user, alias=alias, contact=contact, contact_domain=get_email_domain_part(contact.email), ), ) db.session.commit() return True, "250 Message accepted for delivery"
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 is_spam, spam_status = get_spam_info(msg) if is_spam: LOG.warning("Email detected as spam. Alias: %s, from: %s", alias, contact) email_log.is_spam = True email_log.spam_status = spam_status handle_spam(contact, alias, msg, user, mailbox.email, email_log) return False, "550 SL E1" # create PGP email if needed if mailbox.pgp_finger_print and user.is_premium(): LOG.d("Encrypt message using mailbox %s", mailbox) msg = prepare_pgp_message(msg, mailbox.pgp_finger_print) # add custom header add_or_replace_header(msg, "X-SimpleLogin-Type", "Forward") # remove reply-to & sender header if present delete_header(msg, "Reply-To") 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 # replace the email part in from: header contact_from_header = msg["From"] new_from_header = contact.new_addr() add_or_replace_header(msg, "From", new_from_header) LOG.d("new_from_header:%s, old header %s", new_from_header, contact_from_header) # replace CC & To emails by reply-email for all emails that are not alias replace_header_when_forward(msg, alias, "Cc") replace_header_when_forward(msg, alias, "To") # append alias into the TO header if it's not present in To or CC if should_append_alias(msg, alias.email): LOG.d("append alias %s to TO header %s", alias, msg["To"]) if msg["To"]: to_header = msg["To"] + "," + alias.email else: to_header = alias.email add_or_replace_header(msg, "To", to_header.strip()) # add List-Unsubscribe header if UNSUBSCRIBER: unsubscribe_link = f"mailto:{UNSUBSCRIBER}?subject={alias.id}=" add_or_replace_header(msg, "List-Unsubscribe", f"<{unsubscribe_link}>") else: unsubscribe_link = f"{URL}/dashboard/unsubscribe/{alias.id}" add_or_replace_header(msg, "List-Unsubscribe", f"<{unsubscribe_link}>") add_or_replace_header(msg, "List-Unsubscribe-Post", "List-Unsubscribe=One-Click") add_dkim_signature(msg, EMAIL_DOMAIN) LOG.d( "Forward mail from %s to %s, mail_options %s, rcpt_options %s ", contact.website_email, mailbox.email, envelope.mail_options, envelope.rcpt_options, ) # smtp.send_message has UnicodeEncodeErroremail issue # encode message raw directly instead smtp.sendmail( contact.reply_email, mailbox.email, msg.as_bytes(), envelope.mail_options, envelope.rcpt_options, ) db.session.commit() return True, "250 Message accepted for delivery"
async 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) # sanity check: make sure mailbox is not actually an alias if get_email_domain_part(alias.email) == get_email_domain_part( mailbox.email): LOG.exception( "Mailbox has the same domain as alias. %s -> %s -> %s", contact, alias, mailbox, ) return False, "550 SL E14" # Spam check spam_status = "" is_spam = False if SPAMASSASSIN_HOST: start = time.time() spam_score = await get_spam_score(msg) LOG.d( "%s -> %s - spam score %s in %s seconds", contact, alias, spam_score, time.time() - start, ) email_log.spam_score = spam_score db.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): is_spam = True spam_status = "Spam detected by SpamAssassin server" else: is_spam, spam_status = get_spam_info(msg, max_score=user.max_spam_score) if is_spam: LOG.warning("Email detected as spam. Alias: %s, from: %s", alias, contact) email_log.is_spam = True email_log.spam_status = spam_status db.session.commit() handle_spam(contact, alias, msg, user, mailbox, email_log) return False, "550 SL E1 Email detected as spam" # create PGP email if needed if mailbox.pgp_finger_print and user.is_premium( ) and not alias.disable_pgp: LOG.d("Encrypt message using mailbox %s", mailbox) try: msg = prepare_pgp_message(msg, mailbox.pgp_finger_print) except PGPException: LOG.exception("Cannot encrypt message %s -> %s. %s %s", contact, alias, mailbox, user) # so the client can retry later return False, "421 SL E12 Retry later" # add custom header add_or_replace_header(msg, _DIRECTION, "Forward") # remove reply-to & sender header if present delete_header(msg, "Reply-To") delete_header(msg, "Sender") delete_header(msg, _IP_HEADER) add_or_replace_header(msg, _MAILBOX_ID_HEADER, str(mailbox.id)) add_or_replace_header(msg, _EMAIL_LOG_ID_HEADER, str(email_log.id)) add_or_replace_header(msg, _MESSAGE_ID, make_msgid(str(email_log.id), EMAIL_DOMAIN)) # change the from header so the sender comes from @SL # so it can pass DMARC check # replace the email part in from: header contact_from_header = msg["From"] new_from_header = contact.new_addr() add_or_replace_header(msg, "From", new_from_header) LOG.d("new_from_header:%s, old header %s", new_from_header, contact_from_header) # replace CC & To emails by reply-email for all emails that are not alias replace_header_when_forward(msg, alias, "Cc") replace_header_when_forward(msg, alias, "To") # append alias into the TO header if it's not present in To or CC if should_append_alias(msg, alias.email): LOG.d("append alias %s to TO header %s", alias, msg["To"]) if msg["To"]: to_header = msg["To"] + "," + alias.email else: to_header = alias.email add_or_replace_header(msg, "To", to_header.strip()) # add List-Unsubscribe header if UNSUBSCRIBER: unsubscribe_link = f"mailto:{UNSUBSCRIBER}?subject={alias.id}=" add_or_replace_header(msg, "List-Unsubscribe", f"<{unsubscribe_link}>") else: unsubscribe_link = f"{URL}/dashboard/unsubscribe/{alias.id}" add_or_replace_header(msg, "List-Unsubscribe", f"<{unsubscribe_link}>") add_or_replace_header(msg, "List-Unsubscribe-Post", "List-Unsubscribe=One-Click") add_dkim_signature(msg, EMAIL_DOMAIN) LOG.d( "Forward mail from %s to %s, mail_options %s, rcpt_options %s ", contact.website_email, mailbox.email, envelope.mail_options, envelope.rcpt_options, ) # smtp.send_message has UnicodeEncodeErroremail issue # encode message raw directly instead smtp.sendmail( contact.reply_email, mailbox.email, msg.as_bytes(), envelope.mail_options, envelope.rcpt_options, ) db.session.commit() return True, "250 Message accepted for delivery"