def handle_email_sent_to_ourself(alias, mailbox, msg: Message, user): # store the refused email random_name = str(uuid.uuid4()) full_report_path = f"refused-emails/cycle-{random_name}.eml" s3.upload_email_from_bytesio(full_report_path, BytesIO(msg.as_bytes()), random_name) refused_email = RefusedEmail.create(path=None, full_report_path=full_report_path, user_id=alias.user_id) db.session.commit() LOG.d("Create refused email %s", refused_email) # link available for 6 days as it gets deleted in 7 days refused_email_url = refused_email.get_url(expires_in=518400) send_email_with_rate_control( user, ALERT_SEND_EMAIL_CYCLE, mailbox.email, f"Warning: email sent from {mailbox.email} to {alias.email} forms a cycle", render( "transactional/cycle-email.txt", name=user.name or "", alias=alias, mailbox=mailbox, refused_email_url=refused_email_url, ), render( "transactional/cycle-email.html", name=user.name or "", alias=alias, mailbox=mailbox, refused_email_url=refused_email_url, ), )
def test_send_email_with_rate_control(flask_client): user = User.create(email="[email protected]", password="******", name="Test User", activated=True) Session.commit() for _ in range(MAX_ALERT_24H): assert send_email_with_rate_control(user, "test alert type", "*****@*****.**", "subject", "plaintext") assert not send_email_with_rate_control( user, "test alert type", "*****@*****.**", "subject", "plaintext")
def check_custom_domain(): LOG.d("Check verified domain for DNS issues") for custom_domain in CustomDomain.query.filter_by( verified=True ): # type: CustomDomain mx_domains = get_mx_domains(custom_domain.domain) if sorted(mx_domains) != sorted(EMAIL_SERVERS_WITH_PRIORITY): user = custom_domain.user LOG.warning( "The MX record is not correctly set for %s %s %s", custom_domain, user, mx_domains, ) custom_domain.nb_failed_checks += 1 # send alert if fail for 5 consecutive days if custom_domain.nb_failed_checks > 5: domain_dns_url = f"{URL}/dashboard/domains/{custom_domain.id}/dns" LOG.warning( "Alert domain MX check fails %s about %s", user, custom_domain ) send_email_with_rate_control( user, AlERT_WRONG_MX_RECORD_CUSTOM_DOMAIN, user.email, f"Please update {custom_domain.domain} DNS on SimpleLogin", render( "transactional/custom-domain-dns-issue.txt", custom_domain=custom_domain, domain_dns_url=domain_dns_url, ), render( "transactional/custom-domain-dns-issue.html", custom_domain=custom_domain, domain_dns_url=domain_dns_url, ), max_nb_alert=1, nb_day=30, ) # reset checks custom_domain.nb_failed_checks = 0 else: # reset checks custom_domain.nb_failed_checks = 0 db.session.commit()
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, reverse-alias %s, %s %s", envelope.mail_from, msg["From"], reply_email, alias, user, ) send_email_with_rate_control( user, ALERT_REVERSE_ALIAS_UNKNOWN_MAILBOX, 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, sender=envelope.mail_from, ), render( "transactional/reply-must-use-personal-email.html", name=user.name, alias=alias, sender=envelope.mail_from, ), ) # Notify sender that they cannot send emails to this address send_email_with_rate_control( user, ALERT_REVERSE_ALIAS_UNKNOWN_MAILBOX, envelope.mail_from, f"Your email ({envelope.mail_from}) is not allowed to send emails to {reply_email}", render( "transactional/send-from-alias-from-unknown-sender.txt", sender=envelope.mail_from, reply_email=reply_email, ), render( "transactional/send-from-alias-from-unknown-sender.html", sender=envelope.mail_from, reply_email=reply_email, ), )
def check_custom_domain(): LOG.d("Check verified domain for DNS issues") for custom_domain in CustomDomain.query.filter( CustomDomain.verified == True): mx_domains = get_mx_domains(custom_domain.domain) if sorted(mx_domains) != sorted(EMAIL_SERVERS_WITH_PRIORITY): user = custom_domain.user LOG.warning( "The MX record is not correctly set for %s %s %s", custom_domain, user, mx_domains, ) domain_dns_url = f"{URL}/dashboard/domains/{custom_domain.id}/dns" send_email_with_rate_control( user, AlERT_WRONG_MX_RECORD_CUSTOM_DOMAIN, user.email, f"Please update {custom_domain.domain} DNS on SimpleLogin", render( "transactional/custom-domain-dns-issue.txt", custom_domain=custom_domain, name=user.name or "", domain_dns_url=domain_dns_url, ), render( "transactional/custom-domain-dns-issue.html", custom_domain=custom_domain, name=user.name or "", domain_dns_url=domain_dns_url, ), max_nb_alert=1, nb_day=30, )
def check_single_custom_domain(custom_domain): mx_domains = get_mx_domains(custom_domain.domain) if not is_mx_equivalent(mx_domains, EMAIL_SERVERS_WITH_PRIORITY): user = custom_domain.user LOG.w( "The MX record is not correctly set for %s %s %s", custom_domain, user, mx_domains, ) custom_domain.nb_failed_checks += 1 # send alert if fail for 5 consecutive days if custom_domain.nb_failed_checks > 5: domain_dns_url = f"{URL}/dashboard/domains/{custom_domain.id}/dns" LOG.w("Alert domain MX check fails %s about %s", user, custom_domain) send_email_with_rate_control( user, AlERT_WRONG_MX_RECORD_CUSTOM_DOMAIN, user.email, f"Please update {custom_domain.domain} DNS on SimpleLogin", render( "transactional/custom-domain-dns-issue.txt.jinja2", custom_domain=custom_domain, domain_dns_url=domain_dns_url, ), max_nb_alert=1, nb_day=30, retries=3, ) # reset checks custom_domain.nb_failed_checks = 0 else: # reset checks custom_domain.nb_failed_checks = 0 Session.commit()
def handle_spam( contact: Contact, alias: Alias, msg: Message, user: User, mailbox_email: str, email_log: EmailLog, ): # Store the report & original email orig_msg = get_orig_message_from_spamassassin_report(msg) # generate a name for the email random_name = str(uuid.uuid4()) full_report_path = f"spams/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"spams/{random_name}.eml" s3.upload_email_from_bytesio(file_path, BytesIO(orig_msg.as_bytes()), random_name) refused_email = RefusedEmail.create(path=file_path, full_report_path=full_report_path, user_id=user.id) db.session.flush() email_log.refused_email_id = refused_email.id db.session.commit() LOG.d("Create spam email %s", refused_email) refused_email_url = (URL + f"/dashboard/refused_email?highlight_id=" + str(email_log.id)) disable_alias_link = f"{URL}/dashboard/unsubscribe/{alias.id}" # inform user LOG.d( "Inform user %s about spam email sent by %s to alias %s", user, contact.website_email, alias.email, ) send_email_with_rate_control( user, ALERT_SPAM_EMAIL, mailbox_email, f"Email from {contact.website_email} to {alias.email} is detected as spam", render( "transactional/spam-email.txt", name=user.name, alias=alias, website_email=contact.website_email, disable_alias_link=disable_alias_link, refused_email_url=refused_email_url, ), render( "transactional/spam-email.html", name=user.name, alias=alias, website_email=contact.website_email, disable_alias_link=disable_alias_link, refused_email_url=refused_email_url, ), )
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) db.session.commit() nb_bounced = EmailLog.filter_by(contact_id=contact.id, bounced=True).count() disable_alias_link = f"{URL}/dashboard/unsubscribe/{alias.id}" # <<< 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) if not orig_msg: LOG.error( "Cannot parse original message from bounce message %s %s %s %s", alias, user, contact, full_report_path, ) return file_path = f"refused-emails/{random_name}.eml" s3.upload_email_from_bytesio(file_path, BytesIO(orig_msg.as_bytes()), random_name) # <<< END Store the bounced email >>> 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 %s", mailbox_id, user, alias, contact, full_report_path, ) return refused_email = RefusedEmail.create(path=file_path, full_report_path=full_report_path, user_id=user.id) 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) refused_email_url = (URL + f"/dashboard/refused_email?highlight_id=" + str(email_log.id)) # inform user if this is the first bounced email if nb_bounced == 1: LOG.d( "Inform user %s about bounced email sent by %s to alias %s", user, contact.website_email, address, ) send_email_with_rate_control( user, ALERT_BOUNCE_EMAIL, # use user mail here as only user is authenticated to see the refused email user.email, f"Email from {contact.website_email} to {address} cannot be delivered to your inbox", render( "transactional/bounced-email.txt", name=user.name, alias=alias, website_email=contact.website_email, disable_alias_link=disable_alias_link, refused_email_url=refused_email_url, mailbox_email=mailbox.email, ), render( "transactional/bounced-email.html", name=user.name, alias=alias, website_email=contact.website_email, disable_alias_link=disable_alias_link, refused_email_url=refused_email_url, mailbox_email=mailbox.email, ), # cannot include bounce email as it can contain spammy text # bounced_email=msg, ) # disable the alias the second time email is bounced elif nb_bounced >= 2: LOG.d( "Bounce happens again with alias %s from %s. Disable alias now ", address, contact.website_email, ) alias.enabled = False db.session.commit() send_email_with_rate_control( user, ALERT_BOUNCE_EMAIL, # use user mail here as only user is authenticated to see the refused email user.email, f"Alias {address} has been disabled due to second undelivered email from {contact.website_email}", render( "transactional/automatic-disable-alias.txt", name=user.name, alias=alias, website_email=contact.website_email, refused_email_url=refused_email_url, mailbox_email=mailbox.email, ), render( "transactional/automatic-disable-alias.html", name=user.name, alias=alias, website_email=contact.website_email, refused_email_url=refused_email_url, mailbox_email=mailbox.email, ), # cannot include bounce email as it can contain spammy text # bounced_email=msg, )
def spf_pass( ip: str, envelope, mailbox: Mailbox, user: User, alias: Alias, contact_email: str, msg: Message, ) -> bool: if ip: LOG.d("Enforce SPF") try: r = spf.check2(i=ip, s=envelope.mail_from.lower(), h=None) except Exception: LOG.error("SPF error, mailbox %s, ip %s", mailbox.email, ip) else: # TODO: Handle temperr case (e.g. dns timeout) # only an absolute pass, or no SPF policy at all is 'valid' if r[0] not in ["pass", "none"]: LOG.error( "SPF fail for mailbox %s, reason %s, failed IP %s", mailbox.email, r[0], ip, ) send_email_with_rate_control( user, ALERT_SPF, mailbox.email, f"SimpleLogin Alert: attempt to send emails from your alias {alias.email} from unknown IP Address", render( "transactional/spf-fail.txt", name=user.name, alias=alias.email, ip=ip, mailbox_url=URL + f"/dashboard/mailbox/{mailbox.id}#spf", to_email=contact_email, subject=msg["Subject"], time=arrow.now(), ), render( "transactional/spf-fail.html", name=user.name, alias=alias.email, ip=ip, mailbox_url=URL + f"/dashboard/mailbox/{mailbox.id}#spf", to_email=contact_email, subject=msg["Subject"], time=arrow.now(), ), ) return False else: LOG.warning( "Could not find %s header %s -> %s", _IP_HEADER, mailbox.email, contact_email, ) return True
def handle_bounce(contact: Contact, alias: Alias, msg: Message, user: User): disable_alias_link = f"{URL}/dashboard/unsubscribe/{alias.id}" # 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) file_path = None mailbox = None email_log: EmailLog = None orig_msg = get_orig_message_from_bounce(msg) if not orig_msg: # Some MTA does not return the original message in bounce message # nothing we can do here LOG.warning( "Cannot parse original message from bounce message %s %s %s %s", alias, user, contact, full_report_path, ) else: file_path = f"refused-emails/{random_name}.eml" s3.upload_email_from_bytesio(file_path, BytesIO(orig_msg.as_bytes()), random_name) try: mailbox_id = int(orig_msg[_MAILBOX_ID_HEADER]) except TypeError: LOG.exception( "cannot parse mailbox from original message header %s", orig_msg[_MAILBOX_ID_HEADER], ) else: mailbox = Mailbox.get(mailbox_id) if not mailbox or mailbox.user_id != user.id: LOG.exception( "Tampered message mailbox_id %s, %s, %s, %s %s", mailbox_id, user, alias, contact, full_report_path, ) # cannot use this tampered mailbox, reset it mailbox = None # try to get the original email_log try: email_log_id = int(orig_msg[_EMAIL_LOG_ID_HEADER]) except TypeError: LOG.exception( "cannot parse email log from original message header %s", orig_msg[_EMAIL_LOG_ID_HEADER], ) else: email_log = EmailLog.get(email_log_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) if not mailbox: LOG.debug("Try to get mailbox from bounce report") try: mailbox_id = int(get_header_from_bounce(msg, _MAILBOX_ID_HEADER)) except Exception: LOG.exception("cannot get mailbox-id from bounce report, %s", refused_email) else: mailbox = Mailbox.get(mailbox_id) if not mailbox or mailbox.user_id != user.id: LOG.exception( "Tampered message mailbox_id %s, %s, %s, %s %s", mailbox_id, user, alias, contact, full_report_path, ) mailbox = None if not email_log: LOG.d("Try to get email log from bounce report") try: email_log_id = int( get_header_from_bounce(msg, _EMAIL_LOG_ID_HEADER)) except Exception: LOG.exception("cannot get email log id from bounce report, %s", refused_email) else: email_log = EmailLog.get(email_log_id) # use the default mailbox as the last option if not mailbox: LOG.warning("Use %s default mailbox %s", alias, refused_email) mailbox = alias.mailbox # create a new email log as the last option if not email_log: LOG.warning("cannot get the original email_log, create a new one") email_log: EmailLog = EmailLog.create(contact_id=contact.id, user_id=contact.user_id) email_log.bounced = True email_log.refused_email_id = refused_email.id email_log.bounced_mailbox_id = mailbox.id db.session.commit() refused_email_url = (URL + f"/dashboard/refused_email?highlight_id=" + str(email_log.id)) nb_bounced = EmailLog.filter_by(contact_id=contact.id, bounced=True).count() if nb_bounced >= 2 and alias.cannot_be_disabled: LOG.warning("%s cannot be disabled", alias) # inform user if this is the first bounced email if nb_bounced == 1 or (nb_bounced >= 2 and alias.cannot_be_disabled): LOG.d( "Inform user %s about bounced email sent by %s to alias %s", user, contact.website_email, alias, ) send_email_with_rate_control( user, ALERT_BOUNCE_EMAIL, user.email, f"Email from {contact.website_email} to {alias.email} cannot be delivered to your inbox", render( "transactional/bounced-email.txt", name=user.name, alias=alias, website_email=contact.website_email, disable_alias_link=disable_alias_link, refused_email_url=refused_email_url, mailbox_email=mailbox.email, ), render( "transactional/bounced-email.html", name=user.name, alias=alias, website_email=contact.website_email, disable_alias_link=disable_alias_link, refused_email_url=refused_email_url, mailbox_email=mailbox.email, ), ) # disable the alias the second time email is bounced elif nb_bounced >= 2: LOG.d( "Bounce happens again with alias %s from %s. Disable alias now ", alias, contact.website_email, ) alias.enabled = False db.session.commit() send_email_with_rate_control( user, ALERT_BOUNCE_EMAIL, user.email, f"Alias {alias.email} has been disabled due to second undelivered email from {contact.website_email}", render( "transactional/automatic-disable-alias.txt", name=user.name, alias=alias, website_email=contact.website_email, refused_email_url=refused_email_url, mailbox_email=mailbox.email, ), render( "transactional/automatic-disable-alias.html", name=user.name, alias=alias, website_email=contact.website_email, refused_email_url=refused_email_url, mailbox_email=mailbox.email, ), )
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.warning( "Mailbox has the same domain as alias. %s -> %s -> %s", contact, alias, mailbox, ) mailbox_url = f"{URL}/dashboard/mailbox/{mailbox.id}/" send_email_with_rate_control( user, ALERT_MAILBOX_IS_ALIAS, user.email, f"Your SimpleLogin mailbox {mailbox.email} cannot be an email alias", render( "transactional/mailbox-invalid.txt", name=user.name or "", mailbox=mailbox, mailbox_url=mailbox_url, ), render( "transactional/mailbox-invalid.html", name=user.name or "", mailbox=mailbox, mailbox_url=mailbox_url, ), max_nb_alert=1, ) # retry later # so when user fixes the mailbox, the email can be delivered return False, "421 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 try: smtp.sendmail( contact.reply_email, mailbox.email, msg.as_bytes(), envelope.mail_options, envelope.rcpt_options, ) except SMTPRecipientsRefused: # that means the mailbox is maybe invalid LOG.warning( "SMTPRecipientsRefused forward phase %s -> %s -> %s", contact, alias, mailbox, ) # return 421 so Postfix can retry later return False, "421 SL E17 Retry later" else: db.session.commit() return True, "250 Message accepted for delivery"