diff --git a/app/email/status.py b/app/email/status.py new file mode 100644 index 00000000..f59dad70 --- /dev/null +++ b/app/email/status.py @@ -0,0 +1,39 @@ +# 2** status +E200 = "250 Message accepted for delivery" +E201 = "250 SL E201" +E202 = "250 Unsubscribe request accepted" +E203 = "250 SL E203 email can't be sent from a reverse-alias" +E204 = "250 SL E204 ignore" +E205 = "250 SL E205 bounce handled" +# out of office status +E206 = "250 SL E206 Out of office" + +# 4** errors +E401 = "421 SL E401 Retry later" +E402 = "421 SL E402 Retry later" +E403 = "421 SL E403 Retry later" +E404 = "421 SL E404 Retry later" +E405 = "421 SL E405 Retry later" +E406 = "421 SL E406 Retry later" + +# 5** errors +E501 = "550 SL E501" +E502 = "550 SL E502 Email not exist" +E503 = "550 SL E503" +E504 = "550 SL E504 Account disabled" +E505 = "550 SL E505" +E506 = "550 SL E506 Email detected as spam" +E507 = "550 SL E507 Wrongly formatted subject" +E508 = "550 SL E508 Email not exist" +E509 = "550 SL E509 unauthorized" +E510 = "550 SL E510 so such user" +E511 = "550 SL E511 unsubscribe error" +E512 = "550 SL E512 No such email log" +E513 = "550 SL E513 Email cannot be forwarded to mailbox" +E514 = "550 SL E514 Email sent to noreply address" +E515 = "550 SL E515 Email not exist" +E516 = "550 SL E516 invalid mailbox" +E517 = "550 SL E517 unverified mailbox" +E518 = "550 SL E518 Disabled mailbox" +E519 = "550 SL E519 Email detected as spam" +E520 = "550 SL E24 Email cannot be sent to contact" diff --git a/email_handler.py b/email_handler.py index 087abf96..b4ff15b9 100644 --- a/email_handler.py +++ b/email_handler.py @@ -75,6 +75,7 @@ from app.config import ( ENABLE_SPAM_ASSASSIN, BOUNCE_PREFIX_FOR_REPLY_PHASE, ) +from app.email import status from app.email.spam import get_spam_score from app.email_utils import ( send_email, @@ -485,13 +486,13 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str 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 Email not exist")] + return [(False, status.E515)] user = alias.user if user.disabled: LOG.w("User %s disabled, disable forwarding emails for %s", user, alias) - return [(False, "550 SL E20 Account disabled")] + return [(False, status.E504)] # mail_from = envelope.mail_from # for mb in alias.mailboxes: @@ -518,20 +519,20 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str ) 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, status.E200)] ret = [] mailboxes = alias.mailboxes # no valid mailbox if not mailboxes: - return [(False, "550 SL E16 invalid mailbox")] + return [(False, status.E516)] # no need to create a copy of message for mailbox in mailboxes: if not mailbox.verified: LOG.d("%s unverified, do not forward", mailbox) - ret.append((False, "550 SL E19 unverified mailbox")) + ret.append((False, status.E517)) else: # create a copy of message for each forward ret.append( @@ -560,7 +561,7 @@ def forward_email_to_mailbox( if mailbox.disabled: LOG.debug("%s disabled, do not forward") - return False, "550 SL E21 Disabled mailbox" + return False, status.E518 # sanity check: make sure mailbox is not actually an alias if get_email_domain_part(alias.email) == get_email_domain_part(mailbox.email): @@ -593,7 +594,7 @@ def forward_email_to_mailbox( # retry later # so when user fixes the mailbox, the email can be delivered - return False, "421 SL E14" + return False, status.E405 email_log = EmailLog.create( contact_id=contact.id, user_id=user.id, mailbox_id=mailbox.id, commit=True @@ -641,7 +642,7 @@ def forward_email_to_mailbox( db.session.commit() handle_spam(contact, alias, msg, user, mailbox, email_log) - return False, "550 SL E1 Email detected as spam" + return False, status.E519 if contact.invalid_email: LOG.d("add noreply information %s %s", alias, mailbox) @@ -684,11 +685,11 @@ def forward_email_to_mailbox( msg, mailbox.pgp_finger_print, mailbox.pgp_public_key, can_sign=True ) except PGPException: - LOG.exception( + LOG.e( "Cannot encrypt message %s -> %s. %s %s", contact, alias, mailbox, user ) # so the client can retry later - return False, "421 SL E12 Retry later" + return False, status.E406 # add custom header add_or_replace_header(msg, _DIRECTION, "Forward") @@ -750,10 +751,10 @@ def forward_email_to_mailbox( alias, mailbox, ) - return False, "421 SL E17 Retry later" + return False, status.E401 else: db.session.commit() - return True, "250 Message accepted for delivery" + return True, status.E200 def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str): @@ -766,7 +767,7 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str): # reply_email must end with EMAIL_DOMAIN if not reply_email.endswith(EMAIL_DOMAIN): LOG.w(f"Reply email {reply_email} has wrong domain") - return False, "550 SL E2" + return False, status.E501 # handle case where reply email is generated with non-allowed char reply_email = normalize_reply_email(reply_email) @@ -774,7 +775,7 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str): contact = Contact.get_by(reply_email=reply_email) if not contact: LOG.w(f"No such forward-email with {reply_email} as reply-email") - return False, "550 SL E4 Email not exist" + return False, status.E502 alias = contact.alias address: str = contact.alias.email @@ -784,7 +785,7 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str): # scenario: a user have removed a domain but due to a bug, the aliases are still there if not is_valid_alias_address_domain(alias.email): LOG.exception("%s domain isn't known", alias) - return False, "550 SL E5" + return False, status.E503 user = alias.user mail_from = envelope.mail_from @@ -796,7 +797,7 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str): alias, contact, ) - return [(False, "550 SL E20 Account disabled")] + return [(False, status.E504)] # Anti-spoofing mailbox = get_mailbox_from_mail_from(mail_from, alias) @@ -813,12 +814,13 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str): else: # only mailbox can send email to the reply-email handle_unknown_mailbox(envelope, msg, reply_email, user, alias, contact) - return False, "550 SL E7" + return False, status.E505 if 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. 5** because that generates bounce report - return True, "250 SL E11" + # cannot use 4** here as sender will retry. + # cannot use 5** because that generates bounce report + return True, status.E201 email_log = EmailLog.create( contact_id=contact.id, @@ -870,7 +872,7 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str): db.session.commit() handle_spam(contact, alias, msg, user, mailbox, email_log, is_reply=True) - return False, "550 SL E15 Email detected as spam" + return False, status.E506 delete_all_headers_except( msg, @@ -907,7 +909,7 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str): EmailLog.delete(email_log.id) db.session.commit() # return 421 so the client can retry later - return False, "421 SL E13 Retry later" + return False, status.E402 db.session.commit() @@ -991,8 +993,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 - - return True, "250 Message accepted for delivery" + return True, status.E200 def get_mailbox_from_mail_from(mail_from: str, alias) -> Optional[Mailbox]: @@ -1262,7 +1263,7 @@ def handle_bounce_reply_phase(envelope, msg: Message, email_log: EmailLog): refused_email_url=refused_email_url, ), ) - return "550 SL E24 Email cannot be sent to contact" + return status.E520 def handle_spam( @@ -1381,11 +1382,11 @@ def handle_unsubscribe(envelope: Envelope, msg: Message) -> str: alias = Alias.get(alias_id) except Exception: LOG.w("Cannot parse alias from subject %s", msg["Subject"]) - return "550 SL E8 Wrongly formatted subject" + return status.E507 if not alias: LOG.w("No such alias %s", alias_id) - return "550 SL E9 Email not exist" + return status.E508 # This sender cannot unsubscribe mail_from = envelope.mail_from @@ -1393,7 +1394,7 @@ def handle_unsubscribe(envelope: Envelope, msg: Message) -> str: mailbox = get_mailbox_from_mail_from(mail_from, alias) if not mailbox: LOG.d("%s cannot disable alias %s", envelope.mail_from, alias) - return "550 SL E10 unauthorized" + return status.E509 # Sender is owner of this alias alias.enabled = False @@ -1419,7 +1420,7 @@ def handle_unsubscribe(envelope: Envelope, msg: Message) -> str: ), ) - return "250 Unsubscribe request accepted" + return status.E202 def handle_unsubscribe_user(user_id: int, mail_from: str) -> str: @@ -1427,11 +1428,11 @@ def handle_unsubscribe_user(user_id: int, mail_from: str) -> str: user = User.get(user_id) if not user: LOG.exception("No such user %s %s", user_id, mail_from) - return "550 SL E22 so such user" + return status.E510 if mail_from != user.email: LOG.exception("Unauthorized mail_from %s %s", user, mail_from) - return "550 SL E23 unsubscribe error" + return status.E511 user.notification = False db.session.commit() @@ -1449,7 +1450,7 @@ def handle_unsubscribe_user(user_id: int, mail_from: str) -> str: ), ) - return "250 Unsubscribe request accepted" + return status.E202 def handle_transactional_bounce(envelope: Envelope, rcpt_to): @@ -1472,7 +1473,7 @@ def handle_bounce(envelope, email_log: EmailLog, msg: Message) -> str: if not email_log: LOG.w("No such email log") - return "550 SL E27 No such email log" + return status.E512 contact: Contact = email_log.contact alias = contact.alias @@ -1524,7 +1525,7 @@ def handle_bounce(envelope, email_log: EmailLog, msg: Message) -> str: return handle_bounce_reply_phase(envelope, msg, email_log) else: # forward phase handle_bounce_forward_phase(msg, email_log) - return "550 SL E26 Email cannot be forwarded to mailbox" + return status.E513 def should_ignore(mail_from: str, rcpt_tos: List[str]) -> bool: @@ -1556,7 +1557,7 @@ def handle(envelope: Envelope) -> str: if should_ignore(mail_from, rcpt_tos): LOG.e("Ignore email mail_from=%s rcpt_to=%s", mail_from, rcpt_tos) - return "250 email can't be sent from a reverse-alias" + return status.E204 # sanitize email headers sanitize_header(msg, "from") @@ -1584,7 +1585,7 @@ def handle(envelope: Envelope) -> str: contact.alias, contact.website_email, ) - return "250 email can't be sent from a reverse-alias" + return status.E203 # unsubscribe request if UNSUBSCRIBER and rcpt_tos == [UNSUBSCRIBER]: @@ -1599,7 +1600,7 @@ def handle(envelope: Envelope) -> str: ): LOG.d("Handle email sent to sender from %s", mail_from) handle_transactional_bounce(envelope, rcpt_tos[0]) - return "250 bounce handled" + return status.E205 # Handle bounce if ( @@ -1638,7 +1639,7 @@ def handle(envelope: Envelope) -> str: # Whether it's necessary to apply greylisting if greylisting_needed(mail_from, rcpt_tos): LOG.w("Grey listing applied for mail_from:%s rcpt_tos:%s", mail_from, rcpt_tos) - return "421 SL Retry later" + return status.E403 # Handle "out of office" auto notice. An automatic response is sent for every forwarded email # todo: remove logging @@ -1646,7 +1647,7 @@ def handle(envelope: Envelope) -> str: LOG.w( "out-of-office email to reverse alias %s. %s", rcpt_tos[0], msg.as_string() ) - return "250 SL E28" + return status.E206 # result of all deliveries # each element is a couple of whether the delivery is successful and the smtp status @@ -1656,7 +1657,7 @@ def handle(envelope: Envelope) -> str: for rcpt_index, rcpt_to in enumerate(rcpt_tos): if rcpt_to == NOREPLY: LOG.e("email sent to noreply address from %s", mail_from) - return "550 SL E25 Email sent to noreply address" + return status.E514 # create a copy of msg for each recipient except the last one # as copy() is a slow function @@ -1704,7 +1705,7 @@ class MailHandler: envelope.mail_from, envelope.rcpt_tos, ) - return "421 SL Retry later" + return status.E404 def _handle(self, envelope: Envelope): start = time.time()