Compare commits

...

6 Commits

Author SHA1 Message Date
b643f0644b 4.22.3 2023-03-01 12:00:06 +00:00
5d093db4f6 4.22.2 2023-02-16 12:00:05 +00:00
0b16fcac67 Update 'README.md' 2023-02-10 13:00:46 +00:00
a0d294da53 Update 'README.md' 2023-01-27 16:29:12 +00:00
c3f755aede Update '.drone.yml' 2023-01-27 16:26:22 +00:00
0aea62c222 4.22.0 2023-01-17 12:00:04 +00:00
61 changed files with 624 additions and 217 deletions

View File

@ -34,6 +34,8 @@ steps:
settings: settings:
webhook: webhook:
from_secret: slack_webhook from_secret: slack_webhook
icon_url:
from_secret: slack_avatar
trigger: trigger:
event: event:

View File

@ -1,5 +1,7 @@
# Simple Login # Simple Login
[![Build Status](https://drone.mrmeeb.stream/api/badges/MrMeeb/simple-login/status.svg?ref=refs/heads/main)](https://drone.mrmeeb.stream/MrMeeb/simple-login)
This repo exists to automatically capture any releases of the SaaS edition of SimpleLogin. It checks once a day, and builds the latest one automatically if it is newer than the currentlty built version. This repo exists to automatically capture any releases of the SaaS edition of SimpleLogin. It checks once a day, and builds the latest one automatically if it is newer than the currentlty built version.
This exists to simplify deployment of SimpleLogin in a self-hosted capacity, while also allowing the use of the latest version; SimpleLogin do not provide an up-to-date version for this use. This exists to simplify deployment of SimpleLogin in a self-hosted capacity, while also allowing the use of the latest version; SimpleLogin do not provide an up-to-date version for this use.

1
app/.gitignore vendored
View File

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

View File

@ -21,3 +21,4 @@ repos:
- id: djlint-jinja - id: djlint-jinja
files: '.*\.html' files: '.*\.html'
entry: djlint --reformat entry: djlint --reformat

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

@ -334,6 +334,12 @@ smtpd_recipient_restrictions =
permit permit
``` ```
Check that the ssl certificates `/etc/ssl/certs/ssl-cert-snakeoil.pem` and `/etc/ssl/private/ssl-cert-snakeoil.key` exist. Depending on the linux distribution you are using they may or may not be present. If they are not, you will need to generate them with this command:
```bash
openssl req -x509 -nodes -days 3650 -newkey rsa:2048 -keyout /etc/ssl/private/ssl-cert-snakeoil.key -out /etc/ssl/certs/ssl-cert-snakeoil.pem
```
Create the `/etc/postfix/pgsql-relay-domains.cf` file with the following content. Create the `/etc/postfix/pgsql-relay-domains.cf` file with the following content.
Make sure that the database config is correctly set, replace `mydomain.com` with your domain, update 'myuser' and 'mypassword' with your postgres credentials. Make sure that the database config is correctly set, replace `mydomain.com` with your domain, update 'myuser' and 'mypassword' with your postgres credentials.

View File

@ -620,3 +620,8 @@ class MetricAdmin(SLModelView):
column_exclude_list = ["created_at", "updated_at", "id"] column_exclude_list = ["created_at", "updated_at", "id"]
can_export = True can_export = True
class InvalidMailboxDomainAdmin(SLModelView):
can_create = True
can_delete = True

View File

@ -78,6 +78,9 @@ 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
@ -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

@ -111,11 +111,15 @@ POSTFIX_SERVER = os.environ.get("POSTFIX_SERVER", "240.0.0.1")
DISABLE_REGISTRATION = "DISABLE_REGISTRATION" in os.environ DISABLE_REGISTRATION = "DISABLE_REGISTRATION" in os.environ
# allow using a different postfix port, useful when developing locally # allow using a different postfix port, useful when developing locally
POSTFIX_PORT = int(os.environ.get("POSTFIX_PORT", 25))
# Use port 587 instead of 25 when sending emails through Postfix # Use port 587 instead of 25 when sending emails through Postfix
# Useful when calling Postfix from an external network # Useful when calling Postfix from an external network
POSTFIX_SUBMISSION_TLS = "POSTFIX_SUBMISSION_TLS" in os.environ POSTFIX_SUBMISSION_TLS = "POSTFIX_SUBMISSION_TLS" in os.environ
if POSTFIX_SUBMISSION_TLS:
default_postfix_port = 587
else:
default_postfix_port = 25
POSTFIX_PORT = int(os.environ.get("POSTFIX_PORT", default_postfix_port))
POSTFIX_TIMEOUT = os.environ.get("POSTFIX_TIMEOUT", 3) POSTFIX_TIMEOUT = os.environ.get("POSTFIX_TIMEOUT", 3)
# ["domain1.com", "domain2.com"] # ["domain1.com", "domain2.com"]

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

@ -150,7 +150,13 @@ def index():
flash(f"Alias {alias.email} has been disabled", "success") flash(f"Alias {alias.email} has been disabled", "success")
return redirect( return redirect(
url_for("dashboard.index", query=query, sort=sort, filter=alias_filter) url_for(
"dashboard.index",
query=query,
sort=sort,
filter=alias_filter,
page=page,
)
) )
mailboxes = current_user.mailboxes() mailboxes = current_user.mailboxes()

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,56 @@ 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.", "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 # 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 +109,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:
@ -124,7 +164,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=new_mailbox.id,
) )
) )
@ -132,38 +173,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 +204,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

@ -17,7 +17,7 @@ from attr import dataclass
from app import config from app import config
from app.email import headers from app.email import headers
from app.log import LOG from app.log import LOG
from app.message_utils import message_to_bytes from app.message_utils import message_to_bytes, message_format_base64_parts
@dataclass @dataclass
@ -117,14 +117,12 @@ class MailSender:
return True return True
def _send_to_smtp(self, send_request: SendRequest, retries: int) -> bool: def _send_to_smtp(self, send_request: SendRequest, retries: int) -> bool:
if config.POSTFIX_SUBMISSION_TLS and config.POSTFIX_PORT == 25:
smtp_port = 587
else:
smtp_port = config.POSTFIX_PORT
try: try:
start = time.time() start = time.time()
with SMTP( with SMTP(
config.POSTFIX_SERVER, smtp_port, timeout=config.POSTFIX_TIMEOUT config.POSTFIX_SERVER,
config.POSTFIX_PORT,
timeout=config.POSTFIX_TIMEOUT,
) as smtp: ) as smtp:
if config.POSTFIX_SUBMISSION_TLS: if config.POSTFIX_SUBMISSION_TLS:
smtp.starttls() smtp.starttls()
@ -170,13 +168,17 @@ class MailSender:
LOG.e(f"Ignore smtp error {e}") LOG.e(f"Ignore smtp error {e}")
return False return False
LOG.e( LOG.e(
f"Could not send message to smtp server {config.POSTFIX_SERVER}:{smtp_port}" f"Could not send message to smtp server {config.POSTFIX_SERVER}:{config.POSTFIX_PORT}"
) )
self._save_request_to_unsent_dir(send_request) self._save_request_to_unsent_dir(send_request)
return False return False
def _save_request_to_unsent_dir(self, send_request: SendRequest): def _save_request_to_unsent_dir(
file_name = f"DeliveryFail-{int(time.time())}-{uuid.uuid4()}.{SendRequest.SAVE_EXTENSION}" self, send_request: SendRequest, prefix: str = "DeliveryFail"
):
file_name = (
f"{prefix}-{int(time.time())}-{uuid.uuid4()}.{SendRequest.SAVE_EXTENSION}"
)
file_path = os.path.join(config.SAVE_UNSENT_DIR, file_name) file_path = os.path.join(config.SAVE_UNSENT_DIR, file_name)
file_contents = send_request.to_bytes() file_contents = send_request.to_bytes()
with open(file_path, "wb") as fd: with open(file_path, "wb") as fd:
@ -258,7 +260,7 @@ def sl_sendmail(
send_request = SendRequest( send_request = SendRequest(
envelope_from, envelope_from,
envelope_to, envelope_to,
msg, message_format_base64_parts(msg),
mail_options, mail_options,
rcpt_options, rcpt_options,
is_forward, is_forward,

View File

@ -1,21 +1,42 @@
import re
from email import policy from email import policy
from email.message import Message from email.message import Message
from app.email import headers
from app.log import LOG from app.log import LOG
# Spam assassin might flag as spam with a different line length
BASE64_LINELENGTH = 76
def message_to_bytes(msg: Message) -> bytes: def message_to_bytes(msg: Message) -> bytes:
"""replace Message.as_bytes() method by trying different policies""" """replace Message.as_bytes() method by trying different policies"""
for generator_policy in [None, policy.SMTP, policy.SMTPUTF8]: for generator_policy in [None, policy.SMTP, policy.SMTPUTF8]:
try: try:
return msg.as_bytes(policy=generator_policy) return msg.as_bytes(policy=generator_policy)
except: except Exception:
LOG.w("as_bytes() fails with %s policy", policy, exc_info=True) LOG.w("as_bytes() fails with %s policy", policy, exc_info=True)
msg_string = msg.as_string() msg_string = msg.as_string()
try: try:
return msg_string.encode() return msg_string.encode()
except: except Exception:
LOG.w("as_string().encode() fails", exc_info=True) LOG.w("as_string().encode() fails", exc_info=True)
return msg_string.encode(errors="replace") return msg_string.encode(errors="replace")
def message_format_base64_parts(msg: Message) -> Message:
for part in msg.walk():
if part.get(
headers.CONTENT_TRANSFER_ENCODING
) == "base64" and part.get_content_type() in ("text/plain", "text/html"):
# Remove line breaks
body = re.sub("[\r\n]", "", part.get_payload())
# Split in 80 column lines
chunks = [
body[i : i + BASE64_LINELENGTH]
for i in range(0, len(body), BASE64_LINELENGTH)
]
part.set_payload("\r\n".join(chunks))
return msg

View File

@ -1641,6 +1641,8 @@ class Contact(Base, ModelMixin):
Store configuration of sender (website-email) and alias. Store configuration of sender (website-email) and alias.
""" """
MAX_NAME_LENGTH = 512
__tablename__ = "contact" __tablename__ = "contact"
__table_args__ = ( __table_args__ = (

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

@ -168,7 +168,7 @@ from app.pgp_utils import (
sign_data, sign_data,
load_public_key_and_check, load_public_key_and_check,
) )
from app.utils import sanitize_email from app.utils import sanitize_email, canonicalize_email
from init_app import load_pgp_public_keys from init_app import load_pgp_public_keys
from server import create_light_app from server import create_light_app
@ -182,6 +182,10 @@ def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Con
except ValueError: except ValueError:
contact_name, contact_email = "", "" contact_name, contact_email = "", ""
# Ensure contact_name is within limits
if len(contact_name) >= Contact.MAX_NAME_LENGTH:
contact_name = contact_name[0 : Contact.MAX_NAME_LENGTH]
if not is_valid_email(contact_email): if not is_valid_email(contact_email):
# From header is wrongly formatted, try with mail_from # From header is wrongly formatted, try with mail_from
if mail_from and mail_from != "<>": if mail_from and mail_from != "<>":
@ -1384,21 +1388,26 @@ def get_mailbox_from_mail_from(mail_from: str, alias) -> Optional[Mailbox]:
"""return the corresponding mailbox given the mail_from and alias """return the corresponding mailbox given the mail_from and alias
Usually the mail_from=mailbox.email but it can also be one of the authorized address Usually the mail_from=mailbox.email but it can also be one of the authorized address
""" """
for mailbox in alias.mailboxes:
if mailbox.email == mail_from:
return mailbox
for authorized_address in mailbox.authorized_addresses: def __check(email_address: str, alias: Alias) -> Optional[Mailbox]:
if authorized_address.email == mail_from: for mailbox in alias.mailboxes:
LOG.d( if mailbox.email == email_address:
"Found an authorized address for %s %s %s",
alias,
mailbox,
authorized_address,
)
return mailbox return mailbox
return None for authorized_address in mailbox.authorized_addresses:
if authorized_address.email == email_address:
LOG.d(
"Found an authorized address for %s %s %s",
alias,
mailbox,
authorized_address,
)
return mailbox
return None
# We need to first check for the uncanonicalized version because we still have users in the db with the
# email non canonicalized. So if it matches the already existing one use that, otherwise check the canonical one
return __check(mail_from, alias) or __check(canonicalize_email(mail_from), alias)
def handle_unknown_mailbox( def handle_unknown_mailbox(

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

View File

@ -44,6 +44,7 @@ from app.admin_model import (
NewsletterUserAdmin, NewsletterUserAdmin,
DailyMetricAdmin, DailyMetricAdmin,
MetricAdmin, MetricAdmin,
InvalidMailboxDomainAdmin,
) )
from app.api.base import api_bp from app.api.base import api_bp
from app.auth.base import auth_bp from app.auth.base import auth_bp
@ -105,6 +106,7 @@ from app.models import (
NewsletterUser, NewsletterUser,
DailyMetric, DailyMetric,
Metric2, Metric2,
InvalidMailboxDomain,
) )
from app.monitor.base import monitor_bp from app.monitor.base import monitor_bp
from app.newsletter_utils import send_newsletter_to_user from app.newsletter_utils import send_newsletter_to_user
@ -764,6 +766,7 @@ def init_admin(app):
admin.add_view(NewsletterUserAdmin(NewsletterUser, Session)) admin.add_view(NewsletterUserAdmin(NewsletterUser, Session))
admin.add_view(DailyMetricAdmin(DailyMetric, Session)) admin.add_view(DailyMetricAdmin(DailyMetric, Session))
admin.add_view(MetricAdmin(Metric2, Session)) admin.add_view(MetricAdmin(Metric2, Session))
admin.add_view(InvalidMailboxDomainAdmin(InvalidMailboxDomain, Session))
def register_custom_commands(app): def register_custom_commands(app):

View File

@ -50,7 +50,9 @@
</p> </p>
<p> <p>
This Youtube video can also quickly walk you through the steps: This Youtube video can also quickly walk you through the steps:
<a href="https://www.youtube.com/watch?v=VsypF-DBaow" target="_blank"> <a href="https://www.youtube.com/watch?v=VsypF-DBaow"
target="_blank"
rel="noopener noreferrer">
How to send emails from an alias <i class="fe fe-external-link"></i> How to send emails from an alias <i class="fe fe-external-link"></i>
</a> </a>
</p> </p>

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

@ -23,7 +23,9 @@
<div class="alert alert-danger" role="alert"> <div class="alert alert-danger" role="alert">
This feature is only available on Premium plan. This feature is only available on Premium plan.
<a href="{{ URL }}/dashboard/pricing" target="_blank" rel="noopener"> <a href="{{ URL }}/dashboard/pricing"
target="_blank"
rel="noopener noreferrer">
Upgrade<i class="fe fe-external-link"></i> Upgrade<i class="fe fe-external-link"></i>
</a> </a>
</div> </div>

View File

@ -78,7 +78,7 @@
data-clipboard-text=".*suffix">.*suffix</em> data-clipboard-text=".*suffix">.*suffix</em>
<br /> <br />
To test out regex, we recommend using regex tester tool like To test out regex, we recommend using regex tester tool like
<a href="https://regex101.com" target="_blank">https://regex101.com↗</a> <a href="https://regex101.com" target="_blank" rel="noopener noreferrer">https://regex101.com↗</a>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">

View File

@ -158,7 +158,7 @@
SPF SPF
<a href="https://en.wikipedia.org/wiki/Sender_Policy_Framework" <a href="https://en.wikipedia.org/wiki/Sender_Policy_Framework"
target="_blank" target="_blank"
rel="noopener">(Wikipedia↗)</a> rel="noopener noreferrer">(Wikipedia↗)</a>
is an email is an email
authentication method authentication method
designed to detect forging sender addresses during the delivery of the email. designed to detect forging sender addresses during the delivery of the email.
@ -229,7 +229,7 @@
DKIM DKIM
<a href="https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail" <a href="https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail"
target="_blank" target="_blank"
rel="noopener">(Wikipedia↗)</a> rel="noopener noreferrer">(Wikipedia↗)</a>
is an is an
email email
authentication method authentication method
@ -335,7 +335,7 @@
DMARC DMARC
<a href="https://en.wikipedia.org/wiki/DMARC" <a href="https://en.wikipedia.org/wiki/DMARC"
target="_blank" target="_blank"
rel="noopener"> rel="noopener noreferrer">
(Wikipedia↗) (Wikipedia↗)
</a> </a>
is designed to protect the domain from unauthorized use, commonly known as email spoofing. is designed to protect the domain from unauthorized use, commonly known as email spoofing.

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

@ -72,7 +72,7 @@ PGP Encryption
</ul> </ul>
<div class="small-text"> <div class="small-text">
More information on our More information on our
<a href="https://simplelogin.io/pricing" target="_blank" rel="noopener"> <a href="https://simplelogin.io/pricing" target="_blank" rel="noopener noreferrer">
Pricing Pricing
Page <i class="fe fe-external-link"></i> Page <i class="fe fe-external-link"></i>
</a> </a>
@ -120,7 +120,7 @@ Upgrade your SimpleLogin account
<div id="normal-upgrade" class="{% if proton_upgrade %} collapse{% endif %}"> <div id="normal-upgrade" class="{% if proton_upgrade %} collapse{% endif %}">
<div class="display-6 my-3"> <div class="display-6 my-3">
🔐 Secure payments by 🔐 Secure payments by
<a href="https://paddle.com" target="_blank" rel="noopener"> <a href="https://paddle.com" target="_blank" rel="noopener noreferrer">
Paddle <i class="fe fe-external-link"></i> Paddle <i class="fe fe-external-link"></i>
</a> </a>
</div> </div>
@ -164,23 +164,19 @@ $4/month
<hr /> <hr />
<i class="fa fa-bitcoin"></i> <i class="fa fa-bitcoin"></i>
Payment via Payment via
<a href="https://commerce.coinbase.com/?lang=en" target="_blank"> <a href="https://commerce.coinbase.com/?lang=en" target="_blank" rel="noopener noreferrer">
Coinbase Commerce<i class="fe fe-external-link"></i> Coinbase Commerce<i class="fe fe-external-link"></i>
</a> </a>
<br /> <br />
Currently Bitcoin, Bitcoin Cash, Dai, Ethereum, Litecoin and USD Coin are supported. Currently Bitcoin, Bitcoin Cash, Dai, Ethereum, Litecoin and USD Coin are supported.
<br /> <br />
<a class="btn btn-outline-primary" href="{{ url_for('dashboard.coinbase_checkout_route') }}" target="_blank"> <a class="btn btn-outline-primary" href="{{ url_for('dashboard.coinbase_checkout_route') }}" target="_blank" rel="noopener noreferrer">
Yearly billing - Crypto Yearly billing - Crypto
<br /> <br />
$30/year $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

@ -17,7 +17,8 @@
<div class="alert alert-info"> <div class="alert alert-info">
This page shows all emails that are either refused by your mailbox (bounced) or detected as spams/phishing (quarantine) via our This page shows all emails that are either refused by your mailbox (bounced) or detected as spams/phishing (quarantine) via our
<a href="https://simplelogin.io/docs/getting-started/anti-phishing/" <a href="https://simplelogin.io/docs/getting-started/anti-phishing/"
target="_blank">anti-phishing program ↗</a> target="_blank"
rel="noopener noreferrer">anti-phishing program ↗</a>
<ul class="p-4 mb-0"> <ul class="p-4 mb-0">
<li> <li>
If the email is indeed spam, this means the alias is now in the hands of a spammer, If the email is indeed spam, this means the alias is now in the hands of a spammer,
@ -26,7 +27,8 @@
<li> <li>
If the email isn't spam and your mailbox refuses the email, we recommend to create a <b>filter</b> to avoid your mailbox provider from blocking legitimate emails. Please refer to If the email isn't spam and your mailbox refuses the email, we recommend to create a <b>filter</b> to avoid your mailbox provider from blocking legitimate emails. Please refer to
<a href="https://simplelogin.io/docs/getting-started/troubleshooting/#emails-end-up-in-spam" <a href="https://simplelogin.io/docs/getting-started/troubleshooting/#emails-end-up-in-spam"
target="_blank">Setting up filter for SimpleLogin emails ↗</a> target="_blank"
rel="noopener noreferrer">Setting up filter for SimpleLogin emails ↗</a>
</li> </li>
<li> <li>
If the email is flagged as spams/phishing, this means that the sender explicitly states their emails should respect If the email is flagged as spams/phishing, this means that the sender explicitly states their emails should respect

View File

@ -73,7 +73,8 @@
Yearly plan subscribed with cryptocurrency which expires on Yearly plan subscribed with cryptocurrency which expires on
{{ coinbase_sub.end_at.format("YYYY-MM-DD") }}. {{ coinbase_sub.end_at.format("YYYY-MM-DD") }}.
<a href="{{ url_for('dashboard.coinbase_checkout_route') }}" <a href="{{ url_for('dashboard.coinbase_checkout_route') }}"
target="_blank"> target="_blank"
rel="noopener noreferrer">
Extend Subscription <i class="fe fe-external-link"></i> Extend Subscription <i class="fe fe-external-link"></i>
</a> </a>
</div> </div>

View File

@ -25,7 +25,7 @@
This feature is only available on Premium plan. This feature is only available on Premium plan.
<a href="{{ url_for('dashboard.pricing') }}" <a href="{{ url_for('dashboard.pricing') }}"
target="_blank" target="_blank"
rel="noopener"> rel="noopener noreferrer">
Upgrade<i class="fe fe-external-link"></i> Upgrade<i class="fe fe-external-link"></i>
</a> </a>
</div> </div>
@ -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

@ -33,6 +33,7 @@
</div> </div>
<a href="https://docs.simplelogin.io" <a href="https://docs.simplelogin.io"
target="_blank" target="_blank"
rel="noopener noreferrer"
class="btn btn-block btn-secondary mt-4"> class="btn btn-block btn-secondary mt-4">
Documentation <i class="fe fe-external-link"></i> Documentation <i class="fe fe-external-link"></i>
</a> </a>

View File

@ -10,7 +10,9 @@
<h4 class="alert-heading">Well done!</h4> <h4 class="alert-heading">Well done!</h4>
<p> <p>
Please head to our Please head to our
<a href="https://docs.simplelogin.io" target="_blank" rel="noopener"> <a href="https://docs.simplelogin.io"
target="_blank"
rel="noopener noreferrer">
documentation <i class="fe fe-external-link"></i> documentation <i class="fe fe-external-link"></i>
</a> </a>
to see how to add SIWSL into your app. to see how to add SIWSL into your app.

View File

@ -49,6 +49,7 @@
<a href="{{ url_for('developer.new_client') }}" class="btn btn-primary">New website</a> <a href="{{ url_for('developer.new_client') }}" class="btn btn-primary">New website</a>
<a href="https://docs.simplelogin.io" <a href="https://docs.simplelogin.io"
target="_blank" target="_blank"
rel="noopener noreferrer"
class="ml-2 btn btn-secondary"> class="ml-2 btn btn-secondary">
Docs <i class="fe fe-external-link"></i> Docs <i class="fe fe-external-link"></i>
</a> </a>

View File

@ -13,7 +13,9 @@
<div class="col-sm-4 col-xl-2"> <div class="col-sm-4 col-xl-2">
<div class="card"> <div class="card">
<a href="{{ client.home_url }}" target="_blank" rel="noopener"> <a href="{{ client.home_url }}"
target="_blank"
rel="noopener noreferrer">
<img class="card-img-top" src="{{ client.get_icon_url() }}"> <img class="card-img-top" src="{{ client.get_icon_url() }}">
</a> </a>
<div class="card-body d-flex flex-column"> <div class="card-body d-flex flex-column">

View File

@ -46,7 +46,7 @@ https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
<a href="{{ link }}" <a href="{{ link }}"
class="f-fallback button" class="f-fallback button"
target="_blank" target="_blank"
rel="noopener" rel="noopener noreferrer"
style="color: #FFF; style="color: #FFF;
border-color: #3869d4; border-color: #3869d4;
border-style: solid; border-style: solid;

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

@ -145,28 +145,28 @@
<ul class="list-group list-group-transparent list-group-white list-group-flush list-group-borderless mb-0 footer-list-group"> <ul class="list-group list-group-transparent list-group-white list-group-flush list-group-borderless mb-0 footer-list-group">
<li> <li>
<a class="list-group-item text-white footer-item " <a class="list-group-item text-white footer-item "
rel="noopener" rel="noopener noreferrer"
href="https://chrome.google.com/webstore/detail/dphilobhebphkdjbpfohgikllaljmgbn"> href="https://chrome.google.com/webstore/detail/dphilobhebphkdjbpfohgikllaljmgbn">
Chrome Extension Chrome Extension
</a> </a>
</li> </li>
<li> <li>
<a class="list-group-item text-white footer-item " <a class="list-group-item text-white footer-item "
rel="noopener" rel="noopener noreferrer"
href="https://addons.mozilla.org/firefox/addon/simplelogin/"> href="https://addons.mozilla.org/firefox/addon/simplelogin/">
Firefox Add-on Firefox Add-on
</a> </a>
</li> </li>
<li> <li>
<a class="list-group-item text-white footer-item " <a class="list-group-item text-white footer-item "
rel="noopener" rel="noopener noreferrer"
href="https://microsoftedge.microsoft.com/addons/detail/simpleloginreceive-sen/diacfpipniklenphgljfkmhinphjlfff"> href="https://microsoftedge.microsoft.com/addons/detail/simpleloginreceive-sen/diacfpipniklenphgljfkmhinphjlfff">
Edge Add-on Edge Add-on
</a> </a>
</li> </li>
<li> <li>
<a class="list-group-item text-white footer-item " <a class="list-group-item text-white footer-item "
rel="noopener" rel="noopener noreferrer"
href="https://apps.apple.com/app/id1494051017"> href="https://apps.apple.com/app/id1494051017">
Safari Safari
Extension Extension
@ -174,7 +174,7 @@
</li> </li>
<li> <li>
<a class="list-group-item text-white footer-item " <a class="list-group-item text-white footer-item "
rel="noopener" rel="noopener noreferrer"
href="https://apps.apple.com/app/id1494359858"> href="https://apps.apple.com/app/id1494359858">
iOS iOS
(App Store) (App Store)
@ -182,14 +182,14 @@
</li> </li>
<li> <li>
<a class="list-group-item text-white footer-item " <a class="list-group-item text-white footer-item "
rel="noopener" rel="noopener noreferrer"
href="https://play.google.com/store/apps/details?id=io.simplelogin.android"> href="https://play.google.com/store/apps/details?id=io.simplelogin.android">
Android (Play Store) Android (Play Store)
</a> </a>
</li> </li>
<li> <li>
<a class="list-group-item text-white footer-item " <a class="list-group-item text-white footer-item "
rel="noopener" rel="noopener noreferrer"
href="https://f-droid.org/en/packages/io.simplelogin.android.fdroid/"> href="https://f-droid.org/en/packages/io.simplelogin.android.fdroid/">
Android (F-Droid) Android (F-Droid)
</a> </a>

View File

@ -75,14 +75,17 @@
<a href="#" class="dropdown-toggle" data-toggle="dropdown">Help</a> <a href="#" class="dropdown-toggle" data-toggle="dropdown">Help</a>
<div class="dropdown-menu dropdown-menu-left dropdown-menu-arrow"> <div class="dropdown-menu dropdown-menu-left dropdown-menu-arrow">
<div class="dropdown-item"> <div class="dropdown-item">
<a href="https://simplelogin.io/docs/" target="_blank"> <a href="https://simplelogin.io/docs/"
target="_blank"
rel="noopener noreferrer">
Docs Docs
<i class="fa fa-external-link" aria-hidden="true"></i> <i class="fa fa-external-link" aria-hidden="true"></i>
</a> </a>
</div> </div>
<div class="dropdown-item"> <div class="dropdown-item">
<a href="https://github.com/simple-login/app/discussions" <a href="https://github.com/simple-login/app/discussions"
target="_blank"> target="_blank"
rel="noopener noreferrer">
Forum Forum
<i class="fa fa-external-link" aria-hidden="true"></i> <i class="fa fa-external-link" aria-hidden="true"></i>
</a> </a>
@ -94,7 +97,9 @@
</div> </div>
{% else %} {% else %}
<div class="nav-item"> <div class="nav-item">
<a href="https://simplelogin.io/docs/" target="_blank"> <a href="https://simplelogin.io/docs/"
target="_blank"
rel="noopener noreferrer">
Docs Docs
<i class="fa fa-external-link" aria-hidden="true"></i> <i class="fa fa-external-link" aria-hidden="true"></i>
</a> </a>

View File

@ -98,14 +98,17 @@
<a href="#" class="dropdown-toggle" data-toggle="dropdown">Help</a> <a href="#" class="dropdown-toggle" data-toggle="dropdown">Help</a>
<div class="dropdown-menu dropdown-menu-left dropdown-menu-arrow"> <div class="dropdown-menu dropdown-menu-left dropdown-menu-arrow">
<div class="dropdown-item"> <div class="dropdown-item">
<a href="https://simplelogin.io/docs/" target="_blank"> <a href="https://simplelogin.io/docs/"
target="_blank"
rel="noopener noreferrer">
Docs Docs
<i class="fa fa-external-link" aria-hidden="true"></i> <i class="fa fa-external-link" aria-hidden="true"></i>
</a> </a>
</div> </div>
<div class="dropdown-item"> <div class="dropdown-item">
<a href="https://github.com/simple-login/app/discussions" <a href="https://github.com/simple-login/app/discussions"
target="_blank"> target="_blank"
rel="noopener noreferrer">
Forum Forum
<i class="fa fa-external-link" aria-hidden="true"></i> <i class="fa fa-external-link" aria-hidden="true"></i>
</a> </a>

File diff suppressed because one or more lines are too long

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

View File

@ -22,6 +22,7 @@ from app.models import (
Contact, Contact,
SentAlert, SentAlert,
) )
from app.utils import random_string, canonicalize_email
from email_handler import ( from email_handler import (
get_mailbox_from_mail_from, get_mailbox_from_mail_from,
should_ignore, should_ignore,
@ -308,3 +309,62 @@ def test_replace_contacts_and_user_in_reply_phase(flask_client):
payload = sent_mails[0].msg.get_payload()[0].get_payload() payload = sent_mails[0].msg.get_payload()[0].get_payload()
assert payload.find("Contact is {}".format(contact_real_mail)) > -1 assert payload.find("Contact is {}".format(contact_real_mail)) > -1
assert payload.find("Other contact is {}".format(contact2_real_mail)) > -1 assert payload.find("Other contact is {}".format(contact2_real_mail)) > -1
@mail_sender.store_emails_test_decorator
def test_send_email_from_non_canonical_address_on_reply(flask_client):
email_address = f"{random_string(10)}.suf@gmail.com"
user = create_new_user(email=canonicalize_email(email_address))
alias = Alias.create_new_random(user)
Session.commit()
contact = Contact.create(
user_id=user.id,
alias_id=alias.id,
website_email=random_email(),
reply_email=f"{random_string(10)}@{EMAIL_DOMAIN}",
commit=True,
)
envelope = Envelope()
envelope.mail_from = email_address
envelope.rcpt_tos = [contact.reply_email]
msg = EmailMessage()
msg[headers.TO] = contact.reply_email
msg[headers.SUBJECT] = random_string()
result = email_handler.handle(envelope, msg)
assert result == status.E200
sent_mails = mail_sender.get_stored_emails()
assert len(sent_mails) == 1
email_logs = EmailLog.filter_by(user_id=user.id).all()
assert len(email_logs) == 1
assert email_logs[0].alias_id == alias.id
assert email_logs[0].mailbox_id == user.default_mailbox_id
@mail_sender.store_emails_test_decorator
def test_send_email_from_non_canonical_matches_already_existing_user(flask_client):
email_address = f"{random_string(10)}.suf@gmail.com"
create_new_user(email=canonicalize_email(email_address))
user = create_new_user(email=email_address)
alias = Alias.create_new_random(user)
Session.commit()
contact = Contact.create(
user_id=user.id,
alias_id=alias.id,
website_email=random_email(),
reply_email=f"{random_string(10)}@{EMAIL_DOMAIN}",
commit=True,
)
envelope = Envelope()
envelope.mail_from = email_address
envelope.rcpt_tos = [contact.reply_email]
msg = EmailMessage()
msg[headers.TO] = contact.reply_email
msg[headers.SUBJECT] = random_string()
result = email_handler.handle(envelope, msg)
assert result == status.E200
sent_mails = mail_sender.get_stored_emails()
assert len(sent_mails) == 1
email_logs = EmailLog.filter_by(user_id=user.id).all()
assert len(email_logs) == 1
assert email_logs[0].alias_id == alias.id
assert email_logs[0].mailbox_id == user.default_mailbox_id

View File

@ -2,7 +2,8 @@ import email
from app.email_utils import ( from app.email_utils import (
copy, copy,
) )
from app.message_utils import message_to_bytes from app.message_utils import message_to_bytes, message_format_base64_parts
from tests.utils import load_eml_file
def test_copy(): def test_copy():
@ -33,3 +34,13 @@ def test_to_bytes():
msg = email.message_from_string("éèà€") msg = email.message_from_string("éèà€")
assert message_to_bytes(msg).decode() == "\néèà€" assert message_to_bytes(msg).decode() == "\néèà€"
def test_base64_line_breaks():
msg = load_eml_file("bad_base64format.eml")
msg = message_format_base64_parts(msg)
for part in msg.walk():
if part.get("content-transfer-encoding") == "base64":
body = part.get_payload()
for line in body.splitlines():
assert len(line) <= 76