This commit is contained in:
2022-12-30 16:23:27 +00:00
parent 02776e8478
commit 20da343c54
1304 changed files with 870224 additions and 0 deletions

View File

@ -0,0 +1,35 @@
from .views import (
index,
pricing,
setting,
custom_alias,
subdomain,
billing,
alias_log,
alias_export,
unsubscribe,
api_key,
custom_domain,
alias_contact_manager,
enter_sudo,
mfa_setup,
mfa_cancel,
fido_setup,
coupon,
fido_manage,
domain_detail,
lifetime_licence,
directory,
mailbox,
mailbox_detail,
refused_email,
referral,
contact_detail,
setup_done,
batch_import,
alias_transfer,
app,
delete_account,
notification,
support,
)

View File

@ -0,0 +1,8 @@
from flask import Blueprint
dashboard_bp = Blueprint(
name="dashboard",
import_name=__name__,
url_prefix="/dashboard",
template_folder="templates",
)

View File

View File

@ -0,0 +1,332 @@
from dataclasses import dataclass
from operator import or_
from flask import render_template, request, redirect, flash
from flask import url_for
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from sqlalchemy import and_, func, case
from wtforms import StringField, validators, ValidationError
# Need to import directly from config to allow modification from the tests
from app import config, parallel_limiter
from app.dashboard.base import dashboard_bp
from app.db import Session
from app.email_utils import (
is_valid_email,
generate_reply_email,
parse_full_address,
)
from app.errors import (
CannotCreateContactForReverseAlias,
ErrContactErrorUpgradeNeeded,
ErrAddressInvalid,
ErrContactAlreadyExists,
)
from app.log import LOG
from app.models import Alias, Contact, EmailLog, User
from app.utils import sanitize_email, CSRFValidationForm
def email_validator():
"""validate email address. Handle both only email and email with name:
- ab@cd.com
- AB CD <ab@cd.com>
"""
message = "Invalid email format. Email must be either email@example.com or *First Last <email@example.com>*"
def _check(form, field):
email = field.data
email = email.strip()
email_part = email
if "<" in email and ">" in email:
if email.find("<") + 1 < email.find(">"):
email_part = email[email.find("<") + 1 : email.find(">")].strip()
if not is_valid_email(email_part):
raise ValidationError(message)
return _check
def user_can_create_contacts(user: User) -> bool:
if user.is_premium():
return True
if user.flags & User.FLAG_FREE_DISABLE_CREATE_ALIAS == 0:
return True
return not config.DISABLE_CREATE_CONTACTS_FOR_FREE_USERS
def create_contact(user: User, alias: Alias, contact_address: str) -> Contact:
"""
Create a contact for a user. Can be restricted for new free users by enabling DISABLE_CREATE_CONTACTS_FOR_FREE_USERS.
Can throw exceptions:
- ErrAddressInvalid
- ErrContactAlreadyExists
- ErrContactUpgradeNeeded - If DISABLE_CREATE_CONTACTS_FOR_FREE_USERS this exception will be raised for new free users
"""
if not contact_address:
raise ErrAddressInvalid("Empty address")
try:
contact_name, contact_email = parse_full_address(contact_address)
except ValueError:
raise ErrAddressInvalid(contact_address)
contact_email = sanitize_email(contact_email)
if not is_valid_email(contact_email):
raise ErrAddressInvalid(contact_email)
contact = Contact.get_by(alias_id=alias.id, website_email=contact_email)
if contact:
raise ErrContactAlreadyExists(contact)
if not user_can_create_contacts(user):
raise ErrContactErrorUpgradeNeeded()
contact = Contact.create(
user_id=alias.user_id,
alias_id=alias.id,
website_email=contact_email,
name=contact_name,
reply_email=generate_reply_email(contact_email, user),
)
LOG.d(
"create reverse-alias for %s %s, reverse alias:%s",
contact_address,
alias,
contact.reply_email,
)
Session.commit()
return contact
class NewContactForm(FlaskForm):
email = StringField(
"Email", validators=[validators.DataRequired(), email_validator()]
)
@dataclass
class ContactInfo(object):
contact: Contact
nb_forward: int
nb_reply: int
latest_email_log: EmailLog
def get_contact_infos(
alias: Alias, page=0, contact_id=None, query: str = ""
) -> [ContactInfo]:
"""if contact_id is set, only return the contact info for this contact"""
sub = (
Session.query(
Contact.id,
func.sum(case([(EmailLog.is_reply, 1)], else_=0)).label("nb_reply"),
func.sum(
case(
[
(
and_(
EmailLog.is_reply.is_(False),
EmailLog.blocked.is_(False),
),
1,
)
],
else_=0,
)
).label("nb_forward"),
func.max(EmailLog.created_at).label("max_email_log_created_at"),
)
.join(
EmailLog,
EmailLog.contact_id == Contact.id,
isouter=True,
)
.filter(Contact.alias_id == alias.id)
.group_by(Contact.id)
.subquery()
)
q = (
Session.query(
Contact,
EmailLog,
sub.c.nb_reply,
sub.c.nb_forward,
)
.join(
EmailLog,
EmailLog.contact_id == Contact.id,
isouter=True,
)
.filter(Contact.alias_id == alias.id)
.filter(Contact.id == sub.c.id)
.filter(
or_(
EmailLog.created_at == sub.c.max_email_log_created_at,
# no email log yet for this contact
sub.c.max_email_log_created_at.is_(None),
)
)
)
if query:
q = q.filter(
or_(
Contact.website_email.ilike(f"%{query}%"),
Contact.name.ilike(f"%{query}%"),
)
)
if contact_id:
q = q.filter(Contact.id == contact_id)
latest_activity = case(
[
(EmailLog.created_at > Contact.created_at, EmailLog.created_at),
(EmailLog.created_at < Contact.created_at, Contact.created_at),
],
else_=Contact.created_at,
)
q = (
q.order_by(latest_activity.desc())
.limit(config.PAGE_LIMIT)
.offset(page * config.PAGE_LIMIT)
)
ret = []
for contact, latest_email_log, nb_reply, nb_forward in q:
contact_info = ContactInfo(
contact=contact,
nb_forward=nb_forward,
nb_reply=nb_reply,
latest_email_log=latest_email_log,
)
ret.append(contact_info)
return ret
def delete_contact(alias: Alias, contact_id: int):
contact = Contact.get(contact_id)
if not contact:
flash("Unknown error. Refresh the page", "warning")
elif contact.alias_id != alias.id:
flash("You cannot delete reverse-alias", "warning")
else:
delete_contact_email = contact.website_email
Contact.delete(contact_id)
Session.commit()
flash(f"Reverse-alias for {delete_contact_email} has been deleted", "success")
@dashboard_bp.route("/alias_contact_manager/<int:alias_id>/", methods=["GET", "POST"])
@login_required
@parallel_limiter.lock(name="contact_creation")
def alias_contact_manager(alias_id):
highlight_contact_id = None
if request.args.get("highlight_contact_id"):
try:
highlight_contact_id = int(request.args.get("highlight_contact_id"))
except ValueError:
flash("Invalid contact id", "error")
return redirect(url_for("dashboard.index"))
alias = Alias.get(alias_id)
page = 0
if request.args.get("page"):
page = int(request.args.get("page"))
query = request.args.get("query") or ""
# sanity check
if not alias:
flash("You do not have access to this page", "warning")
return redirect(url_for("dashboard.index"))
if alias.user_id != current_user.id:
flash("You do not have access to this page", "warning")
return redirect(url_for("dashboard.index"))
new_contact_form = NewContactForm()
csrf_form = CSRFValidationForm()
if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
if request.form.get("form-name") == "create":
if new_contact_form.validate():
contact_address = new_contact_form.email.data.strip()
try:
contact = create_contact(current_user, alias, contact_address)
except (
ErrContactErrorUpgradeNeeded,
ErrAddressInvalid,
ErrContactAlreadyExists,
CannotCreateContactForReverseAlias,
) as excp:
flash(excp.error_for_user(), "error")
return redirect(request.url)
flash(f"Reverse alias for {contact_address} is created", "success")
return redirect(
url_for(
"dashboard.alias_contact_manager",
alias_id=alias_id,
highlight_contact_id=contact.id,
)
)
elif request.form.get("form-name") == "delete":
contact_id = request.form.get("contact-id")
delete_contact(alias, contact_id)
return redirect(
url_for("dashboard.alias_contact_manager", alias_id=alias_id)
)
elif request.form.get("form-name") == "search":
query = request.form.get("query")
return redirect(
url_for(
"dashboard.alias_contact_manager",
alias_id=alias_id,
query=query,
highlight_contact_id=highlight_contact_id,
)
)
contact_infos = get_contact_infos(alias, page, query=query)
last_page = len(contact_infos) < config.PAGE_LIMIT
nb_contact = Contact.filter(Contact.alias_id == alias.id).count()
# if highlighted contact isn't included, fetch it
# make sure highlighted contact is at array start
contact_ids = [contact_info.contact.id for contact_info in contact_infos]
if highlight_contact_id and highlight_contact_id not in contact_ids:
contact_infos = (
get_contact_infos(alias, contact_id=highlight_contact_id, query=query)
+ contact_infos
)
return render_template(
"dashboard/alias_contact_manager.html",
contact_infos=contact_infos,
alias=alias,
new_contact_form=new_contact_form,
highlight_contact_id=highlight_contact_id,
page=page,
last_page=last_page,
query=query,
nb_contact=nb_contact,
can_create_contacts=user_can_create_contacts(current_user),
csrf_form=csrf_form,
)

View File

@ -0,0 +1,9 @@
from app.dashboard.base import dashboard_bp
from flask_login import login_required, current_user
from app.alias_utils import alias_export_csv
@dashboard_bp.route("/alias_export", methods=["GET"])
@login_required
def alias_export_route():
return alias_export_csv(current_user)

View File

@ -0,0 +1,92 @@
import arrow
from flask import render_template, flash, redirect, url_for
from flask_login import login_required, current_user
from app.config import PAGE_LIMIT
from app.dashboard.base import dashboard_bp
from app.db import Session
from app.models import Alias, EmailLog, Contact
class AliasLog:
website_email: str
reverse_alias: str
alias: str
when: arrow.Arrow
is_reply: bool
blocked: bool
bounced: bool
email_log: EmailLog
contact: Contact
def __init__(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
@dashboard_bp.route(
"/alias_log/<int:alias_id>", methods=["GET"], defaults={"page_id": 0}
)
@dashboard_bp.route("/alias_log/<int:alias_id>/<int:page_id>")
@login_required
def alias_log(alias_id, page_id):
alias = Alias.get(alias_id)
# sanity check
if not alias:
flash("You do not have access to this page", "warning")
return redirect(url_for("dashboard.index"))
if alias.user_id != current_user.id:
flash("You do not have access to this page", "warning")
return redirect(url_for("dashboard.index"))
logs = get_alias_log(alias, page_id)
base = (
Session.query(Contact, EmailLog)
.filter(Contact.id == EmailLog.contact_id)
.filter(Contact.alias_id == alias.id)
)
total = base.count()
email_forwarded = (
base.filter(EmailLog.is_reply.is_(False))
.filter(EmailLog.blocked.is_(False))
.count()
)
email_replied = base.filter(EmailLog.is_reply.is_(True)).count()
email_blocked = base.filter(EmailLog.blocked.is_(True)).count()
last_page = (
len(logs) < PAGE_LIMIT
) # lightweight pagination without counting all objects
return render_template("dashboard/alias_log.html", **locals())
def get_alias_log(alias: Alias, page_id=0) -> [AliasLog]:
logs: [AliasLog] = []
q = (
Session.query(Contact, EmailLog)
.filter(Contact.id == EmailLog.contact_id)
.filter(Contact.alias_id == alias.id)
.order_by(EmailLog.id.desc())
.limit(PAGE_LIMIT)
.offset(page_id * PAGE_LIMIT)
)
for contact, email_log in q:
al = AliasLog(
website_email=contact.website_email,
reverse_alias=contact.website_send_to(),
alias=alias.email,
when=email_log.created_at,
is_reply=email_log.is_reply,
blocked=email_log.blocked,
bounced=email_log.bounced,
email_log=email_log,
contact=contact,
)
logs.append(al)
logs = sorted(logs, key=lambda l: l.when, reverse=True)
return logs

View File

@ -0,0 +1,225 @@
import base64
import hmac
import secrets
import arrow
from flask import render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user
from app import config
from app.dashboard.base import dashboard_bp
from app.dashboard.views.enter_sudo import sudo_required
from app.db import Session
from app.email_utils import send_email, render
from app.extensions import limiter
from app.log import LOG
from app.models import (
Alias,
Contact,
AliasUsedOn,
AliasMailbox,
User,
ClientUser,
)
from app.models import Mailbox
from app.utils import CSRFValidationForm
def transfer(alias, new_user, new_mailboxes: [Mailbox]):
# cannot transfer alias which is used for receiving newsletter
if User.get_by(newsletter_alias_id=alias.id):
raise Exception("Cannot transfer alias that's used to receive newsletter")
# update user_id
Session.query(Contact).filter(Contact.alias_id == alias.id).update(
{"user_id": new_user.id}
)
Session.query(AliasUsedOn).filter(AliasUsedOn.alias_id == alias.id).update(
{"user_id": new_user.id}
)
Session.query(ClientUser).filter(ClientUser.alias_id == alias.id).update(
{"user_id": new_user.id}
)
# remove existing mailboxes from the alias
Session.query(AliasMailbox).filter(AliasMailbox.alias_id == alias.id).delete()
# set mailboxes
alias.mailbox_id = new_mailboxes.pop().id
for mb in new_mailboxes:
AliasMailbox.create(alias_id=alias.id, mailbox_id=mb.id)
# alias has never been transferred before
if not alias.original_owner_id:
alias.original_owner_id = alias.user_id
# inform previous owner
old_user = alias.user
send_email(
old_user.email,
f"Alias {alias.email} has been received",
render(
"transactional/alias-transferred.txt",
alias=alias,
),
render(
"transactional/alias-transferred.html",
alias=alias,
),
)
# now the alias belongs to the new user
alias.user_id = new_user.id
# set some fields back to default
alias.disable_pgp = False
alias.pinned = False
Session.commit()
def hmac_alias_transfer_token(transfer_token: str) -> str:
alias_hmac = hmac.new(
config.ALIAS_TRANSFER_TOKEN_SECRET.encode("utf-8"),
transfer_token.encode("utf-8"),
"sha3_224",
)
return base64.urlsafe_b64encode(alias_hmac.digest()).decode("utf-8").rstrip("=")
@dashboard_bp.route("/alias_transfer/send/<int:alias_id>/", methods=["GET", "POST"])
@login_required
@sudo_required
def alias_transfer_send_route(alias_id):
alias = Alias.get(alias_id)
if not alias or alias.user_id != current_user.id:
flash("You cannot see this page", "warning")
return redirect(url_for("dashboard.index"))
if current_user.newsletter_alias_id == alias.id:
flash(
"This alias is currently used for receiving the newsletter and cannot be transferred",
"error",
)
return redirect(url_for("dashboard.index"))
alias_transfer_url = None
csrf_form = CSRFValidationForm()
if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
# generate a new transfer_token
if request.form.get("form-name") == "create":
transfer_token = f"{alias.id}.{secrets.token_urlsafe(32)}"
alias.transfer_token = hmac_alias_transfer_token(transfer_token)
alias.transfer_token_expiration = arrow.utcnow().shift(hours=24)
Session.commit()
alias_transfer_url = (
config.URL
+ "/dashboard/alias_transfer/receive"
+ f"?token={transfer_token}"
)
flash("Share alias URL created", "success")
# request.form.get("form-name") == "remove"
else:
alias.transfer_token = None
alias.transfer_token_expiration = None
Session.commit()
alias_transfer_url = None
flash("Share URL deleted", "success")
return render_template(
"dashboard/alias_transfer_send.html",
alias=alias,
alias_transfer_url=alias_transfer_url,
link_active=alias.transfer_token_expiration is not None
and alias.transfer_token_expiration > arrow.utcnow(),
csrf_form=csrf_form,
)
@dashboard_bp.route("/alias_transfer/receive", methods=["GET", "POST"])
@limiter.limit("5/minute")
@login_required
def alias_transfer_receive_route():
"""
URL has ?alias_id=signed_alias_id
"""
token = request.args.get("token")
if not token:
flash("Invalid transfer token", "error")
return redirect(url_for("dashboard.index"))
hashed_token = hmac_alias_transfer_token(token)
# TODO: Don't allow unhashed tokens once all the tokens have been migrated to the new format
alias = Alias.get_by(transfer_token=token) or Alias.get_by(
transfer_token=hashed_token
)
if not alias:
flash("Invalid link", "error")
return redirect(url_for("dashboard.index"))
# TODO: Don't allow none once all the tokens have been migrated to the new format
if (
alias.transfer_token_expiration is not None
and alias.transfer_token_expiration < arrow.utcnow()
):
flash("Expired link, please request a new one", "error")
return redirect(url_for("dashboard.index"))
# alias already belongs to this user
if alias.user_id == current_user.id:
flash("You already own this alias", "warning")
return redirect(url_for("dashboard.index"))
# check if user has not exceeded the alias quota
if not current_user.can_create_new_alias():
LOG.d("%s can't receive new alias", current_user)
flash(
"You have reached free plan limit, please upgrade to create new aliases",
"warning",
)
return redirect(url_for("dashboard.index"))
mailboxes = current_user.mailboxes()
if request.method == "POST":
mailbox_ids = request.form.getlist("mailbox_ids")
# check if mailbox is not tempered with
mailboxes = []
for mailbox_id in mailbox_ids:
mailbox = Mailbox.get(mailbox_id)
if (
not mailbox
or mailbox.user_id != current_user.id
or not mailbox.verified
):
flash("Something went wrong, please retry", "warning")
return redirect(request.url)
mailboxes.append(mailbox)
if not mailboxes:
flash("You must select at least 1 mailbox", "warning")
return redirect(request.url)
LOG.d(
"transfer alias %s from %s to %s with %s with token %s",
alias,
alias.user,
current_user,
mailboxes,
token,
)
transfer(alias, current_user, mailboxes)
flash(f"You are now owner of {alias.email}", "success")
return redirect(url_for("dashboard.index", highlight_alias_id=alias.id))
return render_template(
"dashboard/alias_transfer_receive.html",
alias=alias,
mailboxes=mailboxes,
)

View File

@ -0,0 +1,66 @@
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import StringField, validators
from app.dashboard.base import dashboard_bp
from app.dashboard.views.enter_sudo import sudo_required
from app.db import Session
from app.models import ApiKey
class NewApiKeyForm(FlaskForm):
name = StringField("Name", validators=[validators.DataRequired()])
@dashboard_bp.route("/api_key", methods=["GET", "POST"])
@login_required
@sudo_required
def api_key():
api_keys = (
ApiKey.filter(ApiKey.user_id == current_user.id)
.order_by(ApiKey.created_at.desc())
.all()
)
new_api_key_form = NewApiKeyForm()
if request.method == "POST":
if request.form.get("form-name") == "delete":
api_key_id = request.form.get("api-key-id")
api_key = ApiKey.get(api_key_id)
if not api_key:
flash("Unknown error. Refresh the page", "warning")
return redirect(url_for("dashboard.api_key"))
elif api_key.user_id != current_user.id:
flash("You cannot delete this api key", "warning")
return redirect(url_for("dashboard.api_key"))
name = api_key.name
ApiKey.delete(api_key_id)
Session.commit()
flash(f"API Key {name} has been deleted", "success")
elif request.form.get("form-name") == "create":
if new_api_key_form.validate():
new_api_key = ApiKey.create(
name=new_api_key_form.name.data, user_id=current_user.id
)
Session.commit()
flash(f"New API Key {new_api_key.name} has been created", "success")
return render_template(
"dashboard/new_api_key.html", api_key=new_api_key
)
elif request.form.get("form-name") == "delete-all":
ApiKey.delete_all(current_user.id)
Session.commit()
flash("All API Keys have been deleted", "success")
return redirect(url_for("dashboard.api_key"))
return render_template(
"dashboard/api_key.html", api_keys=api_keys, new_api_key_form=new_api_key_form
)

View File

@ -0,0 +1,48 @@
from app.db import Session
"""
List of apps that user has used via the "Sign in with SimpleLogin"
"""
from flask import render_template, request, flash, redirect
from flask_login import login_required, current_user
from sqlalchemy.orm import joinedload
from app.dashboard.base import dashboard_bp
from app.models import (
ClientUser,
)
@dashboard_bp.route("/app", methods=["GET", "POST"])
@login_required
def app_route():
client_users = (
ClientUser.filter_by(user_id=current_user.id)
.options(joinedload(ClientUser.client))
.options(joinedload(ClientUser.alias))
.all()
)
sorted(client_users, key=lambda cu: cu.client.name)
if request.method == "POST":
client_user_id = request.form.get("client-user-id")
client_user = ClientUser.get(client_user_id)
if not client_user or client_user.user_id != current_user.id:
flash(
"Unknown error, sorry for the inconvenience, refresh the page", "error"
)
return redirect(request.url)
client = client_user.client
ClientUser.delete(client_user_id)
Session.commit()
flash(f"Link with {client.name} has been removed", "success")
return redirect(request.url)
return render_template(
"dashboard/app.html",
client_users=client_users,
)

View File

@ -0,0 +1,78 @@
import arrow
from flask import render_template, flash, request, redirect, url_for
from flask_login import login_required, current_user
from app import s3
from app.config import JOB_BATCH_IMPORT
from app.dashboard.base import dashboard_bp
from app.db import Session
from app.log import LOG
from app.models import File, BatchImport, Job
from app.utils import random_string, CSRFValidationForm
@dashboard_bp.route("/batch_import", methods=["GET", "POST"])
@login_required
def batch_import_route():
# only for users who have custom domains
if not current_user.verified_custom_domains():
flash("Alias batch import is only available for custom domains", "warning")
if current_user.disable_import:
flash(
"you cannot use the import feature, please contact SimpleLogin team",
"error",
)
return redirect(url_for("dashboard.index"))
batch_imports = BatchImport.filter_by(
user_id=current_user.id, processed=False
).all()
csrf_form = CSRFValidationForm()
if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
redirect(request.url)
if len(batch_imports) > 10:
flash(
"You have too many imports already. Wait until some get cleaned up",
"error",
)
return render_template(
"dashboard/batch_import.html",
batch_imports=batch_imports,
csrf_form=csrf_form,
)
alias_file = request.files["alias-file"]
file_path = random_string(20) + ".csv"
file = File.create(user_id=current_user.id, path=file_path)
s3.upload_from_bytesio(file_path, alias_file)
Session.flush()
LOG.d("upload file %s to s3 at %s", file, file_path)
bi = BatchImport.create(user_id=current_user.id, file_id=file.id)
Session.flush()
LOG.d("Add a batch import job %s for %s", bi, current_user)
# Schedule batch import job
Job.create(
name=JOB_BATCH_IMPORT,
payload={"batch_import_id": bi.id},
run_at=arrow.now(),
)
Session.commit()
flash(
"The file has been uploaded successfully and the import will start shortly",
"success",
)
return redirect(url_for("dashboard.batch_import_route"))
return render_template(
"dashboard/batch_import.html", batch_imports=batch_imports, csrf_form=csrf_form
)

View File

@ -0,0 +1,82 @@
from flask import render_template, flash, redirect, url_for, request
from flask_login import login_required, current_user
from app.config import PADDLE_MONTHLY_PRODUCT_ID, PADDLE_YEARLY_PRODUCT_ID
from app.dashboard.base import dashboard_bp
from app.db import Session
from app.log import LOG
from app.models import Subscription, PlanEnum
from app.paddle_utils import cancel_subscription, change_plan
@dashboard_bp.route("/billing", methods=["GET", "POST"])
@login_required
def billing():
# sanity check: make sure this page is only for user who has paddle subscription
sub: Subscription = current_user.get_paddle_subscription()
if not sub:
flash("You don't have any active subscription", "warning")
return redirect(url_for("dashboard.index"))
if request.method == "POST":
if request.form.get("form-name") == "cancel":
LOG.w(f"User {current_user} cancels their subscription")
success = cancel_subscription(sub.subscription_id)
if success:
sub.cancelled = True
Session.commit()
flash("Your subscription has been canceled successfully", "success")
else:
flash(
"Something went wrong, sorry for the inconvenience. Please retry. "
"We are already notified and will be on it asap",
"error",
)
return redirect(url_for("dashboard.billing"))
elif request.form.get("form-name") == "change-monthly":
LOG.d(f"User {current_user} changes to monthly plan")
success, msg = change_plan(
current_user, sub.subscription_id, PADDLE_MONTHLY_PRODUCT_ID
)
if success:
sub.plan = PlanEnum.monthly
Session.commit()
flash("Your subscription has been updated", "success")
else:
if msg:
flash(msg, "error")
else:
flash(
"Something went wrong, sorry for the inconvenience. Please retry. "
"We are already notified and will be on it asap",
"error",
)
return redirect(url_for("dashboard.billing"))
elif request.form.get("form-name") == "change-yearly":
LOG.d(f"User {current_user} changes to yearly plan")
success, msg = change_plan(
current_user, sub.subscription_id, PADDLE_YEARLY_PRODUCT_ID
)
if success:
sub.plan = PlanEnum.yearly
Session.commit()
flash("Your subscription has been updated", "success")
else:
if msg:
flash(msg, "error")
else:
flash(
"Something went wrong, sorry for the inconvenience. Please retry. "
"We are already notified and will be on it asap",
"error",
)
return redirect(url_for("dashboard.billing"))
return render_template("dashboard/billing.html", sub=sub, PlanEnum=PlanEnum)

View File

@ -0,0 +1,75 @@
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import StringField, validators
from app.dashboard.base import dashboard_bp
from app.db import Session
from app.models import Contact
from app.pgp_utils import PGPException, load_public_key_and_check
class PGPContactForm(FlaskForm):
action = StringField(
"action",
validators=[validators.DataRequired(), validators.AnyOf(("save", "remove"))],
)
pgp = StringField("pgp", validators=[validators.Optional()])
@dashboard_bp.route("/contact/<int:contact_id>/", methods=["GET", "POST"])
@login_required
def contact_detail_route(contact_id):
contact = Contact.get(contact_id)
if not contact or contact.user_id != current_user.id:
flash("You cannot see this page", "warning")
return redirect(url_for("dashboard.index"))
alias = contact.alias
pgp_form = PGPContactForm()
if request.method == "POST":
if request.form.get("form-name") == "pgp":
if not pgp_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
if pgp_form.action.data == "save":
if not current_user.is_premium():
flash("Only premium plan can add PGP Key", "warning")
return redirect(
url_for("dashboard.contact_detail_route", contact_id=contact_id)
)
if not pgp_form.pgp.data:
flash("Invalid pgp key")
else:
contact.pgp_public_key = pgp_form.pgp.data
try:
contact.pgp_finger_print = load_public_key_and_check(
contact.pgp_public_key
)
except PGPException:
flash("Cannot add the public key, please verify it", "error")
else:
Session.commit()
flash(
f"PGP public key for {contact.email} is saved successfully",
"success",
)
return redirect(
url_for(
"dashboard.contact_detail_route", contact_id=contact_id
)
)
elif pgp_form.action.data == "remove":
# Free user can decide to remove contact PGP key
contact.pgp_public_key = None
contact.pgp_finger_print = None
Session.commit()
flash(f"PGP public key for {contact.email} is removed", "success")
return redirect(
url_for("dashboard.contact_detail_route", contact_id=contact_id)
)
return render_template(
"dashboard/contact_detail.html", contact=contact, alias=alias, pgp_form=pgp_form
)

View File

@ -0,0 +1,116 @@
import arrow
from flask import render_template, flash, redirect, url_for, request
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 PADDLE_VENDOR_ID, PADDLE_COUPON_ID
from app.dashboard.base import dashboard_bp
from app.db import Session
from app.log import LOG
from app.models import (
ManualSubscription,
Coupon,
Subscription,
AppleSubscription,
CoinbaseSubscription,
LifetimeCoupon,
)
class CouponForm(FlaskForm):
code = StringField("Coupon Code", validators=[validators.DataRequired()])
@dashboard_bp.route("/coupon", methods=["GET", "POST"])
@login_required
@parallel_limiter.lock()
def coupon_route():
coupon_form = CouponForm()
if coupon_form.validate_on_submit():
code = coupon_form.code.data
if LifetimeCoupon.get_by(code=code):
LOG.d("redirect %s to lifetime page instead", current_user)
flash("Redirect to the lifetime coupon page instead", "success")
return redirect(url_for("dashboard.lifetime_licence"))
# handle case user already has an active subscription via another channel (Paddle, Apple, etc)
can_use_coupon = True
if current_user.lifetime:
can_use_coupon = False
sub: Subscription = current_user.get_paddle_subscription()
if sub:
can_use_coupon = False
apple_sub: AppleSubscription = AppleSubscription.get_by(user_id=current_user.id)
if apple_sub and apple_sub.is_valid():
can_use_coupon = False
coinbase_subscription: CoinbaseSubscription = CoinbaseSubscription.get_by(
user_id=current_user.id
)
if coinbase_subscription and coinbase_subscription.is_active():
can_use_coupon = False
if coupon_form.validate_on_submit():
code = coupon_form.code.data
coupon: Coupon = Coupon.get_by(code=code)
if coupon and not coupon.used:
if coupon.expires_date and coupon.expires_date < arrow.now():
flash(
f"The coupon was expired on {coupon.expires_date.humanize()}",
"error",
)
return redirect(request.url)
coupon.used_by_user_id = current_user.id
coupon.used = True
Session.commit()
manual_sub: ManualSubscription = ManualSubscription.get_by(
user_id=current_user.id
)
if manual_sub:
# renew existing subscription
if manual_sub.end_at > arrow.now():
manual_sub.end_at = manual_sub.end_at.shift(years=coupon.nb_year)
else:
manual_sub.end_at = arrow.now().shift(years=coupon.nb_year, days=1)
Session.commit()
flash(
f"Your current subscription is extended to {manual_sub.end_at.humanize()}",
"success",
)
else:
ManualSubscription.create(
user_id=current_user.id,
end_at=arrow.now().shift(years=coupon.nb_year, days=1),
comment="using coupon code",
is_giveaway=coupon.is_giveaway,
commit=True,
)
flash(
f"Your account has been upgraded to Premium, thanks for your support!",
"success",
)
return redirect(url_for("dashboard.index"))
else:
flash(f"Code *{code}* expired or invalid", "warning")
return render_template(
"dashboard/coupon.html",
coupon_form=coupon_form,
PADDLE_VENDOR_ID=PADDLE_VENDOR_ID,
PADDLE_COUPON_ID=PADDLE_COUPON_ID,
can_use_coupon=can_use_coupon,
# a coupon is only valid until this date
# this is to avoid using the coupon to renew an account forever
max_coupon_date=arrow.now().shift(years=1, days=-1),
)

View File

@ -0,0 +1,174 @@
from email_validator import validate_email, EmailNotValidError
from flask import render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user
from sqlalchemy.exc import IntegrityError
from app import parallel_limiter
from app.alias_suffix import (
get_alias_suffixes,
check_suffix_signature,
verify_prefix_suffix,
)
from app.alias_utils import check_alias_prefix
from app.config import (
ALIAS_LIMIT,
)
from app.dashboard.base import dashboard_bp
from app.db import Session
from app.extensions import limiter
from app.log import LOG
from app.models import (
Alias,
DeletedAlias,
Mailbox,
AliasMailbox,
DomainDeletedAlias,
)
@dashboard_bp.route("/custom_alias", methods=["GET", "POST"])
@limiter.limit(ALIAS_LIMIT, methods=["POST"])
@login_required
@parallel_limiter.lock(name="alias_creation")
def custom_alias():
# check if user has not exceeded the alias quota
if not current_user.can_create_new_alias():
LOG.d("%s can't create new alias", current_user)
flash(
"You have reached free plan limit, please upgrade to create new aliases",
"warning",
)
return redirect(url_for("dashboard.index"))
user_custom_domains = [cd.domain for cd in current_user.verified_custom_domains()]
alias_suffixes = get_alias_suffixes(current_user)
at_least_a_premium_domain = False
for alias_suffix in alias_suffixes:
if not alias_suffix.is_custom and alias_suffix.is_premium:
at_least_a_premium_domain = True
break
mailboxes = current_user.mailboxes()
if request.method == "POST":
alias_prefix = request.form.get("prefix").strip().lower().replace(" ", "")
signed_alias_suffix = request.form.get("signed-alias-suffix")
mailbox_ids = request.form.getlist("mailboxes")
alias_note = request.form.get("note")
if not check_alias_prefix(alias_prefix):
flash(
"Only lowercase letters, numbers, dashes (-), dots (.) and underscores (_) "
"are currently supported for alias prefix. Cannot be more than 40 letters",
"error",
)
return redirect(request.url)
# check if mailbox is not tempered with
mailboxes = []
for mailbox_id in mailbox_ids:
mailbox = Mailbox.get(mailbox_id)
if (
not mailbox
or mailbox.user_id != current_user.id
or not mailbox.verified
):
flash("Something went wrong, please retry", "warning")
return redirect(request.url)
mailboxes.append(mailbox)
if not mailboxes:
flash("At least one mailbox must be selected", "error")
return redirect(request.url)
try:
suffix = check_suffix_signature(signed_alias_suffix)
if not suffix:
LOG.w("Alias creation time expired for %s", current_user)
flash("Alias creation time is expired, please retry", "warning")
return redirect(request.url)
except Exception:
LOG.w("Alias suffix is tampered, user %s", current_user)
flash("Unknown error, refresh the page", "error")
return redirect(request.url)
if verify_prefix_suffix(current_user, alias_prefix, suffix):
full_alias = alias_prefix + suffix
if ".." in full_alias:
flash("Your alias can't contain 2 consecutive dots (..)", "error")
return redirect(request.url)
try:
validate_email(
full_alias, check_deliverability=False, allow_smtputf8=False
)
except EmailNotValidError as e:
flash(str(e), "error")
return redirect(request.url)
general_error_msg = f"{full_alias} cannot be used"
if Alias.get_by(email=full_alias):
alias = Alias.get_by(email=full_alias)
if alias.user_id == current_user.id:
flash(f"You already have this alias {full_alias}", "error")
else:
flash(general_error_msg, "error")
elif DomainDeletedAlias.get_by(email=full_alias):
domain_deleted_alias: DomainDeletedAlias = DomainDeletedAlias.get_by(
email=full_alias
)
custom_domain = domain_deleted_alias.domain
if domain_deleted_alias.user_id == current_user.id:
flash(
f"You have deleted this alias before. You can restore it on "
f"{custom_domain.domain} 'Deleted Alias' page",
"error",
)
else:
# should never happen as user can only choose their domains
LOG.e(
"Deleted Alias %s does not belong to user %s",
domain_deleted_alias,
)
elif DeletedAlias.get_by(email=full_alias):
flash(general_error_msg, "error")
else:
try:
alias = Alias.create(
user_id=current_user.id,
email=full_alias,
note=alias_note,
mailbox_id=mailboxes[0].id,
)
Session.flush()
except IntegrityError:
LOG.w("Alias %s already exists", full_alias)
Session.rollback()
flash("Unknown error, please retry", "error")
return redirect(url_for("dashboard.custom_alias"))
for i in range(1, len(mailboxes)):
AliasMailbox.create(
alias_id=alias.id,
mailbox_id=mailboxes[i].id,
)
Session.commit()
flash(f"Alias {full_alias} has been created", "success")
return redirect(url_for("dashboard.index", highlight_alias_id=alias.id))
# only happen if the request has been "hacked"
else:
flash("something went wrong", "warning")
return render_template(
"dashboard/custom_alias.html",
user_custom_domains=user_custom_domains,
alias_suffixes=alias_suffixes,
at_least_a_premium_domain=at_least_a_premium_domain,
mailboxes=mailboxes,
)

View File

@ -0,0 +1,121 @@
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import StringField, validators
from app.config import EMAIL_SERVERS_WITH_PRIORITY
from app.dashboard.base import dashboard_bp
from app.db import Session
from app.email_utils import get_email_domain_part
from app.log import LOG
from app.models import CustomDomain, Mailbox, DomainMailbox, SLDomain
class NewCustomDomainForm(FlaskForm):
domain = StringField(
"domain", validators=[validators.DataRequired(), validators.Length(max=128)]
)
@dashboard_bp.route("/custom_domain", methods=["GET", "POST"])
@login_required
def custom_domain():
custom_domains = CustomDomain.filter_by(
user_id=current_user.id, is_sl_subdomain=False
).all()
mailboxes = current_user.mailboxes()
new_custom_domain_form = NewCustomDomainForm()
errors = {}
if request.method == "POST":
if request.form.get("form-name") == "create":
if not current_user.is_premium():
flash("Only premium plan can add custom domain", "warning")
return redirect(url_for("dashboard.custom_domain"))
if new_custom_domain_form.validate():
new_domain = new_custom_domain_form.domain.data.lower().strip()
if new_domain.startswith("http://"):
new_domain = new_domain[len("http://") :]
if new_domain.startswith("https://"):
new_domain = new_domain[len("https://") :]
if SLDomain.get_by(domain=new_domain):
flash("A custom domain cannot be a built-in domain.", "error")
elif CustomDomain.get_by(domain=new_domain):
flash(f"{new_domain} already used", "error")
elif get_email_domain_part(current_user.email) == new_domain:
flash(
"You cannot add a domain that you are currently using for your personal email. "
"Please change your personal email to your real email",
"error",
)
elif Mailbox.filter(
Mailbox.verified.is_(True), Mailbox.email.endswith(f"@{new_domain}")
).first():
flash(
f"{new_domain} already used in a SimpleLogin mailbox", "error"
)
else:
new_custom_domain = CustomDomain.create(
domain=new_domain, user_id=current_user.id
)
# new domain has ownership verified if its parent has the ownership verified
for root_cd in current_user.custom_domains:
if (
new_domain.endswith("." + root_cd.domain)
and root_cd.ownership_verified
):
LOG.i(
"%s ownership verified thanks to %s",
new_custom_domain,
root_cd,
)
new_custom_domain.ownership_verified = True
Session.commit()
mailbox_ids = request.form.getlist("mailbox_ids")
if mailbox_ids:
# check if mailbox is not tempered with
mailboxes = []
for mailbox_id in mailbox_ids:
mailbox = Mailbox.get(mailbox_id)
if (
not mailbox
or mailbox.user_id != current_user.id
or not mailbox.verified
):
flash("Something went wrong, please retry", "warning")
return redirect(url_for("dashboard.custom_domain"))
mailboxes.append(mailbox)
for mailbox in mailboxes:
DomainMailbox.create(
domain_id=new_custom_domain.id, mailbox_id=mailbox.id
)
Session.commit()
flash(
f"New domain {new_custom_domain.domain} is created", "success"
)
return redirect(
url_for(
"dashboard.domain_detail_dns",
custom_domain_id=new_custom_domain.id,
)
)
return render_template(
"dashboard/custom_domain.html",
custom_domains=custom_domains,
new_custom_domain_form=new_custom_domain_form,
EMAIL_SERVERS_WITH_PRIORITY=EMAIL_SERVERS_WITH_PRIORITY,
errors=errors,
mailboxes=mailboxes,
)

View File

@ -0,0 +1,50 @@
import arrow
from flask import flash, redirect, url_for, request, render_template
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from app.config import JOB_DELETE_ACCOUNT
from app.dashboard.base import dashboard_bp
from app.dashboard.views.enter_sudo import sudo_required
from app.log import LOG
from app.models import Subscription, Job
class DeleteDirForm(FlaskForm):
pass
@dashboard_bp.route("/delete_account", methods=["GET", "POST"])
@login_required
@sudo_required
def delete_account():
delete_form = DeleteDirForm()
if request.method == "POST" and request.form.get("form-name") == "delete-account":
if not delete_form.validate():
flash("Invalid request", "warning")
return render_template(
"dashboard/delete_account.html", delete_form=delete_form
)
sub: Subscription = current_user.get_paddle_subscription()
# user who has canceled can also re-subscribe
if sub and not sub.cancelled:
flash("Please cancel your current subscription first", "warning")
return redirect(url_for("dashboard.setting"))
# Schedule delete account job
LOG.w("schedule delete account job for %s", current_user)
Job.create(
name=JOB_DELETE_ACCOUNT,
payload={"user_id": current_user.id},
run_at=arrow.now(),
commit=True,
)
flash(
"Your account deletion has been scheduled. "
"You'll receive an email when the deletion is finished",
"info",
)
return redirect(url_for("dashboard.setting"))
return render_template("dashboard/delete_account.html", delete_form=delete_form)

View File

@ -0,0 +1,227 @@
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import (
StringField,
validators,
SelectMultipleField,
BooleanField,
IntegerField,
)
from app.config import (
EMAIL_DOMAIN,
ALIAS_DOMAINS,
MAX_NB_DIRECTORY,
BOUNCE_PREFIX_FOR_REPLY_PHASE,
)
from app.dashboard.base import dashboard_bp
from app.db import Session
from app.errors import DirectoryInTrashError
from app.models import Directory, Mailbox, DirectoryMailbox
class NewDirForm(FlaskForm):
name = StringField(
"name", validators=[validators.DataRequired(), validators.Length(min=3)]
)
class ToggleDirForm(FlaskForm):
directory_id = IntegerField(validators=[validators.DataRequired()])
directory_enabled = BooleanField(validators=[])
class UpdateDirForm(FlaskForm):
directory_id = IntegerField(validators=[validators.DataRequired()])
mailbox_ids = SelectMultipleField(
validators=[validators.DataRequired()], validate_choice=False, choices=[]
)
class DeleteDirForm(FlaskForm):
directory_id = IntegerField(validators=[validators.DataRequired()])
@dashboard_bp.route("/directory", methods=["GET", "POST"])
@login_required
def directory():
dirs = (
Directory.filter_by(user_id=current_user.id)
.order_by(Directory.created_at.desc())
.all()
)
mailboxes = current_user.mailboxes()
new_dir_form = NewDirForm()
toggle_dir_form = ToggleDirForm()
update_dir_form = UpdateDirForm()
update_dir_form.mailbox_ids.choices = [
(str(mailbox.id), str(mailbox.id)) for mailbox in mailboxes
]
delete_dir_form = DeleteDirForm()
if request.method == "POST":
if request.form.get("form-name") == "delete":
if not delete_dir_form.validate():
flash(f"Invalid request", "warning")
return redirect(url_for("dashboard.directory"))
dir_obj = Directory.get(delete_dir_form.directory_id.data)
if not dir_obj:
flash("Unknown error. Refresh the page", "warning")
return redirect(url_for("dashboard.directory"))
elif dir_obj.user_id != current_user.id:
flash("You cannot delete this directory", "warning")
return redirect(url_for("dashboard.directory"))
name = dir_obj.name
Directory.delete(dir_obj.id)
Session.commit()
flash(f"Directory {name} has been deleted", "success")
return redirect(url_for("dashboard.directory"))
if request.form.get("form-name") == "toggle-directory":
if not toggle_dir_form.validate():
flash(f"Invalid request", "warning")
return redirect(url_for("dashboard.directory"))
dir_id = toggle_dir_form.directory_id.data
dir_obj = Directory.get(dir_id)
if not dir_obj or dir_obj.user_id != current_user.id:
flash("Unknown error. Refresh the page", "warning")
return redirect(url_for("dashboard.directory"))
if toggle_dir_form.directory_enabled.data:
dir_obj.disabled = False
flash(f"On-the-fly is enabled for {dir_obj.name}", "success")
else:
dir_obj.disabled = True
flash(f"On-the-fly is disabled for {dir_obj.name}", "warning")
Session.commit()
return redirect(url_for("dashboard.directory"))
elif request.form.get("form-name") == "update":
if not update_dir_form.validate():
flash(f"Invalid request", "warning")
return redirect(url_for("dashboard.directory"))
dir_id = update_dir_form.directory_id.data
dir_obj = Directory.get(dir_id)
if not dir_obj or dir_obj.user_id != current_user.id:
flash("Unknown error. Refresh the page", "warning")
return redirect(url_for("dashboard.directory"))
mailbox_ids = update_dir_form.mailbox_ids.data
# check if mailbox is not tempered with
mailboxes = []
for mailbox_id in mailbox_ids:
mailbox = Mailbox.get(mailbox_id)
if (
not mailbox
or mailbox.user_id != current_user.id
or not mailbox.verified
):
flash("Something went wrong, please retry", "warning")
return redirect(url_for("dashboard.directory"))
mailboxes.append(mailbox)
if not mailboxes:
flash("You must select at least 1 mailbox", "warning")
return redirect(url_for("dashboard.directory"))
# first remove all existing directory-mailboxes links
DirectoryMailbox.filter_by(directory_id=dir_obj.id).delete()
Session.flush()
for mailbox in mailboxes:
DirectoryMailbox.create(directory_id=dir_obj.id, mailbox_id=mailbox.id)
Session.commit()
flash(f"Directory {dir_obj.name} has been updated", "success")
return redirect(url_for("dashboard.directory"))
elif request.form.get("form-name") == "create":
if not current_user.is_premium():
flash("Only premium plan can add directory", "warning")
return redirect(url_for("dashboard.directory"))
if current_user.directory_quota <= 0:
flash(
f"You cannot have more than {MAX_NB_DIRECTORY} directories",
"warning",
)
return redirect(url_for("dashboard.directory"))
if new_dir_form.validate():
new_dir_name = new_dir_form.name.data.lower()
if Directory.get_by(name=new_dir_name):
flash(f"{new_dir_name} already used", "warning")
elif new_dir_name in (
"reply",
"ra",
"bounces",
"bounce",
"transactional",
BOUNCE_PREFIX_FOR_REPLY_PHASE,
):
flash(
"this directory name is reserved, please choose another name",
"warning",
)
else:
try:
new_dir = Directory.create(
name=new_dir_name, user_id=current_user.id
)
except DirectoryInTrashError:
flash(
f"{new_dir_name} has been used before and cannot be reused",
"error",
)
else:
Session.commit()
mailbox_ids = request.form.getlist("mailbox_ids")
if mailbox_ids:
# check if mailbox is not tempered with
mailboxes = []
for mailbox_id in mailbox_ids:
mailbox = Mailbox.get(mailbox_id)
if (
not mailbox
or mailbox.user_id != current_user.id
or not mailbox.verified
):
flash(
"Something went wrong, please retry", "warning"
)
return redirect(url_for("dashboard.directory"))
mailboxes.append(mailbox)
for mailbox in mailboxes:
DirectoryMailbox.create(
directory_id=new_dir.id, mailbox_id=mailbox.id
)
Session.commit()
flash(f"Directory {new_dir.name} is created", "success")
return redirect(url_for("dashboard.directory"))
return render_template(
"dashboard/directory.html",
dirs=dirs,
toggle_dir_form=toggle_dir_form,
update_dir_form=update_dir_form,
delete_dir_form=delete_dir_form,
new_dir_form=new_dir_form,
mailboxes=mailboxes,
EMAIL_DOMAIN=EMAIL_DOMAIN,
ALIAS_DOMAINS=ALIAS_DOMAINS,
)

View File

@ -0,0 +1,528 @@
import re
import arrow
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import StringField, validators, IntegerField
from app.config import EMAIL_SERVERS_WITH_PRIORITY, EMAIL_DOMAIN, JOB_DELETE_DOMAIN
from app.custom_domain_validation import CustomDomainValidation
from app.dashboard.base import dashboard_bp
from app.db import Session
from app.dns_utils import (
get_mx_domains,
get_spf_domain,
get_txt_record,
is_mx_equivalent,
)
from app.log import LOG
from app.models import (
CustomDomain,
Alias,
DomainDeletedAlias,
Mailbox,
DomainMailbox,
AutoCreateRule,
AutoCreateRuleMailbox,
Job,
)
from app.regex_utils import regex_match
from app.utils import random_string, CSRFValidationForm
@dashboard_bp.route("/domains/<int:custom_domain_id>/dns", methods=["GET", "POST"])
@login_required
def domain_detail_dns(custom_domain_id):
custom_domain: CustomDomain = CustomDomain.get(custom_domain_id)
if not custom_domain or custom_domain.user_id != current_user.id:
flash("You cannot see this page", "warning")
return redirect(url_for("dashboard.index"))
# generate a domain ownership txt token if needed
if not custom_domain.ownership_verified and not custom_domain.ownership_txt_token:
custom_domain.ownership_txt_token = random_string(30)
Session.commit()
spf_record = f"v=spf1 include:{EMAIL_DOMAIN} ~all"
domain_validator = CustomDomainValidation(EMAIL_DOMAIN)
csrf_form = CSRFValidationForm()
dmarc_record = "v=DMARC1; p=quarantine; pct=100; adkim=s; aspf=s"
mx_ok = spf_ok = dkim_ok = dmarc_ok = ownership_ok = True
mx_errors = spf_errors = dkim_errors = dmarc_errors = ownership_errors = []
if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
if request.form.get("form-name") == "check-ownership":
txt_records = get_txt_record(custom_domain.domain)
if custom_domain.get_ownership_dns_txt_value() in txt_records:
flash(
"Domain ownership is verified. Please proceed to the other records setup",
"success",
)
custom_domain.ownership_verified = True
Session.commit()
return redirect(
url_for(
"dashboard.domain_detail_dns",
custom_domain_id=custom_domain.id,
_anchor="dns-setup",
)
)
else:
flash("We can't find the needed TXT record", "error")
ownership_ok = False
ownership_errors = txt_records
elif request.form.get("form-name") == "check-mx":
mx_domains = get_mx_domains(custom_domain.domain)
if not is_mx_equivalent(mx_domains, EMAIL_SERVERS_WITH_PRIORITY):
flash("The MX record is not correctly set", "warning")
mx_ok = False
# build mx_errors to show to user
mx_errors = [
f"{priority} {domain}" for (priority, domain) in mx_domains
]
else:
flash(
"Your domain can start receiving emails. You can now use it to create alias",
"success",
)
custom_domain.verified = True
Session.commit()
return redirect(
url_for(
"dashboard.domain_detail_dns", custom_domain_id=custom_domain.id
)
)
elif request.form.get("form-name") == "check-spf":
spf_domains = get_spf_domain(custom_domain.domain)
if EMAIL_DOMAIN in spf_domains:
custom_domain.spf_verified = True
Session.commit()
flash("SPF is setup correctly", "success")
return redirect(
url_for(
"dashboard.domain_detail_dns", custom_domain_id=custom_domain.id
)
)
else:
custom_domain.spf_verified = False
Session.commit()
flash(
f"SPF: {EMAIL_DOMAIN} is not included in your SPF record.",
"warning",
)
spf_ok = False
spf_errors = get_txt_record(custom_domain.domain)
elif request.form.get("form-name") == "check-dkim":
dkim_errors = domain_validator.validate_dkim_records(custom_domain)
if len(dkim_errors) == 0:
flash("DKIM is setup correctly.", "success")
return redirect(
url_for(
"dashboard.domain_detail_dns", custom_domain_id=custom_domain.id
)
)
else:
dkim_ok = False
flash("DKIM: the CNAME record is not correctly set", "warning")
elif request.form.get("form-name") == "check-dmarc":
txt_records = get_txt_record("_dmarc." + custom_domain.domain)
if dmarc_record in txt_records:
custom_domain.dmarc_verified = True
Session.commit()
flash("DMARC is setup correctly", "success")
return redirect(
url_for(
"dashboard.domain_detail_dns", custom_domain_id=custom_domain.id
)
)
else:
custom_domain.dmarc_verified = False
Session.commit()
flash(
"DMARC: The TXT record is not correctly set",
"warning",
)
dmarc_ok = False
dmarc_errors = txt_records
return render_template(
"dashboard/domain_detail/dns.html",
EMAIL_SERVERS_WITH_PRIORITY=EMAIL_SERVERS_WITH_PRIORITY,
dkim_records=domain_validator.get_dkim_records(),
**locals(),
)
@dashboard_bp.route("/domains/<int:custom_domain_id>/info", methods=["GET", "POST"])
@login_required
def domain_detail(custom_domain_id):
csrf_form = CSRFValidationForm()
custom_domain: CustomDomain = CustomDomain.get(custom_domain_id)
mailboxes = current_user.mailboxes()
if not custom_domain or custom_domain.user_id != current_user.id:
flash("You cannot see this page", "warning")
return redirect(url_for("dashboard.index"))
if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
if request.form.get("form-name") == "switch-catch-all":
custom_domain.catch_all = not custom_domain.catch_all
Session.commit()
if custom_domain.catch_all:
flash(
f"The catch-all has been enabled for {custom_domain.domain}",
"success",
)
else:
flash(
f"The catch-all has been disabled for {custom_domain.domain}",
"warning",
)
return redirect(
url_for("dashboard.domain_detail", custom_domain_id=custom_domain.id)
)
elif request.form.get("form-name") == "set-name":
if request.form.get("action") == "save":
custom_domain.name = request.form.get("alias-name").replace("\n", "")
Session.commit()
flash(
f"Default alias name for Domain {custom_domain.domain} has been set",
"success",
)
else:
custom_domain.name = None
Session.commit()
flash(
f"Default alias name for Domain {custom_domain.domain} has been removed",
"info",
)
return redirect(
url_for("dashboard.domain_detail", custom_domain_id=custom_domain.id)
)
elif request.form.get("form-name") == "switch-random-prefix-generation":
custom_domain.random_prefix_generation = (
not custom_domain.random_prefix_generation
)
Session.commit()
if custom_domain.random_prefix_generation:
flash(
f"Random prefix generation has been enabled for {custom_domain.domain}",
"success",
)
else:
flash(
f"Random prefix generation has been disabled for {custom_domain.domain}",
"warning",
)
return redirect(
url_for("dashboard.domain_detail", custom_domain_id=custom_domain.id)
)
elif request.form.get("form-name") == "update":
mailbox_ids = request.form.getlist("mailbox_ids")
# check if mailbox is not tempered with
mailboxes = []
for mailbox_id in mailbox_ids:
mailbox = Mailbox.get(mailbox_id)
if (
not mailbox
or mailbox.user_id != current_user.id
or not mailbox.verified
):
flash("Something went wrong, please retry", "warning")
return redirect(
url_for(
"dashboard.domain_detail", custom_domain_id=custom_domain.id
)
)
mailboxes.append(mailbox)
if not mailboxes:
flash("You must select at least 1 mailbox", "warning")
return redirect(
url_for(
"dashboard.domain_detail", custom_domain_id=custom_domain.id
)
)
# first remove all existing domain-mailboxes links
DomainMailbox.filter_by(domain_id=custom_domain.id).delete()
Session.flush()
for mailbox in mailboxes:
DomainMailbox.create(domain_id=custom_domain.id, mailbox_id=mailbox.id)
Session.commit()
flash(f"{custom_domain.domain} mailboxes has been updated", "success")
return redirect(
url_for("dashboard.domain_detail", custom_domain_id=custom_domain.id)
)
elif request.form.get("form-name") == "delete":
name = custom_domain.domain
LOG.d("Schedule deleting %s", custom_domain)
# Schedule delete domain job
LOG.w("schedule delete domain job for %s", custom_domain)
Job.create(
name=JOB_DELETE_DOMAIN,
payload={"custom_domain_id": custom_domain.id},
run_at=arrow.now(),
commit=True,
)
flash(
f"{name} scheduled for deletion."
f"You will receive a confirmation email when the deletion is finished",
"success",
)
if custom_domain.is_sl_subdomain:
return redirect(url_for("dashboard.subdomain_route"))
else:
return redirect(url_for("dashboard.custom_domain"))
nb_alias = Alias.filter_by(custom_domain_id=custom_domain.id).count()
return render_template("dashboard/domain_detail/info.html", **locals())
@dashboard_bp.route("/domains/<int:custom_domain_id>/trash", methods=["GET", "POST"])
@login_required
def domain_detail_trash(custom_domain_id):
csrf_form = CSRFValidationForm()
custom_domain = CustomDomain.get(custom_domain_id)
if not custom_domain or custom_domain.user_id != current_user.id:
flash("You cannot see this page", "warning")
return redirect(url_for("dashboard.index"))
if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
if request.form.get("form-name") == "empty-all":
DomainDeletedAlias.filter_by(domain_id=custom_domain.id).delete()
Session.commit()
flash("All deleted aliases can now be re-created", "success")
return redirect(
url_for(
"dashboard.domain_detail_trash", custom_domain_id=custom_domain.id
)
)
elif request.form.get("form-name") == "remove-single":
deleted_alias_id = request.form.get("deleted-alias-id")
deleted_alias = DomainDeletedAlias.get(deleted_alias_id)
if not deleted_alias or deleted_alias.domain_id != custom_domain.id:
flash("Unknown error, refresh the page", "warning")
return redirect(
url_for(
"dashboard.domain_detail_trash",
custom_domain_id=custom_domain.id,
)
)
DomainDeletedAlias.delete(deleted_alias.id)
Session.commit()
flash(
f"{deleted_alias.email} can now be re-created",
"success",
)
return redirect(
url_for(
"dashboard.domain_detail_trash", custom_domain_id=custom_domain.id
)
)
domain_deleted_aliases = DomainDeletedAlias.filter_by(
domain_id=custom_domain.id
).all()
return render_template(
"dashboard/domain_detail/trash.html",
domain_deleted_aliases=domain_deleted_aliases,
custom_domain=custom_domain,
csrf_form=csrf_form,
)
class AutoCreateRuleForm(FlaskForm):
regex = StringField(
"regex", validators=[validators.DataRequired(), validators.Length(max=128)]
)
order = IntegerField(
"order",
validators=[validators.DataRequired(), validators.NumberRange(min=0, max=100)],
)
class AutoCreateTestForm(FlaskForm):
local = StringField(
"local part", validators=[validators.DataRequired(), validators.Length(max=128)]
)
@dashboard_bp.route(
"/domains/<int:custom_domain_id>/auto-create", methods=["GET", "POST"]
)
@login_required
def domain_detail_auto_create(custom_domain_id):
custom_domain: CustomDomain = CustomDomain.get(custom_domain_id)
mailboxes = current_user.mailboxes()
new_auto_create_rule_form = AutoCreateRuleForm()
auto_create_test_form = AutoCreateTestForm()
auto_create_test_local, auto_create_test_result, auto_create_test_passed = (
"",
"",
False,
)
if not custom_domain or custom_domain.user_id != current_user.id:
flash("You cannot see this page", "warning")
return redirect(url_for("dashboard.index"))
if request.method == "POST":
if request.form.get("form-name") == "create-auto-create-rule":
if new_auto_create_rule_form.validate():
# make sure order isn't used before
for auto_create_rule in custom_domain.auto_create_rules:
auto_create_rule: AutoCreateRule
if auto_create_rule.order == int(
new_auto_create_rule_form.order.data
):
flash(
"Another rule with the same order already exists", "error"
)
break
else:
mailbox_ids = request.form.getlist("mailbox_ids")
# check if mailbox is not tempered with
mailboxes = []
for mailbox_id in mailbox_ids:
mailbox = Mailbox.get(mailbox_id)
if (
not mailbox
or mailbox.user_id != current_user.id
or not mailbox.verified
):
flash("Something went wrong, please retry", "warning")
return redirect(
url_for(
"dashboard.domain_detail_auto_create",
custom_domain_id=custom_domain.id,
)
)
mailboxes.append(mailbox)
if not mailboxes:
flash("You must select at least 1 mailbox", "warning")
return redirect(
url_for(
"dashboard.domain_detail_auto_create",
custom_domain_id=custom_domain.id,
)
)
try:
re.compile(new_auto_create_rule_form.regex.data)
except Exception:
flash(
f"Invalid regex {new_auto_create_rule_form.regex.data}",
"error",
)
return redirect(
url_for(
"dashboard.domain_detail_auto_create",
custom_domain_id=custom_domain.id,
)
)
rule = AutoCreateRule.create(
custom_domain_id=custom_domain.id,
order=int(new_auto_create_rule_form.order.data),
regex=new_auto_create_rule_form.regex.data,
flush=True,
)
for mailbox in mailboxes:
AutoCreateRuleMailbox.create(
auto_create_rule_id=rule.id, mailbox_id=mailbox.id
)
Session.commit()
flash("New auto create rule has been created", "success")
return redirect(
url_for(
"dashboard.domain_detail_auto_create",
custom_domain_id=custom_domain.id,
)
)
elif request.form.get("form-name") == "delete-auto-create-rule":
rule_id = request.form.get("rule-id")
rule: AutoCreateRule = AutoCreateRule.get(int(rule_id))
if not rule or rule.custom_domain_id != custom_domain.id:
flash("Something wrong, please retry", "error")
return redirect(
url_for(
"dashboard.domain_detail_auto_create",
custom_domain_id=custom_domain.id,
)
)
rule_order = rule.order
AutoCreateRule.delete(rule_id)
Session.commit()
flash(f"Rule #{rule_order} has been deleted", "success")
return redirect(
url_for(
"dashboard.domain_detail_auto_create",
custom_domain_id=custom_domain.id,
)
)
elif request.form.get("form-name") == "test-auto-create-rule":
if auto_create_test_form.validate():
local = auto_create_test_form.local.data
auto_create_test_local = local
for rule in custom_domain.auto_create_rules:
if regex_match(rule.regex, local):
auto_create_test_result = (
f"{local}@{custom_domain.domain} passes rule #{rule.order}"
)
auto_create_test_passed = True
break
else: # no rule passes
auto_create_test_result = (
f"{local}@{custom_domain.domain} doesn't pass any rule"
)
return render_template(
"dashboard/domain_detail/auto-create.html", **locals()
)
return render_template("dashboard/domain_detail/auto-create.html", **locals())

View File

@ -0,0 +1,70 @@
from functools import wraps
from time import time
from flask import render_template, flash, redirect, url_for, session, request
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import PasswordField, validators
from app.config import CONNECT_WITH_PROTON
from app.dashboard.base import dashboard_bp
from app.log import LOG
from app.models import PartnerUser
from app.proton.utils import get_proton_partner
from app.utils import sanitize_next_url
_SUDO_GAP = 900
class LoginForm(FlaskForm):
password = PasswordField("Password", validators=[validators.DataRequired()])
@dashboard_bp.route("/enter_sudo", methods=["GET", "POST"])
@login_required
def enter_sudo():
password_check_form = LoginForm()
if password_check_form.validate_on_submit():
password = password_check_form.password.data
if current_user.check_password(password):
session["sudo_time"] = int(time())
# User comes to sudo page from another page
next_url = sanitize_next_url(request.args.get("next"))
if next_url:
LOG.d("redirect user to %s", next_url)
return redirect(next_url)
else:
LOG.d("redirect user to dashboard")
return redirect(url_for("dashboard.index"))
else:
flash("Incorrect password", "warning")
proton_enabled = CONNECT_WITH_PROTON
if proton_enabled:
# Only for users that have the account linked
partner_user = PartnerUser.get_by(user_id=current_user.id)
if not partner_user or partner_user.partner_id != get_proton_partner().id:
proton_enabled = False
return render_template(
"dashboard/enter_sudo.html",
password_check_form=password_check_form,
next=request.args.get("next"),
connect_with_proton=proton_enabled,
)
def sudo_required(f):
@wraps(f)
def wrap(*args, **kwargs):
if (
"sudo_time" not in session
or (time() - int(session["sudo_time"])) > _SUDO_GAP
):
return redirect(url_for("dashboard.enter_sudo", next=request.path))
return f(*args, **kwargs)
return wrap

View File

@ -0,0 +1,59 @@
from flask import render_template, flash, redirect, url_for
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import HiddenField, validators
from app.dashboard.base import dashboard_bp
from app.dashboard.views.enter_sudo import sudo_required
from app.db import Session
from app.log import LOG
from app.models import RecoveryCode, Fido
class FidoManageForm(FlaskForm):
credential_id = HiddenField("credential_id", validators=[validators.DataRequired()])
@dashboard_bp.route("/fido_manage", methods=["GET", "POST"])
@login_required
@sudo_required
def fido_manage():
if not current_user.fido_enabled():
flash("You haven't registered a security key", "warning")
return redirect(url_for("dashboard.index"))
fido_manage_form = FidoManageForm()
if fido_manage_form.validate_on_submit():
credential_id = fido_manage_form.credential_id.data
fido_key = Fido.get_by(uuid=current_user.fido_uuid, credential_id=credential_id)
if not fido_key:
flash("Unknown error, redirect back to manage page", "warning")
return redirect(url_for("dashboard.fido_manage"))
Fido.delete(fido_key.id)
Session.commit()
LOG.d(f"FIDO Key ID={fido_key.id} Removed")
flash(f"Key {fido_key.name} successfully unlinked", "success")
# Disable FIDO for the user if all keys have been deleted
if not Fido.filter_by(uuid=current_user.fido_uuid).all():
current_user.fido_uuid = None
Session.commit()
# user does not have any 2FA enabled left, delete all recovery codes
if not current_user.two_factor_authentication_enabled():
RecoveryCode.empty(current_user)
return redirect(url_for("dashboard.index"))
return redirect(url_for("dashboard.fido_manage"))
return render_template(
"dashboard/fido_manage.html",
fido_manage_form=fido_manage_form,
keys=Fido.filter_by(uuid=current_user.fido_uuid),
)

View File

@ -0,0 +1,126 @@
import json
import secrets
import uuid
import webauthn
from flask import render_template, flash, redirect, url_for, session
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import StringField, HiddenField, validators
from app.config import RP_ID, URL
from app.dashboard.base import dashboard_bp
from app.dashboard.views.enter_sudo import sudo_required
from app.db import Session
from app.log import LOG
from app.models import Fido, RecoveryCode
class FidoTokenForm(FlaskForm):
key_name = StringField("key_name", validators=[validators.DataRequired()])
sk_assertion = HiddenField("sk_assertion", validators=[validators.DataRequired()])
@dashboard_bp.route("/fido_setup", methods=["GET", "POST"])
@login_required
@sudo_required
def fido_setup():
if current_user.fido_uuid is not None:
fidos = Fido.filter_by(uuid=current_user.fido_uuid).all()
else:
fidos = []
fido_token_form = FidoTokenForm()
# Handling POST requests
if fido_token_form.validate_on_submit():
try:
sk_assertion = json.loads(fido_token_form.sk_assertion.data)
except Exception:
flash("Key registration failed. Error: Invalid Payload", "warning")
return redirect(url_for("dashboard.index"))
fido_uuid = session["fido_uuid"]
challenge = session["fido_challenge"]
fido_reg_response = webauthn.WebAuthnRegistrationResponse(
RP_ID,
URL,
sk_assertion,
challenge,
trusted_attestation_cert_required=False,
none_attestation_permitted=True,
)
try:
fido_credential = fido_reg_response.verify()
except Exception as e:
LOG.w(f"An error occurred in WebAuthn registration process: {e}")
flash("Key registration failed.", "warning")
return redirect(url_for("dashboard.index"))
if current_user.fido_uuid is None:
current_user.fido_uuid = fido_uuid
Session.flush()
Fido.create(
credential_id=str(fido_credential.credential_id, "utf-8"),
uuid=fido_uuid,
public_key=str(fido_credential.public_key, "utf-8"),
sign_count=fido_credential.sign_count,
name=fido_token_form.key_name.data,
user_id=current_user.id,
)
Session.commit()
LOG.d(
f"credential_id={str(fido_credential.credential_id, 'utf-8')} added for {fido_uuid}"
)
flash("Security key has been activated", "success")
recovery_codes = RecoveryCode.generate(current_user)
return render_template(
"dashboard/recovery_code.html", recovery_codes=recovery_codes
)
# Prepare information for key registration process
fido_uuid = (
str(uuid.uuid4()) if current_user.fido_uuid is None else current_user.fido_uuid
)
challenge = secrets.token_urlsafe(32)
credential_create_options = webauthn.WebAuthnMakeCredentialOptions(
challenge,
"SimpleLogin",
RP_ID,
fido_uuid,
current_user.email,
current_user.name if current_user.name else current_user.email,
False,
attestation="none",
user_verification="discouraged",
)
# Don't think this one should be used, but it's not configurable by arguments
# https://www.w3.org/TR/webauthn/#sctn-location-extension
registration_dict = credential_create_options.registration_dict
del registration_dict["extensions"]["webauthn.loc"]
# Prevent user from adding duplicated keys
for fido in fidos:
registration_dict["excludeCredentials"].append(
{
"type": "public-key",
"id": fido.credential_id,
"transports": ["usb", "nfc", "ble", "internal"],
}
)
session["fido_uuid"] = fido_uuid
session["fido_challenge"] = challenge.rstrip("=")
return render_template(
"dashboard/fido_setup.html",
fido_token_form=fido_token_form,
credential_create_options=registration_dict,
)

View File

@ -0,0 +1,243 @@
from dataclasses import dataclass
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from app import alias_utils, parallel_limiter
from app.api.serializer import get_alias_infos_with_pagination_v3, get_alias_info_v3
from app.config import ALIAS_LIMIT, PAGE_LIMIT
from app.dashboard.base import dashboard_bp
from app.db import Session
from app.extensions import limiter
from app.log import LOG
from app.models import (
Alias,
AliasGeneratorEnum,
User,
EmailLog,
Contact,
)
from app.utils import CSRFValidationForm
@dataclass
class Stats:
nb_alias: int
nb_forward: int
nb_reply: int
nb_block: int
def get_stats(user: User) -> Stats:
nb_alias = Alias.filter_by(user_id=user.id).count()
nb_forward = (
Session.query(EmailLog)
.filter_by(user_id=user.id, is_reply=False, blocked=False, bounced=False)
.count()
)
nb_reply = (
Session.query(EmailLog)
.filter_by(user_id=user.id, is_reply=True, blocked=False, bounced=False)
.count()
)
nb_block = (
Session.query(EmailLog)
.filter_by(user_id=user.id, is_reply=False, blocked=True, bounced=False)
.count()
)
return Stats(
nb_alias=nb_alias, nb_forward=nb_forward, nb_reply=nb_reply, nb_block=nb_block
)
@dashboard_bp.route("/", methods=["GET", "POST"])
@limiter.limit(
ALIAS_LIMIT,
methods=["POST"],
exempt_when=lambda: request.form.get("form-name") != "create-random-email",
)
@login_required
@parallel_limiter.lock(
name="alias_creation",
only_when=lambda: request.form.get("form-name") == "create-random-email",
)
def index():
query = request.args.get("query") or ""
sort = request.args.get("sort") or ""
alias_filter = request.args.get("filter") or ""
page = 0
if request.args.get("page"):
page = int(request.args.get("page"))
highlight_alias_id = None
if request.args.get("highlight_alias_id"):
try:
highlight_alias_id = int(request.args.get("highlight_alias_id"))
except ValueError:
LOG.w(
"highlight_alias_id must be a number, received %s",
request.args.get("highlight_alias_id"),
)
csrf_form = CSRFValidationForm()
if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
if request.form.get("form-name") == "create-custom-email":
if current_user.can_create_new_alias():
return redirect(url_for("dashboard.custom_alias"))
else:
flash("You need to upgrade your plan to create new alias.", "warning")
elif request.form.get("form-name") == "create-random-email":
if current_user.can_create_new_alias():
scheme = int(
request.form.get("generator_scheme") or current_user.alias_generator
)
if not scheme or not AliasGeneratorEnum.has_value(scheme):
scheme = current_user.alias_generator
alias = Alias.create_new_random(user=current_user, scheme=scheme)
alias.mailbox_id = current_user.default_mailbox_id
Session.commit()
LOG.d("create new random alias %s for user %s", alias, current_user)
flash(f"Alias {alias.email} has been created", "success")
return redirect(
url_for(
"dashboard.index",
highlight_alias_id=alias.id,
query=query,
sort=sort,
filter=alias_filter,
)
)
else:
flash("You need to upgrade your plan to create new alias.", "warning")
elif request.form.get("form-name") in ("delete-alias", "disable-alias"):
try:
alias_id = int(request.form.get("alias-id"))
except ValueError:
flash("unknown error", "error")
return redirect(request.url)
alias: Alias = Alias.get(alias_id)
if not alias or alias.user_id != current_user.id:
flash("Unknown error, sorry for the inconvenience", "error")
return redirect(
url_for(
"dashboard.index",
query=query,
sort=sort,
filter=alias_filter,
)
)
if request.form.get("form-name") == "delete-alias":
LOG.d("delete alias %s", alias)
email = alias.email
alias_utils.delete_alias(alias, current_user)
flash(f"Alias {email} has been deleted", "success")
elif request.form.get("form-name") == "disable-alias":
alias.enabled = False
Session.commit()
flash(f"Alias {alias.email} has been disabled", "success")
return redirect(
url_for("dashboard.index", query=query, sort=sort, filter=alias_filter)
)
mailboxes = current_user.mailboxes()
show_intro = False
if not current_user.intro_shown:
LOG.d("Show intro to %s", current_user)
show_intro = True
# to make sure not showing intro to user again
current_user.intro_shown = True
Session.commit()
stats = get_stats(current_user)
mailbox_id = None
if alias_filter and alias_filter.startswith("mailbox:"):
mailbox_id = int(alias_filter[len("mailbox:") :])
directory_id = None
if alias_filter and alias_filter.startswith("directory:"):
directory_id = int(alias_filter[len("directory:") :])
alias_infos = get_alias_infos_with_pagination_v3(
current_user,
page,
query,
sort,
alias_filter,
mailbox_id,
directory_id,
# load 1 alias more to know whether this is the last page
page_limit=PAGE_LIMIT + 1,
)
last_page = len(alias_infos) <= PAGE_LIMIT
# remove the last alias that's added to know whether this is the last page
alias_infos = alias_infos[:PAGE_LIMIT]
# add highlighted alias in case it's not included
if highlight_alias_id and highlight_alias_id not in [
alias_info.alias.id for alias_info in alias_infos
]:
highlight_alias_info = get_alias_info_v3(
current_user, alias_id=highlight_alias_id
)
if highlight_alias_info:
alias_infos.insert(0, highlight_alias_info)
return render_template(
"dashboard/index.html",
alias_infos=alias_infos,
highlight_alias_id=highlight_alias_id,
query=query,
AliasGeneratorEnum=AliasGeneratorEnum,
mailboxes=mailboxes,
show_intro=show_intro,
page=page,
last_page=last_page,
sort=sort,
filter=alias_filter,
stats=stats,
csrf_form=csrf_form,
)
@dashboard_bp.route("/contacts/<int:contact_id>/toggle", methods=["POST"])
@login_required
def toggle_contact(contact_id):
"""
Block/Unblock contact
"""
contact = Contact.get(contact_id)
if not contact or contact.alias.user_id != current_user.id:
return "Forbidden", 403
contact.block_forward = not contact.block_forward
Session.commit()
if contact.block_forward:
toast_msg = f"{contact.website_email} can no longer send emails to {contact.alias.email}"
else:
toast_msg = (
f"{contact.website_email} can now send emails to {contact.alias.email}"
)
return render_template(
"partials/toggle_contact.html", contact=contact, toast_msg=toast_msg
)

View File

@ -0,0 +1,59 @@
from flask import render_template, flash, redirect, url_for
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import StringField, validators
from app.config import ADMIN_EMAIL
from app.dashboard.base import dashboard_bp
from app.db import Session
from app.email_utils import send_email
from app.models import LifetimeCoupon
class CouponForm(FlaskForm):
code = StringField("Coupon Code", validators=[validators.DataRequired()])
@dashboard_bp.route("/lifetime_licence", methods=["GET", "POST"])
@login_required
def lifetime_licence():
if current_user.lifetime:
flash("You already have a lifetime licence", "warning")
return redirect(url_for("dashboard.index"))
# user needs to cancel active subscription first
# to avoid being charged
sub = current_user.get_paddle_subscription()
if sub and not sub.cancelled:
flash("Please cancel your current subscription first", "warning")
return redirect(url_for("dashboard.index"))
coupon_form = CouponForm()
if coupon_form.validate_on_submit():
code = coupon_form.code.data
coupon: LifetimeCoupon = LifetimeCoupon.get_by(code=code)
if coupon and coupon.nb_used > 0:
coupon.nb_used -= 1
current_user.lifetime = True
current_user.lifetime_coupon_id = coupon.id
if coupon.paid:
current_user.paid_lifetime = True
Session.commit()
# notify admin
send_email(
ADMIN_EMAIL,
subject=f"User {current_user} used lifetime coupon({coupon.comment}). Coupon nb_used: {coupon.nb_used}",
plaintext="",
html="",
)
flash("You are upgraded to lifetime premium!", "success")
return redirect(url_for("dashboard.index"))
else:
flash(f"Code *{code}* expired or invalid", "warning")
return render_template("dashboard/lifetime_licence.html", coupon_form=coupon_form)

View File

@ -0,0 +1,210 @@
import arrow
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from itsdangerous import Signer
from wtforms import validators
from wtforms.fields.html5 import EmailField
from app.config import MAILBOX_SECRET, URL, JOB_DELETE_MAILBOX
from app.dashboard.base import dashboard_bp
from app.db import Session
from app.email_utils import (
email_can_be_used_as_mailbox,
mailbox_already_used,
render,
send_email,
is_valid_email,
)
from app.log import LOG
from app.models import Mailbox, Job
from app.utils import CSRFValidationForm
class NewMailboxForm(FlaskForm):
email = EmailField(
"email", validators=[validators.DataRequired(), validators.Email()]
)
@dashboard_bp.route("/mailbox", methods=["GET", "POST"])
@login_required
def mailbox_route():
mailboxes = (
Mailbox.filter_by(user_id=current_user.id)
.order_by(Mailbox.created_at.desc())
.all()
)
new_mailbox_form = NewMailboxForm()
csrf_form = CSRFValidationForm()
if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
if request.form.get("form-name") == "delete":
mailbox_id = request.form.get("mailbox-id")
mailbox = Mailbox.get(mailbox_id)
if not mailbox or mailbox.user_id != current_user.id:
flash("Unknown error. Refresh the page", "warning")
return redirect(url_for("dashboard.mailbox_route"))
if mailbox.id == current_user.default_mailbox_id:
flash("You cannot delete default mailbox", "error")
return redirect(url_for("dashboard.mailbox_route"))
# Schedule delete account job
LOG.w("schedule delete mailbox job for %s", mailbox)
Job.create(
name=JOB_DELETE_MAILBOX,
payload={"mailbox_id": mailbox.id},
run_at=arrow.now(),
commit=True,
)
flash(
f"Mailbox {mailbox.email} scheduled for deletion."
f"You will receive a confirmation email when the deletion is finished",
"success",
)
return redirect(url_for("dashboard.mailbox_route"))
if request.form.get("form-name") == "set-default":
mailbox_id = request.form.get("mailbox-id")
mailbox = Mailbox.get(mailbox_id)
if not mailbox or mailbox.user_id != current_user.id:
flash("Unknown error. Refresh the page", "warning")
return redirect(url_for("dashboard.mailbox_route"))
if mailbox.id == current_user.default_mailbox_id:
flash("This mailbox is already default one", "error")
return redirect(url_for("dashboard.mailbox_route"))
if not mailbox.verified:
flash("Cannot set unverified mailbox as default", "error")
return redirect(url_for("dashboard.mailbox_route"))
current_user.default_mailbox_id = mailbox.id
Session.commit()
flash(f"Mailbox {mailbox.email} is set as Default Mailbox", "success")
return redirect(url_for("dashboard.mailbox_route"))
elif request.form.get("form-name") == "create":
if not current_user.is_premium():
flash("Only premium plan can add additional mailbox", "warning")
return redirect(url_for("dashboard.mailbox_route"))
if new_mailbox_form.validate():
mailbox_email = (
new_mailbox_form.email.data.lower().strip().replace(" ", "")
)
if not is_valid_email(mailbox_email):
flash(f"{mailbox_email} invalid", "error")
elif mailbox_already_used(mailbox_email, current_user):
flash(f"{mailbox_email} already used", "error")
elif not email_can_be_used_as_mailbox(mailbox_email):
flash(f"You cannot use {mailbox_email}.", "error")
else:
new_mailbox = Mailbox.create(
email=mailbox_email, user_id=current_user.id
)
Session.commit()
send_verification_email(current_user, new_mailbox)
flash(
f"You are going to receive an email to confirm {mailbox_email}.",
"success",
)
return redirect(
url_for(
"dashboard.mailbox_detail_route", mailbox_id=new_mailbox.id
)
)
return render_template(
"dashboard/mailbox.html",
mailboxes=mailboxes,
new_mailbox_form=new_mailbox_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):
s = Signer(MAILBOX_SECRET)
mailbox_id_signed = s.sign(str(mailbox.id)).decode()
verification_url = (
URL + "/dashboard/mailbox_verify" + f"?mailbox_id={mailbox_id_signed}"
)
send_email(
mailbox.email,
f"Please confirm your mailbox {mailbox.email}",
render(
"transactional/verify-mailbox.txt.jinja2",
user=user,
link=verification_url,
mailbox_email=mailbox.email,
),
render(
"transactional/verify-mailbox.html",
user=user,
link=verification_url,
mailbox_email=mailbox.email,
),
)
@dashboard_bp.route("/mailbox_verify")
def mailbox_verify():
s = Signer(MAILBOX_SECRET)
mailbox_id = request.args.get("mailbox_id")
try:
r_id = int(s.unsign(mailbox_id))
except Exception:
flash("Invalid link. Please delete and re-add your mailbox", "error")
return redirect(url_for("dashboard.mailbox_route"))
else:
mailbox = Mailbox.get(r_id)
if not mailbox:
flash("Invalid link", "error")
return redirect(url_for("dashboard.mailbox_route"))
mailbox.verified = True
Session.commit()
LOG.d("Mailbox %s is verified", mailbox)
return render_template("dashboard/mailbox_validation.html", mailbox=mailbox)

View File

@ -0,0 +1,299 @@
from smtplib import SMTPRecipientsRefused
from email_validator import validate_email, EmailNotValidError
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from itsdangerous import Signer
from wtforms import validators
from wtforms.fields.html5 import EmailField
from app.config import ENFORCE_SPF, MAILBOX_SECRET
from app.config import URL
from app.dashboard.base import dashboard_bp
from app.db import Session
from app.email_utils import email_can_be_used_as_mailbox
from app.email_utils import mailbox_already_used, render, send_email
from app.log import LOG
from app.models import Alias, AuthorizedAddress
from app.models import Mailbox
from app.pgp_utils import PGPException, load_public_key_and_check
from app.utils import sanitize_email, CSRFValidationForm
class ChangeEmailForm(FlaskForm):
email = EmailField(
"email", validators=[validators.DataRequired(), validators.Email()]
)
@dashboard_bp.route("/mailbox/<int:mailbox_id>/", methods=["GET", "POST"])
@login_required
def mailbox_detail_route(mailbox_id):
mailbox = Mailbox.get(mailbox_id)
if not mailbox or mailbox.user_id != current_user.id:
flash("You cannot see this page", "warning")
return redirect(url_for("dashboard.index"))
change_email_form = ChangeEmailForm()
csrf_form = CSRFValidationForm()
if mailbox.new_email:
pending_email = mailbox.new_email
else:
pending_email = None
if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
if (
request.form.get("form-name") == "update-email"
and change_email_form.validate_on_submit()
):
new_email = sanitize_email(change_email_form.email.data)
if new_email != mailbox.email and not pending_email:
# check if this email is not already used
if mailbox_already_used(new_email, current_user) or Alias.get_by(
email=new_email
):
flash(f"Email {new_email} already used", "error")
elif not email_can_be_used_as_mailbox(new_email):
flash("You cannot use this email address as your mailbox", "error")
else:
mailbox.new_email = new_email
Session.commit()
try:
verify_mailbox_change(current_user, mailbox, new_email)
except SMTPRecipientsRefused:
flash(
f"Incorrect mailbox, please recheck {mailbox.email}",
"error",
)
else:
flash(
f"You are going to receive an email to confirm {new_email}.",
"success",
)
return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
)
elif request.form.get("form-name") == "force-spf":
if not ENFORCE_SPF:
flash("SPF enforcement globally not enabled", "error")
return redirect(url_for("dashboard.index"))
mailbox.force_spf = (
True if request.form.get("spf-status") == "on" else False
)
Session.commit()
flash(
"SPF enforcement was " + "enabled"
if request.form.get("spf-status")
else "disabled" + " successfully",
"success",
)
return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
)
elif request.form.get("form-name") == "add-authorized-address":
address = sanitize_email(request.form.get("email"))
try:
validate_email(
address, check_deliverability=False, allow_smtputf8=False
).domain
except EmailNotValidError:
flash(f"invalid {address}", "error")
else:
if AuthorizedAddress.get_by(mailbox_id=mailbox.id, email=address):
flash(f"{address} already added", "error")
else:
AuthorizedAddress.create(
user_id=current_user.id,
mailbox_id=mailbox.id,
email=address,
commit=True,
)
flash(f"{address} added as authorized address", "success")
return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
)
elif request.form.get("form-name") == "delete-authorized-address":
authorized_address_id = request.form.get("authorized-address-id")
authorized_address: AuthorizedAddress = AuthorizedAddress.get(
authorized_address_id
)
if not authorized_address or authorized_address.mailbox_id != mailbox.id:
flash("Unknown error. Refresh the page", "warning")
else:
address = authorized_address.email
AuthorizedAddress.delete(authorized_address_id)
Session.commit()
flash(f"{address} has been deleted", "success")
return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
)
elif request.form.get("form-name") == "pgp":
if request.form.get("action") == "save":
if not current_user.is_premium():
flash("Only premium plan can add PGP Key", "warning")
return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
)
mailbox.pgp_public_key = request.form.get("pgp")
try:
mailbox.pgp_finger_print = load_public_key_and_check(
mailbox.pgp_public_key
)
except PGPException:
flash("Cannot add the public key, please verify it", "error")
else:
Session.commit()
flash("Your PGP public key is saved successfully", "success")
return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
)
elif request.form.get("action") == "remove":
# Free user can decide to remove their added PGP key
mailbox.pgp_public_key = None
mailbox.pgp_finger_print = None
mailbox.disable_pgp = False
Session.commit()
flash("Your PGP public key is removed successfully", "success")
return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
)
elif request.form.get("form-name") == "toggle-pgp":
if request.form.get("pgp-enabled") == "on":
mailbox.disable_pgp = False
flash(f"PGP is enabled on {mailbox.email}", "success")
else:
mailbox.disable_pgp = True
flash(f"PGP is disabled on {mailbox.email}", "info")
Session.commit()
return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
)
elif request.form.get("form-name") == "generic-subject":
if request.form.get("action") == "save":
if not mailbox.pgp_enabled():
flash(
"Generic subject can only be used on PGP-enabled mailbox",
"error",
)
return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
)
mailbox.generic_subject = request.form.get("generic-subject")
Session.commit()
flash("Generic subject for PGP-encrypted email is enabled", "success")
return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
)
elif request.form.get("action") == "remove":
mailbox.generic_subject = None
Session.commit()
flash("Generic subject for PGP-encrypted email is disabled", "success")
return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
)
spf_available = ENFORCE_SPF
return render_template("dashboard/mailbox_detail.html", **locals())
def verify_mailbox_change(user, mailbox, new_email):
s = Signer(MAILBOX_SECRET)
mailbox_id_signed = s.sign(str(mailbox.id)).decode()
verification_url = (
f"{URL}/dashboard/mailbox/confirm_change?mailbox_id={mailbox_id_signed}"
)
send_email(
new_email,
"Confirm mailbox change on SimpleLogin",
render(
"transactional/verify-mailbox-change.txt.jinja2",
user=user,
link=verification_url,
mailbox_email=mailbox.email,
mailbox_new_email=new_email,
),
render(
"transactional/verify-mailbox-change.html",
user=user,
link=verification_url,
mailbox_email=mailbox.email,
mailbox_new_email=new_email,
),
)
@dashboard_bp.route(
"/mailbox/<int:mailbox_id>/cancel_email_change", methods=["GET", "POST"]
)
@login_required
def cancel_mailbox_change_route(mailbox_id):
mailbox = Mailbox.get(mailbox_id)
if not mailbox or mailbox.user_id != current_user.id:
flash("You cannot see this page", "warning")
return redirect(url_for("dashboard.index"))
if mailbox.new_email:
mailbox.new_email = None
Session.commit()
flash("Your mailbox change is cancelled", "success")
return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
)
else:
flash("You have no pending mailbox change", "warning")
return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
)
@dashboard_bp.route("/mailbox/confirm_change")
def mailbox_confirm_change_route():
s = Signer(MAILBOX_SECRET)
signed_mailbox_id = request.args.get("mailbox_id")
try:
mailbox_id = int(s.unsign(signed_mailbox_id))
except Exception:
flash("Invalid link", "error")
return redirect(url_for("dashboard.index"))
else:
mailbox = Mailbox.get(mailbox_id)
# new_email can be None if user cancels change in the meantime
if mailbox and mailbox.new_email:
user = mailbox.user
if Mailbox.get_by(email=mailbox.new_email, user_id=user.id):
flash(f"{mailbox.new_email} is already used", "error")
return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox.id)
)
mailbox.email = mailbox.new_email
mailbox.new_email = None
# mark mailbox as verified if the change request is sent from an unverified mailbox
mailbox.verified = True
Session.commit()
LOG.d("Mailbox change %s is verified", mailbox)
flash(f"The {mailbox.email} is updated", "success")
return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox.id)
)
else:
flash("Invalid link", "error")
return redirect(url_for("dashboard.index"))

View File

@ -0,0 +1,31 @@
from flask import render_template, flash, redirect, url_for, request
from flask_login import login_required, current_user
from app.dashboard.base import dashboard_bp
from app.dashboard.views.enter_sudo import sudo_required
from app.db import Session
from app.models import RecoveryCode
@dashboard_bp.route("/mfa_cancel", methods=["GET", "POST"])
@login_required
@sudo_required
def mfa_cancel():
if not current_user.enable_otp:
flash("you don't have MFA enabled", "warning")
return redirect(url_for("dashboard.index"))
# user cancels TOTP
if request.method == "POST":
current_user.enable_otp = False
current_user.otp_secret = None
Session.commit()
# user does not have any 2FA enabled left, delete all recovery codes
if not current_user.two_factor_authentication_enabled():
RecoveryCode.empty(current_user)
flash("TOTP is now disabled", "warning")
return redirect(url_for("dashboard.index"))
return render_template("dashboard/mfa_cancel.html")

View File

@ -0,0 +1,56 @@
import pyotp
from flask import render_template, flash, redirect, url_for
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import StringField, validators
from app.dashboard.base import dashboard_bp
from app.dashboard.views.enter_sudo import sudo_required
from app.db import Session
from app.log import LOG
from app.models import RecoveryCode
class OtpTokenForm(FlaskForm):
token = StringField("Token", validators=[validators.DataRequired()])
@dashboard_bp.route("/mfa_setup", methods=["GET", "POST"])
@login_required
@sudo_required
def mfa_setup():
if current_user.enable_otp:
flash("you have already enabled MFA", "warning")
return redirect(url_for("dashboard.index"))
otp_token_form = OtpTokenForm()
if not current_user.otp_secret:
LOG.d("Generate otp_secret for user %s", current_user)
current_user.otp_secret = pyotp.random_base32()
Session.commit()
totp = pyotp.TOTP(current_user.otp_secret)
if otp_token_form.validate_on_submit():
token = otp_token_form.token.data.replace(" ", "")
if totp.verify(token) and current_user.last_otp != token:
current_user.enable_otp = True
current_user.last_otp = token
Session.commit()
flash("MFA has been activated", "success")
recovery_codes = RecoveryCode.generate(current_user)
return render_template(
"dashboard/recovery_code.html", recovery_codes=recovery_codes
)
else:
flash("Incorrect token", "warning")
otp_uri = pyotp.totp.TOTP(current_user.otp_secret).provisioning_uri(
name=current_user.email, issuer_name="SimpleLogin"
)
return render_template(
"dashboard/mfa_setup.html", otp_token_form=otp_token_form, otp_uri=otp_uri
)

View File

@ -0,0 +1,61 @@
from flask import redirect, url_for, flash, render_template, request
from flask_login import login_required, current_user
from app.config import PAGE_LIMIT
from app.dashboard.base import dashboard_bp
from app.db import Session
from app.models import Notification
@dashboard_bp.route("/notification/<notification_id>", methods=["GET", "POST"])
@login_required
def notification_route(notification_id):
notification = Notification.get(notification_id)
if not notification:
flash("Incorrect link. Redirect you to the home page", "warning")
return redirect(url_for("dashboard.index"))
if notification.user_id != current_user.id:
flash(
"You don't have access to this page. Redirect you to the home page",
"warning",
)
return redirect(url_for("dashboard.index"))
if not notification.read:
notification.read = True
Session.commit()
if request.method == "POST":
notification_title = notification.title or notification.message[:20]
Notification.delete(notification_id)
Session.commit()
flash(f"{notification_title} has been deleted", "success")
return redirect(url_for("dashboard.index"))
else:
return render_template("dashboard/notification.html", notification=notification)
@dashboard_bp.route("/notifications", methods=["GET", "POST"])
@login_required
def notifications_route():
page = 0
if request.args.get("page"):
page = int(request.args.get("page"))
notifications = (
Notification.filter_by(user_id=current_user.id)
.order_by(Notification.read, Notification.created_at.desc())
.limit(PAGE_LIMIT + 1) # load a record more to know whether there's more
.offset(page * PAGE_LIMIT)
.all()
)
return render_template(
"dashboard/notifications.html",
notifications=notifications,
page=page,
last_page=len(notifications) <= PAGE_LIMIT,
)

View File

@ -0,0 +1,101 @@
import arrow
from coinbase_commerce import Client
from flask import render_template, flash, redirect, url_for
from flask_login import login_required, current_user
from app.config import (
PADDLE_VENDOR_ID,
PADDLE_MONTHLY_PRODUCT_ID,
PADDLE_YEARLY_PRODUCT_ID,
URL,
COINBASE_YEARLY_PRICE,
COINBASE_API_KEY,
)
from app.dashboard.base import dashboard_bp
from app.extensions import limiter
from app.log import LOG
from app.models import (
AppleSubscription,
Subscription,
ManualSubscription,
CoinbaseSubscription,
PartnerUser,
PartnerSubscription,
)
from app.proton.utils import get_proton_partner
@dashboard_bp.route("/pricing", methods=["GET", "POST"])
@login_required
def pricing():
if current_user.lifetime:
flash("You already have a lifetime subscription", "error")
return redirect(url_for("dashboard.index"))
paddle_sub: Subscription = current_user.get_paddle_subscription()
# user who has canceled can re-subscribe
if paddle_sub and not paddle_sub.cancelled:
flash("You already have an active subscription", "error")
return redirect(url_for("dashboard.index"))
now = arrow.now()
manual_sub: ManualSubscription = ManualSubscription.filter(
ManualSubscription.user_id == current_user.id, ManualSubscription.end_at > now
).first()
coinbase_sub = CoinbaseSubscription.filter(
CoinbaseSubscription.user_id == current_user.id,
CoinbaseSubscription.end_at > now,
).first()
apple_sub: AppleSubscription = AppleSubscription.get_by(user_id=current_user.id)
if apple_sub and apple_sub.is_valid():
flash("Please make sure to cancel your subscription on Apple first", "warning")
proton_upgrade = False
partner_user = PartnerUser.get_by(user_id=current_user.id)
if partner_user:
partner_sub = PartnerSubscription.get_by(partner_user_id=partner_user.id)
if partner_sub and partner_sub.is_active():
flash(
f"You already have a subscription provided by {partner_user.partner.name}",
"error",
)
return redirect(url_for("dashboard.index"))
proton_upgrade = partner_user.partner_id == get_proton_partner().id
return render_template(
"dashboard/pricing.html",
PADDLE_VENDOR_ID=PADDLE_VENDOR_ID,
PADDLE_MONTHLY_PRODUCT_ID=PADDLE_MONTHLY_PRODUCT_ID,
PADDLE_YEARLY_PRODUCT_ID=PADDLE_YEARLY_PRODUCT_ID,
success_url=URL + "/dashboard/subscription_success",
manual_sub=manual_sub,
coinbase_sub=coinbase_sub,
now=now,
proton_upgrade=proton_upgrade,
)
@dashboard_bp.route("/subscription_success")
@login_required
def subscription_success():
flash("Thanks so much for supporting SimpleLogin!", "success")
return redirect(url_for("dashboard.index"))
@dashboard_bp.route("/coinbase_checkout")
@login_required
@limiter.limit("5/minute")
def coinbase_checkout_route():
client = Client(api_key=COINBASE_API_KEY)
charge = client.charge.create(
name="1 Year SimpleLogin Premium Subscription",
local_price={"amount": str(COINBASE_YEARLY_PRICE), "currency": "USD"},
pricing_type="fixed_price",
metadata={"user_id": current_user.id},
)
LOG.d("Create coinbase charge %s", charge)
return redirect(charge["hosted_url"])

View File

@ -0,0 +1,74 @@
import re2 as re
from flask import render_template, request, flash, redirect, url_for
from flask_login import login_required, current_user
from app.dashboard.base import dashboard_bp
from app.db import Session
from app.models import Referral, Payout
_REFERRAL_PATTERN = r"[0-9a-z-_]{3,}"
@dashboard_bp.route("/referral", methods=["GET", "POST"])
@login_required
def referral_route():
if request.method == "POST":
if request.form.get("form-name") == "create":
code = request.form.get("code")
if re.fullmatch(_REFERRAL_PATTERN, code) is None:
flash(
"At least 3 characters. Only lowercase letters, "
"numbers, dashes (-) and underscores (_) are currently supported.",
"error",
)
return redirect(url_for("dashboard.referral_route"))
if Referral.get_by(code=code):
flash("Code already used", "error")
return redirect(url_for("dashboard.referral_route"))
name = request.form.get("name")
referral = Referral.create(user_id=current_user.id, code=code, name=name)
Session.commit()
flash("A new referral code has been created", "success")
return redirect(
url_for("dashboard.referral_route", highlight_id=referral.id)
)
elif request.form.get("form-name") == "update":
referral_id = request.form.get("referral-id")
referral = Referral.get(referral_id)
if referral and referral.user_id == current_user.id:
referral.name = request.form.get("name")
Session.commit()
flash("Referral name updated", "success")
return redirect(
url_for("dashboard.referral_route", highlight_id=referral.id)
)
elif request.form.get("form-name") == "delete":
referral_id = request.form.get("referral-id")
referral = Referral.get(referral_id)
if referral and referral.user_id == current_user.id:
Referral.delete(referral.id)
Session.commit()
flash("Referral deleted", "success")
return redirect(url_for("dashboard.referral_route"))
# Highlight a referral
highlight_id = request.args.get("highlight_id")
if highlight_id:
highlight_id = int(highlight_id)
referrals = Referral.filter_by(user_id=current_user.id).all()
# make sure the highlighted referral is the first referral
highlight_index = None
for ix, referral in enumerate(referrals):
if referral.id == highlight_id:
highlight_index = ix
break
if highlight_index:
referrals.insert(0, referrals.pop(highlight_index))
payouts = Payout.filter_by(user_id=current_user.id).all()
return render_template("dashboard/referral.html", **locals())

View File

@ -0,0 +1,39 @@
from flask import render_template, request
from flask_login import login_required, current_user
from app.dashboard.base import dashboard_bp
from app.log import LOG
from app.models import EmailLog
@dashboard_bp.route("/refused_email", methods=["GET", "POST"])
@login_required
def refused_email_route():
# Highlight a refused email
highlight_id = request.args.get("highlight_id")
if highlight_id:
try:
highlight_id = int(highlight_id)
except ValueError:
LOG.w("Cannot parse highlight_id %s", highlight_id)
highlight_id = None
email_logs: [EmailLog] = (
EmailLog.filter(
EmailLog.user_id == current_user.id, EmailLog.refused_email_id.isnot(None)
)
.order_by(EmailLog.id.desc())
.all()
)
# make sure the highlighted email_log is the first email_log
highlight_index = None
for ix, email_log in enumerate(email_logs):
if email_log.id == highlight_id:
highlight_index = ix
break
if highlight_index:
email_logs.insert(0, email_logs.pop(highlight_index))
return render_template("dashboard/refused_email.html", **locals())

View File

@ -0,0 +1,498 @@
from io import BytesIO
from typing import Optional, Tuple
import arrow
from flask import (
render_template,
request,
redirect,
url_for,
flash,
)
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from flask_wtf.file import FileField
from wtforms import StringField, validators
from wtforms.fields.html5 import EmailField
from app import s3, email_utils
from app.config import (
URL,
FIRST_ALIAS_DOMAIN,
ALIAS_RANDOM_SUFFIX_LENGTH,
CONNECT_WITH_PROTON,
)
from app.dashboard.base import dashboard_bp
from app.db import Session
from app.email_utils import (
email_can_be_used_as_mailbox,
personal_email_already_used,
)
from app.errors import ProtonPartnerNotSetUp
from app.extensions import limiter
from app.image_validation import detect_image_format, ImageFormat
from app.jobs.export_user_data_job import ExportUserDataJob
from app.log import LOG
from app.models import (
BlockBehaviourEnum,
PlanEnum,
File,
ResetPasswordCode,
EmailChange,
User,
Alias,
CustomDomain,
AliasGeneratorEnum,
AliasSuffixEnum,
ManualSubscription,
SenderFormatEnum,
SLDomain,
CoinbaseSubscription,
AppleSubscription,
PartnerUser,
PartnerSubscription,
UnsubscribeBehaviourEnum,
)
from app.proton.utils import get_proton_partner, perform_proton_account_unlink
from app.utils import (
random_string,
CSRFValidationForm,
canonicalize_email,
)
class SettingForm(FlaskForm):
name = StringField("Name")
profile_picture = FileField("Profile Picture")
class ChangeEmailForm(FlaskForm):
email = EmailField(
"email", validators=[validators.DataRequired(), validators.Email()]
)
class PromoCodeForm(FlaskForm):
code = StringField("Name", validators=[validators.DataRequired()])
def get_proton_linked_account() -> Optional[str]:
# Check if the current user has a partner_id
try:
proton_partner_id = get_proton_partner().id
except ProtonPartnerNotSetUp:
return None
# It has. Retrieve the information for the PartnerUser
proton_linked_account = PartnerUser.get_by(
user_id=current_user.id, partner_id=proton_partner_id
)
if proton_linked_account is None:
return None
return proton_linked_account.partner_email
def get_partner_subscription_and_name(
user_id: int,
) -> Optional[Tuple[PartnerSubscription, str]]:
partner_sub = PartnerSubscription.find_by_user_id(user_id)
if not partner_sub or not partner_sub.is_active():
return None
partner = partner_sub.partner_user.partner
return (partner_sub, partner.name)
@dashboard_bp.route("/setting", methods=["GET", "POST"])
@login_required
@limiter.limit("5/minute", methods=["POST"])
def setting():
form = SettingForm()
promo_form = PromoCodeForm()
change_email_form = ChangeEmailForm()
csrf_form = CSRFValidationForm()
email_change = EmailChange.get_by(user_id=current_user.id)
if email_change:
pending_email = email_change.new_email
else:
pending_email = None
if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(url_for("dashboard.setting"))
if request.form.get("form-name") == "update-email":
if change_email_form.validate():
# whether user can proceed with the email update
new_email_valid = True
new_email = canonicalize_email(change_email_form.email.data)
if new_email != current_user.email and not pending_email:
# check if this email is not already used
if personal_email_already_used(new_email) or Alias.get_by(
email=new_email
):
flash(f"Email {new_email} already used", "error")
new_email_valid = False
elif not email_can_be_used_as_mailbox(new_email):
flash(
"You cannot use this email address as your personal inbox.",
"error",
)
new_email_valid = False
# a pending email change with the same email exists from another user
elif EmailChange.get_by(new_email=new_email):
other_email_change: EmailChange = EmailChange.get_by(
new_email=new_email
)
LOG.w(
"Another user has a pending %s with the same email address. Current user:%s",
other_email_change,
current_user,
)
if other_email_change.is_expired():
LOG.d(
"delete the expired email change %s", other_email_change
)
EmailChange.delete(other_email_change.id)
Session.commit()
else:
flash(
"You cannot use this email address as your personal inbox.",
"error",
)
new_email_valid = False
if new_email_valid:
email_change = EmailChange.create(
user_id=current_user.id,
code=random_string(
60
), # todo: make sure the code is unique
new_email=new_email,
)
Session.commit()
send_change_email_confirmation(current_user, email_change)
flash(
"A confirmation email is on the way, please check your inbox",
"success",
)
return redirect(url_for("dashboard.setting"))
if request.form.get("form-name") == "update-profile":
if form.validate():
profile_updated = False
# update user info
if form.name.data != current_user.name:
current_user.name = form.name.data
Session.commit()
profile_updated = True
if form.profile_picture.data:
image_contents = form.profile_picture.data.read()
if detect_image_format(image_contents) == ImageFormat.Unknown:
flash(
"This image format is not supported",
"error",
)
return redirect(url_for("dashboard.setting"))
file_path = random_string(30)
file = File.create(user_id=current_user.id, path=file_path)
s3.upload_from_bytesio(file_path, BytesIO(image_contents))
Session.flush()
LOG.d("upload file %s to s3", file)
current_user.profile_picture_id = file.id
Session.commit()
profile_updated = True
if profile_updated:
flash("Your profile has been updated", "success")
return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "change-password":
flash(
"You are going to receive an email containing instructions to change your password",
"success",
)
send_reset_password_email(current_user)
return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "notification-preference":
choose = request.form.get("notification")
if choose == "on":
current_user.notification = True
else:
current_user.notification = False
Session.commit()
flash("Your notification preference has been updated", "success")
return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "change-alias-generator":
scheme = int(request.form.get("alias-generator-scheme"))
if AliasGeneratorEnum.has_value(scheme):
current_user.alias_generator = scheme
Session.commit()
flash("Your preference has been updated", "success")
return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "change-random-alias-default-domain":
default_domain = request.form.get("random-alias-default-domain")
if default_domain:
sl_domain: SLDomain = SLDomain.get_by(domain=default_domain)
if sl_domain:
if sl_domain.premium_only and not current_user.is_premium():
flash("You cannot use this domain", "error")
return redirect(url_for("dashboard.setting"))
current_user.default_alias_public_domain_id = sl_domain.id
current_user.default_alias_custom_domain_id = None
else:
custom_domain = CustomDomain.get_by(domain=default_domain)
if custom_domain:
# sanity check
if (
custom_domain.user_id != current_user.id
or not custom_domain.verified
):
LOG.w(
"%s cannot use domain %s", current_user, custom_domain
)
flash(f"Domain {default_domain} can't be used", "error")
return redirect(request.url)
else:
current_user.default_alias_custom_domain_id = (
custom_domain.id
)
current_user.default_alias_public_domain_id = None
else:
current_user.default_alias_custom_domain_id = None
current_user.default_alias_public_domain_id = None
Session.commit()
flash("Your preference has been updated", "success")
return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "random-alias-suffix":
scheme = int(request.form.get("random-alias-suffix-generator"))
if AliasSuffixEnum.has_value(scheme):
current_user.random_alias_suffix = scheme
Session.commit()
flash("Your preference has been updated", "success")
return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "change-sender-format":
sender_format = int(request.form.get("sender-format"))
if SenderFormatEnum.has_value(sender_format):
current_user.sender_format = sender_format
current_user.sender_format_updated_at = arrow.now()
Session.commit()
flash("Your sender format preference has been updated", "success")
Session.commit()
return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "replace-ra":
choose = request.form.get("replace-ra")
if choose == "on":
current_user.replace_reverse_alias = True
else:
current_user.replace_reverse_alias = False
Session.commit()
flash("Your preference has been updated", "success")
return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "sender-in-ra":
choose = request.form.get("enable")
if choose == "on":
current_user.include_sender_in_reverse_alias = True
else:
current_user.include_sender_in_reverse_alias = False
Session.commit()
flash("Your preference has been updated", "success")
return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "expand-alias-info":
choose = request.form.get("enable")
if choose == "on":
current_user.expand_alias_info = True
else:
current_user.expand_alias_info = False
Session.commit()
flash("Your preference has been updated", "success")
return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "ignore-loop-email":
choose = request.form.get("enable")
if choose == "on":
current_user.ignore_loop_email = True
else:
current_user.ignore_loop_email = False
Session.commit()
flash("Your preference has been updated", "success")
return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "one-click-unsubscribe":
choose = request.form.get("unsubscribe-behaviour")
if choose == UnsubscribeBehaviourEnum.PreserveOriginal.name:
current_user.unsub_behaviour = UnsubscribeBehaviourEnum.PreserveOriginal
elif choose == UnsubscribeBehaviourEnum.DisableAlias.name:
current_user.unsub_behaviour = UnsubscribeBehaviourEnum.DisableAlias
elif choose == UnsubscribeBehaviourEnum.BlockContact.name:
current_user.unsub_behaviour = UnsubscribeBehaviourEnum.BlockContact
else:
flash("There was an error. Please try again", "warning")
return redirect(url_for("dashboard.setting"))
Session.commit()
flash("Your preference has been updated", "success")
return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "include_website_in_one_click_alias":
choose = request.form.get("enable")
if choose == "on":
current_user.include_website_in_one_click_alias = True
else:
current_user.include_website_in_one_click_alias = False
Session.commit()
flash("Your preference has been updated", "success")
return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "change-blocked-behaviour":
choose = request.form.get("blocked-behaviour")
if choose == str(BlockBehaviourEnum.return_2xx.value):
current_user.block_behaviour = BlockBehaviourEnum.return_2xx.name
elif choose == str(BlockBehaviourEnum.return_5xx.value):
current_user.block_behaviour = BlockBehaviourEnum.return_5xx.name
else:
flash("There was an error. Please try again", "warning")
return redirect(url_for("dashboard.setting"))
Session.commit()
flash("Your preference has been updated", "success")
elif request.form.get("form-name") == "sender-header":
choose = request.form.get("enable")
if choose == "on":
current_user.include_header_email_header = True
else:
current_user.include_header_email_header = False
Session.commit()
flash("Your preference has been updated", "success")
return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "send-full-user-report":
if ExportUserDataJob(current_user).store_job_in_db():
flash(
"You will receive your SimpleLogin data via email shortly",
"success",
)
else:
flash("An export of your data is currently in progress", "error")
manual_sub = ManualSubscription.get_by(user_id=current_user.id)
apple_sub = AppleSubscription.get_by(user_id=current_user.id)
coinbase_sub = CoinbaseSubscription.get_by(user_id=current_user.id)
paddle_sub = current_user.get_paddle_subscription()
partner_sub = None
partner_name = None
partner_sub_name = get_partner_subscription_and_name(current_user.id)
if partner_sub_name:
partner_sub, partner_name = partner_sub_name
proton_linked_account = get_proton_linked_account()
return render_template(
"dashboard/setting.html",
csrf_form=csrf_form,
form=form,
PlanEnum=PlanEnum,
SenderFormatEnum=SenderFormatEnum,
BlockBehaviourEnum=BlockBehaviourEnum,
promo_form=promo_form,
change_email_form=change_email_form,
pending_email=pending_email,
AliasGeneratorEnum=AliasGeneratorEnum,
UnsubscribeBehaviourEnum=UnsubscribeBehaviourEnum,
manual_sub=manual_sub,
partner_sub=partner_sub,
partner_name=partner_name,
apple_sub=apple_sub,
paddle_sub=paddle_sub,
coinbase_sub=coinbase_sub,
FIRST_ALIAS_DOMAIN=FIRST_ALIAS_DOMAIN,
ALIAS_RAND_SUFFIX_LENGTH=ALIAS_RANDOM_SUFFIX_LENGTH,
connect_with_proton=CONNECT_WITH_PROTON,
proton_linked_account=proton_linked_account,
)
def send_reset_password_email(user):
"""
generate a new ResetPasswordCode and send it over email to user
"""
# the activation code is valid for 1h
reset_password_code = ResetPasswordCode.create(
user_id=user.id, code=random_string(60)
)
Session.commit()
reset_password_link = f"{URL}/auth/reset_password?code={reset_password_code.code}"
email_utils.send_reset_password_email(user.email, reset_password_link)
def send_change_email_confirmation(user: User, email_change: EmailChange):
"""
send confirmation email to the new email address
"""
link = f"{URL}/auth/change_email?code={email_change.code}"
email_utils.send_change_email(email_change.new_email, user.email, link)
@dashboard_bp.route("/resend_email_change", methods=["GET", "POST"])
@login_required
def resend_email_change():
email_change = EmailChange.get_by(user_id=current_user.id)
if email_change:
# extend email change expiration
email_change.expired = arrow.now().shift(hours=12)
Session.commit()
send_change_email_confirmation(current_user, email_change)
flash("A confirmation email is on the way, please check your inbox", "success")
return redirect(url_for("dashboard.setting"))
else:
flash(
"You have no pending email change. Redirect back to Setting page", "warning"
)
return redirect(url_for("dashboard.setting"))
@dashboard_bp.route("/cancel_email_change", methods=["GET", "POST"])
@login_required
def cancel_email_change():
email_change = EmailChange.get_by(user_id=current_user.id)
if email_change:
EmailChange.delete(email_change.id)
Session.commit()
flash("Your email change is cancelled", "success")
return redirect(url_for("dashboard.setting"))
else:
flash(
"You have no pending email change. Redirect back to Setting page", "warning"
)
return redirect(url_for("dashboard.setting"))
@dashboard_bp.route("/unlink_proton_account", methods=["POST"])
@login_required
def unlink_proton_account():
csrf_form = CSRFValidationForm()
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(url_for("dashboard.setting"))
perform_proton_account_unlink(current_user)
flash("Your Proton account has been unlinked", "success")
return redirect(url_for("dashboard.setting"))

View File

@ -0,0 +1,23 @@
import arrow
from flask import make_response, redirect, url_for
from flask_login import login_required
from app.config import URL
from app.dashboard.base import dashboard_bp
@dashboard_bp.route("/setup_done", methods=["GET", "POST"])
@login_required
def setup_done():
response = make_response(redirect(url_for("dashboard.index")))
response.set_cookie(
"setup_done",
value="true",
expires=arrow.now().shift(days=30).datetime,
secure=True if URL.startswith("https") else False,
httponly=True,
samesite="Lax",
)
return response

View File

@ -0,0 +1,111 @@
import re
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from app.config import MAX_NB_SUBDOMAIN
from app.dashboard.base import dashboard_bp
from app.errors import SubdomainInTrashError
from app.log import LOG
from app.models import CustomDomain, Mailbox, SLDomain
# Only lowercase letters, numbers, dashes (-) are currently supported
_SUBDOMAIN_PATTERN = r"[0-9a-z-]{1,}"
@dashboard_bp.route("/subdomain", methods=["GET", "POST"])
@login_required
def subdomain_route():
if not current_user.subdomain_is_available():
flash("Unknown error, redirect to the home page", "error")
return redirect(url_for("dashboard.index"))
sl_domains = SLDomain.filter_by(can_use_subdomain=True).all()
subdomains = CustomDomain.filter_by(
user_id=current_user.id, is_sl_subdomain=True
).all()
errors = {}
if request.method == "POST":
if request.form.get("form-name") == "create":
if not current_user.is_premium():
flash("Only premium plan can add subdomain", "warning")
return redirect(request.url)
if current_user.subdomain_quota <= 0:
flash(
f"You can't create more than {MAX_NB_SUBDOMAIN} subdomains", "error"
)
return redirect(request.url)
subdomain = request.form.get("subdomain").lower().strip()
domain = request.form.get("domain").lower().strip()
if len(subdomain) < 3:
flash("Subdomain must have at least 3 characters", "error")
return redirect(request.url)
if re.fullmatch(_SUBDOMAIN_PATTERN, subdomain) is None:
flash(
"Subdomain can only contain lowercase letters, numbers and dashes (-)",
"error",
)
return redirect(request.url)
if subdomain.endswith("-"):
flash("Subdomain can't end with dash (-)", "error")
return redirect(request.url)
if domain not in [sl_domain.domain for sl_domain in sl_domains]:
LOG.e("Domain %s is tampered by %s", domain, current_user)
flash("Unknown error, refresh the page", "error")
return redirect(request.url)
full_domain = f"{subdomain}.{domain}"
if CustomDomain.get_by(domain=full_domain):
flash(f"{full_domain} already used", "error")
elif Mailbox.filter(
Mailbox.verified.is_(True),
Mailbox.email.endswith(f"@{full_domain}"),
).first():
flash(f"{full_domain} already used in a SimpleLogin mailbox", "error")
else:
try:
new_custom_domain = CustomDomain.create(
is_sl_subdomain=True,
catch_all=True, # by default catch-all is enabled
domain=full_domain,
user_id=current_user.id,
verified=True,
dkim_verified=False, # wildcard DNS does not work for DKIM
spf_verified=True,
dmarc_verified=False, # wildcard DNS does not work for DMARC
ownership_verified=True,
commit=True,
)
except SubdomainInTrashError:
flash(
f"{full_domain} has been used before and cannot be reused",
"error",
)
else:
flash(
f"New subdomain {new_custom_domain.domain} is created",
"success",
)
return redirect(
url_for(
"dashboard.domain_detail",
custom_domain_id=new_custom_domain.id,
)
)
return render_template(
"dashboard/subdomain.html",
sl_domains=sl_domains,
errors=errors,
subdomains=subdomains,
)

View File

@ -0,0 +1,124 @@
import json
import urllib.parse
from typing import Union
import requests
from flask import render_template, request, flash, url_for, redirect, g
from flask_login import login_required, current_user
from werkzeug.datastructures import FileStorage
from app.config import ZENDESK_HOST, ZENDESK_API_TOKEN
from app.dashboard.base import dashboard_bp
from app.extensions import limiter
from app.log import LOG
VALID_MIME_TYPES = ["text/plain", "message/rfc822"]
def check_zendesk_response_status(response_code: int) -> bool:
if response_code != 201:
if response_code in (401, 422):
LOG.error("Could not authenticate to Zendesk")
else:
LOG.error(
"Problem with the Zendesk request. Status {}".format(response_code)
)
return False
return True
def upload_file_to_zendesk_and_get_upload_token(
email: str, file: FileStorage
) -> Union[None, str]:
if file.mimetype not in VALID_MIME_TYPES and not file.mimetype.startswith("image/"):
flash(
"File {} is not an image, text or an email".format(file.filename), "warning"
)
return
escaped_filename = urllib.parse.urlencode({"filename": file.filename})
url = "https://{}/api/v2/uploads?{}".format(ZENDESK_HOST, escaped_filename)
headers = {"content-type": file.mimetype}
auth = ("{}/token".format(email), ZENDESK_API_TOKEN)
response = requests.post(url, headers=headers, data=file.stream, auth=auth)
if not check_zendesk_response_status(response.status_code):
return
data = response.json()
return data["upload"]["token"]
def create_zendesk_request(email: str, content: str, files: [FileStorage]) -> bool:
tokens = []
for file in files:
if not file.filename:
continue
token = upload_file_to_zendesk_and_get_upload_token(email, file)
if token is None:
return False
tokens.append(token)
data = {
"request": {
"subject": "Ticket created for user {}".format(current_user.id),
"comment": {"type": "Comment", "body": content, "uploads": tokens},
"requester": {
"name": "SimpleLogin user {}".format(current_user.id),
"email": email,
},
}
}
url = "https://{}/api/v2/requests.json".format(ZENDESK_HOST)
headers = {"content-type": "application/json"}
auth = (f"{email}/token", ZENDESK_API_TOKEN)
response = requests.post(url, data=json.dumps(data), headers=headers, auth=auth)
if not check_zendesk_response_status(response.status_code):
return False
return True
@dashboard_bp.route("/support", methods=["GET", "POST"])
@login_required
@limiter.limit(
"2/hour",
methods=["POST"],
deduct_when=lambda r: hasattr(g, "deduct_limit") and g.deduct_limit,
)
def support_route():
if not ZENDESK_HOST:
flash("Support isn't enabled", "error")
return redirect(url_for("dashboard.index"))
if request.method == "POST":
content = request.form.get("ticket_content")
email = request.form.get("ticket_email")
if not content:
flash("Please add a description", "error")
return render_template("dashboard/support.html", ticket_email=email)
if not email:
flash("Please provide an email address", "error")
return render_template("dashboard/support.html", ticket_content=content)
if not create_zendesk_request(
email, content, request.files.getlist("ticket_files")
):
flash(
"Cannot create a Zendesk ticket, sorry for the inconvenience! Please retry later.",
"error",
)
return render_template(
"dashboard/support.html", ticket_email=email, ticket_content=content
)
# only enable rate limiting for successful Zendesk ticket creation
g.deduct_limit = True
flash(
"Support ticket is created. You will receive an email about its status.",
"success",
)
return redirect(url_for("dashboard.index"))
return render_template("dashboard/support.html", ticket_email=current_user.email)

View File

@ -0,0 +1,113 @@
"""
Allow user to disable an alias or block a contact via the one click unsubscribe
"""
from app.db import Session
from flask import redirect, url_for, flash, request, render_template
from flask_login import login_required, current_user
from app.dashboard.base import dashboard_bp
from app.handler.unsubscribe_encoder import UnsubscribeAction
from app.handler.unsubscribe_handler import UnsubscribeHandler
from app.models import Alias, Contact
@dashboard_bp.route("/unsubscribe/<int:alias_id>", methods=["GET", "POST"])
@login_required
def unsubscribe(alias_id):
alias = Alias.get(alias_id)
if not alias:
flash("Incorrect link. Redirect you to the home page", "warning")
return redirect(url_for("dashboard.index"))
if alias.user_id != current_user.id:
flash(
"You don't have access to this page. Redirect you to the home page",
"warning",
)
return redirect(url_for("dashboard.index"))
# automatic unsubscribe, according to https://tools.ietf.org/html/rfc8058
if request.method == "POST":
alias.enabled = False
flash(f"Alias {alias.email} has been blocked", "success")
Session.commit()
return redirect(url_for("dashboard.index", highlight_alias_id=alias.id))
else: # ask user confirmation
return render_template("dashboard/unsubscribe.html", alias=alias.email)
@dashboard_bp.route("/block_contact/<int:contact_id>", methods=["GET", "POST"])
@login_required
def block_contact(contact_id):
contact = Contact.get(contact_id)
if not contact:
flash("Incorrect link. Redirect you to the home page", "warning")
return redirect(url_for("dashboard.index"))
if contact.user_id != current_user.id:
flash(
"You don't have access to this page. Redirect you to the home page",
"warning",
)
return redirect(url_for("dashboard.index"))
# automatic unsubscribe, according to https://tools.ietf.org/html/rfc8058
if request.method == "POST":
contact.block_forward = True
flash(f"Emails sent from {contact.website_email} are now blocked", "success")
Session.commit()
return redirect(
url_for(
"dashboard.alias_contact_manager",
alias_id=contact.alias_id,
highlight_contact_id=contact.id,
)
)
else: # ask user confirmation
return render_template("dashboard/block_contact.html", contact=contact)
@dashboard_bp.route("/unsubscribe/encoded/<encoded_request>", methods=["GET"])
@login_required
def encoded_unsubscribe(encoded_request: str):
unsub_data = UnsubscribeHandler().handle_unsubscribe_from_request(
current_user, encoded_request
)
if not unsub_data:
flash(f"Invalid unsubscribe request", "error")
return redirect(url_for("dashboard.index"))
if unsub_data.action == UnsubscribeAction.DisableAlias:
alias = Alias.get(unsub_data.data)
flash(f"Alias {alias.email} has been blocked", "success")
return redirect(url_for("dashboard.index", highlight_alias_id=alias.id))
if unsub_data.action == UnsubscribeAction.DisableContact:
contact = Contact.get(unsub_data.data)
flash(f"Emails sent from {contact.website_email} are now blocked", "success")
return redirect(
url_for(
"dashboard.alias_contact_manager",
alias_id=contact.alias_id,
highlight_contact_id=contact.id,
)
)
if unsub_data.action == UnsubscribeAction.UnsubscribeNewsletter:
flash(f"You've unsubscribed from the newsletter", "success")
return redirect(
url_for(
"dashboard.index",
)
)
if unsub_data.action == UnsubscribeAction.OriginalUnsubscribeMailto:
flash(f"The original unsubscribe request has been forwarded", "success")
return redirect(
url_for(
"dashboard.index",
)
)
return redirect(url_for("dashboard.index"))