4.21.3
This commit is contained in:
35
app/app/dashboard/__init__.py
Normal file
35
app/app/dashboard/__init__.py
Normal 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,
|
||||
)
|
8
app/app/dashboard/base.py
Normal file
8
app/app/dashboard/base.py
Normal file
@ -0,0 +1,8 @@
|
||||
from flask import Blueprint
|
||||
|
||||
dashboard_bp = Blueprint(
|
||||
name="dashboard",
|
||||
import_name=__name__,
|
||||
url_prefix="/dashboard",
|
||||
template_folder="templates",
|
||||
)
|
0
app/app/dashboard/views/__init__.py
Normal file
0
app/app/dashboard/views/__init__.py
Normal file
332
app/app/dashboard/views/alias_contact_manager.py
Normal file
332
app/app/dashboard/views/alias_contact_manager.py
Normal 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,
|
||||
)
|
9
app/app/dashboard/views/alias_export.py
Normal file
9
app/app/dashboard/views/alias_export.py
Normal 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)
|
92
app/app/dashboard/views/alias_log.py
Normal file
92
app/app/dashboard/views/alias_log.py
Normal 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
|
225
app/app/dashboard/views/alias_transfer.py
Normal file
225
app/app/dashboard/views/alias_transfer.py
Normal 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,
|
||||
)
|
66
app/app/dashboard/views/api_key.py
Normal file
66
app/app/dashboard/views/api_key.py
Normal 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
|
||||
)
|
48
app/app/dashboard/views/app.py
Normal file
48
app/app/dashboard/views/app.py
Normal 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,
|
||||
)
|
78
app/app/dashboard/views/batch_import.py
Normal file
78
app/app/dashboard/views/batch_import.py
Normal 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
|
||||
)
|
82
app/app/dashboard/views/billing.py
Normal file
82
app/app/dashboard/views/billing.py
Normal 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)
|
75
app/app/dashboard/views/contact_detail.py
Normal file
75
app/app/dashboard/views/contact_detail.py
Normal 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
|
||||
)
|
116
app/app/dashboard/views/coupon.py
Normal file
116
app/app/dashboard/views/coupon.py
Normal 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),
|
||||
)
|
174
app/app/dashboard/views/custom_alias.py
Normal file
174
app/app/dashboard/views/custom_alias.py
Normal 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,
|
||||
)
|
121
app/app/dashboard/views/custom_domain.py
Normal file
121
app/app/dashboard/views/custom_domain.py
Normal 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,
|
||||
)
|
50
app/app/dashboard/views/delete_account.py
Normal file
50
app/app/dashboard/views/delete_account.py
Normal 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)
|
227
app/app/dashboard/views/directory.py
Normal file
227
app/app/dashboard/views/directory.py
Normal 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,
|
||||
)
|
528
app/app/dashboard/views/domain_detail.py
Normal file
528
app/app/dashboard/views/domain_detail.py
Normal 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())
|
70
app/app/dashboard/views/enter_sudo.py
Normal file
70
app/app/dashboard/views/enter_sudo.py
Normal 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
|
59
app/app/dashboard/views/fido_manage.py
Normal file
59
app/app/dashboard/views/fido_manage.py
Normal 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),
|
||||
)
|
126
app/app/dashboard/views/fido_setup.py
Normal file
126
app/app/dashboard/views/fido_setup.py
Normal 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,
|
||||
)
|
243
app/app/dashboard/views/index.py
Normal file
243
app/app/dashboard/views/index.py
Normal 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
|
||||
)
|
59
app/app/dashboard/views/lifetime_licence.py
Normal file
59
app/app/dashboard/views/lifetime_licence.py
Normal 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)
|
210
app/app/dashboard/views/mailbox.py
Normal file
210
app/app/dashboard/views/mailbox.py
Normal 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)
|
299
app/app/dashboard/views/mailbox_detail.py
Normal file
299
app/app/dashboard/views/mailbox_detail.py
Normal 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"))
|
31
app/app/dashboard/views/mfa_cancel.py
Normal file
31
app/app/dashboard/views/mfa_cancel.py
Normal 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")
|
56
app/app/dashboard/views/mfa_setup.py
Normal file
56
app/app/dashboard/views/mfa_setup.py
Normal 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
|
||||
)
|
61
app/app/dashboard/views/notification.py
Normal file
61
app/app/dashboard/views/notification.py
Normal 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,
|
||||
)
|
101
app/app/dashboard/views/pricing.py
Normal file
101
app/app/dashboard/views/pricing.py
Normal 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"])
|
74
app/app/dashboard/views/referral.py
Normal file
74
app/app/dashboard/views/referral.py
Normal 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())
|
39
app/app/dashboard/views/refused_email.py
Normal file
39
app/app/dashboard/views/refused_email.py
Normal 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())
|
498
app/app/dashboard/views/setting.py
Normal file
498
app/app/dashboard/views/setting.py
Normal 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"))
|
23
app/app/dashboard/views/setup_done.py
Normal file
23
app/app/dashboard/views/setup_done.py
Normal 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
|
111
app/app/dashboard/views/subdomain.py
Normal file
111
app/app/dashboard/views/subdomain.py
Normal 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,
|
||||
)
|
124
app/app/dashboard/views/support.py
Normal file
124
app/app/dashboard/views/support.py
Normal 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)
|
113
app/app/dashboard/views/unsubscribe.py
Normal file
113
app/app/dashboard/views/unsubscribe.py
Normal 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"))
|
Reference in New Issue
Block a user