From d98cde440a95f7b19dad91dddfe35d1aff5ca37e Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sat, 1 Aug 2020 12:20:15 +0200 Subject: [PATCH 1/7] add how to test sending email using swaks and mailcatcher --- README.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/README.md b/README.md index d83b5991..a80af265 100644 --- a/README.md +++ b/README.md @@ -680,6 +680,42 @@ The code is formatted using https://github.com/psf/black, to format the code, si black . ``` +### Test sending email + +[swaks](http://www.jetmore.org/john/code/swaks/) is used for sending test emails to the `email_handler`. + +[mailcatcher](https://github.com/sj26/mailcatcher) is used to receive forwarded emails. + +There are several steps to set up the email handler + +1) run mailcatcher + +```bash +mailcatcher +``` + +2) Make sure to set the following variables in the `.env` file + +``` +NOT_SEND_EMAIL=true +POSTFIX_SERVER=localhost +POSTFIX_PORT=1025 +``` + +3) Run email_handler + +```bash +python email_handler.py +``` + +4) Send a test email + +```bash +swaks --to e1@d1.localhost --from hey@google.com --server 127.0.0.1:20381 +``` + +Now open http://localhost:1080/, you should see the test email. + ## API SimpleLogin current API clients are Chrome/Firefox/Safari extension and mobile (iOS/Android) app. From e8fc9752b5a9a10a4319e15e9ac6e0bacb068c12 Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sat, 1 Aug 2020 12:20:59 +0200 Subject: [PATCH 2/7] Add DomailMailbox model --- app/models.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/app/models.py b/app/models.py index 1c08ba42..86d3143d 100644 --- a/app/models.py +++ b/app/models.py @@ -1353,11 +1353,20 @@ class CustomDomain(db.Model, ModelMixin): db.Boolean, nullable=False, default=False, server_default="0" ) + _mailboxes = db.relationship("Mailbox", secondary="domain_mailbox", lazy="joined") + # an alias is created automatically the first time it receives an email catch_all = db.Column(db.Boolean, nullable=False, default=False, server_default="0") user = db.relationship(User, foreign_keys=[user_id]) + @property + def mailboxes(self): + if self._mailboxes: + return self._mailboxes + else: + return [self.user.default_mailbox] + def nb_alias(self): return Alias.filter_by(custom_domain_id=self.id).count() @@ -1625,6 +1634,21 @@ class DirectoryMailbox(db.Model, ModelMixin): ) +class DomainMailbox(db.Model, ModelMixin): + """store the owning mailboxes for a domain""" + + __table_args__ = ( + db.UniqueConstraint("domain_id", "mailbox_id", name="uq_domain_mailbox"), + ) + + domain_id = db.Column( + db.ForeignKey(CustomDomain.id, ondelete="cascade"), nullable=False + ) + mailbox_id = db.Column( + db.ForeignKey(Mailbox.id, ondelete="cascade"), nullable=False + ) + + _NB_RECOVERY_CODE = 8 _RECOVERY_CODE_LENGTH = 8 From ec8f12008538d0883b6138e234c14fe12123eaed Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sat, 1 Aug 2020 12:22:52 +0200 Subject: [PATCH 3/7] small fixes in directory.py --- app/dashboard/views/directory.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/app/dashboard/views/directory.py b/app/dashboard/views/directory.py index bcad92f9..5ddd7915 100644 --- a/app/dashboard/views/directory.py +++ b/app/dashboard/views/directory.py @@ -50,12 +50,9 @@ def directory(): dir_id = request.form.get("dir-id") dir = Directory.get(dir_id) - if not dir: + if not dir or dir.user_id != current_user.id: flash("Unknown error. Refresh the page", "warning") return redirect(url_for("dashboard.directory")) - elif dir.user_id != current_user.id: - flash("You cannot delete this directory", "warning") - return redirect(url_for("dashboard.directory")) mailbox_ids = request.form.getlist("mailbox_ids") # check if mailbox is not tempered with @@ -75,7 +72,7 @@ def directory(): flash("You must select at least 1 mailbox", "warning") return redirect(url_for("dashboard.directory")) - # first remove all existing alias-mailboxes links + # first remove all existing directory-mailboxes links DirectoryMailbox.query.filter_by(directory_id=dir.id).delete() db.session.flush() @@ -125,7 +122,7 @@ def directory(): or not mailbox.verified ): flash("Something went wrong, please retry", "warning") - return redirect(url_for("dashboard.custom_alias")) + return redirect(url_for("dashboard.directory")) mailboxes.append(mailbox) for mailbox in mailboxes: From f5bc166f3959f27257dd33f6ac22a4c3902432cf Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sat, 1 Aug 2020 12:31:02 +0200 Subject: [PATCH 4/7] able to choose mailboxes for a domain --- .../templates/dashboard/custom_domain.html | 93 ++++++++++++++++--- app/dashboard/views/custom_domain.py | 64 ++++++++++++- 2 files changed, 143 insertions(+), 14 deletions(-) diff --git a/app/dashboard/templates/dashboard/custom_domain.html b/app/dashboard/templates/dashboard/custom_domain.html index 34be64af..1ebc7008 100644 --- a/app/dashboard/templates/dashboard/custom_domain.html +++ b/app/dashboard/templates/dashboard/custom_domain.html @@ -20,7 +20,10 @@ {% if not current_user.is_premium() %} {% endif %} @@ -46,33 +49,99 @@ {% endif %} -
+ +
Created {{ custom_domain.created_at | dt }}
{{ custom_domain.nb_alias() }} aliases. +

+ + Mailboxes: + + +
+ + {% set domain_mailboxes=custom_domain.mailboxes %} +
+ + + +
+
+ +
+
+ +
+
+
+
- Details ➡ + + Details ➡ + {% endfor %} -
- {{ new_custom_domain_form.csrf_token }} - +
+
+
+
-

New Domain

+ + {{ new_custom_domain_form.csrf_token }} + - {{ new_custom_domain_form.domain(class="form-control", placeholder="my-domain.com", maxlength=128) }} - {{ render_field_errors(new_custom_domain_form.domain) }} -
Please use full path domain, for ex my-subdomain.my-domain.com
+

New Domain

- - + {{ new_custom_domain_form.domain(class="form-control", placeholder="my-domain.com", maxlength=128) }} + {{ render_field_errors(new_custom_domain_form.domain) }} +
+ Please use full path domain, for ex my-subdomain.my-domain.com +
+
+ By default, aliases created with your domain are "owned" by your default + mailbox {{ current_user.default_mailbox.email }}.
+ This below option allow you to choose the mailbox(es) that a new alias automatically belongs to. +
+ + + + + +
+
+
+
{% endblock %} + +{% block script %} + +{% endblock %} \ No newline at end of file diff --git a/app/dashboard/views/custom_domain.py b/app/dashboard/views/custom_domain.py index b6953c4d..bcf96323 100644 --- a/app/dashboard/views/custom_domain.py +++ b/app/dashboard/views/custom_domain.py @@ -7,7 +7,7 @@ from app.config import EMAIL_SERVERS_WITH_PRIORITY from app.dashboard.base import dashboard_bp from app.email_utils import get_email_domain_part from app.extensions import db -from app.models import CustomDomain +from app.models import CustomDomain, Mailbox, DomainMailbox class NewCustomDomainForm(FlaskForm): @@ -20,7 +20,7 @@ class NewCustomDomainForm(FlaskForm): @login_required def custom_domain(): custom_domains = CustomDomain.query.filter_by(user_id=current_user.id).all() - + mailboxes = current_user.mailboxes() new_custom_domain_form = NewCustomDomainForm() errors = {} @@ -54,6 +54,28 @@ def custom_domain(): ) db.session.commit() + mailbox_ids = request.form.getlist("mailbox_ids") + if mailbox_ids: + # check if mailbox is not tempered with + mailboxes = [] + for mailbox_id in mailbox_ids: + mailbox = Mailbox.get(mailbox_id) + if ( + not mailbox + or mailbox.user_id != current_user.id + or not mailbox.verified + ): + flash("Something went wrong, please retry", "warning") + return redirect(url_for("dashboard.custom_domain")) + mailboxes.append(mailbox) + + for mailbox in mailboxes: + DomainMailbox.create( + domain_id=new_custom_domain.id, mailbox_id=mailbox.id + ) + + db.session.commit() + flash( f"New domain {new_custom_domain.domain} is created", "success" ) @@ -64,6 +86,43 @@ def custom_domain(): custom_domain_id=new_custom_domain.id, ) ) + elif request.form.get("form-name") == "update": + domain_id = request.form.get("domain-id") + domain = CustomDomain.get(domain_id) + + if not domain or domain.user_id != current_user.id: + flash("Unknown error. Refresh the page", "warning") + return redirect(url_for("dashboard.custom_domain")) + + mailbox_ids = request.form.getlist("mailbox_ids") + # check if mailbox is not tempered with + mailboxes = [] + for mailbox_id in mailbox_ids: + mailbox = Mailbox.get(mailbox_id) + if ( + not mailbox + or mailbox.user_id != current_user.id + or not mailbox.verified + ): + flash("Something went wrong, please retry", "warning") + return redirect(url_for("dashboard.custom_domain")) + mailboxes.append(mailbox) + + if not mailboxes: + flash("You must select at least 1 mailbox", "warning") + return redirect(url_for("dashboard.custom_domain")) + + # first remove all existing domain-mailboxes links + DomainMailbox.query.filter_by(domain_id=domain.id).delete() + db.session.flush() + + for mailbox in mailboxes: + DomainMailbox.create(domain_id=domain.id, mailbox_id=mailbox.id) + + db.session.commit() + flash(f"Domain {domain.domain} has been updated", "success") + + return redirect(url_for("dashboard.custom_domain")) return render_template( "dashboard/custom_domain.html", @@ -71,4 +130,5 @@ def custom_domain(): new_custom_domain_form=new_custom_domain_form, EMAIL_SERVERS_WITH_PRIORITY=EMAIL_SERVERS_WITH_PRIORITY, errors=errors, + mailboxes=mailboxes, ) From 41e2283d9369e7c1250466faa2dfb4942c9da6a2 Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sat, 1 Aug 2020 12:31:43 +0200 Subject: [PATCH 5/7] domain catch-all alias belongs to domain mailboxes --- app/alias_utils.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/alias_utils.py b/app/alias_utils.py index 1a4f4162..e9506055 100644 --- a/app/alias_utils.py +++ b/app/alias_utils.py @@ -98,7 +98,7 @@ def try_auto_create_catch_all_domain(address: str) -> Optional[Alias]: # 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 alias_domain = get_email_domain_part(address) - custom_domain = CustomDomain.get_by(domain=alias_domain) + custom_domain: CustomDomain = CustomDomain.get_by(domain=alias_domain) if not custom_domain: return None @@ -116,13 +116,19 @@ def try_auto_create_catch_all_domain(address: str) -> Optional[Alias]: try: LOG.d("create alias %s for domain %s", address, custom_domain) + mailboxes = custom_domain.mailboxes alias = Alias.create( email=address, user_id=custom_domain.user_id, custom_domain_id=custom_domain.id, automatic_creation=True, - mailbox_id=domain_user.default_mailbox_id, + mailbox_id=mailboxes[0].id, ) + db.session.flush() + for i in range(1, len(mailboxes)): + AliasMailbox.create( + alias_id=alias.id, mailbox_id=mailboxes[i].id, + ) db.session.commit() return alias except AliasInTrashError: From 918b18870f1b816b88ec5b754d0aaebd6569e8e1 Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sat, 1 Aug 2020 12:41:48 +0200 Subject: [PATCH 6/7] show mailboxes that a catch-all alias belongs to --- .../templates/dashboard/domain_detail/info.html | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/app/dashboard/templates/dashboard/domain_detail/info.html b/app/dashboard/templates/dashboard/domain_detail/info.html index 5eb8d6f0..6274e85f 100644 --- a/app/dashboard/templates/dashboard/domain_detail/info.html +++ b/app/dashboard/templates/dashboard/domain_detail/info.html @@ -29,7 +29,12 @@ This feature allows you to create aliases on the fly. Simply use anything@{{ custom_domain.domain }} next time you need an email address.
- The alias will be created the first time it receives an email. + The alias will be created the first time it receives an email + and automatically belong to {{ custom_domain.domain }} mailboxes ( + {% for mailbox in custom_domain.mailboxes %} + {{ mailbox.email }} + {% if not loop.last %},{% endif %} + {% endfor %})
@@ -67,7 +72,7 @@ name="alias-name" placeholder="Alias name">
- From 4a2a4b9828e36e22a5bbc69053599213098136e4 Mon Sep 17 00:00:00 2001 From: Son NK <> Date: Sat, 1 Aug 2020 12:48:03 +0200 Subject: [PATCH 7/7] migration script --- .../versions/2020_080112_a2b95b04d1f7_.py | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 migrations/versions/2020_080112_a2b95b04d1f7_.py diff --git a/migrations/versions/2020_080112_a2b95b04d1f7_.py b/migrations/versions/2020_080112_a2b95b04d1f7_.py new file mode 100644 index 00000000..7abe719f --- /dev/null +++ b/migrations/versions/2020_080112_a2b95b04d1f7_.py @@ -0,0 +1,39 @@ +"""empty message + +Revision ID: a2b95b04d1f7 +Revises: b77ab8c47cc7 +Create Date: 2020-08-01 12:43:56.049075 + +""" +import sqlalchemy_utils +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a2b95b04d1f7' +down_revision = 'b77ab8c47cc7' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('domain_mailbox', + 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('domain_id', sa.Integer(), nullable=False), + sa.Column('mailbox_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['domain_id'], ['custom_domain.id'], ondelete='cascade'), + sa.ForeignKeyConstraint(['mailbox_id'], ['mailbox.id'], ondelete='cascade'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('domain_id', 'mailbox_id', name='uq_domain_mailbox') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('domain_mailbox') + # ### end Alembic commands ###