diff --git a/app/api/views/mailbox.py b/app/api/views/mailbox.py index 27475b86..78378826 100644 --- a/app/api/views/mailbox.py +++ b/app/api/views/mailbox.py @@ -7,15 +7,14 @@ from flask import request from app.api.base import api_bp, require_api_auth from app.config import JOB_DELETE_MAILBOX -from app.dashboard.views.mailbox import send_verification_email from app.dashboard.views.mailbox_detail import verify_mailbox_change from app.db import Session from app.email_utils import ( mailbox_already_used, email_can_be_used_as_mailbox, - is_valid_email, ) from app.log import LOG +from app.mailbox_utils import create_mailbox_and_send_verification, MailboxError from app.models import Mailbox, Job from app.utils import sanitize_email @@ -46,29 +45,14 @@ def create_mailbox(): if not user.is_premium(): return jsonify(error=f"Only premium plan can add additional mailbox"), 400 - - if not is_valid_email(mailbox_email): - return jsonify(error=f"{mailbox_email} invalid"), 400 - elif mailbox_already_used(mailbox_email, user): - return jsonify(error=f"{mailbox_email} already used"), 400 - elif not email_can_be_used_as_mailbox(mailbox_email): + try: + mailbox = create_mailbox_and_send_verification(user, mailbox_email) return ( - jsonify( - error=f"{mailbox_email} cannot be used. Please note a mailbox cannot " - f"be a disposable email address" - ), - 400, - ) - else: - new_mailbox = Mailbox.create(email=mailbox_email, user_id=user.id) - Session.commit() - - send_verification_email(user, new_mailbox) - - return ( - jsonify(mailbox_to_dict(new_mailbox)), + jsonify(mailbox_to_dict(mailbox)), 201, ) + except MailboxError as e: + return jsonify(error=str(e)), 400 @api_bp.route("/mailboxes/", methods=["DELETE"]) diff --git a/app/dashboard/views/mailbox.py b/app/dashboard/views/mailbox.py index 0be92f43..1ea05552 100644 --- a/app/dashboard/views/mailbox.py +++ b/app/dashboard/views/mailbox.py @@ -1,4 +1,3 @@ -import arrow from flask import render_template, request, redirect, url_for, flash from flask_login import login_required, current_user from flask_wtf import FlaskForm @@ -7,18 +6,17 @@ from wtforms import validators, IntegerField from wtforms.fields.html5 import EmailField from app import parallel_limiter -from app.config import MAILBOX_SECRET, URL, JOB_DELETE_MAILBOX +from app.config import MAILBOX_SECRET from app.dashboard.base import dashboard_bp from app.db import Session -from app.email_utils import ( - email_can_be_used_as_mailbox, - mailbox_already_used, - render, - send_email, - is_valid_email, -) from app.log import LOG -from app.models import Mailbox, Job +from app.mailbox_utils import ( + create_mailbox_and_send_verification, + set_mailbox_verified, + MailboxError, + delete_mailbox, +) +from app.models import Mailbox from app.utils import CSRFValidationForm @@ -54,53 +52,16 @@ def mailbox_route(): if not delete_mailbox_form.validate(): flash("Invalid request", "warning") return redirect(request.url) - mailbox = Mailbox.get(delete_mailbox_form.mailbox_id.data) - - if not mailbox or mailbox.user_id != current_user.id: - flash("Invalid mailbox. Refresh the page", "warning") + try: + mailbox = delete_mailbox( + current_user, + delete_mailbox_form.mailbox_id.data, + delete_mailbox_form.transfer_mailbox_id.data, + ) + except MailboxError as e: + flash(str(e), "error") return redirect(url_for("dashboard.mailbox_route")) - if mailbox.id == current_user.default_mailbox_id: - flash("You cannot delete default mailbox", "error") - return redirect(url_for("dashboard.mailbox_route")) - - transfer_mailbox_id = delete_mailbox_form.transfer_mailbox_id.data - if transfer_mailbox_id and transfer_mailbox_id > 0: - transfer_mailbox = Mailbox.get(transfer_mailbox_id) - - if not transfer_mailbox or transfer_mailbox.user_id != current_user.id: - flash( - "You must transfer the aliases to a mailbox you own.", "error" - ) - return redirect(url_for("dashboard.mailbox_route")) - - if transfer_mailbox.id == mailbox.id: - flash( - "You can not transfer the aliases to the mailbox you want to delete.", - "error", - ) - return redirect(url_for("dashboard.mailbox_route")) - - if not transfer_mailbox.verified: - flash("Your new mailbox is not verified", "error") - return redirect(url_for("dashboard.mailbox_route")) - - # Schedule delete account job - LOG.w( - f"schedule delete mailbox job for {mailbox.id} with transfer to mailbox {transfer_mailbox_id}" - ) - Job.create( - name=JOB_DELETE_MAILBOX, - payload={ - "mailbox_id": mailbox.id, - "transfer_mailbox_id": transfer_mailbox_id - if transfer_mailbox_id > 0 - else None, - }, - run_at=arrow.now(), - commit=True, - ) - flash( f"Mailbox {mailbox.email} scheduled for deletion." f"You will receive a confirmation email when the deletion is finished", @@ -137,37 +98,23 @@ def mailbox_route(): 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().strip().replace(" ", "") + mailbox_email = new_mailbox_form.email.data.lower().strip().replace(" ", "") + try: + new_mailbox = create_mailbox_and_send_verification( + current_user, mailbox_email ) - - if not is_valid_email(mailbox_email): - flash(f"{mailbox_email} invalid", "error") - elif mailbox_already_used(mailbox_email, current_user): - flash(f"{mailbox_email} already used", "error") - elif not email_can_be_used_as_mailbox(mailbox_email): - flash(f"You cannot use {mailbox_email}.", "error") - else: - new_mailbox = Mailbox.create( - email=mailbox_email, user_id=current_user.id - ) - Session.commit() - - send_verification_email(current_user, new_mailbox) - - flash( - f"You are going to receive an email to confirm {mailbox_email}.", - "success", - ) - - return redirect( - url_for( - "dashboard.mailbox_detail_route", - mailbox_id=new_mailbox.id, - ) + flash( + f"You are going to receive an email to confirm {mailbox_email}.", + "success", + ) + return redirect( + url_for( + "dashboard.mailbox_detail_route", + mailbox_id=new_mailbox.id, ) + ) + except MailboxError as e: + flash(str(e), "error") return render_template( "dashboard/mailbox.html", @@ -178,30 +125,6 @@ def mailbox_route(): ) -def send_verification_email(user, mailbox): - s = TimestampSigner(MAILBOX_SECRET) - mailbox_id_signed = s.sign(str(mailbox.id)).decode() - verification_url = ( - URL + "/dashboard/mailbox_verify" + f"?mailbox_id={mailbox_id_signed}" - ) - send_email( - mailbox.email, - f"Please confirm your mailbox {mailbox.email}", - render( - "transactional/verify-mailbox.txt.jinja2", - user=user, - link=verification_url, - mailbox_email=mailbox.email, - ), - render( - "transactional/verify-mailbox.html", - user=user, - link=verification_url, - mailbox_email=mailbox.email, - ), - ) - - @dashboard_bp.route("/mailbox_verify") def mailbox_verify(): s = TimestampSigner(MAILBOX_SECRET) @@ -218,8 +141,7 @@ def mailbox_verify(): flash("Invalid link", "error") return redirect(url_for("dashboard.mailbox_route")) - mailbox.verified = True - Session.commit() + set_mailbox_verified(mailbox) LOG.d("Mailbox %s is verified", mailbox) diff --git a/app/mailbox_utils.py b/app/mailbox_utils.py new file mode 100644 index 00000000..52d314c0 --- /dev/null +++ b/app/mailbox_utils.py @@ -0,0 +1,161 @@ +import secrets +from typing import Optional + +import arrow +from itsdangerous import TimestampSigner + +from app import config +from app.config import JOB_DELETE_MAILBOX +from app.db import Session +from app.email_utils import ( + is_valid_email, + mailbox_already_used, + email_can_be_used_as_mailbox, + send_email, + render, +) +from app.log import LOG +from app.models import User, Mailbox, Job + +MAX_MAILBOX_VERIFICATION_TRIES = 3 + + +class MailboxError(Exception): + pass + + +def _attach_new_validation_code_for_mailbox(mailbox: Mailbox) -> Mailbox: + mailbox.verification_code = secrets.randbelow(10**6) + mailbox.verification_expiration = arrow.utcnow().shift(minutes=15) + Session.commit() + return mailbox + + +def _send_verification_email(user, mailbox): + s = TimestampSigner(config.MAILBOX_SECRET) + mailbox_id_signed = s.sign(str(mailbox.id)).decode() + verification_url = ( + config.URL + "/dashboard/mailbox_verify" + f"?mailbox_id={mailbox_id_signed}" + ) + send_email( + mailbox.email, + f"Please confirm your mailbox {mailbox.email}", + render( + "transactional/verify-mailbox.txt.jinja2", + user=user, + link=verification_url, + mailbox=mailbox, + ), + render( + "transactional/verify-mailbox.html", + user=user, + link=verification_url, + mailbox=mailbox, + ), + ) + + +def create_mailbox_and_send_verification( + user: User, email: str, use_code_validation: bool = False +) -> Mailbox: + mailbox_email = email.lower().strip().replace(" ", "") + + if not is_valid_email(mailbox_email): + raise MailboxError(f"Invalid address {mailbox_email}") + elif mailbox_already_used(mailbox_email, user): + raise MailboxError(f"Mailbox {mailbox_email} already exists") + elif not email_can_be_used_as_mailbox(mailbox_email): + raise MailboxError(f"Invalid address {mailbox_email}") + + new_mailbox = Mailbox.create(email=mailbox_email, user_id=user.id) + if use_code_validation: + new_mailbox.verification_tries = 0 + new_mailbox = _attach_new_validation_code_for_mailbox(new_mailbox) + Session.commit() + _send_verification_email(user, new_mailbox) + + return new_mailbox + + +def send_new_verification_to_mailbox(user: User, mailbox: Mailbox): + if mailbox.verified: + return + if mailbox.verification_tries > MAX_MAILBOX_VERIFICATION_TRIES: + mailbox.delete() + Session.commit() + return + mailbox = _attach_new_validation_code_for_mailbox(mailbox) + Session.commit() + _send_verification_email(user, mailbox) + + +def set_mailbox_verified(mailbox: Mailbox) -> Mailbox: + mailbox.verified = True + mailbox.verification_code = None + mailbox.verification_expiration = None + mailbox.verification_tries = 0 + Session.commit() + return mailbox + + +def verify_mailbox_with_code(user: User, mailbox_id: int, code: str) -> Mailbox: + mailbox = Mailbox.get_by(id=mailbox_id) + if mailbox is None: + raise MailboxError("Invalid mailbox") + if mailbox.user_id != user.id: + raise MailboxError("Invalid mailbox") + if mailbox.verified: + return mailbox + if mailbox.verification_expiration < arrow.utcnow(): + mailbox = _attach_new_validation_code_for_mailbox(mailbox) + _send_verification_email(user, mailbox) + raise MailboxError("Code has expired. A new one has been sent") + if mailbox.verification_code != code: + mailbox.verification_tries += 1 + if mailbox.verification_tries >= MAX_MAILBOX_VERIFICATION_TRIES: + mailbox.delete() + Session.commit() + raise MailboxError("Too many tries") + raise MailboxError("Invalid code") + + return set_mailbox_verified(mailbox) + + +def delete_mailbox( + user: User, mailbox_id: int, transfer_mailbox_id: Optional[int] = None +) -> Mailbox: + mailbox = Mailbox.get(mailbox_id) + if mailbox is None: + raise MailboxError("Invalid mailbox") + if mailbox.user_id != user.id: + raise MailboxError("Invalid mailbox") + if mailbox.id == user.default_mailbox_id: + raise MailboxError("You cannot delete your default mailbox") + if transfer_mailbox_id and transfer_mailbox_id > 0: + transfer_mailbox = Mailbox.get(transfer_mailbox_id) + + if not transfer_mailbox or transfer_mailbox.user_id != user.id: + raise MailboxError("You must transfer the aliases to a mailbox you own") + + if transfer_mailbox.id == mailbox.id: + raise MailboxError( + "You can not transfer the aliases to the mailbox you want to delete" + ) + + if not transfer_mailbox.verified: + raise MailboxError("Your new mailbox is not verified") + LOG.w( + f"Schedule delete mailbox job for {mailbox.id} with transfer to mailbox {transfer_mailbox_id}" + ) + Job.create( + name=JOB_DELETE_MAILBOX, + payload={ + "mailbox_id": mailbox.id, + "transfer_mailbox_id": transfer_mailbox_id + if transfer_mailbox_id is not None + else None, + }, + run_at=arrow.now(), + commit=True, + ) + return mailbox diff --git a/app/models.py b/app/models.py index 76a71b58..de4c4617 100644 --- a/app/models.py +++ b/app/models.py @@ -2517,6 +2517,11 @@ class Mailbox(Base, ModelMixin): disabled = sa.Column(sa.Boolean, default=False, nullable=False, server_default="0") generic_subject = sa.Column(sa.String(78), nullable=True) + verification_code = sa.Column(sa.String(128), nullable=True) + verification_expiration = sa.Column(ArrowType, nullable=True) + verification_tries = sa.Column( + sa.Integer, nullable=False, default=0, server_default="0" + ) __table_args__ = (sa.UniqueConstraint("user_id", "email", name="uq_mailbox_user"),) diff --git a/migrations/versions/2023_050911_f5f2e08fbd2e_.py b/migrations/versions/2023_050911_f5f2e08fbd2e_.py new file mode 100644 index 00000000..07e3a79b --- /dev/null +++ b/migrations/versions/2023_050911_f5f2e08fbd2e_.py @@ -0,0 +1,33 @@ +"""empty message + +Revision ID: f5f2e08fbd2e +Revises: 2634b41f54db +Create Date: 2023-05-09 11:52:41.100335 + +""" +import sqlalchemy_utils +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'f5f2e08fbd2e' +down_revision = '2634b41f54db' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('mailbox', sa.Column('verification_code', sa.String(length=128), nullable=True)) + op.add_column('mailbox', sa.Column('verification_expiration', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True)) + op.add_column('mailbox', sa.Column('verification_tries', sa.Integer(), server_default='0', nullable=False)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('mailbox', 'verification_tries') + op.drop_column('mailbox', 'verification_expiration') + op.drop_column('mailbox', 'verification_code') + # ### end Alembic commands ### diff --git a/static/package-lock.json b/static/package-lock.json index 21a28c3b..17bc7b4a 100644 --- a/static/package-lock.json +++ b/static/package-lock.json @@ -1,169 +1,123 @@ { "name": "simplelogin", "version": "1.0.0", - "lockfileVersion": 3, + "lockfileVersion": 1, "requires": true, - "packages": { - "": { - "name": "simplelogin", - "version": "1.0.0", - "license": "MIT", - "dependencies": { - "@sentry/browser": "^5.30.0", - "bootbox": "^5.5.3", - "font-awesome": "^4.7.0", - "htmx.org": "^1.6.1", - "intro.js": "^2.9.3", - "multiple-select": "^1.5.2", - "parsleyjs": "^2.9.2", - "qrious": "^4.0.2", - "toastr": "^2.1.4", - "vue": "^2.6.14" - } - }, - "node_modules/@sentry/browser": { + "dependencies": { + "@sentry/browser": { "version": "5.30.0", "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-5.30.0.tgz", "integrity": "sha512-rOb58ZNVJWh1VuMuBG1mL9r54nZqKeaIlwSlvzJfc89vyfd7n6tQ1UXMN383QBz/MS5H5z44Hy5eE+7pCrYAfw==", - "dependencies": { + "requires": { "@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": { + "@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": { + "requires": { "@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": { + "@sentry/hub": { "version": "5.30.0", "resolved": "https://registry.npmjs.org/@sentry/hub/-/hub-5.30.0.tgz", "integrity": "sha512-2tYrGnzb1gKz2EkMDQcfLrDTvmGcQPuWxLnJKXJvYTQDGLlEvi2tWz1VIHjunmOvJrB5aIQLhm+dcMRwFZDCqQ==", - "dependencies": { + "requires": { "@sentry/types": "5.30.0", "@sentry/utils": "5.30.0", "tslib": "^1.9.3" - }, - "engines": { - "node": ">=6" } }, - "node_modules/@sentry/minimal": { + "@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": { + "requires": { "@sentry/hub": "5.30.0", "@sentry/types": "5.30.0", "tslib": "^1.9.3" - }, - "engines": { - "node": ">=6" } }, - "node_modules/@sentry/types": { + "@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" - } + "integrity": "sha512-R8xOqlSTZ+htqrfteCWU5Nk0CDN5ApUTvrlvBuiH1DyP6czDZ4ktbZB0hAgBlVcK0U+qpD3ag3Tqqpa5Q67rPw==" }, - "node_modules/@sentry/utils": { + "@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": { + "requires": { "@sentry/types": "5.30.0", "tslib": "^1.9.3" - }, - "engines": { - "node": ">=6" } }, - "node_modules/bootbox": { + "bootbox": { "version": "5.5.3", "resolved": "https://registry.npmjs.org/bootbox/-/bootbox-5.5.3.tgz", - "integrity": "sha512-B4mnm1DYgNHzoNtD7I0L/fixqvya4EEQy5bFF/yNmGI2Eq3WwVVwdfWf3hoF8KS+EaV4f0uIMqtxB1EAZwZPhQ==", - "peerDependencies": { - "bootstrap": "^3.1.0 || ^4.4.0", - "jquery": "^3.5.1", - "popper.js": "^1.16.0" - } + "integrity": "sha512-B4mnm1DYgNHzoNtD7I0L/fixqvya4EEQy5bFF/yNmGI2Eq3WwVVwdfWf3hoF8KS+EaV4f0uIMqtxB1EAZwZPhQ==" }, - "node_modules/font-awesome": { + "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" - } + "integrity": "sha512-U6kGnykA/6bFmg1M/oT9EkFeIYv7JlX3bozwQJWiiLz6L0w3F5vBVPxHlwyX/vtNq1ckcpRKOB9f2Qal/VtFpg==" }, - "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==" + "htmx.org": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-1.7.0.tgz", + "integrity": "sha512-wIQ3yNq7yiLTm+6BhV7Z8qKKTzEQv9xN/I4QsN5FvdGi69SNWTsSMlhH69HPa1rpZ8zSq1A/e7gTbTySxliP8g==" }, - "node_modules/intro.js": { + "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==" + "jquery": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.4.tgz", + "integrity": "sha512-v28EW9DWDFpzcD9O5iyJXg3R3+q+mET5JhnjJzQUZMHOv67bpSIHq81GEYpPNZHG+XXHsfSme3nxp/hndKEcsQ==" }, - "node_modules/multiple-select": { + "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" - } + "integrity": "sha512-sTNNRrjnTtB1b1+HTKcjQ/mjWY7Gvigo9F3C/3oTQCTFEpYzwaRYFPRAOu2SogfA1hEfyJTXjyS1VAbanJMsmA==" }, - "node_modules/parsleyjs": { + "parsleyjs": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/parsleyjs/-/parsleyjs-2.9.2.tgz", "integrity": "sha512-DKS2XXTjEUZ1BJWUzgXAr+550kFBZrom2WYweubqdV7WzdNC1hjOajZDfeBPoAZMkXumJPlB3v37IKatbiW8zQ==", - "dependencies": { + "requires": { "jquery": ">=1.8.0" } }, - "node_modules/qrious": { + "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": { + "toastr": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/toastr/-/toastr-2.1.4.tgz", - "integrity": "sha1-i0O+ZPudDEFIcURvLbjoyk6V8YE=", - "dependencies": { + "integrity": "sha512-LIy77F5n+sz4tefMmFOntcJ6HL0Fv3k1TDnNmFZ0bU/GcvIIfy6eG2v7zQmMiYgaalAiUv75ttFrPn5s0gyqlA==", + "requires": { "jquery": ">=1.12.0" } }, - "node_modules/tslib": { + "tslib": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==" }, - "node_modules/vue": { + "vue": { "version": "2.6.14", "resolved": "https://registry.npmjs.org/vue/-/vue-2.6.14.tgz", "integrity": "sha512-x2284lgYvjOMj3Za7kqzRcUSxBboHqtgRE2zlos1qWaOye5yUmHn42LB1250NJBLRwEcdrB0JRwyPTEPhfQjiQ==" diff --git a/templates/emails/transactional/verify-mailbox.html b/templates/emails/transactional/verify-mailbox.html index b0dadd6a..9006258b 100644 --- a/templates/emails/transactional/verify-mailbox.html +++ b/templates/emails/transactional/verify-mailbox.html @@ -3,9 +3,15 @@ {% block content %} {{ render_text("Hi") }} - {{ render_text("You have added "+ mailbox_email +" as an additional mailbox.") }} - {{ render_text("To confirm, please click on the button below.") }} - {{ render_button("Confirm mailbox", link) }} + {{ render_text("You have added "+ mailbox.email +" as an additional mailbox.") }} + {% if mailbox.verification_code is not none -%} + + {{ render_text("To confirm, please write this code to verify") }} + {{ render_text("{}".format(mailbox.verification_code.zfill(6))) }} + {% else -%} + {{ render_text("To confirm, please click on the button below.") }} + {{ render_button("Confirm mailbox", link) }} + {% endif %} {{ render_text("This email will only be valid for the next 15 minutes.") }} {{ render_text('Thanks,
diff --git a/templates/emails/transactional/verify-mailbox.txt.jinja2 b/templates/emails/transactional/verify-mailbox.txt.jinja2 index 588edeee..325666a2 100644 --- a/templates/emails/transactional/verify-mailbox.txt.jinja2 +++ b/templates/emails/transactional/verify-mailbox.txt.jinja2 @@ -3,11 +3,17 @@ {% block content %} Hi -You have added {{mailbox_email}} as an additional mailbox. +You have added {{mailbox.email}} as an additional mailbox. +{% if mailbox.verification_code is not none -%} +To confirm, please write this code to verify: + +{{ mailbox.verification_code.zfill(6) }} +{% else -%} To confirm, please click on this link: {{link}} +{% endif %} -This link will only be valid during the next 15 minutes. +This email will only be valid during the next 15 minutes. {% endblock %} diff --git a/tests/api/test_mailbox.py b/tests/api/test_mailbox.py index 8a28d1ff..73e1e054 100644 --- a/tests/api/test_mailbox.py +++ b/tests/api/test_mailbox.py @@ -28,7 +28,7 @@ def test_create_mailbox(flask_client): ) assert r.status_code == 400 - assert r.json == {"error": "gmail.com invalid"} + assert r.json == {"error": "Invalid address gmail.com"} def test_create_mailbox_fail_for_free_user(flask_client): diff --git a/tests/test_mailbox_utils.py b/tests/test_mailbox_utils.py new file mode 100644 index 00000000..826079f5 --- /dev/null +++ b/tests/test_mailbox_utils.py @@ -0,0 +1,165 @@ +import arrow +import pytest +from sqlalchemy import desc + +from app import config +from app.config import JOB_DELETE_MAILBOX +from app.db import Session +from app.mail_sender import mail_sender +from app.mailbox_utils import ( + create_mailbox_and_send_verification, + verify_mailbox_with_code, + MailboxError, + delete_mailbox, +) +from app.models import Mailbox, Job +from tests.utils import create_new_user, random_email + +test_user = None + + +def setup_module(): + global test_user + config.SKIP_MX_LOOKUP_ON_CHECK = True + test_user = create_new_user() + + +def teardown_module(): + config.SKIP_MX_LOOKUP_ON_CHECK = False + + +@mail_sender.store_emails_test_decorator +def test_mailbox_creation_sends_link_verification(): + mailbox = create_mailbox_and_send_verification(test_user, random_email()) + assert mailbox is not None + assert mailbox.verification_code is None + assert mailbox.verification_expiration is None + assert mailbox.verification_tries == 0 + mails = mail_sender.get_stored_emails() + assert len(mails) == 1 + assert "link" in str(mails[0].msg) + + +@mail_sender.store_emails_test_decorator +def test_mailbox_creation_sends_code_verification(): + mailbox = create_mailbox_and_send_verification(test_user, random_email(), True) + assert mailbox is not None + assert mailbox.verification_code is not None + assert mailbox.verification_expiration is not None + assert mailbox.verification_tries == 0 + mails = mail_sender.get_stored_emails() + assert len(mails) == 1 + assert mailbox.verification_code in str(mails[0].msg) + + +def test_verification_with_code(): + mailbox = create_mailbox_and_send_verification(test_user, random_email(), True) + mailbox_id = mailbox.id + verified_mbox = verify_mailbox_with_code( + test_user, mailbox_id, mailbox.verification_code + ) + assert verified_mbox.id == mailbox_id + assert verified_mbox.verified + assert verified_mbox.verification_code is None + assert verified_mbox.verification_expiration is None + assert verified_mbox.verification_tries == 0 + + +def test_fail_verification_with_code(): + mailbox = create_mailbox_and_send_verification(test_user, random_email(), True) + mailbox_id = mailbox.id + with pytest.raises(MailboxError): + verify_mailbox_with_code(test_user, mailbox_id, "INVALID") + mbox = Mailbox.get_by(id=mailbox_id) + assert mbox.id == mailbox_id + assert not mbox.verified + assert mbox.verification_code is not None + assert mbox.verification_expiration is not None + assert mbox.verification_tries == 1 + + +def test_fail_verification_with_invalid_mbox_id(): + with pytest.raises(MailboxError): + verify_mailbox_with_code(test_user, 99999999, "INVALID") + + +def test_verification_with_verified_mbox_is_ok(): + mailbox = Mailbox.create(email=random_email(), user_id=test_user.id, verified=True) + Session.commit() + verified_mbox = verify_mailbox_with_code(test_user, mailbox.id, "INVALID") + assert verified_mbox.id == mailbox.id + + +@mail_sender.store_emails_test_decorator +def test_validate_expired_sends_a_new_email(): + mailbox = Mailbox.create( + email=random_email(), + user_id=test_user.id, + verification_expiration=arrow.utcnow().shift(days=-1), + ) + Session.commit() + with pytest.raises(MailboxError): + verify_mailbox_with_code(test_user, mailbox.id, mailbox.verification_code) + sent_emails = mail_sender.get_stored_emails() + assert len(sent_emails) == 1 + assert mailbox.verification_code in str(sent_emails[0].msg) + + +def test_delete_mailbox_without_transfer(): + mailbox = Mailbox.create(email=random_email(), user_id=test_user.id, verified=True) + Session.commit() + delete_mailbox(test_user, mailbox.id) + job = ( + Session.query(Job) + .filter_by(name=JOB_DELETE_MAILBOX) + .order_by(desc(Job.id)) + .first() + ) + assert job.payload["mailbox_id"] == mailbox.id + assert job.payload["transfer_mailbox_id"] is None + + +def test_delete_mailbox_with_transfer(): + mailbox = Mailbox.create(email=random_email(), user_id=test_user.id, verified=True) + transfer_mailbox = Mailbox.create( + email=random_email(), user_id=test_user.id, verified=True + ) + Session.commit() + delete_mailbox(test_user, mailbox.id, transfer_mailbox.id) + job = ( + Session.query(Job) + .filter_by(name=JOB_DELETE_MAILBOX) + .order_by(desc(Job.id)) + .first() + ) + assert job.payload["mailbox_id"] == mailbox.id + assert job.payload["transfer_mailbox_id"] == transfer_mailbox.id + + +def test_cannot_delete_primary_mailbox(): + with pytest.raises(MailboxError): + delete_mailbox(test_user, test_user.default_mailbox_id) + + +def test_cannot_delete_another_users_mailbox(): + other_user = create_new_user() + mailbox = Mailbox.create(email=random_email(), user_id=test_user.id, verified=True) + with pytest.raises(MailboxError): + delete_mailbox(other_user, mailbox.id) + + +def test_cannot_delete_transfer_being_the_same(): + mailbox = Mailbox.create(email=random_email(), user_id=test_user.id, verified=True) + with pytest.raises(MailboxError): + delete_mailbox(test_user, mailbox.id, mailbox.id) + + +def test_cannot_transfer_to_another_user_mailbox(): + other_user = create_new_user() + mailbox = Mailbox.create(email=random_email(), user_id=test_user.id, verified=True) + transfer_mailbox = Mailbox.create( + email=random_email(), user_id=other_user.id, verified=True + ) + Session.commit() + with pytest.raises(MailboxError): + delete_mailbox(test_user, mailbox.id, transfer_mailbox.id)