4.22.0
All checks were successful
continuous-integration/drone/tag Build is passing

This commit is contained in:
MrMeeb 2023-01-17 12:00:04 +00:00
parent 32465d1220
commit 8b4e4e3a2b
32 changed files with 367 additions and 157 deletions

1
app/.gitignore vendored
View File

@ -15,3 +15,4 @@ venv/
.coverage .coverage
htmlcov htmlcov
adhoc adhoc
.env.*

View File

@ -2,7 +2,7 @@
FROM node:10.17.0-alpine AS npm FROM node:10.17.0-alpine AS npm
WORKDIR /code WORKDIR /code
COPY ./static/package*.json /code/static/ COPY ./static/package*.json /code/static/
RUN cd /code/static && npm install RUN cd /code/static && npm ci
# Main image # Main image
FROM python:3.10 FROM python:3.10

View File

@ -78,12 +78,15 @@ def delete_mailbox(mailbox_id):
Delete mailbox Delete mailbox
Input: Input:
mailbox_id: in url mailbox_id: in url
(optional) transfer_aliases_to: in body. Id of the new mailbox for the aliases.
If omitted or the value is set to -1,
the aliases of the mailbox will be deleted too.
Output: Output:
200 if deleted successfully 200 if deleted successfully
""" """
user = g.user user = g.user
mailbox = Mailbox.get(mailbox_id) mailbox = Mailbox.get(id=mailbox_id)
if not mailbox or mailbox.user_id != user.id: if not mailbox or mailbox.user_id != user.id:
return jsonify(error="Forbidden"), 403 return jsonify(error="Forbidden"), 403
@ -91,11 +94,36 @@ def delete_mailbox(mailbox_id):
if mailbox.id == user.default_mailbox_id: if mailbox.id == user.default_mailbox_id:
return jsonify(error="You cannot delete the default mailbox"), 400 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)
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 # Schedule delete account job
LOG.w("schedule delete mailbox job for %s", mailbox) LOG.w("schedule delete mailbox job for %s", mailbox)
Job.create( Job.create(
name=JOB_DELETE_MAILBOX, name=JOB_DELETE_MAILBOX,
payload={"mailbox_id": mailbox.id}, payload={
"mailbox_id": mailbox.id,
"transfer_mailbox_id": transfer_mailbox_id,
},
run_at=arrow.now(), run_at=arrow.now(),
commit=True, commit=True,
) )

View File

@ -7,6 +7,7 @@ from app.dashboard.base import dashboard_bp
from app.dashboard.views.enter_sudo import sudo_required from app.dashboard.views.enter_sudo import sudo_required
from app.db import Session from app.db import Session
from app.models import ApiKey from app.models import ApiKey
from app.utils import CSRFValidationForm
class NewApiKeyForm(FlaskForm): class NewApiKeyForm(FlaskForm):
@ -23,9 +24,13 @@ def api_key():
.all() .all()
) )
csrf_form = CSRFValidationForm()
new_api_key_form = NewApiKeyForm() new_api_key_form = NewApiKeyForm()
if request.method == "POST": if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
if request.form.get("form-name") == "delete": if request.form.get("form-name") == "delete":
api_key_id = request.form.get("api-key-id") api_key_id = request.form.get("api-key-id")
@ -62,5 +67,8 @@ def api_key():
return redirect(url_for("dashboard.api_key")) return redirect(url_for("dashboard.api_key"))
return render_template( return render_template(
"dashboard/api_key.html", api_keys=api_keys, new_api_key_form=new_api_key_form "dashboard/api_key.html",
api_keys=api_keys,
new_api_key_form=new_api_key_form,
csrf_form=csrf_form,
) )

View File

@ -34,7 +34,7 @@ def batch_import_route():
if request.method == "POST": if request.method == "POST":
if not csrf_form.validate(): if not csrf_form.validate():
flash("Invalid request", "warning") flash("Invalid request", "warning")
redirect(request.url) return redirect(request.url)
if len(batch_imports) > 10: if len(batch_imports) > 10:
flash( flash(
"You have too many imports already. Wait until some get cleaned up", "You have too many imports already. Wait until some get cleaned up",

View File

@ -3,6 +3,7 @@ from flask_login import login_required, current_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, validators from wtforms import StringField, validators
from app import parallel_limiter
from app.config import EMAIL_SERVERS_WITH_PRIORITY from app.config import EMAIL_SERVERS_WITH_PRIORITY
from app.dashboard.base import dashboard_bp from app.dashboard.base import dashboard_bp
from app.db import Session from app.db import Session
@ -19,6 +20,7 @@ class NewCustomDomainForm(FlaskForm):
@dashboard_bp.route("/custom_domain", methods=["GET", "POST"]) @dashboard_bp.route("/custom_domain", methods=["GET", "POST"])
@login_required @login_required
@parallel_limiter.lock(only_when=lambda: request.method == "POST")
def custom_domain(): def custom_domain():
custom_domains = CustomDomain.filter_by( custom_domains = CustomDomain.filter_by(
user_id=current_user.id, is_sl_subdomain=False user_id=current_user.id, is_sl_subdomain=False

View File

@ -9,6 +9,7 @@ from wtforms import (
IntegerField, IntegerField,
) )
from app import parallel_limiter
from app.config import ( from app.config import (
EMAIL_DOMAIN, EMAIL_DOMAIN,
ALIAS_DOMAINS, ALIAS_DOMAINS,
@ -45,6 +46,7 @@ class DeleteDirForm(FlaskForm):
@dashboard_bp.route("/directory", methods=["GET", "POST"]) @dashboard_bp.route("/directory", methods=["GET", "POST"])
@login_required @login_required
@parallel_limiter.lock(only_when=lambda: request.method == "POST")
def directory(): def directory():
dirs = ( dirs = (
Directory.filter_by(user_id=current_user.id) Directory.filter_by(user_id=current_user.id)

View File

@ -2,10 +2,11 @@ import arrow
from flask import render_template, request, redirect, url_for, flash from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user from flask_login import login_required, current_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from itsdangerous import Signer from itsdangerous import TimestampSigner
from wtforms import validators from wtforms import validators, IntegerField
from wtforms.fields.html5 import EmailField 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, URL, JOB_DELETE_MAILBOX
from app.dashboard.base import dashboard_bp from app.dashboard.base import dashboard_bp
from app.db import Session from app.db import Session
@ -27,8 +28,16 @@ class NewMailboxForm(FlaskForm):
) )
class DeleteMailboxForm(FlaskForm):
mailbox_id = IntegerField(
validators=[validators.DataRequired()],
)
transfer_mailbox_id = IntegerField()
@dashboard_bp.route("/mailbox", methods=["GET", "POST"]) @dashboard_bp.route("/mailbox", methods=["GET", "POST"])
@login_required @login_required
@parallel_limiter.lock(only_when=lambda: request.method == "POST")
def mailbox_route(): def mailbox_route():
mailboxes = ( mailboxes = (
Mailbox.filter_by(user_id=current_user.id) Mailbox.filter_by(user_id=current_user.id)
@ -38,28 +47,53 @@ def mailbox_route():
new_mailbox_form = NewMailboxForm() new_mailbox_form = NewMailboxForm()
csrf_form = CSRFValidationForm() csrf_form = CSRFValidationForm()
delete_mailbox_form = DeleteMailboxForm()
if request.method == "POST": if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
if request.form.get("form-name") == "delete": if request.form.get("form-name") == "delete":
mailbox_id = request.form.get("mailbox-id") if not delete_mailbox_form.validate():
mailbox = Mailbox.get(mailbox_id) 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: if not mailbox or mailbox.user_id != current_user.id:
flash("Unknown error. Refresh the page", "warning") flash("Invalid mailbox. Refresh the page", "warning")
return redirect(url_for("dashboard.mailbox_route")) return redirect(url_for("dashboard.mailbox_route"))
if mailbox.id == current_user.default_mailbox_id: if mailbox.id == current_user.default_mailbox_id:
flash("You cannot delete default mailbox", "error") flash("You cannot delete default mailbox", "error")
return redirect(url_for("dashboard.mailbox_route")) 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.")
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."
)
return redirect(url_for("dashboard.mailbox_route"))
if not transfer_mailbox.verified:
flash("Your new mailbox is not verified")
return redirect(url_for("dashboard.mailbox_route"))
# Schedule delete account job # Schedule delete account job
LOG.w("schedule delete mailbox job for %s", mailbox) LOG.w(
f"schedule delete mailbox job for {mailbox.id} with transfer to mailbox {transfer_mailbox_id}"
)
Job.create( Job.create(
name=JOB_DELETE_MAILBOX, name=JOB_DELETE_MAILBOX,
payload={"mailbox_id": mailbox.id}, payload={
"mailbox_id": mailbox.id,
"transfer_mailbox_id": transfer_mailbox_id
if transfer_mailbox_id > 0
else None,
},
run_at=arrow.now(), run_at=arrow.now(),
commit=True, commit=True,
) )
@ -72,7 +106,10 @@ def mailbox_route():
return redirect(url_for("dashboard.mailbox_route")) return redirect(url_for("dashboard.mailbox_route"))
if request.form.get("form-name") == "set-default": if request.form.get("form-name") == "set-default":
mailbox_id = request.form.get("mailbox-id") 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) mailbox = Mailbox.get(mailbox_id)
if not mailbox or mailbox.user_id != current_user.id: if not mailbox or mailbox.user_id != current_user.id:
@ -110,12 +147,12 @@ def mailbox_route():
elif not email_can_be_used_as_mailbox(mailbox_email): elif not email_can_be_used_as_mailbox(mailbox_email):
flash(f"You cannot use {mailbox_email}.", "error") flash(f"You cannot use {mailbox_email}.", "error")
else: else:
new_mailbox = Mailbox.create( transfer_mailbox = Mailbox.create(
email=mailbox_email, user_id=current_user.id email=mailbox_email, user_id=current_user.id
) )
Session.commit() Session.commit()
send_verification_email(current_user, new_mailbox) send_verification_email(current_user, transfer_mailbox)
flash( flash(
f"You are going to receive an email to confirm {mailbox_email}.", f"You are going to receive an email to confirm {mailbox_email}.",
@ -124,7 +161,8 @@ def mailbox_route():
return redirect( return redirect(
url_for( url_for(
"dashboard.mailbox_detail_route", mailbox_id=new_mailbox.id "dashboard.mailbox_detail_route",
mailbox_id=transfer_mailbox.id,
) )
) )
@ -132,38 +170,13 @@ def mailbox_route():
"dashboard/mailbox.html", "dashboard/mailbox.html",
mailboxes=mailboxes, mailboxes=mailboxes,
new_mailbox_form=new_mailbox_form, new_mailbox_form=new_mailbox_form,
delete_mailbox_form=delete_mailbox_form,
csrf_form=csrf_form, csrf_form=csrf_form,
) )
def delete_mailbox(mailbox_id: int):
from server import create_light_app
with create_light_app().app_context():
mailbox = Mailbox.get(mailbox_id)
if not mailbox:
return
mailbox_email = mailbox.email
user = mailbox.user
Mailbox.delete(mailbox_id)
Session.commit()
LOG.d("Mailbox %s %s deleted", mailbox_id, mailbox_email)
send_email(
user.email,
f"Your mailbox {mailbox_email} has been deleted",
f"""Mailbox {mailbox_email} along with its aliases are deleted successfully.
Regards,
SimpleLogin team.
""",
)
def send_verification_email(user, mailbox): def send_verification_email(user, mailbox):
s = Signer(MAILBOX_SECRET) s = TimestampSigner(MAILBOX_SECRET)
mailbox_id_signed = s.sign(str(mailbox.id)).decode() mailbox_id_signed = s.sign(str(mailbox.id)).decode()
verification_url = ( verification_url = (
URL + "/dashboard/mailbox_verify" + f"?mailbox_id={mailbox_id_signed}" URL + "/dashboard/mailbox_verify" + f"?mailbox_id={mailbox_id_signed}"
@ -188,11 +201,11 @@ def send_verification_email(user, mailbox):
@dashboard_bp.route("/mailbox_verify") @dashboard_bp.route("/mailbox_verify")
def mailbox_verify(): def mailbox_verify():
s = Signer(MAILBOX_SECRET) s = TimestampSigner(MAILBOX_SECRET)
mailbox_id = request.args.get("mailbox_id") mailbox_id = request.args.get("mailbox_id")
try: try:
r_id = int(s.unsign(mailbox_id)) r_id = int(s.unsign(mailbox_id, max_age=900))
except Exception: except Exception:
flash("Invalid link. Please delete and re-add your mailbox", "error") flash("Invalid link. Please delete and re-add your mailbox", "error")
return redirect(url_for("dashboard.mailbox_route")) return redirect(url_for("dashboard.mailbox_route"))

View File

@ -4,7 +4,7 @@ from email_validator import validate_email, EmailNotValidError
from flask import render_template, request, redirect, url_for, flash from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user from flask_login import login_required, current_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from itsdangerous import Signer from itsdangerous import TimestampSigner
from wtforms import validators from wtforms import validators
from wtforms.fields.html5 import EmailField from wtforms.fields.html5 import EmailField
@ -210,7 +210,7 @@ def mailbox_detail_route(mailbox_id):
def verify_mailbox_change(user, mailbox, new_email): def verify_mailbox_change(user, mailbox, new_email):
s = Signer(MAILBOX_SECRET) s = TimestampSigner(MAILBOX_SECRET)
mailbox_id_signed = s.sign(str(mailbox.id)).decode() mailbox_id_signed = s.sign(str(mailbox.id)).decode()
verification_url = ( verification_url = (
f"{URL}/dashboard/mailbox/confirm_change?mailbox_id={mailbox_id_signed}" f"{URL}/dashboard/mailbox/confirm_change?mailbox_id={mailbox_id_signed}"
@ -262,11 +262,11 @@ def cancel_mailbox_change_route(mailbox_id):
@dashboard_bp.route("/mailbox/confirm_change") @dashboard_bp.route("/mailbox/confirm_change")
def mailbox_confirm_change_route(): def mailbox_confirm_change_route():
s = Signer(MAILBOX_SECRET) s = TimestampSigner(MAILBOX_SECRET)
signed_mailbox_id = request.args.get("mailbox_id") signed_mailbox_id = request.args.get("mailbox_id")
try: try:
mailbox_id = int(s.unsign(signed_mailbox_id)) mailbox_id = int(s.unsign(signed_mailbox_id, max_age=900))
except Exception: except Exception:
flash("Invalid link", "error") flash("Invalid link", "error")
return redirect(url_for("dashboard.index")) return redirect(url_for("dashboard.index"))

View File

@ -5,6 +5,7 @@ from app.dashboard.base import dashboard_bp
from app.dashboard.views.enter_sudo import sudo_required from app.dashboard.views.enter_sudo import sudo_required
from app.db import Session from app.db import Session
from app.models import RecoveryCode from app.models import RecoveryCode
from app.utils import CSRFValidationForm
@dashboard_bp.route("/mfa_cancel", methods=["GET", "POST"]) @dashboard_bp.route("/mfa_cancel", methods=["GET", "POST"])
@ -15,8 +16,13 @@ def mfa_cancel():
flash("you don't have MFA enabled", "warning") flash("you don't have MFA enabled", "warning")
return redirect(url_for("dashboard.index")) return redirect(url_for("dashboard.index"))
csrf_form = CSRFValidationForm()
# user cancels TOTP # user cancels TOTP
if request.method == "POST": if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
current_user.enable_otp = False current_user.enable_otp = False
current_user.otp_secret = None current_user.otp_secret = None
Session.commit() Session.commit()
@ -28,4 +34,4 @@ def mfa_cancel():
flash("TOTP is now disabled", "warning") flash("TOTP is now disabled", "warning")
return redirect(url_for("dashboard.index")) return redirect(url_for("dashboard.index"))
return render_template("dashboard/mfa_cancel.html") return render_template("dashboard/mfa_cancel.html", csrf_form=csrf_form)

View File

@ -2,7 +2,10 @@ import re
from flask import render_template, request, redirect, url_for, flash from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import StringField, validators
from app import parallel_limiter
from app.config import MAX_NB_SUBDOMAIN from app.config import MAX_NB_SUBDOMAIN
from app.dashboard.base import dashboard_bp from app.dashboard.base import dashboard_bp
from app.errors import SubdomainInTrashError from app.errors import SubdomainInTrashError
@ -13,8 +16,18 @@ from app.models import CustomDomain, Mailbox, SLDomain
_SUBDOMAIN_PATTERN = r"[0-9a-z-]{1,}" _SUBDOMAIN_PATTERN = r"[0-9a-z-]{1,}"
class NewSubdomainForm(FlaskForm):
domain = StringField(
"domain", validators=[validators.DataRequired(), validators.Length(max=64)]
)
subdomain = StringField(
"subdomain", validators=[validators.DataRequired(), validators.Length(max=64)]
)
@dashboard_bp.route("/subdomain", methods=["GET", "POST"]) @dashboard_bp.route("/subdomain", methods=["GET", "POST"])
@login_required @login_required
@parallel_limiter.lock(only_when=lambda: request.method == "POST")
def subdomain_route(): def subdomain_route():
if not current_user.subdomain_is_available(): if not current_user.subdomain_is_available():
flash("Unknown error, redirect to the home page", "error") flash("Unknown error, redirect to the home page", "error")
@ -26,9 +39,13 @@ def subdomain_route():
).all() ).all()
errors = {} errors = {}
new_subdomain_form = NewSubdomainForm()
if request.method == "POST": if request.method == "POST":
if request.form.get("form-name") == "create": if request.form.get("form-name") == "create":
if not new_subdomain_form.validate():
flash("Invalid new subdomain", "warning")
return redirect(url_for("dashboard.subdomain_route"))
if not current_user.is_premium(): if not current_user.is_premium():
flash("Only premium plan can add subdomain", "warning") flash("Only premium plan can add subdomain", "warning")
return redirect(request.url) return redirect(request.url)
@ -39,8 +56,8 @@ def subdomain_route():
) )
return redirect(request.url) return redirect(request.url)
subdomain = request.form.get("subdomain").lower().strip() subdomain = new_subdomain_form.subdomain.data.lower().strip()
domain = request.form.get("domain").lower().strip() domain = new_subdomain_form.domain.data.lower().strip()
if len(subdomain) < 3: if len(subdomain) < 3:
flash("Subdomain must have at least 3 characters", "error") flash("Subdomain must have at least 3 characters", "error")
@ -108,4 +125,5 @@ def subdomain_route():
sl_domains=sl_domains, sl_domains=sl_domains,
errors=errors, errors=errors,
subdomains=subdomains, subdomains=subdomains,
new_subdomain_form=new_subdomain_form,
) )

View File

@ -42,9 +42,11 @@ class UnsubscribeLink:
class UnsubscribeEncoder: class UnsubscribeEncoder:
@staticmethod @staticmethod
def encode( def encode(
action: UnsubscribeAction, data: Union[int, UnsubscribeOriginalData] action: UnsubscribeAction,
data: Union[int, UnsubscribeOriginalData],
force_web: bool = False,
) -> UnsubscribeLink: ) -> UnsubscribeLink:
if config.UNSUBSCRIBER: if config.UNSUBSCRIBER and not force_web:
return UnsubscribeLink(UnsubscribeEncoder.encode_mailto(action, data), True) return UnsubscribeLink(UnsubscribeEncoder.encode_mailto(action, data), True)
return UnsubscribeLink(UnsubscribeEncoder.encode_url(action, data), False) return UnsubscribeLink(UnsubscribeEncoder.encode_url(action, data), False)

View File

@ -49,7 +49,7 @@ class UnsubscribeHandler:
return status.E507 return status.E507
mailbox = Mailbox.get_by(email=envelope.mail_from) mailbox = Mailbox.get_by(email=envelope.mail_from)
if not mailbox: if not mailbox:
LOG.w("Unknown mailbox %s", msg[headers.SUBJECT]) LOG.w("Unknown mailbox %s", envelope.mail_from)
return status.E507 return status.E507
if unsub_data.action == UnsubscribeAction.DisableAlias: if unsub_data.action == UnsubscribeAction.DisableAlias:

View File

@ -27,13 +27,15 @@ def send_newsletter_to_user(newsletter, user) -> (bool, str):
comm_alias_id = comm_alias.id comm_alias_id = comm_alias.id
unsubscribe_oneclick = unsubscribe_link unsubscribe_oneclick = unsubscribe_link
if via_email: if via_email and comm_alias_id > -1:
unsubscribe_oneclick = UnsubscribeEncoder.encode( unsubscribe_oneclick = UnsubscribeEncoder.encode(
UnsubscribeAction.DisableAlias, comm_alias_id UnsubscribeAction.DisableAlias,
) comm_alias_id,
force_web=True,
).link
send_email( send_email(
comm_alias.email, comm_email,
newsletter.subject, newsletter.subject,
text_template.render( text_template.render(
user=user, user=user,

View File

@ -7,7 +7,7 @@ from app.session import RedisSessionStore
def initialize_redis_services(app: flask.Flask, redis_url: str): def initialize_redis_services(app: flask.Flask, redis_url: str):
if redis_url.startswith("redis://"): if redis_url.startswith("redis://") or redis_url.startswith("rediss://"):
storage = limits.storage.RedisStorage(redis_url) storage = limits.storage.RedisStorage(redis_url)
app.session_interface = RedisSessionStore(storage.storage, storage.storage, app) app.session_interface = RedisSessionStore(storage.storage, storage.storage, app)
set_redis_concurrent_lock(storage) set_redis_concurrent_lock(storage)

View File

@ -387,7 +387,7 @@ Input:
- `Authentication` header that contains the api key - `Authentication` header that contains the api key
- (Optional but recommended) `hostname` passed in query string - (Optional but recommended) `hostname` passed in query string
- (Optional) mode: either `uuid` or `word`. By default, use the user setting when creating new random alias. - (Optional) mode: either `uuid` or `word` passed in query string. By default, use the user setting when creating new random alias.
- Request Message Body in json (`Content-Type` is `application/json`) - Request Message Body in json (`Content-Type` is `application/json`)
- (Optional) note: alias note - (Optional) note: alias note
@ -764,6 +764,7 @@ Input:
- `Authentication` header that contains the api key - `Authentication` header that contains the api key
- `mailbox_id`: in url - `mailbox_id`: in url
- (optional) `transfer_aliases_to`: in body as json. id of the new mailbox for the aliases. If omitted or set to -1, the aliases will be delete with the mailbox.
Output: Output:

View File

@ -124,6 +124,58 @@ def welcome_proton(user):
) )
def delete_mailbox_job(job: Job):
mailbox_id = job.payload.get("mailbox_id")
mailbox = Mailbox.get(mailbox_id)
if not mailbox:
return
transfer_mailbox_id = job.payload.get("transfer_mailbox_id")
alias_transferred_to = None
if transfer_mailbox_id:
transfer_mailbox = Mailbox.get(transfer_mailbox_id)
if transfer_mailbox:
alias_transferred_to = transfer_mailbox.email
for alias in mailbox.aliases:
if alias.mailbox_id == mailbox.id:
alias.mailbox_id = transfer_mailbox.id
if transfer_mailbox in alias._mailboxes:
alias._mailboxes.remove(transfer_mailbox)
else:
alias._mailboxes.remove(mailbox)
if transfer_mailbox not in alias._mailboxes:
alias._mailboxes.append(transfer_mailbox)
Session.commit()
mailbox_email = mailbox.email
user = mailbox.user
Mailbox.delete(mailbox_id)
Session.commit()
LOG.d("Mailbox %s %s deleted", mailbox_id, mailbox_email)
if alias_transferred_to:
send_email(
user.email,
f"Your mailbox {mailbox_email} has been deleted",
f"""Mailbox {mailbox_email} and its alias have been transferred to {alias_transferred_to}.
Regards,
SimpleLogin team.
""",
retries=3,
)
else:
send_email(
user.email,
f"Your mailbox {mailbox_email} has been deleted",
f"""Mailbox {mailbox_email} along with its aliases have been deleted successfully.
Regards,
SimpleLogin team.
""",
retries=3,
)
def process_job(job: Job): def process_job(job: Job):
if job.name == config.JOB_ONBOARDING_1: if job.name == config.JOB_ONBOARDING_1:
user_id = job.payload.get("user_id") user_id = job.payload.get("user_id")
@ -178,27 +230,7 @@ def process_job(job: Job):
retries=3, retries=3,
) )
elif job.name == config.JOB_DELETE_MAILBOX: elif job.name == config.JOB_DELETE_MAILBOX:
mailbox_id = job.payload.get("mailbox_id") delete_mailbox_job(job)
mailbox = Mailbox.get(mailbox_id)
if not mailbox:
return
mailbox_email = mailbox.email
user = mailbox.user
Mailbox.delete(mailbox_id)
Session.commit()
LOG.d("Mailbox %s %s deleted", mailbox_id, mailbox_email)
send_email(
user.email,
f"Your mailbox {mailbox_email} has been deleted",
f"""Mailbox {mailbox_email} along with its aliases are deleted successfully.
Regards,
SimpleLogin team.
""",
retries=3,
)
elif job.name == config.JOB_DELETE_DOMAIN: elif job.name == config.JOB_DELETE_DOMAIN:
custom_domain_id = job.payload.get("custom_domain_id") custom_domain_id = job.payload.get("custom_domain_id")

View File

@ -3552,7 +3552,6 @@ impute
inaner inaner
inborn inborn
inbred inbred
incest
inched inched
inches inches
incing incing

View File

@ -1494,7 +1494,6 @@ youth
pressure pressure
submitted submitted
boston boston
incest
debt debt
keywords keywords
medium medium
@ -45883,7 +45882,6 @@ yue
piu piu
oligo oligo
chairpersons chairpersons
incesto
spca spca
zapper zapper
materialized materialized
@ -69506,7 +69504,6 @@ shaadi
lovehoney lovehoney
austrians austrians
annemarie annemarie
incesttaboo
fml fml
craves craves
teleportation teleportation
@ -77183,7 +77180,6 @@ schwul
objectivist objectivist
aftershocks aftershocks
ornette ornette
incestuous
antic antic
worland worland
abed abed
@ -78300,7 +78296,6 @@ acheive
pochette pochette
mutcd mutcd
kirschner kirschner
incestquest
tras tras
babor babor
shirin shirin
@ -82745,7 +82740,6 @@ snagging
viviun viviun
iniquities iniquities
oav oav
inceststories
incinerated incinerated
ornstein ornstein
matc matc
@ -84958,7 +84952,6 @@ repartee
pharmacie pharmacie
skus skus
lyttelton lyttelton
inceste
barska barska
comida comida
ciated ciated
@ -93685,7 +93678,6 @@ amundson
tinta tinta
withholds withholds
wfn wfn
incestcartoons
westpoint westpoint
cancelable cancelable
houseplant houseplant
@ -118454,7 +118446,6 @@ jtr
zeilenga zeilenga
arenaria arenaria
pazza pazza
incests
upmann upmann
jezabel jezabel
dowlnoad dowlnoad
@ -119737,7 +119728,6 @@ therrien
spysweeper spysweeper
psrc psrc
polgar polgar
incestgrrl
dunnville dunnville
speeders speeders
redraws redraws
@ -176740,7 +176730,6 @@ cebas
tenebril tenebril
rcsdiff rcsdiff
leclercq leclercq
incestual
gouse gouse
anga anga
peats peats
@ -279833,7 +279822,6 @@ freshies
ceravolo ceravolo
caespitosa caespitosa
streeet streeet
incestincest
huwag huwag
disordering disordering
burdur burdur
@ -296614,7 +296602,6 @@ outwits
oblog oblog
mulqueen mulqueen
menck menck
incestlinks
imputable imputable
guandong guandong
gorgan gorgan
@ -316826,7 +316813,6 @@ wartung
portinatx portinatx
orfeon orfeon
observar observar
incesticide
herro herro
didt didt
comosus comosus
@ -322556,7 +322542,6 @@ manorhaven
lounsbery lounsbery
linuxtracker linuxtracker
liberales liberales
incestos
haramayn haramayn
greyer greyer
goflo goflo

View File

@ -149803,11 +149803,6 @@ incessant
incessantly incessantly
incessantness incessantness
incession incession
incest
incests
incestuous
incestuously
incestuousness
incgrporate incgrporate
inch inch
inchain inchain
@ -204633,9 +204628,6 @@ nonincandescent
nonincandescently nonincandescently
nonincarnate nonincarnate
nonincarnated nonincarnated
nonincestuous
nonincestuously
nonincestuousness
nonincident nonincident
nonincidental nonincidental
nonincidentally nonincidentally
@ -344408,8 +344400,6 @@ unincarnated
unincensed unincensed
uninceptive uninceptive
uninceptively uninceptively
unincestuous
unincestuously
uninchoative uninchoative
unincidental unincidental
unincidentally unincidentally
@ -370100,4 +370090,4 @@ zwinglianism
zwinglianist zwinglianist
zwitter zwitter
zwitterion zwitterion
zwitterionic zwitterionic

View File

@ -43,6 +43,7 @@
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<form method="post"> <form method="post">
{{ csrf_form.csrf_token }}
<input type="hidden" name="form-name" value="delete"> <input type="hidden" name="form-name" value="delete">
<input type="hidden" name="api-key-id" value="{{ api_key.id }}"> <input type="hidden" name="api-key-id" value="{{ api_key.id }}">
<span class="card-link btn btn-link float-right text-danger delete-api-key">Delete</span> <span class="card-link btn btn-link float-right text-danger delete-api-key">Delete</span>
@ -57,6 +58,7 @@
{% if api_keys|length > 0 %} {% if api_keys|length > 0 %}
<form method="post"> <form method="post">
{{ csrf_form.csrf_token }}
<input type="hidden" name="form-name" value="delete-all"> <input type="hidden" name="form-name" value="delete-all">
<span class="delete btn btn-outline-danger delete-all-api-keys float-right"> <span class="delete btn btn-outline-danger delete-all-api-keys float-right">
Delete All &nbsp; &nbsp; <i class="fe fe-trash"></i> Delete All &nbsp; &nbsp; <i class="fe fe-trash"></i>
@ -66,7 +68,7 @@
{% endif %} {% endif %}
<hr /> <hr />
<form method="post"> <form method="post">
{{ new_api_key_form.csrf_token }} {{ csrf_form.csrf_token }}
<input type="hidden" name="form-name" value="create"> <input type="hidden" name="form-name" value="create">
<h2 class="h4">New API Key</h2> <h2 class="h4">New API Key</h2>
{{ new_api_key_form.name(class="form-control", placeholder="Chrome") }} {{ new_api_key_form.name(class="form-control", placeholder="Chrome") }}

View File

@ -25,17 +25,18 @@
<div class="alert alert-primary collapse {% if mailboxes|length == 1 %} show{% endif %}" <div class="alert alert-primary collapse {% if mailboxes|length == 1 %} show{% endif %}"
id="howtouse" id="howtouse"
role="alert"> role="alert">
A <em>mailbox</em> is just another personal email address. When creating a new alias, you could choose the A <em>mailbox</em> is just another personal email address. When creating a new alias, you could choose
the
mailbox that <em>owns</em> this alias, i.e: mailbox that <em>owns</em> this alias, i.e:
<br /> <br/>
- all emails sent to this alias will be forwarded to this mailbox - all emails sent to this alias will be forwarded to this mailbox
<br /> <br/>
- from this mailbox, you can reply/send emails from the alias. - from this mailbox, you can reply/send emails from the alias.
<br /> <br/>
<br /> <br/>
When you signed up, a mailbox is automatically created with your email <b>{{ current_user.email }}</b> When you signed up, a mailbox is automatically created with your email <b>{{ current_user.email }}</b>
<br /> <br/>
<br /> <br/>
The mailbox doesn't have to be your email: it can be your friend's email The mailbox doesn't have to be your email: it can be your friend's email
if you want to create aliases for your buddy. if you want to create aliases for your buddy.
</div> </div>
@ -74,11 +75,12 @@
</h5> </h5>
<h6 class="card-subtitle mb-2 text-muted"> <h6 class="card-subtitle mb-2 text-muted">
Created {{ mailbox.created_at | dt }} Created {{ mailbox.created_at | dt }}
<br /> <br/>
<span class="font-weight-bold">{{ mailbox.nb_alias() }}</span> aliases. <span class="font-weight-bold">{{ mailbox.nb_alias() }}</span> aliases.
<br /> <br/>
</h6> </h6>
<a href="{{ url_for('dashboard.mailbox_detail_route', mailbox_id=mailbox.id) }}">Edit ➡</a> <a href="{{ url_for('dashboard.mailbox_detail_route', mailbox_id=mailbox.id) }}">Edit
</a>
</div> </div>
<div class="card-footer p-0"> <div class="card-footer p-0">
<div class="row"> <div class="row">
@ -89,7 +91,7 @@
{{ csrf_form.csrf_token }} {{ csrf_form.csrf_token }}
<input type="hidden" name="form-name" value="set-default"> <input type="hidden" name="form-name" value="set-default">
<input type="hidden" class="mailbox" value="{{ mailbox.email }}"> <input type="hidden" class="mailbox" value="{{ mailbox.email }}">
<input type="hidden" name="mailbox-id" value="{{ mailbox.id }}"> <input type="hidden" name="mailbox_id" value="{{ mailbox.id }}">
<button class="card-link btn btn-link {% if mailbox.id == current_user.default_mailbox_id %} disabled{% endif %}"> <button class="card-link btn btn-link {% if mailbox.id == current_user.default_mailbox_id %} disabled{% endif %}">
Set As Default Mailbox Set As Default Mailbox
</button> </button>
@ -98,10 +100,24 @@
{% endif %} {% endif %}
<div class="col"> <div class="col">
<form method="post"> <form method="post">
{{ csrf_form.csrf_token }} {{ delete_mailbox_form.csrf_token }}
<input type="hidden" name="form-name" value="delete"> <input type="hidden" name="form-name" value="delete">
<input type="hidden" class="mailbox" value="{{ mailbox.email }}"> <input type="hidden" class="mailbox" value="{{ mailbox.email }}">
<input type="hidden" name="mailbox-id" value="{{ mailbox.id }}"> <input type="hidden" name="mailbox_id" value="{{ mailbox.id }}">
<select hidden name="transfer_mailbox_id" value="">
<option value="-1">
Delete my aliases
</option>
{% for mailbox_opt in mailboxes %}
{% if mailbox_opt.verified and mailbox_opt.id != mailbox.id %}
<option value="{{ mailbox_opt.id }}">
{{ mailbox_opt.email }}
</option>
{% endif %}
{% endfor %}
</select>
<span class="card-link btn btn-link text-danger float-right delete-mailbox {% if mailbox.id == current_user.default_mailbox_id %} disabled{% endif %}"> <span class="card-link btn btn-link text-danger float-right delete-mailbox {% if mailbox.id == current_user.default_mailbox_id %} disabled{% endif %}">
Delete Delete
</span> </span>
@ -128,31 +144,39 @@
{% block script %} {% block script %}
<script> <script>
$(".delete-mailbox").on("click", function (e) { $(".delete-mailbox").on("click", function (e) {
let mailbox = $(this).parent().find(".mailbox").val(); let mailbox = $(this).parent().find(".mailbox").val();
let that = $(this); let new_mailboxes = $(this).parent().find("select[name='transfer_mailbox_id']").find("option")
let message = `All aliases owned by this mailbox <b>${mailbox}</b> will be also deleted, ` + let inputOptions = new_mailboxes.map((index, option) => { return {["value"]: option.value, ["text"]: option.text}}).toArray()
" please confirm.";
bootbox.confirm({ let that = $(this);
message: message, let message = `All aliases owned by the mailbox <b>${mailbox}</b> will be also deleted.<br>` +
buttons: { "You can choose to transfer them to a different mailbox:<br><br>";
confirm: {
label: 'Yes, delete it', bootbox.prompt({
className: 'btn-danger' title: '<b>Delete Mailbox</b>',
}, message: message,
cancel: { value: ["-1"],
label: 'Cancel', inputType: 'select',
className: 'btn-outline-primary' inputOptions: inputOptions,
} buttons: {
}, confirm: {
callback: function (result) { label: 'Yes, delete it',
if (result) { className: 'btn-danger'
that.closest("form").submit(); },
} cancel: {
} label: 'Cancel',
}) className: 'btn-outline-primary mr-auto'
}); }
},
callback: function (result) {
if (result) {
that.closest("form").find("select[name='transfer_mailbox_id']").val(result)
that.closest("form").submit();
}
}
})
});
</script> </script>
{% endblock %} {% endblock %}

View File

@ -12,6 +12,7 @@
or use WebAuthn (FIDO). or use WebAuthn (FIDO).
</div> </div>
<form method="post"> <form method="post">
{{ csrf_form.csrf_token }}
<button class="btn btn-danger mt-2">Disable TOTP</button> <button class="btn btn-danger mt-2">Disable TOTP</button>
</form> </form>
</div> </div>

View File

@ -177,10 +177,6 @@ $30/year
<i class="fe fe-external-link"></i> <i class="fe fe-external-link"></i>
</a> </a>
<hr /> <hr />
For other payment options, please send us an email at
<a href="mailto:hi@simplelogin.io">hi@simplelogin.io</a>
.
<br />
If you have bought a coupon, please go to the If you have bought a coupon, please go to the
<a href="{{ url_for('dashboard.coupon_route') }}">coupon page</a> <a href="{{ url_for('dashboard.coupon_route') }}">coupon page</a>
to apply the coupon code. to apply the coupon code.

View File

@ -38,7 +38,7 @@
Handy when you need to quickly give out an email address, for example on a phone call, in a meeting or just Handy when you need to quickly give out an email address, for example on a phone call, in a meeting or just
anywhere you want. anywhere you want.
<br /> <br />
After choosing a subdomain, simply use <b>anything@my-subdomain.simplelogin.co</b> After choosing a subdomain, simply use <b>anything@my-subdomain.simplelogin.com</b>
next time you need an alias: next time you need an alias:
it'll be <b>automatically created</b> the first time it receives an email. it'll be <b>automatically created</b> the first time it receives an email.
<br /> <br />
@ -72,6 +72,7 @@
<div class="card-body"> <div class="card-body">
<h2 class="h4 mb-1">New Subdomain</h2> <h2 class="h4 mb-1">New Subdomain</h2>
<form method="post" class="mt-2" data-parsley-validate> <form method="post" class="mt-2" data-parsley-validate>
{{ new_subdomain_form.csrf_token }}
<input type="hidden" name="form-name" value="create"> <input type="hidden" name="form-name" value="create">
<div class="form-group"> <div class="form-group">
<label>Subdomain</label> <label>Subdomain</label>

View File

@ -9,8 +9,7 @@
{% endcall %} {% endcall %}
{% call text() %} {% call text() %}
Please contact us at Please <a href="https://app.simplelogin.io/dashboard/support">contact us</a>
<a href="mailto:hi@simplelogin.io">hi@simplelogin.io</a>
to renew your subscription. to renew your subscription.
{% endcall %} {% endcall %}

View File

@ -2,4 +2,6 @@
{% block content %} {% block content %}
Your subscription will end on {{ manual_sub.end_at.format("YYYY-MM-DD") }} Your subscription will end on {{ manual_sub.end_at.format("YYYY-MM-DD") }}
Please contact us on https://app.simplelogin.io/dashboard/support to renew your subscription.
{% endblock %} {% endblock %}

View File

@ -6,6 +6,7 @@
{{ render_text("You recently requested to change mailbox <b>"+ mailbox_email +"</b> to <b>" + mailbox_new_email + "</b>.") }} {{ render_text("You recently requested to change mailbox <b>"+ mailbox_email +"</b> to <b>" + mailbox_new_email + "</b>.") }}
{{ render_text("To confirm, please click on the button below.") }} {{ render_text("To confirm, please click on the button below.") }}
{{ render_button("Confirm mailbox change", link) }} {{ render_button("Confirm mailbox change", link) }}
{{ render_text("This email will only be valid for the next 15 minutes.") }}
{{ render_text('Thanks, {{ render_text('Thanks,
<br /> <br />
SimpleLogin Team.') }} SimpleLogin Team.') }}

View File

@ -8,4 +8,6 @@ You recently requested to change mailbox {{mailbox_email}} to {{mailbox_new_emai
To confirm, please click on this link: To confirm, please click on this link:
{{link}} {{link}}
This link will only be valid during the next 15 minutes.
{% endblock %} {% endblock %}

View File

@ -6,6 +6,7 @@
{{ render_text("You have added <b>"+ mailbox_email +"</b> as an additional mailbox.") }} {{ render_text("You have added <b>"+ mailbox_email +"</b> as an additional mailbox.") }}
{{ render_text("To confirm, please click on the button below.") }} {{ render_text("To confirm, please click on the button below.") }}
{{ render_button("Confirm mailbox", link) }} {{ render_button("Confirm mailbox", link) }}
{{ render_text("This email will only be valid for the next 15 minutes.") }}
{{ render_text('Thanks, {{ render_text('Thanks,
<br /> <br />
SimpleLogin Team.') }} SimpleLogin Team.') }}

View File

@ -8,4 +8,6 @@ You have added {{mailbox_email}} as an additional mailbox.
To confirm, please click on this link: To confirm, please click on this link:
{{link}} {{link}}
This link will only be valid during the next 15 minutes.
{% endblock %} {% endblock %}

View File

@ -0,0 +1,90 @@
from sqlalchemy_utils.types.arrow import arrow
from app.config import JOB_DELETE_MAILBOX
from app.db import Session
from app.mail_sender import mail_sender
from app.models import Alias, Mailbox, Job, AliasMailbox
from job_runner import delete_mailbox_job
from tests.utils import create_new_user, random_email
@mail_sender.store_emails_test_decorator
def test_delete_mailbox_transfer_mailbox_primary(flask_client):
user = create_new_user()
m1 = Mailbox.create(
user_id=user.id, email=random_email(), verified=True, flush=True
)
m2 = Mailbox.create(
user_id=user.id, email=random_email(), verified=True, flush=True
)
alias_id = Alias.create_new(user, "prefix", mailbox_id=m1.id).id
AliasMailbox.create(alias_id=alias_id, mailbox_id=m2.id)
job = Job.create(
name=JOB_DELETE_MAILBOX,
payload={"mailbox_id": m1.id, "transfer_mailbox_id": m2.id},
run_at=arrow.now(),
commit=True,
)
Session.commit()
delete_mailbox_job(job)
alias = Alias.get(alias_id)
assert alias.mailbox_id == m2.id
assert len(alias._mailboxes) == 0
mails_sent = mail_sender.get_stored_emails()
assert len(mails_sent) == 1
assert str(mails_sent[0].msg).find("alias have been transferred") > -1
@mail_sender.store_emails_test_decorator
def test_delete_mailbox_transfer_mailbox_in_list(flask_client):
user = create_new_user()
m1 = Mailbox.create(
user_id=user.id, email=random_email(), verified=True, flush=True
)
m2 = Mailbox.create(
user_id=user.id, email=random_email(), verified=True, flush=True
)
m3 = Mailbox.create(
user_id=user.id, email=random_email(), verified=True, flush=True
)
alias_id = Alias.create_new(user, "prefix", mailbox_id=m1.id).id
AliasMailbox.create(alias_id=alias_id, mailbox_id=m2.id)
job = Job.create(
name=JOB_DELETE_MAILBOX,
payload={"mailbox_id": m2.id, "transfer_mailbox_id": m3.id},
run_at=arrow.now(),
commit=True,
)
Session.commit()
delete_mailbox_job(job)
alias = Alias.get(alias_id)
assert alias.mailbox_id == m1.id
assert len(alias._mailboxes) == 1
assert alias._mailboxes[0].id == m3.id
mails_sent = mail_sender.get_stored_emails()
assert len(mails_sent) == 1
assert str(mails_sent[0].msg).find("alias have been transferred") > -1
@mail_sender.store_emails_test_decorator
def test_delete_mailbox_no_transfer(flask_client):
user = create_new_user()
m1 = Mailbox.create(
user_id=user.id, email=random_email(), verified=True, flush=True
)
alias_id = Alias.create_new(user, "prefix", mailbox_id=m1.id).id
job = Job.create(
name=JOB_DELETE_MAILBOX,
payload={"mailbox_id": m1.id},
run_at=arrow.now(),
commit=True,
)
Session.commit()
delete_mailbox_job(job)
assert Alias.get(alias_id) is None
mails_sent = mail_sender.get_stored_emails()
assert len(mails_sent) == 1
assert str(mails_sent[0].msg).find("along with its aliases have been deleted") > -1