Compare commits

...

24 Commits

Author SHA1 Message Date
d172825900 4.36.7 2023-12-21 12:00:09 +00:00
026865e5bf 4.36.6 2023-12-17 14:56:57 +00:00
add94ef2a2 4.36.5 2023-11-30 12:00:09 +00:00
1081400948 4.36.4 2023-11-22 12:00:09 +00:00
5776128905 4.36.3 2023-11-08 12:00:06 +00:00
d661860f4c 4.35.6 2023-11-07 12:00:06 +00:00
0a52e32972 4.35.3 2023-10-05 12:00:06 +01:00
703dcbd0eb 4.35.2 2023-10-03 12:00:06 +01:00
ce7ed69547 4.35.1 2023-10-02 12:00:06 +01:00
4f5564df16 4.35.0 2023-09-29 12:00:06 +01:00
2fee569131 4.34.4 2023-08-31 12:00:06 +01:00
7ea45d6f5d 4.34.3 2023-08-29 20:20:00 +01:00
6d24db50bd 4.34.2 2023-08-25 12:00:05 +01:00
88f270c6a1 4.34.1 2023-08-09 12:00:05 +01:00
0962b1cf29 Update .drone.yml 2023-08-06 17:56:31 +00:00
6051d72691 4.33.3 2023-08-06 17:51:04 +01:00
c31a75a9ef Update README.md 2023-08-06 16:04:57 +00:00
ef289385ff Update README.md 2023-08-06 16:04:47 +00:00
9b12a2ad33 Update README.md 2023-08-06 16:04:41 +00:00
8eb19d88f3 Remove provenance [CI SKIP] 2023-08-06 16:01:04 +00:00
e36e9d3077 4.32.4 2023-08-02 16:49:54 +01:00
b2430cbc5b 4.32.1 2023-07-12 11:00:04 +00:00
1258115397 4.32.0 2023-07-11 11:00:05 +00:00
38c134d903 4.31.0 2023-06-30 11:00:06 +00:00
119 changed files with 2239 additions and 980 deletions

View File

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

View File

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

View File

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

View File

@ -7,18 +7,19 @@ repos:
hooks: hooks:
- id: check-yaml - id: check-yaml
- id: trailing-whitespace - 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 - repo: https://github.com/Riverside-Healthcare/djLint
rev: v1.3.0 rev: v1.3.0
hooks: hooks:
- id: djlint-jinja - id: djlint-jinja
files: '.*\.html' files: '.*\.html'
entry: djlint --reformat 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

@ -169,6 +169,12 @@ For HTML templates, we use `djlint`. Before creating a pull request, please run
poetry run djlint --check templates poetry run djlint --check templates
``` ```
If some files aren't properly formatted, you can format all files with
```bash
poetry run djlint --reformat .
```
## Test sending email ## Test sending email
[swaks](http://www.jetmore.org/john/code/swaks/) is used for sending test emails to the `email_handler`. [swaks](http://www.jetmore.org/john/code/swaks/) is used for sending test emails to the `email_handler`.

View File

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

View File

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

View File

@ -256,6 +256,17 @@ class UserAdmin(SLModelView):
Session.commit() Session.commit()
@action(
"clear_delete_on",
"Remove scheduled deletion of user",
"This will remove the scheduled deletion for this users",
)
def clean_delete_on(self, ids):
for user in User.filter(User.id.in_(ids)):
user.delete_on = None
Session.commit()
# @action( # @action(
# "login_as", # "login_as",
# "Login as this user", # "Login as this user",
@ -600,6 +611,26 @@ class NewsletterAdmin(SLModelView):
else: else:
flash(error_msg, "error") 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): class NewsletterUserAdmin(SLModelView):
column_searchable_list = ["id"] column_searchable_list = ["id"]

View File

@ -70,7 +70,6 @@ def verify_prefix_suffix(
# when DISABLE_ALIAS_SUFFIX is true, alias_domain_prefix is empty # when DISABLE_ALIAS_SUFFIX is true, alias_domain_prefix is empty
and not config.DISABLE_ALIAS_SUFFIX and not config.DISABLE_ALIAS_SUFFIX
): ):
if not alias_domain_prefix.startswith("."): if not alias_domain_prefix.startswith("."):
LOG.e("User %s submits a wrong alias suffix %s", user, alias_suffix) LOG.e("User %s submits a wrong alias suffix %s", user, alias_suffix)
return False return False

View File

@ -21,6 +21,8 @@ from app.email_utils import (
send_cannot_create_directory_alias_disabled, send_cannot_create_directory_alias_disabled,
get_email_local_part, get_email_local_part,
send_cannot_create_domain_alias, send_cannot_create_domain_alias,
send_email,
render,
) )
from app.errors import AliasInTrashError from app.errors import AliasInTrashError
from app.log import LOG from app.log import LOG
@ -36,6 +38,8 @@ from app.models import (
EmailLog, EmailLog,
Contact, Contact,
AutoCreateRule, AutoCreateRule,
AliasUsedOn,
ClientUser,
) )
from app.regex_utils import regex_match from app.regex_utils import regex_match
@ -399,3 +403,58 @@ def alias_export_csv(user, csv_direct_export=False):
output.headers["Content-Disposition"] = "attachment; filename=aliases.csv" output.headers["Content-Disposition"] = "attachment; filename=aliases.csv"
output.headers["Content-type"] = "text/csv" output.headers["Content-type"] = "text/csv"
return output return output
def transfer_alias(alias, new_user, new_mailboxes: [Mailbox]):
# cannot transfer alias which is used for receiving newsletter
if User.get_by(newsletter_alias_id=alias.id):
raise Exception("Cannot transfer alias that's used to receive newsletter")
# update user_id
Session.query(Contact).filter(Contact.alias_id == alias.id).update(
{"user_id": new_user.id}
)
Session.query(AliasUsedOn).filter(AliasUsedOn.alias_id == alias.id).update(
{"user_id": new_user.id}
)
Session.query(ClientUser).filter(ClientUser.alias_id == alias.id).update(
{"user_id": new_user.id}
)
# remove existing mailboxes from the alias
Session.query(AliasMailbox).filter(AliasMailbox.alias_id == alias.id).delete()
# set mailboxes
alias.mailbox_id = new_mailboxes.pop().id
for mb in new_mailboxes:
AliasMailbox.create(alias_id=alias.id, mailbox_id=mb.id)
# alias has never been transferred before
if not alias.original_owner_id:
alias.original_owner_id = alias.user_id
# inform previous owner
old_user = alias.user
send_email(
old_user.email,
f"Alias {alias.email} has been received",
render(
"transactional/alias-transferred.txt",
alias=alias,
),
render(
"transactional/alias-transferred.html",
alias=alias,
),
)
# now the alias belongs to the new user
alias.user_id = new_user.id
# set some fields back to default
alias.disable_pgp = False
alias.pinned = False
Session.commit()

View File

@ -16,3 +16,22 @@ from .views import (
sudo, sudo,
user, 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,12 +24,14 @@ from app.errors import (
ErrContactAlreadyExists, ErrContactAlreadyExists,
ErrAddressInvalid, ErrAddressInvalid,
) )
from app.extensions import limiter
from app.models import Alias, Contact, Mailbox, AliasMailbox from app.models import Alias, Contact, Mailbox, AliasMailbox
@deprecated @deprecated
@api_bp.route("/aliases", methods=["GET", "POST"]) @api_bp.route("/aliases", methods=["GET", "POST"])
@require_api_auth @require_api_auth
@limiter.limit("10/minute", key_func=lambda: g.user.id)
def get_aliases(): def get_aliases():
""" """
Get aliases Get aliases
@ -72,6 +74,7 @@ def get_aliases():
@api_bp.route("/v2/aliases", methods=["GET", "POST"]) @api_bp.route("/v2/aliases", methods=["GET", "POST"])
@require_api_auth @require_api_auth
@limiter.limit("10/minute", key_func=lambda: g.user.id)
def get_aliases_v2(): def get_aliases_v2():
""" """
Get aliases Get aliases

View File

@ -63,6 +63,11 @@ def auth_login():
elif user.disabled: elif user.disabled:
LoginEvent(LoginEvent.ActionType.disabled_login, LoginEvent.Source.api).send() LoginEvent(LoginEvent.ActionType.disabled_login, LoginEvent.Source.api).send()
return jsonify(error="Account disabled"), 400 return jsonify(error="Account disabled"), 400
elif user.delete_on is not None:
LoginEvent(
LoginEvent.ActionType.scheduled_to_be_deleted, LoginEvent.Source.api
).send()
return jsonify(error="Account scheduled for deletion"), 400
elif not user.activated: elif not user.activated:
LoginEvent(LoginEvent.ActionType.not_activated, LoginEvent.Source.api).send() LoginEvent(LoginEvent.ActionType.not_activated, LoginEvent.Source.api).send()
return jsonify(error="Account not activated"), 422 return jsonify(error="Account not activated"), 422

View File

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

View File

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

View File

@ -17,3 +17,23 @@ from .views import (
recovery, recovery,
api_to_cookie, 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")) browser = MfaBrowser.get_by(token=request.cookies.get("mfa"))
if browser and not browser.is_expired() and browser.user_id == user.id: if browser and not browser.is_expired() and browser.user_id == user.id:
login_user(user) login_user(user)
flash(f"Welcome back!", "success") flash("Welcome back!", "success")
# Redirect user to correct page # Redirect user to correct page
return redirect(next_url or url_for("dashboard.index")) return redirect(next_url or url_for("dashboard.index"))
else: else:
@ -110,7 +110,7 @@ def fido():
session["sudo_time"] = int(time()) session["sudo_time"] = int(time())
login_user(user) login_user(user)
flash(f"Welcome back!", "success") flash("Welcome back!", "success")
# Redirect user to correct page # Redirect user to correct page
response = make_response(redirect(next_url or url_for("dashboard.index"))) response = make_response(redirect(next_url or url_for("dashboard.index")))

View File

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

View File

@ -55,7 +55,7 @@ def mfa():
browser = MfaBrowser.get_by(token=request.cookies.get("mfa")) browser = MfaBrowser.get_by(token=request.cookies.get("mfa"))
if browser and not browser.is_expired() and browser.user_id == user.id: if browser and not browser.is_expired() and browser.user_id == user.id:
login_user(user) login_user(user)
flash(f"Welcome back!", "success") flash("Welcome back!", "success")
# Redirect user to correct page # Redirect user to correct page
return redirect(next_url or url_for("dashboard.index")) return redirect(next_url or url_for("dashboard.index"))
else: else:
@ -73,7 +73,7 @@ def mfa():
Session.commit() Session.commit()
login_user(user) login_user(user)
flash(f"Welcome back!", "success") flash("Welcome back!", "success")
# Redirect user to correct page # Redirect user to correct page
response = make_response(redirect(next_url or url_for("dashboard.index"))) 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] del session[MFA_USER_ID]
login_user(user) login_user(user)
flash(f"Welcome back!", "success") flash("Welcome back!", "success")
recovery_code.used = True recovery_code.used = True
recovery_code.used_at = arrow.now() recovery_code.used_at = arrow.now()

View File

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

View File

@ -179,6 +179,7 @@ AWS_REGION = os.environ.get("AWS_REGION") or "eu-west-3"
BUCKET = os.environ.get("BUCKET") BUCKET = os.environ.get("BUCKET")
AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID") AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID")
AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY") AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY")
AWS_ENDPOINT_URL = os.environ.get("AWS_ENDPOINT_URL", None)
# Paddle # Paddle
try: try:
@ -534,3 +535,8 @@ SKIP_MX_LOOKUP_ON_CHECK = False
DISABLE_RATE_LIMIT = "DISABLE_RATE_LIMIT" in os.environ DISABLE_RATE_LIMIT = "DISABLE_RATE_LIMIT" in os.environ
SUBSCRIPTION_CHANGE_WEBHOOK = os.environ.get("SUBSCRIPTION_CHANGE_WEBHOOK", None) 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,3 +33,39 @@ from .views import (
notification, notification,
support, 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.dashboard.base import dashboard_bp
from app.db import Session from app.db import Session
from app.email_utils import ( from app.email_utils import (
is_valid_email,
generate_reply_email, generate_reply_email,
parse_full_address, parse_full_address,
) )
from app.email_validation import is_valid_email
from app.errors import ( from app.errors import (
CannotCreateContactForReverseAlias, CannotCreateContactForReverseAlias,
ErrContactErrorUpgradeNeeded, ErrContactErrorUpgradeNeeded,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -24,6 +24,7 @@ from app.models import (
AliasMailbox, AliasMailbox,
DomainDeletedAlias, DomainDeletedAlias,
) )
from app.utils import CSRFValidationForm
@dashboard_bp.route("/custom_alias", methods=["GET", "POST"]) @dashboard_bp.route("/custom_alias", methods=["GET", "POST"])
@ -48,9 +49,13 @@ def custom_alias():
at_least_a_premium_domain = True at_least_a_premium_domain = True
break break
csrf_form = CSRFValidationForm()
mailboxes = current_user.mailboxes() mailboxes = current_user.mailboxes()
if request.method == "POST": if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
alias_prefix = request.form.get("prefix").strip().lower().replace(" ", "") alias_prefix = request.form.get("prefix").strip().lower().replace(" ", "")
signed_alias_suffix = request.form.get("signed-alias-suffix") signed_alias_suffix = request.form.get("signed-alias-suffix")
mailbox_ids = request.form.getlist("mailboxes") mailbox_ids = request.form.getlist("mailboxes")
@ -164,4 +169,5 @@ def custom_alias():
alias_suffixes=alias_suffixes, alias_suffixes=alias_suffixes,
at_least_a_premium_domain=at_least_a_premium_domain, at_least_a_premium_domain=at_least_a_premium_domain,
mailboxes=mailboxes, mailboxes=mailboxes,
csrf_form=csrf_form,
) )

View File

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

View File

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

View File

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

View File

@ -19,8 +19,8 @@ from app.email_utils import (
mailbox_already_used, mailbox_already_used,
render, render,
send_email, send_email,
is_valid_email,
) )
from app.email_validation import is_valid_email
from app.log import LOG from app.log import LOG
from app.models import Mailbox, Job from app.models import Mailbox, Job
from app.utils import CSRFValidationForm from app.utils import CSRFValidationForm

View File

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

View File

@ -128,7 +128,6 @@ def setting():
new_email_valid = True new_email_valid = True
new_email = canonicalize_email(change_email_form.email.data) new_email = canonicalize_email(change_email_form.email.data)
if new_email != current_user.email and not pending_email: if new_email != current_user.email and not pending_email:
# check if this email is not already used # check if this email is not already used
if personal_email_already_used(new_email) or Alias.get_by( if personal_email_already_used(new_email) or Alias.get_by(
email=new_email email=new_email

View File

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

View File

@ -1 +1,3 @@
from .views import index, new_client, client_detail 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( flash(
f"Thanks for submitting, we are informed and will come back to you asap!", "Thanks for submitting, we are informed and will come back to you asap!",
"success", "success",
) )

View File

@ -1 +1,3 @@
from .views import index 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)]: def get_mx_domains(hostname) -> [(int, str)]:
"""return list of (priority, domain name). """return list of (priority, domain name) sorted by priority (lowest priority first)
domain name ends with a "." at the end. domain name ends with a "." at the end.
""" """
try: try:
@ -50,7 +50,7 @@ def get_mx_domains(hostname) -> [(int, str)]:
ret.append((int(parts[0]), parts[1])) ret.append((int(parts[0]), parts[1]))
return ret return sorted(ret, key=lambda prio_domain: prio_domain[0])
_include_spf = "include:" _include_spf = "include:"

View File

@ -93,7 +93,7 @@ def send_welcome_email(user):
send_email( send_email(
comm_email, comm_email,
f"Welcome to SimpleLogin", "Welcome to SimpleLogin",
render("com/welcome.txt", user=user, alias=alias), render("com/welcome.txt", user=user, alias=alias),
render("com/welcome.html", user=user, alias=alias), render("com/welcome.html", user=user, alias=alias),
unsubscribe_link, unsubscribe_link,
@ -104,7 +104,7 @@ def send_welcome_email(user):
def send_trial_end_soon_email(user): def send_trial_end_soon_email(user):
send_email( send_email(
user.email, user.email,
f"Your trial will end soon", "Your trial will end soon",
render("transactional/trial-end.txt.jinja2", user=user), render("transactional/trial-end.txt.jinja2", user=user),
render("transactional/trial-end.html", user=user), render("transactional/trial-end.html", user=user),
ignore_smtp_error=True, ignore_smtp_error=True,
@ -114,7 +114,7 @@ def send_trial_end_soon_email(user):
def send_activation_email(email, activation_link): def send_activation_email(email, activation_link):
send_email( send_email(
email, email,
f"Just one more step to join SimpleLogin", "Just one more step to join SimpleLogin",
render( render(
"transactional/activation.txt", "transactional/activation.txt",
activation_link=activation_link, activation_link=activation_link,
@ -768,7 +768,7 @@ def get_header_unicode(header: Union[str, Header]) -> str:
ret = "" ret = ""
for to_decoded_str, charset in decode_header(header): for to_decoded_str, charset in decode_header(header):
if charset is None: if charset is None:
if type(to_decoded_str) is bytes: if isinstance(to_decoded_str, bytes):
decoded_str = to_decoded_str.decode() decoded_str = to_decoded_str.decode()
else: else:
decoded_str = to_decoded_str decoded_str = to_decoded_str
@ -805,13 +805,13 @@ def to_bytes(msg: Message):
for generator_policy in [None, policy.SMTP, policy.SMTPUTF8]: for generator_policy in [None, policy.SMTP, policy.SMTPUTF8]:
try: try:
return msg.as_bytes(policy=generator_policy) return msg.as_bytes(policy=generator_policy)
except: except Exception:
LOG.w("as_bytes() fails with %s policy", policy, exc_info=True) LOG.w("as_bytes() fails with %s policy", policy, exc_info=True)
msg_string = msg.as_string() msg_string = msg.as_string()
try: try:
return msg_string.encode() return msg_string.encode()
except: except Exception:
LOG.w("as_string().encode() fails", exc_info=True) LOG.w("as_string().encode() fails", exc_info=True)
return msg_string.encode(errors="replace") return msg_string.encode(errors="replace")
@ -828,19 +828,6 @@ def should_add_dkim_signature(domain: str) -> bool:
return False return False
def is_valid_email(email_address: str) -> bool:
"""
Used to check whether an email address is valid
NOT run MX check.
NOT allow unicode.
"""
try:
validate_email(email_address, check_deliverability=False, allow_smtputf8=False)
return True
except EmailNotValidError:
return False
class EmailEncoding(enum.Enum): class EmailEncoding(enum.Enum):
BASE64 = "base64" BASE64 = "base64"
QUOTED = "quoted-printable" QUOTED = "quoted-printable"
@ -919,7 +906,7 @@ def add_header(msg: Message, text_header, html_header=None) -> Message:
if content_type == "text/plain": if content_type == "text/plain":
encoding = get_encoding(msg) encoding = get_encoding(msg)
payload = msg.get_payload() payload = msg.get_payload()
if type(payload) is str: if isinstance(payload, str):
clone_msg = copy(msg) clone_msg = copy(msg)
new_payload = f"""{text_header} new_payload = f"""{text_header}
------------------------------ ------------------------------
@ -929,7 +916,7 @@ def add_header(msg: Message, text_header, html_header=None) -> Message:
elif content_type == "text/html": elif content_type == "text/html":
encoding = get_encoding(msg) encoding = get_encoding(msg)
payload = msg.get_payload() payload = msg.get_payload()
if type(payload) is str: if isinstance(payload, str):
new_payload = f"""<table width="100%" style="width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0; new_payload = f"""<table width="100%" style="width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0;
-premailer-cellspacing: 0; margin: 0; padding: 0;"> -premailer-cellspacing: 0; margin: 0; padding: 0;">
<tr> <tr>
@ -951,6 +938,8 @@ def add_header(msg: Message, text_header, html_header=None) -> Message:
for part in msg.get_payload(): for part in msg.get_payload():
if isinstance(part, Message): if isinstance(part, Message):
new_parts.append(add_header(part, text_header, html_header)) new_parts.append(add_header(part, text_header, html_header))
elif isinstance(part, str):
new_parts.append(MIMEText(part))
else: else:
new_parts.append(part) new_parts.append(part)
clone_msg = copy(msg) clone_msg = copy(msg)
@ -959,7 +948,14 @@ def add_header(msg: Message, text_header, html_header=None) -> Message:
elif content_type in ("multipart/mixed", "multipart/signed"): elif content_type in ("multipart/mixed", "multipart/signed"):
new_parts = [] new_parts = []
parts = list(msg.get_payload()) payload = msg.get_payload()
if isinstance(payload, str):
# The message is badly formatted inject as new
new_parts = [MIMEText(text_header, "plain"), MIMEText(payload, "plain")]
clone_msg = copy(msg)
clone_msg.set_payload(new_parts)
return clone_msg
parts = list(payload)
LOG.d("only add header for the first part for %s", content_type) LOG.d("only add header for the first part for %s", content_type)
for ix, part in enumerate(parts): for ix, part in enumerate(parts):
if ix == 0: if ix == 0:
@ -976,7 +972,7 @@ def add_header(msg: Message, text_header, html_header=None) -> Message:
def replace(msg: Union[Message, str], old, new) -> Union[Message, str]: def replace(msg: Union[Message, str], old, new) -> Union[Message, str]:
if type(msg) is str: if isinstance(msg, str):
msg = msg.replace(old, new) msg = msg.replace(old, new)
return msg return msg
@ -999,7 +995,7 @@ def replace(msg: Union[Message, str], old, new) -> Union[Message, str]:
if content_type in ("text/plain", "text/html"): if content_type in ("text/plain", "text/html"):
encoding = get_encoding(msg) encoding = get_encoding(msg)
payload = msg.get_payload() payload = msg.get_payload()
if type(payload) is str: if isinstance(payload, str):
if encoding == EmailEncoding.QUOTED: if encoding == EmailEncoding.QUOTED:
LOG.d("handle quoted-printable replace %s -> %s", old, new) LOG.d("handle quoted-printable replace %s -> %s", old, new)
# first decode the payload # first decode the payload
@ -1107,26 +1103,6 @@ def is_reverse_alias(address: str) -> bool:
) )
# allow also + and @ that are present in a reply address
_ALLOWED_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.+@"
def normalize_reply_email(reply_email: str) -> str:
"""Handle the case where reply email contains *strange* char that was wrongly generated in the past"""
if not reply_email.isascii():
reply_email = convert_to_id(reply_email)
ret = []
# drop all control characters like shift, separator, etc
for c in reply_email:
if c not in _ALLOWED_CHARS:
ret.append("_")
else:
ret.append(c)
return "".join(ret)
def should_disable(alias: Alias) -> (bool, str): def should_disable(alias: Alias) -> (bool, str):
""" """
Return whether an alias should be disabled and if yes, the reason why Return whether an alias should be disabled and if yes, the reason why

View File

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

View File

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

View File

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

View File

@ -34,10 +34,10 @@ def apply_dmarc_policy_for_forward_phase(
from_header = get_header_unicode(msg[headers.FROM]) from_header = get_header_unicode(msg[headers.FROM])
warning_plain_text = f"""This email failed anti-phishing checks when it was received by SimpleLogin, be careful with its content. warning_plain_text = """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/ More info on https://simplelogin.io/docs/getting-started/anti-phishing/
""" """
warning_html = f""" warning_html = """
<p style="color:red"> <p style="color:red">
This email failed anti-phishing checks when it was received by SimpleLogin, be careful with its content. 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> 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 return True
if is_deleted_alias(msg_info.sender_address): if is_deleted_alias(msg_info.sender_address):
LOG.i(f"Complaint is for deleted alias. Do nothing") LOG.i("Complaint is for deleted alias. Do nothing")
return True return True
contact = Contact.get_by(reply_email=msg_info.sender_address) 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) alias = find_alias_with_address(msg_info.rcpt_address)
if is_deleted_alias(msg_info.rcpt_address): if is_deleted_alias(msg_info.rcpt_address):
LOG.i(f"Complaint is for deleted alias. Do nothing") LOG.i("Complaint is for deleted alias. Do nothing")
return True return True
if not alias: if not alias:

View File

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

View File

@ -1,4 +1,5 @@
import urllib import urllib
from email.header import Header
from email.message import Message from email.message import Message
from app.email import headers from app.email import headers
@ -33,6 +34,8 @@ class UnsubscribeGenerator:
if not unsubscribe_data: if not unsubscribe_data:
LOG.info("Email has no unsubscribe header") LOG.info("Email has no unsubscribe header")
return message return message
if isinstance(unsubscribe_data, Header):
unsubscribe_data = str(unsubscribe_data.encode())
raw_methods = [method.strip() for method in unsubscribe_data.split(",")] raw_methods = [method.strip() for method in unsubscribe_data.split(",")]
mailto_unsubs = None mailto_unsubs = None
other_unsubs = [] other_unsubs = []

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) LOG.d("Download file %s from %s", batch_import.file, file_url)
r = requests.get(file_url) r = requests.get(file_url)
lines = [line.decode() for line in r.iter_lines()] lines = [line.decode("utf-8") for line in r.iter_lines()]
import_from_csv(batch_import, user, lines) import_from_csv(batch_import, user, lines)

View File

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

View File

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

View File

@ -22,7 +22,6 @@ from app.message_utils import message_to_bytes, message_format_base64_parts
@dataclass @dataclass
class SendRequest: class SendRequest:
SAVE_EXTENSION = "sendrequest" SAVE_EXTENSION = "sendrequest"
envelope_from: str envelope_from: str
@ -46,6 +45,7 @@ class SendRequest:
"mail_options": self.mail_options, "mail_options": self.mail_options,
"rcpt_options": self.rcpt_options, "rcpt_options": self.rcpt_options,
"is_forward": self.is_forward, "is_forward": self.is_forward,
"retries": self.retries,
} }
return json.dumps(data).encode("utf-8") return json.dumps(data).encode("utf-8")
@ -66,6 +66,7 @@ class SendRequest:
mail_options=decoded_data["mail_options"], mail_options=decoded_data["mail_options"],
rcpt_options=decoded_data["rcpt_options"], rcpt_options=decoded_data["rcpt_options"],
is_forward=decoded_data["is_forward"], is_forward=decoded_data["is_forward"],
retries=decoded_data.get("retries", 1),
) )
def save_request_to_unsent_dir(self, prefix: str = "DeliveryFail"): def save_request_to_unsent_dir(self, prefix: str = "DeliveryFail"):

View File

@ -30,6 +30,8 @@ from sqlalchemy_utils import ArrowType
from app import config from app import config
from app import s3 from app import s3
from app.db import Session from app.db import Session
from app.dns_utils import get_mx_domains
from app.errors import ( from app.errors import (
AliasInTrashError, AliasInTrashError,
DirectoryInTrashError, DirectoryInTrashError,
@ -278,6 +280,7 @@ class IntEnumType(sa.types.TypeDecorator):
class AliasOptions: class AliasOptions:
show_sl_domains: bool = True show_sl_domains: bool = True
show_partner_domains: Optional[Partner] = None show_partner_domains: Optional[Partner] = None
show_partner_premium: Optional[bool] = None
class Hibp(Base, ModelMixin): class Hibp(Base, ModelMixin):
@ -341,7 +344,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
sa.Boolean, default=True, nullable=False, server_default="1" sa.Boolean, default=True, nullable=False, server_default="1"
) )
activated = sa.Column(sa.Boolean, default=False, nullable=False) activated = sa.Column(sa.Boolean, default=False, nullable=False, index=True)
# an account can be disabled if having harmful behavior # an account can be disabled if having harmful behavior
disabled = sa.Column(sa.Boolean, default=False, nullable=False, server_default="0") disabled = sa.Column(sa.Boolean, default=False, nullable=False, server_default="0")
@ -411,7 +414,10 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
) )
referral_id = sa.Column( referral_id = sa.Column(
sa.ForeignKey("referral.id", ondelete="SET NULL"), nullable=True, default=None sa.ForeignKey("referral.id", ondelete="SET NULL"),
nullable=True,
default=None,
index=True,
) )
referral = orm.relationship("Referral", foreign_keys=[referral_id]) referral = orm.relationship("Referral", foreign_keys=[referral_id])
@ -534,6 +540,16 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
nullable=False, nullable=False,
) )
# Trigger hard deletion of the account at this time
delete_on = sa.Column(ArrowType, default=None)
__table_args__ = (
sa.Index(
"ix_users_activated_trial_end_lifetime", activated, trial_end, lifetime
),
sa.Index("ix_users_delete_on", delete_on),
)
@property @property
def directory_quota(self): def directory_quota(self):
return min( return min(
@ -568,6 +584,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
@classmethod @classmethod
def create(cls, email, name="", password=None, from_partner=False, **kwargs): def create(cls, email, name="", password=None, from_partner=False, **kwargs):
email = sanitize_email(email)
user: User = super(User, cls).create(email=email, name=name[:100], **kwargs) user: User = super(User, cls).create(email=email, name=name[:100], **kwargs)
if password: if password:
@ -821,6 +838,17 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
< self.max_alias_for_free_account() < self.max_alias_for_free_account()
) )
def can_send_or_receive(self) -> bool:
if self.disabled:
LOG.i(f"User {self} is disabled. Cannot receive or send emails")
return False
if self.delete_on is not None:
LOG.i(
f"User {self} is scheduled to be deleted. Cannot receive or send emails"
)
return False
return True
def profile_picture_url(self): def profile_picture_url(self):
if self.profile_picture_id: if self.profile_picture_id:
return self.profile_picture.get_url() return self.profile_picture.get_url()
@ -1011,29 +1039,35 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
) -> list["SLDomain"]: ) -> list["SLDomain"]:
if alias_options is None: if alias_options is None:
alias_options = AliasOptions() alias_options = AliasOptions()
conditions = [SLDomain.hidden == False] # noqa: E712 top_conds = [SLDomain.hidden == False] # noqa: E712
if not self.is_premium(): or_conds = [] # noqa:E711
conditions.append(SLDomain.premium_only == False) # noqa: E712
partner_domain_cond = [] # noqa:E711
if self.default_alias_public_domain_id is not None: if self.default_alias_public_domain_id is not None:
partner_domain_cond.append( default_domain_conds = [SLDomain.id == self.default_alias_public_domain_id]
SLDomain.id == self.default_alias_public_domain_id if not self.is_premium():
) default_domain_conds.append(
SLDomain.premium_only == False # noqa: E712
)
or_conds.append(and_(*default_domain_conds).self_group())
if alias_options.show_partner_domains is not None: if alias_options.show_partner_domains is not None:
partner_user = PartnerUser.filter_by( partner_user = PartnerUser.filter_by(
user_id=self.id, partner_id=alias_options.show_partner_domains.id user_id=self.id, partner_id=alias_options.show_partner_domains.id
).first() ).first()
if partner_user is not None: if partner_user is not None:
partner_domain_cond.append( partner_domain_cond = [SLDomain.partner_id == partner_user.partner_id]
SLDomain.partner_id == partner_user.partner_id if alias_options.show_partner_premium is None:
) alias_options.show_partner_premium = self.is_premium()
if not alias_options.show_partner_premium:
partner_domain_cond.append(
SLDomain.premium_only == False # noqa: E712
)
or_conds.append(and_(*partner_domain_cond).self_group())
if alias_options.show_sl_domains: if alias_options.show_sl_domains:
partner_domain_cond.append(SLDomain.partner_id == None) # noqa:E711 sl_conds = [SLDomain.partner_id == None] # noqa: E711
if len(partner_domain_cond) == 1: if not self.is_premium():
conditions.append(partner_domain_cond[0]) sl_conds.append(SLDomain.premium_only == False) # noqa: E712
else: or_conds.append(and_(*sl_conds).self_group())
conditions.append(or_(*partner_domain_cond)) top_conds.append(or_(*or_conds))
query = Session.query(SLDomain).filter(*conditions).order_by(SLDomain.order) query = Session.query(SLDomain).filter(*top_conds).order_by(SLDomain.order)
return query.all() return query.all()
def available_alias_domains( def available_alias_domains(
@ -1445,7 +1479,7 @@ class Alias(Base, ModelMixin):
) )
# have I been pwned # have I been pwned
hibp_last_check = sa.Column(ArrowType, default=None) hibp_last_check = sa.Column(ArrowType, default=None, index=True)
hibp_breaches = orm.relationship("Hibp", secondary="alias_hibp") hibp_breaches = orm.relationship("Hibp", secondary="alias_hibp")
# to use Postgres full text search. Only applied on "note" column for now # to use Postgres full text search. Only applied on "note" column for now
@ -1913,6 +1947,7 @@ class Contact(Base, ModelMixin):
class EmailLog(Base, ModelMixin): class EmailLog(Base, ModelMixin):
__tablename__ = "email_log" __tablename__ = "email_log"
__table_args__ = (Index("ix_email_log_created_at", "created_at"),)
user_id = sa.Column( user_id = sa.Column(
sa.ForeignKey(User.id, ondelete="cascade"), nullable=False, index=True sa.ForeignKey(User.id, ondelete="cascade"), nullable=False, index=True
@ -2291,6 +2326,7 @@ class CustomDomain(Base, ModelMixin):
@classmethod @classmethod
def create(cls, **kwargs): def create(cls, **kwargs):
domain = kwargs.get("domain") domain = kwargs.get("domain")
kwargs["domain"] = domain.replace("\n", "")
if DeletedSubdomain.get_by(domain=domain): if DeletedSubdomain.get_by(domain=domain):
raise SubdomainInTrashError raise SubdomainInTrashError
@ -2558,6 +2594,28 @@ class Mailbox(Base, ModelMixin):
+ Alias.filter_by(mailbox_id=self.id).count() + Alias.filter_by(mailbox_id=self.id).count()
) )
def is_proton(self) -> bool:
if (
self.email.endswith("@proton.me")
or self.email.endswith("@protonmail.com")
or self.email.endswith("@protonmail.ch")
or self.email.endswith("@proton.ch")
or self.email.endswith("@pm.me")
):
return True
from app.email_utils import get_email_local_part
mx_domains: [(int, str)] = get_mx_domains(get_email_local_part(self.email))
# Proton is the first domain
if mx_domains and mx_domains[0][1] in (
"mail.protonmail.ch.",
"mailsec.protonmail.ch.",
):
return True
return False
@classmethod @classmethod
def delete(cls, obj_id): def delete(cls, obj_id):
mailbox: Mailbox = cls.get(obj_id) mailbox: Mailbox = cls.get(obj_id)
@ -2590,6 +2648,12 @@ class Mailbox(Base, ModelMixin):
return ret return ret
@classmethod
def create(cls, **kw):
if "email" in kw:
kw["email"] = sanitize_email(kw["email"])
return super().create(**kw)
def __repr__(self): def __repr__(self):
return f"<Mailbox {self.id} {self.email}>" return f"<Mailbox {self.id} {self.email}>"
@ -2928,6 +2992,8 @@ class Monitoring(Base, ModelMixin):
active_queue = sa.Column(sa.Integer, nullable=False) active_queue = sa.Column(sa.Integer, nullable=False)
deferred_queue = sa.Column(sa.Integer, nullable=False) deferred_queue = sa.Column(sa.Integer, nullable=False)
__table_args__ = (Index("ix_monitoring_created_at", "created_at"),)
class BatchImport(Base, ModelMixin): class BatchImport(Base, ModelMixin):
__tablename__ = "batch_import" __tablename__ = "batch_import"
@ -3053,6 +3119,8 @@ class Bounce(Base, ModelMixin):
email = sa.Column(sa.String(256), nullable=False, index=True) email = sa.Column(sa.String(256), nullable=False, index=True)
info = sa.Column(sa.Text, nullable=True) info = sa.Column(sa.Text, nullable=True)
__table_args__ = (sa.Index("ix_bounce_created_at", "created_at"),)
class TransactionalEmail(Base, ModelMixin): class TransactionalEmail(Base, ModelMixin):
"""Storing all email addresses that receive transactional emails, including account email and mailboxes. """Storing all email addresses that receive transactional emails, including account email and mailboxes.
@ -3062,6 +3130,8 @@ class TransactionalEmail(Base, ModelMixin):
__tablename__ = "transactional_email" __tablename__ = "transactional_email"
email = sa.Column(sa.String(256), nullable=False, unique=False) email = sa.Column(sa.String(256), nullable=False, unique=False)
__table_args__ = (sa.Index("ix_transactional_email_created_at", "created_at"),)
class Payout(Base, ModelMixin): class Payout(Base, ModelMixin):
"""Referral payouts""" """Referral payouts"""
@ -3114,7 +3184,7 @@ class MessageIDMatching(Base, ModelMixin):
# to track what email_log that has created this matching # to track what email_log that has created this matching
email_log_id = sa.Column( email_log_id = sa.Column(
sa.ForeignKey("email_log.id", ondelete="cascade"), nullable=True sa.ForeignKey("email_log.id", ondelete="cascade"), nullable=True, index=True
) )
email_log = orm.relationship("EmailLog") email_log = orm.relationship("EmailLog")
@ -3447,7 +3517,7 @@ class PartnerSubscription(Base, ModelMixin):
class Newsletter(Base, ModelMixin): class Newsletter(Base, ModelMixin):
__tablename__ = "newsletter" __tablename__ = "newsletter"
subject = sa.Column(sa.String(), nullable=False, unique=True, index=True) subject = sa.Column(sa.String(), nullable=False, index=True)
html = sa.Column(sa.Text) html = sa.Column(sa.Text)
plain_text = sa.Column(sa.Text) plain_text = sa.Column(sa.Text)

View File

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

View File

@ -1 +1,3 @@
from .views import authorize, token, user_info 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 - the response_type/scope passed as a list ?scope=scope_1&scope=scope_2
""" """
res = set() res = set()
if type(arg_input) is str: if isinstance(arg_input, str):
if " " in arg_input: if " " in arg_input:
for x in arg_input.split(" "): for x in arg_input.split(" "):
if x: if x:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,17 +13,29 @@ from app.config import (
LOCAL_FILE_UPLOAD, LOCAL_FILE_UPLOAD,
UPLOAD_DIR, UPLOAD_DIR,
URL, URL,
AWS_ENDPOINT_URL,
) )
from app.log import LOG
if not LOCAL_FILE_UPLOAD:
_session = boto3.Session(
aws_access_key_id=AWS_ACCESS_KEY_ID,
aws_secret_access_key=AWS_SECRET_ACCESS_KEY,
region_name=AWS_REGION,
)
def upload_from_bytesio(key: str, bs: BytesIO, content_type="string"): _s3_client = None
def _get_s3client():
global _s3_client
if _s3_client is None:
args = {
"aws_access_key_id": AWS_ACCESS_KEY_ID,
"aws_secret_access_key": AWS_SECRET_ACCESS_KEY,
"region_name": AWS_REGION,
}
if AWS_ENDPOINT_URL:
args["endpoint_url"] = AWS_ENDPOINT_URL
_s3_client = boto3.client("s3", **args)
return _s3_client
def upload_from_bytesio(key: str, bs: BytesIO, content_type="application/octet-stream"):
bs.seek(0) bs.seek(0)
if LOCAL_FILE_UPLOAD: if LOCAL_FILE_UPLOAD:
@ -34,7 +46,8 @@ def upload_from_bytesio(key: str, bs: BytesIO, content_type="string"):
f.write(bs.read()) f.write(bs.read())
else: else:
_session.resource("s3").Bucket(BUCKET).put_object( _get_s3client().put_object(
Bucket=BUCKET,
Key=key, Key=key,
Body=bs, Body=bs,
ContentType=content_type, ContentType=content_type,
@ -52,7 +65,8 @@ def upload_email_from_bytesio(path: str, bs: BytesIO, filename):
f.write(bs.read()) f.write(bs.read())
else: else:
_session.resource("s3").Bucket(BUCKET).put_object( _get_s3client().put_object(
Bucket=BUCKET,
Key=path, Key=path,
Body=bs, Body=bs,
# Support saving a remote file using Http header # Support saving a remote file using Http header
@ -67,12 +81,9 @@ def download_email(path: str) -> Optional[str]:
file_path = os.path.join(UPLOAD_DIR, path) file_path = os.path.join(UPLOAD_DIR, path)
with open(file_path, "rb") as f: with open(file_path, "rb") as f:
return f.read() return f.read()
resp = ( resp = _get_s3client().get_object(
_session.resource("s3") Bucket=BUCKET,
.Bucket(BUCKET) Key=path,
.get_object(
Key=path,
)
) )
if not resp or "Body" not in resp: if not resp or "Body" not in resp:
return None return None
@ -88,8 +99,7 @@ def get_url(key: str, expires_in=3600) -> str:
if LOCAL_FILE_UPLOAD: if LOCAL_FILE_UPLOAD:
return URL + "/static/upload/" + key return URL + "/static/upload/" + key
else: else:
s3_client = _session.client("s3") return _get_s3client().generate_presigned_url(
return s3_client.generate_presigned_url(
ExpiresIn=expires_in, ExpiresIn=expires_in,
ClientMethod="get_object", ClientMethod="get_object",
Params={"Bucket": BUCKET, "Key": key}, Params={"Bucket": BUCKET, "Key": key},
@ -100,5 +110,15 @@ def delete(path: str):
if LOCAL_FILE_UPLOAD: if LOCAL_FILE_UPLOAD:
os.remove(os.path.join(UPLOAD_DIR, path)) os.remove(os.path.join(UPLOAD_DIR, path))
else: else:
o = _session.resource("s3").Bucket(BUCKET).Object(path) _get_s3client().delete_object(Bucket=BUCKET, Key=path)
o.delete()
def create_bucket_if_not_exists():
s3client = _get_s3client()
buckets = s3client.list_buckets()
for bucket in buckets["Buckets"]:
if bucket["Name"] == BUCKET:
LOG.i("Bucket already exists")
return
s3client.create_bucket(Bucket=BUCKET)
LOG.i(f"Bucket {BUCKET} created")

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -106,8 +106,6 @@ from app.email_utils import (
get_header_unicode, get_header_unicode,
generate_reply_email, generate_reply_email,
is_reverse_alias, is_reverse_alias,
normalize_reply_email,
is_valid_email,
replace, replace,
should_disable, should_disable,
parse_id_from_bounce, parse_id_from_bounce,
@ -123,6 +121,7 @@ from app.email_utils import (
generate_verp_email, generate_verp_email,
sl_formataddr, sl_formataddr,
) )
from app.email_validation import is_valid_email, normalize_reply_email
from app.errors import ( from app.errors import (
NonReverseAliasInReplyPhase, NonReverseAliasInReplyPhase,
VERPTransactional, VERPTransactional,
@ -236,7 +235,6 @@ def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Con
contact.mail_from = mail_from contact.mail_from = mail_from
Session.commit() Session.commit()
else: else:
try: try:
contact = Contact.create( contact = Contact.create(
user_id=alias.user_id, user_id=alias.user_id,
@ -262,7 +260,7 @@ def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Con
Session.commit() Session.commit()
except IntegrityError: except IntegrityError:
LOG.w("Contact %s %s already exist", alias, contact_email) LOG.w(f"Contact with email {contact_email} for alias {alias} already exist")
Session.rollback() Session.rollback()
contact = Contact.get_by(alias_id=alias.id, website_email=contact_email) contact = Contact.get_by(alias_id=alias.id, website_email=contact_email)
@ -280,6 +278,9 @@ def get_or_create_reply_to_contact(
except ValueError: except ValueError:
return return
if len(contact_name) >= Contact.MAX_NAME_LENGTH:
contact_name = contact_name[0 : Contact.MAX_NAME_LENGTH]
if not is_valid_email(contact_address): if not is_valid_email(contact_address):
LOG.w( LOG.w(
"invalid reply-to address %s. Parse from %s", "invalid reply-to address %s. Parse from %s",
@ -348,6 +349,10 @@ def replace_header_when_forward(msg: Message, alias: Alias, header: str):
continue continue
contact = Contact.get_by(alias_id=alias.id, website_email=contact_email) contact = Contact.get_by(alias_id=alias.id, website_email=contact_email)
contact_name = full_address.display_name
if len(contact_name) >= Contact.MAX_NAME_LENGTH:
contact_name = contact_name[0 : Contact.MAX_NAME_LENGTH]
if contact: if contact:
# update the contact name if needed # update the contact name if needed
if contact.name != full_address.display_name: if contact.name != full_address.display_name:
@ -355,9 +360,9 @@ def replace_header_when_forward(msg: Message, alias: Alias, header: str):
"Update contact %s name %s to %s", "Update contact %s name %s to %s",
contact, contact,
contact.name, contact.name,
full_address.display_name, contact_name,
) )
contact.name = full_address.display_name contact.name = contact_name
Session.commit() Session.commit()
else: else:
LOG.d( LOG.d(
@ -372,7 +377,7 @@ def replace_header_when_forward(msg: Message, alias: Alias, header: str):
user_id=alias.user_id, user_id=alias.user_id,
alias_id=alias.id, alias_id=alias.id,
website_email=contact_email, website_email=contact_email,
name=full_address.display_name, name=contact_name,
reply_email=generate_reply_email(contact_email, alias), reply_email=generate_reply_email(contact_email, alias),
is_cc=header.lower() == "cc", is_cc=header.lower() == "cc",
automatic_created=True, automatic_created=True,
@ -541,12 +546,20 @@ def sign_msg(msg: Message) -> Message:
signature.add_header("Content-Disposition", 'attachment; filename="signature.asc"') signature.add_header("Content-Disposition", 'attachment; filename="signature.asc"')
try: try:
signature.set_payload(sign_data(message_to_bytes(msg).replace(b"\n", b"\r\n"))) payload = sign_data(message_to_bytes(msg).replace(b"\n", b"\r\n"))
if not payload:
raise PGPException("Empty signature by gnupg")
signature.set_payload(payload)
except Exception: except Exception:
LOG.e("Cannot sign, try using pgpy") LOG.e("Cannot sign, try using pgpy")
signature.set_payload( payload = sign_data_with_pgpy(message_to_bytes(msg).replace(b"\n", b"\r\n"))
sign_data_with_pgpy(message_to_bytes(msg).replace(b"\n", b"\r\n"))
) if not payload:
raise PGPException("Empty signature by pgpy")
signature.set_payload(payload)
container.attach(signature) container.attach(signature)
@ -623,8 +636,8 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str
user = alias.user user = alias.user
if user.disabled: if not user.can_send_or_receive():
LOG.w("User %s disabled, disable forwarding emails for %s", user, alias) LOG.i(f"User {user} cannot receive emails")
if should_ignore_bounce(envelope.mail_from): if should_ignore_bounce(envelope.mail_from):
return [(True, status.E207)] return [(True, status.E207)]
else: else:
@ -864,21 +877,22 @@ def forward_email_to_mailbox(
headers_to_keep.append(headers.AUTHENTICATION_RESULTS) headers_to_keep.append(headers.AUTHENTICATION_RESULTS)
delete_all_headers_except(msg, headers_to_keep) 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""",
)
# create PGP email if needed # create PGP email if needed
if mailbox.pgp_enabled() and user.is_premium() and not alias.disable_pgp: if mailbox.pgp_enabled() and user.is_premium() and not alias.disable_pgp:
LOG.d("Encrypt message using mailbox %s", mailbox) 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: try:
msg = prepare_pgp_message( msg = prepare_pgp_message(
@ -899,7 +913,11 @@ def forward_email_to_mailbox(
msg[headers.SL_EMAIL_LOG_ID] = str(email_log.id) msg[headers.SL_EMAIL_LOG_ID] = str(email_log.id)
if user.include_header_email_header: if user.include_header_email_header:
msg[headers.SL_ENVELOPE_FROM] = envelope.mail_from msg[headers.SL_ENVELOPE_FROM] = envelope.mail_from
msg[headers.SL_ORIGINAL_FROM] = contact.website_email if contact.name:
original_from = f"{contact.name} <{contact.website_email}>"
else:
original_from = contact.website_email
msg[headers.SL_ORIGINAL_FROM] = original_from
# when an alias isn't in the To: header, there's no way for users to know what alias has received the email # when an alias isn't in the To: header, there's no way for users to know what alias has received the email
msg[headers.SL_ENVELOPE_TO] = alias.email msg[headers.SL_ENVELOPE_TO] = alias.email
@ -1051,13 +1069,8 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str):
user = alias.user user = alias.user
mail_from = envelope.mail_from mail_from = envelope.mail_from
if user.disabled: if not user.can_send_or_receive():
LOG.e( LOG.i(f"User {user} cannot send emails")
"User %s disabled, disable sending emails from %s to %s",
user,
alias,
contact,
)
return False, status.E504 return False, status.E504
# Check if we need to reject or quarantine based on dmarc # Check if we need to reject or quarantine based on dmarc
@ -1183,7 +1196,7 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str):
) )
# replace reverse alias by real address for all contacts # 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 Contact.reply_email, Contact.website_email
): ):
msg = replace(msg, reply_email, website_email) msg = replace(msg, reply_email, website_email)
@ -1238,7 +1251,6 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str):
if str(msg[headers.TO]).lower() == "undisclosed-recipients:;": if str(msg[headers.TO]).lower() == "undisclosed-recipients:;":
# no need to replace TO header # no need to replace TO header
LOG.d("email is sent in BCC mode") LOG.d("email is sent in BCC mode")
del msg[headers.TO]
else: else:
replace_header_when_reply(msg, alias, headers.TO) replace_header_when_reply(msg, alias, headers.TO)
@ -1939,7 +1951,7 @@ def handle_bounce(envelope, email_log: EmailLog, msg: Message) -> str:
for is_delivered, smtp_status in handle_forward(envelope, msg, alias.email): for is_delivered, smtp_status in handle_forward(envelope, msg, alias.email):
res.append((is_delivered, smtp_status)) 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 # Consider all deliveries successful if 1 delivery is successful
if is_success: if is_success:
return smtp_status return smtp_status
@ -2259,7 +2271,7 @@ def handle(envelope: Envelope, msg: Message) -> str:
if nb_success > 0 and nb_non_success > 0: if nb_success > 0 and nb_non_success > 0:
LOG.e(f"some deliveries fail and some success, {mail_from}, {rcpt_tos}, {res}") 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 # Consider all deliveries successful if 1 delivery is successful
if is_success: if is_success:
return smtp_status return smtp_status

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

View File

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

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

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

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

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

View File

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

502
app/poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -407,8 +407,10 @@ def jinja2_filter(app):
@app.context_processor @app.context_processor
def inject_stage_and_region(): def inject_stage_and_region():
now = arrow.now()
return dict( return dict(
YEAR=arrow.now().year, YEAR=now.year,
NOW=now,
URL=URL, URL=URL,
SENTRY_DSN=SENTRY_FRONT_END_DSN, SENTRY_DSN=SENTRY_FRONT_END_DSN,
VERSION=SHA1, VERSION=SHA1,
@ -641,7 +643,7 @@ def setup_paddle_callback(app: Flask):
@app.route("/paddle_coupon", methods=["GET", "POST"]) @app.route("/paddle_coupon", methods=["GET", "POST"])
def paddle_coupon(): def paddle_coupon():
LOG.d(f"paddle coupon callback %s", request.form) LOG.d("paddle coupon callback %s", request.form)
if not paddle_utils.verify_incoming_request(dict(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)) LOG.e("request not coming from paddle. Request data:%s", dict(request.form))

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 38 KiB

View File

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

View File

@ -86,6 +86,12 @@
</head> </head>
<body> <body>
<div class="page"> <div class="page">
{% if NOW.timestamp < 1701475201 and 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 %} {% block announcement %}{% endblock %}
<div class="container"> <div class="container">
<!-- For flash messages --> <!-- For flash messages -->

View File

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

View File

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

View File

@ -93,6 +93,7 @@
</div> </div>
<div class="row"> <div class="row">
<div class="col p-1"> <div class="col p-1">
{{ csrf_form.csrf_token }}
<button type="submit" id="create" class="btn btn-primary mt-1">Create</button> <button type="submit" id="create" class="btn btn-primary mt-1">Create</button>
</div> </div>
</div> </div>

View File

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

View File

@ -71,177 +71,181 @@
</form> </form>
</div> </div>
<!-- END Change email --> <!-- END Change email -->
{% if mailbox.pgp_finger_print and not mailbox.disable_pgp and current_user.include_sender_in_reverse_alias %} <!-- Not show PGP option for Proton mailbox -->
{% if mailbox.is_proton() and not mailbox.pgp_enabled() %}
<div class="alert alert-info"> <div class="alert alert-info">
Email headers like <span class="italic">From, To, Subject</span> aren't encrypted by PGP. 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.
Currently, your reverse alias includes the sender address. <br>
You can disable this on <a href="/dashboard/setting#sender-in-ra">Settings</a>. The PGP option on SimpleLogin is instead useful for when your mailbox provider isn't encrypted by default like Gmail, Outlook, etc.
</div> </div>
{% endif %} {% endif %}
<div class="card"> <div class="{% if mailbox.is_proton() and not mailbox.pgp_enabled() %}
<div class="card-body"> disabled-content{% endif %}">
<div class="card-title"> {% 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="d-flex">
Pretty Good Privacy (PGP) <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>
{% if mailbox.pgp_finger_print %} {% if mailbox.pgp_finger_print %}
<form method="post"> <button class="btn btn-danger float-right" name="action" value="remove">Remove</button>
{{ 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 %} {% endif %}
</div> </form>
<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> </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> </div>
<div class="card" {% if not mailbox.pgp_enabled() %} <div class="card" id="generic-subject">
disabled {% endif %}> <form method="post" action="#generic-subject">
<form method="post">
{{ csrf_form.csrf_token }} {{ csrf_form.csrf_token }}
<input type="hidden" name="form-name" value="generic-subject"> <input type="hidden" name="form-name" value="generic-subject">
<div class="card-body"> <div class="card-body">
<div class="card-title"> <div class="card-title">
Hide email subject when PGP is enabled Hide email subject
<div class="small-text mt-1"> <div class="small-text mt-1">
When PGP is enabled, you can choose to use a <b>generic</b> subject for the forwarded emails. The original subject will be added to the email body and all forwarded emails will have the generic subject.
The original subject is then added into the email body.
<br /> <br />
As PGP does not encrypt the email subject and the email subject might contain sensitive information, This option is often used when PGP is enabled.
this option will allow a further protection of your email content. As PGP does not encrypt the email subject, it allows a further protection of your email content.
</div> </div>
</div> </div>
<div class="alert alert-info">
As the email is encrypted, a subject like "Email for you"
will probably be rejected by your mailbox since it sounds like a spam.
<br />
Something like "Encrypted Email" would work much better :).
</div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Generic Subject</label> <label class="form-label">Generic Subject</label>
<input name="generic-subject" {% if not mailbox.pgp_enabled() %} <input name="generic-subject"
disabled {% endif %} class="form-control" maxlength="78" placeholder="Generic Subject" value="{{ mailbox.generic_subject or "" }}"> class="form-control"
</div> maxlength="78"
<button class="btn btn-primary" name="action" {% if not mailbox.pgp_enabled() %} placeholder="Generic Subject"
disabled {% endif %} value="save"> value="{{ mailbox.generic_subject or "" }}">
Save </div>
</button> <button class="btn btn-primary" name="action" value="save">Save</button>
{% if mailbox.generic_subject %} {% if mailbox.generic_subject %}
<button class="btn btn-danger float-right" name="action" value="remove">Remove</button> <button class="btn btn-danger float-right" name="action" value="remove">Remove</button>
{% endif %} {% 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>
</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> </div>
</form> </form>
</div> </div>
<hr /> {% endif %}
<h2 class="h4">Advanced Options</h2> <div class="card" id="authorized-address">
{% if spf_available %} <div class="card-body">
<div class="card-title">
<div class="card" id="spf"> Authorized addresses
<form method="post"> <div class="small-text">
{{ csrf_form.csrf_token }} Emails sent from these addresses to a <b>reverse-alias</b> are considered as being sent
<input type="hidden" name="form-name" value="force-spf"> from {{ mailbox.email }}
<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>
{% 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> </div>
{% if mailbox.authorized_addresses | length == 0 %}
{% 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>
</div> </div>
{% if mailbox.authorized_addresses | length == 0 %}
{% 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>
</div> </div>
</div> </div>
</div> </div>
{% endblock %} </div>
{% block script %} {% endblock %}
<script src="/static/js/utils/drag-drop-into-text.js"></script> {% block script %}
<script> <script src="/static/js/utils/drag-drop-into-text.js"></script>
<script>
$(".custom-switch-input").change(function (e) { $(".custom-switch-input").change(function (e) {
$(this).closest("form").submit(); $(this).closest("form").submit();
}); });
enableDragDropForPGPKeys('#pgp-public-key'); enableDragDropForPGPKeys('#pgp-public-key');
</script> </script>
{% endblock %} {% endblock %}

View File

@ -57,6 +57,22 @@
{% endblock %} {% endblock %}
{% block default_content %} {% block default_content %}
{% if NOW.timestamp < 1701475201 %}
<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>
{% endif %}
<div class="pb-8"> <div class="pb-8">
<div class="text-center mx-md-auto mb-8 mt-6"> <div class="text-center mx-md-auto mb-8 mt-6">
<h1>Upgrade to unlock premium features</h1> <h1>Upgrade to unlock premium features</h1>
@ -207,7 +223,7 @@
<div class="card-body"> <div class="card-body">
<div class="text-center"> <div class="text-center">
<div class="h3">Proton plan</div> <div class="h3">Proton plan</div>
<div class="h3 my-3">Starts at $11.99 / month</div> <div class="h3 my-3">Starts at $12.99 / month</div>
<div class="text-center mt-4 mb-6"> <div class="text-center mt-4 mb-6">
<a class="btn btn-lg btn-outline-primary w-100" <a class="btn btn-lg btn-outline-primary w-100"
role="button" role="button"
@ -225,10 +241,6 @@
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i> <i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
500 GB storage 500 GB storage
</li> </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"> <li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i> <i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
Unlimited folders, labels, and filters Unlimited folders, labels, and filters
@ -239,11 +251,7 @@
</li> </li>
<li class="d-flex"> <li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i> <i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
15 email addresses 25 calendars
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
20 Calendars
</li> </li>
<li class="d-flex"> <li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i> <i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
@ -376,10 +384,6 @@
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i> <i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
500 GB storage 500 GB storage
</li> </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"> <li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i> <i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
Unlimited folders, labels, and filters Unlimited folders, labels, and filters
@ -390,11 +394,7 @@
</li> </li>
<li class="d-flex"> <li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i> <i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
15 email addresses/aliases 25 calendars
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
20 Calendars
</li> </li>
<li class="d-flex"> <li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i> <i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
@ -478,7 +478,7 @@
</a>, which currently supports Bitcoin, Bitcoin Cash, DAI, ApeCoin, Dogecoin, Ethereum, Litecoin, SHIBA INU, Tether and USD Coin. </a>, which currently supports Bitcoin, Bitcoin Cash, DAI, ApeCoin, Dogecoin, Ethereum, Litecoin, SHIBA INU, Tether and USD Coin.
</p> </p>
<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">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" target="_blank">support@simplelogin.zendesk.com</a> if you want to use this cryptocurrency.
</p> </p>
<div class="d-flex justify-content-center"> <div class="d-flex justify-content-center">
<a class="btn btn-outline-primary text-center" <a class="btn btn-outline-primary text-center"
@ -645,7 +645,7 @@
</li> </li>
</ul> </ul>
<p> <p>
Please send us an email at <a href="mailto:support@simplelogin.zendesk.com">support@simplelogin.zendesk.com</a> for more info. Please send us an email at <a href="mailto:support@simplelogin.zendesk.com" target="_blank">support@simplelogin.zendesk.com</a> for more info.
</p> </p>
<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. 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.
@ -708,7 +708,7 @@
data-parent="#pricing-faq"> data-parent="#pricing-faq">
<div class="card-body"> <div class="card-body">
<p> <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">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" target="_blank">support@simplelogin.zendesk.com</a> for more information.
</p> </p>
</div> </div>
</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 :). For every user who <b>upgrades</b> and stays with us at least 3 months, you'll get $5 :).
<br /> <br />
The payout can be initiated any time, just send us an email at The payout can be initiated any time, just send us an email at
<a href="mailto:hi@simplelogin.io">hi@simplelogin.io</a> <a href="mailto:hi@simplelogin.io" target="_blank">hi@simplelogin.io</a>
when you want to receive the payout. when you want to receive the payout.
</div> </div>
{% if referrals|length == 0 %} {% if referrals|length == 0 %}

View File

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

View File

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

View File

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

View File

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

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