From eca2422be4e7645f59d6ef93c9f8cc3aac10f220 Mon Sep 17 00:00:00 2001 From: Son NK Date: Mon, 10 Feb 2020 23:11:09 +0700 Subject: [PATCH 01/18] Add Mailbox model, GenEmail.mailbox_id column --- app/models.py | 24 ++++++++++- .../versions/2020_021023_6664d75ce3d4_.py | 43 +++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) create mode 100644 migrations/versions/2020_021023_6664d75ce3d4_.py diff --git a/app/models.py b/app/models.py index 53efb870..e96a8abc 100644 --- a/app/models.py +++ b/app/models.py @@ -17,7 +17,7 @@ from app.config import ( AVATAR_URL_EXPIRATION, JOB_ONBOARDING_1, ) -from app.email_utils import get_email_name + from app.extensions import db from app.log import LOG from app.oauth_models import Scope @@ -478,7 +478,13 @@ class GenEmail(db.Model, ModelMixin): note = db.Column(db.Text, default=None, nullable=True) + # an alias can be owned by another mailbox + mailbox_id = db.Column( + db.ForeignKey("mailbox.id", ondelete="cascade"), nullable=True, default=None + ) + user = db.relationship(User) + mailbox = db.relationship('Mailbox') @classmethod def create_new(cls, user_id, prefix, note=None): @@ -626,6 +632,8 @@ class ForwardEmail(db.Model, ModelMixin): def website_send_to(self): """return the email address with name. to use when user wants to send an email from the alias""" + from app.email_utils import get_email_name + if self.website_from: name = get_email_name(self.website_from) if name: @@ -799,3 +807,17 @@ class Job(db.Model, ModelMixin): def __repr__(self): return f"" + + +class Mailbox(db.Model, ModelMixin): + user_id = db.Column(db.ForeignKey(User.id, ondelete="cascade"), nullable=False) + email = db.Column(db.String(256), unique=True, nullable=False) + verified = db.Column(db.Boolean, default=False, nullable=False) + + user = db.relationship(User) + + def nb_alias(self): + return GenEmail.filter_by(mailbox_id=self.id).count() + + def __repr__(self): + return f"" diff --git a/migrations/versions/2020_021023_6664d75ce3d4_.py b/migrations/versions/2020_021023_6664d75ce3d4_.py new file mode 100644 index 00000000..26015c00 --- /dev/null +++ b/migrations/versions/2020_021023_6664d75ce3d4_.py @@ -0,0 +1,43 @@ +"""empty message + +Revision ID: 6664d75ce3d4 +Revises: b9f849432543 +Create Date: 2020-02-10 23:10:09.134369 + +""" +import sqlalchemy_utils +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '6664d75ce3d4' +down_revision = 'b9f849432543' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('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('user_id', sa.Integer(), nullable=False), + sa.Column('email', sa.String(length=256), nullable=False), + sa.Column('verified', sa.Boolean(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='cascade'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('email') + ) + op.add_column('gen_email', sa.Column('mailbox_id', sa.Integer(), nullable=True)) + op.create_foreign_key(None, 'gen_email', 'mailbox', ['mailbox_id'], ['id'], ondelete='cascade') + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_constraint(None, 'gen_email', type_='foreignkey') + op.drop_column('gen_email', 'mailbox_id') + op.drop_table('mailbox') + # ### end Alembic commands ### From 821372fdfddc446763bd772a0e744d37e526ed25 Mon Sep 17 00:00:00 2001 From: Son NK Date: Mon, 10 Feb 2020 23:14:36 +0700 Subject: [PATCH 02/18] add email_already_used() and use it when creating user --- app/auth/views/facebook.py | 4 ++-- app/auth/views/github.py | 4 ++-- app/auth/views/google.py | 4 ++-- app/auth/views/register.py | 6 ++---- app/dashboard/views/setting.py | 4 ++-- app/email_utils.py | 15 +++++++++++++++ 6 files changed, 25 insertions(+), 12 deletions(-) diff --git a/app/auth/views/facebook.py b/app/auth/views/facebook.py index 3d590140..b59beceb 100644 --- a/app/auth/views/facebook.py +++ b/app/auth/views/facebook.py @@ -16,7 +16,7 @@ from app.extensions import db from app.log import LOG from app.models import User from .login_utils import after_login -from ...email_utils import can_be_used_as_personal_email +from ...email_utils import can_be_used_as_personal_email, email_already_used _authorization_base_url = "https://www.facebook.com/dialog/oauth" _token_url = "https://graph.facebook.com/oauth/access_token" @@ -112,7 +112,7 @@ def facebook_callback(): flash("Registration is closed", "error") return redirect(url_for("auth.login")) - if not can_be_used_as_personal_email(email): + if not can_be_used_as_personal_email(email) or email_already_used(email): flash( f"You cannot use {email} as your personal inbox.", "error", ) diff --git a/app/auth/views/github.py b/app/auth/views/github.py index a0488a7f..8f24f132 100644 --- a/app/auth/views/github.py +++ b/app/auth/views/github.py @@ -6,7 +6,7 @@ from app import email_utils from app.auth.base import auth_bp from app.auth.views.login_utils import after_login from app.config import GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, URL, DISABLE_REGISTRATION -from app.email_utils import can_be_used_as_personal_email +from app.email_utils import can_be_used_as_personal_email, email_already_used from app.extensions import db from app.log import LOG from app.models import User @@ -89,7 +89,7 @@ def github_callback(): flash("Registration is closed", "error") return redirect(url_for("auth.login")) - if not can_be_used_as_personal_email(email): + if not can_be_used_as_personal_email(email) or email_already_used(email): flash( f"You cannot use {email} as your personal inbox.", "error", ) diff --git a/app/auth/views/google.py b/app/auth/views/google.py index 3538fed3..78792e60 100644 --- a/app/auth/views/google.py +++ b/app/auth/views/google.py @@ -10,7 +10,7 @@ from app.log import LOG from app.models import User, File from app.utils import random_string from .login_utils import after_login -from ...email_utils import can_be_used_as_personal_email +from ...email_utils import can_be_used_as_personal_email, email_already_used _authorization_base_url = "https://accounts.google.com/o/oauth2/v2/auth" _token_url = "https://www.googleapis.com/oauth2/v4/token" @@ -97,7 +97,7 @@ def google_callback(): flash("Registration is closed", "error") return redirect(url_for("auth.login")) - if not can_be_used_as_personal_email(email): + if not can_be_used_as_personal_email(email) or email_already_used(email): flash( f"You cannot use {email} as your personal inbox.", "error", ) diff --git a/app/auth/views/register.py b/app/auth/views/register.py index e4efa46d..3acb9a91 100644 --- a/app/auth/views/register.py +++ b/app/auth/views/register.py @@ -6,7 +6,7 @@ from wtforms import StringField, validators from app import email_utils, config from app.auth.base import auth_bp from app.config import URL, DISABLE_REGISTRATION -from app.email_utils import can_be_used_as_personal_email +from app.email_utils import can_be_used_as_personal_email, email_already_used from app.extensions import db from app.log import LOG from app.models import User, ActivationCode @@ -41,9 +41,7 @@ def register(): "You cannot use this email address as your personal inbox.", "error", ) else: - user = User.get_by(email=email) - - if user: + if email_already_used(email): flash(f"Email {email} already used", "error") else: LOG.debug("create user %s", form.email.data) diff --git a/app/dashboard/views/setting.py b/app/dashboard/views/setting.py index f83e9a77..b1a6f294 100644 --- a/app/dashboard/views/setting.py +++ b/app/dashboard/views/setting.py @@ -11,7 +11,7 @@ from wtforms import StringField, validators from app import s3, email_utils from app.config import URL from app.dashboard.base import dashboard_bp -from app.email_utils import can_be_used_as_personal_email +from app.email_utils import can_be_used_as_personal_email, email_already_used from app.extensions import db from app.log import LOG from app.models import ( @@ -88,7 +88,7 @@ def setting(): # check if this email is not used by other user, or as alias if ( - User.get_by(email=new_email) + email_already_used(new_email) or GenEmail.get_by(email=new_email) or DeletedAlias.get_by(email=new_email) ): diff --git a/app/email_utils.py b/app/email_utils.py index 40accd84..d4ad481a 100644 --- a/app/email_utils.py +++ b/app/email_utils.py @@ -18,6 +18,7 @@ from app.config import ( SUPPORT_NAME, ) from app.log import LOG +from app.models import Mailbox, User def render(template_name, **kwargs) -> str: @@ -330,3 +331,17 @@ def can_be_used_as_personal_email(email: str) -> bool: return False return True + + +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 From 8a531f6c86abdd3cf531bdf1d3b55ca3cd09c8fe Mon Sep 17 00:00:00 2001 From: Son NK Date: Mon, 10 Feb 2020 23:17:05 +0700 Subject: [PATCH 03/18] User can add/delete/verify mailbox --- app/dashboard/__init__.py | 1 + .../templates/dashboard/mailbox.html | 111 +++++++++++++++ app/dashboard/views/mailbox.py | 129 ++++++++++++++++++ .../emails/transactional/verify-mailbox.html | 9 ++ .../emails/transactional/verify-mailbox.txt | 10 ++ templates/menu.html | 8 ++ 6 files changed, 268 insertions(+) create mode 100644 app/dashboard/templates/dashboard/mailbox.html create mode 100644 app/dashboard/views/mailbox.py create mode 100644 templates/emails/transactional/verify-mailbox.html create mode 100644 templates/emails/transactional/verify-mailbox.txt diff --git a/app/dashboard/__init__.py b/app/dashboard/__init__.py index 4cc25e80..1a684cc4 100644 --- a/app/dashboard/__init__.py +++ b/app/dashboard/__init__.py @@ -14,4 +14,5 @@ from .views import ( domain_detail, lifetime_licence, directory, + mailbox, ) diff --git a/app/dashboard/templates/dashboard/mailbox.html b/app/dashboard/templates/dashboard/mailbox.html new file mode 100644 index 00000000..bfa07221 --- /dev/null +++ b/app/dashboard/templates/dashboard/mailbox.html @@ -0,0 +1,111 @@ +{% extends 'default.html' %} +{% set active_page = "mailbox" %} + +{% block title %} + Mailboxes +{% endblock %} + +{% block default_content %} +
+
+

Mailboxes

+ + {% if not current_user.is_premium() %} + + {% endif %} + + + + {% for mailbox in mailboxes %} +
+
+
+ {{ mailbox.email }} + {% if mailbox.verified %} + + {% else %} + + 🚫 + + {% endif %} + +
+
+ Created {{ mailbox.created_at | dt }}
+ {{ mailbox.nb_alias() }} aliases. +
+
+ + + +
+ {% endfor %} + + {% if mailboxs|length > 0 %} +
+ {% endif %} + +
+ {{ new_mailbox_form.csrf_token }} + + +
Email
+
+ A verification email will be sent to this email to make sure you have access to this email. +
+ + {{ new_mailbox_form.email(class="form-control", placeholder="email@example.com", + autofocus="1") }} + {{ render_field_errors(new_mailbox_form.email) }} + +
+ +
+ +
+{% endblock %} + +{% block script %} + +{% endblock %} \ No newline at end of file diff --git a/app/dashboard/views/mailbox.py b/app/dashboard/views/mailbox.py new file mode 100644 index 00000000..8b95e42e --- /dev/null +++ b/app/dashboard/views/mailbox.py @@ -0,0 +1,129 @@ +from flask import render_template, request, redirect, url_for, flash +from flask_login import login_required, current_user +from flask_wtf import FlaskForm +from itsdangerous import Signer, BadSignature +from wtforms import validators +from wtforms.fields.html5 import EmailField + +from app.config import EMAIL_DOMAIN, ALIAS_DOMAINS, FLASK_SECRET, URL +from app.dashboard.base import dashboard_bp +from app.email_utils import ( + send_email, + render, + can_be_used_as_personal_email, + email_already_used, +) +from app.extensions import db +from app.log import LOG +from app.models import Mailbox + + +class NewMailboxForm(FlaskForm): + email = EmailField( + "email", validators=[validators.DataRequired(), validators.Email()] + ) + + +@dashboard_bp.route("/mailbox", methods=["GET", "POST"]) +@login_required +def mailbox_route(): + mailboxes = Mailbox.query.filter_by(user_id=current_user.id).all() + + new_mailbox_form = NewMailboxForm() + + if request.method == "POST": + if request.form.get("form-name") == "delete": + mailbox_id = request.form.get("mailbox-id") + mailbox = Mailbox.get(mailbox_id) + + if not mailbox or mailbox.user_id != current_user.id: + flash("Unknown error. Refresh the page", "warning") + return redirect(url_for("dashboard.mailbox_route")) + + email = mailbox.email + Mailbox.delete(mailbox_id) + db.session.commit() + flash(f"Mailbox {email} has been deleted", "success") + + return redirect(url_for("dashboard.mailbox_route")) + + elif request.form.get("form-name") == "create": + if not current_user.is_premium(): + flash("Only premium plan can add additional mailbox", "warning") + return redirect(url_for("dashboard.mailbox_route")) + + if new_mailbox_form.validate(): + mailbox_email = new_mailbox_form.email.data.lower() + + if email_already_used(mailbox_email): + flash(f"{mailbox_email} already used", "error") + elif not can_be_used_as_personal_email(mailbox_email): + flash( + f"You cannot use {mailbox_email}.", "error", + ) + else: + new_mailbox = Mailbox.create( + email=mailbox_email, user_id=current_user.id + ) + db.session.commit() + + s = Signer(FLASK_SECRET) + mailbox_id_signed = s.sign(str(new_mailbox.id)).decode() + verification_url = ( + URL + + "/dashboard/mailbox_verify" + + f"?mailbox_id={mailbox_id_signed}" + ) + send_email( + current_user.email, + f"Please confirm your email {mailbox_email}", + render( + "transactional/verify-mailbox.txt", + user=current_user, + link=verification_url, + mailbox_email=mailbox_email, + ), + render( + "transactional/verify-mailbox.html", + user=current_user, + link=verification_url, + mailbox_email=mailbox_email, + ), + ) + + flash( + f"You are going to receive an email to confirm {mailbox_email}.", + "success", + ) + + return redirect(url_for("dashboard.mailbox_route")) + + return render_template( + "dashboard/mailbox.html", + mailboxes=mailboxes, + new_mailbox_form=new_mailbox_form, + EMAIL_DOMAIN=EMAIL_DOMAIN, + ALIAS_DOMAINS=ALIAS_DOMAINS, + ) + + +@dashboard_bp.route("/mailbox_verify") +def mailbox_verify(): + s = Signer(FLASK_SECRET) + mailbox_id = request.args.get("mailbox_id") + + try: + r_id = int(s.unsign(mailbox_id)) + except BadSignature: + flash("Invalid link. Please delete and re-add your mailbox", "error") + else: + mailbox = Mailbox.get(r_id) + mailbox.verified = True + db.session.commit() + + LOG.d("Mailbox %s is verified", mailbox) + flash( + f"The {mailbox.email} is now verified, you can start creating alias with it", + "success", + ) + return redirect(url_for("dashboard.mailbox_route")) diff --git a/templates/emails/transactional/verify-mailbox.html b/templates/emails/transactional/verify-mailbox.html new file mode 100644 index 00000000..e774b1d4 --- /dev/null +++ b/templates/emails/transactional/verify-mailbox.html @@ -0,0 +1,9 @@ +{% extends "base.html" %} + +{% block content %} + {{ render_text("Hi " + user.name) }} + {{ render_text("You have added "+ mailbox_email +" as an additional mailbox.") }} + {{ render_text("To confirm, please click on the button below or use this link:
" + link) }} + {{ render_button("Confirm email", link) }} +{% endblock %} + diff --git a/templates/emails/transactional/verify-mailbox.txt b/templates/emails/transactional/verify-mailbox.txt new file mode 100644 index 00000000..6a804029 --- /dev/null +++ b/templates/emails/transactional/verify-mailbox.txt @@ -0,0 +1,10 @@ +Hi {{user.name}} + +You have added {{mailbox_email}} as an additional mailbox. + +To confirm, please click on this link: + +{{link}} + +Regards, +Son - SimpleLogin founder. diff --git a/templates/menu.html b/templates/menu.html index 03de7d78..7eefa451 100644 --- a/templates/menu.html +++ b/templates/menu.html @@ -39,6 +39,14 @@ + +