def test_alias_contacts(flask_client): user = User.create(email="[email protected]", password="******", name="Test User", activated=True) db.session.commit() # create api_key api_key = ApiKey.create(user.id, "for test") db.session.commit() gen_email = GenEmail.create_new_random(user) db.session.commit() # create some alias log for i in range(PAGE_LIMIT + 1): forward_email = ForwardEmail.create( website_email=f"marketing-{i}@example.com", reply_email=f"reply-{i}@a.b", gen_email_id=gen_email.id, ) db.session.commit() ForwardEmailLog.create(forward_id=forward_email.id, is_reply=True) db.session.commit() r = flask_client.get( url_for("api.get_alias_contacts_route", alias_id=gen_email.id, page_id=0), headers={"Authentication": api_key.code}, ) assert r.status_code == 200 assert len(r.json["contacts"]) == PAGE_LIMIT for ac in r.json["contacts"]: assert ac["creation_date"] assert ac["creation_timestamp"] assert ac["last_email_sent_date"] assert ac["last_email_sent_timestamp"] assert ac["contact"] assert ac["reverse_alias"] # second page, should return 1 result only r = flask_client.get( url_for("api.get_alias_contacts_route", alias_id=gen_email.id, page_id=1), headers={"Authentication": api_key.code}, ) assert len(r.json["contacts"]) == 1
def test_alias_activities(flask_client): user = User.create(email="[email protected]", password="******", name="Test User", activated=True) db.session.commit() # create api_key api_key = ApiKey.create(user.id, "for test") db.session.commit() gen_email = GenEmail.create_new_random(user.id) db.session.commit() # create some alias log forward_email = ForwardEmail.create( website_email="*****@*****.**", reply_email="[email protected]", gen_email_id=gen_email.id, ) db.session.commit() for _ in range(int(PAGE_LIMIT / 2)): ForwardEmailLog.create(forward_id=forward_email.id, is_reply=True) for _ in range(int(PAGE_LIMIT / 2) + 2): ForwardEmailLog.create(forward_id=forward_email.id, blocked=True) r = flask_client.get( url_for("api.get_alias_activities", alias_id=gen_email.id, page_id=0), headers={"Authentication": api_key.code}, ) assert r.status_code == 200 assert len(r.json["activities"]) == PAGE_LIMIT for ac in r.json["activities"]: assert ac["action"] assert ac["from"] assert ac["action"] assert ac["action"] # second page, should return 1 or 2 results only r = flask_client.get( url_for("api.get_alias_activities", alias_id=gen_email.id, page_id=1), headers={"Authentication": api_key.code}, ) assert len(r.json["activities"]) < 3
def handle_reply(self, envelope, smtp: SMTP, msg: Message) -> str: reply_email = envelope.rcpt_tos[0].lower() # reply_email must end with EMAIL_DOMAIN if not reply_email.endswith(EMAIL_DOMAIN): LOG.error(f"Reply email {reply_email} has wrong domain") return "550 wrong reply email" forward_email = ForwardEmail.get_by(reply_email=reply_email) alias: str = forward_email.gen_email.email alias_domain = alias[alias.find("@") + 1:] # alias must end with one of the ALIAS_DOMAINS or custom-domain if not email_belongs_to_alias_domains(alias): if not CustomDomain.get_by(domain=alias_domain): return "550 alias unknown by SimpleLogin" user_email = forward_email.gen_email.user.email if envelope.mail_from.lower() != user_email.lower(): LOG.error( f"Reply email can only be used by user email. Actual mail_from: %s. msg from header: %s, User email %s. reply_email %s", envelope.mail_from, msg["From"], user_email, reply_email, ) send_reply_alias_must_use_personal_email( forward_email.gen_email.user, forward_email.gen_email.email, envelope.mail_from, ) send_email( envelope.mail_from, f"Your email ({envelope.mail_from}) is not allowed to send email to {reply_email}", "", "", ) return "550 ignored" delete_header(msg, "DKIM-Signature") # the email comes from alias msg.replace_header("From", alias) # some email providers like ProtonMail adds automatically the Reply-To field # make sure to delete it delete_header(msg, "Reply-To") msg.replace_header("To", forward_email.website_email) # add List-Unsubscribe header unsubscribe_link = f"{URL}/dashboard/unsubscribe/{forward_email.gen_email_id}" add_or_replace_header(msg, "List-Unsubscribe", f"<{unsubscribe_link}>") add_or_replace_header(msg, "List-Unsubscribe-Post", "List-Unsubscribe=One-Click") # 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, forward_email.website_email, envelope.mail_options, envelope.rcpt_options, ) 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) msg_raw = msg.as_string().encode() smtp.sendmail( alias, forward_email.website_email, msg_raw, envelope.mail_options, envelope.rcpt_options, ) ForwardEmailLog.create(forward_id=forward_email.id, is_reply=True) db.session.commit() return "250 Message accepted for delivery"
def handle_forward(self, envelope, smtp: SMTP, msg: Message) -> str: """return *status_code message*""" alias = envelope.rcpt_tos[0].lower() # alias@SL gen_email = GenEmail.get_by(email=alias) if not gen_email: LOG.d( "alias %s not exist. Try to see if it can be created on the fly", alias) # try to see if alias could be created on-the-fly on_the_fly = False # check if alias belongs to a directory, ie having directory/anything@EMAIL_DOMAIN format if email_belongs_to_alias_domains(alias): if "/" in alias or "+" in alias or "#" in alias: if "/" in alias: sep = "/" elif "+" in alias: sep = "+" else: sep = "#" directory_name = alias[:alias.find(sep)] LOG.d("directory_name %s", directory_name) directory = Directory.get_by(name=directory_name) # Only premium user can use the directory feature if directory: dir_user = directory.user if dir_user.is_premium(): LOG.d("create alias %s for directory %s", alias, directory) on_the_fly = True gen_email = GenEmail.create( email=alias, user_id=directory.user_id, directory_id=directory.id, ) db.session.commit() else: LOG.error( "User %s is not premium anymore and cannot create alias with directory", dir_user, ) send_cannot_create_directory_alias( dir_user, alias, directory_name) # try to create alias on-the-fly with custom-domain catch-all feature # check if alias is custom-domain alias and if the custom-domain has catch-all enabled if not on_the_fly: alias_domain = get_email_domain_part(alias) custom_domain = CustomDomain.get_by(domain=alias_domain) # Only premium user can continue using the catch-all feature if custom_domain and custom_domain.catch_all: domain_user = custom_domain.user if domain_user.is_premium(): LOG.d("create alias %s for domain %s", alias, custom_domain) on_the_fly = True gen_email = GenEmail.create( email=alias, user_id=custom_domain.user_id, custom_domain_id=custom_domain.id, automatic_creation=True, ) db.session.commit() else: LOG.error( "User %s is not premium anymore and cannot create alias with domain %s", domain_user, alias_domain, ) send_cannot_create_domain_alias( domain_user, alias, alias_domain) if not on_the_fly: LOG.d("alias %s cannot be created on-the-fly, return 510", alias) return "510 Email not exist" user_email = gen_email.user.email website_email = get_email_part(msg["From"]) forward_email = ForwardEmail.get_by(gen_email_id=gen_email.id, website_email=website_email) if not forward_email: LOG.debug( "create forward email for alias %s and website email %s", alias, website_email, ) # generate a reply_email, make sure it is unique # not use while to avoid infinite loop for _ in range(1000): reply_email = f"reply+{random_string(30)}@{EMAIL_DOMAIN}" if not ForwardEmail.get_by(reply_email=reply_email): break forward_email = ForwardEmail.create( gen_email_id=gen_email.id, website_email=website_email, website_from=msg["From"], reply_email=reply_email, ) db.session.commit() forward_log = ForwardEmailLog.create(forward_id=forward_email.id) if gen_email.enabled: # add custom header add_or_replace_header(msg, "X-SimpleLogin-Type", "Forward") # remove reply-to header if present delete_header(msg, "Reply-To") # change the from header so the sender comes from @SL # so it can pass DMARC check # replace the email part in from: header from_header = (get_email_name(msg["From"]) + " - " + website_email.replace("@", " at ") + f" <{forward_email.reply_email}>") msg.replace_header("From", from_header) LOG.d("new from header:%s", from_header) # add List-Unsubscribe header unsubscribe_link = f"{URL}/dashboard/unsubscribe/{gen_email.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 ", website_email, user_email, envelope.mail_options, envelope.rcpt_options, ) # smtp.send_message has UnicodeEncodeErroremail issue # encode message raw directly instead msg_raw = msg.as_string().encode() smtp.sendmail( forward_email.reply_email, user_email, msg_raw, envelope.mail_options, envelope.rcpt_options, ) else: LOG.d("%s is disabled, do not forward", gen_email) forward_log.blocked = True db.session.commit() return "250 Message accepted for delivery"
def handle_reply(self, envelope, smtp: SMTP, msg: EmailMessage) -> str: reply_email = envelope.rcpt_tos[0] # reply_email must end with EMAIL_DOMAIN if not reply_email.endswith(EMAIL_DOMAIN): LOG.error(f"Reply email {reply_email} has wrong domain") return "550 wrong reply email" forward_email = ForwardEmail.get_by(reply_email=reply_email) alias: str = forward_email.gen_email.email # alias must end with EMAIL_DOMAIN or custom-domain alias_domain = alias[alias.find("@") + 1 :] if alias_domain != EMAIL_DOMAIN: if not CustomDomain.get_by(domain=alias_domain): return "550 alias unknown by SimpleLogin" user_email = forward_email.gen_email.user.email if envelope.mail_from != user_email: LOG.error( f"Reply email can only be used by user email. Actual mail_from: %s. User email %s", envelope.mail_from, user_email, ) send_email( envelope.mail_from, f"Your email ({envelope.mail_from}) is not allowed to send email to {reply_email}", "", "", ) return "250 ignored" # remove DKIM-Signature if msg["DKIM-Signature"]: LOG.d("Remove DKIM-Signature %s", msg["DKIM-Signature"]) del msg["DKIM-Signature"] # email seems to come from alias msg.replace_header("From", alias) msg.replace_header("To", forward_email.website_email) # add List-Unsubscribe header unsubscribe_link = f"{URL}/dashboard/unsubscribe/{forward_email.gen_email_id}" add_or_replace_header(msg, "List-Unsubscribe", f"<{unsubscribe_link}>") add_or_replace_header( msg, "List-Unsubscribe-Post", "List-Unsubscribe=One-Click" ) LOG.d( "send email from %s to %s, mail_options:%s,rcpt_options:%s", alias, forward_email.website_email, envelope.mail_options, envelope.rcpt_options, ) if alias_domain == EMAIL_DOMAIN: add_dkim_signature(msg, EMAIL_DOMAIN) # add DKIM-Signature for non-custom-domain alias else: custom_domain: CustomDomain = CustomDomain.get_by(domain=alias_domain) if custom_domain.dkim_verified: add_dkim_signature(msg, alias_domain) msg_raw = msg.as_string().encode() smtp.sendmail( alias, forward_email.website_email, msg_raw, envelope.mail_options, envelope.rcpt_options, ) ForwardEmailLog.create(forward_id=forward_email.id, is_reply=True) db.session.commit() return "250 Message accepted for delivery"
def handle_forward(self, envelope, smtp: SMTP, msg: EmailMessage) -> str: """return *status_code message*""" alias = envelope.rcpt_tos[0] # alias@SL gen_email = GenEmail.get_by(email=alias) if not gen_email: LOG.d("alias %s not exist") return "510 Email not exist" user_email = gen_email.user.email website_email = get_email_part(msg["From"]) forward_email = ForwardEmail.get_by( gen_email_id=gen_email.id, website_email=website_email ) if not forward_email: LOG.debug( "create forward email for alias %s and website email %s", alias, website_email, ) # generate a reply_email, make sure it is unique # not use while to avoid infinite loop for _ in range(1000): reply_email = f"reply+{random_string(30)}@{EMAIL_DOMAIN}" if not ForwardEmail.get_by(reply_email=reply_email): break forward_email = ForwardEmail.create( gen_email_id=gen_email.id, website_email=website_email, website_from=msg["From"], reply_email=reply_email, ) db.session.commit() forward_log = ForwardEmailLog.create(forward_id=forward_email.id) if gen_email.enabled: # add custom header add_or_replace_header(msg, "X-SimpleLogin-Type", "Forward") # remove reply-to header if present if msg["Reply-To"]: LOG.d("Delete reply-to header %s", msg["Reply-To"]) del msg["Reply-To"] # change the from header so the sender comes from @SL # so it can pass DMARC check # replace the email part in from: header from_header = ( get_email_name(msg["From"]) + " - " + website_email.replace("@", " at ") + f" <{forward_email.reply_email}>" ) msg.replace_header("From", from_header) LOG.d("new from header:%s", from_header) # add List-Unsubscribe header unsubscribe_link = f"{URL}/dashboard/unsubscribe/{gen_email.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 ", website_email, user_email, envelope.mail_options, envelope.rcpt_options, ) # smtp.send_message has UnicodeEncodeErroremail issue # encode message raw directly instead msg_raw = msg.as_string().encode() smtp.sendmail( forward_email.reply_email, user_email, msg_raw, envelope.mail_options, envelope.rcpt_options, ) else: LOG.d("%s is disabled, do not forward", gen_email) forward_log.blocked = True db.session.commit() return "250 Message accepted for delivery"
def handle_bounce( alias, envelope, forward_email, gen_email, msg, smtp, user, mailbox_email ): ForwardEmailLog.create(forward_id=forward_email.id, bounced=True) db.session.commit() nb_bounced = ForwardEmailLog.filter_by( forward_id=forward_email.id, bounced=True ).count() disable_alias_link = f"{URL}/dashboard/unsubscribe/{gen_email.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, forward_email.website_from, alias, ) send_email( mailbox_email, f"Email from {forward_email.website_from} to {alias} cannot be delivered to your inbox", render( "transactional/bounced-email.txt", name=user.name, alias=alias, website_from=forward_email.website_from, website_email=forward_email.website_email, disable_alias_link=disable_alias_link, ), render( "transactional/bounced-email.html", name=user.name, alias=alias, website_from=forward_email.website_from, website_email=forward_email.website_email, disable_alias_link=disable_alias_link, ), 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 ", alias, forward_email.website_from, ) gen_email.enabled = False db.session.commit() send_email( mailbox_email, f"Alias {alias} has been disabled due to second undelivered email from {forward_email.website_from}", render( "transactional/automatic-disable-alias.txt", name=user.name, alias=alias, website_from=forward_email.website_from, website_email=forward_email.website_email, ), render( "transactional/automatic-disable-alias.html", name=user.name, alias=alias, website_from=forward_email.website_from, website_email=forward_email.website_email, ), bounced_email=msg, )
def handle_reply(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> str: reply_email = rcpt_to.lower() # 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 "550 wrong reply email" forward_email = ForwardEmail.get_by(reply_email=reply_email) if not forward_email: LOG.warning(f"No such forward-email with {reply_email} as reply-email") return "550 wrong reply email" alias: str = forward_email.gen_email.email alias_domain = alias[alias.find("@") + 1 :] # alias must end with one of the ALIAS_DOMAINS or custom-domain if not email_belongs_to_alias_domains(alias): if not CustomDomain.get_by(domain=alias_domain): return "550 alias unknown by SimpleLogin" gen_email = forward_email.gen_email user = gen_email.user mailbox_email = gen_email.mailbox_email() # 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 == "<>": LOG.error("Bounce when sending to alias %s, user %s", alias, gen_email.user) handle_bounce( alias, envelope, forward_email, gen_email, msg, smtp, user, mailbox_email ) return "550 ignored" # only mailbox can send email to the reply-email if envelope.mail_from.lower() != mailbox_email.lower(): LOG.warning( f"Reply email can only be used by user email. Actual mail_from: %s. msg from header: %s, User email %s. reply_email %s", envelope.mail_from, msg["From"], mailbox_email, reply_email, ) user = gen_email.user send_email( mailbox_email, f"Reply from your alias {alias} only works from your mailbox", render( "transactional/reply-must-use-personal-email.txt", name=user.name, alias=alias, sender=envelope.mail_from, mailbox_email=mailbox_email, ), render( "transactional/reply-must-use-personal-email.html", name=user.name, alias=alias, sender=envelope.mail_from, mailbox_email=mailbox_email, ), ) # Notify sender that they cannot send emails to this address send_email( 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, ), "", ) return "550 ignored" delete_header(msg, "DKIM-Signature") # the email comes from alias add_or_replace_header(msg, "From", alias) # 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") add_or_replace_header(msg, "To", forward_email.website_email) # add List-Unsubscribe header unsubscribe_link = f"{URL}/dashboard/unsubscribe/{forward_email.gen_email_id}" add_or_replace_header(msg, "List-Unsubscribe", f"<{unsubscribe_link}>") add_or_replace_header(msg, "List-Unsubscribe-Post", "List-Unsubscribe=One-Click") # 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, forward_email.website_email, envelope.mail_options, envelope.rcpt_options, ) 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) msg_raw = msg.as_string().encode() smtp.sendmail( alias, forward_email.website_email, msg_raw, envelope.mail_options, envelope.rcpt_options, ) ForwardEmailLog.create(forward_id=forward_email.id, is_reply=True) db.session.commit() return "250 Message accepted for delivery"
def handle_forward(envelope, smtp: SMTP, msg: Message, rcpt_to: str) -> str: """return *status_code message*""" alias = rcpt_to.lower() # alias@SL gen_email = GenEmail.get_by(email=alias) if not gen_email: LOG.d("alias %s not exist. Try to see if it can be created on the fly", alias) gen_email = try_auto_create(alias) if not gen_email: LOG.d("alias %s cannot be created on-the-fly, return 510", alias) return "510 Email not exist" mailbox = gen_email.mailbox mailbox_email = mailbox.email # create PGP email if needed if mailbox.pgp_finger_print: LOG.d("Encrypt message using mailbox %s", mailbox) msg = prepare_pgp_message(msg, mailbox.pgp_finger_print) forward_email = get_or_create_forward_email(msg["From"], gen_email) forward_log = ForwardEmailLog.create(forward_id=forward_email.id) if gen_email.enabled: # 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") # change the from header so the sender comes from @SL # so it can pass DMARC check # replace the email part in from: header website_from_header = msg["From"] website_email = get_email_part(website_from_header) from_header = ( get_email_name(website_from_header) + ("" if get_email_name(website_from_header) == "" else " - ") + website_email.replace("@", " at ") + f" <{forward_email.reply_email}>" ) add_or_replace_header(msg, "From", from_header) LOG.d("new from header:%s", from_header) # append alias into the TO header if it's not present in To or CC if should_append_alias(msg, alias): LOG.d("append alias %s to TO header %s", alias, msg["To"]) if msg["To"]: to_header = msg["To"] + "," + alias else: to_header = alias add_or_replace_header(msg, "To", to_header) # add List-Unsubscribe header unsubscribe_link = f"{URL}/dashboard/unsubscribe/{gen_email.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 ", website_email, mailbox_email, envelope.mail_options, envelope.rcpt_options, ) # smtp.send_message has UnicodeEncodeErroremail issue # encode message raw directly instead msg_raw = msg.as_string().encode() smtp.sendmail( forward_email.reply_email, mailbox_email, msg_raw, envelope.mail_options, envelope.rcpt_options, ) else: LOG.d("%s is disabled, do not forward", gen_email) forward_log.blocked = True db.session.commit() return "250 Message accepted for delivery"