def encrypt_file(data: BytesIO, fingerprint: str) -> str: LOG.d("encrypt for %s", fingerprint) mem_usage = memory_usage(-1, interval=1, timeout=1)[0] LOG.d("mem_usage %s", mem_usage) # todo if mem_usage > 300: LOG.exception("Force exit") hard_exit() r = gpg.encrypt_file(data, fingerprint, always_trust=True) if not r.ok: # maybe the fingerprint is not loaded on this host, try to load it mailbox = Mailbox.get_by(pgp_finger_print=fingerprint) if mailbox: LOG.d("(re-)load public key for %s", mailbox) load_public_key(mailbox.pgp_public_key) LOG.d("retry to encrypt") data.seek(0) r = gpg.encrypt_file(data, fingerprint, always_trust=True) if not r.ok: raise PGPException(f"Cannot encrypt, status: {r.status}") return str(r)
def import_from_csv(batch_import: BatchImport, user: User, lines): reader = csv.DictReader(lines) for row in reader: try: full_alias = sanitize_email(row["alias"]) note = row["note"] except KeyError: LOG.warning("Cannot parse row %s", row) continue alias_domain = get_email_domain_part(full_alias) custom_domain = CustomDomain.get_by(domain=alias_domain) if (not custom_domain or not custom_domain.verified or custom_domain.user_id != user.id): LOG.debug("domain %s can't be used %s", alias_domain, user) continue if (Alias.get_by(email=full_alias) or DeletedAlias.get_by(email=full_alias) or DomainDeletedAlias.get_by(email=full_alias)): LOG.d("alias already used %s", full_alias) continue mailboxes = [] if "mailboxes" in row: for mailbox_email in row["mailboxes"].split(): mailbox_email = sanitize_email(mailbox_email) mailbox = Mailbox.get_by(email=mailbox_email) if not mailbox or not mailbox.verified or mailbox.user_id != user.id: LOG.d("mailbox %s can't be used %s", mailbox, user) continue mailboxes.append(mailbox.id) if len(mailboxes) == 0: mailboxes = [user.default_mailbox_id] alias = Alias.create( user_id=user.id, email=full_alias, note=note, mailbox_id=mailboxes[0], custom_domain_id=custom_domain.id, batch_import_id=batch_import.id, ) db.session.commit() db.session.flush() LOG.d("Create %s", alias) for i in range(1, len(mailboxes)): alias_mailbox = AliasMailbox.create( alias_id=alias.id, mailbox_id=mailboxes[i], ) db.session.commit() LOG.d("Create %s", alias_mailbox)
def encrypt_file(data: BytesIO, fingerprint: str) -> str: LOG.d("encrypt for %s", fingerprint) mem_usage = memory_usage(-1, interval=1, timeout=1)[0] LOG.d("mem_usage %s", mem_usage) r = gpg.encrypt_file(data, fingerprint, always_trust=True) if not r.ok: # maybe the fingerprint is not loaded on this host, try to load it found = False # searching for the key in mailbox mailbox = Mailbox.get_by(pgp_finger_print=fingerprint) if mailbox: LOG.d("(re-)load public key for %s", mailbox) load_public_key(mailbox.pgp_public_key) found = True # searching for the key in contact contact = Contact.get_by(pgp_finger_print=fingerprint) if contact: LOG.d("(re-)load public key for %s", contact) load_public_key(contact.pgp_public_key) found = True if found: LOG.d("retry to encrypt") data.seek(0) r = gpg.encrypt_file(data, fingerprint, always_trust=True) if not r.ok: raise PGPException(f"Cannot encrypt, status: {r.status}") return str(r)
def mailbox_already_used(email: str, user) -> bool: if Mailbox.get_by(email=email, user_id=user.id): return True # support the case user wants to re-add their real email as mailbox # can happen when user changes their root email and wants to add this new email as mailbox if email == user.email: return False return False
def handle_unsubscribe(envelope: Envelope): msg = email.message_from_bytes(envelope.original_content) # format: alias_id: subject = msg["Subject"] try: # subject has the format {alias.id}= if subject.endswith("="): alias_id = int(subject[:-1]) # some email providers might strip off the = suffix else: alias_id = int(subject) alias = Alias.get(alias_id) except Exception: LOG.warning("Cannot parse alias from subject %s", msg["Subject"]) return "550 SL E8" if not alias: LOG.warning("No such alias %s", alias_id) return "550 SL E9" # This sender cannot unsubscribe mail_from = envelope.mail_from.lower().strip() mailbox = Mailbox.get_by(user_id=alias.user_id, email=mail_from) if not mailbox or mailbox not in alias.mailboxes: LOG.d("%s cannot disable alias %s", envelope.mail_from, alias) return "550 SL E10" # Sender is owner of this alias alias.enabled = False db.session.commit() user = alias.user enable_alias_url = URL + f"/dashboard/?highlight_alias_id={alias.id}" for mailbox in alias.mailboxes: send_email( mailbox.email, f"Alias {alias.email} has been disabled successfully", render( "transactional/unsubscribe-disable-alias.txt", user=user, alias=alias.email, enable_alias_url=enable_alias_url, ), render( "transactional/unsubscribe-disable-alias.html", user=user, alias=alias.email, enable_alias_url=enable_alias_url, ), ) return "250 Unsubscribe request accepted"
def email_already_used(email: str) -> bool: """test if an email can be used when: - user signs up - add a new mailbox """ if User.get_by(email=email): return True if Mailbox.get_by(email=email): return True return False
def mailbox_confirm_change_route(): s = Signer(MAILBOX_SECRET) signed_mailbox_id = request.args.get("mailbox_id") try: mailbox_id = int(s.unsign(signed_mailbox_id)) except Exception: flash("Invalid link", "error") return redirect(url_for("dashboard.index")) else: mailbox = Mailbox.get(mailbox_id) # new_email can be None if user cancels change in the meantime if mailbox and mailbox.new_email: user = mailbox.user if Mailbox.get_by(email=mailbox.new_email, user_id=user.id): flash(f"{mailbox.new_email} is already used", "error") return redirect( url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox.id)) mailbox.email = mailbox.new_email mailbox.new_email = None # mark mailbox as verified if the change request is sent from an unverified mailbox mailbox.verified = True Session.commit() LOG.d("Mailbox change %s is verified", mailbox) flash(f"The {mailbox.email} is updated", "success") return redirect( url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox.id)) else: flash("Invalid link", "error") return redirect(url_for("dashboard.index"))
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" 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" 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") # 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, ) 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) smtp.sendmail( alias.email, contact.website_email, msg.as_bytes(), envelope.mail_options, envelope.rcpt_options, ) EmailLog.create(contact_id=contact.id, is_reply=True, user_id=contact.user_id) db.session.commit() return True, "250 Message accepted for delivery"
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" 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") # 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) # 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: 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), ), ) else: EmailLog.create(contact_id=contact.id, is_reply=True, user_id=contact.user_id) db.session.commit() return True, "250 Message accepted for delivery"
def custom_alias(): # check if user has not exceeded the alias quota if not current_user.can_create_new_alias(): # notify admin LOG.error("user %s tries to create custom alias", current_user) flash( "You have reached free plan limit, please upgrade to create new aliases", "warning", ) return redirect(url_for("dashboard.index")) user_custom_domains = [cd.domain for cd in current_user.verified_custom_domains()] # List of (is_custom_domain, alias-suffix) suffixes = [] # put custom domain first for alias_domain in user_custom_domains: suffixes.append((True, "@" + alias_domain)) # then default domain for domain in ALIAS_DOMAINS: suffixes.append( ( False, ("" if DISABLE_ALIAS_SUFFIX else "." + random_word()) + "@" + domain, ) ) mailboxes = current_user.mailboxes() if request.method == "POST": alias_prefix = request.form.get("prefix") alias_suffix = request.form.get("suffix") mailbox_email = request.form.get("mailbox") alias_note = request.form.get("note") # check if mailbox is not tempered with if mailbox_email != current_user.email: mailbox = Mailbox.get_by(email=mailbox_email) if not mailbox or mailbox.user_id != current_user.id: flash("Something went wrong, please retry", "warning") return redirect(url_for("dashboard.custom_alias")) if verify_prefix_suffix( current_user, alias_prefix, alias_suffix, user_custom_domains ): full_alias = alias_prefix + alias_suffix if GenEmail.get_by(email=full_alias) or DeletedAlias.get_by( email=full_alias ): LOG.d("full alias already used %s", full_alias) flash( f"Alias {full_alias} already exists, please choose another one", "warning", ) else: mailbox = Mailbox.get_by(email=mailbox_email) gen_email = GenEmail.create( user_id=current_user.id, email=full_alias, note=alias_note, mailbox_id=mailbox.id, ) # get the custom_domain_id if alias is created with a custom domain alias_domain = get_email_domain_part(full_alias) custom_domain = CustomDomain.get_by(domain=alias_domain) if custom_domain: LOG.d("Set alias %s domain to %s", full_alias, custom_domain) gen_email.custom_domain_id = custom_domain.id db.session.commit() flash(f"Alias {full_alias} has been created", "success") return redirect( url_for("dashboard.index", highlight_gen_email_id=gen_email.id) ) # only happen if the request has been "hacked" else: flash("something went wrong", "warning") return render_template("dashboard/custom_alias.html", **locals())
def index(): query = request.args.get("query") or "" highlight_gen_email_id = None if request.args.get("highlight_gen_email_id"): highlight_gen_email_id = int(request.args.get("highlight_gen_email_id")) # User generates a new email if request.method == "POST": if request.form.get("form-name") == "trigger-email": gen_email_id = request.form.get("gen-email-id") gen_email = GenEmail.get(gen_email_id) LOG.d("trigger an email to %s", gen_email) email_utils.send_test_email_alias(gen_email.email, gen_email.user.name) flash( f"An email sent to {gen_email.email} is on its way, please check your inbox/spam folder", "success", ) elif request.form.get("form-name") == "create-custom-email": if current_user.can_create_new_alias(): return redirect(url_for("dashboard.custom_alias")) else: flash(f"You need to upgrade your plan to create new alias.", "warning") elif request.form.get("form-name") == "create-random-email": if current_user.can_create_new_alias(): scheme = int( request.form.get("generator_scheme") or current_user.alias_generator ) if not scheme or not AliasGeneratorEnum.has_value(scheme): scheme = current_user.alias_generator gen_email = GenEmail.create_new_random(user=current_user, scheme=scheme) gen_email.mailbox_id = current_user.default_mailbox_id db.session.commit() LOG.d("generate new email %s for user %s", gen_email, current_user) flash(f"Alias {gen_email.email} has been created", "success") return redirect( url_for( "dashboard.index", highlight_gen_email_id=gen_email.id, query=query, ) ) else: flash(f"You need to upgrade your plan to create new alias.", "warning") elif request.form.get("form-name") == "switch-email-forwarding": gen_email_id = request.form.get("gen-email-id") gen_email: GenEmail = GenEmail.get(gen_email_id) LOG.d("switch email forwarding for %s", gen_email) gen_email.enabled = not gen_email.enabled if gen_email.enabled: flash(f"Alias {gen_email.email} is enabled", "success") else: flash(f"Alias {gen_email.email} is disabled", "warning") db.session.commit() return redirect( url_for( "dashboard.index", highlight_gen_email_id=gen_email.id, query=query ) ) elif request.form.get("form-name") == "delete-email": gen_email_id = request.form.get("gen-email-id") gen_email: GenEmail = GenEmail.get(gen_email_id) LOG.d("delete gen email %s", gen_email) email = gen_email.email GenEmail.delete(gen_email.id) db.session.commit() flash(f"Alias {email} has been deleted", "success") # try to save deleted alias try: DeletedAlias.create(user_id=current_user.id, email=email) db.session.commit() # this can happen when a previously deleted alias is re-created via catch-all or directory feature except IntegrityError: LOG.error("alias %s has been added before to DeletedAlias", email) db.session.rollback() elif request.form.get("form-name") == "set-note": gen_email_id = request.form.get("gen-email-id") gen_email: GenEmail = GenEmail.get(gen_email_id) note = request.form.get("note") gen_email.note = note db.session.commit() flash(f"Update note for alias {gen_email.email}", "success") return redirect( url_for( "dashboard.index", highlight_gen_email_id=gen_email.id, query=query ) ) elif request.form.get("form-name") == "set-mailbox": gen_email_id = request.form.get("gen-email-id") gen_email: GenEmail = GenEmail.get(gen_email_id) mailbox_email = request.form.get("mailbox") mailbox = Mailbox.get_by(email=mailbox_email) if not mailbox or mailbox.user_id != current_user.id: flash("Something went wrong, please retry", "warning") else: gen_email.mailbox_id = mailbox.id db.session.commit() LOG.d("Set alias %s mailbox to %s", gen_email, mailbox) flash( f"Update mailbox for {gen_email.email} to {mailbox_email}", "success", ) return redirect( url_for( "dashboard.index", highlight_gen_email_id=gen_email.id, query=query, ) ) return redirect(url_for("dashboard.index", query=query)) client_users = ( ClientUser.filter_by(user_id=current_user.id) .options(joinedload(ClientUser.client)) .options(joinedload(ClientUser.gen_email)) .all() ) sorted(client_users, key=lambda cu: cu.client.name) mailboxes = current_user.mailboxes() return render_template( "dashboard/index.html", client_users=client_users, aliases=get_alias_info(current_user, query, highlight_gen_email_id), highlight_gen_email_id=highlight_gen_email_id, query=query, AliasGeneratorEnum=AliasGeneratorEnum, mailboxes=mailboxes, )