diff --git a/app/app/admin_model.py b/app/app/admin_model.py index 0aa7edd..565e4b6 100644 --- a/app/app/admin_model.py +++ b/app/app/admin_model.py @@ -2,6 +2,7 @@ from typing import Optional import arrow import sqlalchemy +from flask_admin import BaseView from flask_admin.form import SecureForm from flask_admin.model.template import EndpointLinkRowAction from markupsafe import Markup @@ -28,10 +29,24 @@ from app.models import ( Alias, Newsletter, PADDLE_SUBSCRIPTION_GRACE_DAYS, + Mailbox, ) from app.newsletter_utils import send_newsletter_to_user, send_newsletter_to_address +def _admin_action_formatter(view, context, model, name): + action_name = AuditLogActionEnum.get_name(model.action) + return "{} ({})".format(action_name, model.action) + + +def _admin_date_formatter(view, context, model, name): + return model.created_at.format() + + +def _user_upgrade_channel_formatter(view, context, model, name): + return Markup(model.upgrade_channel) + + class SLModelView(sqla.ModelView): column_default_sort = ("id", True) column_display_pk = True @@ -96,10 +111,6 @@ class SLAdminIndexView(AdminIndexView): return redirect("/admin/user") -def _user_upgrade_channel_formatter(view, context, model, name): - return Markup(model.upgrade_channel) - - class UserAdmin(SLModelView): form_base_class = SecureForm column_searchable_list = ["email", "id"] @@ -120,6 +131,8 @@ class UserAdmin(SLModelView): column_formatters = { "upgrade_channel": _user_upgrade_channel_formatter, + "created_at": _admin_date_formatter, + "updated_at": _admin_date_formatter, } @action( @@ -353,12 +366,22 @@ class EmailLogAdmin(SLModelView): can_edit = False can_create = False + column_formatters = { + "created_at": _admin_date_formatter, + "updated_at": _admin_date_formatter, + } + class AliasAdmin(SLModelView): form_base_class = SecureForm column_searchable_list = ["id", "user.email", "email", "mailbox.email"] column_filters = ["id", "user.email", "email", "mailbox.email"] + column_formatters = { + "created_at": _admin_date_formatter, + "updated_at": _admin_date_formatter, + } + @action( "disable_email_spoofing_check", "Disable email spoofing protection", @@ -385,6 +408,11 @@ class MailboxAdmin(SLModelView): column_searchable_list = ["id", "user.email", "email"] column_filters = ["id", "user.email", "email"] + column_formatters = { + "created_at": _admin_date_formatter, + "updated_at": _admin_date_formatter, + } + # class LifetimeCouponAdmin(SLModelView): # can_edit = True @@ -396,12 +424,22 @@ class CouponAdmin(SLModelView): can_edit = False can_create = True + column_formatters = { + "created_at": _admin_date_formatter, + "updated_at": _admin_date_formatter, + } + class ManualSubscriptionAdmin(SLModelView): form_base_class = SecureForm can_edit = True column_searchable_list = ["id", "user.email"] + column_formatters = { + "created_at": _admin_date_formatter, + "updated_at": _admin_date_formatter, + } + @action( "extend_1y", "Extend for 1 year", @@ -445,12 +483,22 @@ class CustomDomainAdmin(SLModelView): column_exclude_list = ["ownership_txt_token"] can_edit = False + column_formatters = { + "created_at": _admin_date_formatter, + "updated_at": _admin_date_formatter, + } + class ReferralAdmin(SLModelView): form_base_class = SecureForm column_searchable_list = ["id", "user.email", "code", "name"] column_filters = ["id", "user.email", "code", "name"] + column_formatters = { + "created_at": _admin_date_formatter, + "updated_at": _admin_date_formatter, + } + def scaffold_list_columns(self): ret = super().scaffold_list_columns() ret.insert(0, "nb_user") @@ -466,15 +514,6 @@ class ReferralAdmin(SLModelView): # can_delete = True -def _admin_action_formatter(view, context, model, name): - action_name = AuditLogActionEnum.get_name(model.action) - return "{} ({})".format(action_name, model.action) - - -def _admin_created_at_formatter(view, context, model, name): - return model.created_at.format() - - class AdminAuditLogAdmin(SLModelView): form_base_class = SecureForm column_searchable_list = ["admin.id", "admin.email", "model_id", "created_at"] @@ -487,7 +526,8 @@ class AdminAuditLogAdmin(SLModelView): column_formatters = { "action": _admin_action_formatter, - "created_at": _admin_created_at_formatter, + "created_at": _admin_date_formatter, + "updated_at": _admin_date_formatter, } @@ -516,8 +556,8 @@ class ProviderComplaintAdmin(SLModelView): can_delete = False column_formatters = { - "created_at": _admin_created_at_formatter, - "updated_at": _admin_created_at_formatter, + "created_at": _admin_date_formatter, + "updated_at": _admin_date_formatter, "state": _transactionalcomplaint_state_formatter, "phase": _transactionalcomplaint_phase_formatter, "refused_email": _transactionalcomplaint_refused_email_id_formatter, @@ -687,3 +727,38 @@ class InvalidMailboxDomainAdmin(SLModelView): form_base_class = SecureForm can_create = True can_delete = True + + +class EmailSearchAdmin(BaseView): + def is_accessible(self): + return current_user.is_authenticated and current_user.is_admin + + def inaccessible_callback(self, name, **kwargs): + # redirect to login page if user doesn't have access + flash("You don't have access to the admin page", "error") + return redirect(url_for("dashboard.index", next=request.url)) + + @expose("/", methods=["GET", "POST"]) + def index(self): + alias = None + user = None + mailbox = None + no_match = False + email = None + if request.form and request.form["email"]: + email = request.form["email"] + alias = Alias.get_by(email=email) + user = User.get_by(email=email) + mailbox = Mailbox.get_by(email=email) + if not alias and not user and not mailbox: + no_match = True + + return self.render( + "admin/alias_search.html", + email=email, + no_match=no_match, + alias=alias, + mailbox=mailbox, + user=user, + user_aliases=lambda user_id: Alias.filter_by(user_id=user_id).all(), + ) diff --git a/app/app/api/views/mailbox.py b/app/app/api/views/mailbox.py index 1c2c0f9..10bba2c 100644 --- a/app/app/api/views/mailbox.py +++ b/app/app/api/views/mailbox.py @@ -1,22 +1,18 @@ from smtplib import SMTPRecipientsRefused -import arrow from flask import g from flask import jsonify from flask import request +from app import mailbox_utils 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, ) -from app.email_validation import is_valid_email -from app.log import LOG -from app.models import Mailbox, Job +from app.models import Mailbox from app.utils import sanitize_email @@ -44,31 +40,15 @@ def create_mailbox(): user = g.user mailbox_email = sanitize_email(request.get_json().get("email")) - if not user.is_premium(): - return jsonify(error="Only premium plan can add additional mailbox"), 400 + try: + new_mailbox = mailbox_utils.create_mailbox(user, mailbox_email).mailbox + except mailbox_utils.MailboxError as e: + return jsonify(error=e.msg), 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): - 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)), - 201, - ) + return ( + jsonify(mailbox_to_dict(new_mailbox)), + 201, + ) @api_bp.route("/mailboxes/", methods=["DELETE"]) @@ -86,47 +66,17 @@ def delete_mailbox(mailbox_id): """ user = g.user - mailbox = Mailbox.get(mailbox_id) - - if not mailbox or mailbox.user_id != user.id: - return jsonify(error="Forbidden"), 403 - - if mailbox.id == user.default_mailbox_id: - return jsonify(error="You cannot delete the default mailbox"), 400 - data = request.get_json() or {} transfer_mailbox_id = data.get("transfer_aliases_to") if transfer_mailbox_id and int(transfer_mailbox_id) >= 0: - transfer_mailbox = Mailbox.get(transfer_mailbox_id) + transfer_mailbox_id = int(transfer_mailbox_id) + else: + transfer_mailbox_id = None - if not transfer_mailbox or transfer_mailbox.user_id != user.id: - return ( - jsonify(error="You must transfer the aliases to a mailbox you own."), - 403, - ) - - if transfer_mailbox_id == mailbox_id: - return ( - jsonify( - error="You can not transfer the aliases to the mailbox you want to delete." - ), - 400, - ) - - if not transfer_mailbox.verified: - return jsonify(error="Your new mailbox is not verified"), 400 - - # Schedule delete account job - LOG.w("schedule delete mailbox job for %s", mailbox) - Job.create( - name=JOB_DELETE_MAILBOX, - payload={ - "mailbox_id": mailbox.id, - "transfer_mailbox_id": transfer_mailbox_id, - }, - run_at=arrow.now(), - commit=True, - ) + try: + mailbox_utils.delete_mailbox(user, mailbox_id, transfer_mailbox_id) + except mailbox_utils.MailboxError as e: + return jsonify(error=e.msg), 400 return jsonify(deleted=True), 200 diff --git a/app/app/dashboard/views/mailbox.py b/app/app/dashboard/views/mailbox.py index ee113c2..0746387 100644 --- a/app/app/dashboard/views/mailbox.py +++ b/app/app/dashboard/views/mailbox.py @@ -2,7 +2,6 @@ import base64 import binascii import json -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 @@ -10,19 +9,12 @@ from itsdangerous import TimestampSigner 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 import parallel_limiter, mailbox_utils, user_settings +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, -) -from app.email_validation import is_valid_email from app.log import LOG -from app.models import Mailbox, Job +from app.models import Mailbox from app.utils import CSRFValidationForm @@ -58,120 +50,61 @@ 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 = mailbox_utils.delete_mailbox( + current_user, + delete_mailbox_form.mailbox_id.data, + delete_mailbox_form.transfer_mailbox_id.data, + ) + except mailbox_utils.MailboxError as e: + flash(e.msg, "warning") 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", "success", ) - return redirect(url_for("dashboard.mailbox_route")) + if request.form.get("form-name") == "set-default": if not csrf_form.validate(): flash("Invalid request", "warning") return redirect(request.url) - 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") + try: + mailbox_id = request.form.get("mailbox_id") + mailbox = user_settings.set_default_mailbox(current_user, mailbox_id) + except user_settings.CannotSetMailbox as e: + flash(e.msg, "warning") return redirect(url_for("dashboard.mailbox_route")) - if mailbox.id == current_user.default_mailbox_id: - flash("This mailbox is already default one", "error") - return redirect(url_for("dashboard.mailbox_route")) - - if not mailbox.verified: - flash("Cannot set unverified mailbox as default", "error") - return redirect(url_for("dashboard.mailbox_route")) - - current_user.default_mailbox_id = mailbox.id - Session.commit() flash(f"Mailbox {mailbox.email} is set as Default Mailbox", "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") + if not new_mailbox_form.validate(): + flash("Invalid request", "warning") + return redirect(request.url) + mailbox_email = new_mailbox_form.email.data.lower().strip().replace(" ", "") + try: + mailbox = mailbox_utils.create_mailbox( + current_user, mailbox_email + ).mailbox + except mailbox_utils.MailboxError as e: + flash(e.msg, "warning") return redirect(url_for("dashboard.mailbox_route")) - if new_mailbox_form.validate(): - mailbox_email = ( - new_mailbox_form.email.data.lower().strip().replace(" ", "") + flash( + f"You are going to receive an email to confirm {mailbox.email}.", + "success", + ) + + return redirect( + url_for( + "dashboard.mailbox_detail_route", + mailbox_id=mailbox.id, ) - - 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, - ) - ) + ) return render_template( "dashboard/mailbox.html", @@ -182,34 +115,20 @@ def mailbox_route(): ) -def send_verification_email(user, mailbox): - s = TimestampSigner(MAILBOX_SECRET) - encoded_data = json.dumps([mailbox.id, mailbox.email]).encode("utf-8") - b64_data = base64.urlsafe_b64encode(encoded_data) - mailbox_id_signed = s.sign(b64_data).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") +@login_required def mailbox_verify(): + mailbox_id = request.args.get("mailbox_id") + code = request.args.get("code") + if not code: + # Old way + return verify_with_signed_secret(mailbox_id) + mailbox = mailbox_utils.verify_mailbox_code(current_user, mailbox_id, code) + LOG.d("Mailbox %s is verified", mailbox) + return render_template("dashboard/mailbox_validation.html", mailbox=mailbox) + + +def verify_with_signed_secret(request: str): s = TimestampSigner(MAILBOX_SECRET) mailbox_verify_request = request.args.get("mailbox_id") try: diff --git a/app/app/mailbox_utils.py b/app/app/mailbox_utils.py new file mode 100644 index 0000000..39f364c --- /dev/null +++ b/app/app/mailbox_utils.py @@ -0,0 +1,260 @@ +import dataclasses +import secrets +import random +from typing import Optional +import arrow + +from app import config +from app.config import JOB_DELETE_MAILBOX +from app.db import Session +from app.email_utils import ( + mailbox_already_used, + email_can_be_used_as_mailbox, + send_email, + render, +) +from app.email_validation import is_valid_email +from app.log import LOG +from app.models import User, Mailbox, Job, MailboxActivation + + +@dataclasses.dataclass +class CreateMailboxOutput: + mailbox: Mailbox + activation: Optional[MailboxActivation] + + +class MailboxError(Exception): + def __init__(self, msg: str): + self.msg = msg + + +class OnlyPaidError(MailboxError): + def __init__(self): + self.msg = "Only available for paid plans" + + +class CannotVerifyError(MailboxError): + def __init__(self, msg: str): + self.msg = msg + + +MAX_ACTIVATION_TRIES = 3 + + +def create_mailbox( + user: User, + email: str, + verified: bool = False, + send_email: bool = True, + use_digit_codes: bool = False, + send_link: bool = True, +) -> CreateMailboxOutput: + if not user.is_premium(): + LOG.i( + f"User {user} has tried to create mailbox with {email} but is not premium" + ) + raise OnlyPaidError() + if not is_valid_email(email): + LOG.i( + f"User {user} has tried to create mailbox with {email} but is not valid email" + ) + raise MailboxError("Invalid email") + elif mailbox_already_used(email, user): + LOG.i( + f"User {user} has tried to create mailbox with {email} but email is already used" + ) + raise MailboxError("Email already used") + elif not email_can_be_used_as_mailbox(email): + LOG.i( + f"User {user} has tried to create mailbox with {email} but email is invalid" + ) + raise MailboxError("Invalid email") + new_mailbox = Mailbox.create( + email=email, user_id=user.id, verified=verified, commit=True + ) + + if verified: + LOG.i(f"User {user} as created a pre-verified mailbox with {email}") + return CreateMailboxOutput(mailbox=new_mailbox, activation=None) + + LOG.i(f"User {user} has created mailbox with {email}") + activation = generate_activation_code(new_mailbox, use_digit_code=use_digit_codes) + output = CreateMailboxOutput(mailbox=new_mailbox, activation=activation) + + if not send_email: + LOG.i(f"Skipping sending validation email for mailbox {new_mailbox}") + return output + + send_verification_email( + user, + new_mailbox, + activation=activation, + send_link=send_link, + ) + return output + + +def delete_mailbox( + user: User, mailbox_id: int, transfer_mailbox_id: Optional[int] +) -> Mailbox: + mailbox = Mailbox.get(mailbox_id) + + if not mailbox or mailbox.user_id != user.id: + LOG.i( + f"User {user} has tried to delete another user's mailbox with {mailbox_id}" + ) + raise MailboxError("Invalid mailbox") + + if mailbox.id == user.default_mailbox_id: + LOG.i(f"User {user} has tried to delete the default mailbox") + raise MailboxError("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: + LOG.i( + f"User {user} has tried to transfer to a mailbox owned by another user" + ) + raise MailboxError("You must transfer the aliases to a mailbox you own") + + if transfer_mailbox.id == mailbox.id: + LOG.i( + f"User {user} has tried to transfer to the same mailbox he is deleting" + ) + raise MailboxError( + "You can not transfer the aliases to the mailbox you want to delete" + ) + + if not transfer_mailbox.verified: + LOG.i(f"User {user} has tried to transfer to a non verified mailbox") + MailboxError("Your new mailbox is not verified") + + # Schedule delete account job + LOG.i( + f"User {user} has scheduled 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 and transfer_mailbox_id > 0 + else None, + }, + run_at=arrow.now(), + commit=True, + ) + return mailbox + + +def clear_activation_codes_for_mailbox(mailbox: Mailbox): + Session.query(MailboxActivation).filter( + MailboxActivation.mailbox_id == mailbox.id + ).delete() + Session.commit() + + +def verify_mailbox_code(user: User, mailbox_id: int, code: str) -> Mailbox: + mailbox = Mailbox.get(mailbox_id) + if not mailbox: + LOG.i( + f"User {user} failed to verify mailbox {mailbox_id} because it does not exist" + ) + raise MailboxError("Invalid mailbox") + if mailbox.verified: + LOG.i( + f"User {user} failed to verify mailbox {mailbox_id} because it's already verified" + ) + clear_activation_codes_for_mailbox(mailbox) + return mailbox + if mailbox.user_id != user.id: + LOG.i( + f"User {user} failed to verify mailbox {mailbox_id} because it's owned by another user" + ) + raise MailboxError("Invalid mailbox") + + activation = ( + MailboxActivation.filter(MailboxActivation.mailbox_id == mailbox_id) + .order_by(MailboxActivation.created_at.desc()) + .first() + ) + if not activation: + LOG.i( + f"User {user} failed to verify mailbox {mailbox_id} because there is no activation" + ) + raise MailboxError("Invalid code") + if activation.tries >= MAX_ACTIVATION_TRIES: + LOG.i(f"User {user} failed to verify mailbox {mailbox_id} more than 3 times") + clear_activation_codes_for_mailbox(mailbox) + raise CannotVerifyError("Invalid activation code. Please request another code.") + if activation.created_at < arrow.now().shift(minutes=-15): + LOG.i( + f"User {user} failed to verify mailbox {mailbox_id} because code is too old" + ) + clear_activation_codes_for_mailbox(mailbox) + raise CannotVerifyError("Invalid activation code. Please request another code.") + if code != activation.code: + LOG.i( + f"User {user} failed to verify mailbox {mailbox_id} because code does not match" + ) + activation.tries = activation.tries + 1 + Session.commit() + raise CannotVerifyError("Invalid activation code") + LOG.i(f"User {user} has verified mailbox {mailbox_id}") + mailbox.verified = True + clear_activation_codes_for_mailbox(mailbox) + return mailbox + + +def generate_activation_code( + mailbox: Mailbox, use_digit_code: bool = False +) -> MailboxActivation: + clear_activation_codes_for_mailbox(mailbox) + if use_digit_code: + code = "{:06d}".format(random.randint(1, 999999)) + else: + code = secrets.token_urlsafe(16) + return MailboxActivation.create( + mailbox_id=mailbox.id, + code=code, + tries=0, + commit=True, + ) + + +def send_verification_email( + user: User, mailbox: Mailbox, activation: MailboxActivation, send_link: bool = True +): + LOG.i( + f"Sending mailbox verification email to {mailbox.email} with send link={send_link}" + ) + + if send_link: + verification_url = ( + config.URL + + "/dashboard/mailbox_verify" + + f"?mailbox_id={mailbox.id}&code={activation.code}" + ) + else: + verification_url = None + + send_email( + mailbox.email, + f"Please confirm your mailbox {mailbox.email}", + render( + "transactional/verify-mailbox.txt.jinja2", + user=user, + code=activation.code, + link=verification_url, + mailbox_email=mailbox.email, + ), + render( + "transactional/verify-mailbox.html", + user=user, + code=activation.code, + link=verification_url, + mailbox_email=mailbox.email, + ), + ) diff --git a/app/app/models.py b/app/app/models.py index e5297aa..96794d8 100644 --- a/app/app/models.py +++ b/app/app/models.py @@ -2804,6 +2804,16 @@ class Mailbox(Base, ModelMixin): return f"" +class MailboxActivation(Base, ModelMixin): + __tablename__ = "mailbox_activation" + + mailbox_id = sa.Column( + sa.ForeignKey(Mailbox.id, ondelete="cascade"), nullable=False, index=True + ) + code = sa.Column(sa.String(32), nullable=False, index=True) + tries = sa.Column(sa.Integer, default=0, nullable=False) + + class AccountActivation(Base, ModelMixin): """contains code to activate the user account when they sign up on mobile""" diff --git a/app/app/user_settings.py b/app/app/user_settings.py index f9bbb96..cef224d 100644 --- a/app/app/user_settings.py +++ b/app/app/user_settings.py @@ -2,7 +2,7 @@ from typing import Optional from app.db import Session from app.log import LOG -from app.models import User, SLDomain, CustomDomain +from app.models import User, SLDomain, CustomDomain, Mailbox class CannotSetAlias(Exception): @@ -10,6 +10,11 @@ class CannotSetAlias(Exception): self.msg = msg +class CannotSetMailbox(Exception): + def __init__(self, msg: str): + self.msg = msg + + def set_default_alias_domain(user: User, domain_name: Optional[str]): if domain_name is None: LOG.i(f"User {user} has set no domain as default domain") @@ -45,3 +50,21 @@ def set_default_alias_domain(user: User, domain_name: Optional[str]): user.default_alias_public_domain_id = None user.default_alias_custom_domain_id = custom_domain.id Session.flush() + + +def set_default_mailbox(user: User, mailbox_id: int) -> Mailbox: + mailbox = Mailbox.get(mailbox_id) + + if not mailbox or mailbox.user_id != user.id: + raise CannotSetMailbox("Invalid mailbox") + + if not mailbox.verified: + raise CannotSetMailbox("This is mailbox is not verified") + + if mailbox.id == user.default_mailbox_id: + return mailbox + LOG.i(f"User {user} has set mailbox {mailbox} as his default one") + + user.default_mailbox_id = mailbox.id + Session.commit() + return mailbox diff --git a/app/local_data/words.txt b/app/local_data/words.txt index a3f5fdb..643b5af 100644 --- a/app/local_data/words.txt +++ b/app/local_data/words.txt @@ -745,8 +745,6 @@ bullish bullpen bullring bullseye -bullwhip -bully bunch bundle bungee @@ -1149,7 +1147,6 @@ coherence coherent cohesive coil -coke cola cold coleslaw @@ -1674,8 +1671,6 @@ delta deluge delusion deluxe -demanding -demeaning demeanor demise democracy @@ -1897,9 +1892,6 @@ divisible divisibly division divisive -divorcee -dizziness -dizzy doable docile dock @@ -1913,7 +1905,6 @@ dole dollar dollhouse dollop -dolly dolphin domain domelike @@ -2027,7 +2018,6 @@ duh duke dumping dumpling -dumpster duo dupe duplex @@ -2036,14 +2026,12 @@ duplicity durable durably duration -duress during dusk dust dutiful duty duvet -dwarf dweeb dwelled dweller @@ -3782,10 +3770,6 @@ makeshift making malformed malt -mama -mammal -mammary -mammogram manager managing manatee @@ -3798,7 +3782,6 @@ mangle mango mangy manhandle -manhole manhood manhunt manicotti @@ -3813,7 +3796,6 @@ manmade manned mannish manor -manpower mantis mantra manual @@ -3850,7 +3832,6 @@ mashed mashing massager masses -massive mastiff matador matchbook @@ -3863,15 +3844,11 @@ maternal maternity math mating -matriarch matrimony matrix matron matted matter -maturely -maturing -maturity mauve maverick maximize @@ -3891,9 +3868,6 @@ modify modular modulator module -moisten -moistness -moisture molar molasses mold @@ -3946,11 +3920,7 @@ morality morally morbidity morbidly -morphine -morphing morse -mortality -mortally mortician mortified mortify @@ -3976,7 +3946,6 @@ mournful mouse mousiness moustache -mousy mouth movable move @@ -3985,7 +3954,6 @@ moving mower mowing much -muck mud mug mulberry @@ -4002,7 +3970,6 @@ mumbling mumbo mummified mummify -mummy mumps munchkin mundane @@ -4798,7 +4765,6 @@ princess print prior prism -prison prissy pristine privacy @@ -4822,8 +4788,6 @@ prodigal prodigy produce product -profane -profanity professed professor profile @@ -5992,10 +5956,6 @@ slit sliver slobbery slogan -sloped -sloping -sloppily -sloppy slot slouching slouchy @@ -6011,7 +5971,6 @@ smartness smasher smashing smashup -smell smelting smile smilingly @@ -6021,11 +5980,6 @@ smith smitten smock smog -smoked -smokeless -smokiness -smoking -smoky smolder smooth smother @@ -6047,7 +6001,6 @@ sneer sneeze sneezing snide -sniff snippet snipping snitch @@ -6203,7 +6156,6 @@ squiggle squiggly squint squire -squirt squishier squishy stability @@ -6323,7 +6275,6 @@ stoning stony stood stooge -stool stoop stoplight stoppable @@ -6458,12 +6409,9 @@ subwoofer subzero succulent such -suction sudden sudoku suds -sufferer -suffering suffice suffix suffocate @@ -6515,7 +6463,6 @@ surplus surprise surreal surrender -surrogate surround survey survival @@ -6528,7 +6475,6 @@ suspend suspense sustained sustainer -swab swaddling swagger swampland @@ -6536,7 +6482,6 @@ swan swapping swarm sway -swear sweat sweep swell @@ -6605,9 +6550,6 @@ talcum talisman tall talon -tamale -tameness -tamer tamper tank tanned @@ -6647,7 +6589,6 @@ thaw theater theatrics thee -theft theme theology theorize @@ -6752,7 +6693,6 @@ trade trading tradition traffic -tragedy trailing trailside train @@ -6772,7 +6712,6 @@ trapped trapper trapping traps -trash travel traverse travesty diff --git a/app/migrations/versions/2024_073011_1c14339aae90_.py b/app/migrations/versions/2024_073011_1c14339aae90_.py new file mode 100644 index 0000000..b848842 --- /dev/null +++ b/app/migrations/versions/2024_073011_1c14339aae90_.py @@ -0,0 +1,42 @@ +"""empty message + +Revision ID: 1c14339aae90 +Revises: 56d08955fcab +Create Date: 2024-07-30 11:46:32.460221 + +""" +import sqlalchemy_utils +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '1c14339aae90' +down_revision = '56d08955fcab' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('mailbox_activation', + 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('mailbox_id', sa.Integer(), nullable=False), + sa.Column('code', sa.String(length=32), nullable=False), + sa.Column('tries', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['mailbox_id'], ['mailbox.id'], ondelete='cascade'), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_mailbox_activation_code'), 'mailbox_activation', ['code'], unique=False) + op.create_index(op.f('ix_mailbox_activation_mailbox_id'), 'mailbox_activation', ['mailbox_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_mailbox_activation_mailbox_id'), table_name='mailbox_activation') + op.drop_index(op.f('ix_mailbox_activation_code'), table_name='mailbox_activation') + op.drop_table('mailbox_activation') + # ### end Alembic commands ### diff --git a/app/server.py b/app/server.py index ea74d1e..ce57e95 100644 --- a/app/server.py +++ b/app/server.py @@ -45,6 +45,7 @@ from app.admin_model import ( DailyMetricAdmin, MetricAdmin, InvalidMailboxDomainAdmin, + EmailSearchAdmin, ) from app.api.base import api_bp from app.auth.base import auth_bp @@ -200,7 +201,7 @@ def create_app() -> Flask: "username": "admin", "password": FLASK_PROFILER_PASSWORD, }, - "ignore": ["^/static/.*", "/git", "/exception"], + "ignore": ["^/static/.*", "/git", "/exception", "/health"], } flask_profiler.init_app(app) @@ -218,6 +219,10 @@ def create_app() -> Flask: def cleanup(resp_or_exc): Session.remove() + @app.route("/health", methods=["GET"]) + def healthcheck(): + return "success", 200 + return app @@ -282,6 +287,7 @@ def set_index_page(app): and not request.path.startswith("/_debug_toolbar") and not request.path.startswith("/git") and not request.path.startswith("/favicon.ico") + and not request.path.startswith("/health") ): start_time = g.start_time or time.time() LOG.d( @@ -781,6 +787,7 @@ def init_admin(app): admin.add_view(UserAdmin(User, Session)) admin.add_view(AliasAdmin(Alias, Session)) admin.add_view(MailboxAdmin(Mailbox, Session)) + admin.add_view(EmailSearchAdmin(name="Email Search", endpoint="email_search")) admin.add_view(CouponAdmin(Coupon, Session)) admin.add_view(ManualSubscriptionAdmin(ManualSubscription, Session)) admin.add_view(CustomDomainAdmin(CustomDomain, Session)) diff --git a/app/templates/admin/alias_search.html b/app/templates/admin/alias_search.html new file mode 100644 index 0000000..159d733 --- /dev/null +++ b/app/templates/admin/alias_search.html @@ -0,0 +1,300 @@ +{% extends 'admin/master.html' %} + +{% block body %} + +
+
+
+ + +
+ +
+
+ {% if no_match %} + + + {% endif %} + {% if alias %} + +
+

Alias {{ alias.email }} found

+
+
+ Alias id +
+
+ {{ alias.id }} +
+
+ Email +
+
+ {{ alias.email }} +
+
+ Created at +
+
+ {{ alias.created_at }} +
+
+ User +
+
+
+
+ User id +
+
+ {{ alias.user.id }} +
+
+ Email +
+
+ {{ alias.user.email }} +
+
+ Premium +
+
+ {{ alias.user.is_premium() }} +
+
+ Disabled +
+
+ {{ alias.user.disabled }} +
+
+ Crated At +
+
+ {{ alias.user.created_at }} +
+
+ Mailboxes +
+
+ {% for mailbox in alias.mailboxes %} + +
+
+ Mailbox id +
+
+ {{ mailbox.id }} +
+
+ Email +
+
+ {{ mailbox.email }} +
+
+ Verified +
+
+ {{ mailbox.verified }} +
+
+ Created At +
+
+ {{ mailbox.created_at }} +
+
+ {% endfor %} +
+
+
+
+
+ {% endif %} + {% if user %} + +
+

User {{ user.email }} found

+
+
+ User id +
+
+ {{ user.id }} +
+
+ Email +
+
+ {{ user.email }} +
+ {% if user.is_paid() %} + +
+ Paid +
+
+ Yes +
+
+ Subscription +
+
+ {{ user.get_active_subscription() }} +
+ {% else %} +
+ Paid +
+
+ No +
+ {% endif %} +
+ Created at +
+
+ {{ user.created_at }} +
+
+ Mailboxes +
+
+ {% for mailbox in user.mailboxes() %} + +
+
+ Mailbox id +
+
+ {{ mailbox.id }} +
+
+ Email +
+
+ {{ mailbox.email }} +
+
+ Verified +
+
+ {{ mailbox.verified }} +
+
+ Created At +
+
+ {{ mailbox.created_at }} +
+
+ {% endfor %} +
+
+ Aliases +
+
+ {% for mailbox in user_aliases(user.id) %} + +
+
+ Mailbox id +
+
+ {{ mailbox.id }} +
+
+ Email +
+
+ {{ mailbox.email }} +
+
+ Verified +
+
+ {{ mailbox.verified }} +
+
+ Created At +
+
+ {{ mailbox.created_at }} +
+
+ {% endfor %} +
+
+
+ {% endif %} + {% if mailbox %} + +
+

Mailbox {{ mailbox.email }} found

+
+
+ Mailbox id +
+
+ {{ mailbox.id }} +
+
+ Email +
+
+ {{ mailbox.email }} +
+
+ Created at +
+
+ {{ mailbox.created_at }} +
+
+ User +
+
+
+
+ User id +
+
+ {{ mailbox.user.id }} +
+
+ Email +
+
+ {{ mailbox.user.email }} +
+
+ Premium +
+
+ {{ mailbox.user.is_premium() }} +
+
+ Disabled +
+
+ {{ mailbox.user.disabled }} +
+
+ Crated At +
+
+ {{ mailbox.user.created_at }} +
+
+
+
+
+ {% endif %} +{% endblock %} diff --git a/app/templates/emails/transactional/verify-mailbox.html b/app/templates/emails/transactional/verify-mailbox.html index b0dadd6..1c36aad 100644 --- a/app/templates/emails/transactional/verify-mailbox.html +++ b/app/templates/emails/transactional/verify-mailbox.html @@ -4,8 +4,13 @@ {{ 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) }} + {% if link %} + + {{ render_text("To confirm, please click on the button below.") }} + {{ render_button("Confirm mailbox", link) }} + {% else %} + {{ render_text("Please enter "+code+" as your verification code") }} + {% endif %} {{ render_text("This email will only be valid for the next 15 minutes.") }} {{ render_text('Thanks,
diff --git a/app/templates/emails/transactional/verify-mailbox.txt.jinja2 b/app/templates/emails/transactional/verify-mailbox.txt.jinja2 index 588edee..049e30a 100644 --- a/app/templates/emails/transactional/verify-mailbox.txt.jinja2 +++ b/app/templates/emails/transactional/verify-mailbox.txt.jinja2 @@ -5,9 +5,13 @@ Hi You have added {{mailbox_email}} as an additional mailbox. +{% if link %} To confirm, please click on this link: {{link}} +{% else %} +Please enter {{ code }} as your verification code for this mailbox +{% endif %} This link will only be valid during the next 15 minutes. {% endblock %} diff --git a/app/tests/api/test_mailbox.py b/app/tests/api/test_mailbox.py index 8a28d1f..085a42a 100644 --- a/app/tests/api/test_mailbox.py +++ b/app/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 email"} def test_create_mailbox_fail_for_free_user(flask_client): @@ -42,7 +42,7 @@ def test_create_mailbox_fail_for_free_user(flask_client): ) assert r.status_code == 400 - assert r.json == {"error": "Only premium plan can add additional mailbox"} + assert r.json == {"error": "Only available for paid plans"} def test_delete_mailbox(flask_client): diff --git a/app/tests/test_mailbox_utils.py b/app/tests/test_mailbox_utils.py new file mode 100644 index 0000000..bc3bf39 --- /dev/null +++ b/app/tests/test_mailbox_utils.py @@ -0,0 +1,304 @@ +from typing import Optional + +import arrow +import pytest + +from app import mailbox_utils, config +from app.db import Session +from app.mail_sender import mail_sender +from app.models import Mailbox, MailboxActivation, User, Job +from tests.utils import create_new_user, random_email + + +user: Optional[User] = None + + +def setup_module(): + global user + config.SKIP_MX_LOOKUP_ON_CHECK = True + user = create_new_user() + user.trial_end = None + user.lifetime = True + Session.commit() + + +def teardown_module(): + config.SKIP_MX_LOOKUP_ON_CHECK = False + + +def test_free_user_cannot_add_mailbox(): + user.lifetime = False + email = random_email() + try: + with pytest.raises(mailbox_utils.OnlyPaidError): + mailbox_utils.create_mailbox(user, email) + finally: + user.lifetime = True + + +def test_invalid_email(): + user.lifetime = True + with pytest.raises(mailbox_utils.MailboxError): + mailbox_utils.create_mailbox(user, "invalid") + + +def test_already_used(): + user.lifetime = True + with pytest.raises(mailbox_utils.MailboxError): + mailbox_utils.create_mailbox(user, user.email) + + +@mail_sender.store_emails_test_decorator +def test_create_mailbox(): + email = random_email() + mailbox_utils.create_mailbox(user, email) + mailbox = Mailbox.get_by(email=email) + assert mailbox is not None + assert not mailbox.verified + activation = MailboxActivation.get_by(mailbox_id=mailbox.id) + assert activation is not None + assert activation.tries == 0 + assert len(activation.code) > 6 + + assert 1 == len(mail_sender.get_stored_emails()) + mail_sent = mail_sender.get_stored_emails()[0] + mail_contents = str(mail_sent.msg) + assert mail_contents.find(config.URL) > 0 + assert mail_contents.find(activation.code) > 0 + assert mail_sent.envelope_to == email + + +@mail_sender.store_emails_test_decorator +def test_create_mailbox_verified(): + email = random_email() + output = mailbox_utils.create_mailbox(user, email, verified=True) + assert output.mailbox is not None + assert output.mailbox.verified + assert output.activation is None + mailbox = Mailbox.get_by(email=email) + assert mailbox is not None + assert mailbox.verified + activation = MailboxActivation.get_by(mailbox_id=mailbox.id) + assert activation is None + + assert 0 == len(mail_sender.get_stored_emails()) + + +@mail_sender.store_emails_test_decorator +def test_create_mailbox_with_digits(): + email = random_email() + output = mailbox_utils.create_mailbox( + user, email, use_digit_codes=True, send_link=False + ) + assert output.activation is not None + assert output.activation.tries == 0 + assert len(output.activation.code) == 6 + + mailbox = Mailbox.get_by(email=email) + assert mailbox is not None + assert not mailbox.verified + assert output.mailbox.id == mailbox.id + + activation = MailboxActivation.get_by(mailbox_id=mailbox.id) + assert activation is not None + assert output.activation.mailbox_id == activation.mailbox_id + + assert 1 == len(mail_sender.get_stored_emails()) + mail_sent = mail_sender.get_stored_emails()[0] + mail_contents = str(mail_sent.msg) + assert mail_contents.find(output.activation.code) > 0 + assert mail_contents.find(config.URL) == -1 + assert mail_sent.envelope_to == email + + +@mail_sender.store_emails_test_decorator +def test_create_mailbox_without_verification_email(): + email = random_email() + output = mailbox_utils.create_mailbox( + user, email, use_digit_codes=True, send_email=False + ) + mailbox = Mailbox.get_by(email=email) + assert mailbox is not None + assert not mailbox.verified + assert mailbox.id == output.mailbox.id + activation = MailboxActivation.get_by(mailbox_id=mailbox.id) + assert activation is not None + assert activation.tries == 0 + assert len(activation.code) == 6 + assert activation.code == output.activation.code + + assert 0 == len(mail_sender.get_stored_emails()) + + +@mail_sender.store_emails_test_decorator +def test_send_verification_email(): + email = random_email() + mailbox_utils.create_mailbox(user, email, use_digit_codes=True, send_link=False) + mailbox = Mailbox.get_by(email=email) + activation = MailboxActivation.get_by(mailbox_id=mailbox.id) + mail_sender.purge_stored_emails() + mailbox_utils.send_verification_email(user, mailbox, activation, send_link=False) + + assert 1 == len(mail_sender.get_stored_emails()) + mail_sent = mail_sender.get_stored_emails()[0] + mail_contents = str(mail_sent.msg) + assert mail_contents.find(activation.code) > 0 + assert mail_contents.find(config.URL) == -1 + assert mail_sent.envelope_to == email + + +@mail_sender.store_emails_test_decorator +def test_send_verification_email_with_link(): + email = random_email() + mailbox_utils.create_mailbox(user, email, use_digit_codes=True, send_link=False) + mailbox = Mailbox.get_by(email=email) + activation = MailboxActivation.get_by(mailbox_id=mailbox.id) + mail_sender.purge_stored_emails() + mailbox_utils.send_verification_email(user, mailbox, activation, send_link=True) + + assert 1 == len(mail_sender.get_stored_emails()) + mail_sent = mail_sender.get_stored_emails()[0] + mail_contents = str(mail_sent.msg) + assert mail_contents.find(activation.code) > 0 + assert mail_contents.find(config.URL) > -1 + assert mail_sent.envelope_to == email + + +def test_delete_other_user_mailbox(): + other = create_new_user() + mailbox = Mailbox.create(user_id=other.id, email=random_email(), commit=True) + with pytest.raises(mailbox_utils.MailboxError): + mailbox_utils.delete_mailbox(user, mailbox.id, transfer_mailbox_id=None) + + +def test_delete_default_mailbox(): + with pytest.raises(mailbox_utils.MailboxError): + mailbox_utils.delete_mailbox( + user, user.default_mailbox_id, transfer_mailbox_id=None + ) + + +def test_transfer_to_same_mailbox(): + email = random_email() + mailbox = mailbox_utils.create_mailbox( + user, email, use_digit_codes=True, send_link=False + ).mailbox + with pytest.raises(mailbox_utils.MailboxError): + mailbox_utils.delete_mailbox(user, mailbox.id, transfer_mailbox_id=mailbox.id) + + +def test_transfer_to_other_users_mailbox(): + email = random_email() + mailbox = mailbox_utils.create_mailbox( + user, email, use_digit_codes=True, send_link=False + ).mailbox + other = create_new_user() + other_mailbox = Mailbox.create(user_id=other.id, email=random_email(), commit=True) + with pytest.raises(mailbox_utils.MailboxError): + mailbox_utils.delete_mailbox( + user, mailbox.id, transfer_mailbox_id=other_mailbox.id + ) + + +def test_delete_with_no_transfer(): + email = random_email() + mailbox = mailbox_utils.create_mailbox( + user, email, use_digit_codes=True, send_link=False + ).mailbox + mailbox_utils.delete_mailbox(user, mailbox.id, transfer_mailbox_id=None) + job = Session.query(Job).order_by(Job.id.desc()).first() + assert job is not None + assert job.name == config.JOB_DELETE_MAILBOX + assert job.payload["mailbox_id"] == mailbox.id + assert job.payload["transfer_mailbox_id"] is None + + +def test_delete_with_transfer(): + mailbox = mailbox_utils.create_mailbox( + user, random_email(), use_digit_codes=True, send_link=False + ).mailbox + transfer_mailbox = mailbox_utils.create_mailbox( + user, random_email(), use_digit_codes=True, send_link=False + ).mailbox + mailbox_utils.delete_mailbox( + user, mailbox.id, transfer_mailbox_id=transfer_mailbox.id + ) + job = Session.query(Job).order_by(Job.id.desc()).first() + assert job is not None + assert job.name == config.JOB_DELETE_MAILBOX + assert job.payload["mailbox_id"] == mailbox.id + assert job.payload["transfer_mailbox_id"] == transfer_mailbox.id + mailbox_utils.delete_mailbox(user, mailbox.id, transfer_mailbox_id=None) + job = Session.query(Job).order_by(Job.id.desc()).first() + assert job is not None + assert job.name == config.JOB_DELETE_MAILBOX + assert job.payload["mailbox_id"] == mailbox.id + assert job.payload["transfer_mailbox_id"] is None + + +def test_verify_non_existing_mailbox(): + with pytest.raises(mailbox_utils.MailboxError): + mailbox_utils.verify_mailbox_code(user, 999999999, "9999999") + + +def test_verify_already_verified_mailbox(): + mailbox = Mailbox.create( + user_id=user.id, email=random_email(), verified=True, commit=True + ) + mbox = mailbox_utils.verify_mailbox_code(user, mailbox.id, "9999999") + assert mbox.id == mailbox.id + + +def test_verify_other_users_mailbox(): + other = create_new_user() + mailbox = Mailbox.create( + user_id=other.id, email=random_email(), verified=False, commit=True + ) + with pytest.raises(mailbox_utils.MailboxError): + mailbox_utils.verify_mailbox_code(user, mailbox.id, "9999999") + + +@mail_sender.store_emails_test_decorator +def test_verify_fail(): + output = mailbox_utils.create_mailbox(user, random_email()) + for i in range(mailbox_utils.MAX_ACTIVATION_TRIES - 1): + try: + mailbox_utils.verify_mailbox_code( + user, output.mailbox.id, output.activation.code + "nop" + ) + assert False, f"test {i}" + except mailbox_utils.CannotVerifyError: + activation = MailboxActivation.get_by(mailbox_id=output.mailbox.id) + assert activation.tries == i + 1 + + +@mail_sender.store_emails_test_decorator +def test_verify_too_may(): + output = mailbox_utils.create_mailbox(user, random_email()) + output.activation.tries = mailbox_utils.MAX_ACTIVATION_TRIES + Session.commit() + with pytest.raises(mailbox_utils.CannotVerifyError): + mailbox_utils.verify_mailbox_code( + user, output.mailbox.id, output.activation.code + ) + + +@mail_sender.store_emails_test_decorator +def test_verify_too_old_code(): + output = mailbox_utils.create_mailbox(user, random_email()) + output.activation.created_at = arrow.now().shift(minutes=-30) + Session.commit() + with pytest.raises(mailbox_utils.CannotVerifyError): + mailbox_utils.verify_mailbox_code( + user, output.mailbox.id, output.activation.code + ) + + +@mail_sender.store_emails_test_decorator +def test_verify_ok(): + output = mailbox_utils.create_mailbox(user, random_email()) + mailbox_utils.verify_mailbox_code(user, output.mailbox.id, output.activation.code) + activation = MailboxActivation.get_by(mailbox_id=output.mailbox.id) + assert activation is None + mailbox = Mailbox.get(id=output.mailbox.id) + assert mailbox.verified diff --git a/app/tests/user_settings/test_set_default_mailbox.py b/app/tests/user_settings/test_set_default_mailbox.py new file mode 100644 index 0000000..66e93a7 --- /dev/null +++ b/app/tests/user_settings/test_set_default_mailbox.py @@ -0,0 +1,61 @@ +from typing import Optional +import pytest + +from app import mailbox_utils, user_settings, config +from app.db import Session +from app.models import User +from tests.utils import random_email, create_new_user + + +user: Optional[User] = None + + +def setup_module(): + global user + config.SKIP_MX_LOOKUP_ON_CHECK = True + user = create_new_user() + user.trial_end = None + user.lifetime = True + Session.commit() + + +def teardown_module(): + config.SKIP_MX_LOOKUP_ON_CHECK = False # noqa: F821 + + +def test_set_default_mailbox(): + other = create_new_user() + output = mailbox_utils.create_mailbox( + other, + random_email(), + use_digit_codes=True, + send_link=False, + ) + output.mailbox.verified = True + Session.commit() + user_settings.set_default_mailbox(other, output.mailbox.id) + other = User.get(other.id) + assert other.default_mailbox_id == output.mailbox.id + + +def test_cannot_set_unverified(): + output = mailbox_utils.create_mailbox( + user, + random_email(), + use_digit_codes=True, + send_link=False, + ) + with pytest.raises(user_settings.CannotSetMailbox): + user_settings.set_default_mailbox(user, output.mailbox.id) + + +def test_cannot_default_other_user_mailbox(): + other = create_new_user() + mailbox = mailbox_utils.create_mailbox( + other, + random_email(), + use_digit_codes=True, + send_link=False, + ).mailbox + with pytest.raises(user_settings.CannotSetMailbox): + user_settings.set_default_mailbox(user, mailbox.id)