diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 33d901d5b..9e316c20d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -174,4 +174,11 @@ python email_handler.py swaks --to e1@sl.local --from hey@google.com --server 127.0.0.1:20381 ``` +4) Send a test email via SMTP + * Make sure SMTP is enabled in settings and password is generated for the alias + +```bash +swaks --to hey@google.com --from e1@sl.local --server 127.0.0.1:465 --tls-on-connect --auth --auth-user e1@sl.local +``` + Now open http://localhost:1080/ (or http://localhost:1080/ for MailHog), you should see the forwarded email. \ No newline at end of file diff --git a/README.md b/README.md index aff3d18de..e55323ee2 100644 --- a/README.md +++ b/README.md @@ -489,6 +489,40 @@ docker run -d \ simplelogin/app:3.4.0 python job_runner.py ``` +#### Enable Sending via SMTP + +For sending via SMTP you need to make sure you have the required SSL certificate and key at following, as SMTP requires SSL. + +- `$(pwd)/smtp_ssl_cert.pem` for SSL Certificate file, +- `$(pwd)/smtp_ssl_privkey.key` for Key file. +- adjust this according to where you have the files present. + +You can use the following command to generate a self-signed certificate for testing. + +```bash +openssl req -x509 -newkey rsa:4096 -keyout smtp_ssl_privkey.key \ + -out smtp_ssl_cert.pem -sha256 -days 3650 -nodes \ + -subj "/C=US/ST=Oregon/L=Portland/O=My Company/OU=Org/CN=mydomain.com" +``` + +then run `SMTP handler` + +```bash +docker run -d \ + --name sl-smtp \ + -v $(pwd)/sl:/sl \ + -v $(pwd)/sl/upload:/code/static/upload \ + -v $(pwd)/simplelogin.env:/code/.env \ + -v $(pwd)/dkim.key:/dkim.key \ + -v $(pwd)/dkim.pub.key:/dkim.pub.key \ + -v $(pwd)/smtp_ssl_cert.pem:/smtp_ssl_cert.pem + -v $(pwd)/smtp_ssl_privkey.key:/smtp_ssl_privkey.key + -p 465:465 \ + --restart always \ + --network="sl-network" \ + simplelogin/app:3.4.0 python SMTP_handler.py +``` + ### Nginx Install Nginx and make sure to replace `mydomain.com` by your domain diff --git a/SMTP_handler.py b/SMTP_handler.py new file mode 100644 index 000000000..cebc4d7c5 --- /dev/null +++ b/SMTP_handler.py @@ -0,0 +1,576 @@ +""" +Enable SMTP access for aliases. + +This will allow a MUA to send directly from the alias without the need for the user to create a reverse-alias. + +""" + +import argparse +import time +import ssl +import uuid +import email + +import newrelic.agent + +from aiosmtpd.controller import Controller +from aiosmtpd.smtp import AuthResult, LoginPassword, Envelope + +from sqlalchemy.orm.exc import ObjectDeletedError +from sqlalchemy.exc import IntegrityError +from email.message import Message +from email.utils import formataddr, formatdate +from init_app import load_pgp_public_keys +from server import create_light_app +from app.db import Session +from app.log import LOG, set_message_id +from app.email import status, headers +from app.utils import sanitize_email +from app.email.spam import get_spam_score +from app.pgp_utils import PGPException +from app.models import Alias, SMTPCredentials, EmailLog, Contact, Mailbox +from app.config import ( + SMTP_SSL_KEY_FILEPATH, + SMTP_SSL_CERT_FILEPATH, + NOREPLY, + ENABLE_SPAM_ASSASSIN, + SPAMASSASSIN_HOST, + MAX_REPLY_PHASE_SPAM_SCORE, + BOUNCE_PREFIX_FOR_REPLY_PHASE, + LOAD_PGP_EMAIL_HANDLER, +) +from app.email_utils import ( + copy, + save_email_for_debugging, + sanitize_header, + get_header_unicode, + parse_full_address, + is_valid_alias_address_domain, + get_spam_info, + delete_all_headers_except, + sl_sendmail, + add_or_replace_header, + should_add_dkim_signature, + add_dkim_signature, + send_email, + render, + get_email_domain_part, + is_valid_email, + generate_reply_email, +) + +from email_handler import ( + send_no_reply_response, + handle_spam, + prepare_pgp_message, + replace_original_message_id, + replace_header_when_reply, +) + + +def get_or_create_contact_for_SMTP_phase(mail_from: str, alias: Alias) -> Contact: + """ + Create Contact for SMTP phase + """ + contact_email = mail_from + + if not is_valid_email(contact_email): + LOG.w( + "invalid contact email %s.", + contact_email, + ) + # either reuse a contact with empty email or create a new contact with empty email + contact_email = "" + + contact_email = sanitize_email(contact_email, not_lower=True) + + contact = Contact.get_by(alias_id=alias.id, website_email=contact_email) + if not contact: + try: + contact = Contact.create( + user_id=alias.user_id, + alias_id=alias.id, + website_email=contact_email, + mail_from=mail_from, + reply_email=generate_reply_email(contact_email, alias.user) + if is_valid_email(contact_email) + else NOREPLY, + automatic_created=True, + ) + if not contact_email: + LOG.d("Create a contact with invalid email for %s", alias) + contact.invalid_email = True + + LOG.d( + "create contact %s for %s, reverse alias:%s", + contact_email, + alias, + contact.reply_email, + ) + + Session.commit() + except IntegrityError: + LOG.w("Contact %s %s already exist", alias, contact_email) + Session.rollback() + contact = Contact.get_by(alias_id=alias.id, website_email=contact_email) + + return contact + + +def handle_SMTP(envelope, 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) + """ + website_email = rcpt_to + + alias_address: str = envelope.mail_from + alias = Alias.get_by(email=alias_address) + if not alias: + LOG.e("Alias: %s isn't known", alias_address) + return False, status.E503 + + user = alias.user + if user.disabled: + LOG.e( + "User %s disabled, disable sending emails from %s to %s", + user, + alias, + website_email, + ) + return [(False, status.E504)] + + alias_domain = get_email_domain_part(alias_address) + + # Sanity check: verify alias domain is managed by SimpleLogin + # 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.e("%s domain isn't known", alias) + return False, status.E503 + + contact = Contact.get_by(website_email=website_email) + + # Check if website email id a reverse alias. + if not contact: + contact = Contact.get_by(reply_email=website_email) + if contact: + LOG.d(f"{website_email} is a reverse-alias. Changing 'To:' to actual website email.") + website_email = contact.website_email + replace_header_when_reply(msg, alias, headers.TO) + replace_header_when_reply(msg, alias, headers.CC) + + if not contact: + LOG.d(f"No contact with {website_email} as website email") + try: + LOG.d("Create or get contact for website_email:%s", website_email) + contact = get_or_create_contact_for_SMTP_phase(website_email, alias) + except ObjectDeletedError: + LOG.d("maybe alias was deleted in the meantime") + alias = Alias.get_by(email=alias_address) + if not alias: + LOG.i("Alias %s was deleted in the meantime", alias_address) + return [(False, status.E515)] + + mailbox = Mailbox.get_by(id=alias.mailbox_id) + + email_log = EmailLog.create( + contact_id=contact.id, + alias_id=contact.alias_id, + is_reply=True, + is_SMTP=True, + user_id=contact.user_id, + mailbox_id=mailbox.id, + message_id=msg[headers.MESSAGE_ID], + commit=True, + ) + LOG.d("Create %s for %s, %s, %s", email_log, contact, user, mailbox) + + # Spam check + if ENABLE_SPAM_ASSASSIN: + spam_status = "" + is_spam = False + + # do not use user.max_spam_score here + if SPAMASSASSIN_HOST: + start = time.time() + spam_score, spam_report = get_spam_score(msg, email_log) + LOG.d( + "%s -> %s - spam score %s in %s seconds. Spam report %s", + alias, + contact, + spam_score, + time.time() - start, + spam_report, + ) + email_log.spam_score = spam_score + if spam_score > MAX_REPLY_PHASE_SPAM_SCORE: + is_spam = True + # only set the spam report for spam + email_log.spam_report = spam_report + else: + is_spam, spam_status = get_spam_info( + msg, max_score=MAX_REPLY_PHASE_SPAM_SCORE + ) + + if is_spam: + LOG.w( + "Email detected as spam. SMTP phase. %s -> %s. Spam Score: %s, Spam Report: %s", + alias, + contact, + email_log.spam_score, + email_log.spam_report, + ) + + email_log.is_spam = True + email_log.spam_status = spam_status + Session.commit() + + handle_spam(contact, alias, msg, user, mailbox, email_log, is_reply=True) + return False, status.E506 + + delete_all_headers_except( + msg, + [ + headers.FROM, + headers.TO, + headers.CC, + headers.SUBJECT, + headers.DATE, + # do not delete original message id + headers.MESSAGE_ID, + # References and In-Reply-To are used for keeping the email thread + headers.REFERENCES, + headers.IN_REPLY_TO, + ] + + headers.MIME_HEADERS, + ) + + # 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, contact.pgp_public_key + ) + except PGPException: + LOG.e( + "Cannot encrypt message %s -> %s. %s %s", alias, contact, mailbox, user + ) + # programming error, user shouldn't see a new email log + EmailLog.delete(email_log.id, commit=True) + # return 421 so the client can retry later + return False, status.E402 + + Session.commit() + + # make the email comes from alias <- Can be kept for SMTP as well + from_header = alias.email + # add alias name from alias + if alias.name: + LOG.d("Put alias name %s in from header", alias.name) + from_header = formataddr((alias.name, alias.email)) + elif alias.custom_domain: + # add alias name from domain + if alias.custom_domain.name: + LOG.d( + "Put domain default alias name %s in from header", + alias.custom_domain.name, + ) + from_header = formataddr((alias.custom_domain.name, alias.email)) + + LOG.d("From header is %s", from_header) + add_or_replace_header(msg, headers.FROM, from_header) + + replace_original_message_id(alias, email_log, msg) + + if not msg[headers.DATE]: + date_header = formatdate() + LOG.w("missing date header, add one") + msg[headers.DATE] = date_header + + msg[headers.SL_DIRECTION] = "Reply" + msg[headers.SL_EMAIL_LOG_ID] = str(email_log.id) + + 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 should_add_dkim_signature(alias_domain): + add_dkim_signature(msg, alias_domain) + + # generate a mail_from for VERP <- Can be kept for SMTP as well + verp_mail_from = f"{BOUNCE_PREFIX_FOR_REPLY_PHASE}+{email_log.id}+@{alias_domain}" + + try: + sl_sendmail( + verp_mail_from, + contact.website_email, + msg, + envelope.mail_options, + envelope.rcpt_options, + is_forward=False, + ) + except Exception: + LOG.w("Cannot send email from %s to %s", alias, contact) + EmailLog.delete(email_log.id, commit=True) + send_email( + mailbox.email, + f"Email cannot be sent to {contact.email} from {alias.email}", + render( + "transactional/reply-error.txt.jinja2", + 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), + ), + ) + + # return 250 even if error as user is already informed of the incident and can retry sending the email + return True, status.E200 + + +class SMTPAuthenticator: + def fail_nothandled(self, message=None) -> AuthResult: + return AuthResult(success=False, handled=False, message=message) + + def __call__(self, server, session, envelope, mechanism, auth_data): + if mechanism not in ("LOGIN", "PLAIN"): + LOG.e("mechanism %s not supported.", mechanism) + return self.fail_nothandled("550 Mechanism not supported") + + if not isinstance(auth_data, LoginPassword): + LOG.e("Incorrect Format for Credentials") + return self.fail_nothandled(status.E501) + + username = auth_data.login.decode("utf-8") + password = auth_data.password.decode("utf-8") + + alias = Alias.get_by(email=username) + if not alias: + LOG.e("alias %s does not exist.", alias.email) + return self.fail_nothandled(status.E502) + + user = alias.user + + if not user or user.disabled: + LOG.e("User for alias %s is disabled", username) + return self.fail_nothandled(status.E504) + + is_smtp_enabled_for_aliases = user.enable_SMTP_aliases + + if not is_smtp_enabled_for_aliases: + LOG.e("SMTP disabled by user") + return self.fail_nothandled("521 SMTP disabled by user") + + SMTPCred = SMTPCredentials.get_by(alias_id=alias.id) + if not SMTPCred or not SMTPCred.check_password(password): + LOG.e( + "Credentials Mismatch for alias %s", + username, + ) + return self.fail_nothandled("535 5.7.8 Authentication credentials invalid") + + return AuthResult(success=True, auth_data=auth_data) + + +def handle(envelope: Envelope, msg: Message) -> str: + """Return SMTP status""" + + # sanitize mail_from, rcpt_tos + mail_from = sanitize_email(envelope.mail_from) + rcpt_tos = [sanitize_email(rcpt_to) for rcpt_to in envelope.rcpt_tos] + envelope.mail_from = mail_from + envelope.rcpt_tos = rcpt_tos + + # sanitize email headers + sanitize_header(msg, "from") + sanitize_header(msg, "to") + sanitize_header(msg, "cc") + sanitize_header(msg, "reply-to") + + LOG.d( + "==>> Handle mail_from:%s, rcpt_tos:%s, header_from:%s, header_to:%s, " + "cc:%s, reply-to:%s, message_id:%s, client_ip:%s, headers:%s, mail_options:%s, rcpt_options:%s", + mail_from, + rcpt_tos, + msg[headers.FROM], + msg[headers.TO], + msg[headers.CC], + msg[headers.REPLY_TO], + msg[headers.MESSAGE_ID], + msg[headers.SL_CLIENT_IP], + msg._headers, + envelope.mail_options, + envelope.rcpt_options, + ) + + res: [(bool, str)] = [] + + nb_rcpt_tos = len(rcpt_tos) + for rcpt_index, rcpt_to in enumerate(rcpt_tos): + if rcpt_to == NOREPLY: + LOG.i("email sent to {} address from {}".format(NOREPLY, mail_from)) + send_no_reply_response(mail_from, msg) + return status.E200 + + # create a copy of msg for each recipient except the last one + # as copy() is a slow function + if rcpt_index < nb_rcpt_tos - 1: + LOG.d("copy message for rcpt %s", rcpt_to) + copy_msg = copy(msg) + else: + copy_msg = msg + + # SMTP case + LOG.d("SMTP phase %s(%s) -> %s", mail_from, copy_msg[headers.FROM], rcpt_to) + is_delivered, smtp_status = handle_SMTP(envelope, copy_msg, rcpt_to) + res.append((is_delivered, smtp_status)) + + # to know whether both successful and unsuccessful deliveries can happen at the same time + nb_success = len([is_success for (is_success, smtp_status) in res if is_success]) + nb_non_success = len( + [is_success for (is_success, smtp_status) in res if not is_success] + ) + + if nb_success > 0 and nb_non_success > 0: + LOG.e(f"some deliveries fail and some success, {mail_from}, {rcpt_tos}, {res}") + + for (is_success, smtp_status) in res: + # Consider all deliveries successful if 1 delivery is successful + if is_success: + return smtp_status + + # Failed delivery for all, return the first failure + return res[0][1] + + +class SMTPHandler: + async def handle_DATA(self, server, session, envelope: Envelope): + username = session.auth_data.login.decode("utf-8") + msg = email.message_from_bytes(envelope.original_content) + try: + ret = self.check_and_handle(envelope, msg, username) + return ret + except Exception as e: + LOG.e( + "email handling fail with error:%s " + "mail_from:%s, rcpt_tos:%s, header_from:%s, header_to:%s, saved to %s", + e, + envelope.mail_from, + envelope.rcpt_tos, + msg[headers.FROM], + msg[headers.TO], + save_email_for_debugging( + msg, file_name_prefix=e.__class__.__name__ + ), # todo: remove + ) + return status.E404 + + def check_and_handle(self, envelope: Envelope, msg: Message, username) -> str: + """Return SMTP status""" + mail_from = sanitize_email(envelope.mail_from) + envelope.mail_from = mail_from + + # sanitize email headers + sanitize_header(msg, "from") + + # If Sending from MUA, "mail_from", "from" and "username" should match <- This should prevent Spoofing + from_header = get_header_unicode(msg[headers.FROM]) + if from_header: + try: + _, from_header_address = parse_full_address(from_header) + except ValueError: + LOG.w("cannot parse the From header %s", from_header) + return status.E501 # Could be changed + else: + if mail_from != username or from_header_address != username: + LOG.e( + "Mail_From: '%s' , From Header: '%s' and username '%s' does not match", + mail_from, + msg[headers.FROM], + username, + ) + return status.E509 + + return self._handle(envelope, msg) + + @newrelic.agent.background_task() + def _handle(self, envelope: Envelope, msg: Message): + start = time.time() + + # generate a different message_id to keep track of an email lifecycle + message_id = str(uuid.uuid4()) + set_message_id(message_id) + + LOG.d("====>=====>====>====>====>====>====>====>") + LOG.i( + "New message, mail from %s, rctp tos %s ", + envelope.mail_from, + envelope.rcpt_tos, + ) + newrelic.agent.record_custom_metric( + "Custom/nb_rcpt_tos", len(envelope.rcpt_tos) + ) + + with create_light_app().app_context(): + ret = handle(envelope, msg) + elapsed = time.time() - start + + LOG.i( + "Finish mail_from %s, rcpt_tos %s, takes %s seconds <<===", + envelope.mail_from, + envelope.rcpt_tos, + elapsed, + ) + newrelic.agent.record_custom_metric("Custom/email_handler_time", elapsed) + newrelic.agent.record_custom_metric("Custom/number_incoming_email", 1) + return ret + + +def main(port: int): + """Use aiosmtpd Controller""" + handler = SMTPHandler() + ssl_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH) + ssl_context.load_cert_chain(certfile=SMTP_SSL_CERT_FILEPATH, keyfile=SMTP_SSL_KEY_FILEPATH) + controller = Controller( + handler, + hostname="0.0.0.0", + port=port, + ssl_context=ssl_context, # Implicit SSL/TLS + authenticator=SMTPAuthenticator(), + auth_required=True, + # Below param needs to be set in case of implicit SSL/TLS as per (https://github.com/aio-libs/aiosmtpd/issues/281) + auth_require_tls=False, + ) + + controller.start() + LOG.d("Start SMTP controller %s %s", controller.hostname, controller.port) + + if LOAD_PGP_EMAIL_HANDLER: + LOG.w("LOAD PGP keys") + load_pgp_public_keys() + + while True: + time.sleep(2) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument( + "-p", "--port", help="SMTP port to listen for", type=int, default=465 + ) + args = parser.parse_args() + + LOG.i("Listen for port %s", args.port) + main(port=args.port) diff --git a/app/api/serializer.py b/app/api/serializer.py index 21b801dee..9dd27e266 100644 --- a/app/api/serializer.py +++ b/app/api/serializer.py @@ -180,6 +180,8 @@ def get_alias_infos_with_pagination_v3( q = q.filter(Alias.pinned) elif alias_filter == "hibp": q = q.filter(Alias.hibp_breaches.any()) + elif alias_filter == "enable_SMTP": + q = q.filter(Alias.enable_SMTP) if sort == "old2new": q = q.order_by(Alias.created_at) diff --git a/app/api/views/alias.py b/app/api/views/alias.py index cff85601a..b2a6e0fe6 100644 --- a/app/api/views/alias.py +++ b/app/api/views/alias.py @@ -24,7 +24,7 @@ ) from app.errors import CannotCreateContactForReverseAlias from app.log import LOG -from app.models import Alias, Contact, Mailbox, AliasMailbox +from app.models import Alias, Contact, Mailbox, AliasMailbox, SMTPCredentials from app.utils import sanitize_email @@ -242,6 +242,7 @@ def update_alias(alias_id): name (optional): in body mailbox_id (optional): in body disable_pgp (optional): in body + enable_SMTP & SMTP_password (optional): in body Output: 200 """ @@ -318,6 +319,25 @@ def update_alias(alias_id): alias.pinned = data.get("pinned") changed = True + if "enable_SMTP" in data: + enable_SMTP = data.get("enable_SMTP") + if enable_SMTP: + if "smtp_password" in data: + smtp_password = data.get("smtp_password") + if ( + smtp_password and len(smtp_password) != 21 + ): # 21 is default password length set for nanoid. + return jsonify(error="Invalid SMTP Password Length"), 400 + smtp_cred = SMTPCredentials.create(alias_id=alias.id, password=smtp_password) + if not smtp_cred: + return jsonify(error="Error Saving SMTP Password"), 400 + else: + return jsonify(error="SMTP password not supplied"), 400 + else: + SMTPCredentials.delete_by_alias_id(alias_id=alias.id) + alias.enable_SMTP = enable_SMTP + changed = True + if changed: Session.commit() diff --git a/app/config.py b/app/config.py index 4a83f985e..68afcd27e 100644 --- a/app/config.py +++ b/app/config.py @@ -444,3 +444,7 @@ def setup_nameservers(): NAMESERVERS = setup_nameservers() + +# For SMTP support +SMTP_SSL_CERT_FILEPATH = os.environ.get("SMTP_SSL_CERT_FILEPATH") or "/smtp_ssl_cert.pem" +SMTP_SSL_KEY_FILEPATH = os.environ.get("SMTP_SSL_KEY_FILEPATH") or "/smtp_ssl_privkey.key" diff --git a/app/dashboard/views/setting.py b/app/dashboard/views/setting.py index b9af1e0bf..0c1d92b6e 100644 --- a/app/dashboard/views/setting.py +++ b/app/dashboard/views/setting.py @@ -258,6 +258,16 @@ def setting(): flash("Your preference has been updated", "success") return redirect(url_for("dashboard.setting")) + elif request.form.get("form-name") == "smtp-for-aliases": + choose = request.form.get("smtp-for-aliases") + if choose == "on": + current_user.enable_SMTP_aliases = True + else: + current_user.enable_SMTP_aliases = False + Session.commit() + flash("Your preference has been updated", "success") + return redirect(url_for("dashboard.setting")) + elif request.form.get("form-name") == "sender-in-ra": choose = request.form.get("enable") if choose == "on": diff --git a/app/models.py b/app/models.py index b40ca7410..681664b46 100644 --- a/app/models.py +++ b/app/models.py @@ -417,6 +417,10 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle): sa.Boolean, default=False, nullable=False, server_default="0" ) + enable_SMTP_aliases = sa.Column( + sa.Boolean, default=False, nullable=False, server_default="0" + ) + referral_id = sa.Column( sa.ForeignKey("referral.id", ondelete="SET NULL"), nullable=True, default=None ) @@ -1305,6 +1309,11 @@ class Alias(Base, ModelMixin): TSVector(), sa.Computed("to_tsvector('english', note)", persisted=True) ) + # Enable SMTP for alias + enable_SMTP = sa.Column( + sa.Boolean(), default=False, nullable=False, server_default="0" + ) + __table_args__ = ( Index("ix_video___ts_vector__", ts_vector, postgresql_using="gin"), # index on note column using pg_trgm @@ -1354,6 +1363,12 @@ def pgp_enabled(self) -> bool: return True return False + def SMTP_enabled(self) -> bool: + """return True is SMTP is enabled for the alias""" + if self.enable_SMTP: + return True + return False + @staticmethod def get_custom_domain(alias_address) -> Optional["CustomDomain"]: alias_domain = validate_email( @@ -1784,6 +1799,9 @@ class EmailLog(Base, ModelMixin): # whether this is a reply is_reply = sa.Column(sa.Boolean, nullable=False, default=False) + # whether this is sent from SMTP + is_SMTP = sa.Column(sa.Boolean, nullable=False, default=False) + # for ex if alias is disabled, this forwarding is blocked blocked = sa.Column(sa.Boolean, nullable=False, default=False) @@ -2557,6 +2575,41 @@ class AliasMailbox(Base, ModelMixin): alias = orm.relationship(Alias) +class SMTPCredentials(Base, ModelMixin, PasswordOracle): + __tablename__ = "SMTP_credentials" + + alias_id = sa.Column( + sa.ForeignKey(Alias.id, ondelete="cascade"), unique=True, nullable=False, index=True + ) + + # override PasswordOracle.password, SMTP password should not be null + password = sa.Column(sa.String(128), nullable=False) + + alias = orm.relationship(Alias) + + @classmethod + def create(cls, alias_id, password, **kwargs): + if password and len(password) == 21: # This is default length for nanoid + smtp_cred: SMTPCredentials = super(SMTPCredentials, cls).create( + alias_id=alias_id, **kwargs + ) + smtp_cred.set_password(password) + else: + return None + Session.flush() + return smtp_cred + + @classmethod + def delete_by_alias_id(cls, alias_id, commit=False): + Session.query(cls).filter(cls.alias_id == alias_id).delete() + + if commit: + Session.commit() + + def __repr__(self): + return f"" + + class AliasHibp(Base, ModelMixin): __tablename__ = "alias_hibp" diff --git a/email_handler.py b/email_handler.py index ddba3e4b6..96cb0c6f5 100644 --- a/email_handler.py +++ b/email_handler.py @@ -938,7 +938,9 @@ def forward_email_to_mailbox( reply_to_header = msg[headers.REPLY_TO] new_reply_to_header = reply_to_contact.new_addr() add_or_replace_header(msg, "Reply-To", new_reply_to_header) - LOG.d("Reply-To header, new:%s, old:%s", new_reply_to_header, reply_to_header) + LOG.d( + "Reply-To header, new:%s, old:%s", new_reply_to_header, reply_to_header + ) # replace CC & To emails by reverse-alias for all emails that are not alias try: diff --git a/example.env b/example.env index 31429f754..3d56dfbd1 100644 --- a/example.env +++ b/example.env @@ -179,3 +179,7 @@ ALLOWED_REDIRECT_DOMAINS=[] # DNS nameservers to be used by the app # Multiple nameservers can be specified, separated by ',' NAMESERVERS="1.1.1.1" + +# For SMTP support +# SMTP_SSL_CERT_FILE = "/smtp_ssl_cert.pem" +# SMTP_SSL_KEY_FILE = "/smtp_ssl_privkey.key" diff --git a/migrations/versions/2022_041423_6dff9b5f827a_.py b/migrations/versions/2022_041423_6dff9b5f827a_.py new file mode 100644 index 000000000..00df4d610 --- /dev/null +++ b/migrations/versions/2022_041423_6dff9b5f827a_.py @@ -0,0 +1,57 @@ +"""empty message + +Revision ID: 6dff9b5f827a +Revises: b500363567e3 +Create Date: 2022-04-14 23:31:57.185111 + +""" +import sqlalchemy_utils +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '6dff9b5f827a' +down_revision = 'b500363567e3' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('SMTP_credentials', + sa.Column('id', sa.Integer(), autoincrement=True, nullable=False), + sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False), + sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True), + sa.Column('alias_id', sa.Integer(), nullable=False), + sa.Column('password', sa.String(length=128), nullable=False), + sa.ForeignKeyConstraint(['alias_id'], ['alias.id'], ondelete='cascade'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_SMTP_credentials_alias_id'), 'SMTP_credentials', ['alias_id'], unique=True) + op.alter_column('admin_audit_log', 'data', + existing_type=postgresql.JSONB(astext_type=sa.Text()), + nullable=True) + op.drop_index('admin_audit_log_admin_user_id_idx', table_name='admin_audit_log') + op.drop_constraint('admin_audit_log_admin_user_id_fkey', 'admin_audit_log', type_='foreignkey') + op.create_foreign_key(None, 'admin_audit_log', 'users', ['admin_user_id'], ['id']) + op.add_column('alias', sa.Column('enable_SMTP', sa.Boolean(), server_default='0', nullable=False)) + op.add_column('email_log', sa.Column('is_SMTP', sa.Boolean(), nullable=False)) + op.add_column('users', sa.Column('enable_SMTP_aliases', sa.Boolean(), server_default='0', nullable=False)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('users', 'enable_SMTP_aliases') + op.drop_column('email_log', 'is_SMTP') + op.drop_column('alias', 'enable_SMTP') + op.drop_constraint(None, 'admin_audit_log', type_='foreignkey') + op.create_foreign_key('admin_audit_log_admin_user_id_fkey', 'admin_audit_log', 'users', ['admin_user_id'], ['id'], ondelete='CASCADE') + op.create_index('admin_audit_log_admin_user_id_idx', 'admin_audit_log', ['admin_user_id'], unique=False) + op.alter_column('admin_audit_log', 'data', + existing_type=postgresql.JSONB(astext_type=sa.Text()), + nullable=False) + op.drop_index(op.f('ix_SMTP_credentials_alias_id'), table_name='SMTP_credentials') + op.drop_table('SMTP_credentials') + # ### end Alembic commands ### diff --git a/static/package-lock.json b/static/package-lock.json index 4d1dd1ca3..19b1584f8 100644 --- a/static/package-lock.json +++ b/static/package-lock.json @@ -1,8 +1,209 @@ { "name": "simplelogin", "version": "1.0.0", - "lockfileVersion": 1, + "lockfileVersion": 2, "requires": true, + "packages": { + "": { + "name": "simplelogin", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@sentry/browser": "^5.30.0", + "bootbox": "^5.5.2", + "font-awesome": "^4.7.0", + "htmx.org": "^1.6.1", + "intro.js": "^2.9.3", + "multiple-select": "^1.5.2", + "nanoid": "^3.3.2", + "parsleyjs": "^2.9.2", + "qrious": "^4.0.2", + "toastr": "^2.1.4", + "vue": "^2.6.14" + } + }, + "node_modules/@sentry/browser": { + "version": "5.30.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-5.30.0.tgz", + "integrity": "sha512-rOb58ZNVJWh1VuMuBG1mL9r54nZqKeaIlwSlvzJfc89vyfd7n6tQ1UXMN383QBz/MS5H5z44Hy5eE+7pCrYAfw==", + "dependencies": { + "@sentry/core": "5.30.0", + "@sentry/types": "5.30.0", + "@sentry/utils": "5.30.0", + "tslib": "^1.9.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@sentry/core": { + "version": "5.30.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-5.30.0.tgz", + "integrity": "sha512-TmfrII8w1PQZSZgPpUESqjB+jC6MvZJZdLtE/0hZ+SrnKhW3x5WlYLvTXZpcWePYBku7rl2wn1RZu6uT0qCTeg==", + "dependencies": { + "@sentry/hub": "5.30.0", + "@sentry/minimal": "5.30.0", + "@sentry/types": "5.30.0", + "@sentry/utils": "5.30.0", + "tslib": "^1.9.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@sentry/hub": { + "version": "5.30.0", + "resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-5.30.0.tgz", + "integrity": "sha512-2tYrGnzb1gKz2EkMDQcfLrDTvmGcQPuWxLnJKXJvYTQDGLlEvi2tWz1VIHjunmOvJrB5aIQLhm+dcMRwFZDCqQ==", + "dependencies": { + "@sentry/types": "5.30.0", + "@sentry/utils": "5.30.0", + "tslib": "^1.9.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@sentry/minimal": { + "version": "5.30.0", + "resolved": "https://registry.npmjs.org/@sentry/minimal/-/minimal-5.30.0.tgz", + "integrity": "sha512-BwWb/owZKtkDX+Sc4zCSTNcvZUq7YcH3uAVlmh/gtR9rmUvbzAA3ewLuB3myi4wWRAMEtny6+J/FN/x+2wn9Xw==", + "dependencies": { + "@sentry/hub": "5.30.0", + "@sentry/types": "5.30.0", + "tslib": "^1.9.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@sentry/types": { + "version": "5.30.0", + "resolved": "https://registry.npmjs.org/@sentry/types/-/types-5.30.0.tgz", + "integrity": "sha512-R8xOqlSTZ+htqrfteCWU5Nk0CDN5ApUTvrlvBuiH1DyP6czDZ4ktbZB0hAgBlVcK0U+qpD3ag3Tqqpa5Q67rPw==", + "engines": { + "node": ">=6" + } + }, + "node_modules/@sentry/utils": { + "version": "5.30.0", + "resolved": "https://registry.npmjs.org/@sentry/utils/-/utils-5.30.0.tgz", + "integrity": "sha512-zaYmoH0NWWtvnJjC9/CBseXMtKHm/tm40sz3YfJRxeQjyzRqNQPgivpd9R/oDJCYj999mzdW382p/qi2ypjLww==", + "dependencies": { + "@sentry/types": "5.30.0", + "tslib": "^1.9.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/bootbox": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/bootbox/-/bootbox-5.5.2.tgz", + "integrity": "sha512-q8d9VO2A4+q6S0XvovLtqtBUp7uRy0wtDOuuycnoheK2TiAm3um0jOlAOu9ORn9XoT92tdil+p15Dle1mRgSPQ==", + "dependencies": { + "bootstrap": "^4.4.0", + "jquery": "^3.5.1", + "popper.js": "^1.16.0" + } + }, + "node_modules/bootstrap": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.5.3.tgz", + "integrity": "sha512-o9ppKQioXGqhw8Z7mah6KdTYpNQY//tipnkxppWhPbiSWdD+1raYsnhwEZjkTHYbGee4cVQ0Rx65EhOY/HNLcQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/bootstrap" + }, + "peerDependencies": { + "jquery": "1.9.1 - 3", + "popper.js": "^1.16.1" + } + }, + "node_modules/font-awesome": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/font-awesome/-/font-awesome-4.7.0.tgz", + "integrity": "sha1-j6jPBBGhoxr9B7BtKQK7n8gVoTM=", + "engines": { + "node": ">=0.10.3" + } + }, + "node_modules/htmx.org": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-1.6.1.tgz", + "integrity": "sha512-i+1k5ee2eFWaZbomjckyrDjUpa3FMDZWufatUSBmmsjXVksn89nsXvr1KLGIdAajiz+ZSL7TE4U/QaZVd2U2sA==" + }, + "node_modules/intro.js": { + "version": "2.9.3", + "resolved": "https://registry.npmjs.org/intro.js/-/intro.js-2.9.3.tgz", + "integrity": "sha512-hC+EXWnEuJeA3CveGMat3XHePd2iaXNFJIVfvJh2E9IzBMGLTlhWvPIVHAgKlOpO4lNayCxEqzr4N02VmHFr9Q==" + }, + "node_modules/jquery": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.5.1.tgz", + "integrity": "sha512-XwIBPqcMn57FxfT+Go5pzySnm4KWkT1Tv7gjrpT1srtf8Weynl6R273VJ5GjkRb51IzMp5nbaPjJXMWeju2MKg==" + }, + "node_modules/multiple-select": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/multiple-select/-/multiple-select-1.5.2.tgz", + "integrity": "sha512-sTNNRrjnTtB1b1+HTKcjQ/mjWY7Gvigo9F3C/3oTQCTFEpYzwaRYFPRAOu2SogfA1hEfyJTXjyS1VAbanJMsmA==", + "peerDependencies": { + "jquery": "1.9.1 - 3" + } + }, + "node_modules/nanoid": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.2.tgz", + "integrity": "sha512-CuHBogktKwpm5g2sRgv83jEy2ijFzBwMoYA60orPDR7ynsLijJDqgsi4RDGj3OJpy3Ieb+LYwiRmIOGyytgITA==", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/parsleyjs": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/parsleyjs/-/parsleyjs-2.9.2.tgz", + "integrity": "sha512-DKS2XXTjEUZ1BJWUzgXAr+550kFBZrom2WYweubqdV7WzdNC1hjOajZDfeBPoAZMkXumJPlB3v37IKatbiW8zQ==", + "dependencies": { + "jquery": ">=1.8.0" + } + }, + "node_modules/popper.js": { + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz", + "integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==", + "deprecated": "You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/qrious": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/qrious/-/qrious-4.0.2.tgz", + "integrity": "sha512-xWPJIrK1zu5Ypn898fBp8RHkT/9ibquV2Kv24S/JY9VYEhMBMKur1gHVsOiNUh7PHP9uCgejjpZUHUIXXKoU/g==" + }, + "node_modules/toastr": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/toastr/-/toastr-2.1.4.tgz", + "integrity": "sha1-i0O+ZPudDEFIcURvLbjoyk6V8YE=", + "dependencies": { + "jquery": ">=1.12.0" + } + }, + "node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" + }, + "node_modules/vue": { + "version": "2.6.14", + "resolved": "https://registry.npmjs.org/vue/-/vue-2.6.14.tgz", + "integrity": "sha512-x2284lgYvjOMj3Za7kqzRcUSxBboHqtgRE2zlos1qWaOye5yUmHn42LB1250NJBLRwEcdrB0JRwyPTEPhfQjiQ==" + } + }, "dependencies": { "@sentry/browser": { "version": "5.30.0", @@ -74,7 +275,8 @@ "bootstrap": { "version": "4.5.3", "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.5.3.tgz", - "integrity": "sha512-o9ppKQioXGqhw8Z7mah6KdTYpNQY//tipnkxppWhPbiSWdD+1raYsnhwEZjkTHYbGee4cVQ0Rx65EhOY/HNLcQ==" + "integrity": "sha512-o9ppKQioXGqhw8Z7mah6KdTYpNQY//tipnkxppWhPbiSWdD+1raYsnhwEZjkTHYbGee4cVQ0Rx65EhOY/HNLcQ==", + "requires": {} }, "font-awesome": { "version": "4.7.0", @@ -99,7 +301,13 @@ "multiple-select": { "version": "1.5.2", "resolved": "https://registry.npmjs.org/multiple-select/-/multiple-select-1.5.2.tgz", - "integrity": "sha512-sTNNRrjnTtB1b1+HTKcjQ/mjWY7Gvigo9F3C/3oTQCTFEpYzwaRYFPRAOu2SogfA1hEfyJTXjyS1VAbanJMsmA==" + "integrity": "sha512-sTNNRrjnTtB1b1+HTKcjQ/mjWY7Gvigo9F3C/3oTQCTFEpYzwaRYFPRAOu2SogfA1hEfyJTXjyS1VAbanJMsmA==", + "requires": {} + }, + "nanoid": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.2.tgz", + "integrity": "sha512-CuHBogktKwpm5g2sRgv83jEy2ijFzBwMoYA60orPDR7ynsLijJDqgsi4RDGj3OJpy3Ieb+LYwiRmIOGyytgITA==" }, "parsleyjs": { "version": "2.9.2", diff --git a/static/package.json b/static/package.json index b48b6dc7a..6fac196ce 100644 --- a/static/package.json +++ b/static/package.json @@ -22,6 +22,7 @@ "htmx.org": "^1.6.1", "intro.js": "^2.9.3", "multiple-select": "^1.5.2", + "nanoid": "^3.3.2", "parsleyjs": "^2.9.2", "qrious": "^4.0.2", "toastr": "^2.1.4", diff --git a/templates/dashboard/index.html b/templates/dashboard/index.html index 225c3cf02..e36078cfc 100644 --- a/templates/dashboard/index.html +++ b/templates/dashboard/index.html @@ -190,6 +190,13 @@ + + {% if current_user.enable_SMTP_aliases %} + + {% endif %} + @@ -277,6 +284,13 @@ title="This alias is pinned"> {% endif %} + {% if current_user.enable_SMTP_aliases %} + {% if alias.enable_SMTP %} + + {% endif %} + {% endif %} + {% if alias.hibp_breaches | length > 0 %} + {% if current_user.enable_SMTP_aliases %} +
+ Enable SMTP for this alias + +
+
+ +
+ {% endif %} +
+ + {% if current_user.enable_SMTP_aliases %} + + {% endif %} {% endblock %} diff --git a/templates/dashboard/setting.html b/templates/dashboard/setting.html index da0b10970..72c8e9812 100644 --- a/templates/dashboard/setting.html +++ b/templates/dashboard/setting.html @@ -351,6 +351,34 @@
+ +
+
+
SMTP for Aliases +
Experimental
+
+
+ This will enable SMTP access for aliases. + If this option is enabled, emails can be sent directly from aliases via SMTP without the need for creation of reverse-alias. +
+ Note: After enabling this, you would still need to enable SMTP individually per alias. +
+ This will also disable receiving via reverse-alias for that particular alias. i.e. Sender's original address will be displayed in "From:". +
+
+
+ +
+ + +
+ +
+
+
+ +