Compare commits

..

3 commits

Author SHA1 Message Date
Adrià Casajús
0aa7b426f5
Check users aren't using an alias as their link email address for partner links 2023-05-08 18:29:54 +02:00
Adrià Casajús
be70e49596
Update test to handle redirect 2023-05-03 16:11:59 +02:00
Adrià Casajús
e54af47c70
Ensure coupons are only used once 2023-05-03 16:01:21 +02:00
137 changed files with 1443 additions and 3513 deletions

View file

@ -15,15 +15,9 @@ jobs:
- uses: actions/setup-python@v4
with:
python-version: '3.10'
python-version: '3.9'
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
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
run: poetry install --no-interaction

View file

@ -7,19 +7,18 @@ repos:
hooks:
- id: check-yaml
- id: trailing-whitespace
- repo: https://github.com/psf/black
rev: 22.3.0
hooks:
- id: black
- repo: https://github.com/pycqa/flake8
rev: 3.9.2
hooks:
- id: flake8
- repo: https://github.com/Riverside-Healthcare/djLint
rev: v1.3.0
hooks:
- id: djlint-jinja
files: '.*\.html'
entry: djlint --reformat
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.1.5
hooks:
# Run the linter.
- id: ruff
args: [ --fix ]
# Run the formatter.
- id: ruff-format

View file

@ -34,7 +34,7 @@ poetry install
On Mac, sometimes you might need to install some other packages via `brew`:
```bash
brew install pkg-config libffi openssl postgresql@13
brew install pkg-config libffi openssl postgresql
```
You also need to install `gpg` tool, on Mac it can be done with:
@ -169,12 +169,6 @@ For HTML templates, we use `djlint`. Before creating a pull request, please run
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
[swaks](http://www.jetmore.org/john/code/swaks/) is used for sending test emails to the `email_handler`.

View file

@ -23,15 +23,15 @@ COPY poetry.lock pyproject.toml ./
# Install and setup poetry
RUN pip install -U pip \
&& apt-get update \
&& apt install -y curl netcat-traditional gcc python3-dev gnupg git libre2-dev cmake ninja-build\
&& apt install -y curl netcat gcc python3-dev gnupg git libre2-dev \
&& curl -sSL https://install.python-poetry.org | python3 - \
# Remove curl and netcat from the image
&& apt-get purge -y curl netcat-traditional \
&& apt-get purge -y curl netcat \
# Run poetry
&& poetry config virtualenvs.create false \
&& poetry install --no-interaction --no-ansi --no-root \
# Clear apt cache \
&& apt-get purge -y libre2-dev cmake ninja-build\
&& apt-get purge -y libre2-dev \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*

View file

@ -5,15 +5,13 @@ from typing import Optional
from arrow import Arrow
from newrelic import agent
from sqlalchemy import or_
from app.db import Session
from app.email_utils import send_welcome_email
from app.utils import sanitize_email, canonicalize_email
from app.utils import sanitize_email
from app.errors import (
AccountAlreadyLinkedToAnotherPartnerException,
AccountIsUsingAliasAsEmail,
AccountAlreadyLinkedToAnotherUserException,
)
from app.log import LOG
from app.models import (
@ -132,9 +130,8 @@ class ClientMergeStrategy(ABC):
class NewUserStrategy(ClientMergeStrategy):
def process(self) -> LinkResult:
# Will create a new SL User with a random password
canonical_email = canonicalize_email(self.link_request.email)
new_user = User.create(
email=canonical_email,
email=self.link_request.email,
name=self.link_request.name,
password=random_string(20),
activated=True,
@ -168,6 +165,7 @@ class NewUserStrategy(ClientMergeStrategy):
class ExistingUnlinkedUserStrategy(ClientMergeStrategy):
def process(self) -> LinkResult:
partner_user = ensure_partner_user_exists_for_user(
self.link_request, self.user, self.partner
)
@ -181,7 +179,7 @@ class ExistingUnlinkedUserStrategy(ClientMergeStrategy):
class LinkedWithAnotherPartnerUserStrategy(ClientMergeStrategy):
def process(self) -> LinkResult:
raise AccountAlreadyLinkedToAnotherUserException()
raise AccountAlreadyLinkedToAnotherPartnerException()
def get_login_strategy(
@ -214,21 +212,11 @@ def process_login_case(
partner_id=partner.id, external_user_id=link_request.external_user_id
)
if partner_user is None:
canonical_email = canonicalize_email(link_request.email)
# 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)
check_alias(canonical_email)
# Try to find it using the partner's e-mail address
users = User.filter(
or_(User.email == link_request.email, User.email == canonical_email)
).all()
if len(users) > 1:
user = [user for user in users if user.email == canonical_email][0]
elif len(users) == 1:
user = users[0]
else:
user = None
user = User.get_by(email=link_request.email)
return get_login_strategy(link_request, user, partner).process()
else:
# We found the SL user registered with that partner user id

View file

@ -256,17 +256,6 @@ class UserAdmin(SLModelView):
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(
# "login_as",
# "Login as this user",
@ -611,26 +600,6 @@ class NewsletterAdmin(SLModelView):
else:
flash(error_msg, "error")
@action(
"clone_newsletter",
"Clone this newsletter",
)
def clone_newsletter(self, newsletter_ids):
if len(newsletter_ids) != 1:
flash("you can only select 1 newsletter", "error")
return
newsletter_id = newsletter_ids[0]
newsletter: Newsletter = Newsletter.get(newsletter_id)
new_newsletter = Newsletter.create(
subject=newsletter.subject,
html=newsletter.html,
plain_text=newsletter.plain_text,
commit=True,
)
flash(f"Newsletter {new_newsletter.subject} has been cloned", "success")
class NewsletterUserAdmin(SLModelView):
column_searchable_list = ["id"]

View file

@ -6,7 +6,7 @@ from typing import Optional
import itsdangerous
from app import config
from app.log import LOG
from app.models import User, AliasOptions, SLDomain
from app.models import User, AliasOptions
signer = itsdangerous.TimestampSigner(config.CUSTOM_ALIAS_SECRET)
@ -70,6 +70,7 @@ def verify_prefix_suffix(
# when DISABLE_ALIAS_SUFFIX is true, alias_domain_prefix is empty
and not config.DISABLE_ALIAS_SUFFIX
):
if not alias_domain_prefix.startswith("."):
LOG.e("User %s submits a wrong alias suffix %s", user, alias_suffix)
return False
@ -104,7 +105,10 @@ def get_alias_suffixes(
for custom_domain in user_custom_domains:
if custom_domain.random_prefix_generation:
suffix = (
f".{user.get_random_alias_suffix(custom_domain)}@{custom_domain.domain}"
"."
+ user.get_random_alias_suffix(custom_domain)
+ "@"
+ custom_domain.domain
)
alias_suffix = AliasSuffix(
is_custom=True,
@ -119,7 +123,7 @@ def get_alias_suffixes(
else:
alias_suffixes.append(alias_suffix)
suffix = f"@{custom_domain.domain}"
suffix = "@" + custom_domain.domain
alias_suffix = AliasSuffix(
is_custom=True,
suffix=suffix,
@ -140,13 +144,16 @@ def get_alias_suffixes(
alias_suffixes.append(alias_suffix)
# then SimpleLogin domain
sl_domains = user.get_sl_domains(alias_options=alias_options)
default_domain_found = False
for sl_domain in sl_domains:
prefix = (
"" if config.DISABLE_ALIAS_SUFFIX else f".{user.get_random_alias_suffix()}"
for sl_domain in user.get_sl_domains(alias_options=alias_options):
suffix = (
(
""
if config.DISABLE_ALIAS_SUFFIX
else "." + user.get_random_alias_suffix()
)
+ "@"
+ sl_domain.domain
)
suffix = f"{prefix}@{sl_domain.domain}"
alias_suffix = AliasSuffix(
is_custom=False,
suffix=suffix,
@ -155,36 +162,11 @@ def get_alias_suffixes(
domain=sl_domain.domain,
mx_verified=True,
)
# No default or this is not the default
if (
user.default_alias_public_domain_id is None
or user.default_alias_public_domain_id != sl_domain.id
):
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,
)
# put the default domain to top
if user.default_alias_public_domain_id == sl_domain.id:
alias_suffixes.insert(0, alias_suffix)
else:
alias_suffixes.append(alias_suffix)
return alias_suffixes

View file

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

@ -16,22 +16,3 @@ from .views import (
sudo,
user,
)
__all__ = [
"alias_options",
"new_custom_alias",
"custom_domain",
"new_random_alias",
"user_info",
"auth",
"auth_mfa",
"alias",
"apple",
"mailbox",
"notification",
"setting",
"export",
"phone",
"sudo",
"user",
]

View file

@ -24,7 +24,6 @@ from app.errors import (
ErrContactAlreadyExists,
ErrAddressInvalid,
)
from app.extensions import limiter
from app.models import Alias, Contact, Mailbox, AliasMailbox
@ -72,9 +71,6 @@ def get_aliases():
@api_bp.route("/v2/aliases", methods=["GET", "POST"])
@limiter.limit(
"5/minute",
)
@require_api_auth
def get_aliases_v2():
"""

View file

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

View file

@ -63,11 +63,6 @@ def auth_login():
elif user.disabled:
LoginEvent(LoginEvent.ActionType.disabled_login, LoginEvent.Source.api).send()
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:
LoginEvent(LoginEvent.ActionType.not_activated, LoginEvent.Source.api).send()
return jsonify(error="Account not activated"), 422

View file

@ -13,8 +13,8 @@ from app.db import Session
from app.email_utils import (
mailbox_already_used,
email_can_be_used_as_mailbox,
is_valid_email,
)
from app.email_validation import is_valid_email
from app.log import LOG
from app.models import Mailbox, Job
from app.utils import sanitize_email
@ -45,7 +45,7 @@ def create_mailbox():
mailbox_email = sanitize_email(request.get_json().get("email"))
if not user.is_premium():
return jsonify(error="Only premium plan can add additional mailbox"), 400
return jsonify(error=f"Only premium plan can add additional mailbox"), 400
if not is_valid_email(mailbox_email):
return jsonify(error=f"{mailbox_email} invalid"), 400

View file

@ -150,7 +150,7 @@ def new_custom_alias_v3():
if not data:
return jsonify(error="request body cannot be empty"), 400
if not isinstance(data, dict):
if type(data) is not dict:
return jsonify(error="request body does not follow the required format"), 400
alias_prefix = data.get("alias_prefix", "").strip().lower().replace(" ", "")
@ -168,7 +168,7 @@ def new_custom_alias_v3():
return jsonify(error="alias prefix invalid format or too long"), 400
# check if mailbox is not tempered with
if not isinstance(mailbox_ids, list):
if type(mailbox_ids) is not list:
return jsonify(error="mailbox_ids must be an array of id"), 400
mailboxes = []
for mailbox_id in mailbox_ids:

View file

@ -1,5 +1,4 @@
import base64
import dataclasses
from io import BytesIO
from typing import Optional
@ -8,7 +7,6 @@ from flask import jsonify, g, request, make_response
from app import s3, config
from app.api.base import api_bp, require_api_auth
from app.config import SESSION_COOKIE_NAME
from app.dashboard.views.index import get_stats
from app.db import Session
from app.models import ApiKey, File, PartnerUser, User
from app.proton.utils import get_proton_partner
@ -138,22 +136,3 @@ def logout():
response.delete_cookie(SESSION_COOKIE_NAME)
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

@ -17,23 +17,3 @@ from .views import (
recovery,
api_to_cookie,
)
__all__ = [
"login",
"logout",
"register",
"activate",
"resend_activation",
"reset_password",
"forgot_password",
"github",
"google",
"facebook",
"proton",
"change_email",
"mfa",
"fido",
"social",
"recovery",
"api_to_cookie",
]

View file

@ -62,7 +62,7 @@ def fido():
browser = MfaBrowser.get_by(token=request.cookies.get("mfa"))
if browser and not browser.is_expired() and browser.user_id == user.id:
login_user(user)
flash("Welcome back!", "success")
flash(f"Welcome back!", "success")
# Redirect user to correct page
return redirect(next_url or url_for("dashboard.index"))
else:
@ -110,7 +110,7 @@ def fido():
session["sudo_time"] = int(time())
login_user(user)
flash("Welcome back!", "success")
flash(f"Welcome back!", "success")
# Redirect user to correct page
response = make_response(redirect(next_url or url_for("dashboard.index")))

View file

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

View file

@ -54,12 +54,6 @@ def login():
"error",
)
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:
show_resend_activation = True
flash(

View file

@ -55,7 +55,7 @@ def mfa():
browser = MfaBrowser.get_by(token=request.cookies.get("mfa"))
if browser and not browser.is_expired() and browser.user_id == user.id:
login_user(user)
flash("Welcome back!", "success")
flash(f"Welcome back!", "success")
# Redirect user to correct page
return redirect(next_url or url_for("dashboard.index"))
else:
@ -73,7 +73,7 @@ def mfa():
Session.commit()
login_user(user)
flash("Welcome back!", "success")
flash(f"Welcome back!", "success")
# Redirect user to correct page
response = make_response(redirect(next_url or url_for("dashboard.index")))

View file

@ -53,7 +53,7 @@ def recovery_route():
del session[MFA_USER_ID]
login_user(user)
flash("Welcome back!", "success")
flash(f"Welcome back!", "success")
recovery_code.used = True
recovery_code.used_at = arrow.now()

View file

@ -94,7 +94,9 @@ def register():
try:
send_activation_email(user, next_url)
RegisterEvent(RegisterEvent.ActionType.success).send()
DailyMetric.get_or_create_today_metric().nb_new_web_non_proton_user += 1
DailyMetric.get_or_create_today_metric().nb_new_web_non_proton_user += (
1
)
Session.commit()
except Exception:
flash("Invalid email, are you sure the email is correct?", "error")

View file

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

View file

@ -532,10 +532,3 @@ if ENABLE_ALL_REVERSE_ALIAS_REPLACEMENT:
SKIP_MX_LOOKUP_ON_CHECK = False
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

@ -33,39 +33,3 @@ from .views import (
notification,
support,
)
__all__ = [
"index",
"pricing",
"setting",
"custom_alias",
"subdomain",
"billing",
"alias_log",
"alias_export",
"unsubscribe",
"api_key",
"custom_domain",
"alias_contact_manager",
"enter_sudo",
"mfa_setup",
"mfa_cancel",
"fido_setup",
"coupon",
"fido_manage",
"domain_detail",
"lifetime_licence",
"directory",
"mailbox",
"mailbox_detail",
"refused_email",
"referral",
"contact_detail",
"setup_done",
"batch_import",
"alias_transfer",
"app",
"delete_account",
"notification",
"support",
]

View file

@ -13,10 +13,10 @@ from app import config, parallel_limiter
from app.dashboard.base import dashboard_bp
from app.db import Session
from app.email_utils import (
is_valid_email,
generate_reply_email,
parse_full_address,
)
from app.email_validation import is_valid_email
from app.errors import (
CannotCreateContactForReverseAlias,
ErrContactErrorUpgradeNeeded,

View file

@ -87,6 +87,6 @@ def get_alias_log(alias: Alias, page_id=0) -> [AliasLog]:
contact=contact,
)
logs.append(al)
logs = sorted(logs, key=lambda log: log.when, reverse=True)
logs = sorted(logs, key=lambda l: l.when, reverse=True)
return logs

View file

@ -7,19 +7,79 @@ from flask import render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user
from app import config
from app.alias_utils import transfer_alias
from app.dashboard.base import dashboard_bp
from app.dashboard.views.enter_sudo import sudo_required
from app.db import Session
from app.email_utils import send_email, render
from app.extensions import limiter
from app.log import LOG
from app.models import (
Alias,
Contact,
AliasUsedOn,
AliasMailbox,
User,
ClientUser,
)
from app.models import Mailbox
from app.utils import CSRFValidationForm
def transfer(alias, new_user, new_mailboxes: [Mailbox]):
# cannot transfer alias which is used for receiving newsletter
if User.get_by(newsletter_alias_id=alias.id):
raise Exception("Cannot transfer alias that's used to receive newsletter")
# update user_id
Session.query(Contact).filter(Contact.alias_id == alias.id).update(
{"user_id": new_user.id}
)
Session.query(AliasUsedOn).filter(AliasUsedOn.alias_id == alias.id).update(
{"user_id": new_user.id}
)
Session.query(ClientUser).filter(ClientUser.alias_id == alias.id).update(
{"user_id": new_user.id}
)
# remove existing mailboxes from the alias
Session.query(AliasMailbox).filter(AliasMailbox.alias_id == alias.id).delete()
# set mailboxes
alias.mailbox_id = new_mailboxes.pop().id
for mb in new_mailboxes:
AliasMailbox.create(alias_id=alias.id, mailbox_id=mb.id)
# alias has never been transferred before
if not alias.original_owner_id:
alias.original_owner_id = alias.user_id
# inform previous owner
old_user = alias.user
send_email(
old_user.email,
f"Alias {alias.email} has been received",
render(
"transactional/alias-transferred.txt",
alias=alias,
),
render(
"transactional/alias-transferred.html",
alias=alias,
),
)
# now the alias belongs to the new user
alias.user_id = new_user.id
# set some fields back to default
alias.disable_pgp = False
alias.pinned = False
Session.commit()
def hmac_alias_transfer_token(transfer_token: str) -> str:
alias_hmac = hmac.new(
config.ALIAS_TRANSFER_TOKEN_SECRET.encode("utf-8"),
@ -154,7 +214,7 @@ def alias_transfer_receive_route():
mailboxes,
token,
)
transfer_alias(alias, current_user, mailboxes)
transfer(alias, current_user, mailboxes)
# reset transfer token
alias.transfer_token = None

View file

@ -3,11 +3,9 @@ from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import StringField, validators
from app import config
from app.dashboard.base import dashboard_bp
from app.dashboard.views.enter_sudo import sudo_required
from app.db import Session
from app.extensions import limiter
from app.models import ApiKey
from app.utils import CSRFValidationForm
@ -16,34 +14,9 @@ class NewApiKeyForm(FlaskForm):
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"])
@login_required
@sudo_required
@limiter.limit("10/hour")
def api_key():
api_keys = (
ApiKey.filter(ApiKey.user_id == current_user.id)
@ -77,7 +50,6 @@ def api_key():
elif request.form.get("form-name") == "create":
if new_api_key_form.validate():
clean_up_unused_or_old_api_keys(current_user.id)
new_api_key = ApiKey.create(
name=new_api_key_form.name.data, user_id=current_user.id
)

View file

@ -1,9 +1,14 @@
from app.db import Session
"""
List of apps that user has used via the "Sign in with SimpleLogin"
"""
from flask import render_template, request, flash, redirect
from flask_login import login_required, current_user
from sqlalchemy.orm import joinedload
from app.dashboard.base import dashboard_bp
from app.db import Session
from app.models import (
ClientUser,
)
@ -12,10 +17,6 @@ from app.models import (
@dashboard_bp.route("/app", methods=["GET", "POST"])
@login_required
def app_route():
"""
List of apps that user has used via the "Sign in with SimpleLogin"
"""
client_users = (
ClientUser.filter_by(user_id=current_user.id)
.options(joinedload(ClientUser.client))

View file

@ -100,7 +100,7 @@ def coupon_route():
commit=True,
)
flash(
"Your account has been upgraded to Premium, thanks for your support!",
f"Your account has been upgraded to Premium, thanks for your support!",
"success",
)

View file

@ -67,7 +67,7 @@ def directory():
if request.method == "POST":
if request.form.get("form-name") == "delete":
if not delete_dir_form.validate():
flash("Invalid request", "warning")
flash(f"Invalid request", "warning")
return redirect(url_for("dashboard.directory"))
dir_obj = Directory.get(delete_dir_form.directory_id.data)
@ -87,7 +87,7 @@ def directory():
if request.form.get("form-name") == "toggle-directory":
if not toggle_dir_form.validate():
flash("Invalid request", "warning")
flash(f"Invalid request", "warning")
return redirect(url_for("dashboard.directory"))
dir_id = toggle_dir_form.directory_id.data
dir_obj = Directory.get(dir_id)
@ -109,7 +109,7 @@ def directory():
elif request.form.get("form-name") == "update":
if not update_dir_form.validate():
flash("Invalid request", "warning")
flash(f"Invalid request", "warning")
return redirect(url_for("dashboard.directory"))
dir_id = update_dir_form.directory_id.data
dir_obj = Directory.get(dir_id)

View file

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

View file

@ -57,10 +57,6 @@ def get_stats(user: User) -> Stats:
methods=["POST"],
exempt_when=lambda: request.form.get("form-name") != "create-random-email",
)
@limiter.limit(
"5/minute",
methods=["GET"],
)
@login_required
@parallel_limiter.lock(
name="alias_creation",

View file

@ -1,7 +1,3 @@
import base64
import binascii
import json
import arrow
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
@ -19,8 +15,8 @@ from app.email_utils import (
mailbox_already_used,
render,
send_email,
is_valid_email,
)
from app.email_validation import is_valid_email
from app.log import LOG
from app.models import Mailbox, Job
from app.utils import CSRFValidationForm
@ -184,9 +180,7 @@ def mailbox_route():
def send_verification_email(user, mailbox):
s = TimestampSigner(MAILBOX_SECRET)
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()
mailbox_id_signed = s.sign(str(mailbox.id)).decode()
verification_url = (
URL + "/dashboard/mailbox_verify" + f"?mailbox_id={mailbox_id_signed}"
)
@ -211,34 +205,22 @@ def send_verification_email(user, mailbox):
@dashboard_bp.route("/mailbox_verify")
def mailbox_verify():
s = TimestampSigner(MAILBOX_SECRET)
mailbox_verify_request = request.args.get("mailbox_id")
mailbox_id = request.args.get("mailbox_id")
try:
mailbox_raw_data = s.unsign(mailbox_verify_request, max_age=900)
r_id = int(s.unsign(mailbox_id, max_age=900))
except Exception:
flash("Invalid link. Please delete and re-add your mailbox", "error")
return redirect(url_for("dashboard.mailbox_route"))
try:
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:
flash("Invalid link", "error")
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"))
else:
mailbox = Mailbox.get(r_id)
if not mailbox:
flash("Invalid link", "error")
return redirect(url_for("dashboard.mailbox_route"))
mailbox.verified = True
Session.commit()
mailbox.verified = True
Session.commit()
LOG.d("Mailbox %s is verified", mailbox)
LOG.d("Mailbox %s is verified", mailbox)
return render_template("dashboard/mailbox_validation.html", mailbox=mailbox)
return render_template("dashboard/mailbox_validation.html", mailbox=mailbox)

View file

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

View file

@ -128,6 +128,7 @@ def setting():
new_email_valid = True
new_email = canonicalize_email(change_email_form.email.data)
if new_email != current_user.email and not pending_email:
# check if this email is not already used
if personal_email_already_used(new_email) or Alias.get_by(
email=new_email
@ -197,16 +198,6 @@ def 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 = File.create(user_id=current_user.id, path=file_path)
@ -460,13 +451,8 @@ def send_change_email_confirmation(user: User, email_change: EmailChange):
@dashboard_bp.route("/resend_email_change", methods=["GET", "POST"])
@limiter.limit("5/hour")
@login_required
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)
if email_change:
# extend email change expiration
@ -486,10 +472,6 @@ def resend_email_change():
@dashboard_bp.route("/cancel_email_change", methods=["GET", "POST"])
@login_required
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)
if email_change:
EmailChange.delete(email_change.id)

View file

@ -75,11 +75,12 @@ def block_contact(contact_id):
@dashboard_bp.route("/unsubscribe/encoded/<encoded_request>", methods=["GET"])
@login_required
def encoded_unsubscribe(encoded_request: str):
unsub_data = UnsubscribeHandler().handle_unsubscribe_from_request(
current_user, encoded_request
)
if not unsub_data:
flash("Invalid unsubscribe request", "error")
flash(f"Invalid unsubscribe request", "error")
return redirect(url_for("dashboard.index"))
if unsub_data.action == UnsubscribeAction.DisableAlias:
alias = Alias.get(unsub_data.data)
@ -96,14 +97,14 @@ def encoded_unsubscribe(encoded_request: str):
)
)
if unsub_data.action == UnsubscribeAction.UnsubscribeNewsletter:
flash("You've unsubscribed from the newsletter", "success")
flash(f"You've unsubscribed from the newsletter", "success")
return redirect(
url_for(
"dashboard.index",
)
)
if unsub_data.action == UnsubscribeAction.OriginalUnsubscribeMailto:
flash("The original unsubscribe request has been forwarded", "success")
flash(f"The original unsubscribe request has been forwarded", "success")
return redirect(
url_for(
"dashboard.index",

View file

@ -1,3 +1 @@
from .views import index, new_client, client_detail
__all__ = ["index", "new_client", "client_detail"]

View file

@ -87,7 +87,7 @@ def client_detail(client_id):
)
flash(
"Thanks for submitting, we are informed and will come back to you asap!",
f"Thanks for submitting, we are informed and will come back to you asap!",
"success",
)

View file

@ -1,3 +1 @@
from .views import index
__all__ = ["index"]

View file

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

View file

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

View file

@ -93,7 +93,7 @@ def send_welcome_email(user):
send_email(
comm_email,
"Welcome to SimpleLogin",
f"Welcome to SimpleLogin",
render("com/welcome.txt", user=user, alias=alias),
render("com/welcome.html", user=user, alias=alias),
unsubscribe_link,
@ -104,7 +104,7 @@ def send_welcome_email(user):
def send_trial_end_soon_email(user):
send_email(
user.email,
"Your trial will end soon",
f"Your trial will end soon",
render("transactional/trial-end.txt.jinja2", user=user),
render("transactional/trial-end.html", user=user),
ignore_smtp_error=True,
@ -114,7 +114,7 @@ def send_trial_end_soon_email(user):
def send_activation_email(email, activation_link):
send_email(
email,
"Just one more step to join SimpleLogin",
f"Just one more step to join SimpleLogin",
render(
"transactional/activation.txt",
activation_link=activation_link,
@ -768,7 +768,7 @@ def get_header_unicode(header: Union[str, Header]) -> str:
ret = ""
for to_decoded_str, charset in decode_header(header):
if charset is None:
if isinstance(to_decoded_str, bytes):
if type(to_decoded_str) is bytes:
decoded_str = to_decoded_str.decode()
else:
decoded_str = to_decoded_str
@ -805,13 +805,13 @@ def to_bytes(msg: Message):
for generator_policy in [None, policy.SMTP, policy.SMTPUTF8]:
try:
return msg.as_bytes(policy=generator_policy)
except Exception:
except:
LOG.w("as_bytes() fails with %s policy", policy, exc_info=True)
msg_string = msg.as_string()
try:
return msg_string.encode()
except Exception:
except:
LOG.w("as_string().encode() fails", exc_info=True)
return msg_string.encode(errors="replace")
@ -828,6 +828,19 @@ def should_add_dkim_signature(domain: str) -> bool:
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):
BASE64 = "base64"
QUOTED = "quoted-printable"
@ -906,7 +919,7 @@ def add_header(msg: Message, text_header, html_header=None) -> Message:
if content_type == "text/plain":
encoding = get_encoding(msg)
payload = msg.get_payload()
if isinstance(payload, str):
if type(payload) is str:
clone_msg = copy(msg)
new_payload = f"""{text_header}
------------------------------
@ -916,7 +929,7 @@ def add_header(msg: Message, text_header, html_header=None) -> Message:
elif content_type == "text/html":
encoding = get_encoding(msg)
payload = msg.get_payload()
if isinstance(payload, str):
if type(payload) is str:
new_payload = f"""<table width="100%" style="width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0;
-premailer-cellspacing: 0; margin: 0; padding: 0;">
<tr>
@ -938,8 +951,6 @@ def add_header(msg: Message, text_header, html_header=None) -> Message:
for part in msg.get_payload():
if isinstance(part, Message):
new_parts.append(add_header(part, text_header, html_header))
elif isinstance(part, str):
new_parts.append(MIMEText(part))
else:
new_parts.append(part)
clone_msg = copy(msg)
@ -948,14 +959,7 @@ def add_header(msg: Message, text_header, html_header=None) -> Message:
elif content_type in ("multipart/mixed", "multipart/signed"):
new_parts = []
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)
parts = list(msg.get_payload())
LOG.d("only add header for the first part for %s", content_type)
for ix, part in enumerate(parts):
if ix == 0:
@ -972,7 +976,7 @@ def add_header(msg: Message, text_header, html_header=None) -> Message:
def replace(msg: Union[Message, str], old, new) -> Union[Message, str]:
if isinstance(msg, str):
if type(msg) is str:
msg = msg.replace(old, new)
return msg
@ -995,7 +999,7 @@ def replace(msg: Union[Message, str], old, new) -> Union[Message, str]:
if content_type in ("text/plain", "text/html"):
encoding = get_encoding(msg)
payload = msg.get_payload()
if isinstance(payload, str):
if type(payload) is str:
if encoding == EmailEncoding.QUOTED:
LOG.d("handle quoted-printable replace %s -> %s", old, new)
# first decode the payload
@ -1103,6 +1107,26 @@ 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):
"""
Return whether an alias should be disabled and if yes, the reason why

View file

@ -1,38 +0,0 @@
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,14 +84,6 @@ class ErrAddressInvalid(SLException):
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):
"""raised when a contact already exists"""
@ -121,10 +113,3 @@ class AccountAlreadyLinkedToAnotherUserException(LinkException):
class AccountIsUsingAliasAsEmail(LinkException):
def __init__(self):
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,7 +9,6 @@ class LoginEvent:
failed = 1
disabled_login = 2
not_activated = 3
scheduled_to_be_deleted = 4
class Source(EnumE):
web = 0

View file

@ -34,10 +34,10 @@ def apply_dmarc_policy_for_forward_phase(
from_header = get_header_unicode(msg[headers.FROM])
warning_plain_text = """This email failed anti-phishing checks when it was received by SimpleLogin, be careful with its content.
warning_plain_text = f"""This email failed anti-phishing checks when it was received by SimpleLogin, be careful with its content.
More info on https://simplelogin.io/docs/getting-started/anti-phishing/
"""
warning_html = """
warning_html = f"""
<p style="color:red">
This email failed anti-phishing checks when it was received by SimpleLogin, be careful with its content.
More info on <a href="https://simplelogin.io/docs/getting-started/anti-phishing/">anti-phishing measure</a>

View file

@ -221,7 +221,7 @@ def handle_complaint(message: Message, origin: ProviderComplaintOrigin) -> bool:
return True
if is_deleted_alias(msg_info.sender_address):
LOG.i("Complaint is for deleted alias. Do nothing")
LOG.i(f"Complaint is for deleted alias. Do nothing")
return True
contact = Contact.get_by(reply_email=msg_info.sender_address)
@ -231,7 +231,7 @@ def handle_complaint(message: Message, origin: ProviderComplaintOrigin) -> bool:
alias = find_alias_with_address(msg_info.rcpt_address)
if is_deleted_alias(msg_info.rcpt_address):
LOG.i("Complaint is for deleted alias. Do nothing")
LOG.i(f"Complaint is for deleted alias. Do nothing")
return True
if not alias:

View file

@ -54,8 +54,9 @@ class UnsubscribeEncoder:
def encode_subject(
cls, action: UnsubscribeAction, data: Union[int, UnsubscribeOriginalData]
) -> str:
if action != UnsubscribeAction.OriginalUnsubscribeMailto and not isinstance(
data, int
if (
action != UnsubscribeAction.OriginalUnsubscribeMailto
and type(data) is not int
):
raise ValueError(f"Data has to be an int for an action of type {action}")
if action == UnsubscribeAction.OriginalUnsubscribeMailto:
@ -73,8 +74,8 @@ class UnsubscribeEncoder:
)
signed_data = cls._get_signer().sign(serialized_data).decode("utf-8")
encoded_request = f"{UNSUB_PREFIX}.{signed_data}"
if len(encoded_request) > 512:
LOG.w("Encoded request is longer than 512 chars")
if len(encoded_request) > 256:
LOG.e("Encoded request is longer than 256 chars")
return encoded_request
@staticmethod

View file

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

View file

@ -30,7 +30,7 @@ def handle_batch_import(batch_import: BatchImport):
LOG.d("Download file %s from %s", batch_import.file, file_url)
r = requests.get(file_url)
lines = [line.decode("utf-8") for line in r.iter_lines()]
lines = [line.decode() for line in r.iter_lines()]
import_from_csv(batch_import, user, lines)

View file

@ -1,4 +1,2 @@
from .integrations import set_enable_proton_cookie
from .exit_sudo import exit_sudo_mode
__all__ = ["set_enable_proton_cookie", "exit_sudo_mode"]

View file

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

View file

@ -22,6 +22,7 @@ from app.message_utils import message_to_bytes, message_format_base64_parts
@dataclass
class SendRequest:
SAVE_EXTENSION = "sendrequest"
envelope_from: str
@ -31,7 +32,6 @@ class SendRequest:
rcpt_options: Dict = {}
is_forward: bool = False
ignore_smtp_errors: bool = False
retries: int = 0
def to_bytes(self) -> bytes:
if not config.SAVE_UNSENT_DIR:
@ -45,7 +45,6 @@ class SendRequest:
"mail_options": self.mail_options,
"rcpt_options": self.rcpt_options,
"is_forward": self.is_forward,
"retries": self.retries,
}
return json.dumps(data).encode("utf-8")
@ -66,33 +65,8 @@ class SendRequest:
mail_options=decoded_data["mail_options"],
rcpt_options=decoded_data["rcpt_options"],
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:
def __init__(self):
@ -197,9 +171,21 @@ class MailSender:
f"Could not send message to smtp server {config.POSTFIX_SERVER}:{config.POSTFIX_PORT}"
)
if config.SAVE_UNSENT_DIR:
send_request.save_request_to_unsent_dir()
self._save_request_to_unsent_dir(send_request)
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()
@ -233,7 +219,6 @@ def load_unsent_mails_from_fs_and_resend():
LOG.i(f"Trying to re-deliver email {filename}")
try:
send_request = SendRequest.load_from_file(full_file_path)
send_request.retries += 1
except Exception as e:
LOG.e(f"Cannot load {filename}. Error {e}")
continue
@ -245,11 +230,6 @@ def load_unsent_mails_from_fs_and_resend():
"DeliverUnsentEmail", {"delivered": "true"}
)
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(
"DeliverUnsentEmail", {"delivered": "false"}
)

View file

@ -30,8 +30,6 @@ from sqlalchemy_utils import ArrowType
from app import config
from app import s3
from app.db import Session
from app.dns_utils import get_mx_domains
from app.errors import (
AliasInTrashError,
DirectoryInTrashError,
@ -280,7 +278,6 @@ class IntEnumType(sa.types.TypeDecorator):
class AliasOptions:
show_sl_domains: bool = True
show_partner_domains: Optional[Partner] = None
show_partner_premium: Optional[bool] = None
class Hibp(Base, ModelMixin):
@ -344,7 +341,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
sa.Boolean, default=True, nullable=False, server_default="1"
)
activated = sa.Column(sa.Boolean, default=False, nullable=False, index=True)
activated = sa.Column(sa.Boolean, default=False, nullable=False)
# an account can be disabled if having harmful behavior
disabled = sa.Column(sa.Boolean, default=False, nullable=False, server_default="0")
@ -414,10 +411,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
)
referral_id = sa.Column(
sa.ForeignKey("referral.id", ondelete="SET NULL"),
nullable=True,
default=None,
index=True,
sa.ForeignKey("referral.id", ondelete="SET NULL"), nullable=True, default=None
)
referral = orm.relationship("Referral", foreign_keys=[referral_id])
@ -451,7 +445,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
random_alias_suffix = sa.Column(
sa.Integer,
nullable=False,
default=AliasSuffixEnum.word.value,
default=AliasSuffixEnum.random_string.value,
server_default=str(AliasSuffixEnum.random_string.value),
)
@ -520,8 +514,9 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
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(
sa.Boolean, default=True, nullable=False, server_default="1"
sa.Boolean, default=False, nullable=False, server_default="1"
)
# bitwise flags. Allow for future expansion
@ -540,16 +535,6 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
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
def directory_quota(self):
return min(
@ -584,7 +569,6 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
@classmethod
def create(cls, email, name="", password=None, from_partner=False, **kwargs):
email = sanitize_email(email)
user: User = super(User, cls).create(email=email, name=name[:100], **kwargs)
if password:
@ -596,6 +580,19 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
Session.flush()
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
if "alternative_id" not in kwargs:
user.alternative_id = str(uuid.uuid4())
@ -614,19 +611,6 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
Session.flush()
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:
LOG.d("Disable onboarding emails")
return user
@ -652,7 +636,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
return user
def get_active_subscription(
self, include_partner_subscription: bool = True
self,
) -> Optional[
Union[
Subscription
@ -680,40 +664,19 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
if coinbase_subscription and coinbase_subscription.is_active():
return coinbase_subscription
if include_partner_subscription:
partner_sub: PartnerSubscription = PartnerSubscription.find_by_user_id(
self.id
)
if partner_sub and partner_sub.is_active():
return partner_sub
partner_sub: PartnerSubscription = PartnerSubscription.find_by_user_id(self.id)
if partner_sub and partner_sub.is_active():
return partner_sub
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
def lifetime_or_active_subscription(
self, include_partner_subscription: bool = True
) -> bool:
def lifetime_or_active_subscription(self) -> bool:
"""True if user has lifetime licence or active subscription"""
if self.lifetime:
return True
return self.get_active_subscription(include_partner_subscription) is not None
return self.get_active_subscription() is not None
def is_paid(self) -> bool:
"""same as _lifetime_or_active_subscription but not include free manual subscription"""
@ -742,14 +705,14 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
return True
def is_premium(self, include_partner_subscription: bool = True) -> bool:
def is_premium(self) -> bool:
"""
user is premium if they:
- have a lifetime deal or
- in trial period or
- active subscription
"""
if self.lifetime_or_active_subscription(include_partner_subscription):
if self.lifetime_or_active_subscription():
return True
if self.trial_end and arrow.now() < self.trial_end:
@ -838,17 +801,6 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
< 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):
if self.profile_picture_id:
return self.profile_picture.get_url()
@ -1039,35 +991,25 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
) -> list["SLDomain"]:
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())
conditions = [SLDomain.hidden == False] # noqa: E712
if not self.is_premium():
conditions.append(SLDomain.premium_only == False) # noqa: E712
partner_domain_cond = [] # noqa:E711
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())
partner_domain_cond.append(
SLDomain.partner_id == partner_user.partner_id
)
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)
partner_domain_cond.append(SLDomain.partner_id == None) # noqa:E711
if len(partner_domain_cond) == 1:
conditions.append(partner_domain_cond[0])
else:
conditions.append(or_(*partner_domain_cond))
query = Session.query(SLDomain).filter(*conditions).order_by(SLDomain.order)
return query.all()
def available_alias_domains(
@ -1479,7 +1421,7 @@ class Alias(Base, ModelMixin):
)
# have I been pwned
hibp_last_check = sa.Column(ArrowType, default=None, index=True)
hibp_last_check = sa.Column(ArrowType, default=None)
hibp_breaches = orm.relationship("Hibp", secondary="alias_hibp")
# to use Postgres full text search. Only applied on "note" column for now
@ -1947,7 +1889,6 @@ class Contact(Base, ModelMixin):
class EmailLog(Base, ModelMixin):
__tablename__ = "email_log"
__table_args__ = (Index("ix_email_log_created_at", "created_at"),)
user_id = sa.Column(
sa.ForeignKey(User.id, ondelete="cascade"), nullable=False, index=True
@ -2326,7 +2267,6 @@ class CustomDomain(Base, ModelMixin):
@classmethod
def create(cls, **kwargs):
domain = kwargs.get("domain")
kwargs["domain"] = domain.replace("\n", "")
if DeletedSubdomain.get_by(domain=domain):
raise SubdomainInTrashError
@ -2594,28 +2534,6 @@ class Mailbox(Base, ModelMixin):
+ 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
def delete(cls, obj_id):
mailbox: Mailbox = cls.get(obj_id)
@ -2648,12 +2566,6 @@ class Mailbox(Base, ModelMixin):
return ret
@classmethod
def create(cls, **kw):
if "email" in kw:
kw["email"] = sanitize_email(kw["email"])
return super().create(**kw)
def __repr__(self):
return f"<Mailbox {self.id} {self.email}>"
@ -2992,8 +2904,6 @@ class Monitoring(Base, ModelMixin):
active_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):
__tablename__ = "batch_import"
@ -3119,8 +3029,6 @@ class Bounce(Base, ModelMixin):
email = sa.Column(sa.String(256), nullable=False, index=True)
info = sa.Column(sa.Text, nullable=True)
__table_args__ = (sa.Index("ix_bounce_created_at", "created_at"),)
class TransactionalEmail(Base, ModelMixin):
"""Storing all email addresses that receive transactional emails, including account email and mailboxes.
@ -3130,8 +3038,6 @@ class TransactionalEmail(Base, ModelMixin):
__tablename__ = "transactional_email"
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):
"""Referral payouts"""
@ -3184,7 +3090,7 @@ class MessageIDMatching(Base, ModelMixin):
# to track what email_log that has created this matching
email_log_id = sa.Column(
sa.ForeignKey("email_log.id", ondelete="cascade"), nullable=True, index=True
sa.ForeignKey("email_log.id", ondelete="cascade"), nullable=True
)
email_log = orm.relationship("EmailLog")
@ -3517,7 +3423,7 @@ class PartnerSubscription(Base, ModelMixin):
class Newsletter(Base, ModelMixin):
__tablename__ = "newsletter"
subject = sa.Column(sa.String(), nullable=False, index=True)
subject = sa.Column(sa.String(), nullable=False, unique=True, index=True)
html = sa.Column(sa.Text)
plain_text = sa.Column(sa.Text)

View file

@ -1,3 +1 @@
from . import views
__all__ = ["views"]

View file

@ -1,3 +1 @@
from .views import authorize, token, user_info
__all__ = ["authorize", "token", "user_info"]

View file

@ -64,7 +64,7 @@ def _split_arg(arg_input: Union[str, list]) -> Set[str]:
- the response_type/scope passed as a list ?scope=scope_1&scope=scope_2
"""
res = set()
if isinstance(arg_input, str):
if type(arg_input) is str:
if " " in arg_input:
for x in arg_input.split(" "):
if x:

View file

@ -5,11 +5,3 @@ from .views import (
account_activated,
extension_redirect,
)
__all__ = [
"index",
"final",
"setup_done",
"account_activated",
"extension_redirect",
]

View file

@ -39,6 +39,7 @@ class _InnerLock:
lock_redis.storage.delete(lock_name)
def __call__(self, f: Callable[..., Any]):
if self.lock_suffix is None:
lock_suffix = f.__name__
else:

View file

@ -5,11 +5,3 @@ from .views import (
provider1_callback,
provider2_callback,
)
__all__ = [
"index",
"phone_reservation",
"twilio_callback",
"provider1_callback",
"provider2_callback",
]

View file

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

View file

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

View file

@ -75,7 +75,7 @@ class RedisSessionStore(SessionInterface):
try:
data = pickle.loads(val)
return ServerSession(data, session_id=session_id)
except Exception:
except:
pass
return ServerSession(session_id=str(uuid.uuid4()))

View file

@ -1,33 +0,0 @@
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

@ -99,7 +99,7 @@ def sanitize_email(email_address: str, not_lower=False) -> str:
email_address = email_address.strip().replace(" ", "").replace("\n", " ")
if not not_lower:
email_address = email_address.lower()
return email_address.replace("\u200f", "")
return email_address
class NextUrlSanitizer:

93
cron.py
View file

@ -5,11 +5,11 @@ from typing import List, Tuple
import arrow
import requests
from sqlalchemy import func, desc, or_, and_
from sqlalchemy import func, desc, or_
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import joinedload
from sqlalchemy.orm.exc import ObjectDeletedError
from sqlalchemy.sql import Insert, text
from sqlalchemy.sql import Insert
from app import s3, config
from app.alias_utils import nb_email_log_for_mailbox
@ -22,9 +22,10 @@ from app.email_utils import (
render,
email_can_be_used_as_mailbox,
send_email_with_rate_control,
normalize_reply_email,
is_valid_email,
get_email_domain_part,
)
from app.email_validation import is_valid_email, normalize_reply_email
from app.errors import ProtonPartnerNotSetUp
from app.log import LOG
from app.mail_sender import load_unsent_mails_from_fs_and_resend
@ -65,14 +66,12 @@ from server import create_light_app
def notify_trial_end():
for user in User.filter(
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),
User.activated.is_(True), User.trial_end.isnot(None), User.lifetime.is_(False)
).all():
try:
if user.in_trial():
if user.in_trial() and arrow.now().shift(
days=3
) > user.trial_end >= arrow.now().shift(days=2):
LOG.d("Send trial end email to user %s", user)
send_trial_end_soon_email(user)
# happens if user has been deleted in the meantime
@ -85,49 +84,27 @@ def delete_logs():
delete_refused_emails()
delete_old_monitoring()
for t_email in TransactionalEmail.filter(
for t in TransactionalEmail.filter(
TransactionalEmail.created_at < arrow.now().shift(days=-7)
):
TransactionalEmail.delete(t_email.id)
TransactionalEmail.delete(t.id)
for b in Bounce.filter(Bounce.created_at < arrow.now().shift(days=-7)):
Bounce.delete(b.id)
Session.commit()
LOG.d("Deleting EmailLog older than 2 weeks")
LOG.d("Delete EmailLog older than 2 weeks")
total_deleted = 0
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(
"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()
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
max_dt = arrow.now().shift(weeks=-2)
nb_deleted = EmailLog.filter(EmailLog.created_at < max_dt).delete()
Session.commit()
LOG.i("Deleted %s email logs", total_deleted)
LOG.i("Delete %s email logs", nb_deleted)
def delete_refused_emails():
for refused_email in (
RefusedEmail.filter_by(deleted=False).order_by(RefusedEmail.id).all()
):
for refused_email in RefusedEmail.filter_by(deleted=False).all():
if arrow.now().shift(days=1) > refused_email.delete_at >= arrow.now():
LOG.d("Delete refused email %s", refused_email)
if refused_email.path:
@ -161,7 +138,7 @@ def notify_premium_end():
send_email(
user.email,
"Your subscription will end soon",
f"Your subscription will end soon",
render(
"transactional/subscription-end.txt",
user=user,
@ -218,7 +195,7 @@ def notify_manual_sub_end():
LOG.d("Remind user %s that their manual sub is ending soon", user)
send_email(
user.email,
"Your subscription will end soon",
f"Your subscription will end soon",
render(
"transactional/manual-subscription-end.txt",
user=user,
@ -295,11 +272,7 @@ def compute_metric2() -> Metric2:
_24h_ago = now.shift(days=-1)
nb_referred_user_paid = 0
for user in (
User.filter(User.referral_id.isnot(None))
.yield_per(500)
.enable_eagerloads(False)
):
for user in User.filter(User.referral_id.isnot(None)):
if user.is_paid():
nb_referred_user_paid += 1
@ -590,21 +563,21 @@ nb_total_bounced_last_24h: {stats_today.nb_total_bounced_last_24h} - {increase_p
"""
monitoring_report += "\n====================================\n"
monitoring_report += """
monitoring_report += f"""
# Account bounce report:
"""
for email, bounces in bounce_report():
monitoring_report += f"{email}: {bounces}\n"
monitoring_report += """\n
monitoring_report += f"""\n
# Alias creation report:
"""
for email, nb_alias, date in alias_creation_report():
monitoring_report += f"{email}, {date}: {nb_alias}\n"
monitoring_report += """\n
monitoring_report += f"""\n
# Full bounce detail report:
"""
monitoring_report += all_bounce_report()
@ -1047,8 +1020,7 @@ async def check_hibp():
)
.filter(Alias.enabled)
.order_by(Alias.hibp_last_check.asc())
.yield_per(500)
.enable_eagerloads(False)
.all()
):
await queue.put(alias.id)
@ -1099,14 +1071,14 @@ def notify_hibp():
)
LOG.d(
"Send new breaches found email to %s for %s breaches aliases",
f"Send new breaches found email to %s for %s breaches aliases",
user,
len(breached_aliases),
)
send_email(
user.email,
"You were in a data breach",
f"You were in a data breach",
render(
"transactional/hibp-new-breaches.txt.jinja2",
user=user,
@ -1126,18 +1098,6 @@ def notify_hibp():
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__":
LOG.d("Start running cronjob")
parser = argparse.ArgumentParser()
@ -1204,6 +1164,3 @@ if __name__ == "__main__":
elif args.job == "send_undelivered_mails":
LOG.d("Sending undelivered emails")
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,66 +5,65 @@ jobs:
schedule: "0 0 * * *"
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
command: python /code/cron.py -j delete_old_monitoring
shell: /bin/bash
schedule: "15 1 * * *"
schedule: "0 14 * * *"
captureStderr: true
- name: SimpleLogin Custom Domain check
command: python /code/cron.py -j check_custom_domain
shell: /bin/bash
schedule: "15 2 * * *"
schedule: "0 15 * * *"
captureStderr: true
- name: SimpleLogin HIBP check
command: python /code/cron.py -j check_hibp
shell: /bin/bash
schedule: "15 3 * * *"
schedule: "0 18 * * *"
captureStderr: true
concurrencyPolicy: Forbid
- name: SimpleLogin Notify HIBP breaches
command: python /code/cron.py -j notify_hibp
shell: /bin/bash
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 * * *"
schedule: "0 19 * * *"
captureStderr: true
concurrencyPolicy: Forbid

View file

@ -15,7 +15,6 @@
- [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.
- [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.
[Alias endpoints](#alias-endpoints)
@ -227,22 +226,6 @@ Input:
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
Enable sudo mode
@ -711,7 +694,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.
```json
``json
{
"error": "Please upgrade to create a reverse-alias"
}

View file

@ -1,4 +1,4 @@
# SSL, HTTPS, HSTS and additional security measures
# SSL, HTTPS, and HSTS
It's highly recommended to enable SSL/TLS on your server, both for the web app and email server.
@ -58,124 +58,3 @@ Now, reload Nginx:
```bash
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,6 +106,8 @@ from app.email_utils import (
get_header_unicode,
generate_reply_email,
is_reverse_alias,
normalize_reply_email,
is_valid_email,
replace,
should_disable,
parse_id_from_bounce,
@ -121,7 +123,6 @@ from app.email_utils import (
generate_verp_email,
sl_formataddr,
)
from app.email_validation import is_valid_email, normalize_reply_email
from app.errors import (
NonReverseAliasInReplyPhase,
VERPTransactional,
@ -235,6 +236,7 @@ def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Con
contact.mail_from = mail_from
Session.commit()
else:
try:
contact = Contact.create(
user_id=alias.user_id,
@ -260,7 +262,7 @@ def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Con
Session.commit()
except IntegrityError:
LOG.w(f"Contact with email {contact_email} for alias {alias} already exist")
LOG.w("Contact %s %s already exist", alias, contact_email)
Session.rollback()
contact = Contact.get_by(alias_id=alias.id, website_email=contact_email)
@ -278,9 +280,6 @@ def get_or_create_reply_to_contact(
except ValueError:
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):
LOG.w(
"invalid reply-to address %s. Parse from %s",
@ -349,10 +348,6 @@ def replace_header_when_forward(msg: Message, alias: Alias, header: str):
continue
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:
# update the contact name if needed
if contact.name != full_address.display_name:
@ -360,9 +355,9 @@ def replace_header_when_forward(msg: Message, alias: Alias, header: str):
"Update contact %s name %s to %s",
contact,
contact.name,
contact_name,
full_address.display_name,
)
contact.name = contact_name
contact.name = full_address.display_name
Session.commit()
else:
LOG.d(
@ -377,7 +372,7 @@ def replace_header_when_forward(msg: Message, alias: Alias, header: str):
user_id=alias.user_id,
alias_id=alias.id,
website_email=contact_email,
name=contact_name,
name=full_address.display_name,
reply_email=generate_reply_email(contact_email, alias),
is_cc=header.lower() == "cc",
automatic_created=True,
@ -546,20 +541,12 @@ def sign_msg(msg: Message) -> Message:
signature.add_header("Content-Disposition", 'attachment; filename="signature.asc"')
try:
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)
signature.set_payload(sign_data(message_to_bytes(msg).replace(b"\n", b"\r\n")))
except Exception:
LOG.e("Cannot sign, try using pgpy")
payload = 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)
signature.set_payload(
sign_data_with_pgpy(message_to_bytes(msg).replace(b"\n", b"\r\n"))
)
container.attach(signature)
@ -636,8 +623,8 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str
user = alias.user
if not user.can_send_or_receive():
LOG.i(f"User {user} cannot receive emails")
if user.disabled:
LOG.w("User %s disabled, disable forwarding emails for %s", user, alias)
if should_ignore_bounce(envelope.mail_from):
return [(True, status.E207)]
else:
@ -859,40 +846,38 @@ def forward_email_to_mailbox(
f"""Email sent to {alias.email} from an invalid address and cannot be replied""",
)
headers_to_keep = [
headers.FROM,
headers.TO,
headers.CC,
headers.SUBJECT,
headers.DATE,
# do not delete original message id
headers.MESSAGE_ID,
# References and In-Reply-To are used for keeping the email thread
headers.REFERENCES,
headers.IN_REPLY_TO,
headers.LIST_UNSUBSCRIBE,
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)
if mailbox.generic_subject:
LOG.d("Use a generic subject for %s", mailbox)
orig_subject = msg[headers.SUBJECT]
orig_subject = get_header_unicode(orig_subject)
add_or_replace_header(msg, "Subject", mailbox.generic_subject)
sender = msg[headers.FROM]
sender = get_header_unicode(sender)
msg = add_header(
msg,
f"""Forwarded by SimpleLogin to {alias.email} from "{sender}" with "{orig_subject}" as subject""",
f"""Forwarded by SimpleLogin to {alias.email} from "{sender}" with <b>{orig_subject}</b> as subject""",
)
delete_all_headers_except(
msg,
[
headers.FROM,
headers.TO,
headers.CC,
headers.SUBJECT,
headers.DATE,
# do not delete original message id
headers.MESSAGE_ID,
# References and In-Reply-To are used for keeping the email thread
headers.REFERENCES,
headers.IN_REPLY_TO,
]
+ headers.MIME_HEADERS,
)
# 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:
LOG.d("Use a generic subject for %s", mailbox)
orig_subject = msg[headers.SUBJECT]
orig_subject = get_header_unicode(orig_subject)
add_or_replace_header(msg, "Subject", mailbox.generic_subject)
sender = msg[headers.FROM]
sender = get_header_unicode(sender)
msg = add_header(
msg,
f"""Forwarded by SimpleLogin to {alias.email} from "{sender}" with "{orig_subject}" as subject""",
f"""Forwarded by SimpleLogin to {alias.email} from "{sender}" with <b>{orig_subject}</b> as subject""",
)
try:
msg = prepare_pgp_message(
@ -913,11 +898,6 @@ def forward_email_to_mailbox(
msg[headers.SL_EMAIL_LOG_ID] = str(email_log.id)
if user.include_header_email_header:
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
msg[headers.SL_ENVELOPE_TO] = alias.email
@ -1069,8 +1049,13 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str):
user = alias.user
mail_from = envelope.mail_from
if not user.can_send_or_receive():
LOG.i(f"User {user} cannot send emails")
if user.disabled:
LOG.e(
"User %s disabled, disable sending emails from %s to %s",
user,
alias,
contact,
)
return False, status.E504
# Check if we need to reject or quarantine based on dmarc
@ -1196,7 +1181,7 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str):
)
# replace reverse alias by real address for all contacts
for reply_email, website_email in contact_query.values(
for (reply_email, website_email) in contact_query.values(
Contact.reply_email, Contact.website_email
):
msg = replace(msg, reply_email, website_email)
@ -1251,6 +1236,7 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str):
if str(msg[headers.TO]).lower() == "undisclosed-recipients:;":
# no need to replace TO header
LOG.d("email is sent in BCC mode")
del msg[headers.TO]
else:
replace_header_when_reply(msg, alias, headers.TO)
@ -1951,7 +1937,7 @@ def handle_bounce(envelope, email_log: EmailLog, msg: Message) -> str:
for is_delivered, smtp_status in handle_forward(envelope, msg, alias.email):
res.append((is_delivered, smtp_status))
for is_success, smtp_status in res:
for (is_success, smtp_status) in res:
# Consider all deliveries successful if 1 delivery is successful
if is_success:
return smtp_status
@ -2271,7 +2257,7 @@ def handle(envelope: Envelope, msg: Message) -> str:
if nb_success > 0 and nb_non_success > 0:
LOG.e(f"some deliveries fail and some success, {mail_from}, {rcpt_tos}, {res}")
for is_success, smtp_status in res:
for (is_success, smtp_status) in res:
# Consider all deliveries successful if 1 delivery is successful
if is_success:
return smtp_status

View file

@ -89,6 +89,7 @@ aghast
agile
agility
aging
agnostic
agonize
agonizing
agony
@ -374,6 +375,8 @@ augmented
august
authentic
author
autism
autistic
autograph
automaker
automated
@ -443,6 +446,7 @@ backyard
bacon
bacteria
bacterium
badass
badge
badland
badly
@ -1102,6 +1106,7 @@ clinic
clinking
clip
clique
cloak
clobber
clock
clone
@ -1771,6 +1776,7 @@ diagnosis
diagram
dial
diameter
diaper
diaphragm
diary
dice
@ -2026,6 +2032,9 @@ duffel
dugout
duh
duke
duller
dullness
duly
dumping
dumpling
dumpster
@ -2518,6 +2527,8 @@ feisty
feline
felt-tip
feminine
feminism
feminist
feminize
femur
fence
@ -2656,6 +2667,7 @@ fondness
fondue
font
food
fool
footage
football
footbath
@ -2765,6 +2777,7 @@ gag
gainfully
gaining
gains
gala
gallantly
galleria
gallery
@ -3151,6 +3164,8 @@ hardware
hardwired
hardwood
hardy
harmful
harmless
harmonica
harmonics
harmonize
@ -3325,6 +3340,7 @@ identical
identify
identity
ideology
idiocy
idiom
idly
igloo
@ -3341,6 +3357,7 @@ imaging
imbecile
imitate
imitation
immature
immerse
immersion
imminent
@ -3370,10 +3387,14 @@ implode
implosion
implosive
imply
impolite
important
importer
impose
imposing
impotence
impotency
impotent
impound
imprecise
imprint
@ -3403,6 +3424,8 @@ irritable
irritably
irritant
irritate
islamic
islamist
isolated
isolating
isolation
@ -3501,6 +3524,7 @@ june
junior
juniper
junkie
junkman
junkyard
jurist
juror
@ -3546,6 +3570,9 @@ king
kinship
kinsman
kinswoman
kissable
kisser
kissing
kitchen
kite
kitten
@ -3622,6 +3649,7 @@ laundry
laurel
lavender
lavish
laxative
lazily
laziness
lazy
@ -3662,6 +3690,7 @@ liable
liberty
librarian
library
licking
licorice
lid
life
@ -3712,6 +3741,8 @@ livestock
lividly
living
lizard
lubricant
lubricate
lucid
luckily
luckiness
@ -3847,6 +3878,7 @@ marshland
marshy
marsupial
marvelous
marxism
mascot
masculine
mashed
@ -3882,6 +3914,8 @@ maximum
maybe
mayday
mayflower
moaner
moaning
mobile
mobility
mobilize
@ -4090,6 +4124,7 @@ nemeses
nemesis
neon
nephew
nerd
nervous
nervy
nest
@ -4104,6 +4139,7 @@ never
next
nibble
nickname
nicotine
niece
nifty
nimble
@ -4131,10 +4167,14 @@ nuptials
nursery
nursing
nurture
nutcase
nutlike
nutmeg
nutrient
nutshell
nuttiness
nutty
nuzzle
nylon
oaf
oak
@ -4165,6 +4205,7 @@ obstinate
obstruct
obtain
obtrusive
obtuse
obvious
occultist
occupancy
@ -4405,6 +4446,7 @@ palpitate
paltry
pampered
pamperer
pampers
pamphlet
panama
pancake
@ -4609,6 +4651,7 @@ plated
platform
plating
platinum
platonic
platter
platypus
plausible
@ -4734,6 +4777,8 @@ prancing
pranker
prankish
prankster
prayer
praying
preacher
preaching
preachy
@ -4751,6 +4796,8 @@ prefix
preflight
preformed
pregame
pregnancy
pregnant
preheated
prelaunch
prelaw
@ -4890,6 +4937,7 @@ prudishly
prune
pruning
pry
psychic
public
publisher
pucker
@ -4909,7 +4957,8 @@ punctual
punctuate
punctured
pungent
punishe
punisher
punk
pupil
puppet
puppy
@ -4991,6 +5040,7 @@ quote
rabid
race
racing
racism
rack
racoon
radar
@ -5105,6 +5155,7 @@ recount
recoup
recovery
recreate
rectal
rectangle
rectified
rectify
@ -5571,6 +5622,7 @@ sarcastic
sardine
sash
sasquatch
sassy
satchel
satiable
satin
@ -5599,6 +5651,7 @@ scaling
scallion
scallop
scalping
scam
scandal
scanner
scanning
@ -5875,6 +5928,8 @@ silent
silica
silicon
silk
silliness
silly
silo
silt
silver
@ -5936,6 +5991,7 @@ skimmer
skimming
skimpily
skincare
skinhead
skinless
skinning
skinny
@ -6141,6 +6197,7 @@ splinter
splotchy
splurge
spoilage
spoiled
spoiler
spoiling
spoils
@ -7022,6 +7079,7 @@ undocked
undoing
undone
undrafted
undress
undrilled
undusted
undying

View file

@ -158677,6 +158677,16 @@ isis
isize
isl
islay
islam
islamic
islamism
islamist
islamistic
islamite
islamitic
islamitish
islamization
islamize
island
islanded
islander

View file

@ -1,42 +0,0 @@
"""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

@ -1,33 +0,0 @@
"""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

@ -1,34 +0,0 @@
"""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

@ -1,39 +0,0 @@
"""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 ###

View file

@ -1,31 +0,0 @@
"""empty message
Revision ID: 4bc54632d9aa
Revises: 46ecb648a47e
Create Date: 2023-11-07 14:02:17.610226
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '4bc54632d9aa'
down_revision = '46ecb648a47e'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('ix_newsletter_subject', table_name='newsletter')
op.create_index(op.f('ix_newsletter_subject'), 'newsletter', ['subject'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index(op.f('ix_newsletter_subject'), table_name='newsletter')
op.create_index('ix_newsletter_subject', 'newsletter', ['subject'], unique=True)
# ### end Alembic commands ###

View file

View file

@ -1,21 +0,0 @@
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

@ -1,20 +0,0 @@
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}")

View file

@ -1,26 +0,0 @@
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()

View file

@ -1,82 +0,0 @@
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,4 +1,3 @@
import configparser
import os
import subprocess
from time import sleep
@ -8,7 +7,6 @@ import newrelic.agent
from app.db import Session
from app.log import LOG
from monitor.metric_exporter import MetricExporter
# the number of consecutive fails
# if more than _max_nb_fails, alert
@ -21,18 +19,6 @@ _max_nb_fails = 10
# the maximum number of emails in incoming & active queue
_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()
def log_postfix_metrics():
@ -94,13 +80,10 @@ def log_nb_db_connection():
if __name__ == "__main__":
exporter = MetricExporter(get_newrelic_license())
while True:
log_postfix_metrics()
log_nb_db_connection()
Session.close()
exporter.run()
# 1 min
sleep(60)

1319
poetry.lock generated

File diff suppressed because it is too large Load diff

View file

@ -18,9 +18,6 @@ exclude = '''
)
'''
[tool.ruff]
ignore-init-module-imports = true
[tool.djlint]
indent = 2
profile = "jinja"
@ -56,7 +53,7 @@ packages = [
include = ["templates/*", "templates/**/*", "local_data/*.txt"]
[tool.poetry.dependencies]
python = "^3.10"
python = "^3.7.2"
flask = "^1.1.2"
flask_login = "^0.5.0"
wtforms = "^2.3.3"
@ -98,12 +95,13 @@ webauthn = "^0.4.7"
pyspf = "^2.0.14"
Flask-Limiter = "^1.4"
memory_profiler = "^0.57.0"
gevent = "22.10.2"
gevent = "^21.12.0"
aiospamc = "^0.6.1"
email_validator = "^1.1.1"
PGPy = "0.5.4"
coinbase-commerce = "^1.0.1"
requests = "^2.25.1"
newrelic = "8.8.0"
newrelic = "^7.10.0"
flanker = "^0.9.11"
pyre2 = "^0.3.6"
tldextract = "^3.1.2"
@ -113,8 +111,6 @@ Deprecated = "^1.2.13"
cryptography = "37.0.1"
SQLAlchemy = "1.3.24"
redis = "^4.5.3"
newrelic-telemetry-sdk = "^0.5.0"
aiospamc = "0.10"
[tool.poetry.dev-dependencies]
pytest = "^7.0.0"
@ -124,9 +120,6 @@ black = "^22.1.0"
djlint = "^1.3.0"
pylint = "^2.14.4"
[tool.poetry.group.dev.dependencies]
ruff = "^0.1.5"
[build-system]
requires = ["poetry>=0.12"]
build-backend = "poetry.masonry.api"

View file

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

View file

@ -1,12 +1,13 @@
from time import sleep
import flask_migrate
from IPython import embed
from sqlalchemy_utils import create_database, database_exists, drop_database
from app import models
from app.config import DB_URI
from app.db import Session
from app.log import LOG
from app.models import User, RecoveryCode
from app.models import *
if False:
# noinspection PyUnreachableCode

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 13 KiB

16
static/package-lock.json generated vendored
View file

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

View file

@ -9,13 +9,10 @@
<h1 class="card-title">Create new account</h1>
<div class="form-group">
<label class="form-label">Email address</label>
{{ form.email(class="form-control", type="email", placeholder="YourName@protonmail.com") }}
{{ form.email(class="form-control", type="email") }}
<div class="small-text alert alert-info" style="margin-top: 1px">
Emails sent to your alias will be forwarded to this email address.
<br>
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>
{{ render_field_errors(form.email) }}
</div>

View file

@ -23,7 +23,7 @@
<!-- Yandex -->
<meta name="yandex-verification" content="c9e5d4d68bc983a1" />
<meta name="description"
content="Protect your email address with email ALIAS. Create a different email alias for each website. No more phishing, or spam."/>
content="Protect your email address with email ALIAS. Create a different email alias for each website. No more phishing, spams."/>
<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="canonical" href="{{ CANONICAL_URL }}" />
@ -86,12 +86,6 @@
</head>
<body>
<div class="page">
{% if current_user.is_authenticated and current_user.should_show_upgrade_button() %}
<div class="alert alert-success text-center mb-0" role="alert">
Black Friday: $20 for the first year instead of $30. Available until December 1st.
</div>
{% endif %}
{% block announcement %}{% endblock %}
<div class="container">
<!-- For flash messages -->

View file

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

View file

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

View file

@ -268,7 +268,7 @@
If you are using a subdomain, e.g. <i>subdomain.domain.com</i>,
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.
That means, if your domain is <i>mail.domain.com</i> you should enter <i>dkim._domainkey.mail.domain.com</i> as the Domain.
<br />
</div>
<div class="alert alert-info">

View file

@ -71,181 +71,177 @@
</form>
</div>
<!-- END Change email -->
<!-- Not show PGP option for Proton mailbox -->
{% if mailbox.is_proton() and not mailbox.pgp_enabled() %}
{% if mailbox.pgp_finger_print and not mailbox.disable_pgp and current_user.include_sender_in_reverse_alias %}
<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.
Email headers like <span class="italic">From, To, Subject</span> aren't encrypted by PGP.
Currently, your reverse alias includes the sender address.
You can disable this on <a href="/dashboard/setting#sender-in-ra">Settings</a>.
</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">
Email headers like <span class="italic">From, To, Subject</span> aren't encrypted by PGP.
Currently, your reverse alias includes the sender address.
You can disable this on <a href="/dashboard/setting#sender-in-ra">Settings</a>.
</div>
{% endif %}
<div class="card">
<div class="card-body">
<div class="card-title">
<div class="d-flex">
Pretty Good Privacy (PGP)
{% if mailbox.pgp_finger_print %}
<form method="post">
{{ csrf_form.csrf_token }}
<input type="hidden" name="form-name" value="toggle-pgp">
<label class="custom-switch cursor" style="padding-left: 1rem" data-toggle="tooltip" {% if mailbox.disable_pgp %}
title="Enable PGP" {% else %} title="Disable PGP" {% endif %}>
<input type="checkbox" class="custom-switch-input" name="pgp-enabled" {{ "" if mailbox.disable_pgp else "checked" }}>
<span class="custom-switch-indicator"></span>
</label>
</form>
{% endif %}
</div>
<div class="small-text mt-1">
By importing your PGP Public Key into SimpleLogin, all emails sent to {{ mailbox.email }} are
<b>encrypted</b> with your key.
<br />
{% if PGP_SIGNER %}All forwarded emails will be signed with <b>{{ PGP_SIGNER }}</b>.{% endif %}
</div>
</div>
{% if not current_user.is_premium() %}
<div class="alert alert-danger" role="alert">This feature is only available in premium plan.</div>
{% endif %}
<form method="post">
{{ csrf_form.csrf_token }}
<div class="form-group">
<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="(Drag and drop or paste your pgp public key here)&#10;-----BEGIN PGP PUBLIC KEY BLOCK-----">{{ mailbox.pgp_public_key or "" }}</textarea>
</div>
<input type="hidden" name="form-name" value="pgp">
<button class="btn btn-primary" name="action" {% if not current_user.is_premium() %}
disabled {% endif %} value="save">
Save
</button>
<div class="card">
<div class="card-body">
<div class="card-title">
<div class="d-flex">
Pretty Good Privacy (PGP)
{% if mailbox.pgp_finger_print %}
<button class="btn btn-danger float-right" name="action" value="remove">Remove</button>
<form method="post">
{{ csrf_form.csrf_token }}
<input type="hidden" name="form-name" value="toggle-pgp">
<label class="custom-switch cursor" style="padding-left: 1rem" data-toggle="tooltip" {% if mailbox.disable_pgp %}
title="Enable PGP" {% else %} title="Disable PGP" {% endif %}>
<input type="checkbox" class="custom-switch-input" name="pgp-enabled" {{ "" if mailbox.disable_pgp else "checked" }}>
<span class="custom-switch-indicator"></span>
</label>
</form>
{% endif %}
</form>
</div>
<div class="small-text mt-1">
By importing your PGP Public Key into SimpleLogin, all emails sent to {{ mailbox.email }} are
<b>encrypted</b> with your key.
<br />
{% if PGP_SIGNER %}All forwarded emails will be signed with <b>{{ PGP_SIGNER }}</b>.{% endif %}
</div>
</div>
{% if not current_user.is_premium() %}
<div class="alert alert-danger" role="alert">This feature is only available in premium plan.</div>
{% endif %}
<form method="post">
{{ csrf_form.csrf_token }}
<div class="form-group">
<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="(Drag and drop or paste your pgp public key here)&#10;-----BEGIN PGP PUBLIC KEY BLOCK-----">{{ mailbox.pgp_public_key or "" }}</textarea>
</div>
<input type="hidden" name="form-name" value="pgp">
<button class="btn btn-primary" name="action" {% if not current_user.is_premium() %}
disabled {% endif %} value="save">
Save
</button>
{% if mailbox.pgp_finger_print %}
<button class="btn btn-danger float-right" name="action" value="remove">Remove</button>
{% endif %}
</form>
</div>
</div>
<div class="card" id="generic-subject">
<form method="post" action="#generic-subject">
<div class="card" {% if not mailbox.pgp_enabled() %}
disabled {% endif %}>
<form method="post">
{{ csrf_form.csrf_token }}
<input type="hidden" name="form-name" value="generic-subject">
<div class="card-body">
<div class="card-title">
Hide email subject
Hide email subject when PGP is enabled
<div class="small-text mt-1">
The original subject will be added to the email body and all forwarded emails will have the generic subject.
When PGP is enabled, you can choose to use a <b>generic</b> subject for the forwarded emails.
The original subject is then added into the email body.
<br />
This option is often used when PGP is enabled.
As PGP does not encrypt the email subject, it allows a further protection of your email content.
As PGP does not encrypt the email subject and the email subject might contain sensitive information,
this option will allow a further protection of your email content.
</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">
<label class="form-label">Generic Subject</label>
<input name="generic-subject"
class="form-control"
maxlength="78"
placeholder="Generic Subject"
value="{{ mailbox.generic_subject or "" }}">
</div>
<button class="btn btn-primary" name="action" value="save">Save</button>
{% if mailbox.generic_subject %}
<button class="btn btn-danger float-right" name="action" value="remove">Remove</button>
{% endif %}
</div>
</form>
</div>
<hr />
<h2 class="h4">Advanced Options</h2>
{% if spf_available %}
<div class="card" id="spf">
<form method="post">
{{ csrf_form.csrf_token }}
<input type="hidden" name="form-name" value="force-spf">
<div class="card-body">
<div class="card-title">
Enforce SPF
<div class="small-text">
To avoid email-spoofing, SimpleLogin blocks email that
<em data-toggle="tooltip"
title="Email that has your mailbox as envelope-sender address">seems</em> to come from your
mailbox
but sent from <em data-toggle="tooltip"
title="IP Address that is not known by your mailbox email service">unknown</em>
IP address.
<br />
Only turn off this option if you know what you're doing :).
</div>
<input name="generic-subject" {% if not mailbox.pgp_enabled() %}
disabled {% endif %} class="form-control" maxlength="78" placeholder="Generic Subject" value="{{ mailbox.generic_subject or "" }}">
</div>
<label class="custom-switch cursor mt-2 pl-0" data-toggle="tooltip" {% if mailbox.force_spf %}
title="Disable SPF enforcement" {% else %} title="Enable SPF enforcement" {% endif %}>
<input type="checkbox" name="spf-status" class="custom-switch-input" {{ "checked" if mailbox.force_spf else "" }}>
<span class="custom-switch-indicator"></span>
</label>
<button class="btn btn-primary" name="action" {% if not mailbox.pgp_enabled() %}
disabled {% endif %} value="save">
Save
</button>
{% if mailbox.generic_subject %}
<button class="btn btn-danger float-right" name="action" value="remove">Remove</button>
{% endif %}
</div>
</form>
</div>
{% endif %}
<div class="card" id="authorized-address">
<div class="card-body">
<div class="card-title">
Authorized addresses
<div class="small-text">
Emails sent from these addresses to a <b>reverse-alias</b> are considered as being sent
from {{ mailbox.email }}
</div>
<hr />
<h2 class="h4">Advanced Options</h2>
{% if spf_available %}
<div class="card" id="spf">
<form method="post">
{{ csrf_form.csrf_token }}
<input type="hidden" name="form-name" value="force-spf">
<div class="card-body">
<div class="card-title">
Enforce SPF
<div class="small-text">
To avoid email-spoofing, SimpleLogin blocks email that
<em data-toggle="tooltip"
title="Email that has your mailbox as envelope-sender address">seems</em> to come from your
mailbox
but sent from <em data-toggle="tooltip"
title="IP Address that is not known by your mailbox email service">unknown</em>
IP address.
<br />
Only turn off this option if you know what you're doing :).
</div>
</div>
<label class="custom-switch cursor mt-2 pl-0" data-toggle="tooltip" {% if mailbox.force_spf %}
title="Disable SPF enforcement" {% else %} title="Enable SPF enforcement" {% endif %}>
<input type="checkbox" name="spf-status" class="custom-switch-input" {{ "checked" if mailbox.force_spf else "" }}>
<span class="custom-switch-indicator"></span>
</label>
</div>
</form>
</div>
{% if mailbox.authorized_addresses | length == 0 %}
{% endif %}
<div class="card" id="authorized-address">
<div class="card-body">
<div class="card-title">
Authorized addresses
<div class="small-text">
Emails sent from these addresses to a <b>reverse-alias</b> are considered as being sent
from {{ mailbox.email }}
</div>
</div>
{% if mailbox.authorized_addresses | length == 0 %}
{% else %}
<ul>
{% for authorized_address in mailbox.authorized_addresses %}
{% else %}
<ul>
{% for authorized_address in mailbox.authorized_addresses %}
<li>
{{ authorized_address.email }}
<form method="post" action="#authorized-address" style="display: inline">
{{ csrf_form.csrf_token }}
<input type="hidden" name="form-name" value="delete-authorized-address">
<input type="hidden"
name="authorized-address-id"
value="{{ authorized_address.id }}">
<input type="submit" class="btn btn-sm btn-outline-warning" value="Delete">
</form>
</li>
{% endfor %}
</ul>
{% endif %}
<form method="post" action="#authorized-address" class="form-inline">
{{ csrf_form.csrf_token }}
<input type="hidden" name="form-name" value="add-authorized-address">
<input type="email" name="email" size="50" class="form-control" required>
<input type="submit" class="btn btn-primary" value="Add">
</form>
<li>
{{ authorized_address.email }}
<form method="post" action="#authorized-address" style="display: inline">
{{ csrf_form.csrf_token }}
<input type="hidden" name="form-name" value="delete-authorized-address">
<input type="hidden"
name="authorized-address-id"
value="{{ authorized_address.id }}">
<input type="submit" class="btn btn-sm btn-outline-warning" value="Delete">
</form>
</li>
{% endfor %}
</ul>
{% endif %}
<form method="post" action="#authorized-address" class="form-inline">
{{ csrf_form.csrf_token }}
<input type="hidden" name="form-name" value="add-authorized-address">
<input type="email" name="email" size="50" class="form-control" required>
<input type="submit" class="btn btn-primary" value="Add">
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block script %}
<script src="/static/js/utils/drag-drop-into-text.js"></script>
<script>
{% endblock %}
{% block script %}
<script src="/static/js/utils/drag-drop-into-text.js"></script>
<script>
$(".custom-switch-input").change(function (e) {
$(this).closest("form").submit();
});
enableDragDropForPGPKeys('#pgp-public-key');
</script>
{% endblock %}
</script>
{% endblock %}

View file

@ -57,19 +57,6 @@
{% endblock %}
{% block default_content %}
<div class="alert alert-info">
Black Friday Deal: 33% off on the yearly plan for the <b>first</b> year ($20 instead of $30).
<br>
Please use this coupon code
<em data-toggle="tooltip"
title="Click to copy"
class="clipboard"
data-clipboard-text="BF2023">BF2023</em> during the checkout.
<br>
<img src="/static/images/coupon.png" class="m-2" style="max-width: 300px">
<br>
Available until December 1, 2023.
</div>
<div class="pb-8">
<div class="text-center mx-md-auto mb-8 mt-6">
<h1>Upgrade to unlock premium features</h1>
@ -220,7 +207,7 @@
<div class="card-body">
<div class="text-center">
<div class="h3">Proton plan</div>
<div class="h3 my-3">Starts at $12.99 / month</div>
<div class="h3 my-3">Starts at $11.99 / month</div>
<div class="text-center mt-4 mb-6">
<a class="btn btn-lg btn-outline-primary w-100"
role="button"
@ -238,6 +225,10 @@
<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>
15 email addresses
</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
@ -248,7 +239,11 @@
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
25 calendars
15 email addresses
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
20 Calendars
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
@ -381,6 +376,10 @@
<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>
15 email addresses/aliases
</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
@ -391,7 +390,11 @@
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
25 calendars
15 email addresses/aliases
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
20 Calendars
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
@ -475,7 +478,7 @@
</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.
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">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"
@ -642,7 +645,7 @@
</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.
Please send us an email at <a href="mailto:support@simplelogin.zendesk.com">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.
@ -705,7 +708,7 @@
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.
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">support@simplelogin.zendesk.com</a> for more information.
</p>
</div>
</div>

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 :).
<br />
The payout can be initiated any time, just send us an email at
<a href="mailto:hi@simplelogin.io" target="_blank">hi@simplelogin.io</a>
<a href="mailto:hi@simplelogin.io">hi@simplelogin.io</a>
when you want to receive the payout.
</div>
{% if referrals|length == 0 %}

View file

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

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