Compare commits

...

28 Commits

Author SHA1 Message Date
0a52e32972 4.35.3 2023-10-05 12:00:06 +01:00
703dcbd0eb 4.35.2 2023-10-03 12:00:06 +01:00
ce7ed69547 4.35.1 2023-10-02 12:00:06 +01:00
4f5564df16 4.35.0 2023-09-29 12:00:06 +01:00
2fee569131 4.34.4 2023-08-31 12:00:06 +01:00
7ea45d6f5d 4.34.3 2023-08-29 20:20:00 +01:00
6d24db50bd 4.34.2 2023-08-25 12:00:05 +01:00
88f270c6a1 4.34.1 2023-08-09 12:00:05 +01:00
0962b1cf29 Update .drone.yml 2023-08-06 17:56:31 +00:00
6051d72691 4.33.3 2023-08-06 17:51:04 +01:00
c31a75a9ef Update README.md 2023-08-06 16:04:57 +00:00
ef289385ff Update README.md 2023-08-06 16:04:47 +00:00
9b12a2ad33 Update README.md 2023-08-06 16:04:41 +00:00
8eb19d88f3 Remove provenance [CI SKIP] 2023-08-06 16:01:04 +00:00
e36e9d3077 4.32.4 2023-08-02 16:49:54 +01:00
b2430cbc5b 4.32.1 2023-07-12 11:00:04 +00:00
1258115397 4.32.0 2023-07-11 11:00:05 +00:00
38c134d903 4.31.0 2023-06-30 11:00:06 +00:00
cd77e4cc2d 4.30.1 2023-06-28 11:00:03 +00:00
87aedf3207 4.30.0 2023-06-27 11:00:04 +00:00
3523c9fc15 4.29.4 2023-06-07 11:00:05 +00:00
a6f4995cb5 4.29.3 2023-06-01 11:00:05 +00:00
727f61a35e 4.28.2 2023-05-16 11:00:09 +00:00
ce5124605a 4.28.1 2023-05-10 11:00:05 +00:00
2c82b03f8d 4.27.0 2023-04-25 11:00:05 +00:00
1b7a6223ac 4.26.1 2023-04-20 11:00:06 +00:00
75331c62a4 4.25.1 2023-04-15 11:00:05 +00:00
3f68a3e640 4.24.0 2023-04-11 11:00:05 +00:00
115 changed files with 7136 additions and 3112 deletions

View File

@ -17,6 +17,7 @@ steps:
image: thegeeklab/drone-docker-buildx image: thegeeklab/drone-docker-buildx
privileged: true privileged: true
settings: settings:
provenance: false
dockerfile: app/Dockerfile dockerfile: app/Dockerfile
context: app context: app
registry: git.mrmeeb.stream registry: git.mrmeeb.stream
@ -35,6 +36,7 @@ steps:
status: status:
- success - success
- failure - failure
- killed
settings: settings:
webhook: webhook:
from_secret: slack_webhook from_secret: slack_webhook

View File

@ -1,9 +1,7 @@
# Simple Login # SimpleLogin
[![Build Status](https://drone.mrmeeb.stream/api/badges/MrMeeb/simple-login/status.svg?ref=refs/heads/main)](https://drone.mrmeeb.stream/MrMeeb/simple-login) This repo exists to automatically capture any releases of the SaaS edition of SimpleLogin. It checks the simplelogin/app GitHub repo once a day, and builds the latest release automatically if it is newer than the currently built version.
This repo exists to automatically capture any releases of the SaaS edition of SimpleLogin. It checks once a day, and builds the latest one automatically if it is newer than the currentlty built version. I did this to simplify deployment of my self-hosted SimpleLogin instance. SimpleLogin do not provide an up-to-date version for self-hosting, leaving you with the options of either running a very outdated version with no app support, a beta version, or their `simplelogin/app-ci` version. This last option works well if you use an x86 machine, but I'm running SimpleLogin on an ARM machine. Since I don't want to have to build containers on the machine itself, this repo handles that for me.
This exists to simplify deployment of SimpleLogin in a self-hosted capacity, while also allowing the use of the latest version; SimpleLogin do not provide an up-to-date version for this use. As a result, this image is built for both amd64 and arm64 devices.
The image is built for amd64 and arm64 devices.

View File

@ -15,9 +15,15 @@ jobs:
- uses: actions/setup-python@v4 - uses: actions/setup-python@v4
with: with:
python-version: '3.9' python-version: '3.10'
cache: 'poetry' cache: 'poetry'
- name: Install OS dependencies
if: ${{ matrix.python-version }} == '3.10'
run: |
sudo apt update
sudo apt install -y libre2-dev libpq-dev
- name: Install dependencies - name: Install dependencies
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true' if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
run: poetry install --no-interaction run: poetry install --no-interaction

View File

@ -34,7 +34,7 @@ poetry install
On Mac, sometimes you might need to install some other packages via `brew`: On Mac, sometimes you might need to install some other packages via `brew`:
```bash ```bash
brew install pkg-config libffi openssl postgresql brew install pkg-config libffi openssl postgresql@13
``` ```
You also need to install `gpg` tool, on Mac it can be done with: You also need to install `gpg` tool, on Mac it can be done with:
@ -169,6 +169,12 @@ For HTML templates, we use `djlint`. Before creating a pull request, please run
poetry run djlint --check templates poetry run djlint --check templates
``` ```
If some files aren't properly formatted, you can format all files with
```bash
poetry run djlint --reformat .
```
## Test sending email ## Test sending email
[swaks](http://www.jetmore.org/john/code/swaks/) is used for sending test emails to the `email_handler`. [swaks](http://www.jetmore.org/john/code/swaks/) is used for sending test emails to the `email_handler`.

View File

@ -23,10 +23,10 @@ COPY poetry.lock pyproject.toml ./
# Install and setup poetry # Install and setup poetry
RUN pip install -U pip \ RUN pip install -U pip \
&& apt-get update \ && apt-get update \
&& apt install -y curl netcat gcc python3-dev gnupg git libre2-dev \ && apt install -y curl netcat-traditional gcc python3-dev gnupg git libre2-dev \
&& curl -sSL https://install.python-poetry.org | python3 - \ && curl -sSL https://install.python-poetry.org | python3 - \
# Remove curl and netcat from the image # Remove curl and netcat from the image
&& apt-get purge -y curl netcat \ && apt-get purge -y curl netcat-traditional \
# Run poetry # Run poetry
&& poetry config virtualenvs.create false \ && poetry config virtualenvs.create false \
&& poetry install --no-interaction --no-ansi --no-root \ && poetry install --no-interaction --no-ansi --no-root \

View File

@ -12,6 +12,7 @@ from app.utils import sanitize_email
from app.errors import ( from app.errors import (
AccountAlreadyLinkedToAnotherPartnerException, AccountAlreadyLinkedToAnotherPartnerException,
AccountIsUsingAliasAsEmail, AccountIsUsingAliasAsEmail,
AccountAlreadyLinkedToAnotherUserException,
) )
from app.log import LOG from app.log import LOG
from app.models import ( from app.models import (
@ -179,7 +180,7 @@ class ExistingUnlinkedUserStrategy(ClientMergeStrategy):
class LinkedWithAnotherPartnerUserStrategy(ClientMergeStrategy): class LinkedWithAnotherPartnerUserStrategy(ClientMergeStrategy):
def process(self) -> LinkResult: def process(self) -> LinkResult:
raise AccountAlreadyLinkedToAnotherPartnerException() raise AccountAlreadyLinkedToAnotherUserException()
def get_login_strategy( def get_login_strategy(
@ -207,13 +208,14 @@ def process_login_case(
) -> LinkResult: ) -> LinkResult:
# Sanitize email just in case # Sanitize email just in case
link_request.email = sanitize_email(link_request.email) link_request.email = sanitize_email(link_request.email)
check_alias(link_request.email)
# Try to find a SimpleLogin user registered with that partner user id # Try to find a SimpleLogin user registered with that partner user id
partner_user = PartnerUser.get_by( partner_user = PartnerUser.get_by(
partner_id=partner.id, external_user_id=link_request.external_user_id partner_id=partner.id, external_user_id=link_request.external_user_id
) )
if partner_user is None: if partner_user is None:
# We didn't find any SimpleLogin user registered with that partner user id # We didn't find any SimpleLogin user registered with that partner user id
# Make sure they aren't using an alias as their link email
check_alias(link_request.email)
# Try to find it using the partner's e-mail address # Try to find it using the partner's e-mail address
user = User.get_by(email=link_request.email) user = User.get_by(email=link_request.email)
return get_login_strategy(link_request, user, partner).process() return get_login_strategy(link_request, user, partner).process()

View File

@ -256,6 +256,17 @@ class UserAdmin(SLModelView):
Session.commit() Session.commit()
@action(
"clear_delete_on",
"Remove scheduled deletion of user",
"This will remove the scheduled deletion for this users",
)
def clean_delete_on(self, ids):
for user in User.filter(User.id.in_(ids)):
user.delete_on = None
Session.commit()
# @action( # @action(
# "login_as", # "login_as",
# "Login as this user", # "Login as this user",

View File

@ -6,8 +6,7 @@ from typing import Optional
import itsdangerous import itsdangerous
from app import config from app import config
from app.log import LOG from app.log import LOG
from app.models import User from app.models import User, AliasOptions, SLDomain
signer = itsdangerous.TimestampSigner(config.CUSTOM_ALIAS_SECRET) signer = itsdangerous.TimestampSigner(config.CUSTOM_ALIAS_SECRET)
@ -43,7 +42,9 @@ def check_suffix_signature(signed_suffix: str) -> Optional[str]:
return None return None
def verify_prefix_suffix(user: User, alias_prefix, alias_suffix) -> bool: def verify_prefix_suffix(
user: User, alias_prefix, alias_suffix, alias_options: Optional[AliasOptions] = None
) -> bool:
"""verify if user could create an alias with the given prefix and suffix""" """verify if user could create an alias with the given prefix and suffix"""
if not alias_prefix or not alias_suffix: # should be caught on frontend if not alias_prefix or not alias_suffix: # should be caught on frontend
return False return False
@ -56,7 +57,7 @@ def verify_prefix_suffix(user: User, alias_prefix, alias_suffix) -> bool:
alias_domain_prefix, alias_domain = alias_suffix.split("@", 1) alias_domain_prefix, alias_domain = alias_suffix.split("@", 1)
# alias_domain must be either one of user custom domains or built-in domains # alias_domain must be either one of user custom domains or built-in domains
if alias_domain not in user.available_alias_domains(): if alias_domain not in user.available_alias_domains(alias_options=alias_options):
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user) LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
return False return False
@ -64,7 +65,7 @@ def verify_prefix_suffix(user: User, alias_prefix, alias_suffix) -> bool:
# 1) alias_suffix must start with "." and # 1) alias_suffix must start with "." and
# 2) alias_domain_prefix must come from the word list # 2) alias_domain_prefix must come from the word list
if ( if (
alias_domain in user.available_sl_domains() alias_domain in user.available_sl_domains(alias_options=alias_options)
and alias_domain not in user_custom_domains and alias_domain not in user_custom_domains
# when DISABLE_ALIAS_SUFFIX is true, alias_domain_prefix is empty # when DISABLE_ALIAS_SUFFIX is true, alias_domain_prefix is empty
and not config.DISABLE_ALIAS_SUFFIX and not config.DISABLE_ALIAS_SUFFIX
@ -80,14 +81,18 @@ def verify_prefix_suffix(user: User, alias_prefix, alias_suffix) -> bool:
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user) LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
return False return False
if alias_domain not in user.available_sl_domains(): if alias_domain not in user.available_sl_domains(
alias_options=alias_options
):
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user) LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
return False return False
return True return True
def get_alias_suffixes(user: User) -> [AliasSuffix]: def get_alias_suffixes(
user: User, alias_options: Optional[AliasOptions] = None
) -> [AliasSuffix]:
""" """
Similar to as get_available_suffixes() but also return custom domain that doesn't have MX set up. Similar to as get_available_suffixes() but also return custom domain that doesn't have MX set up.
""" """
@ -99,7 +104,9 @@ def get_alias_suffixes(user: User) -> [AliasSuffix]:
# for each user domain, generate both the domain and a random suffix version # for each user domain, generate both the domain and a random suffix version
for custom_domain in user_custom_domains: for custom_domain in user_custom_domains:
if custom_domain.random_prefix_generation: if custom_domain.random_prefix_generation:
suffix = "." + user.get_random_alias_suffix() + "@" + custom_domain.domain suffix = (
f".{user.get_random_alias_suffix(custom_domain)}@{custom_domain.domain}"
)
alias_suffix = AliasSuffix( alias_suffix = AliasSuffix(
is_custom=True, is_custom=True,
suffix=suffix, suffix=suffix,
@ -113,7 +120,7 @@ def get_alias_suffixes(user: User) -> [AliasSuffix]:
else: else:
alias_suffixes.append(alias_suffix) alias_suffixes.append(alias_suffix)
suffix = "@" + custom_domain.domain suffix = f"@{custom_domain.domain}"
alias_suffix = AliasSuffix( alias_suffix = AliasSuffix(
is_custom=True, is_custom=True,
suffix=suffix, suffix=suffix,
@ -134,16 +141,13 @@ def get_alias_suffixes(user: User) -> [AliasSuffix]:
alias_suffixes.append(alias_suffix) alias_suffixes.append(alias_suffix)
# then SimpleLogin domain # then SimpleLogin domain
for sl_domain in user.get_sl_domains(): sl_domains = user.get_sl_domains(alias_options=alias_options)
suffix = ( default_domain_found = False
( for sl_domain in sl_domains:
"" prefix = (
if config.DISABLE_ALIAS_SUFFIX "" if config.DISABLE_ALIAS_SUFFIX else f".{user.get_random_alias_suffix()}"
else "." + user.get_random_alias_suffix()
)
+ "@"
+ sl_domain.domain
) )
suffix = f"{prefix}@{sl_domain.domain}"
alias_suffix = AliasSuffix( alias_suffix = AliasSuffix(
is_custom=False, is_custom=False,
suffix=suffix, suffix=suffix,
@ -152,11 +156,36 @@ def get_alias_suffixes(user: User) -> [AliasSuffix]:
domain=sl_domain.domain, domain=sl_domain.domain,
mx_verified=True, mx_verified=True,
) )
# No default or this is not the default
# put the default domain to top if (
if user.default_alias_public_domain_id == sl_domain.id: user.default_alias_public_domain_id is None
alias_suffixes.insert(0, alias_suffix) or user.default_alias_public_domain_id != sl_domain.id
else: ):
alias_suffixes.append(alias_suffix) alias_suffixes.append(alias_suffix)
else:
default_domain_found = True
alias_suffixes.insert(0, alias_suffix)
if not default_domain_found:
domain_conditions = {"id": user.default_alias_public_domain_id, "hidden": False}
if not user.is_premium():
domain_conditions["premium_only"] = False
sl_domain = SLDomain.get_by(**domain_conditions)
if sl_domain:
prefix = (
""
if config.DISABLE_ALIAS_SUFFIX
else f".{user.get_random_alias_suffix()}"
)
suffix = f"{prefix}@{sl_domain.domain}"
alias_suffix = AliasSuffix(
is_custom=False,
suffix=suffix,
signed_suffix=signer.sign(suffix).decode(),
is_premium=sl_domain.premium_only,
domain=sl_domain.domain,
mx_verified=True,
)
alias_suffixes.insert(0, alias_suffix)
return alias_suffixes return alias_suffixes

View File

@ -21,6 +21,8 @@ from app.email_utils import (
send_cannot_create_directory_alias_disabled, send_cannot_create_directory_alias_disabled,
get_email_local_part, get_email_local_part,
send_cannot_create_domain_alias, send_cannot_create_domain_alias,
send_email,
render,
) )
from app.errors import AliasInTrashError from app.errors import AliasInTrashError
from app.log import LOG from app.log import LOG
@ -36,6 +38,8 @@ from app.models import (
EmailLog, EmailLog,
Contact, Contact,
AutoCreateRule, AutoCreateRule,
AliasUsedOn,
ClientUser,
) )
from app.regex_utils import regex_match from app.regex_utils import regex_match
@ -57,6 +61,8 @@ def get_user_if_alias_would_auto_create(
domain_and_rule = check_if_alias_can_be_auto_created_for_custom_domain( domain_and_rule = check_if_alias_can_be_auto_created_for_custom_domain(
address, notify_user=notify_user address, notify_user=notify_user
) )
if DomainDeletedAlias.get_by(email=address):
return None
if domain_and_rule: if domain_and_rule:
return domain_and_rule[0].user return domain_and_rule[0].user
directory = check_if_alias_can_be_auto_created_for_a_directory( directory = check_if_alias_can_be_auto_created_for_a_directory(
@ -397,3 +403,58 @@ def alias_export_csv(user, csv_direct_export=False):
output.headers["Content-Disposition"] = "attachment; filename=aliases.csv" output.headers["Content-Disposition"] = "attachment; filename=aliases.csv"
output.headers["Content-type"] = "text/csv" output.headers["Content-type"] = "text/csv"
return output return output
def transfer_alias(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()

View File

@ -9,6 +9,7 @@ from requests import RequestException
from app.api.base import api_bp, require_api_auth from app.api.base import api_bp, require_api_auth
from app.config import APPLE_API_SECRET, MACAPP_APPLE_API_SECRET from app.config import APPLE_API_SECRET, MACAPP_APPLE_API_SECRET
from app.subscription_webhook import execute_subscription_webhook
from app.db import Session from app.db import Session
from app.log import LOG from app.log import LOG
from app.models import PlanEnum, AppleSubscription from app.models import PlanEnum, AppleSubscription
@ -50,6 +51,7 @@ def apple_process_payment():
apple_sub = verify_receipt(receipt_data, user, password) apple_sub = verify_receipt(receipt_data, user, password)
if apple_sub: if apple_sub:
execute_subscription_webhook(user)
return jsonify(ok=True), 200 return jsonify(ok=True), 200
return jsonify(error="Processing failed"), 400 return jsonify(error="Processing failed"), 400
@ -282,6 +284,7 @@ def apple_update_notification():
apple_sub.plan = plan apple_sub.plan = plan
apple_sub.product_id = transaction["product_id"] apple_sub.product_id = transaction["product_id"]
Session.commit() Session.commit()
execute_subscription_webhook(user)
return jsonify(ok=True), 200 return jsonify(ok=True), 200
else: else:
LOG.w( LOG.w(
@ -554,6 +557,7 @@ def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]:
product_id=latest_transaction["product_id"], product_id=latest_transaction["product_id"],
) )
execute_subscription_webhook(user)
Session.commit() Session.commit()
return apple_sub return apple_sub

View File

@ -63,6 +63,11 @@ def auth_login():
elif user.disabled: elif user.disabled:
LoginEvent(LoginEvent.ActionType.disabled_login, LoginEvent.Source.api).send() LoginEvent(LoginEvent.ActionType.disabled_login, LoginEvent.Source.api).send()
return jsonify(error="Account disabled"), 400 return jsonify(error="Account disabled"), 400
elif user.delete_on is not None:
LoginEvent(
LoginEvent.ActionType.scheduled_to_be_deleted, LoginEvent.Source.api
).send()
return jsonify(error="Account scheduled for deletion"), 400
elif not user.activated: elif not user.activated:
LoginEvent(LoginEvent.ActionType.not_activated, LoginEvent.Source.api).send() LoginEvent(LoginEvent.ActionType.not_activated, LoginEvent.Source.api).send()
return jsonify(error="Account not activated"), 422 return jsonify(error="Account not activated"), 422
@ -357,7 +362,7 @@ def auth_payload(user, device) -> dict:
@api_bp.route("/auth/forgot_password", methods=["POST"]) @api_bp.route("/auth/forgot_password", methods=["POST"])
@limiter.limit("10/minute") @limiter.limit("2/minute")
def forgot_password(): def forgot_password():
""" """
User forgot password User forgot password

View File

@ -13,8 +13,8 @@ from app.db import Session
from app.email_utils import ( from app.email_utils import (
mailbox_already_used, mailbox_already_used,
email_can_be_used_as_mailbox, email_can_be_used_as_mailbox,
is_valid_email,
) )
from app.email_validation import is_valid_email
from app.log import LOG from app.log import LOG
from app.models import Mailbox, Job from app.models import Mailbox, Job
from app.utils import sanitize_email from app.utils import sanitize_email

View File

@ -1,4 +1,5 @@
import base64 import base64
import dataclasses
from io import BytesIO from io import BytesIO
from typing import Optional from typing import Optional
@ -7,6 +8,7 @@ from flask import jsonify, g, request, make_response
from app import s3, config from app import s3, config
from app.api.base import api_bp, require_api_auth from app.api.base import api_bp, require_api_auth
from app.config import SESSION_COOKIE_NAME from app.config import SESSION_COOKIE_NAME
from app.dashboard.views.index import get_stats
from app.db import Session from app.db import Session
from app.models import ApiKey, File, PartnerUser, User from app.models import ApiKey, File, PartnerUser, User
from app.proton.utils import get_proton_partner from app.proton.utils import get_proton_partner
@ -136,3 +138,22 @@ def logout():
response.delete_cookie(SESSION_COOKIE_NAME) response.delete_cookie(SESSION_COOKIE_NAME)
return response return response
@api_bp.route("/stats")
@require_api_auth
def user_stats():
"""
Return stats
Output as json
- nb_alias
- nb_forward
- nb_reply
- nb_block
"""
user = g.user
stats = get_stats(user)
return jsonify(dataclasses.asdict(stats))

View File

@ -1,4 +1,4 @@
from flask import request, render_template, redirect, url_for, flash, g from flask import request, render_template, flash, g
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, validators from wtforms import StringField, validators
@ -16,7 +16,7 @@ class ForgotPasswordForm(FlaskForm):
@auth_bp.route("/forgot_password", methods=["GET", "POST"]) @auth_bp.route("/forgot_password", methods=["GET", "POST"])
@limiter.limit( @limiter.limit(
"10/minute", deduct_when=lambda r: hasattr(g, "deduct_limit") and g.deduct_limit "10/hour", deduct_when=lambda r: hasattr(g, "deduct_limit") and g.deduct_limit
) )
def forgot_password(): def forgot_password():
form = ForgotPasswordForm(request.form) form = ForgotPasswordForm(request.form)
@ -37,6 +37,5 @@ def forgot_password():
if user: if user:
LOG.d("Send forgot password email to %s", user) LOG.d("Send forgot password email to %s", user)
send_reset_password_email(user) send_reset_password_email(user)
return redirect(url_for("auth.forgot_password"))
return render_template("auth/forgot_password.html", form=form) return render_template("auth/forgot_password.html", form=form)

View File

@ -54,6 +54,12 @@ def login():
"error", "error",
) )
LoginEvent(LoginEvent.ActionType.disabled_login).send() LoginEvent(LoginEvent.ActionType.disabled_login).send()
elif user.delete_on is not None:
flash(
f"Your account is scheduled to be deleted on {user.delete_on}",
"error",
)
LoginEvent(LoginEvent.ActionType.scheduled_to_be_deleted).send()
elif not user.activated: elif not user.activated:
show_resend_activation = True show_resend_activation = True
flash( flash(

View File

@ -60,8 +60,8 @@ def reset_password():
# this can be served to activate user too # this can be served to activate user too
user.activated = True user.activated = True
# remove the reset password code # remove all reset password codes
ResetPasswordCode.delete(reset_password_code.id) ResetPasswordCode.filter_by(user_id=user.id).delete()
# change the alternative_id to log user out on other browsers # change the alternative_id to log user out on other browsers
user.alternative_id = str(uuid.uuid4()) user.alternative_id = str(uuid.uuid4())

View File

@ -532,3 +532,10 @@ if ENABLE_ALL_REVERSE_ALIAS_REPLACEMENT:
SKIP_MX_LOOKUP_ON_CHECK = False SKIP_MX_LOOKUP_ON_CHECK = False
DISABLE_RATE_LIMIT = "DISABLE_RATE_LIMIT" in os.environ DISABLE_RATE_LIMIT = "DISABLE_RATE_LIMIT" in os.environ
SUBSCRIPTION_CHANGE_WEBHOOK = os.environ.get("SUBSCRIPTION_CHANGE_WEBHOOK", None)
MAX_API_KEYS = int(os.environ.get("MAX_API_KEYS", 30))
UPCLOUD_USERNAME = os.environ.get("UPCLOUD_USERNAME", None)
UPCLOUD_PASSWORD = os.environ.get("UPCLOUD_PASSWORD", None)
UPCLOUD_DB_ID = os.environ.get("UPCLOUD_DB_ID", None)

View File

@ -13,10 +13,10 @@ from app import config, parallel_limiter
from app.dashboard.base import dashboard_bp from app.dashboard.base import dashboard_bp
from app.db import Session from app.db import Session
from app.email_utils import ( from app.email_utils import (
is_valid_email,
generate_reply_email, generate_reply_email,
parse_full_address, parse_full_address,
) )
from app.email_validation import is_valid_email
from app.errors import ( from app.errors import (
CannotCreateContactForReverseAlias, CannotCreateContactForReverseAlias,
ErrContactErrorUpgradeNeeded, ErrContactErrorUpgradeNeeded,
@ -90,7 +90,7 @@ def create_contact(user: User, alias: Alias, contact_address: str) -> Contact:
alias_id=alias.id, alias_id=alias.id,
website_email=contact_email, website_email=contact_email,
name=contact_name, name=contact_name,
reply_email=generate_reply_email(contact_email, user), reply_email=generate_reply_email(contact_email, alias),
) )
LOG.d( LOG.d(

View File

@ -7,79 +7,19 @@ from flask import render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app import config from app import config
from app.alias_utils import transfer_alias
from app.dashboard.base import dashboard_bp from app.dashboard.base import dashboard_bp
from app.dashboard.views.enter_sudo import sudo_required from app.dashboard.views.enter_sudo import sudo_required
from app.db import Session from app.db import Session
from app.email_utils import send_email, render
from app.extensions import limiter from app.extensions import limiter
from app.log import LOG from app.log import LOG
from app.models import ( from app.models import (
Alias, Alias,
Contact,
AliasUsedOn,
AliasMailbox,
User,
ClientUser,
) )
from app.models import Mailbox from app.models import Mailbox
from app.utils import CSRFValidationForm 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: def hmac_alias_transfer_token(transfer_token: str) -> str:
alias_hmac = hmac.new( alias_hmac = hmac.new(
config.ALIAS_TRANSFER_TOKEN_SECRET.encode("utf-8"), config.ALIAS_TRANSFER_TOKEN_SECRET.encode("utf-8"),
@ -214,7 +154,7 @@ def alias_transfer_receive_route():
mailboxes, mailboxes,
token, token,
) )
transfer(alias, current_user, mailboxes) transfer_alias(alias, current_user, mailboxes)
# reset transfer token # reset transfer token
alias.transfer_token = None alias.transfer_token = None

View File

@ -3,9 +3,11 @@ from flask_login import login_required, current_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, validators from wtforms import StringField, validators
from app import config
from app.dashboard.base import dashboard_bp from app.dashboard.base import dashboard_bp
from app.dashboard.views.enter_sudo import sudo_required from app.dashboard.views.enter_sudo import sudo_required
from app.db import Session from app.db import Session
from app.extensions import limiter
from app.models import ApiKey from app.models import ApiKey
from app.utils import CSRFValidationForm from app.utils import CSRFValidationForm
@ -14,9 +16,34 @@ class NewApiKeyForm(FlaskForm):
name = StringField("Name", validators=[validators.DataRequired()]) name = StringField("Name", validators=[validators.DataRequired()])
def clean_up_unused_or_old_api_keys(user_id: int):
total_keys = ApiKey.filter_by(user_id=user_id).count()
if total_keys <= config.MAX_API_KEYS:
return
# Remove oldest unused
for api_key in (
ApiKey.filter_by(user_id=user_id, last_used=None)
.order_by(ApiKey.created_at.asc())
.all()
):
Session.delete(api_key)
total_keys -= 1
if total_keys <= config.MAX_API_KEYS:
return
# Clean up oldest used
for api_key in (
ApiKey.filter_by(user_id=user_id).order_by(ApiKey.last_used.asc()).all()
):
Session.delete(api_key)
total_keys -= 1
if total_keys <= config.MAX_API_KEYS:
return
@dashboard_bp.route("/api_key", methods=["GET", "POST"]) @dashboard_bp.route("/api_key", methods=["GET", "POST"])
@login_required @login_required
@sudo_required @sudo_required
@limiter.limit("10/hour")
def api_key(): def api_key():
api_keys = ( api_keys = (
ApiKey.filter(ApiKey.user_id == current_user.id) ApiKey.filter(ApiKey.user_id == current_user.id)
@ -50,6 +77,7 @@ def api_key():
elif request.form.get("form-name") == "create": elif request.form.get("form-name") == "create":
if new_api_key_form.validate(): if new_api_key_form.validate():
clean_up_unused_or_old_api_keys(current_user.id)
new_api_key = ApiKey.create( new_api_key = ApiKey.create(
name=new_api_key_form.name.data, user_id=current_user.id name=new_api_key_form.name.data, user_id=current_user.id
) )

View File

@ -68,9 +68,14 @@ def coupon_route():
) )
return redirect(request.url) return redirect(request.url)
coupon.used_by_user_id = current_user.id updated = (
coupon.used = True Session.query(Coupon)
Session.commit() .filter_by(code=code, used=False)
.update({"used_by_user_id": current_user.id, "used": True})
)
if updated != 1:
flash("Coupon is not valid", "error")
return redirect(request.url)
manual_sub: ManualSubscription = ManualSubscription.get_by( manual_sub: ManualSubscription = ManualSubscription.get_by(
user_id=current_user.id user_id=current_user.id

View File

@ -8,6 +8,7 @@ from wtforms import PasswordField, validators
from app.config import CONNECT_WITH_PROTON from app.config import CONNECT_WITH_PROTON
from app.dashboard.base import dashboard_bp from app.dashboard.base import dashboard_bp
from app.extensions import limiter
from app.log import LOG from app.log import LOG
from app.models import PartnerUser from app.models import PartnerUser
from app.proton.utils import get_proton_partner from app.proton.utils import get_proton_partner
@ -21,6 +22,7 @@ class LoginForm(FlaskForm):
@dashboard_bp.route("/enter_sudo", methods=["GET", "POST"]) @dashboard_bp.route("/enter_sudo", methods=["GET", "POST"])
@limiter.limit("3/minute")
@login_required @login_required
def enter_sudo(): def enter_sudo():
password_check_form = LoginForm() password_check_form = LoginForm()

View File

@ -1,3 +1,7 @@
import base64
import binascii
import json
import arrow import arrow
from flask import render_template, request, redirect, url_for, flash from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user from flask_login import login_required, current_user
@ -15,8 +19,8 @@ from app.email_utils import (
mailbox_already_used, mailbox_already_used,
render, render,
send_email, send_email,
is_valid_email,
) )
from app.email_validation import is_valid_email
from app.log import LOG from app.log import LOG
from app.models import Mailbox, Job from app.models import Mailbox, Job
from app.utils import CSRFValidationForm from app.utils import CSRFValidationForm
@ -180,7 +184,9 @@ def mailbox_route():
def send_verification_email(user, mailbox): def send_verification_email(user, mailbox):
s = TimestampSigner(MAILBOX_SECRET) s = TimestampSigner(MAILBOX_SECRET)
mailbox_id_signed = s.sign(str(mailbox.id)).decode() encoded_data = json.dumps([mailbox.id, mailbox.email]).encode("utf-8")
b64_data = base64.urlsafe_b64encode(encoded_data)
mailbox_id_signed = s.sign(b64_data).decode()
verification_url = ( verification_url = (
URL + "/dashboard/mailbox_verify" + f"?mailbox_id={mailbox_id_signed}" URL + "/dashboard/mailbox_verify" + f"?mailbox_id={mailbox_id_signed}"
) )
@ -205,18 +211,30 @@ def send_verification_email(user, mailbox):
@dashboard_bp.route("/mailbox_verify") @dashboard_bp.route("/mailbox_verify")
def mailbox_verify(): def mailbox_verify():
s = TimestampSigner(MAILBOX_SECRET) s = TimestampSigner(MAILBOX_SECRET)
mailbox_id = request.args.get("mailbox_id") mailbox_verify_request = request.args.get("mailbox_id")
try: try:
r_id = int(s.unsign(mailbox_id, max_age=900)) mailbox_raw_data = s.unsign(mailbox_verify_request, max_age=900)
except Exception: except Exception:
flash("Invalid link. Please delete and re-add your mailbox", "error") flash("Invalid link. Please delete and re-add your mailbox", "error")
return redirect(url_for("dashboard.mailbox_route")) return redirect(url_for("dashboard.mailbox_route"))
else: try:
mailbox = Mailbox.get(r_id) decoded_data = base64.urlsafe_b64decode(mailbox_raw_data)
except binascii.Error:
flash("Invalid link. Please delete and re-add your mailbox", "error")
return redirect(url_for("dashboard.mailbox_route"))
mailbox_data = json.loads(decoded_data)
if not isinstance(mailbox_data, list) or len(mailbox_data) != 2:
flash("Invalid link. Please delete and re-add your mailbox", "error")
return redirect(url_for("dashboard.mailbox_route"))
mailbox_id = mailbox_data[0]
mailbox = Mailbox.get(mailbox_id)
if not mailbox: if not mailbox:
flash("Invalid link", "error") flash("Invalid link", "error")
return redirect(url_for("dashboard.mailbox_route")) return redirect(url_for("dashboard.mailbox_route"))
mailbox_email = mailbox_data[1]
if mailbox_email != mailbox.email:
flash("Invalid link", "error")
return redirect(url_for("dashboard.mailbox_route"))
mailbox.verified = True mailbox.verified = True
Session.commit() Session.commit()

View File

@ -30,7 +30,7 @@ class ChangeEmailForm(FlaskForm):
@dashboard_bp.route("/mailbox/<int:mailbox_id>/", methods=["GET", "POST"]) @dashboard_bp.route("/mailbox/<int:mailbox_id>/", methods=["GET", "POST"])
@login_required @login_required
def mailbox_detail_route(mailbox_id): def mailbox_detail_route(mailbox_id):
mailbox = Mailbox.get(mailbox_id) mailbox: Mailbox = Mailbox.get(mailbox_id)
if not mailbox or mailbox.user_id != current_user.id: if not mailbox or mailbox.user_id != current_user.id:
flash("You cannot see this page", "warning") flash("You cannot see this page", "warning")
return redirect(url_for("dashboard.index")) return redirect(url_for("dashboard.index"))
@ -144,6 +144,15 @@ def mailbox_detail_route(mailbox_id):
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id) url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
) )
if mailbox.is_proton():
flash(
"Enabling PGP for a Proton Mail mailbox is redundant and does not add any security benefit",
"info",
)
return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
)
mailbox.pgp_public_key = request.form.get("pgp") mailbox.pgp_public_key = request.form.get("pgp")
try: try:
mailbox.pgp_finger_print = load_public_key_and_check( mailbox.pgp_finger_print = load_public_key_and_check(
@ -182,25 +191,16 @@ def mailbox_detail_route(mailbox_id):
) )
elif request.form.get("form-name") == "generic-subject": elif request.form.get("form-name") == "generic-subject":
if request.form.get("action") == "save": 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") mailbox.generic_subject = request.form.get("generic-subject")
Session.commit() Session.commit()
flash("Generic subject for PGP-encrypted email is enabled", "success") flash("Generic subject is enabled", "success")
return redirect( return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id) url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
) )
elif request.form.get("action") == "remove": elif request.form.get("action") == "remove":
mailbox.generic_subject = None mailbox.generic_subject = None
Session.commit() Session.commit()
flash("Generic subject for PGP-encrypted email is disabled", "success") flash("Generic subject is disabled", "success")
return redirect( return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id) url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
) )

View File

@ -198,6 +198,16 @@ def setting():
) )
return redirect(url_for("dashboard.setting")) return redirect(url_for("dashboard.setting"))
if current_user.profile_picture_id is not None:
current_profile_file = File.get_by(
id=current_user.profile_picture_id
)
if (
current_profile_file is not None
and current_profile_file.user_id == current_user.id
):
s3.delete(current_profile_file.path)
file_path = random_string(30) file_path = random_string(30)
file = File.create(user_id=current_user.id, path=file_path) file = File.create(user_id=current_user.id, path=file_path)
@ -451,8 +461,13 @@ def send_change_email_confirmation(user: User, email_change: EmailChange):
@dashboard_bp.route("/resend_email_change", methods=["GET", "POST"]) @dashboard_bp.route("/resend_email_change", methods=["GET", "POST"])
@limiter.limit("5/hour")
@login_required @login_required
def resend_email_change(): def resend_email_change():
form = CSRFValidationForm()
if not form.validate():
flash("Invalid request. Please try again", "warning")
return redirect(url_for("dashboard.setting"))
email_change = EmailChange.get_by(user_id=current_user.id) email_change = EmailChange.get_by(user_id=current_user.id)
if email_change: if email_change:
# extend email change expiration # extend email change expiration
@ -472,6 +487,10 @@ def resend_email_change():
@dashboard_bp.route("/cancel_email_change", methods=["GET", "POST"]) @dashboard_bp.route("/cancel_email_change", methods=["GET", "POST"])
@login_required @login_required
def cancel_email_change(): def cancel_email_change():
form = CSRFValidationForm()
if not form.validate():
flash("Invalid request. Please try again", "warning")
return redirect(url_for("dashboard.setting"))
email_change = EmailChange.get_by(user_id=current_user.id) email_change = EmailChange.get_by(user_id=current_user.id)
if email_change: if email_change:
EmailChange.delete(email_change.id) EmailChange.delete(email_change.id)

View File

@ -34,7 +34,7 @@ def get_cname_record(hostname) -> Optional[str]:
def get_mx_domains(hostname) -> [(int, str)]: def get_mx_domains(hostname) -> [(int, str)]:
"""return list of (priority, domain name). """return list of (priority, domain name) sorted by priority (lowest priority first)
domain name ends with a "." at the end. domain name ends with a "." at the end.
""" """
try: try:
@ -50,7 +50,7 @@ def get_mx_domains(hostname) -> [(int, str)]:
ret.append((int(parts[0]), parts[1])) ret.append((int(parts[0]), parts[1]))
return ret return sorted(ret, key=lambda prio_domain: prio_domain[0])
_include_spf = "include:" _include_spf = "include:"

View File

@ -20,6 +20,7 @@ X_SPAM_STATUS = "X-Spam-Status"
LIST_UNSUBSCRIBE = "List-Unsubscribe" LIST_UNSUBSCRIBE = "List-Unsubscribe"
LIST_UNSUBSCRIBE_POST = "List-Unsubscribe-Post" LIST_UNSUBSCRIBE_POST = "List-Unsubscribe-Post"
RETURN_PATH = "Return-Path" RETURN_PATH = "Return-Path"
AUTHENTICATION_RESULTS = "Authentication-Results"
# headers used to DKIM sign in order of preference # headers used to DKIM sign in order of preference
DKIM_HEADERS = [ DKIM_HEADERS = [
@ -32,6 +33,7 @@ DKIM_HEADERS = [
SL_DIRECTION = "X-SimpleLogin-Type" SL_DIRECTION = "X-SimpleLogin-Type"
SL_EMAIL_LOG_ID = "X-SimpleLogin-EmailLog-ID" SL_EMAIL_LOG_ID = "X-SimpleLogin-EmailLog-ID"
SL_ENVELOPE_FROM = "X-SimpleLogin-Envelope-From" SL_ENVELOPE_FROM = "X-SimpleLogin-Envelope-From"
SL_ORIGINAL_FROM = "X-SimpleLogin-Original-From"
SL_ENVELOPE_TO = "X-SimpleLogin-Envelope-To" SL_ENVELOPE_TO = "X-SimpleLogin-Envelope-To"
SL_CLIENT_IP = "X-SimpleLogin-Client-IP" SL_CLIENT_IP = "X-SimpleLogin-Client-IP"

View File

@ -54,6 +54,7 @@ from app.models import (
IgnoreBounceSender, IgnoreBounceSender,
InvalidMailboxDomain, InvalidMailboxDomain,
VerpType, VerpType,
available_sl_email,
) )
from app.utils import ( from app.utils import (
random_string, random_string,
@ -827,19 +828,6 @@ def should_add_dkim_signature(domain: str) -> bool:
return False return False
def is_valid_email(email_address: str) -> bool:
"""
Used to check whether an email address is valid
NOT run MX check.
NOT allow unicode.
"""
try:
validate_email(email_address, check_deliverability=False, allow_smtputf8=False)
return True
except EmailNotValidError:
return False
class EmailEncoding(enum.Enum): class EmailEncoding(enum.Enum):
BASE64 = "base64" BASE64 = "base64"
QUOTED = "quoted-printable" QUOTED = "quoted-printable"
@ -950,6 +938,8 @@ def add_header(msg: Message, text_header, html_header=None) -> Message:
for part in msg.get_payload(): for part in msg.get_payload():
if isinstance(part, Message): if isinstance(part, Message):
new_parts.append(add_header(part, text_header, html_header)) new_parts.append(add_header(part, text_header, html_header))
elif isinstance(part, str):
new_parts.append(MIMEText(part))
else: else:
new_parts.append(part) new_parts.append(part)
clone_msg = copy(msg) clone_msg = copy(msg)
@ -958,7 +948,14 @@ def add_header(msg: Message, text_header, html_header=None) -> Message:
elif content_type in ("multipart/mixed", "multipart/signed"): elif content_type in ("multipart/mixed", "multipart/signed"):
new_parts = [] new_parts = []
parts = list(msg.get_payload()) payload = msg.get_payload()
if isinstance(payload, str):
# The message is badly formatted inject as new
new_parts = [MIMEText(text_header, "plain"), MIMEText(payload, "plain")]
clone_msg = copy(msg)
clone_msg.set_payload(new_parts)
return clone_msg
parts = list(payload)
LOG.d("only add header for the first part for %s", content_type) LOG.d("only add header for the first part for %s", content_type)
for ix, part in enumerate(parts): for ix, part in enumerate(parts):
if ix == 0: if ix == 0:
@ -1043,7 +1040,7 @@ def replace(msg: Union[Message, str], old, new) -> Union[Message, str]:
return msg return msg
def generate_reply_email(contact_email: str, user: User) -> str: def generate_reply_email(contact_email: str, alias: Alias) -> str:
""" """
generate a reply_email (aka reverse-alias), make sure it isn't used by any contact generate a reply_email (aka reverse-alias), make sure it isn't used by any contact
""" """
@ -1054,6 +1051,7 @@ def generate_reply_email(contact_email: str, user: User) -> str:
include_sender_in_reverse_alias = False include_sender_in_reverse_alias = False
user = alias.user
# user has set this option explicitly # user has set this option explicitly
if user.include_sender_in_reverse_alias is not None: if user.include_sender_in_reverse_alias is not None:
include_sender_in_reverse_alias = user.include_sender_in_reverse_alias include_sender_in_reverse_alias = user.include_sender_in_reverse_alias
@ -1068,6 +1066,12 @@ def generate_reply_email(contact_email: str, user: User) -> str:
contact_email = contact_email.replace(".", "_") contact_email = contact_email.replace(".", "_")
contact_email = convert_to_alphanumeric(contact_email) contact_email = convert_to_alphanumeric(contact_email)
reply_domain = config.EMAIL_DOMAIN
alias_domain = get_email_domain_part(alias.email)
sl_domain = SLDomain.get_by(domain=alias_domain)
if sl_domain and sl_domain.use_as_reverse_alias:
reply_domain = alias_domain
# not use while to avoid infinite loop # not use while to avoid infinite loop
for _ in range(1000): for _ in range(1000):
if include_sender_in_reverse_alias and contact_email: if include_sender_in_reverse_alias and contact_email:
@ -1075,15 +1079,15 @@ def generate_reply_email(contact_email: str, user: User) -> str:
reply_email = ( reply_email = (
# do not use the ra+ anymore # do not use the ra+ anymore
# f"ra+{contact_email}+{random_string(random_length)}@{config.EMAIL_DOMAIN}" # f"ra+{contact_email}+{random_string(random_length)}@{config.EMAIL_DOMAIN}"
f"{contact_email}_{random_string(random_length)}@{config.EMAIL_DOMAIN}" f"{contact_email}_{random_string(random_length)}@{reply_domain}"
) )
else: else:
random_length = random.randint(20, 50) random_length = random.randint(20, 50)
# do not use the ra+ anymore # do not use the ra+ anymore
# reply_email = f"ra+{random_string(random_length)}@{config.EMAIL_DOMAIN}" # reply_email = f"ra+{random_string(random_length)}@{config.EMAIL_DOMAIN}"
reply_email = f"{random_string(random_length)}@{config.EMAIL_DOMAIN}" reply_email = f"{random_string(random_length)}@{reply_domain}"
if not Contact.get_by(reply_email=reply_email): if available_sl_email(reply_email):
return reply_email return reply_email
raise Exception("Cannot generate reply email") raise Exception("Cannot generate reply email")
@ -1099,26 +1103,6 @@ def is_reverse_alias(address: str) -> bool:
) )
# allow also + and @ that are present in a reply address
_ALLOWED_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.+@"
def normalize_reply_email(reply_email: str) -> str:
"""Handle the case where reply email contains *strange* char that was wrongly generated in the past"""
if not reply_email.isascii():
reply_email = convert_to_id(reply_email)
ret = []
# drop all control characters like shift, separator, etc
for c in reply_email:
if c not in _ALLOWED_CHARS:
ret.append("_")
else:
ret.append(c)
return "".join(ret)
def should_disable(alias: Alias) -> (bool, str): def should_disable(alias: Alias) -> (bool, str):
""" """
Return whether an alias should be disabled and if yes, the reason why Return whether an alias should be disabled and if yes, the reason why

View File

@ -0,0 +1,38 @@
from email_validator import (
validate_email,
EmailNotValidError,
)
from app.utils import convert_to_id
# allow also + and @ that are present in a reply address
_ALLOWED_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.+@"
def is_valid_email(email_address: str) -> bool:
"""
Used to check whether an email address is valid
NOT run MX check.
NOT allow unicode.
"""
try:
validate_email(email_address, check_deliverability=False, allow_smtputf8=False)
return True
except EmailNotValidError:
return False
def normalize_reply_email(reply_email: str) -> str:
"""Handle the case where reply email contains *strange* char that was wrongly generated in the past"""
if not reply_email.isascii():
reply_email = convert_to_id(reply_email)
ret = []
# drop all control characters like shift, separator, etc
for c in reply_email:
if c not in _ALLOWED_CHARS:
ret.append("_")
else:
ret.append(c)
return "".join(ret)

View File

@ -84,6 +84,14 @@ class ErrAddressInvalid(SLException):
return f"{self.address} is not a valid email address" return f"{self.address} is not a valid email address"
class InvalidContactEmailError(SLException):
def __init__(self, website_email: str): # noqa: F821
self.website_email = website_email
def error_for_user(self) -> str:
return f"Cannot create contact with invalid email {self.website_email}"
class ErrContactAlreadyExists(SLException): class ErrContactAlreadyExists(SLException):
"""raised when a contact already exists""" """raised when a contact already exists"""
@ -113,3 +121,10 @@ class AccountAlreadyLinkedToAnotherUserException(LinkException):
class AccountIsUsingAliasAsEmail(LinkException): class AccountIsUsingAliasAsEmail(LinkException):
def __init__(self): def __init__(self):
super().__init__("Your account has an alias as it's email address") super().__init__("Your account has an alias as it's email address")
class ProtonAccountNotVerified(LinkException):
def __init__(self):
super().__init__(
"The Proton account you are trying to use has not been verified"
)

View File

@ -9,6 +9,7 @@ class LoginEvent:
failed = 1 failed = 1
disabled_login = 2 disabled_login = 2
not_activated = 3 not_activated = 3
scheduled_to_be_deleted = 4
class Source(EnumE): class Source(EnumE):
web = 0 web = 0

View File

@ -74,8 +74,8 @@ class UnsubscribeEncoder:
) )
signed_data = cls._get_signer().sign(serialized_data).decode("utf-8") signed_data = cls._get_signer().sign(serialized_data).decode("utf-8")
encoded_request = f"{UNSUB_PREFIX}.{signed_data}" encoded_request = f"{UNSUB_PREFIX}.{signed_data}"
if len(encoded_request) > 256: if len(encoded_request) > 512:
LOG.e("Encoded request is longer than 256 chars") LOG.w("Encoded request is longer than 512 chars")
return encoded_request return encoded_request
@staticmethod @staticmethod

View File

@ -1,4 +1,5 @@
import urllib import urllib
from email.header import Header
from email.message import Message from email.message import Message
from app.email import headers from app.email import headers
@ -9,6 +10,7 @@ from app.handler.unsubscribe_encoder import (
UnsubscribeData, UnsubscribeData,
UnsubscribeOriginalData, UnsubscribeOriginalData,
) )
from app.log import LOG
from app.models import Alias, Contact, UnsubscribeBehaviourEnum from app.models import Alias, Contact, UnsubscribeBehaviourEnum
@ -30,7 +32,10 @@ class UnsubscribeGenerator:
""" """
unsubscribe_data = message[headers.LIST_UNSUBSCRIBE] unsubscribe_data = message[headers.LIST_UNSUBSCRIBE]
if not unsubscribe_data: if not unsubscribe_data:
LOG.info("Email has no unsubscribe header")
return message return message
if isinstance(unsubscribe_data, Header):
unsubscribe_data = str(unsubscribe_data.encode())
raw_methods = [method.strip() for method in unsubscribe_data.split(",")] raw_methods = [method.strip() for method in unsubscribe_data.split(",")]
mailto_unsubs = None mailto_unsubs = None
other_unsubs = [] other_unsubs = []
@ -44,7 +49,9 @@ class UnsubscribeGenerator:
if url_data.scheme == "mailto": if url_data.scheme == "mailto":
query_data = urllib.parse.parse_qs(url_data.query) query_data = urllib.parse.parse_qs(url_data.query)
mailto_unsubs = (url_data.path, query_data.get("subject", [""])[0]) mailto_unsubs = (url_data.path, query_data.get("subject", [""])[0])
LOG.debug(f"Unsub is mailto to {mailto_unsubs}")
else: else:
LOG.debug(f"Unsub has {url_data.scheme} scheme")
other_unsubs.append(method) other_unsubs.append(method)
# If there are non mailto unsubscribe methods, use those in the header # If there are non mailto unsubscribe methods, use those in the header
if other_unsubs: if other_unsubs:
@ -56,18 +63,19 @@ class UnsubscribeGenerator:
add_or_replace_header( add_or_replace_header(
message, headers.LIST_UNSUBSCRIBE_POST, "List-Unsubscribe=One-Click" message, headers.LIST_UNSUBSCRIBE_POST, "List-Unsubscribe=One-Click"
) )
LOG.debug(f"Adding click unsub methods to header {other_unsubs}")
return message return message
if not mailto_unsubs: elif not mailto_unsubs:
message = delete_header(message, headers.LIST_UNSUBSCRIBE) LOG.debug("No unsubs. Deleting all unsub headers")
message = delete_header(message, headers.LIST_UNSUBSCRIBE_POST) delete_header(message, headers.LIST_UNSUBSCRIBE)
delete_header(message, headers.LIST_UNSUBSCRIBE_POST)
return message return message
return self._add_unsubscribe_header( unsub_data = UnsubscribeData(
message,
UnsubscribeData(
UnsubscribeAction.OriginalUnsubscribeMailto, UnsubscribeAction.OriginalUnsubscribeMailto,
UnsubscribeOriginalData(alias.id, mailto_unsubs[0], mailto_unsubs[1]), UnsubscribeOriginalData(alias.id, mailto_unsubs[0], mailto_unsubs[1]),
),
) )
LOG.debug(f"Adding unsub data {unsub_data}")
return self._add_unsubscribe_header(message, unsub_data)
def _add_unsubscribe_header( def _add_unsubscribe_header(
self, message: Message, unsub: UnsubscribeData self, message: Message, unsub: UnsubscribeData

View File

@ -41,7 +41,7 @@ from app.models import (
class ExportUserDataJob: class ExportUserDataJob:
REMOVE_FIELDS = { REMOVE_FIELDS = {
"User": ("otp_secret",), "User": ("otp_secret", "password"),
"Alias": ("ts_vector", "transfer_token", "hibp_last_check"), "Alias": ("ts_vector", "transfer_token", "hibp_last_check"),
"CustomDomain": ("ownership_txt_token",), "CustomDomain": ("ownership_txt_token",),
} }

View File

@ -32,6 +32,7 @@ class SendRequest:
rcpt_options: Dict = {} rcpt_options: Dict = {}
is_forward: bool = False is_forward: bool = False
ignore_smtp_errors: bool = False ignore_smtp_errors: bool = False
retries: int = 0
def to_bytes(self) -> bytes: def to_bytes(self) -> bytes:
if not config.SAVE_UNSENT_DIR: if not config.SAVE_UNSENT_DIR:
@ -45,6 +46,7 @@ class SendRequest:
"mail_options": self.mail_options, "mail_options": self.mail_options,
"rcpt_options": self.rcpt_options, "rcpt_options": self.rcpt_options,
"is_forward": self.is_forward, "is_forward": self.is_forward,
"retries": self.retries,
} }
return json.dumps(data).encode("utf-8") return json.dumps(data).encode("utf-8")
@ -65,8 +67,33 @@ class SendRequest:
mail_options=decoded_data["mail_options"], mail_options=decoded_data["mail_options"],
rcpt_options=decoded_data["rcpt_options"], rcpt_options=decoded_data["rcpt_options"],
is_forward=decoded_data["is_forward"], is_forward=decoded_data["is_forward"],
retries=decoded_data.get("retries", 1),
) )
def save_request_to_unsent_dir(self, prefix: str = "DeliveryFail"):
file_name = (
f"{prefix}-{int(time.time())}-{uuid.uuid4()}.{SendRequest.SAVE_EXTENSION}"
)
file_path = os.path.join(config.SAVE_UNSENT_DIR, file_name)
self.save_request_to_file(file_path)
@staticmethod
def save_request_to_failed_dir(self, prefix: str = "DeliveryRetryFail"):
file_name = (
f"{prefix}-{int(time.time())}-{uuid.uuid4()}.{SendRequest.SAVE_EXTENSION}"
)
dir_name = os.path.join(config.SAVE_UNSENT_DIR, "failed")
if not os.path.isdir(dir_name):
os.makedirs(dir_name)
file_path = os.path.join(dir_name, file_name)
self.save_request_to_file(file_path)
def save_request_to_file(self, file_path: str):
file_contents = self.to_bytes()
with open(file_path, "wb") as fd:
fd.write(file_contents)
LOG.i(f"Saved unsent message {file_path}")
class MailSender: class MailSender:
def __init__(self): def __init__(self):
@ -171,21 +198,9 @@ class MailSender:
f"Could not send message to smtp server {config.POSTFIX_SERVER}:{config.POSTFIX_PORT}" f"Could not send message to smtp server {config.POSTFIX_SERVER}:{config.POSTFIX_PORT}"
) )
if config.SAVE_UNSENT_DIR: if config.SAVE_UNSENT_DIR:
self._save_request_to_unsent_dir(send_request) send_request.save_request_to_unsent_dir()
return False return False
def _save_request_to_unsent_dir(
self, send_request: SendRequest, prefix: str = "DeliveryFail"
):
file_name = (
f"{prefix}-{int(time.time())}-{uuid.uuid4()}.{SendRequest.SAVE_EXTENSION}"
)
file_path = os.path.join(config.SAVE_UNSENT_DIR, file_name)
file_contents = send_request.to_bytes()
with open(file_path, "wb") as fd:
fd.write(file_contents)
LOG.i(f"Saved unsent message {file_path}")
mail_sender = MailSender() mail_sender = MailSender()
@ -219,6 +234,7 @@ def load_unsent_mails_from_fs_and_resend():
LOG.i(f"Trying to re-deliver email {filename}") LOG.i(f"Trying to re-deliver email {filename}")
try: try:
send_request = SendRequest.load_from_file(full_file_path) send_request = SendRequest.load_from_file(full_file_path)
send_request.retries += 1
except Exception as e: except Exception as e:
LOG.e(f"Cannot load {filename}. Error {e}") LOG.e(f"Cannot load {filename}. Error {e}")
continue continue
@ -230,6 +246,11 @@ def load_unsent_mails_from_fs_and_resend():
"DeliverUnsentEmail", {"delivered": "true"} "DeliverUnsentEmail", {"delivered": "true"}
) )
else: else:
if send_request.retries > 2:
os.unlink(full_file_path)
send_request.save_request_to_failed_dir()
else:
send_request.save_request_to_file(full_file_path)
newrelic.agent.record_custom_event( newrelic.agent.record_custom_event(
"DeliverUnsentEmail", {"delivered": "false"} "DeliverUnsentEmail", {"delivered": "false"}
) )

View File

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import base64 import base64
import dataclasses
import enum import enum
import hashlib import hashlib
import hmac import hmac
@ -18,7 +19,7 @@ from flanker.addresslib import address
from flask import url_for from flask import url_for
from flask_login import UserMixin from flask_login import UserMixin
from jinja2 import FileSystemLoader, Environment from jinja2 import FileSystemLoader, Environment
from sqlalchemy import orm from sqlalchemy import orm, or_
from sqlalchemy import text, desc, CheckConstraint, Index, Column from sqlalchemy import text, desc, CheckConstraint, Index, Column
from sqlalchemy.dialects.postgresql import TSVECTOR from sqlalchemy.dialects.postgresql import TSVECTOR
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
@ -29,6 +30,8 @@ from sqlalchemy_utils import ArrowType
from app import config from app import config
from app import s3 from app import s3
from app.db import Session from app.db import Session
from app.dns_utils import get_mx_domains
from app.errors import ( from app.errors import (
AliasInTrashError, AliasInTrashError,
DirectoryInTrashError, DirectoryInTrashError,
@ -273,6 +276,13 @@ class IntEnumType(sa.types.TypeDecorator):
return self._enum_type(enum_value) return self._enum_type(enum_value)
@dataclasses.dataclass
class AliasOptions:
show_sl_domains: bool = True
show_partner_domains: Optional[Partner] = None
show_partner_premium: Optional[bool] = None
class Hibp(Base, ModelMixin): class Hibp(Base, ModelMixin):
__tablename__ = "hibp" __tablename__ = "hibp"
name = sa.Column(sa.String(), nullable=False, unique=True, index=True) name = sa.Column(sa.String(), nullable=False, unique=True, index=True)
@ -291,7 +301,9 @@ class HibpNotifiedAlias(Base, ModelMixin):
""" """
__tablename__ = "hibp_notified_alias" __tablename__ = "hibp_notified_alias"
alias_id = sa.Column(sa.ForeignKey("alias.id", ondelete="cascade"), nullable=False) alias_id = sa.Column(
sa.ForeignKey("alias.id", ondelete="cascade"), nullable=False, index=True
)
user_id = sa.Column(sa.ForeignKey("users.id", ondelete="cascade"), nullable=False) user_id = sa.Column(sa.ForeignKey("users.id", ondelete="cascade"), nullable=False)
notified_at = sa.Column(ArrowType, default=arrow.utcnow, nullable=False) notified_at = sa.Column(ArrowType, default=arrow.utcnow, nullable=False)
@ -332,7 +344,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
sa.Boolean, default=True, nullable=False, server_default="1" sa.Boolean, default=True, nullable=False, server_default="1"
) )
activated = sa.Column(sa.Boolean, default=False, nullable=False) activated = sa.Column(sa.Boolean, default=False, nullable=False, index=True)
# an account can be disabled if having harmful behavior # an account can be disabled if having harmful behavior
disabled = sa.Column(sa.Boolean, default=False, nullable=False, server_default="0") disabled = sa.Column(sa.Boolean, default=False, nullable=False, server_default="0")
@ -402,7 +414,10 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
) )
referral_id = sa.Column( referral_id = sa.Column(
sa.ForeignKey("referral.id", ondelete="SET NULL"), nullable=True, default=None sa.ForeignKey("referral.id", ondelete="SET NULL"),
nullable=True,
default=None,
index=True,
) )
referral = orm.relationship("Referral", foreign_keys=[referral_id]) referral = orm.relationship("Referral", foreign_keys=[referral_id])
@ -419,7 +434,10 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
# newsletter is sent to this address # newsletter is sent to this address
newsletter_alias_id = sa.Column( newsletter_alias_id = sa.Column(
sa.ForeignKey("alias.id", ondelete="SET NULL"), nullable=True, default=None sa.ForeignKey("alias.id", ondelete="SET NULL"),
nullable=True,
default=None,
index=True,
) )
# whether to include the sender address in reverse-alias # whether to include the sender address in reverse-alias
@ -433,7 +451,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
random_alias_suffix = sa.Column( random_alias_suffix = sa.Column(
sa.Integer, sa.Integer,
nullable=False, nullable=False,
default=AliasSuffixEnum.random_string.value, default=AliasSuffixEnum.word.value,
server_default=str(AliasSuffixEnum.random_string.value), server_default=str(AliasSuffixEnum.random_string.value),
) )
@ -502,9 +520,8 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
server_default=BlockBehaviourEnum.return_2xx.name, server_default=BlockBehaviourEnum.return_2xx.name,
) )
# to keep existing behavior, the server default is TRUE whereas for new user, the default value is FALSE
include_header_email_header = sa.Column( include_header_email_header = sa.Column(
sa.Boolean, default=False, nullable=False, server_default="1" sa.Boolean, default=True, nullable=False, server_default="1"
) )
# bitwise flags. Allow for future expansion # bitwise flags. Allow for future expansion
@ -523,6 +540,16 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
nullable=False, nullable=False,
) )
# Trigger hard deletion of the account at this time
delete_on = sa.Column(ArrowType, default=None)
__table_args__ = (
sa.Index(
"ix_users_activated_trial_end_lifetime", activated, trial_end, lifetime
),
sa.Index("ix_users_delete_on", delete_on),
)
@property @property
def directory_quota(self): def directory_quota(self):
return min( return min(
@ -557,7 +584,8 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
@classmethod @classmethod
def create(cls, email, name="", password=None, from_partner=False, **kwargs): def create(cls, email, name="", password=None, from_partner=False, **kwargs):
user: User = super(User, cls).create(email=email, name=name, **kwargs) email = sanitize_email(email)
user: User = super(User, cls).create(email=email, name=name[:100], **kwargs)
if password: if password:
user.set_password(password) user.set_password(password)
@ -568,19 +596,6 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
Session.flush() Session.flush()
user.default_mailbox_id = mb.id user.default_mailbox_id = mb.id
# create a first alias mail to show user how to use when they login
alias = Alias.create_new(
user,
prefix="simplelogin-newsletter",
mailbox_id=mb.id,
note="This is your first alias. It's used to receive SimpleLogin communications "
"like new features announcements, newsletters.",
)
Session.flush()
user.newsletter_alias_id = alias.id
Session.flush()
# generate an alternative_id if needed # generate an alternative_id if needed
if "alternative_id" not in kwargs: if "alternative_id" not in kwargs:
user.alternative_id = str(uuid.uuid4()) user.alternative_id = str(uuid.uuid4())
@ -599,6 +614,19 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
Session.flush() Session.flush()
return user return user
# create a first alias mail to show user how to use when they login
alias = Alias.create_new(
user,
prefix="simplelogin-newsletter",
mailbox_id=mb.id,
note="This is your first alias. It's used to receive SimpleLogin communications "
"like new features announcements, newsletters.",
)
Session.flush()
user.newsletter_alias_id = alias.id
Session.flush()
if config.DISABLE_ONBOARDING: if config.DISABLE_ONBOARDING:
LOG.d("Disable onboarding emails") LOG.d("Disable onboarding emails")
return user return user
@ -624,7 +652,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
return user return user
def get_active_subscription( def get_active_subscription(
self, self, include_partner_subscription: bool = True
) -> Optional[ ) -> Optional[
Union[ Union[
Subscription Subscription
@ -652,19 +680,40 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
if coinbase_subscription and coinbase_subscription.is_active(): if coinbase_subscription and coinbase_subscription.is_active():
return coinbase_subscription return coinbase_subscription
partner_sub: PartnerSubscription = PartnerSubscription.find_by_user_id(self.id) if include_partner_subscription:
partner_sub: PartnerSubscription = PartnerSubscription.find_by_user_id(
self.id
)
if partner_sub and partner_sub.is_active(): if partner_sub and partner_sub.is_active():
return partner_sub return partner_sub
return None return None
def get_active_subscription_end(
self, include_partner_subscription: bool = True
) -> Optional[arrow.Arrow]:
sub = self.get_active_subscription(
include_partner_subscription=include_partner_subscription
)
if isinstance(sub, Subscription):
return arrow.get(sub.next_bill_date)
if isinstance(sub, AppleSubscription):
return sub.expires_date
if isinstance(sub, ManualSubscription):
return sub.end_at
if isinstance(sub, CoinbaseSubscription):
return sub.end_at
return None
# region Billing # region Billing
def lifetime_or_active_subscription(self) -> bool: def lifetime_or_active_subscription(
self, include_partner_subscription: bool = True
) -> bool:
"""True if user has lifetime licence or active subscription""" """True if user has lifetime licence or active subscription"""
if self.lifetime: if self.lifetime:
return True return True
return self.get_active_subscription() is not None return self.get_active_subscription(include_partner_subscription) is not None
def is_paid(self) -> bool: def is_paid(self) -> bool:
"""same as _lifetime_or_active_subscription but not include free manual subscription""" """same as _lifetime_or_active_subscription but not include free manual subscription"""
@ -693,14 +742,14 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
return True return True
def is_premium(self) -> bool: def is_premium(self, include_partner_subscription: bool = True) -> bool:
""" """
user is premium if they: user is premium if they:
- have a lifetime deal or - have a lifetime deal or
- in trial period or - in trial period or
- active subscription - active subscription
""" """
if self.lifetime_or_active_subscription(): if self.lifetime_or_active_subscription(include_partner_subscription):
return True return True
if self.trial_end and arrow.now() < self.trial_end: if self.trial_end and arrow.now() < self.trial_end:
@ -789,6 +838,17 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
< self.max_alias_for_free_account() < self.max_alias_for_free_account()
) )
def can_send_or_receive(self) -> bool:
if self.disabled:
LOG.i(f"User {self} is disabled. Cannot receive or send emails")
return False
if self.delete_on is not None:
LOG.i(
f"User {self} is scheduled to be deleted. Cannot receive or send emails"
)
return False
return True
def profile_picture_url(self): def profile_picture_url(self):
if self.profile_picture_id: if self.profile_picture_id:
return self.profile_picture.get_url() return self.profile_picture.get_url()
@ -867,14 +927,16 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
def custom_domains(self): def custom_domains(self):
return CustomDomain.filter_by(user_id=self.id, verified=True).all() return CustomDomain.filter_by(user_id=self.id, verified=True).all()
def available_domains_for_random_alias(self) -> List[Tuple[bool, str]]: def available_domains_for_random_alias(
self, alias_options: Optional[AliasOptions] = None
) -> List[Tuple[bool, str]]:
"""Return available domains for user to create random aliases """Return available domains for user to create random aliases
Each result record contains: Each result record contains:
- whether the domain belongs to SimpleLogin - whether the domain belongs to SimpleLogin
- the domain - the domain
""" """
res = [] res = []
for domain in self.available_sl_domains(): for domain in self.available_sl_domains(alias_options=alias_options):
res.append((True, domain)) res.append((True, domain))
for custom_domain in self.verified_custom_domains(): for custom_domain in self.verified_custom_domains():
@ -959,30 +1021,65 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
return None, "", False return None, "", False
def available_sl_domains(self) -> [str]: def available_sl_domains(
self, alias_options: Optional[AliasOptions] = None
) -> [str]:
""" """
Return all SimpleLogin domains that user can use when creating a new alias, including: Return all SimpleLogin domains that user can use when creating a new alias, including:
- SimpleLogin public domains, available for all users (ALIAS_DOMAIN) - SimpleLogin public domains, available for all users (ALIAS_DOMAIN)
- SimpleLogin premium domains, only available for Premium accounts (PREMIUM_ALIAS_DOMAIN) - SimpleLogin premium domains, only available for Premium accounts (PREMIUM_ALIAS_DOMAIN)
""" """
return [sl_domain.domain for sl_domain in self.get_sl_domains()] return [
sl_domain.domain
for sl_domain in self.get_sl_domains(alias_options=alias_options)
]
def get_sl_domains(self) -> List["SLDomain"]: def get_sl_domains(
query = SLDomain.filter_by(hidden=False).order_by(SLDomain.order) self, alias_options: Optional[AliasOptions] = None
) -> list["SLDomain"]:
if self.is_premium(): if alias_options is None:
alias_options = AliasOptions()
top_conds = [SLDomain.hidden == False] # noqa: E712
or_conds = [] # noqa:E711
if self.default_alias_public_domain_id is not None:
default_domain_conds = [SLDomain.id == self.default_alias_public_domain_id]
if not self.is_premium():
default_domain_conds.append(
SLDomain.premium_only == False # noqa: E712
)
or_conds.append(and_(*default_domain_conds).self_group())
if alias_options.show_partner_domains is not None:
partner_user = PartnerUser.filter_by(
user_id=self.id, partner_id=alias_options.show_partner_domains.id
).first()
if partner_user is not None:
partner_domain_cond = [SLDomain.partner_id == partner_user.partner_id]
if alias_options.show_partner_premium is None:
alias_options.show_partner_premium = self.is_premium()
if not alias_options.show_partner_premium:
partner_domain_cond.append(
SLDomain.premium_only == False # noqa: E712
)
or_conds.append(and_(*partner_domain_cond).self_group())
if alias_options.show_sl_domains:
sl_conds = [SLDomain.partner_id == None] # noqa: E711
if not self.is_premium():
sl_conds.append(SLDomain.premium_only == False) # noqa: E712
or_conds.append(and_(*sl_conds).self_group())
top_conds.append(or_(*or_conds))
query = Session.query(SLDomain).filter(*top_conds).order_by(SLDomain.order)
return query.all() return query.all()
else:
return query.filter_by(premium_only=False).all()
def available_alias_domains(self) -> [str]: def available_alias_domains(
self, alias_options: Optional[AliasOptions] = None
) -> [str]:
"""return all domains that user can use when creating a new alias, including: """return all domains that user can use when creating a new alias, including:
- SimpleLogin public domains, available for all users (ALIAS_DOMAIN) - SimpleLogin public domains, available for all users (ALIAS_DOMAIN)
- SimpleLogin premium domains, only available for Premium accounts (PREMIUM_ALIAS_DOMAIN) - SimpleLogin premium domains, only available for Premium accounts (PREMIUM_ALIAS_DOMAIN)
- Verified custom domains - Verified custom domains
""" """
domains = self.available_sl_domains() domains = self.available_sl_domains(alias_options=alias_options)
for custom_domain in self.verified_custom_domains(): for custom_domain in self.verified_custom_domains():
domains.append(custom_domain.domain) domains.append(custom_domain.domain)
@ -1000,17 +1097,22 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
> 0 > 0
) )
def get_random_alias_suffix(self): def get_random_alias_suffix(self, custom_domain: Optional["CustomDomain"] = None):
"""Get random suffix for an alias based on user's preference. """Get random suffix for an alias based on user's preference.
Use a shorter suffix in case of custom domain
Returns: Returns:
str: the random suffix generated str: the random suffix generated
""" """
if self.random_alias_suffix == AliasSuffixEnum.random_string.value: if self.random_alias_suffix == AliasSuffixEnum.random_string.value:
return random_string(config.ALIAS_RANDOM_SUFFIX_LENGTH, include_digits=True) return random_string(config.ALIAS_RANDOM_SUFFIX_LENGTH, include_digits=True)
if custom_domain is None:
return random_words(1, 3) return random_words(1, 3)
return random_words(1)
def __repr__(self): def __repr__(self):
return f"<User {self.id} {self.name} {self.email}>" return f"<User {self.id} {self.name} {self.email}>"
@ -1254,16 +1356,30 @@ class OauthToken(Base, ModelMixin):
return self.expired < arrow.now() return self.expired < arrow.now()
def generate_email( def available_sl_email(email: str) -> bool:
if (
Alias.get_by(email=email)
or Contact.get_by(reply_email=email)
or DeletedAlias.get_by(email=email)
):
return False
return True
def generate_random_alias_email(
scheme: int = AliasGeneratorEnum.word.value, scheme: int = AliasGeneratorEnum.word.value,
in_hex: bool = False, in_hex: bool = False,
alias_domain=config.FIRST_ALIAS_DOMAIN, alias_domain: str = config.FIRST_ALIAS_DOMAIN,
retries: int = 10,
) -> str: ) -> str:
"""generate an email address that does not exist before """generate an email address that does not exist before
:param alias_domain: the domain used to generate the alias. :param alias_domain: the domain used to generate the alias.
:param scheme: int, value of AliasGeneratorEnum, indicate how the email is generated :param scheme: int, value of AliasGeneratorEnum, indicate how the email is generated
:param retries: int, How many times we can try to generate an alias in case of collision
:type in_hex: bool, if the generate scheme is uuid, is hex favorable? :type in_hex: bool, if the generate scheme is uuid, is hex favorable?
""" """
if retries <= 0:
raise Exception("Cannot generate alias after many retries")
if scheme == AliasGeneratorEnum.uuid.value: if scheme == AliasGeneratorEnum.uuid.value:
name = uuid.uuid4().hex if in_hex else uuid.uuid4().__str__() name = uuid.uuid4().hex if in_hex else uuid.uuid4().__str__()
random_email = name + "@" + alias_domain random_email = name + "@" + alias_domain
@ -1273,15 +1389,15 @@ def generate_email(
random_email = random_email.lower().strip() random_email = random_email.lower().strip()
# check that the client does not exist yet # check that the client does not exist yet
if not Alias.get_by(email=random_email) and not DeletedAlias.get_by( if available_sl_email(random_email):
email=random_email
):
LOG.d("generate email %s", random_email) LOG.d("generate email %s", random_email)
return random_email return random_email
# Rerun the function # Rerun the function
LOG.w("email %s already exists, generate a new email", random_email) LOG.w("email %s already exists, generate a new email", random_email)
return generate_email(scheme=scheme, in_hex=in_hex) return generate_random_alias_email(
scheme=scheme, in_hex=in_hex, retries=retries - 1
)
class Alias(Base, ModelMixin): class Alias(Base, ModelMixin):
@ -1363,7 +1479,7 @@ class Alias(Base, ModelMixin):
) )
# have I been pwned # have I been pwned
hibp_last_check = sa.Column(ArrowType, default=None) hibp_last_check = sa.Column(ArrowType, default=None, index=True)
hibp_breaches = orm.relationship("Hibp", secondary="alias_hibp") hibp_breaches = orm.relationship("Hibp", secondary="alias_hibp")
# to use Postgres full text search. Only applied on "note" column for now # to use Postgres full text search. Only applied on "note" column for now
@ -1480,7 +1596,7 @@ class Alias(Base, ModelMixin):
suffix = user.get_random_alias_suffix() suffix = user.get_random_alias_suffix()
email = f"{prefix}.{suffix}@{config.FIRST_ALIAS_DOMAIN}" email = f"{prefix}.{suffix}@{config.FIRST_ALIAS_DOMAIN}"
if not cls.get_by(email=email) and not DeletedAlias.get_by(email=email): if available_sl_email(email):
break break
return Alias.create( return Alias.create(
@ -1509,7 +1625,7 @@ class Alias(Base, ModelMixin):
if user.default_alias_custom_domain_id: if user.default_alias_custom_domain_id:
custom_domain = CustomDomain.get(user.default_alias_custom_domain_id) custom_domain = CustomDomain.get(user.default_alias_custom_domain_id)
random_email = generate_email( random_email = generate_random_alias_email(
scheme=scheme, in_hex=in_hex, alias_domain=custom_domain.domain scheme=scheme, in_hex=in_hex, alias_domain=custom_domain.domain
) )
elif user.default_alias_public_domain_id: elif user.default_alias_public_domain_id:
@ -1517,12 +1633,12 @@ class Alias(Base, ModelMixin):
if sl_domain.premium_only and not user.is_premium(): if sl_domain.premium_only and not user.is_premium():
LOG.w("%s not premium, cannot use %s", user, sl_domain) LOG.w("%s not premium, cannot use %s", user, sl_domain)
else: else:
random_email = generate_email( random_email = generate_random_alias_email(
scheme=scheme, in_hex=in_hex, alias_domain=sl_domain.domain scheme=scheme, in_hex=in_hex, alias_domain=sl_domain.domain
) )
if not random_email: if not random_email:
random_email = generate_email(scheme=scheme, in_hex=in_hex) random_email = generate_random_alias_email(scheme=scheme, in_hex=in_hex)
alias = Alias.create( alias = Alias.create(
user_id=user.id, user_id=user.id,
@ -1556,7 +1672,9 @@ class ClientUser(Base, ModelMixin):
client_id = sa.Column(sa.ForeignKey(Client.id, ondelete="cascade"), nullable=False) client_id = sa.Column(sa.ForeignKey(Client.id, ondelete="cascade"), nullable=False)
# Null means client has access to user original email # Null means client has access to user original email
alias_id = sa.Column(sa.ForeignKey(Alias.id, ondelete="cascade"), nullable=True) alias_id = sa.Column(
sa.ForeignKey(Alias.id, ondelete="cascade"), nullable=True, index=True
)
# user can decide to send to client another name # user can decide to send to client another name
name = sa.Column( name = sa.Column(
@ -1675,7 +1793,7 @@ class Contact(Base, ModelMixin):
is_cc = sa.Column(sa.Boolean, nullable=False, default=False, server_default="0") is_cc = sa.Column(sa.Boolean, nullable=False, default=False, server_default="0")
pgp_public_key = sa.Column(sa.Text, nullable=True) pgp_public_key = sa.Column(sa.Text, nullable=True)
pgp_finger_print = sa.Column(sa.String(512), nullable=True) pgp_finger_print = sa.Column(sa.String(512), nullable=True, index=True)
alias = orm.relationship(Alias, backref="contacts") alias = orm.relationship(Alias, backref="contacts")
user = orm.relationship(User) user = orm.relationship(User)
@ -1829,6 +1947,7 @@ class Contact(Base, ModelMixin):
class EmailLog(Base, ModelMixin): class EmailLog(Base, ModelMixin):
__tablename__ = "email_log" __tablename__ = "email_log"
__table_args__ = (Index("ix_email_log_created_at", "created_at"),)
user_id = sa.Column( user_id = sa.Column(
sa.ForeignKey(User.id, ondelete="cascade"), nullable=False, index=True sa.ForeignKey(User.id, ondelete="cascade"), nullable=False, index=True
@ -2086,7 +2205,9 @@ class AliasUsedOn(Base, ModelMixin):
sa.UniqueConstraint("alias_id", "hostname", name="uq_alias_used"), sa.UniqueConstraint("alias_id", "hostname", name="uq_alias_used"),
) )
alias_id = sa.Column(sa.ForeignKey(Alias.id, ondelete="cascade"), nullable=False) alias_id = sa.Column(
sa.ForeignKey(Alias.id, ondelete="cascade"), nullable=False, index=True
)
user_id = sa.Column(sa.ForeignKey(User.id, ondelete="cascade"), nullable=False) user_id = sa.Column(sa.ForeignKey(User.id, ondelete="cascade"), nullable=False)
alias = orm.relationship(Alias) alias = orm.relationship(Alias)
@ -2205,6 +2326,7 @@ class CustomDomain(Base, ModelMixin):
@classmethod @classmethod
def create(cls, **kwargs): def create(cls, **kwargs):
domain = kwargs.get("domain") domain = kwargs.get("domain")
kwargs["domain"] = domain.replace("\n", "")
if DeletedSubdomain.get_by(domain=domain): if DeletedSubdomain.get_by(domain=domain):
raise SubdomainInTrashError raise SubdomainInTrashError
@ -2472,6 +2594,28 @@ class Mailbox(Base, ModelMixin):
+ Alias.filter_by(mailbox_id=self.id).count() + Alias.filter_by(mailbox_id=self.id).count()
) )
def is_proton(self) -> bool:
if (
self.email.endswith("@proton.me")
or self.email.endswith("@protonmail.com")
or self.email.endswith("@protonmail.ch")
or self.email.endswith("@proton.ch")
or self.email.endswith("@pm.me")
):
return True
from app.email_utils import get_email_local_part
mx_domains: [(int, str)] = get_mx_domains(get_email_local_part(self.email))
# Proton is the first domain
if mx_domains and mx_domains[0][1] in (
"mail.protonmail.ch.",
"mailsec.protonmail.ch.",
):
return True
return False
@classmethod @classmethod
def delete(cls, obj_id): def delete(cls, obj_id):
mailbox: Mailbox = cls.get(obj_id) mailbox: Mailbox = cls.get(obj_id)
@ -2504,6 +2648,12 @@ class Mailbox(Base, ModelMixin):
return ret return ret
@classmethod
def create(cls, **kw):
if "email" in kw:
kw["email"] = sanitize_email(kw["email"])
return super().create(**kw)
def __repr__(self): def __repr__(self):
return f"<Mailbox {self.id} {self.email}>" return f"<Mailbox {self.id} {self.email}>"
@ -2763,6 +2913,31 @@ class Notification(Base, ModelMixin):
) )
class Partner(Base, ModelMixin):
__tablename__ = "partner"
name = sa.Column(sa.String(128), unique=True, nullable=False)
contact_email = sa.Column(sa.String(128), unique=True, nullable=False)
@staticmethod
def find_by_token(token: str) -> Optional[Partner]:
hmaced = PartnerApiToken.hmac_token(token)
res = (
Session.query(Partner, PartnerApiToken)
.filter(
and_(
PartnerApiToken.token == hmaced,
Partner.id == PartnerApiToken.partner_id,
)
)
.first()
)
if res:
partner, partner_api_token = res
return partner
return None
class SLDomain(Base, ModelMixin): class SLDomain(Base, ModelMixin):
"""SimpleLogin domains""" """SimpleLogin domains"""
@ -2780,12 +2955,23 @@ class SLDomain(Base, ModelMixin):
sa.Boolean, nullable=False, default=False, server_default="0" sa.Boolean, nullable=False, default=False, server_default="0"
) )
partner_id = sa.Column(
sa.ForeignKey(Partner.id, ondelete="cascade"),
nullable=True,
default=None,
server_default="NULL",
)
# if enabled, do not show this domain when user creates a custom alias # if enabled, do not show this domain when user creates a custom alias
hidden = sa.Column(sa.Boolean, nullable=False, default=False, server_default="0") hidden = sa.Column(sa.Boolean, nullable=False, default=False, server_default="0")
# the order in which the domains are shown when user creates a custom alias # the order in which the domains are shown when user creates a custom alias
order = sa.Column(sa.Integer, nullable=False, default=0, server_default="0") order = sa.Column(sa.Integer, nullable=False, default=0, server_default="0")
use_as_reverse_alias = sa.Column(
sa.Boolean, nullable=False, default=False, server_default="0"
)
def __repr__(self): def __repr__(self):
return f"<SLDomain {self.domain} {'Premium' if self.premium_only else 'Free'}" return f"<SLDomain {self.domain} {'Premium' if self.premium_only else 'Free'}"
@ -2806,6 +2992,8 @@ class Monitoring(Base, ModelMixin):
active_queue = sa.Column(sa.Integer, nullable=False) active_queue = sa.Column(sa.Integer, nullable=False)
deferred_queue = sa.Column(sa.Integer, nullable=False) deferred_queue = sa.Column(sa.Integer, nullable=False)
__table_args__ = (Index("ix_monitoring_created_at", "created_at"),)
class BatchImport(Base, ModelMixin): class BatchImport(Base, ModelMixin):
__tablename__ = "batch_import" __tablename__ = "batch_import"
@ -2931,6 +3119,8 @@ class Bounce(Base, ModelMixin):
email = sa.Column(sa.String(256), nullable=False, index=True) email = sa.Column(sa.String(256), nullable=False, index=True)
info = sa.Column(sa.Text, nullable=True) info = sa.Column(sa.Text, nullable=True)
__table_args__ = (sa.Index("ix_bounce_created_at", "created_at"),)
class TransactionalEmail(Base, ModelMixin): class TransactionalEmail(Base, ModelMixin):
"""Storing all email addresses that receive transactional emails, including account email and mailboxes. """Storing all email addresses that receive transactional emails, including account email and mailboxes.
@ -2940,6 +3130,8 @@ class TransactionalEmail(Base, ModelMixin):
__tablename__ = "transactional_email" __tablename__ = "transactional_email"
email = sa.Column(sa.String(256), nullable=False, unique=False) email = sa.Column(sa.String(256), nullable=False, unique=False)
__table_args__ = (sa.Index("ix_transactional_email_created_at", "created_at"),)
class Payout(Base, ModelMixin): class Payout(Base, ModelMixin):
"""Referral payouts""" """Referral payouts"""
@ -2992,7 +3184,7 @@ class MessageIDMatching(Base, ModelMixin):
# to track what email_log that has created this matching # to track what email_log that has created this matching
email_log_id = sa.Column( email_log_id = sa.Column(
sa.ForeignKey("email_log.id", ondelete="cascade"), nullable=True sa.ForeignKey("email_log.id", ondelete="cascade"), nullable=True, index=True
) )
email_log = orm.relationship("EmailLog") email_log = orm.relationship("EmailLog")
@ -3226,31 +3418,6 @@ class ProviderComplaint(Base, ModelMixin):
refused_email = orm.relationship(RefusedEmail, foreign_keys=[refused_email_id]) refused_email = orm.relationship(RefusedEmail, foreign_keys=[refused_email_id])
class Partner(Base, ModelMixin):
__tablename__ = "partner"
name = sa.Column(sa.String(128), unique=True, nullable=False)
contact_email = sa.Column(sa.String(128), unique=True, nullable=False)
@staticmethod
def find_by_token(token: str) -> Optional[Partner]:
hmaced = PartnerApiToken.hmac_token(token)
res = (
Session.query(Partner, PartnerApiToken)
.filter(
and_(
PartnerApiToken.token == hmaced,
Partner.id == PartnerApiToken.partner_id,
)
)
.first()
)
if res:
partner, partner_api_token = res
return partner
return None
class PartnerApiToken(Base, ModelMixin): class PartnerApiToken(Base, ModelMixin):
__tablename__ = "partner_api_token" __tablename__ = "partner_api_token"
@ -3320,7 +3487,7 @@ class PartnerSubscription(Base, ModelMixin):
) )
# when the partner subscription ends # when the partner subscription ends
end_at = sa.Column(ArrowType, nullable=False) end_at = sa.Column(ArrowType, nullable=False, index=True)
partner_user = orm.relationship(PartnerUser) partner_user = orm.relationship(PartnerUser)

View File

@ -7,11 +7,12 @@ from typing import Optional
from app.account_linking import SLPlan, SLPlanType from app.account_linking import SLPlan, SLPlanType
from app.config import PROTON_EXTRA_HEADER_NAME, PROTON_EXTRA_HEADER_VALUE from app.config import PROTON_EXTRA_HEADER_NAME, PROTON_EXTRA_HEADER_VALUE
from app.errors import ProtonAccountNotVerified
from app.log import LOG from app.log import LOG
_APP_VERSION = "OauthClient_1.0.0" _APP_VERSION = "OauthClient_1.0.0"
PROTON_ERROR_CODE_NOT_EXISTS = 2501 PROTON_ERROR_CODE_HV_NEEDED = 9001
PLAN_FREE = 1 PLAN_FREE = 1
PLAN_PREMIUM = 2 PLAN_PREMIUM = 2
@ -57,6 +58,15 @@ def convert_access_token(access_token_response: str) -> AccessCredentials:
) )
def handle_response_not_ok(status: int, body: dict, text: str) -> Exception:
if status == HTTPStatus.UNPROCESSABLE_ENTITY:
res_code = body.get("Code")
if res_code == PROTON_ERROR_CODE_HV_NEEDED:
return ProtonAccountNotVerified()
return Exception(f"Unexpected status code. Wanted 200 and got {status}: " + text)
class ProtonClient(ABC): class ProtonClient(ABC):
@abstractmethod @abstractmethod
def get_user(self) -> Optional[UserInformation]: def get_user(self) -> Optional[UserInformation]:
@ -124,11 +134,11 @@ class HttpProtonClient(ProtonClient):
@staticmethod @staticmethod
def __validate_response(res: Response) -> dict: def __validate_response(res: Response) -> dict:
status = res.status_code status = res.status_code
if status != HTTPStatus.OK:
raise Exception(
f"Unexpected status code. Wanted 200 and got {status}: " + res.text
)
as_json = res.json() as_json = res.json()
if status != HTTPStatus.OK:
raise HttpProtonClient.__handle_response_not_ok(
status=status, body=as_json, text=res.text
)
res_code = as_json.get("Code") res_code = as_json.get("Code")
if not res_code or res_code != 1000: if not res_code or res_code != 1000:
raise Exception( raise Exception(

View File

@ -0,0 +1,33 @@
import requests
from requests import RequestException
from app import config
from app.log import LOG
from app.models import User
def execute_subscription_webhook(user: User):
webhook_url = config.SUBSCRIPTION_CHANGE_WEBHOOK
if webhook_url is None:
return
subscription_end = user.get_active_subscription_end(
include_partner_subscription=False
)
sl_subscription_end = None
if subscription_end:
sl_subscription_end = subscription_end.timestamp
payload = {
"user_id": user.id,
"is_premium": user.is_premium(),
"active_subscription_end": sl_subscription_end,
}
try:
response = requests.post(webhook_url, json=payload, timeout=2)
if response.status_code == 200:
LOG.i("Sent request to subscription update webhook successfully")
else:
LOG.i(
f"Request to webhook failed with statue {response.status_code}: {response.text}"
)
except RequestException as e:
LOG.error(f"Subscription request exception: {e}")

View File

@ -32,8 +32,8 @@ def random_words(words: int = 2, numbers: int = 0):
fields = [secrets.choice(_words) for i in range(words)] fields = [secrets.choice(_words) for i in range(words)]
if numbers > 0: if numbers > 0:
fields.append("".join([str(random.randint(0, 9)) for i in range(numbers)])) digits = "".join([str(random.randint(0, 9)) for i in range(numbers)])
return "".join(fields) return "_".join(fields) + digits
else: else:
return "_".join(fields) return "_".join(fields)
@ -99,7 +99,7 @@ def sanitize_email(email_address: str, not_lower=False) -> str:
email_address = email_address.strip().replace(" ", "").replace("\n", " ") email_address = email_address.strip().replace(" ", "").replace("\n", " ")
if not not_lower: if not not_lower:
email_address = email_address.lower() email_address = email_address.lower()
return email_address return email_address.replace("\u200f", "")
class NextUrlSanitizer: class NextUrlSanitizer:

View File

@ -5,11 +5,11 @@ from typing import List, Tuple
import arrow import arrow
import requests import requests
from sqlalchemy import func, desc, or_ from sqlalchemy import func, desc, or_, and_
from sqlalchemy.ext.compiler import compiles from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from sqlalchemy.orm.exc import ObjectDeletedError from sqlalchemy.orm.exc import ObjectDeletedError
from sqlalchemy.sql import Insert from sqlalchemy.sql import Insert, text
from app import s3, config from app import s3, config
from app.alias_utils import nb_email_log_for_mailbox from app.alias_utils import nb_email_log_for_mailbox
@ -22,10 +22,9 @@ from app.email_utils import (
render, render,
email_can_be_used_as_mailbox, email_can_be_used_as_mailbox,
send_email_with_rate_control, send_email_with_rate_control,
normalize_reply_email,
is_valid_email,
get_email_domain_part, get_email_domain_part,
) )
from app.email_validation import is_valid_email, normalize_reply_email
from app.errors import ProtonPartnerNotSetUp from app.errors import ProtonPartnerNotSetUp
from app.log import LOG from app.log import LOG
from app.mail_sender import load_unsent_mails_from_fs_and_resend from app.mail_sender import load_unsent_mails_from_fs_and_resend
@ -66,12 +65,14 @@ from server import create_light_app
def notify_trial_end(): def notify_trial_end():
for user in User.filter( for user in User.filter(
User.activated.is_(True), User.trial_end.isnot(None), User.lifetime.is_(False) User.activated.is_(True),
User.trial_end.isnot(None),
User.trial_end >= arrow.now().shift(days=2),
User.trial_end < arrow.now().shift(days=3),
User.lifetime.is_(False),
).all(): ).all():
try: try:
if user.in_trial() and arrow.now().shift( if user.in_trial():
days=3
) > user.trial_end >= arrow.now().shift(days=2):
LOG.d("Send trial end email to user %s", user) LOG.d("Send trial end email to user %s", user)
send_trial_end_soon_email(user) send_trial_end_soon_email(user)
# happens if user has been deleted in the meantime # happens if user has been deleted in the meantime
@ -84,27 +85,49 @@ def delete_logs():
delete_refused_emails() delete_refused_emails()
delete_old_monitoring() delete_old_monitoring()
for t in TransactionalEmail.filter( for t_email in TransactionalEmail.filter(
TransactionalEmail.created_at < arrow.now().shift(days=-7) TransactionalEmail.created_at < arrow.now().shift(days=-7)
): ):
TransactionalEmail.delete(t.id) TransactionalEmail.delete(t_email.id)
for b in Bounce.filter(Bounce.created_at < arrow.now().shift(days=-7)): for b in Bounce.filter(Bounce.created_at < arrow.now().shift(days=-7)):
Bounce.delete(b.id) Bounce.delete(b.id)
Session.commit() Session.commit()
LOG.d("Delete EmailLog older than 2 weeks") LOG.d("Deleting EmailLog older than 2 weeks")
max_dt = arrow.now().shift(weeks=-2) total_deleted = 0
nb_deleted = EmailLog.filter(EmailLog.created_at < max_dt).delete() batch_size = 500
Session.execute("set session statement_timeout=30000").rowcount
queries_done = 0
cutoff_time = arrow.now().shift(days=-14)
rows_to_delete = EmailLog.filter(EmailLog.created_at < cutoff_time).count()
expected_queries = int(rows_to_delete / batch_size)
sql = text(
f"DELETE FROM email_log WHERE id IN (SELECT id FROM email_log WHERE created_at < :cutoff_time order by created_at limit :batch_size)"
)
str_cutoff_time = cutoff_time.isoformat()
while total_deleted < rows_to_delete:
deleted_count = Session.execute(
sql, {"cutoff_time": str_cutoff_time, "batch_size": batch_size}
).rowcount
Session.commit() Session.commit()
total_deleted += deleted_count
queries_done += 1
LOG.i(
f"[{queries_done}/{expected_queries}] Deleted {total_deleted} EmailLog entries"
)
if deleted_count < batch_size:
break
LOG.i("Delete %s email logs", nb_deleted) LOG.i("Deleted %s email logs", total_deleted)
def delete_refused_emails(): def delete_refused_emails():
for refused_email in RefusedEmail.filter_by(deleted=False).all(): for refused_email in (
RefusedEmail.filter_by(deleted=False).order_by(RefusedEmail.id).all()
):
if arrow.now().shift(days=1) > refused_email.delete_at >= arrow.now(): if arrow.now().shift(days=1) > refused_email.delete_at >= arrow.now():
LOG.d("Delete refused email %s", refused_email) LOG.d("Delete refused email %s", refused_email)
if refused_email.path: if refused_email.path:
@ -272,7 +295,11 @@ def compute_metric2() -> Metric2:
_24h_ago = now.shift(days=-1) _24h_ago = now.shift(days=-1)
nb_referred_user_paid = 0 nb_referred_user_paid = 0
for user in User.filter(User.referral_id.isnot(None)): for user in (
User.filter(User.referral_id.isnot(None))
.yield_per(500)
.enable_eagerloads(False)
):
if user.is_paid(): if user.is_paid():
nb_referred_user_paid += 1 nb_referred_user_paid += 1
@ -1020,7 +1047,8 @@ async def check_hibp():
) )
.filter(Alias.enabled) .filter(Alias.enabled)
.order_by(Alias.hibp_last_check.asc()) .order_by(Alias.hibp_last_check.asc())
.all() .yield_per(500)
.enable_eagerloads(False)
): ):
await queue.put(alias.id) await queue.put(alias.id)
@ -1098,6 +1126,18 @@ def notify_hibp():
Session.commit() Session.commit()
def clear_users_scheduled_to_be_deleted():
users = User.filter(
and_(User.delete_on.isnot(None), User.delete_on < arrow.now())
).all()
for user in users:
LOG.i(
f"Scheduled deletion of user {user} with scheduled delete on {user.delete_on}"
)
User.delete(user.id)
Session.commit()
if __name__ == "__main__": if __name__ == "__main__":
LOG.d("Start running cronjob") LOG.d("Start running cronjob")
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
@ -1164,3 +1204,6 @@ if __name__ == "__main__":
elif args.job == "send_undelivered_mails": elif args.job == "send_undelivered_mails":
LOG.d("Sending undelivered emails") LOG.d("Sending undelivered emails")
load_unsent_mails_from_fs_and_resend() load_unsent_mails_from_fs_and_resend()
elif args.job == "delete_scheduled_users":
LOG.d("Deleting users scheduled to be deleted")
clear_users_scheduled_to_be_deleted()

View File

@ -5,65 +5,66 @@ jobs:
schedule: "0 0 * * *" schedule: "0 0 * * *"
captureStderr: true captureStderr: true
- name: SimpleLogin Notify Trial Ends
command: python /code/cron.py -j notify_trial_end
shell: /bin/bash
schedule: "0 8 * * *"
captureStderr: true
- name: SimpleLogin Notify Manual Subscription Ends
command: python /code/cron.py -j notify_manual_subscription_end
shell: /bin/bash
schedule: "0 9 * * *"
captureStderr: true
- name: SimpleLogin Notify Premium Ends
command: python /code/cron.py -j notify_premium_end
shell: /bin/bash
schedule: "0 10 * * *"
captureStderr: true
- name: SimpleLogin Delete Logs
command: python /code/cron.py -j delete_logs
shell: /bin/bash
schedule: "0 11 * * *"
captureStderr: true
- name: SimpleLogin Poll Apple Subscriptions
command: python /code/cron.py -j poll_apple_subscription
shell: /bin/bash
schedule: "0 12 * * *"
captureStderr: true
- name: SimpleLogin Sanity Check
command: python /code/cron.py -j sanity_check
shell: /bin/bash
schedule: "0 2 * * *"
captureStderr: true
- name: SimpleLogin Delete Old Monitoring records - name: SimpleLogin Delete Old Monitoring records
command: python /code/cron.py -j delete_old_monitoring command: python /code/cron.py -j delete_old_monitoring
shell: /bin/bash shell: /bin/bash
schedule: "0 14 * * *" schedule: "15 1 * * *"
captureStderr: true captureStderr: true
- name: SimpleLogin Custom Domain check - name: SimpleLogin Custom Domain check
command: python /code/cron.py -j check_custom_domain command: python /code/cron.py -j check_custom_domain
shell: /bin/bash shell: /bin/bash
schedule: "0 15 * * *" schedule: "15 2 * * *"
captureStderr: true captureStderr: true
- name: SimpleLogin HIBP check - name: SimpleLogin HIBP check
command: python /code/cron.py -j check_hibp command: python /code/cron.py -j check_hibp
shell: /bin/bash shell: /bin/bash
schedule: "0 18 * * *" schedule: "15 3 * * *"
captureStderr: true captureStderr: true
concurrencyPolicy: Forbid concurrencyPolicy: Forbid
- name: SimpleLogin Notify HIBP breaches - name: SimpleLogin Notify HIBP breaches
command: python /code/cron.py -j notify_hibp command: python /code/cron.py -j notify_hibp
shell: /bin/bash shell: /bin/bash
schedule: "0 19 * * *" schedule: "15 4 * * *"
captureStderr: true
concurrencyPolicy: Forbid
- name: SimpleLogin Delete Logs
command: python /code/cron.py -j delete_logs
shell: /bin/bash
schedule: "15 5 * * *"
captureStderr: true
- name: SimpleLogin Poll Apple Subscriptions
command: python /code/cron.py -j poll_apple_subscription
shell: /bin/bash
schedule: "15 6 * * *"
captureStderr: true
- name: SimpleLogin Notify Trial Ends
command: python /code/cron.py -j notify_trial_end
shell: /bin/bash
schedule: "15 8 * * *"
captureStderr: true
- name: SimpleLogin Notify Manual Subscription Ends
command: python /code/cron.py -j notify_manual_subscription_end
shell: /bin/bash
schedule: "15 9 * * *"
captureStderr: true
- name: SimpleLogin Notify Premium Ends
command: python /code/cron.py -j notify_premium_end
shell: /bin/bash
schedule: "15 10 * * *"
captureStderr: true
- name: SimpleLogin delete users scheduled to be deleted
command: echo disabled_user_deletion #python /code/cron.py -j delete_scheduled_users
shell: /bin/bash
schedule: "15 11 * * *"
captureStderr: true captureStderr: true
concurrencyPolicy: Forbid concurrencyPolicy: Forbid

View File

@ -15,6 +15,7 @@
- [GET /api/user/cookie_token](#get-apiusercookie_token): Get a one time use token to exchange it for a valid cookie - [GET /api/user/cookie_token](#get-apiusercookie_token): Get a one time use token to exchange it for a valid cookie
- [PATCH /api/user_info](#patch-apiuser_info): Update user's information. - [PATCH /api/user_info](#patch-apiuser_info): Update user's information.
- [POST /api/api_key](#post-apiapi_key): Create a new API key. - [POST /api/api_key](#post-apiapi_key): Create a new API key.
- [GET /api/stats](#get-apistats): Get user's stats.
- [GET /api/logout](#get-apilogout): Log out. - [GET /api/logout](#get-apilogout): Log out.
[Alias endpoints](#alias-endpoints) [Alias endpoints](#alias-endpoints)
@ -226,6 +227,22 @@ Input:
Output: same as GET /api/user_info Output: same as GET /api/user_info
#### GET /api/stats
Given the API Key, return stats about the number of aliases, number of emails forwarded/replied/blocked
Input:
- `Authentication` header that contains the api key
Output: if api key is correct, return a json with the following fields:
```json
{"nb_alias": 1, "nb_block": 0, "nb_forward": 0, "nb_reply": 0}
```
If api key is incorrect, return 401.
#### PATCH /api/sudo #### PATCH /api/sudo
Enable sudo mode Enable sudo mode
@ -694,7 +711,7 @@ Return 200 and `existed=true` if contact is already added.
It can return 403 with an error if the user cannot create reverse alias. It can return 403 with an error if the user cannot create reverse alias.
``json ```json
{ {
"error": "Please upgrade to create a reverse-alias" "error": "Please upgrade to create a reverse-alias"
} }

View File

@ -1,4 +1,4 @@
# SSL, HTTPS, and HSTS # SSL, HTTPS, HSTS and additional security measures
It's highly recommended to enable SSL/TLS on your server, both for the web app and email server. It's highly recommended to enable SSL/TLS on your server, both for the web app and email server.
@ -58,3 +58,124 @@ Now, reload Nginx:
```bash ```bash
sudo systemctl reload nginx sudo systemctl reload nginx
``` ```
## Additional security measures
For additional security, we recommend you take some extra steps.
### Enable Certificate Authority Authorization (CAA)
[Certificate Authority Authorization](https://letsencrypt.org/docs/caa/) is a step you can take to restrict the list of certificate authorities that are allowed to issue certificates for your domains.
Use [SSLMates CAA Record Generator](https://sslmate.com/caa/) to create a **CAA record** with the following configuration:
- `flags`: `0`
- `tag`: `issue`
- `value`: `"letsencrypt.org"`
To verify if the DNS works, the following command
```bash
dig @1.1.1.1 mydomain.com caa
```
should return:
```
mydomain.com. 3600 IN CAA 0 issue "letsencrypt.org"
```
### SMTP MTA Strict Transport Security (MTA-STS)
[MTA-STS](https://datatracker.ietf.org/doc/html/rfc8461) is an extra step you can take to broadcast the ability of your instance to receive and, optionally enforce, TSL-secure SMTP connections to protect email traffic.
Enabling MTA-STS requires you serve a specific file from subdomain `mta-sts.domain.com` on a well-known route.
Create a text file `/var/www/.well-known/mta-sts.txt` with the content:
```txt
version: STSv1
mode: testing
mx: app.mydomain.com
max_age: 86400
```
It is recommended to start with `mode: testing` for starters to get time to review failure reports. Add as many `mx:` domain entries as you have matching **MX records** in your DNS configuration.
Create a **TXT record** for `_mta-sts.mydomain.com.` with the following value:
```txt
v=STSv1; id=UNIX_TIMESTAMP
```
With `UNIX_TIMESTAMP` being the current date/time.
Use the following command to generate the record:
```bash
echo "v=STSv1; id=$(date +%s)"
```
To verify if the DNS works, the following command
```bash
dig @1.1.1.1 _mta-sts.mydomain.com txt
```
should return a result similar to this one:
```
_mta-sts.mydomain.com. 3600 IN TXT "v=STSv1; id=1689416399"
```
Create an additional Nginx configuration in `/etc/nginx/sites-enabled/mta-sts` with the following content:
```
server {
server_name mta-sts.mydomain.com;
root /var/www;
listen 80;
location ^~ /.well-known {}
}
```
Restart Nginx with the following command:
```sh
sudo service nginx restart
```
A correct configuration of MTA-STS, however, requires that the certificate used to host the `mta-sts` subdomain matches that of the subdomain referred to by the **MX record** from the DNS. In other words, both `mta-sts.mydomain.com` and `app.mydomain.com` must share the same certificate.
The easiest way to do this is to _expand_the certificate associated with `app.mydomain.com` to also support the `mta-sts` subdomain using the following command:
```sh
certbot --expand --nginx -d app.mydomain.com,mta-sts.mydomain.com
```
## SMTP TLS Reporting
[TLSRPT](https://datatracker.ietf.org/doc/html/rfc8460) is used by SMTP systems to report failures in establishing TLS-secure sessions as broadcast by the MTA-STS configuration.
Configuring MTA-STS in `mode: testing` as shown in the previous section gives you time to review failures from some SMTP senders.
Create a **TXT record** for `_smtp._tls.mydomain.com.` with the following value:
```txt
v=TSLRPTv1; rua=mailto:YOUR_EMAIL
```
The TLSRPT configuration at the DNS level allows SMTP senders that fail to initiate TLS-secure sessions to send reports to a particular email address. We suggest creating a `tls-reports` alias in SimpleLogin for this purpose.
To verify if the DNS works, the following command
```bash
dig @1.1.1.1 _smtp._tls.mydomain.com txt
```
should return a result similar to this one:
```
_smtp._tls.mydomain.com. 3600 IN TXT "v=TSLRPTv1; rua=mailto:tls-reports@mydomain.com"
```

View File

@ -106,8 +106,6 @@ from app.email_utils import (
get_header_unicode, get_header_unicode,
generate_reply_email, generate_reply_email,
is_reverse_alias, is_reverse_alias,
normalize_reply_email,
is_valid_email,
replace, replace,
should_disable, should_disable,
parse_id_from_bounce, parse_id_from_bounce,
@ -123,6 +121,7 @@ from app.email_utils import (
generate_verp_email, generate_verp_email,
sl_formataddr, sl_formataddr,
) )
from app.email_validation import is_valid_email, normalize_reply_email
from app.errors import ( from app.errors import (
NonReverseAliasInReplyPhase, NonReverseAliasInReplyPhase,
VERPTransactional, VERPTransactional,
@ -161,6 +160,7 @@ from app.models import (
MessageIDMatching, MessageIDMatching,
Notification, Notification,
VerpType, VerpType,
SLDomain,
) )
from app.pgp_utils import ( from app.pgp_utils import (
PGPException, PGPException,
@ -243,7 +243,7 @@ def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Con
website_email=contact_email, website_email=contact_email,
name=contact_name, name=contact_name,
mail_from=mail_from, mail_from=mail_from,
reply_email=generate_reply_email(contact_email, alias.user) reply_email=generate_reply_email(contact_email, alias)
if is_valid_email(contact_email) if is_valid_email(contact_email)
else NOREPLY, else NOREPLY,
automatic_created=True, automatic_created=True,
@ -261,7 +261,7 @@ def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Con
Session.commit() Session.commit()
except IntegrityError: except IntegrityError:
LOG.w("Contact %s %s already exist", alias, contact_email) LOG.w(f"Contact with email {contact_email} for alias {alias} already exist")
Session.rollback() Session.rollback()
contact = Contact.get_by(alias_id=alias.id, website_email=contact_email) contact = Contact.get_by(alias_id=alias.id, website_email=contact_email)
@ -279,6 +279,9 @@ def get_or_create_reply_to_contact(
except ValueError: except ValueError:
return return
if len(contact_name) >= Contact.MAX_NAME_LENGTH:
contact_name = contact_name[0 : Contact.MAX_NAME_LENGTH]
if not is_valid_email(contact_address): if not is_valid_email(contact_address):
LOG.w( LOG.w(
"invalid reply-to address %s. Parse from %s", "invalid reply-to address %s. Parse from %s",
@ -304,7 +307,7 @@ def get_or_create_reply_to_contact(
alias_id=alias.id, alias_id=alias.id,
website_email=contact_address, website_email=contact_address,
name=contact_name, name=contact_name,
reply_email=generate_reply_email(contact_address, alias.user), reply_email=generate_reply_email(contact_address, alias),
automatic_created=True, automatic_created=True,
) )
Session.commit() Session.commit()
@ -347,6 +350,10 @@ def replace_header_when_forward(msg: Message, alias: Alias, header: str):
continue continue
contact = Contact.get_by(alias_id=alias.id, website_email=contact_email) contact = Contact.get_by(alias_id=alias.id, website_email=contact_email)
contact_name = full_address.display_name
if len(contact_name) >= Contact.MAX_NAME_LENGTH:
contact_name = contact_name[0 : Contact.MAX_NAME_LENGTH]
if contact: if contact:
# update the contact name if needed # update the contact name if needed
if contact.name != full_address.display_name: if contact.name != full_address.display_name:
@ -354,9 +361,9 @@ def replace_header_when_forward(msg: Message, alias: Alias, header: str):
"Update contact %s name %s to %s", "Update contact %s name %s to %s",
contact, contact,
contact.name, contact.name,
full_address.display_name, contact_name,
) )
contact.name = full_address.display_name contact.name = contact_name
Session.commit() Session.commit()
else: else:
LOG.d( LOG.d(
@ -371,8 +378,8 @@ def replace_header_when_forward(msg: Message, alias: Alias, header: str):
user_id=alias.user_id, user_id=alias.user_id,
alias_id=alias.id, alias_id=alias.id,
website_email=contact_email, website_email=contact_email,
name=full_address.display_name, name=contact_name,
reply_email=generate_reply_email(contact_email, alias.user), reply_email=generate_reply_email(contact_email, alias),
is_cc=header.lower() == "cc", is_cc=header.lower() == "cc",
automatic_created=True, automatic_created=True,
) )
@ -540,12 +547,20 @@ def sign_msg(msg: Message) -> Message:
signature.add_header("Content-Disposition", 'attachment; filename="signature.asc"') signature.add_header("Content-Disposition", 'attachment; filename="signature.asc"')
try: try:
signature.set_payload(sign_data(message_to_bytes(msg).replace(b"\n", b"\r\n"))) payload = sign_data(message_to_bytes(msg).replace(b"\n", b"\r\n"))
if not payload:
raise PGPException("Empty signature by gnupg")
signature.set_payload(payload)
except Exception: except Exception:
LOG.e("Cannot sign, try using pgpy") LOG.e("Cannot sign, try using pgpy")
signature.set_payload( payload = sign_data_with_pgpy(message_to_bytes(msg).replace(b"\n", b"\r\n"))
sign_data_with_pgpy(message_to_bytes(msg).replace(b"\n", b"\r\n"))
) if not payload:
raise PGPException("Empty signature by pgpy")
signature.set_payload(payload)
container.attach(signature) container.attach(signature)
@ -622,8 +637,8 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str
user = alias.user user = alias.user
if user.disabled: if not user.can_send_or_receive():
LOG.w("User %s disabled, disable forwarding emails for %s", user, alias) LOG.i(f"User {user} cannot receive emails")
if should_ignore_bounce(envelope.mail_from): if should_ignore_bounce(envelope.mail_from):
return [(True, status.E207)] return [(True, status.E207)]
else: else:
@ -845,9 +860,7 @@ def forward_email_to_mailbox(
f"""Email sent to {alias.email} from an invalid address and cannot be replied""", f"""Email sent to {alias.email} from an invalid address and cannot be replied""",
) )
delete_all_headers_except( headers_to_keep = [
msg,
[
headers.FROM, headers.FROM,
headers.TO, headers.TO,
headers.CC, headers.CC,
@ -858,13 +871,13 @@ def forward_email_to_mailbox(
# References and In-Reply-To are used for keeping the email thread # References and In-Reply-To are used for keeping the email thread
headers.REFERENCES, headers.REFERENCES,
headers.IN_REPLY_TO, headers.IN_REPLY_TO,
] headers.LIST_UNSUBSCRIBE,
+ headers.MIME_HEADERS, headers.LIST_UNSUBSCRIBE_POST,
) ] + headers.MIME_HEADERS
if user.include_header_email_header:
headers_to_keep.append(headers.AUTHENTICATION_RESULTS)
delete_all_headers_except(msg, headers_to_keep)
# create PGP email if needed
if mailbox.pgp_enabled() and user.is_premium() and not alias.disable_pgp:
LOG.d("Encrypt message using mailbox %s", mailbox)
if mailbox.generic_subject: if mailbox.generic_subject:
LOG.d("Use a generic subject for %s", mailbox) LOG.d("Use a generic subject for %s", mailbox)
orig_subject = msg[headers.SUBJECT] orig_subject = msg[headers.SUBJECT]
@ -878,6 +891,10 @@ def forward_email_to_mailbox(
f"""Forwarded by SimpleLogin to {alias.email} from "{sender}" with <b>{orig_subject}</b> as subject""", f"""Forwarded by SimpleLogin to {alias.email} from "{sender}" with <b>{orig_subject}</b> as subject""",
) )
# create PGP email if needed
if mailbox.pgp_enabled() and user.is_premium() and not alias.disable_pgp:
LOG.d("Encrypt message using mailbox %s", mailbox)
try: try:
msg = prepare_pgp_message( msg = prepare_pgp_message(
msg, mailbox.pgp_finger_print, mailbox.pgp_public_key, can_sign=True msg, mailbox.pgp_finger_print, mailbox.pgp_public_key, can_sign=True
@ -897,6 +914,11 @@ def forward_email_to_mailbox(
msg[headers.SL_EMAIL_LOG_ID] = str(email_log.id) msg[headers.SL_EMAIL_LOG_ID] = str(email_log.id)
if user.include_header_email_header: if user.include_header_email_header:
msg[headers.SL_ENVELOPE_FROM] = envelope.mail_from msg[headers.SL_ENVELOPE_FROM] = envelope.mail_from
if contact.name:
original_from = f"{contact.name} <{contact.website_email}>"
else:
original_from = contact.website_email
msg[headers.SL_ORIGINAL_FROM] = original_from
# when an alias isn't in the To: header, there's no way for users to know what alias has received the email # when an alias isn't in the To: header, there's no way for users to know what alias has received the email
msg[headers.SL_ENVELOPE_TO] = alias.email msg[headers.SL_ENVELOPE_TO] = alias.email
@ -945,10 +967,11 @@ def forward_email_to_mailbox(
envelope.rcpt_options, envelope.rcpt_options,
) )
contact_domain = get_email_domain_part(contact.reply_email)
try: try:
sl_sendmail( sl_sendmail(
# use a different envelope sender for each forward (aka VERP) # use a different envelope sender for each forward (aka VERP)
generate_verp_email(VerpType.bounce_forward, email_log.id), generate_verp_email(VerpType.bounce_forward, email_log.id, contact_domain),
mailbox.email, mailbox.email,
msg, msg,
envelope.mail_options, envelope.mail_options,
@ -1017,8 +1040,12 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str):
reply_email = rcpt_to reply_email = rcpt_to
# reply_email must end with EMAIL_DOMAIN reply_domain = get_email_domain_part(reply_email)
# reply_email must end with EMAIL_DOMAIN or a domain that can be used as reverse alias domain
if not reply_email.endswith(EMAIL_DOMAIN): if not reply_email.endswith(EMAIL_DOMAIN):
sl_domain: SLDomain = SLDomain.get_by(domain=reply_domain)
if sl_domain is None:
LOG.w(f"Reply email {reply_email} has wrong domain") LOG.w(f"Reply email {reply_email} has wrong domain")
return False, status.E501 return False, status.E501
@ -1032,7 +1059,7 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str):
alias = contact.alias alias = contact.alias
alias_address: str = contact.alias.email alias_address: str = contact.alias.email
alias_domain = alias_address[alias_address.find("@") + 1 :] alias_domain = get_email_domain_part(alias_address)
# Sanity check: verify alias domain is managed by SimpleLogin # Sanity check: verify alias domain is managed by SimpleLogin
# scenario: a user have removed a domain but due to a bug, the aliases are still there # scenario: a user have removed a domain but due to a bug, the aliases are still there
@ -1043,13 +1070,8 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str):
user = alias.user user = alias.user
mail_from = envelope.mail_from mail_from = envelope.mail_from
if user.disabled: if not user.can_send_or_receive():
LOG.e( LOG.i(f"User {user} cannot send emails")
"User %s disabled, disable sending emails from %s to %s",
user,
alias,
contact,
)
return False, status.E504 return False, status.E504
# Check if we need to reject or quarantine based on dmarc # Check if we need to reject or quarantine based on dmarc
@ -1230,7 +1252,6 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str):
if str(msg[headers.TO]).lower() == "undisclosed-recipients:;": if str(msg[headers.TO]).lower() == "undisclosed-recipients:;":
# no need to replace TO header # no need to replace TO header
LOG.d("email is sent in BCC mode") LOG.d("email is sent in BCC mode")
del msg[headers.TO]
else: else:
replace_header_when_reply(msg, alias, headers.TO) replace_header_when_reply(msg, alias, headers.TO)

View File

@ -42,14 +42,16 @@ def add_sl_domains():
LOG.d("%s is already a SL domain", alias_domain) LOG.d("%s is already a SL domain", alias_domain)
else: else:
LOG.i("Add %s to SL domain", alias_domain) LOG.i("Add %s to SL domain", alias_domain)
SLDomain.create(domain=alias_domain) SLDomain.create(domain=alias_domain, use_as_reverse_alias=True)
for premium_domain in PREMIUM_ALIAS_DOMAINS: for premium_domain in PREMIUM_ALIAS_DOMAINS:
if SLDomain.get_by(domain=premium_domain): if SLDomain.get_by(domain=premium_domain):
LOG.d("%s is already a SL domain", premium_domain) LOG.d("%s is already a SL domain", premium_domain)
else: else:
LOG.i("Add %s to SL domain", premium_domain) LOG.i("Add %s to SL domain", premium_domain)
SLDomain.create(domain=premium_domain, premium_only=True) SLDomain.create(
domain=premium_domain, premium_only=True, use_as_reverse_alias=True
)
Session.commit() Session.commit()

View File

@ -5991,7 +5991,6 @@ skimmer
skimming skimming
skimpily skimpily
skincare skincare
skinhead
skinless skinless
skinning skinning
skinny skinny

View File

@ -0,0 +1,31 @@
"""empty message
Revision ID: 5f4a5625da66
Revises: 2c2093c82bc0
Create Date: 2023-04-03 18:30:46.488231
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '5f4a5625da66'
down_revision = '2c2093c82bc0'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('public_domain', sa.Column('partner_id', sa.Integer(), nullable=True))
op.create_foreign_key(None, 'public_domain', 'partner', ['partner_id'], ['id'], ondelete='cascade')
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'public_domain', type_='foreignkey')
op.drop_column('public_domain', 'partner_id')
# ### end Alembic commands ###

View File

@ -0,0 +1,29 @@
"""empty message
Revision ID: 893c0d18475f
Revises: 5f4a5625da66
Create Date: 2023-04-14 18:20:03.807367
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '893c0d18475f'
down_revision = '5f4a5625da66'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_index(op.f('ix_contact_pgp_finger_print'), 'contact', ['pgp_finger_print'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_contact_pgp_finger_print'), table_name='contact')
# ### end Alembic commands ###

View File

@ -0,0 +1,35 @@
"""empty message
Revision ID: bc496c0a0279
Revises: 893c0d18475f
Create Date: 2023-04-14 19:09:38.540514
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'bc496c0a0279'
down_revision = '893c0d18475f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_index(op.f('ix_alias_used_on_alias_id'), 'alias_used_on', ['alias_id'], unique=False)
op.create_index(op.f('ix_client_user_alias_id'), 'client_user', ['alias_id'], unique=False)
op.create_index(op.f('ix_hibp_notified_alias_alias_id'), 'hibp_notified_alias', ['alias_id'], unique=False)
op.create_index(op.f('ix_users_newsletter_alias_id'), 'users', ['newsletter_alias_id'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_users_newsletter_alias_id'), table_name='users')
op.drop_index(op.f('ix_hibp_notified_alias_alias_id'), table_name='hibp_notified_alias')
op.drop_index(op.f('ix_client_user_alias_id'), table_name='client_user')
op.drop_index(op.f('ix_alias_used_on_alias_id'), table_name='alias_used_on')
# ### end Alembic commands ###

View File

@ -0,0 +1,29 @@
"""empty message
Revision ID: 2d89315ac650
Revises: bc496c0a0279
Create Date: 2023-04-15 20:43:44.218020
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '2d89315ac650'
down_revision = 'bc496c0a0279'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_index(op.f('ix_partner_subscription_end_at'), 'partner_subscription', ['end_at'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_partner_subscription_end_at'), table_name='partner_subscription')
# ### end Alembic commands ###

View File

@ -0,0 +1,29 @@
"""empty message
Revision ID: 01e2997e90d3
Revises: 893c0d18475f
Create Date: 2023-04-19 16:09:11.851588
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '01e2997e90d3'
down_revision = '893c0d18475f'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('public_domain', sa.Column('use_as_reverse_alias', sa.Boolean(), server_default='0', nullable=False))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('public_domain', 'use_as_reverse_alias')
# ### end Alembic commands ###

View File

@ -0,0 +1,25 @@
"""empty message
Revision ID: 2634b41f54db
Revises: 01e2997e90d3, 2d89315ac650
Create Date: 2023-04-20 11:47:43.048536
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '2634b41f54db'
down_revision = ('01e2997e90d3', '2d89315ac650')
branch_labels = None
depends_on = None
def upgrade():
pass
def downgrade():
pass

View File

@ -0,0 +1,42 @@
"""empty message
Revision ID: 01827104004b
Revises: 2634b41f54db
Create Date: 2023-07-28 19:39:28.675490
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '01827104004b'
down_revision = '2634b41f54db'
branch_labels = None
depends_on = None
def upgrade():
with op.get_context().autocommit_block():
# ### commands auto generated by Alembic - please adjust! ###
op.create_index(op.f('ix_alias_hibp_last_check'), 'alias', ['hibp_last_check'], unique=False, postgresql_concurrently=True)
op.create_index('ix_bounce_created_at', 'bounce', ['created_at'], unique=False, postgresql_concurrently=True)
op.create_index('ix_monitoring_created_at', 'monitoring', ['created_at'], unique=False, postgresql_concurrently=True)
op.create_index('ix_transactional_email_created_at', 'transactional_email', ['created_at'], unique=False, postgresql_concurrently=True)
op.create_index(op.f('ix_users_activated'), 'users', ['activated'], unique=False, postgresql_concurrently=True)
op.create_index('ix_users_activated_trial_end_lifetime', 'users', ['activated', 'trial_end', 'lifetime'], unique=False, postgresql_concurrently=True)
op.create_index(op.f('ix_users_referral_id'), 'users', ['referral_id'], unique=False, postgresql_concurrently=True)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_users_referral_id'), table_name='users')
op.drop_index('ix_users_activated_trial_end_lifetime', table_name='users')
op.drop_index(op.f('ix_users_activated'), table_name='users')
op.drop_index('ix_transactional_email_created_at', table_name='transactional_email')
op.drop_index('ix_monitoring_created_at', table_name='monitoring')
op.drop_index('ix_bounce_created_at', table_name='bounce')
op.drop_index(op.f('ix_alias_hibp_last_check'), table_name='alias')
# ### end Alembic commands ###

View File

@ -0,0 +1,33 @@
"""empty message
Revision ID: 0a5701a4f5e4
Revises: 01827104004b
Create Date: 2023-09-07 15:28:10.122756
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0a5701a4f5e4'
down_revision = '01827104004b'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('users', sa.Column('delete_on', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True))
with op.get_context().autocommit_block():
op.create_index('ix_users_delete_on', 'users', ['delete_on'], unique=False, postgresql_concurrently=True)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.get_context().autocommit_block():
op.drop_index('ix_users_delete_on', table_name='users', postgresql_concurrently=True)
op.drop_column('users', 'delete_on')
# ### end Alembic commands ###

View File

@ -0,0 +1,34 @@
"""empty message
Revision ID: ec7fdde8da9f
Revises: 0a5701a4f5e4
Create Date: 2023-09-28 18:09:48.016620
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "ec7fdde8da9f"
down_revision = "0a5701a4f5e4"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.get_context().autocommit_block():
op.create_index(
"ix_email_log_created_at", "email_log", ["created_at"], unique=False
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.get_context().autocommit_block():
op.drop_index("ix_email_log_created_at", table_name="email_log")
# ### end Alembic commands ###

View File

@ -0,0 +1,39 @@
"""empty message
Revision ID: 46ecb648a47e
Revises: ec7fdde8da9f
Create Date: 2023-10-05 10:43:35.668902
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "46ecb648a47e"
down_revision = "ec7fdde8da9f"
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.get_context().autocommit_block():
op.create_index(
op.f("ix_message_id_matching_email_log_id"),
"message_id_matching",
["email_log_id"],
unique=False,
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
with op.get_context().autocommit_block():
op.drop_index(
op.f("ix_message_id_matching_email_log_id"),
table_name="message_id_matching",
)
# ### end Alembic commands ###

0
app/monitor/__init__.py Normal file
View File

21
app/monitor/metric.py Normal file
View File

@ -0,0 +1,21 @@
from dataclasses import dataclass
from typing import List
@dataclass
class UpcloudRecord:
db_role: str
label: str
time: str
value: float
@dataclass
class UpcloudMetric:
metric_name: str
records: List[UpcloudRecord]
@dataclass
class UpcloudMetrics:
metrics: List[UpcloudMetric]

View File

@ -0,0 +1,20 @@
from app.config import UPCLOUD_DB_ID, UPCLOUD_PASSWORD, UPCLOUD_USERNAME
from app.log import LOG
from monitor.newrelic import NewRelicClient
from monitor.upcloud import UpcloudClient
class MetricExporter:
def __init__(self, newrelic_license: str):
self.__upcloud = UpcloudClient(
username=UPCLOUD_USERNAME, password=UPCLOUD_PASSWORD
)
self.__newrelic = NewRelicClient(newrelic_license)
def run(self):
try:
metrics = self.__upcloud.get_metrics(UPCLOUD_DB_ID)
self.__newrelic.send(metrics)
LOG.info("Upcloud metrics sent to NewRelic")
except Exception as e:
LOG.warn(f"Could not export metrics: {e}")

26
app/monitor/newrelic.py Normal file
View File

@ -0,0 +1,26 @@
from monitor.metric import UpcloudMetrics
from newrelic_telemetry_sdk import GaugeMetric, MetricClient
_NEWRELIC_BASE_HOST = "metric-api.eu.newrelic.com"
class NewRelicClient:
def __init__(self, license_key: str):
self.__client = MetricClient(license_key=license_key, host=_NEWRELIC_BASE_HOST)
def send(self, metrics: UpcloudMetrics):
batch = []
for metric in metrics.metrics:
for record in metric.records:
batch.append(
GaugeMetric(
name=f"upcloud.db.{metric.metric_name}",
value=record.value,
tags={"host": record.label, "db_role": record.db_role},
)
)
response = self.__client.send_batch(batch)
response.raise_for_status()

82
app/monitor/upcloud.py Normal file
View File

@ -0,0 +1,82 @@
from app.log import LOG
from monitor.metric import UpcloudMetric, UpcloudMetrics, UpcloudRecord
import base64
import requests
from typing import Any
BASE_URL = "https://api.upcloud.com"
def get_metric(json: Any, metric: str) -> UpcloudMetric:
records = []
if metric in json:
metric_data = json[metric]
data = metric_data["data"]
cols = list(map(lambda x: x["label"], data["cols"][1:]))
latest = data["rows"][-1]
time = latest[0]
for column_idx in range(len(cols)):
value = latest[1 + column_idx]
# If the latest value is None, try to fetch the second to last
if value is None:
value = data["rows"][-2][1 + column_idx]
if value is not None:
label = cols[column_idx]
if "(master)" in label:
db_role = "master"
else:
db_role = "standby"
records.append(
UpcloudRecord(time=time, db_role=db_role, label=label, value=value)
)
else:
LOG.warn(f"Could not get value for metric {metric}")
return UpcloudMetric(metric_name=metric, records=records)
def get_metrics(json: Any) -> UpcloudMetrics:
return UpcloudMetrics(
metrics=[
get_metric(json, "cpu_usage"),
get_metric(json, "disk_usage"),
get_metric(json, "diskio_reads"),
get_metric(json, "diskio_writes"),
get_metric(json, "load_average"),
get_metric(json, "mem_usage"),
get_metric(json, "net_receive"),
get_metric(json, "net_send"),
]
)
class UpcloudClient:
def __init__(self, username: str, password: str):
if not username:
raise Exception("UpcloudClient username must be set")
if not password:
raise Exception("UpcloudClient password must be set")
client = requests.Session()
encoded_auth = base64.b64encode(
f"{username}:{password}".encode("utf-8")
).decode("utf-8")
client.headers = {"Authorization": f"Basic {encoded_auth}"}
self.__client = client
def get_metrics(self, db_uuid: str) -> UpcloudMetrics:
url = f"{BASE_URL}/1.3/database/{db_uuid}/metrics?period=hour"
LOG.d(f"Performing request to {url}")
response = self.__client.get(url)
LOG.d(f"Status code: {response.status_code}")
if response.status_code != 200:
return UpcloudMetrics(metrics=[])
as_json = response.json()
return get_metrics(as_json)

View File

@ -1,3 +1,4 @@
import configparser
import os import os
import subprocess import subprocess
from time import sleep from time import sleep
@ -7,6 +8,7 @@ import newrelic.agent
from app.db import Session from app.db import Session
from app.log import LOG from app.log import LOG
from monitor.metric_exporter import MetricExporter
# the number of consecutive fails # the number of consecutive fails
# if more than _max_nb_fails, alert # if more than _max_nb_fails, alert
@ -19,6 +21,18 @@ _max_nb_fails = 10
# the maximum number of emails in incoming & active queue # the maximum number of emails in incoming & active queue
_max_incoming = 50 _max_incoming = 50
_NR_CONFIG_FILE_LOCATION_VAR = "NEW_RELIC_CONFIG_FILE"
def get_newrelic_license() -> str:
nr_file = os.environ.get(_NR_CONFIG_FILE_LOCATION_VAR, None)
if nr_file is None:
raise Exception(f"{_NR_CONFIG_FILE_LOCATION_VAR} not defined")
config = configparser.ConfigParser()
config.read(nr_file)
return config["newrelic"]["license_key"]
@newrelic.agent.background_task() @newrelic.agent.background_task()
def log_postfix_metrics(): def log_postfix_metrics():
@ -80,10 +94,13 @@ def log_nb_db_connection():
if __name__ == "__main__": if __name__ == "__main__":
exporter = MetricExporter(get_newrelic_license())
while True: while True:
log_postfix_metrics() log_postfix_metrics()
log_nb_db_connection() log_nb_db_connection()
Session.close() Session.close()
exporter.run()
# 1 min # 1 min
sleep(60) sleep(60)

5321
app/poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -53,7 +53,7 @@ packages = [
include = ["templates/*", "templates/**/*", "local_data/*.txt"] include = ["templates/*", "templates/**/*", "local_data/*.txt"]
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.7.2" python = "^3.10"
flask = "^1.1.2" flask = "^1.1.2"
flask_login = "^0.5.0" flask_login = "^0.5.0"
wtforms = "^2.3.3" wtforms = "^2.3.3"
@ -95,13 +95,12 @@ webauthn = "^0.4.7"
pyspf = "^2.0.14" pyspf = "^2.0.14"
Flask-Limiter = "^1.4" Flask-Limiter = "^1.4"
memory_profiler = "^0.57.0" memory_profiler = "^0.57.0"
gevent = "^21.12.0" gevent = "22.10.2"
aiospamc = "^0.6.1"
email_validator = "^1.1.1" email_validator = "^1.1.1"
PGPy = "0.5.4" PGPy = "0.5.4"
coinbase-commerce = "^1.0.1" coinbase-commerce = "^1.0.1"
requests = "^2.25.1" requests = "^2.25.1"
newrelic = "^7.10.0" newrelic = "8.8.0"
flanker = "^0.9.11" flanker = "^0.9.11"
pyre2 = "^0.3.6" pyre2 = "^0.3.6"
tldextract = "^3.1.2" tldextract = "^3.1.2"
@ -110,7 +109,9 @@ twilio = "^7.3.2"
Deprecated = "^1.2.13" Deprecated = "^1.2.13"
cryptography = "37.0.1" cryptography = "37.0.1"
SQLAlchemy = "1.3.24" SQLAlchemy = "1.3.24"
redis = "^4.3.4" redis = "^4.5.3"
newrelic-telemetry-sdk = "^0.5.0"
aiospamc = "0.10"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
pytest = "^7.0.0" pytest = "^7.0.0"

View File

@ -79,6 +79,7 @@ from app.config import (
MEM_STORE_URI, MEM_STORE_URI,
) )
from app.dashboard.base import dashboard_bp from app.dashboard.base import dashboard_bp
from app.subscription_webhook import execute_subscription_webhook
from app.db import Session from app.db import Session
from app.developer.base import developer_bp from app.developer.base import developer_bp
from app.discover.base import discover_bp from app.discover.base import discover_bp
@ -491,6 +492,7 @@ def setup_paddle_callback(app: Flask):
# in case user cancels a plan and subscribes a new plan # in case user cancels a plan and subscribes a new plan
sub.cancelled = False sub.cancelled = False
execute_subscription_webhook(user)
LOG.d("User %s upgrades!", user) LOG.d("User %s upgrades!", user)
Session.commit() Session.commit()
@ -509,6 +511,7 @@ def setup_paddle_callback(app: Flask):
).date() ).date()
Session.commit() Session.commit()
execute_subscription_webhook(sub.user)
elif request.form.get("alert_name") == "subscription_cancelled": elif request.form.get("alert_name") == "subscription_cancelled":
subscription_id = request.form.get("subscription_id") subscription_id = request.form.get("subscription_id")
@ -538,6 +541,7 @@ def setup_paddle_callback(app: Flask):
end_date=request.form.get("cancellation_effective_date"), end_date=request.form.get("cancellation_effective_date"),
), ),
) )
execute_subscription_webhook(sub.user)
else: else:
# user might have deleted their account # user might have deleted their account
@ -580,6 +584,7 @@ def setup_paddle_callback(app: Flask):
sub.cancelled = False sub.cancelled = False
Session.commit() Session.commit()
execute_subscription_webhook(sub.user)
else: else:
LOG.w( LOG.w(
f"update non-exist subscription {subscription_id}. {request.form}" f"update non-exist subscription {subscription_id}. {request.form}"
@ -596,6 +601,7 @@ def setup_paddle_callback(app: Flask):
Subscription.delete(sub.id) Subscription.delete(sub.id)
Session.commit() Session.commit()
LOG.e("%s requests a refund", user) LOG.e("%s requests a refund", user)
execute_subscription_webhook(sub.user)
elif request.form.get("alert_name") == "subscription_payment_refunded": elif request.form.get("alert_name") == "subscription_payment_refunded":
subscription_id = request.form.get("subscription_id") subscription_id = request.form.get("subscription_id")
@ -629,6 +635,7 @@ def setup_paddle_callback(app: Flask):
LOG.e("Unknown plan_id %s", plan_id) LOG.e("Unknown plan_id %s", plan_id)
else: else:
LOG.w("partial subscription_payment_refunded, not handled") LOG.w("partial subscription_payment_refunded, not handled")
execute_subscription_webhook(sub.user)
return "OK" return "OK"
@ -742,6 +749,7 @@ def handle_coinbase_event(event) -> bool:
coinbase_subscription=coinbase_subscription, coinbase_subscription=coinbase_subscription,
), ),
) )
execute_subscription_webhook(user)
return True return True

View File

@ -155,10 +155,8 @@ $(".pin-alias").change(async function () {
} }
}); });
$(".save-note").on("click", async function () { async function handleNoteChange(aliasId, aliasEmail) {
let oldValue; const note = document.getElementById(`note-${aliasId}`).value;
let aliasId = $(this).data("alias");
let note = $(`#note-${aliasId}`).val();
try { try {
let res = await fetch(`/api/aliases/${aliasId}`, { let res = await fetch(`/api/aliases/${aliasId}`, {
@ -172,26 +170,27 @@ $(".save-note").on("click", async function () {
}); });
if (res.ok) { if (res.ok) {
toastr.success(`Saved`); toastr.success(`Description saved for ${aliasEmail}`);
} else { } else {
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error"); toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
// reset to the original value
oldValue = !$(this).prop("checked");
$(this).prop("checked", oldValue);
} }
} catch (e) { } catch (e) {
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error"); toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
// reset to the original value
oldValue = !$(this).prop("checked");
$(this).prop("checked", oldValue);
} }
}); }
$(".save-mailbox").on("click", async function () { function handleNoteFocus(aliasId) {
let oldValue; document.getElementById(`note-focus-message-${aliasId}`).classList.remove('d-none');
let aliasId = $(this).data("alias"); }
let mailbox_ids = $(`#mailbox-${aliasId}`).val();
function handleNoteBlur(aliasId) {
document.getElementById(`note-focus-message-${aliasId}`).classList.add('d-none');
}
async function handleMailboxChange(aliasId, aliasEmail) {
const selectedOptions = document.getElementById(`mailbox-${aliasId}`).selectedOptions;
const mailbox_ids = Array.from(selectedOptions).map((selectedOption) => selectedOption.value);
if (mailbox_ids.length === 0) { if (mailbox_ids.length === 0) {
toastr.error("You must select at least a mailbox", "Error"); toastr.error("You must select at least a mailbox", "Error");
@ -210,25 +209,18 @@ $(".save-mailbox").on("click", async function () {
}); });
if (res.ok) { if (res.ok) {
toastr.success(`Mailbox Updated`); toastr.success(`Mailbox updated for ${aliasEmail}`);
} else { } else {
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error"); toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
// reset to the original value
oldValue = !$(this).prop("checked");
$(this).prop("checked", oldValue);
} }
} catch (e) { } catch (e) {
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error"); toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
// reset to the original value
oldValue = !$(this).prop("checked");
$(this).prop("checked", oldValue);
} }
}); }
$(".save-alias-name").on("click", async function () { async function handleDisplayNameChange(aliasId, aliasEmail) {
let aliasId = $(this).data("alias"); const name = document.getElementById(`alias-name-${aliasId}`).value;
let name = $(`#alias-name-${aliasId}`).val();
try { try {
let res = await fetch(`/api/aliases/${aliasId}`, { let res = await fetch(`/api/aliases/${aliasId}`, {
@ -242,7 +234,7 @@ $(".save-alias-name").on("click", async function () {
}); });
if (res.ok) { if (res.ok) {
toastr.success(`Alias Name Saved`); toastr.success(`Display name saved for ${aliasEmail}`);
} else { } else {
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error"); toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
} }
@ -250,24 +242,41 @@ $(".save-alias-name").on("click", async function () {
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error"); toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
} }
}); }
function handleDisplayNameFocus(aliasId) {
document.getElementById(`display-name-focus-message-${aliasId}`).classList.remove('d-none');
}
function handleDisplayNameBlur(aliasId) {
document.getElementById(`display-name-focus-message-${aliasId}`).classList.add('d-none');
}
new Vue({ new Vue({
el: '#filter-app', el: '#filter-app',
delimiters: ["[[", "]]"], // necessary to avoid conflict with jinja delimiters: ["[[", "]]"], // necessary to avoid conflict with jinja
data: { data: {
showFilter: false showFilter: false,
showStats: false
}, },
methods: { methods: {
async toggleFilter() { async toggleFilter() {
let that = this; let that = this;
that.showFilter = !that.showFilter; that.showFilter = !that.showFilter;
store.set('showFilter', that.showFilter); store.set('showFilter', that.showFilter);
},
async toggleStats() {
let that = this;
that.showStats = !that.showStats;
store.set('showStats', that.showStats);
} }
}, },
async mounted() { async mounted() {
if (store.get("showFilter")) if (store.get("showFilter"))
this.showFilter = true; this.showFilter = true;
if (store.get("showStats"))
this.showStats = true;
} }
}); });

View File

@ -8,7 +8,8 @@ function enableDragDropForPGPKeys(inputID) {
let files = event.dataTransfer.files; let files = event.dataTransfer.files;
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
let file = files[i]; let file = files[i];
if(file.type !== 'text/plain'){ const isValidPgpFile = file.type === 'text/plain' || file.name.endsWith('.asc') || file.name.endsWith('.pub') || file.name.endsWith('.pgp') || file.name.endsWith('.key');
if (!isValidPgpFile) {
toastr.warning(`File ${file.name} is not a public key file`); toastr.warning(`File ${file.name} is not a public key file`);
continue; continue;
} }
@ -16,6 +17,7 @@ function enableDragDropForPGPKeys(inputID) {
reader.onloadend = onFileLoaded; reader.onloadend = onFileLoaded;
reader.readAsBinaryString(file); reader.readAsBinaryString(file);
} }
dropArea.classList.remove("dashed-outline");
} }
function onFileLoaded(event) { function onFileLoaded(event) {
@ -24,5 +26,20 @@ function enableDragDropForPGPKeys(inputID) {
} }
const dropArea = $(inputID).get(0); const dropArea = $(inputID).get(0);
dropArea.addEventListener("dragenter", (event) => {
event.stopPropagation();
event.preventDefault();
dropArea.classList.add("dashed-outline");
});
dropArea.addEventListener("dragover", (event) => {
event.stopPropagation();
event.preventDefault();
dropArea.classList.add("dashed-outline");
});
dropArea.addEventListener("dragleave", (event) => {
event.stopPropagation();
event.preventDefault();
dropArea.classList.remove("dashed-outline");
});
dropArea.addEventListener("drop", drop, false); dropArea.addEventListener("drop", drop, false);
} }

16
app/static/package-lock.json generated vendored
View File

@ -69,12 +69,12 @@
"font-awesome": { "font-awesome": {
"version": "4.7.0", "version": "4.7.0",
"resolved": "https://registry.npmjs.org/font-awesome/-/font-awesome-4.7.0.tgz", "resolved": "https://registry.npmjs.org/font-awesome/-/font-awesome-4.7.0.tgz",
"integrity": "sha1-j6jPBBGhoxr9B7BtKQK7n8gVoTM=" "integrity": "sha512-U6kGnykA/6bFmg1M/oT9EkFeIYv7JlX3bozwQJWiiLz6L0w3F5vBVPxHlwyX/vtNq1ckcpRKOB9f2Qal/VtFpg=="
}, },
"htmx.org": { "htmx.org": {
"version": "1.6.1", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-1.6.1.tgz", "resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-1.7.0.tgz",
"integrity": "sha512-i+1k5ee2eFWaZbomjckyrDjUpa3FMDZWufatUSBmmsjXVksn89nsXvr1KLGIdAajiz+ZSL7TE4U/QaZVd2U2sA==" "integrity": "sha512-wIQ3yNq7yiLTm+6BhV7Z8qKKTzEQv9xN/I4QsN5FvdGi69SNWTsSMlhH69HPa1rpZ8zSq1A/e7gTbTySxliP8g=="
}, },
"intro.js": { "intro.js": {
"version": "2.9.3", "version": "2.9.3",
@ -82,9 +82,9 @@
"integrity": "sha512-hC+EXWnEuJeA3CveGMat3XHePd2iaXNFJIVfvJh2E9IzBMGLTlhWvPIVHAgKlOpO4lNayCxEqzr4N02VmHFr9Q==" "integrity": "sha512-hC+EXWnEuJeA3CveGMat3XHePd2iaXNFJIVfvJh2E9IzBMGLTlhWvPIVHAgKlOpO4lNayCxEqzr4N02VmHFr9Q=="
}, },
"jquery": { "jquery": {
"version": "3.5.1", "version": "3.6.4",
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.5.1.tgz", "resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.4.tgz",
"integrity": "sha512-XwIBPqcMn57FxfT+Go5pzySnm4KWkT1Tv7gjrpT1srtf8Weynl6R273VJ5GjkRb51IzMp5nbaPjJXMWeju2MKg==" "integrity": "sha512-v28EW9DWDFpzcD9O5iyJXg3R3+q+mET5JhnjJzQUZMHOv67bpSIHq81GEYpPNZHG+XXHsfSme3nxp/hndKEcsQ=="
}, },
"multiple-select": { "multiple-select": {
"version": "1.5.2", "version": "1.5.2",
@ -107,7 +107,7 @@
"toastr": { "toastr": {
"version": "2.1.4", "version": "2.1.4",
"resolved": "https://registry.npmjs.org/toastr/-/toastr-2.1.4.tgz", "resolved": "https://registry.npmjs.org/toastr/-/toastr-2.1.4.tgz",
"integrity": "sha1-i0O+ZPudDEFIcURvLbjoyk6V8YE=", "integrity": "sha512-LIy77F5n+sz4tefMmFOntcJ6HL0Fv3k1TDnNmFZ0bU/GcvIIfy6eG2v7zQmMiYgaalAiUv75ttFrPn5s0gyqlA==",
"requires": { "requires": {
"jquery": ">=1.12.0" "jquery": ">=1.12.0"
} }

View File

@ -218,3 +218,8 @@ textarea.parsley-error {
.italic { .italic {
font-style: italic; font-style: italic;
} }
/* dashed outline to indicate droppable area */
.dashed-outline {
outline: 4px dashed gray;
}

View File

@ -9,10 +9,13 @@
<h1 class="card-title">Create new account</h1> <h1 class="card-title">Create new account</h1>
<div class="form-group"> <div class="form-group">
<label class="form-label">Email address</label> <label class="form-label">Email address</label>
{{ form.email(class="form-control", type="email") }} {{ form.email(class="form-control", type="email", placeholder="YourName@protonmail.com") }}
<div class="small-text alert alert-info" style="margin-top: 1px"> <div class="small-text alert alert-info" style="margin-top: 1px">
Emails sent to your alias will be forwarded to this email address. Emails sent to your alias will be forwarded to this email address.
<br>
It can't be a disposable or forwarding email address. It can't be a disposable or forwarding email address.
<br>
We recommend using a <a href="https://proton.me/mail" target="_blank">Proton Mail</a> address
</div> </div>
{{ render_field_errors(form.email) }} {{ render_field_errors(form.email) }}
</div> </div>

View File

@ -23,7 +23,7 @@
<!-- Yandex --> <!-- Yandex -->
<meta name="yandex-verification" content="c9e5d4d68bc983a1" /> <meta name="yandex-verification" content="c9e5d4d68bc983a1" />
<meta name="description" <meta name="description"
content="Protect your email address with email ALIAS. Create a different email alias for each website. No more phishing, spams."/> content="Protect your email address with email ALIAS. Create a different email alias for each website. No more phishing, or spam."/>
<link rel="icon" href="/static/favicon.ico" type="image/x-icon" /> <link rel="icon" href="/static/favicon.ico" type="image/x-icon" />
<link rel="shortcut icon" type="image/x-icon" href="/static/favicon.ico" /> <link rel="shortcut icon" type="image/x-icon" href="/static/favicon.ico" />
<link rel="canonical" href="{{ CANONICAL_URL }}" /> <link rel="canonical" href="{{ CANONICAL_URL }}" />

View File

@ -133,6 +133,7 @@
<div> <div>
<span> <span>
<a href="{{ 'mailto:' + contact.website_send_to() }}" <a href="{{ 'mailto:' + contact.website_send_to() }}"
target="_blank"
data-toggle="tooltip" data-toggle="tooltip"
title="You can click on this to open your email client. Or use the copy button 👉" title="You can click on this to open your email client. Or use the copy button 👉"
class="font-weight-bold"> class="font-weight-bold">

View File

@ -48,7 +48,7 @@
{% if scope == "email" %} {% if scope == "email" %}
Email: Email:
<a href="mailto:{{ val }}">{{ val }}</a> <a href="mailto:{{ val }}" target="_blank">{{ val }}</a>
{% elif scope == "name" %} {% elif scope == "name" %}
Name: {{ val }} Name: {{ val }}
{% endif %} {% endif %}

View File

@ -43,7 +43,7 @@
{% endif %} {% endif %}
<div class="form-group"> <div class="form-group">
<label class="form-label">PGP Public Key</label> <label class="form-label">PGP Public Key</label>
<textarea name="pgp" {% if not current_user.is_premium() %} disabled {% endif %} class="form-control" rows=10 id="pgp-public-key" placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----">{{ contact.pgp_public_key or "" }}</textarea> <textarea name="pgp" {% if not current_user.is_premium() %} disabled {% endif %} class="form-control" rows=10 id="pgp-public-key" placeholder="(Drag and drop or paste your pgp public key here)&#10;-----BEGIN PGP PUBLIC KEY BLOCK-----">{{ contact.pgp_public_key or "" }}</textarea>
</div> </div>
<button class="btn btn-primary" name="action" {% if not current_user.is_premium() %} <button class="btn btn-primary" name="action" {% if not current_user.is_premium() %}
disabled {% endif %} value="save"> disabled {% endif %} value="save">

View File

@ -266,7 +266,9 @@
<i>dkim._domainkey.{{ custom_domain.domain }}</i> as domain value instead. <i>dkim._domainkey.{{ custom_domain.domain }}</i> as domain value instead.
<br /> <br />
If you are using a subdomain, e.g. <i>subdomain.domain.com</i>, If you are using a subdomain, e.g. <i>subdomain.domain.com</i>,
you need to use <i>dkim._domainkey.subdomain</i> as domain value instead. you need to use <i>dkim._domainkey.subdomain</i> as the domain instead.
<br />
That means, if your domain is <i>mail.domain.com</i> you should enter <i>dkim._domainkey.mail</i> as the Domain.
<br /> <br />
</div> </div>
<div class="alert alert-info"> <div class="alert alert-info">

View File

@ -31,63 +31,11 @@
{% block title %}Alias{% endblock %} {% block title %}Alias{% endblock %}
{% block default_content %} {% block default_content %}
<!-- Global Stats -->
<div class="row">
<div class="col-12 col-md-6 col-lg-3">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="subheader">Aliases</div>
<div class="text-muted"
style="order: 2; margin-left: auto; font-size: .8rem">All time</div>
</div>
<div class="h1 m-0">{{ stats.nb_alias }}</div>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-lg-3">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="subheader">Forwarded</div>
<div class="text-muted"
style="order: 2; margin-left: auto; font-size: .8rem">Last 14 days</div>
</div>
<div class="h1 m-0">{{ stats.nb_forward }}</div>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-lg-3">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="subheader">Replies/Sent</div>
<div class="text-muted"
style="order: 2; margin-left: auto; font-size: .8rem">Last 14 days</div>
</div>
<div class="h1 m-0">{{ stats.nb_reply }}</div>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-lg-3">
<div class="card">
<div class="card-body">
<div class="d-flex align-items-center">
<div class="subheader">Blocked</div>
<div class="text-muted"
style="order: 2; margin-left: auto; font-size: .8rem">Last 14 days</div>
</div>
<div class="h1 m-0">{{ stats.nb_block }}</div>
</div>
</div>
</div>
</div>
<!-- END Global Stats -->
<!-- Controls: buttons & search --> <!-- Controls: buttons & search -->
<div id="filter-app"> <div id="filter-app">
<div class="row mb-3"> <div class="row mb-3">
<div class="col d-flex"> <div class="col d-flex flex-wrap justify-content-between">
<div> <div class="mb-1">
<div class="btn-group" role="group"> <div class="btn-group" role="group">
<form method="post"> <form method="post">
{{ csrf_form.csrf_token }} {{ csrf_form.csrf_token }}
@ -141,17 +89,86 @@
</div> </div>
</div> </div>
</div> </div>
<div style="margin-left: auto"> <div>
<div class="btn-group"> <div class="btn-group">
<a v-if="!showFilter" <a @click="toggleStats()" class="btn btn-outline-secondary">
@click="toggleFilter()" <span v-if="!showStats">
class="btn btn-outline-secondary"> <i class="fe fe-chevrons-down"></i>
<i class="fe fe-chevrons-down"></i> Filters Show stats
</span>
<span v-else>
<i class="fe fe-chevrons-up"></i>
Hide stats
</span>
</a>
</div>
<div class="btn-group">
<a @click="toggleFilter()" class="btn btn-outline-secondary">
<span v-if="!showFilter">
<i class="fe fe-chevrons-down"></i>
Show filters
</span>
<span v-else>
<i class="fe fe-chevrons-up"></i>
Hide filters
</span>
</a> </a>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- Global Stats -->
<div class="row" v-if="showStats">
<div class="col-12 col-md-6 col-lg-3">
<div class="card mb-3">
<div class="card-body py-3">
<div class="d-flex align-items-center">
<div class="subheader">Aliases</div>
<div class="text-muted"
style="order: 2; margin-left: auto; font-size: .8rem">All time</div>
</div>
<div class="h1 m-0">{{ stats.nb_alias }}</div>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-lg-3">
<div class="card mb-3">
<div class="card-body py-3">
<div class="d-flex align-items-center">
<div class="subheader">Forwarded</div>
<div class="text-muted"
style="order: 2; margin-left: auto; font-size: .8rem">Last 14 days</div>
</div>
<div class="h1 m-0">{{ stats.nb_forward }}</div>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-lg-3">
<div class="card mb-3">
<div class="card-body py-3">
<div class="d-flex align-items-center">
<div class="subheader">Replies/Sent</div>
<div class="text-muted"
style="order: 2; margin-left: auto; font-size: .8rem">Last 14 days</div>
</div>
<div class="h1 m-0">{{ stats.nb_reply }}</div>
</div>
</div>
</div>
<div class="col-12 col-md-6 col-lg-3">
<div class="card mb-3">
<div class="card-body py-3">
<div class="d-flex align-items-center">
<div class="subheader">Blocked</div>
<div class="text-muted"
style="order: 2; margin-left: auto; font-size: .8rem">Last 14 days</div>
</div>
<div class="h1 m-0">{{ stats.nb_block }}</div>
</div>
</div>
</div>
</div>
<!-- END Global Stats -->
<div class="row mb-2" v-if="showFilter" id="filter-control"> <div class="row mb-2" v-if="showFilter" id="filter-control">
<!-- Filter Control --> <!-- Filter Control -->
<div class="col d-flex"> <div class="col d-flex">
@ -223,11 +240,6 @@
<a href="{{ url_for('dashboard.index') }}" <a href="{{ url_for('dashboard.index') }}"
class="btn btn-outline-secondary">Reset</a> class="btn btn-outline-secondary">Reset</a>
{% endif %} {% endif %}
<a v-if="showFilter"
@click="toggleFilter()"
class="btn btn-outline-secondary">
<i class="fe fe-chevrons-up"></i>
</a>
</div> </div>
</div> </div>
</div> </div>
@ -342,17 +354,11 @@
</div> </div>
<!-- END Email Activity --> <!-- END Email Activity -->
<div class="small-text mt-1"> <div class="small-text mt-1">
Alias description Alias description <span id="note-focus-message-{{ alias.id }}" class="d-none font-italic">(automatically saved when you click outside the field)</span>
</div> </div>
<div class="d-flex mb-2"> <div class="d-flex mb-2">
<div class="flex-grow-1 mr-2"> <div class="flex-grow-1 mr-2">
<textarea id="note-{{ alias.id }}" name="note" class="form-control" style="font-size: 12px" rows="2" placeholder="e.g. where the alias is used or why is it created">{{ alias.note or "" }}</textarea> <textarea id="note-{{ alias.id }}" name="note" class="form-control" style="font-size: 12px" rows="2" placeholder="e.g. where the alias is used or why is it created" onchange="handleNoteChange({{ alias.id }}, '{{ alias.email }}')" onfocus="handleNoteFocus({{ alias.id }})" onblur="handleNoteBlur({{ alias.id }})">{{ alias.note or "" }}</textarea>
</div>
<div>
<a data-alias="{{ alias.id }}"
class="save-note btn btn-sm btn-outline-success w-100">
Save
</a>
</div> </div>
</div> </div>
<!-- Send Email && More button --> <!-- Send Email && More button -->
@ -421,7 +427,8 @@
data-width="100%" data-width="100%"
class="mailbox-select" class="mailbox-select"
multiple multiple
name="mailbox"> name="mailbox"
onchange="handleMailboxChange({{ alias.id }}, '{{ alias.email }}')">
{% for mailbox in mailboxes %} {% for mailbox in mailboxes %}
<option value="{{ mailbox.id }}" {% if alias_info.contain_mailbox(mailbox.id) %} <option value="{{ mailbox.id }}" {% if alias_info.contain_mailbox(mailbox.id) %}
@ -431,12 +438,6 @@
{% endfor %} {% endfor %}
</select> </select>
</div> </div>
<div>
<a data-alias="{{ alias.id }}"
class="save-mailbox btn btn-sm btn-outline-info w-100">
Update
</a>
</div>
</div> </div>
{% elif alias_info.mailbox != None and alias_info.mailbox.email != current_user.email %} {% elif alias_info.mailbox != None and alias_info.mailbox.email != current_user.email %}
<div class="small-text"> <div class="small-text">
@ -448,19 +449,18 @@
title="When sending an email from this alias, the email will have 'Display Name <{{ alias.email }}>' as sender."> title="When sending an email from this alias, the email will have 'Display Name <{{ alias.email }}>' as sender.">
Display name Display name
<i class="fe fe-help-circle"></i> <i class="fe fe-help-circle"></i>
<span id="display-name-focus-message-{{ alias.id }}"
class="d-none font-italic">(automatically saved when you click outside the field or press Enter)</span>
</div> </div>
<div class="d-flex"> <div class="d-flex">
<div class="flex-grow-1 mr-2"> <div class="flex-grow-1 mr-2">
<input id="alias-name-{{ alias.id }}" <input id="alias-name-{{ alias.id }}"
value="{{ alias.name or '' }}" value="{{ alias.name or '' }}"
class="form-control" class="form-control"
placeholder="{{ alias.custom_domain.name or "Alias name" }}"> placeholder="{{ alias.custom_domain.name or "Alias name" }}"
</div> onchange="handleDisplayNameChange({{ alias.id }}, '{{ alias.email }}')"
<div> onfocus="handleDisplayNameFocus({{ alias.id }})"
<a data-alias="{{ alias.id }}" onblur="handleDisplayNameBlur({{ alias.id }})">
class="save-alias-name btn btn-sm btn-outline-primary w-100">
Save
</a>
</div> </div>
</div> </div>
{% if alias.mailbox_support_pgp() %} {% if alias.mailbox_support_pgp() %}

View File

@ -71,7 +71,18 @@
</form> </form>
</div> </div>
<!-- END Change email --> <!-- END Change email -->
{% if mailbox.pgp_finger_print and not mailbox.disable_pgp and current_user.include_sender_in_reverse_alias %} <!-- Not show PGP option for Proton mailbox -->
{% if mailbox.is_proton() and not mailbox.pgp_enabled() %}
<div class="alert alert-info">
As an email is always encrypted at rest in Proton Mail, having SimpleLogin also encrypt your email is redundant and does not add any security benefit.
<br>
The PGP option on SimpleLogin is instead useful for when your mailbox provider isn't encrypted by default like Gmail, Outlook, etc.
</div>
{% endif %}
<div class="{% if mailbox.is_proton() and not mailbox.pgp_enabled() %}
disabled-content{% endif %}">
{% if mailbox.pgp_finger_print and not mailbox.disable_pgp and current_user.include_sender_in_reverse_alias and not mailbox.is_proton() %}
<div class="alert alert-info"> <div class="alert alert-info">
Email headers like <span class="italic">From, To, Subject</span> aren't encrypted by PGP. Email headers like <span class="italic">From, To, Subject</span> aren't encrypted by PGP.
@ -112,7 +123,7 @@
{{ csrf_form.csrf_token }} {{ csrf_form.csrf_token }}
<div class="form-group"> <div class="form-group">
<label class="form-label">PGP Public Key</label> <label class="form-label">PGP Public Key</label>
<textarea name="pgp" {% if not current_user.is_premium() %} disabled {% endif %} class="form-control" rows=10 id="pgp-public-key" placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----">{{ mailbox.pgp_public_key or "" }}</textarea> <textarea name="pgp" {% if not current_user.is_premium() %} disabled {% endif %} class="form-control" rows=10 id="pgp-public-key" placeholder="(Drag and drop or paste your pgp public key here)&#10;-----BEGIN PGP PUBLIC KEY BLOCK-----">{{ mailbox.pgp_public_key or "" }}</textarea>
</div> </div>
<input type="hidden" name="form-name" value="pgp"> <input type="hidden" name="form-name" value="pgp">
<button class="btn btn-primary" name="action" {% if not current_user.is_premium() %} <button class="btn btn-primary" name="action" {% if not current_user.is_premium() %}
@ -126,37 +137,30 @@
</form> </form>
</div> </div>
</div> </div>
<div class="card" {% if not mailbox.pgp_enabled() %} </div>
disabled {% endif %}> <div class="card" id="generic-subject">
<form method="post"> <form method="post" action="#generic-subject">
{{ csrf_form.csrf_token }} {{ csrf_form.csrf_token }}
<input type="hidden" name="form-name" value="generic-subject"> <input type="hidden" name="form-name" value="generic-subject">
<div class="card-body"> <div class="card-body">
<div class="card-title"> <div class="card-title">
Hide email subject when PGP is enabled Hide email subject
<div class="small-text mt-1"> <div class="small-text mt-1">
When PGP is enabled, you can choose to use a <b>generic</b> subject for the forwarded emails. The original subject will be added to the email body and all forwarded emails will have the generic subject.
The original subject is then added into the email body.
<br /> <br />
As PGP does not encrypt the email subject and the email subject might contain sensitive information, This option is often used when PGP is enabled.
this option will allow a further protection of your email content. As PGP does not encrypt the email subject, it allows a further protection of your email content.
</div> </div>
</div> </div>
<div class="alert alert-info">
As the email is encrypted, a subject like "Email for you"
will probably be rejected by your mailbox since it sounds like a spam.
<br />
Something like "Encrypted Email" would work much better :).
</div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Generic Subject</label> <label class="form-label">Generic Subject</label>
<input name="generic-subject" {% if not mailbox.pgp_enabled() %} <input name="generic-subject"
disabled {% endif %} class="form-control" maxlength="78" placeholder="Generic Subject" value="{{ mailbox.generic_subject or "" }}"> class="form-control"
maxlength="78"
placeholder="Generic Subject"
value="{{ mailbox.generic_subject or "" }}">
</div> </div>
<button class="btn btn-primary" name="action" {% if not mailbox.pgp_enabled() %} <button class="btn btn-primary" name="action" value="save">Save</button>
disabled {% endif %} value="save">
Save
</button>
{% if mailbox.generic_subject %} {% if mailbox.generic_subject %}
<button class="btn btn-danger float-right" name="action" value="remove">Remove</button> <button class="btn btn-danger float-right" name="action" value="remove">Remove</button>
@ -235,8 +239,8 @@
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block script %} {% block script %}
<script src="/static/js/utils/drag-drop-into-text.js"></script> <script src="/static/js/utils/drag-drop-into-text.js"></script>
<script> <script>
$(".custom-switch-input").change(function (e) { $(".custom-switch-input").change(function (e) {
@ -244,4 +248,4 @@
}); });
enableDragDropForPGPKeys('#pgp-public-key'); enableDragDropForPGPKeys('#pgp-public-key');
</script> </script>
{% endblock %} {% endblock %}

View File

@ -8,10 +8,11 @@
<script> <script>
if (window.Paddle === undefined) { if (window.Paddle === undefined) {
console.log("cannot load Paddle from CDN"); console.log("cannot load Paddle from CDN");
document.write('<script src="/static/vendor/paddle.js"><\/script>') // split string to avoid djlint incorrectly formatting the file
document.write('<' + 'script src="/static/vendor/paddle.js"><\/script' + '>');
} }
</script> </script>
<style type="text/css"> <style type="text/css">
html.mvc__a.mvc__lot.mvc__of.mvc__classes.mvc__to.mvc__increase.mvc__the.mvc__odds.mvc__of.mvc__winning.mvc__specificity, html.mvc__a.mvc__lot.mvc__of.mvc__classes.mvc__to.mvc__increase.mvc__the.mvc__odds.mvc__of.mvc__winning.mvc__specificity > body { html.mvc__a.mvc__lot.mvc__of.mvc__classes.mvc__to.mvc__increase.mvc__the.mvc__odds.mvc__of.mvc__winning.mvc__specificity, html.mvc__a.mvc__lot.mvc__of.mvc__classes.mvc__to.mvc__increase.mvc__the.mvc__odds.mvc__of.mvc__winning.mvc__specificity > body {
position: static; position: static;
} }
@ -25,191 +26,721 @@
[data-toggle="collapse"]:not(.collapsed) .if-collapsed { [data-toggle="collapse"]:not(.collapsed) .if-collapsed {
display: none; display: none;
} }
</style>
.btn-no-pointer {
pointer-events: none !important;
}
.tab-yearly__badge {
top: -8px !important;
left: 52px !important;
}
.border-2 {
border-width: 2px !important;
}
.text-start {
text-align: start !important;
}
</style>
{% endblock %} {% endblock %}
{% block announcement %} {% block announcement %}
{# TODO: to remove#} {# TODO: to remove#}
{# <div class="alert alert-danger text-center mb-0" role="alert">#} {# <div class="alert alert-danger text-center mb-0" role="alert">#}
{# Our payment provider Paddle is experiencing#} {# Our payment provider Paddle is experiencing#}
{# <a href="https://paddle.status.io" target="_blank">server issue <i class="fe fe-external-link"></i></a>#} {# <a href="https://paddle.status.io" target="_blank">server issue <i class="fe fe-external-link"></i></a>#}
{# that can make our checkout page unusable. <br />#} {# that can make our checkout page unusable. <br />#}
{# Please retry later and sorry for this issue!#} {# Please retry later and sorry for this issue!#}
{# </div>#} {# </div>#}
{% endblock %} {% endblock %}
{% block default_content %} {% block default_content %}
<div class="row"> <div class="pb-8">
<div class="col-sm-6 col-lg-6"> <div class="text-center mx-md-auto mb-8 mt-6">
<div class="card"> <h1>Upgrade to unlock premium features</h1>
<div class="card-body text-center"> </div>
<div class="h3">Premium</div> {% if manual_sub %}
<ul class="list-unstyled leading-loose mb-3">
<li>
<i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
Unlimited aliases
</li>
<li>
<i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
Unlimited custom domains
</li>
<li>
<i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
Catch-all (or wildcard) aliases
</li>
<li>
<i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
Up to 50 directories (or usernames)
</li>
<li>
<i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
Unlimited mailboxes
</li>
<li>
<i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
PGP Encryption
</li>
</ul>
<div class="small-text">
More information on our
<a href="https://simplelogin.io/pricing" target="_blank" rel="noopener noreferrer">
Pricing
Page <i class="fe fe-external-link"></i>
</a>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-6">
{% if manual_sub %}
<div class="alert alert-info"> <div class="alert alert-info mt-0 mb-6">
You currently have a subscription until <b>{{ manual_sub.end_at.format("YYYY-MM-DD") }}</b> You currently have a subscription until <b>{{ manual_sub.end_at.format("YYYY-MM-DD") }}</b>
({{ (manual_sub.end_at - now).days }} days left). ({{ (manual_sub.end_at - now).days }} days left).
<br /> <br />
Please note that the time left will <b>not</b> be taken into account in a new subscription. Please note that the time left will <b>not</b> be taken into account in a new subscription.
</div> </div>
<hr /> <hr />
{% endif %} {% endif %}
{% if proton_upgrade %} {% set sub = current_user.get_paddle_subscription() %}
{% if sub and sub.cancelled %}
<div id="proton-upgrade"> <div class="alert alert-primary mt-0 mb-6" role="alert">
<h4>Proton Unlimited, Business and Visionary plans include SimpleLogin premium and more!</h4> You have an active subscription until {{ sub.next_bill_date.strftime("%Y-%m-%d") }}.
<a class="btn btn-primary" role="button" href="https://account.proton.me/u/0/mail/upgrade"> <br />
<b>Upgrade your Proton account</b> Please note that if you re-subscribe now, this will be a completely
</a> new subscription and
<p class="mt-2 small"> your payment method will be charged <b>immediately</b>.
Starts at $9.99/month (billed yearly), starting with 500GB of storage, VPN, encrypted </div>
calendar & file storage and more. {% endif %}
</p> {% if coinbase_sub %}
<div class="middle-line my-5 h4">OR</div>
<div id="normal-upgrade-button">
<a class="btn btn-secondary collapsed" data-toggle="collapse" href="#normal-upgrade" role="button">
Upgrade your SimpleLogin account
<span class="if-collapsed">
<i class="fe fe-chevron-down"></i>
</span>
<span class="if-not-collapsed">
<i class="fe fe-chevron-up"></i>
</span>
</a>
<p class="mt-2 small">Starts at $2.5/month (billed yearly)</p>
</div>
</div>
{% endif %}
<div id="normal-upgrade" class="{% if proton_upgrade %} collapse{% endif %}">
<div class="display-6 my-3">
🔐 Secure payments by
<a href="https://paddle.com" target="_blank" rel="noopener noreferrer">
Paddle <i class="fe fe-external-link"></i>
</a>
</div>
{% set sub = current_user.get_paddle_subscription() %}
{% if sub and sub.cancelled %}
<div class="alert alert-primary" role="alert"> <div class="alert alert-info mt-0 mb-6">
You have an active subscription until {{ sub.next_bill_date.strftime("%Y-%m-%d") }}. You currently have a Coinbase subscription until <b>{{ coinbase_sub.end_at.format("YYYY-MM-DD") }}</b>
<br /> ({{ (coinbase_sub.end_at - now).days }} days left).
Please note that if you re-subscribe now, this will be a completely <br />
new subscription and Please note that the time left will <b>not</b> be taken into account in a new Paddle subscription.
your payment method will be charged <b>immediately</b>. </div>
</div> {% endif %}
{% endif %} <div class="nav btn-group mb-4 justify-content-center position-relative flex-nowrap d-flex"
{% if coinbase_sub %} id="pills-tab"
role="tablist">
<a class="btn btn-outline-primary flex-grow-0 px-8 py-2"
id="monthly-plan-tab"
data-toggle="tab"
href="#monthly-plan"
role="tab"
aria-controls="monthly-plan"
aria-selected="false">Monthly</a>
<a class="btn btn-outline-primary flex-grow-0 px-8 py-2 position-relative active"
id="yearly-plan-tab"
data-toggle="tab"
href="#yearly-plan"
role="tab"
aria-controls="yearly-plan"
aria-selected="true">Yearly<span class="badge badge-success position-absolute tab-yearly__badge"
style="font-size: 12px">Save $18</span></a>
</div>
<div class="tab-content mb-8">
<!-- monthly tab content -->
<div class="tab-pane"
id="monthly-plan"
role="tabpanel"
aria-labelledby="monthly-plan-tab">
<div class="row row-cards">
<!-- monthly free plan -->
<div class="{{ 'col-md-6 col-lg-4' if proton_upgrade else 'col-md-6' }}">
<div class="card card-md flex-grow-1">
<div class="card-body">
<div class="text-center">
<div class="h3">Free</div>
<div class="h3 my-3">$0</div>
<div class="text-center mt-4 mb-6">
{% set sub = current_user.get_paddle_subscription() %}
<button class="{{ 'invisible' if sub or manual_sub or coinbase_sub }} btn btn-lg btn-outline-secondary w-100 btn-no-pointer"
aria-disabled="true"
disabled>
Current plan
</button>
</div>
</div>
<ul class="list-unstyled">
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
10 aliases
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
1 mailbox
</li>
</ul>
</div>
</div>
</div>
<!-- END monthly free plan -->
<!-- monthly premium plan -->
<div class="{{ 'col-md-6 col-lg-4' if proton_upgrade else 'col-md-6' }}">
<div class="card card-md flex-grow-1 border-primary border-2">
<div class="card-body">
<div class="text-center">
<div class="h3">SimpleLogin Premium</div>
<div class="h3 my-3">$4 / month</div>
<div class="text-center mt-4 mb-6">
<button class="btn btn-primary btn-lg w-100"
onclick="upgradePaddle({{ PADDLE_MONTHLY_PRODUCT_ID }})">
Upgrade to Premium
</button>
</div>
</div>
<ul class="list-unstyled">
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
Unlimited aliases
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
Unlimited mailboxes
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
Custom domains: bring your own domain to create aliases like contact@your-domain.com
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
Catch-all (or wildcard) domain
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
Initiate a new email from your alias
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
5 subdomains
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
50 directories
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
PGP Encryption
</li>
</ul>
</div>
</div>
</div>
<!-- END monthly premium plan -->
<!-- monthly Proton plan -->
{% if proton_upgrade %}
<div class="alert alert-info"> <div class="col-md-6 col-lg-4">
You currently have a Coinbase subscription until <b>{{ coinbase_sub.end_at.format("YYYY-MM-DD") }}</b> <div class="card card-md flex-grow-1">
({{ (coinbase_sub.end_at - now).days }} days left). <div class="card-body">
<br /> <div class="text-center">
Please note that the time left will <b>not</b> be taken into account in a new Paddle subscription. <div class="h3">Proton plan</div>
</div> <div class="h3 my-3">Starts at $12.99 / month</div>
{% endif %} <div class="text-center mt-4 mb-6">
<div class="mb-3"> <a class="btn btn-lg btn-outline-primary w-100"
Paddle supports bank cards role="button"
(Mastercard, Visa, American Express, etc) and PayPal. href="https://account.proton.me/u/0/mail/upgrade"
</div> target="_blank">Upgrade your Proton account</a>
<button class="btn btn-primary" onclick="upgrade({{ PADDLE_YEARLY_PRODUCT_ID }})"> </div>
Yearly billing </div>
<span class="badge badge-success">Save $18</span> <p>Proton Unlimited / Business plans include:</p>
<br /> <ul class="list-unstyled">
<span style="font-size: 18px">$30/year</span> <li class="d-flex">
</button> <i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
<button class="btn btn-secondary" onclick="upgrade({{ PADDLE_MONTHLY_PRODUCT_ID }})"> SimpleLogin Premium
Monthly billing </li>
<br /> <li class="d-flex">
<b> <i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
$4/month 500 GB storage
</b> </li>
</button> <li class="d-flex">
<hr /> <i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
<i class="fa fa-bitcoin"></i> Unlimited folders, labels, and filters
Payment via </li>
<a href="https://commerce.coinbase.com/?lang=en" target="_blank" rel="noopener noreferrer"> <li class="d-flex">
Coinbase Commerce<i class="fe fe-external-link"></i> <i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
</a> Unlimited messages per day
<br /> </li>
Currently Bitcoin, Bitcoin Cash, Dai, Ethereum, Litecoin and USD Coin are supported. <li class="d-flex">
<br /> <i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
<a class="btn btn-outline-primary" href="{{ url_for('dashboard.coinbase_checkout_route') }}" target="_blank" rel="noopener noreferrer"> 25 calendars
Yearly billing - Crypto </li>
<br /> <li class="d-flex">
$30/year <i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
<i class="fe fe-external-link"></i> 10 high-speed VPN connections
</a> </li>
<hr /> <li class="d-flex">
If you have bought a coupon, please go to the <i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
<a href="{{ url_for('dashboard.coupon_route') }}">coupon page</a> 3 custom email domains
to apply the coupon code. </li>
</div> </ul>
</div> </div>
</div> </div>
<script type="text/javascript"> </div>
{% endif %}
<!-- END monthly Proton plan -->
</div>
</div>
<!-- END monthly tab content -->
<!-- yearly tab content -->
<div class="tab-pane show active"
id="yearly-plan"
role="tabpanel"
aria-labelledby="yearly-plan-tab">
<div class="row row-cards">
<!-- yearly free plan (identical to monthly) -->
<div class="{{ 'col-md-6 col-lg-4' if proton_upgrade else 'col-md-6' }}">
<div class="card card-md flex-grow-1">
<div class="card-body">
<div class="text-center">
<div class="h3">Free</div>
<div class="h3 my-3">$0</div>
<div class="text-center mt-4 mb-6">
{% set sub = current_user.get_paddle_subscription() %}
<button class="{{ 'invisible' if sub or manual_sub or coinbase_sub }} btn btn-lg btn-outline-secondary w-100 btn-no-pointer"
aria-disabled="true"
disabled>
Current plan
</button>
</div>
</div>
<ul class="list-unstyled">
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
10 aliases
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
1 mailbox
</li>
</ul>
</div>
</div>
</div>
<!-- END yearly free plan -->
<!-- yearly premium plan -->
<div class="{{ 'col-md-6 col-lg-4' if proton_upgrade else 'col-md-6' }}">
<div class="card card-md flex-grow-1 border-primary border-2">
<div class="card-body">
<div class="text-center">
<div class="h3">SimpleLogin Premium</div>
<div class="h3 my-3">$30 / year</div>
<div class="text-center mt-4 mb-6">
<button class="btn btn-primary btn-lg w-100"
onclick="upgradePaddle({{ PADDLE_YEARLY_PRODUCT_ID }})">
Upgrade to Premium
</button>
</div>
</div>
<ul class="list-unstyled">
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
Unlimited aliases
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
Unlimited mailboxes
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
Custom domains: bring your own domain to create aliases like contact@your-domain.com
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
Catch-all (or wildcard) domain
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
Initiate a new email from your alias
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
5 subdomains
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
50 directories
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
PGP Encryption
</li>
</ul>
</div>
</div>
</div>
<!-- END yearly premium plan -->
<!-- yearly Proton plan -->
{% if proton_upgrade %}
<div class="col-md-6 col-lg-4">
<div class="card card-md flex-grow-1">
<div class="card-body">
<div class="text-center">
<div class="h3">Proton plan</div>
<div class="h3 my-3">Starts at $119.88 / year</div>
<div class="text-center mt-4 mb-6">
<a class="btn btn-lg btn-outline-primary w-100"
role="button"
href="https://account.proton.me/u/0/mail/upgrade"
target="_blank">Upgrade your Proton account</a>
</div>
</div>
<p>Proton Unlimited / Business plans include:</p>
<ul class="list-unstyled">
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
SimpleLogin Premium
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
500 GB storage
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
Unlimited folders, labels, and filters
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
Unlimited messages per day
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
25 calendars
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
10 high-speed VPN connections
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
3 custom email domains
</li>
</ul>
</div>
</div>
</div>
{% endif %}
<!-- END yearly Proton plan -->
</div>
</div>
<!-- END yearly tab content -->
</div>
<hr />
<!-- FAQ section -->
<div>
<h3 class="text-center mb-5 mt-7">Frequently asked questions</h3>
<div id="pricing-faq">
<div class="card mb-3">
<div class="card-header card-collapse p-0"
id="pricing-faq-question-payment-methods">
<h5 class="mb-0 w-100">
<button class="btn btn-link btn-block d-flex justify-content-between card-btn p-4 collapsed text-decoration-none"
data-toggle="collapse"
data-target="#pricing-faq-answer-payment-methods"
aria-controls="pricing-faq-answer-payment-methods"
aria-expanded="false">
<span class="text-start">Which payment methods (credit cards, PayPal, cryptocurrencies...) do you support?</span>
<span class="if-collapsed">
<i class="fe fe-chevron-down"></i>
</span>
<span class="if-not-collapsed">
<i class="fe fe-chevron-up"></i>
</span>
</button>
</h5>
</div>
<div id="pricing-faq-answer-payment-methods"
class="collapse"
aria-labelledby="pricing-faq-question-payment-methods"
data-parent="#pricing-faq">
<div class="card-body">
<p>
We use <a href="https://paddle.com" target="_blank" rel="noopener noreferrer">Paddle <i class="fe fe-external-link"></i></a> by default for handling payments via credit cards and PayPal. Paddle currently supports the following payment methods:
</p>
<ul>
<li>
Cards (including Mastercard, Visa, Maestro, American Express, Discover, Diners Club, JCB, UnionPay, and Mada)
</li>
<li>
PayPal
</li>
<li>
Apple Pay
</li>
<li>
Wire Transfers (ACH/SEPA/BACS)
</li>
</ul>
<p>
More information can be found on
<a href="https://paddle.com/support/which-payment-methods-do-you-support/"
target="_blank"
rel="noopener noreferrer">
Paddle supported payment methods <i class="fe fe-external-link"></i>
</a>.
</p>
<hr />
<p>
Furthermore we also support cryptocurrencies for the yearly plan via
<a href="https://commerce.coinbase.com"
target="_blank"
rel="noopener noreferrer">
Coinbase Commerce <i class="fe fe-external-link"></i>
</a>, which currently supports Bitcoin, Bitcoin Cash, DAI, ApeCoin, Dogecoin, Ethereum, Litecoin, SHIBA INU, Tether and USD Coin.
</p>
<p>
In the future, we are going to support Monero as well. In the meantime, please send us an email at <a href="mailto:support@simplelogin.zendesk.com" target="_blank">support@simplelogin.zendesk.com</a> if you want to use this cryptocurrency.
</p>
<div class="d-flex justify-content-center">
<a class="btn btn-outline-primary text-center"
href="{{ url_for('dashboard.coinbase_checkout_route') }}"
target="_blank"
rel="noopener noreferrer">
Upgrade to Premium - cryptocurrency
<br />
$30 / year
<i class="fe fe-external-link"></i>
</a>
</div>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header card-collapse p-0"
id="pricing-faq-question-coupon">
<h5 class="mb-0 w-100">
<button class="btn btn-link btn-block d-flex justify-content-between card-btn p-4 collapsed text-decoration-none"
data-toggle="collapse"
data-target="#pricing-faq-answer-coupon"
aria-controls="pricing-faq-answer-coupon"
aria-expanded="false">
<span class="text-start">Where can I redeem / buy a coupon?</span>
<span class="if-collapsed">
<i class="fe fe-chevron-down"></i>
</span>
<span class="if-not-collapsed">
<i class="fe fe-chevron-up"></i>
</span>
</button>
</h5>
</div>
<div id="pricing-faq-answer-coupon"
class="collapse"
aria-labelledby="pricing-faq-question-coupon"
data-parent="#pricing-faq">
<div class="card-body">
<p>
To redeem or buy a coupon, please go to the
<a href="{{ url_for('dashboard.coupon_route') }}">coupon page</a>. The coupon code can be used by you or given to someone as a gift.
</p>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header card-collapse p-0"
id="pricing-faq-question-aliases-sub-stopped">
<h5 class="mb-0 w-100">
<button class="btn btn-link btn-block d-flex justify-content-between card-btn p-4 collapsed text-decoration-none"
data-toggle="collapse"
data-target="#pricing-faq-answer-aliases-sub-stopped"
aria-controls="pricing-faq-answer-aliases-sub-stopped"
aria-expanded="false">
<span class="text-start">What happens to my aliases when I stop the subscription?</span>
<span class="if-collapsed">
<i class="fe fe-chevron-down"></i>
</span>
<span class="if-not-collapsed">
<i class="fe fe-chevron-up"></i>
</span>
</button>
</h5>
</div>
<div id="pricing-faq-answer-aliases-sub-stopped"
class="collapse"
aria-labelledby="pricing-faq-question-aliases-sub-stopped"
data-parent="#pricing-faq">
<div class="card-body">
<p>
When your subscription ends, all aliases you created continue working normally, both on receiving and
sending emails. Concretely:
</p>
<ul>
<li>
All aliases/domains/directories/mailboxes you have created are kept and continue working normally.
</li>
<li>
You cannot create new aliases if you exceed the free plan limit, i.e. have more than 10 aliases.
</li>
<li>
As features like catch-all or directory allow you to create aliases on-the-fly, those aliases cannot be automatically created if you have more than 10 aliases.
</li>
<li>
You cannot add new domain, directory or mailbox.
</li>
</ul>
<p>
For example, if you have 100 aliases by the time your subscription ends, these 100 aliases will continue receiving and sending emails normally. You cannot however create new aliases.
</p>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header card-collapse p-0"
id="pricing-faq-question-aliases-max">
<h5 class="mb-0 w-100">
<button class="btn btn-link btn-block d-flex justify-content-between card-btn p-4 collapsed text-decoration-none"
data-toggle="collapse"
data-target="#pricing-faq-answer-aliases-max"
aria-controls="pricing-faq-answer-aliases-max"
aria-expanded="false">
<span class="text-start">What happens when I reach the maximum number of alias in free plan?</span>
<span class="if-collapsed">
<i class="fe fe-chevron-down"></i>
</span>
<span class="if-not-collapsed">
<i class="fe fe-chevron-up"></i>
</span>
</button>
</h5>
</div>
<div id="pricing-faq-answer-aliases-max"
class="collapse"
aria-labelledby="pricing-faq-question-aliases-max"
data-parent="#pricing-faq">
<div class="card-body">
<p>
If you are in the free plan, you cannot create new aliases when you reach the maximum number of aliases
(i.e. 10 aliases).
<br>
Aliases that would otherwise be created automatically via the catch-all domain or directory feature also cannot be created.
</p>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header card-collapse p-0"
id="pricing-faq-question-discounts">
<h5 class="mb-0 w-100">
<button class="btn btn-link btn-block d-flex justify-content-between card-btn p-4 collapsed text-decoration-none"
data-toggle="collapse"
data-target="#pricing-faq-answer-discounts"
aria-controls="pricing-faq-answer-discounts"
aria-expanded="false">
<span class="text-start">Do you offer discounts?</span>
<span class="if-collapsed">
<i class="fe fe-chevron-down"></i>
</span>
<span class="if-not-collapsed">
<i class="fe fe-chevron-up"></i>
</span>
</button>
</h5>
</div>
<div id="pricing-faq-answer-discounts"
class="collapse"
aria-labelledby="pricing-faq-question-discounts"
data-parent="#pricing-faq">
<div class="card-body">
<p>
We offer important discounts or free premium for:
</p>
<ul>
<li>
students, professors or technical staffs working at an educational institute
</li>
<li>
activists, dissidents or journalists
</li>
<li>
charity organizations
</li>
</ul>
<p>
Please send us an email at <a href="mailto:support@simplelogin.zendesk.com" target="_blank">support@simplelogin.zendesk.com</a> for more info.
</p>
<p>
We used to offer free premium accounts for students but this program ended at June 17 2021. Please note this doesn't affect existing accounts who have already benefited from the program or requests sent before this date.
</p>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header card-collapse p-0"
id="pricing-faq-question-refund">
<h5 class="mb-0 w-100">
<button class="btn btn-link btn-block d-flex justify-content-between card-btn p-4 collapsed text-decoration-none"
data-toggle="collapse"
data-target="#pricing-faq-answer-refund"
aria-controls="pricing-faq-answer-refund"
aria-expanded="false">
<span class="text-start">Do you have a refund policy?</span>
<span class="if-collapsed">
<i class="fe fe-chevron-down"></i>
</span>
<span class="if-not-collapsed">
<i class="fe fe-chevron-up"></i>
</span>
</button>
</h5>
</div>
<div id="pricing-faq-answer-refund"
class="collapse"
aria-labelledby="pricing-faq-question-refund"
data-parent="#pricing-faq">
<div class="card-body">
<p>
No we don't have a refund policy because SimpleLogin has a trial period where you can try all premium features.
</p>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header card-collapse p-0"
id="pricing-faq-question-family">
<h5 class="mb-0 w-100">
<button class="btn btn-link btn-block d-flex justify-content-between card-btn p-4 collapsed text-decoration-none"
data-toggle="collapse"
data-target="#pricing-faq-answer-family"
aria-controls="pricing-faq-answer-family"
aria-expanded="false">
<span class="text-start">Do you have a family plan?</span>
<span class="if-collapsed">
<i class="fe fe-chevron-down"></i>
</span>
<span class="if-not-collapsed">
<i class="fe fe-chevron-up"></i>
</span>
</button>
</h5>
</div>
<div id="pricing-faq-answer-family"
class="collapse"
aria-labelledby="pricing-faq-question-family"
data-parent="#pricing-faq">
<div class="card-body">
<p>
No we don't have a family plan but offer 30% reduction for additional subscriptions. Please contact us at <a href="mailto:support@simplelogin.zendesk.com" target="_blank">support@simplelogin.zendesk.com</a> for more information.
</p>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header card-collapse p-0"
id="pricing-faq-question-other-ways">
<h5 class="mb-0 w-100">
<button class="btn btn-link btn-block d-flex justify-content-between card-btn p-4 collapsed text-decoration-none"
data-toggle="collapse"
data-target="#pricing-faq-answer-other-ways"
aria-controls="pricing-faq-answer-other-ways"
aria-expanded="false">
<span class="text-start">Are there other ways to buy SimpleLogin subscriptions?</span>
<span class="if-collapsed">
<i class="fe fe-chevron-down"></i>
</span>
<span class="if-not-collapsed">
<i class="fe fe-chevron-up"></i>
</span>
</button>
</h5>
</div>
<div id="pricing-faq-answer-other-ways"
class="collapse"
aria-labelledby="pricing-faq-question-other-ways"
data-parent="#pricing-faq">
<div class="card-body">
<p>
Yes you can also buy SimpleLogin subscription coupon via <a href="https://proxysto.re/en/index.html" target="_blank">ProxyStore <i class="fe fe-external-link"></i></a>, our official reseller.
</p>
</div>
</div>
</div>
</div>
</div>
<!-- END FAQ section -->
</div>
<script type="text/javascript">
Paddle.Setup({vendor: {{ PADDLE_VENDOR_ID }}}); Paddle.Setup({vendor: {{ PADDLE_VENDOR_ID }}});
function upgrade(productId) { function upgradePaddle(productId) {
bootbox.dialog({
title: `Payment with credit card or PayPal via Paddle`,
message: `Paddle will ask for an email address for sending out the invoices, please feel free to use an alias. <br />
You don't have to use your SimpleLogin account email address`,
size: 'large',
onEscape: true,
backdrop: true,
buttons: {
got_it: {
label: 'Got it!',
className: 'btn-outline-primary',
callback: function () {
Paddle.Checkout.open({ Paddle.Checkout.open({
product: productId, product: productId,
success: "{{ success_url }}", success: "{{ success_url }}",
passthrough: "{\"user_id\": {{current_user.id}} }" passthrough: "{\"user_id\": {{current_user.id}} }"
}); });
} }
},
}
});
}
plausible("visit pricing"); </script>
</script>
{% endblock %} {% endblock %}

View File

@ -22,7 +22,7 @@
For every user who <b>upgrades</b> and stays with us at least 3 months, you'll get $5 :). For every user who <b>upgrades</b> and stays with us at least 3 months, you'll get $5 :).
<br /> <br />
The payout can be initiated any time, just send us an email at The payout can be initiated any time, just send us an email at
<a href="mailto:hi@simplelogin.io">hi@simplelogin.io</a> <a href="mailto:hi@simplelogin.io" target="_blank">hi@simplelogin.io</a>
when you want to receive the payout. when you want to receive the payout.
</div> </div>
{% if referrals|length == 0 %} {% if referrals|length == 0 %}

View File

@ -15,7 +15,7 @@
<div class="col"> <div class="col">
<h1 class="h3 mb-5">Quarantine & Bounce</h1> <h1 class="h3 mb-5">Quarantine & Bounce</h1>
<div class="alert alert-info"> <div class="alert alert-info">
This page shows all emails that are either refused by your mailbox (bounced) or detected as spams/phishing (quarantine) via our This page shows all emails that are either refused by your mailbox (bounced) or detected as spam/phishing (quarantine) via our
<a href="https://simplelogin.io/docs/getting-started/anti-phishing/" <a href="https://simplelogin.io/docs/getting-started/anti-phishing/"
target="_blank" target="_blank"
rel="noopener noreferrer">anti-phishing program ↗</a> rel="noopener noreferrer">anti-phishing program ↗</a>
@ -31,7 +31,7 @@
rel="noopener noreferrer">Setting up filter for SimpleLogin emails ↗</a> rel="noopener noreferrer">Setting up filter for SimpleLogin emails ↗</a>
</li> </li>
<li> <li>
If the email is flagged as spams/phishing, this means that the sender explicitly states their emails should respect If the email is flagged as spam/phishing, this means that the sender explicitly states their emails should respect
<b>DMARC</b> (an email authentication protocol) <b>DMARC</b> (an email authentication protocol)
and any email that violates this should either be quarantined or rejected. If possible, please contact the sender and any email that violates this should either be quarantined or rejected. If possible, please contact the sender
so they can update their DMARC setting or fix their SPF/DKIM that cause the DMARC failure. so they can update their DMARC setting or fix their SPF/DKIM that cause the DMARC failure.

View File

@ -181,10 +181,10 @@
<!-- END change name & profile picture --> <!-- END change name & profile picture -->
<!-- Change email --> <!-- Change email -->
<div class="card"> <div class="card">
<div class="card-body">
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
<input type="hidden" name="form-name" value="update-email"> <input type="hidden" name="form-name" value="update-email">
{{ change_email_form.csrf_token }} {{ change_email_form.csrf_token }}
<div class="card-body">
<div class="card-title">Account Email</div> <div class="card-title">Account Email</div>
<div class="mb-3"> <div class="mb-3">
This email address is used to log in to SimpleLogin. This email address is used to log in to SimpleLogin.
@ -199,26 +199,30 @@
<!-- Not allow user to change email if there's a pending change --> <!-- Not allow user to change email if there's a pending change -->
{{ change_email_form.email(class="form-control", value=current_user.email, readonly=pending_email != None) }} {{ change_email_form.email(class="form-control", value=current_user.email, readonly=pending_email != None) }}
{{ render_field_errors(change_email_form.email) }} {{ render_field_errors(change_email_form.email) }}
</div>
<button class="btn btn-outline-primary">Change Email</button>
</form>
{% if pending_email %} {% if pending_email %}
<div class="mt-2"> <div class="mt-2">
<span class="text-danger">Pending email change: {{ pending_email }}</span> <span class="text-danger float-left">Pending email change: {{ pending_email }}</span>
<a href="{{ url_for('dashboard.resend_email_change') }}" <form method="POST"
class="btn btn-secondary btn-sm"> action="{{ url_for('dashboard.resend_email_change') }}"
Resend class="float-left ml-2">
confirmation email {{ change_email_form.csrf_token }}
</a> <a onclick="this.closest('form').submit()"
<a href="{{ url_for('dashboard.cancel_email_change') }}" class="btn btn-secondary btn-sm">Resend confirmation email</a>
class="btn btn-secondary btn-sm"> </form>
Cancel email <form method="POST"
change action="{{ url_for('dashboard.cancel_email_change') }}"
</a> class="float-left ml-2">
{{ change_email_form.csrf_token }}
<a onclick="this.closest('form').submit()"
class="btn btn-secondary btn-sm">Cancel email change</a>
</form>
</div> </div>
{% endif %} {% endif %}
</div> </div>
<button class="btn btn-outline-primary">Change Email</button>
</div>
</form>
</div> </div>
<!-- END Change email --> <!-- END Change email -->
<!-- Connect with Proton --> <!-- Connect with Proton -->
@ -265,11 +269,15 @@
<div class="card" id="change_password"> <div class="card" id="change_password">
<div class="card-body"> <div class="card-body">
<div class="card-title">Password</div> <div class="card-title">Password</div>
<div class="mb-3">You will receive an email containing instructions on how to change your password.</div> <div class="mb-3">
You will receive an email containing instructions on how to change your password.
</div>
<form method="post"> <form method="post">
{{ csrf_form.csrf_token }} {{ csrf_form.csrf_token }}
<input type="hidden" name="form-name" value="change-password"> <input type="hidden" name="form-name" value="change-password">
<button class="btn btn-outline-primary">Change password</button> <button class="btn btn-outline-primary">
Change password
</button>
</form> </form>
</div> </div>
</div> </div>
@ -676,7 +684,8 @@
SimpleLogin forwards emails to your mailbox from the <b>reverse-alias</b> and not from the <b>original</b> SimpleLogin forwards emails to your mailbox from the <b>reverse-alias</b> and not from the <b>original</b>
sender address. sender address.
<br /> <br />
If this option is enabled, the original sender addresses is stored in the email header <b>X-SimpleLogin-Envelope-From</b>. If this option is enabled, the original sender addresses is stored in the email header <b>X-SimpleLogin-Envelope-From</b>
and the original From header is stored in <b>X-SimpleLogin-Original-From<b>.
You can choose to display this header in your email client. You can choose to display this header in your email client.
<br /> <br />
As email headers aren't encrypted, your mailbox service can know the sender address via this header. As email headers aren't encrypted, your mailbox service can know the sender address via this header.

View File

@ -28,7 +28,7 @@
<form id="supportZendeskForm" method="post" enctype="multipart/form-data"> <form id="supportZendeskForm" method="post" enctype="multipart/form-data">
<div class="mt-4 mb-5"> <div class="mt-4 mb-5">
<label for="issueDescription" class="form-label font-weight-bold">What happened?</label> <label for="issueDescription" class="form-label font-weight-bold">What happened?</label>
<textarea class="form-control" required name="ticket_content" id="issueDescription" rows="3" placeholder="Please provide as much information as possible. For example which alias(es), mailbox(es) ar affected, if this is a persistent issue...">{{- ticket_content or '' -}}</textarea> <textarea class="form-control" required name="ticket_content" id="issueDescription" rows="3" placeholder="Please provide as much information as possible. For example which alias(es), mailbox(es) are affected, if this is a persistent issue...">{{- ticket_content or '' -}}</textarea>
</div> </div>
<div class="mt-5 font-weight-bold">Attach files to support request</div> <div class="mt-5 font-weight-bold">Attach files to support request</div>
<div class="text-muted">Only images, text and emails are accepted</div> <div class="text-muted">Only images, text and emails are accepted</div>

View File

@ -15,5 +15,4 @@
<a class="btn btn-primary" href="/">Close</a> <a class="btn btn-primary" href="/">Close</a>
</div> </div>
</div> </div>
<script>plausible("upgraded")</script>
{% endblock %} {% endblock %}

View File

@ -9,7 +9,7 @@
<h1 class="h3">Block alias</h1> <h1 class="h3">Block alias</h1>
<p> <p>
You are about to block the alias You are about to block the alias
<a href="mailto:{{ alias }}">{{ alias }}</a> <a href="mailto:{{ alias }}" target="_blank">{{ alias }}</a>
</p> </p>
<p>After this, you will stop receiving all emails sent to this alias, please confirm.</p> <p>After this, you will stop receiving all emails sent to this alias, please confirm.</p>
<form method="post"> <form method="post">

View File

@ -31,7 +31,7 @@ Please consider the following options:
<a href="{{ disable_alias_link }}">disable the alias</a> <a href="{{ disable_alias_link }}">disable the alias</a>
or or
<a href="{{ block_sender_link }}">block the sender</a> <a href="{{ block_sender_link }}">block the sender</a>
if they send too many spams. if they send too many spam emails.
</li> </li>
</ol> </ol>
<br /> <br />

View File

@ -12,7 +12,7 @@ Please consider the following options:
2. If this email is spam, it means your alias {{alias}} is now in the hands of a spammer. 2. If this email is spam, it means your alias {{alias}} is now in the hands of a spammer.
You can either disable the alias on {{disable_alias_link}} You can either disable the alias on {{disable_alias_link}}
or block the sender on {{ block_sender_link }} if they send too many spams. or block the sender on {{ block_sender_link }} if they send too many spam emails.
Please note that the alias can be automatically disabled if too many emails sent to it are bounced. Please note that the alias can be automatically disabled if too many emails sent to it are bounced.

View File

@ -286,6 +286,7 @@
}, },
async mounted() { async mounted() {
Object.freeze(Object.prototype);
let that = this; let that = this;
let res = await fetch(`/api/notifications?page=${that.page}`, { let res = await fetch(`/api/notifications?page=${that.page}`, {
method: "GET", method: "GET",

View File

@ -19,7 +19,7 @@
<a href="{{ disable_alias_link }}">disable the alias</a> <a href="{{ disable_alias_link }}">disable the alias</a>
or or
<a href="{{ block_sender_link }}">block the sender</a> <a href="{{ block_sender_link }}">block the sender</a>
if they send too many spams. if they send too many spam emails.
</li> </li>
</ol> </ol>
</div> </div>

View File

@ -61,7 +61,7 @@
<img src="{{ user_info[scope.value] }}" class="avatar"> <img src="{{ user_info[scope.value] }}" class="avatar">
{% elif scope == Scope.EMAIL %} {% elif scope == Scope.EMAIL %}
{{ scope.value }}: {{ scope.value }}:
<a href="mailto:{{ user_info[scope.value] }}">{{ user_info[scope.value] }}</a> <a href="mailto:{{ user_info[scope.value] }}" target="_blank">{{ user_info[scope.value] }}</a>
{% elif scope == Scope.NAME %} {% elif scope == Scope.NAME %}
{{ scope.value }}: <b>{{ user_info[scope.value] }}</b> {{ scope.value }}: <b>{{ user_info[scope.value] }}</b>
{% endif %} {% endif %}

View File

@ -5,7 +5,7 @@
<div class="page-single"> <div class="page-single">
<div class="container"> <div class="container">
<div class="row"> <div class="row">
<div class="col mx-auto" style="max-width: 28rem"> <div class="col mx-auto" style="max-width: 32rem">
<div class="text-center mb-6"> <div class="text-center mb-6">
<a href="{{ LANDING_PAGE_URL }}"> <a href="{{ LANDING_PAGE_URL }}">
<img src="/static/logo.svg" <img src="/static/logo.svg"

View File

@ -17,7 +17,7 @@ def test_get_setting(flask_client):
"notification": True, "notification": True,
"random_alias_default_domain": "sl.local", "random_alias_default_domain": "sl.local",
"sender_format": "AT", "sender_format": "AT",
"random_alias_suffix": "random_string", "random_alias_suffix": "word",
} }
@ -95,11 +95,13 @@ def test_get_setting_domains_v2(flask_client):
def test_update_settings_random_alias_suffix(flask_client): def test_update_settings_random_alias_suffix(flask_client):
user = login(flask_client) user = login(flask_client)
# default random_alias_suffix is random_string # default random_alias_suffix is random_string
assert user.random_alias_suffix == AliasSuffixEnum.random_string.value assert user.random_alias_suffix == AliasSuffixEnum.word.value
r = flask_client.patch("/api/setting", json={"random_alias_suffix": "invalid"}) r = flask_client.patch("/api/setting", json={"random_alias_suffix": "invalid"})
assert r.status_code == 400 assert r.status_code == 400
r = flask_client.patch("/api/setting", json={"random_alias_suffix": "word"}) r = flask_client.patch(
"/api/setting", json={"random_alias_suffix": "random_string"}
)
assert r.status_code == 200 assert r.status_code == 200
assert user.random_alias_suffix == AliasSuffixEnum.word.value assert user.random_alias_suffix == AliasSuffixEnum.random_string.value

View File

@ -129,3 +129,12 @@ def test_change_name(flask_client):
assert r.json["name"] == "new name" assert r.json["name"] == "new name"
assert user.name == "new name" assert user.name == "new name"
def test_stats(flask_client):
login(flask_client)
r = flask_client.get("/api/stats")
assert r.status_code == 200
assert r.json == {"nb_alias": 1, "nb_block": 0, "nb_forward": 0, "nb_reply": 0}

View File

@ -0,0 +1,26 @@
from flask import url_for
from app.db import Session
from app.models import User, ResetPasswordCode
from tests.utils import create_new_user, random_token
def test_successful_reset_password(flask_client):
user = create_new_user()
original_pass_hash = user.password
user_id = user.id
reset_code = random_token()
ResetPasswordCode.create(user_id=user.id, code=reset_code)
ResetPasswordCode.create(user_id=user.id, code=random_token())
Session.commit()
r = flask_client.post(
url_for("auth.reset_password", code=reset_code),
data={"password": "1231idsfjaads"},
)
assert r.status_code == 302
assert ResetPasswordCode.get_by(user_id=user_id) is None
user = User.get(user_id)
assert user.password != original_pass_hash

View File

@ -1,4 +1,4 @@
from app.dashboard.views import alias_transfer import app.alias_utils
from app.db import Session from app.db import Session
from app.models import ( from app.models import (
Alias, Alias,
@ -29,7 +29,7 @@ def test_alias_transfer(flask_client):
user_id=new_user.id, email="hey2@example.com", verified=True, commit=True user_id=new_user.id, email="hey2@example.com", verified=True, commit=True
) )
alias_transfer.transfer(alias, new_user, new_user.mailboxes()) app.alias_utils.transfer_alias(alias, new_user, new_user.mailboxes())
# refresh from db # refresh from db
alias = Alias.get(alias.id) alias = Alias.get(alias.id)

View File

@ -1,10 +1,13 @@
from time import time from time import time
import arrow
from flask import url_for from flask import url_for
from app import config
from app.dashboard.views.api_key import clean_up_unused_or_old_api_keys
from app.db import Session from app.db import Session
from app.models import User, ApiKey from app.models import User, ApiKey
from tests.utils import login from tests.utils import login, create_new_user
def test_api_key_page_requires_password(flask_client): def test_api_key_page_requires_password(flask_client):
@ -34,6 +37,17 @@ def test_create_delete_api_key(flask_client):
assert ApiKey.filter(ApiKey.user_id == user.id).count() == 1 assert ApiKey.filter(ApiKey.user_id == user.id).count() == 1
assert api_key.name == "for test" assert api_key.name == "for test"
# create second api_key
create_r = flask_client.post(
url_for("dashboard.api_key"),
data={"form-name": "create", "name": "for test 2"},
follow_redirects=True,
)
assert create_r.status_code == 200
api_key_2 = ApiKey.filter_by(user_id=user.id).order_by(ApiKey.id.desc()).first()
assert ApiKey.filter(ApiKey.user_id == user.id).count() == 2
assert api_key_2.name == "for test 2"
# delete api_key # delete api_key
delete_r = flask_client.post( delete_r = flask_client.post(
url_for("dashboard.api_key"), url_for("dashboard.api_key"),
@ -41,7 +55,7 @@ def test_create_delete_api_key(flask_client):
follow_redirects=True, follow_redirects=True,
) )
assert delete_r.status_code == 200 assert delete_r.status_code == 200
assert ApiKey.count() == nb_api_key assert ApiKey.count() == nb_api_key + 1
def test_delete_all_api_keys(flask_client): def test_delete_all_api_keys(flask_client):
@ -87,3 +101,26 @@ def test_delete_all_api_keys(flask_client):
assert ( assert (
ApiKey.filter(ApiKey.user_id == user_2.id).count() == 1 ApiKey.filter(ApiKey.user_id == user_2.id).count() == 1
) # assert that user 2 still has 1 API key ) # assert that user 2 still has 1 API key
def test_cleanup_api_keys():
user = create_new_user()
ApiKey.create(
user_id=user.id, name="used", last_used=arrow.utcnow().shift(days=-3), times=1
)
ApiKey.create(
user_id=user.id, name="keep 1", last_used=arrow.utcnow().shift(days=-2), times=1
)
ApiKey.create(
user_id=user.id, name="keep 2", last_used=arrow.utcnow().shift(days=-1), times=1
)
ApiKey.create(user_id=user.id, name="not used", last_used=None, times=1)
Session.flush()
old_max_api_keys = config.MAX_API_KEYS
config.MAX_API_KEYS = 2
clean_up_unused_or_old_api_keys(user.id)
keys = ApiKey.filter_by(user_id=user.id).all()
assert len(keys) == 2
assert keys[0].name.find("keep") == 0
assert keys[1].name.find("keep") == 0
config.MAX_API_KEYS = old_max_api_keys

View File

@ -0,0 +1,20 @@
from flask import url_for
from app.models import Coupon
from app.utils import random_string
from tests.utils import login
def test_use_coupon(flask_client):
user = login(flask_client)
code = random_string(10)
Coupon.create(code=code, nb_year=1, commit=True)
r = flask_client.post(
url_for("dashboard.coupon_route"),
data={"code": code},
)
assert r.status_code == 302
coupon = Coupon.get_by(code=code)
assert coupon.used
assert coupon.used_by_user_id == user.id

View File

@ -316,6 +316,10 @@ def test_add_alias_in_global_trash(flask_client):
def test_add_alias_in_custom_domain_trash(flask_client): def test_add_alias_in_custom_domain_trash(flask_client):
user = login(flask_client) user = login(flask_client)
for deleted_domain in DomainDeletedAlias.all():
Session.delete(deleted_domain)
Session.flush()
domain = random_domain() domain = random_domain()
custom_domain = CustomDomain.create( custom_domain = CustomDomain.create(
user_id=user.id, domain=domain, ownership_verified=True, commit=True user_id=user.id, domain=domain, ownership_verified=True, commit=True

View File

@ -0,0 +1,21 @@
Sender: somebody@somewhere.net
Content-Type: multipart/mixed; boundary="----=_Part_3946_1099248058.1688752298149"
--0c916c9b5fe3c925d7bafeb988bb6794
Content-Type: text/plain; charset="UTF-8"
Content-Transfer-Encoding: quoted-printable
notification test
--0c916c9b5fe3c925d7bafeb988bb6794
Content-Type: text/html; charset="UTF-8"
Content-Transfer-Encoding: quoted-printable
<html><head><meta http-equiv=3D"Content-Type" content=3D"text/html; charset=
=3DUTF-8"><meta http-equiv=3D"X-UA-Compatible" content=3D"IE=3Dedge"><meta =
name=3D"format-detection" content=3D"telephone=3Dno"><meta name=3D"viewport=
" content=3D"width=3Ddevice-width, initial-scale=3D1.0">
--0c916c9b5fe3c925d7bafeb988bb6794--

View File

@ -0,0 +1,27 @@
From: {{sender_address}}
To: {{recipient_address}}
Subject: Test subject
Content-Type: multipart/alternative; boundary="MLF8fvg556fdhFDH7=_?:
--MLF8fvg556fdhFDH7=_?:
Content-Type: text/plain;
charset="utf-8"
Content-Transfer-Encoding: quoted-printable
*************************************************************************
This five-part limited series, based on the brilliant graphic novel by Me
--MLF8fvg556fdhFDH7=_?:
Content-Type: text/html;
charset="utf-8"
Content-Transfer-Encoding: 8bit
--MLF8fvg556fdhFDH7=_?:
Content-Type: text/plain;
charset="utf-8"
Content-Transfer-Encoding: quoted-printable
*************************************************************************
*************************************************************************

View File

@ -0,0 +1,65 @@
Received: by mail-ed1-f49.google.com with SMTP id ej4so13657316edb.7
for <gmail@simplemail.fplante.fr>; Mon, 27 Jun 2022 08:48:15 -0700 (PDT)
X-Gm-Message-State: AJIora8exR9DGeRFoKAtjzwLtUpH5hqx6Zt3tm8n4gUQQivGQ3fELjUV
yT7RQIfeW9Kv2atuOcgtmGYVU4iQ8VBeLmK1xvOYL4XpXfrT7ZrJNQ==
Authentication-Results: mx.google.com;
dkim=pass header.i=@matera.eu header.s=fnt header.b=XahYMey7;
dkim=pass header.i=@sendgrid.info header.s=smtpapi header.b="QOCS/yjt";
spf=pass (google.com: domain of bounces+14445963-ab4e-csyndic.quartz=gmail.com@front-mail.matera.eu designates 168.245.4.42 as permitted sender) smtp.mailfrom="bounces+14445963-ab4e-csyndic.quartz=gmail.com@front-mail.matera.eu";
dmarc=pass (p=NONE sp=NONE dis=NONE) header.from=matera.eu
Received: from out.frontapp.com (unknown)
by geopod-ismtpd-3-0 (SG)
with ESMTP id d2gM2N7PT7W8d2-UEC4ESA
for <csyndic.quartz@gmail.com>;
Mon, 27 Jun 2022 15:48:11.014 +0000 (UTC)
Content-Type: multipart/alternative;
boundary="----sinikael-?=_1-16563448907660.10629093370416887"
In-Reply-To:
<imported@frontapp.com_81c5208b4cff8b0633f167fda4e6e8e8f63b7a9b>
References:
<imported@frontapp.com_t:AssembléeGénérale2022-06-25T16:32:03+02:006b3cdade-982b-47cd-8114-6a037dfb7d60>
<imported@frontapp.com_f924cce139940c9935621f067d46443597394f34>
<imported@frontapp.com_t:Appeldefonds2022-06-26T10:04:55+02:00d89f5e23-6d98-4f01-95fa-b7c7544b7aa9>
<imported@frontapp.com_81c5208b4cff8b0633f167fda4e6e8e8f63b7a9b>
<af07e94a66ece6564ae30a2aaac7a34c@frontapp.com>
From: {{ sender_address }}
To: {{ recipient_address }}
CC: {{ cc_address }}
Subject: Something
Message-ID: <af07e94a66ece6564ae30a2aaac7a34c@frontapp.com>
X-Mailer: Front (1.0; +https://frontapp.com;
+msgid=af07e94a66ece6564ae30a2aaac7a34c@frontapp.com)
X-Feedback-ID: 14445963:SG
X-SG-EID:
=?us-ascii?Q?XtlxQDg5i3HqMzQY2Upg19JPZBVl1RybInUUL2yta9uBoIU4KU1FMJ5DjWrz6g?=
=?us-ascii?Q?fJUK5Qmneg2uc46gwp5BdHdp6Foaq5gg3xJriv3?=
=?us-ascii?Q?9OA=2FWRifeylU9O+ngdNbOKXoeJAkROmp2mCgw9x?=
=?us-ascii?Q?uud+EclOT9mYVtbZsydOLLm6Y2PPswQl8lnmiku?=
=?us-ascii?Q?DAhkG15HTz2FbWGWNDFb7VrSsN5ddjAscr6sIHw?=
=?us-ascii?Q?S48R5fnXmfhPbmlCgqFjr0FGphfuBdNAt6z6w8a?=
=?us-ascii?Q?o9u1EYDIX7zWHZ+Tr3eyw=3D=3D?=
X-SG-ID:
=?us-ascii?Q?N2C25iY2uzGMFz6rgvQsb8raWjw0ZPf1VmjsCkspi=2FI9PhcvqXQTpKqqyZkvBe?=
=?us-ascii?Q?+2RscnQ4WPkA+BN1vYgz1rezTVIqgp+rlWrKk8o?=
=?us-ascii?Q?HoB5dzpX6HKWtWCVRi10zwlDN1+pJnySoIUrlaT?=
=?us-ascii?Q?PA2aqQKmMQbjTl0CUAFryR8hhHcxdS0cQowZSd7?=
=?us-ascii?Q?XNjJWLvCGF7ODwg=2FKr+4yRE8UvULS2nrdO2wWyQ?=
=?us-ascii?Q?AiFHdPdZsRlgNomEo=3D?=
X-Spamd-Result: default: False [-2.00 / 13.00];
ARC_ALLOW(-1.00)[google.com:s=arc-20160816:i=1];
MIME_GOOD(-0.10)[multipart/alternative,text/plain];
REPLYTO_ADDR_EQ_FROM(0.00)[];
FORGED_RECIPIENTS_FORWARDING(0.00)[];
NEURAL_HAM(-0.00)[-0.981];
FREEMAIL_TO(0.00)[gmail.com];
RCVD_TLS_LAST(0.00)[];
FREEMAIL_ENVFROM(0.00)[gmail.com];
MIME_TRACE(0.00)[0:+,1:+,2:~];
RWL_MAILSPIKE_POSSIBLE(0.00)[209.85.208.49:from]
------sinikael-?=_1-16563448907660.10629093370416887
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: quoted-printable
From {{ sender_address }} To {{ recipient_address }}
------sinikael-?=_1-16563448907660.10629093370416887--

Some files were not shown because too many files have changed in this diff Show More