Compare commits

..

8 Commits

Author SHA1 Message Date
dae6f64482 4.47.2
All checks were successful
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m33s
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m39s
Build-Release-Image / Merge-Images (push) Successful in 12s
Build-Release-Image / Create-Release (push) Successful in 8s
Build-Release-Image / Notify (push) Successful in 5s
2024-07-30 12:00:06 +01:00
e7f0f81d85 4.46.4
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 2m53s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m13s
Build-Release-Image / Merge-Images (push) Successful in 12s
Build-Release-Image / Create-Release (push) Successful in 9s
Build-Release-Image / Notify (push) Successful in 2s
2024-07-13 12:00:06 +01:00
e82190f227 4.46.3
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 2m51s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m20s
Build-Release-Image / Merge-Images (push) Successful in 13s
Build-Release-Image / Create-Release (push) Successful in 9s
Build-Release-Image / Notify (push) Successful in 4s
2024-07-12 12:00:06 +01:00
9002bbad09 4.46.2
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m7s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 4m35s
Build-Release-Image / Merge-Images (push) Successful in 21s
Build-Release-Image / Create-Release (push) Successful in 9s
Build-Release-Image / Notify (push) Successful in 3s
2024-07-11 12:00:06 +01:00
f51d31f431 4.46.0
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m41s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 4m54s
Build-Release-Image / Merge-Images (push) Successful in 19s
Build-Release-Image / Create-Release (push) Successful in 16s
Build-Release-Image / Notify (push) Successful in 19s
2024-07-09 12:00:06 +01:00
c67b97fe32 4.45.1
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m52s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 4m0s
Build-Release-Image / Merge-Images (push) Successful in 18s
Build-Release-Image / Create-Release (push) Successful in 14s
Build-Release-Image / Notify (push) Successful in 8s
2024-06-26 12:00:08 +01:00
bd414b1fc7 4.45.0
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m1s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 4m13s
Build-Release-Image / Merge-Images (push) Successful in 22s
Build-Release-Image / Create-Release (push) Successful in 9s
Build-Release-Image / Notify (push) Successful in 4s
2024-06-11 12:00:06 +01:00
0f73a14926 4.44.3 2024-05-24 12:00:06 +01:00
78 changed files with 2312 additions and 845 deletions

View File

@ -14,4 +14,4 @@ venv/
.venv .venv
.coverage .coverage
htmlcov htmlcov
.git/ .git/

3
app/.gitignore vendored
View File

@ -11,8 +11,7 @@ db.sqlite-journal
static/upload static/upload
venv/ venv/
.venv .venv
.python-version
.coverage .coverage
htmlcov htmlcov
adhoc adhoc
.env.* .env.*

View File

@ -20,15 +20,15 @@ SimpleLogin backend consists of 2 main components:
## Install dependencies ## Install dependencies
The project requires: The project requires:
- Python 3.7+ and [poetry](https://python-poetry.org/) to manage dependencies - Python 3.10 and [rye](https://github.com/astral-sh/rye) to manage dependencies
- Node v10 for front-end. - Node v10 for front-end.
- Postgres 12+ - Postgres 13+
First, install all dependencies by running the following command. First, install all dependencies by running the following command.
Feel free to use `virtualenv` or similar tools to isolate development environment. Feel free to use `virtualenv` or similar tools to isolate development environment.
```bash ```bash
poetry install rye sync
``` ```
On Mac, sometimes you might need to install some other packages via `brew`: On Mac, sometimes you might need to install some other packages via `brew`:
@ -55,7 +55,7 @@ brew install -s re2 pybind11
We use pre-commit to run all our linting and static analysis checks. Please run We use pre-commit to run all our linting and static analysis checks. Please run
```bash ```bash
poetry run pre-commit install rye run pre-commit install
``` ```
To install it in your development environment. To install it in your development environment.
@ -160,25 +160,25 @@ Here are the small sum-ups of the directory structures and their roles:
The code is formatted using [ruff](https://github.com/astral-sh/ruff), to format the code, simply run The code is formatted using [ruff](https://github.com/astral-sh/ruff), to format the code, simply run
``` ```
poetry run ruff format . rye run ruff format .
``` ```
The code is also checked with `flake8`, make sure to run `flake8` before creating the pull request by The code is also checked with `flake8`, make sure to run `flake8` before creating the pull request by
```bash ```bash
poetry run flake8 rye run flake8
``` ```
For HTML templates, we use `djlint`. Before creating a pull request, please run For HTML templates, we use `djlint`. Before creating a pull request, please run
```bash ```bash
poetry run djlint --check templates rye run djlint --check templates
``` ```
If some files aren't properly formatted, you can format all files with If some files aren't properly formatted, you can format all files with
```bash ```bash
poetry run djlint --reformat . rye run djlint --reformat .
``` ```
## Test sending email ## Test sending email
@ -225,4 +225,4 @@ Now open http://localhost:1080/ (or http://localhost:1080/ for MailHog), you sho
Some features require a job handler (such as GDPR data export). To test such feature you need to run the job_runner Some features require a job handler (such as GDPR data export). To test such feature you need to run the job_runner
```bash ```bash
python job_runner.py python job_runner.py
``` ```

View File

@ -541,7 +541,7 @@ exit
Once you've created all your desired login accounts, add these lines to `/simplelogin.env` to disable further registrations: Once you've created all your desired login accounts, add these lines to `/simplelogin.env` to disable further registrations:
``` ```.env
DISABLE_REGISTRATION=1 DISABLE_REGISTRATION=1
DISABLE_ONBOARDING=true DISABLE_ONBOARDING=true
``` ```

View File

@ -2,6 +2,7 @@ from typing import Optional
import arrow import arrow
import sqlalchemy import sqlalchemy
from flask_admin.form import SecureForm
from flask_admin.model.template import EndpointLinkRowAction from flask_admin.model.template import EndpointLinkRowAction
from markupsafe import Markup from markupsafe import Markup
@ -100,6 +101,7 @@ def _user_upgrade_channel_formatter(view, context, model, name):
class UserAdmin(SLModelView): class UserAdmin(SLModelView):
form_base_class = SecureForm
column_searchable_list = ["email", "id"] column_searchable_list = ["email", "id"]
column_exclude_list = [ column_exclude_list = [
"salt", "salt",
@ -344,6 +346,7 @@ def manual_upgrade(way: str, ids: [int], is_giveaway: bool):
class EmailLogAdmin(SLModelView): class EmailLogAdmin(SLModelView):
form_base_class = SecureForm
column_searchable_list = ["id"] column_searchable_list = ["id"]
column_filters = ["id", "user.email", "mailbox.email", "contact.website_email"] column_filters = ["id", "user.email", "mailbox.email", "contact.website_email"]
@ -352,6 +355,7 @@ class EmailLogAdmin(SLModelView):
class AliasAdmin(SLModelView): class AliasAdmin(SLModelView):
form_base_class = SecureForm
column_searchable_list = ["id", "user.email", "email", "mailbox.email"] column_searchable_list = ["id", "user.email", "email", "mailbox.email"]
column_filters = ["id", "user.email", "email", "mailbox.email"] column_filters = ["id", "user.email", "email", "mailbox.email"]
@ -377,6 +381,7 @@ class AliasAdmin(SLModelView):
class MailboxAdmin(SLModelView): class MailboxAdmin(SLModelView):
form_base_class = SecureForm
column_searchable_list = ["id", "user.email", "email"] column_searchable_list = ["id", "user.email", "email"]
column_filters = ["id", "user.email", "email"] column_filters = ["id", "user.email", "email"]
@ -387,11 +392,13 @@ class MailboxAdmin(SLModelView):
class CouponAdmin(SLModelView): class CouponAdmin(SLModelView):
form_base_class = SecureForm
can_edit = False can_edit = False
can_create = True can_create = True
class ManualSubscriptionAdmin(SLModelView): class ManualSubscriptionAdmin(SLModelView):
form_base_class = SecureForm
can_edit = True can_edit = True
column_searchable_list = ["id", "user.email"] column_searchable_list = ["id", "user.email"]
@ -433,12 +440,14 @@ class ManualSubscriptionAdmin(SLModelView):
class CustomDomainAdmin(SLModelView): class CustomDomainAdmin(SLModelView):
form_base_class = SecureForm
column_searchable_list = ["domain", "user.email", "user.id"] column_searchable_list = ["domain", "user.email", "user.id"]
column_exclude_list = ["ownership_txt_token"] column_exclude_list = ["ownership_txt_token"]
can_edit = False can_edit = False
class ReferralAdmin(SLModelView): class ReferralAdmin(SLModelView):
form_base_class = SecureForm
column_searchable_list = ["id", "user.email", "code", "name"] column_searchable_list = ["id", "user.email", "code", "name"]
column_filters = ["id", "user.email", "code", "name"] column_filters = ["id", "user.email", "code", "name"]
@ -467,6 +476,7 @@ def _admin_created_at_formatter(view, context, model, name):
class AdminAuditLogAdmin(SLModelView): class AdminAuditLogAdmin(SLModelView):
form_base_class = SecureForm
column_searchable_list = ["admin.id", "admin.email", "model_id", "created_at"] column_searchable_list = ["admin.id", "admin.email", "model_id", "created_at"]
column_filters = ["admin.id", "admin.email", "model_id", "created_at"] column_filters = ["admin.id", "admin.email", "model_id", "created_at"]
column_exclude_list = ["id"] column_exclude_list = ["id"]
@ -497,6 +507,7 @@ def _transactionalcomplaint_refused_email_id_formatter(view, context, model, nam
class ProviderComplaintAdmin(SLModelView): class ProviderComplaintAdmin(SLModelView):
form_base_class = SecureForm
column_searchable_list = ["id", "user.id", "created_at"] column_searchable_list = ["id", "user.id", "created_at"]
column_filters = ["user.id", "state"] column_filters = ["user.id", "state"]
column_hide_backrefs = False column_hide_backrefs = False
@ -567,6 +578,7 @@ def _newsletter_html_formatter(view, context, model: Newsletter, name):
class NewsletterAdmin(SLModelView): class NewsletterAdmin(SLModelView):
form_base_class = SecureForm
list_template = "admin/model/newsletter-list.html" list_template = "admin/model/newsletter-list.html"
edit_template = "admin/model/newsletter-edit.html" edit_template = "admin/model/newsletter-edit.html"
edit_modal = False edit_modal = False
@ -648,6 +660,7 @@ class NewsletterAdmin(SLModelView):
class NewsletterUserAdmin(SLModelView): class NewsletterUserAdmin(SLModelView):
form_base_class = SecureForm
column_searchable_list = ["id"] column_searchable_list = ["id"]
column_filters = ["id", "user.email", "newsletter.subject"] column_filters = ["id", "user.email", "newsletter.subject"]
column_exclude_list = ["created_at", "updated_at", "id"] column_exclude_list = ["created_at", "updated_at", "id"]
@ -657,17 +670,20 @@ class NewsletterUserAdmin(SLModelView):
class DailyMetricAdmin(SLModelView): class DailyMetricAdmin(SLModelView):
form_base_class = SecureForm
column_exclude_list = ["created_at", "updated_at", "id"] column_exclude_list = ["created_at", "updated_at", "id"]
can_export = True can_export = True
class MetricAdmin(SLModelView): class MetricAdmin(SLModelView):
form_base_class = SecureForm
column_exclude_list = ["created_at", "updated_at", "id"] column_exclude_list = ["created_at", "updated_at", "id"]
can_export = True can_export = True
class InvalidMailboxDomainAdmin(SLModelView): class InvalidMailboxDomainAdmin(SLModelView):
form_base_class = SecureForm
can_create = True can_create = True
can_delete = True can_delete = True

View File

@ -64,8 +64,12 @@ def verify_prefix_suffix(
# SimpleLogin domain case: # SimpleLogin domain case:
# 1) alias_suffix must start with "." and # 1) alias_suffix must start with "." and
# 2) alias_domain_prefix must come from the word list # 2) alias_domain_prefix must come from the word list
available_sl_domains = [
sl_domain.domain
for sl_domain in user.get_sl_domains(alias_options=alias_options)
]
if ( if (
alias_domain in user.available_sl_domains(alias_options=alias_options) alias_domain in available_sl_domains
and alias_domain not in user_custom_domains and alias_domain not in user_custom_domains
# when DISABLE_ALIAS_SUFFIX is true, alias_domain_prefix is empty # when DISABLE_ALIAS_SUFFIX is true, alias_domain_prefix is empty
and not config.DISABLE_ALIAS_SUFFIX and not config.DISABLE_ALIAS_SUFFIX
@ -80,9 +84,7 @@ def verify_prefix_suffix(
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user) LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
return False return False
if alias_domain not in user.available_sl_domains( if alias_domain not in available_sl_domains:
alias_options=alias_options
):
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user) LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
return False return False

View File

@ -26,10 +26,15 @@ from app.email_utils import (
) )
from app.errors import AliasInTrashError from app.errors import AliasInTrashError
from app.events.event_dispatcher import EventDispatcher from app.events.event_dispatcher import EventDispatcher
from app.events.generated.event_pb2 import AliasDeleted, AliasStatusChange, EventContent from app.events.generated.event_pb2 import (
AliasDeleted,
AliasStatusChanged,
EventContent,
)
from app.log import LOG from app.log import LOG
from app.models import ( from app.models import (
Alias, Alias,
AliasDeleteReason,
CustomDomain, CustomDomain,
Directory, Directory,
User, User,
@ -305,7 +310,9 @@ def try_auto_create_via_domain(address: str) -> Optional[Alias]:
return None return None
def delete_alias(alias: Alias, user: User): def delete_alias(
alias: Alias, user: User, reason: AliasDeleteReason = AliasDeleteReason.Unspecified
):
""" """
Delete an alias and add it to either global or domain trash Delete an alias and add it to either global or domain trash
Should be used instead of Alias.delete, DomainDeletedAlias.create, DeletedAlias.create Should be used instead of Alias.delete, DomainDeletedAlias.create, DeletedAlias.create
@ -320,6 +327,7 @@ def delete_alias(alias: Alias, user: User):
user_id=user.id, user_id=user.id,
email=alias.email, email=alias.email,
domain_id=alias.custom_domain_id, domain_id=alias.custom_domain_id,
reason=reason,
) )
Session.add(domain_deleted_alias) Session.add(domain_deleted_alias)
Session.commit() Session.commit()
@ -328,7 +336,7 @@ def delete_alias(alias: Alias, user: User):
) )
else: else:
if not DeletedAlias.get_by(email=alias.email): if not DeletedAlias.get_by(email=alias.email):
deleted_alias = DeletedAlias(email=alias.email) deleted_alias = DeletedAlias(email=alias.email, reason=reason)
Session.add(deleted_alias) Session.add(deleted_alias)
Session.commit() Session.commit()
LOG.i(f"Moving {alias} to global trash {deleted_alias}") LOG.i(f"Moving {alias} to global trash {deleted_alias}")
@ -449,10 +457,12 @@ def transfer_alias(alias, new_user, new_mailboxes: [Mailbox]):
f"Alias {alias.email} has been received", f"Alias {alias.email} has been received",
render( render(
"transactional/alias-transferred.txt", "transactional/alias-transferred.txt",
user=old_user,
alias=alias, alias=alias,
), ),
render( render(
"transactional/alias-transferred.html", "transactional/alias-transferred.html",
user=old_user,
alias=alias, alias=alias,
), ),
) )
@ -468,9 +478,10 @@ def transfer_alias(alias, new_user, new_mailboxes: [Mailbox]):
def change_alias_status(alias: Alias, enabled: bool, commit: bool = False): def change_alias_status(alias: Alias, enabled: bool, commit: bool = False):
LOG.i(f"Changing alias {alias} enabled to {enabled}")
alias.enabled = enabled alias.enabled = enabled
event = AliasStatusChange( event = AliasStatusChanged(
alias_id=alias.id, alias_email=alias.email, enabled=enabled alias_id=alias.id, alias_email=alias.email, enabled=enabled
) )
EventDispatcher.send_event(alias.user, EventContent(alias_status_change=event)) EventDispatcher.send_event(alias.user, EventContent(alias_status_change=event))

View File

@ -19,6 +19,9 @@ def authorize_request() -> Optional[Tuple[str, int]]:
if not api_key: if not api_key:
if current_user.is_authenticated: if current_user.is_authenticated:
# if current_user.is_authenticated and request.headers.get(
# constants.HEADER_ALLOW_API_COOKIES
# ):
g.user = current_user g.user = current_user
else: else:
return jsonify(error="Wrong api key"), 401 return jsonify(error="Wrong api key"), 401

View File

@ -25,7 +25,8 @@ from app.errors import (
ErrAddressInvalid, ErrAddressInvalid,
) )
from app.extensions import limiter from app.extensions import limiter
from app.models import Alias, Contact, Mailbox, AliasMailbox from app.log import LOG
from app.models import Alias, Contact, Mailbox, AliasMailbox, AliasDeleteReason
@deprecated @deprecated
@ -160,7 +161,7 @@ def delete_alias(alias_id):
if not alias or alias.user_id != user.id: if not alias or alias.user_id != user.id:
return jsonify(error="Forbidden"), 403 return jsonify(error="Forbidden"), 403
alias_utils.delete_alias(alias, user) alias_utils.delete_alias(alias, user, AliasDeleteReason.ManualAction)
return jsonify(deleted=True), 200 return jsonify(deleted=True), 200
@ -185,6 +186,7 @@ def toggle_alias(alias_id):
return jsonify(error="Forbidden"), 403 return jsonify(error="Forbidden"), 403
alias_utils.change_alias_status(alias, enabled=not alias.enabled) alias_utils.change_alias_status(alias, enabled=not alias.enabled)
LOG.i(f"User {user} changed alias {alias} enabled status to {alias.enabled}")
Session.commit() Session.commit()
return jsonify(enabled=alias.enabled), 200 return jsonify(enabled=alias.enabled), 200

View File

@ -129,8 +129,8 @@ def auth_register():
send_email( send_email(
email, email,
"Just one more step to join SimpleLogin", "Just one more step to join SimpleLogin",
render("transactional/code-activation.txt.jinja2", code=code), render("transactional/code-activation.txt.jinja2", user=user, code=code),
render("transactional/code-activation.html", code=code), render("transactional/code-activation.html", user=user, code=code),
) )
RegisterEvent(RegisterEvent.ActionType.success, RegisterEvent.Source.api).send() RegisterEvent(RegisterEvent.ActionType.success, RegisterEvent.Source.api).send()
@ -226,8 +226,8 @@ def auth_reactivate():
send_email( send_email(
email, email,
"Just one more step to join SimpleLogin", "Just one more step to join SimpleLogin",
render("transactional/code-activation.txt.jinja2", code=code), render("transactional/code-activation.txt.jinja2", user=user, code=code),
render("transactional/code-activation.html", code=code), render("transactional/code-activation.html", user=user, code=code),
) )
return jsonify(msg="User needs to confirm their account"), 200 return jsonify(msg="User needs to confirm their account"), 200

View File

@ -10,6 +10,7 @@ from app.api.base import api_bp, require_api_auth
from app.config import SESSION_COOKIE_NAME from app.config import SESSION_COOKIE_NAME
from app.dashboard.views.index import get_stats from app.dashboard.views.index import get_stats
from app.db import Session from app.db import Session
from app.image_validation import detect_image_format, ImageFormat
from app.models import ApiKey, File, PartnerUser, User from app.models import ApiKey, File, PartnerUser, User
from app.proton.utils import get_proton_partner from app.proton.utils import get_proton_partner
from app.session import logout_session from app.session import logout_session
@ -78,17 +79,18 @@ def update_user_info():
data = request.get_json() or {} data = request.get_json() or {}
if "profile_picture" in data: if "profile_picture" in data:
if data["profile_picture"] is None: if user.profile_picture_id:
if user.profile_picture_id: file = user.profile_picture
file = user.profile_picture user.profile_picture_id = None
user.profile_picture_id = None Session.flush()
if file:
File.delete(file.id)
s3.delete(file.path)
Session.flush() Session.flush()
if file:
File.delete(file.id)
s3.delete(file.path)
Session.flush()
else: else:
raw_data = base64.decodebytes(data["profile_picture"].encode()) raw_data = base64.decodebytes(data["profile_picture"].encode())
if detect_image_format(raw_data) == ImageFormat.Unknown:
return jsonify(error="Unsupported image format"), 400
file_path = random_string(30) file_path = random_string(30)
file = File.create(user_id=user.id, path=file_path) file = File.create(user_id=user.id, path=file_path)
Session.flush() Session.flush()

View File

@ -115,7 +115,8 @@ def register():
def send_activation_email(user, next_url): def send_activation_email(user, next_url):
# the activation code is valid for 1h # the activation code is valid for 1h and delete all previous codes
Session.query(ActivationCode).filter(ActivationCode.user_id == user.id).delete()
activation = ActivationCode.create(user_id=user.id, code=random_string(30)) activation = ActivationCode.create(user_id=user.id, code=random_string(30))
Session.commit() Session.commit()
@ -125,4 +126,4 @@ def send_activation_email(user, next_url):
LOG.d("redirect user to %s after activation", next_url) LOG.d("redirect user to %s after activation", next_url)
activation_link = activation_link + "&next=" + encode_url(next_url) activation_link = activation_link + "&next=" + encode_url(next_url)
email_utils.send_activation_email(user.email, activation_link) email_utils.send_activation_email(user, activation_link)

View File

@ -120,7 +120,7 @@ if POSTFIX_SUBMISSION_TLS:
else: else:
default_postfix_port = 25 default_postfix_port = 25
POSTFIX_PORT = int(os.environ.get("POSTFIX_PORT", default_postfix_port)) POSTFIX_PORT = int(os.environ.get("POSTFIX_PORT", default_postfix_port))
POSTFIX_TIMEOUT = os.environ.get("POSTFIX_TIMEOUT", 3) POSTFIX_TIMEOUT = int(os.environ.get("POSTFIX_TIMEOUT", 3))
# ["domain1.com", "domain2.com"] # ["domain1.com", "domain2.com"]
OTHER_ALIAS_DOMAINS = sl_getenv("OTHER_ALIAS_DOMAINS", list) OTHER_ALIAS_DOMAINS = sl_getenv("OTHER_ALIAS_DOMAINS", list)
@ -281,6 +281,7 @@ JOB_DELETE_MAILBOX = "delete-mailbox"
JOB_DELETE_DOMAIN = "delete-domain" JOB_DELETE_DOMAIN = "delete-domain"
JOB_SEND_USER_REPORT = "send-user-report" JOB_SEND_USER_REPORT = "send-user-report"
JOB_SEND_PROTON_WELCOME_1 = "proton-welcome-1" JOB_SEND_PROTON_WELCOME_1 = "proton-welcome-1"
JOB_SEND_ALIAS_CREATION_EVENTS = "send-alias-creation-events"
# for pagination # for pagination
PAGE_LIMIT = 20 PAGE_LIMIT = 20
@ -583,3 +584,7 @@ UPCLOUD_DB_ID = os.environ.get("UPCLOUD_DB_ID", None)
STORE_TRANSACTIONAL_EMAILS = "STORE_TRANSACTIONAL_EMAILS" in os.environ STORE_TRANSACTIONAL_EMAILS = "STORE_TRANSACTIONAL_EMAILS" in os.environ
EVENT_WEBHOOK = os.environ.get("EVENT_WEBHOOK", None) EVENT_WEBHOOK = os.environ.get("EVENT_WEBHOOK", None)
# We want it disabled by default, so only skip if defined
EVENT_WEBHOOK_SKIP_VERIFY_SSL = "EVENT_WEBHOOK_SKIP_VERIFY_SSL" in os.environ
EVENT_WEBHOOK_DISABLE = "EVENT_WEBHOOK_DISABLE" in os.environ

1
app/app/constants.py Normal file
View File

@ -0,0 +1 @@
HEADER_ALLOW_API_COOKIES = "X-Sl-Allowcookies"

View File

@ -169,7 +169,7 @@ def send_reset_password_email(user):
reset_password_link = f"{URL}/auth/reset_password?code={reset_password_code.code}" reset_password_link = f"{URL}/auth/reset_password?code={reset_password_code.code}"
email_utils.send_reset_password_email(user.email, reset_password_link) email_utils.send_reset_password_email(user, reset_password_link)
def send_change_email_confirmation(user: User, email_change: EmailChange): def send_change_email_confirmation(user: User, email_change: EmailChange):
@ -179,7 +179,7 @@ def send_change_email_confirmation(user: User, email_change: EmailChange):
link = f"{URL}/auth/change_email?code={email_change.code}" link = f"{URL}/auth/change_email?code={email_change.code}"
email_utils.send_change_email(email_change.new_email, user.email, link) email_utils.send_change_email(user, email_change.new_email, link)
@dashboard_bp.route("/resend_email_change", methods=["GET", "POST"]) @dashboard_bp.route("/resend_email_change", methods=["GET", "POST"])

View File

@ -12,6 +12,7 @@ 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,
AliasDeleteReason,
AliasGeneratorEnum, AliasGeneratorEnum,
User, User,
EmailLog, EmailLog,
@ -143,7 +144,9 @@ def index():
if request.form.get("form-name") == "delete-alias": if request.form.get("form-name") == "delete-alias":
LOG.i(f"User {current_user} requested deletion of alias {alias}") LOG.i(f"User {current_user} requested deletion of alias {alias}")
email = alias.email email = alias.email
alias_utils.delete_alias(alias, current_user) alias_utils.delete_alias(
alias, current_user, AliasDeleteReason.ManualAction
)
flash(f"Alias {email} has been deleted", "success") flash(f"Alias {email} has been deleted", "success")
elif request.form.get("form-name") == "disable-alias": elif request.form.get("form-name") == "disable-alias":
alias_utils.change_alias_status(alias, enabled=False) alias_utils.change_alias_status(alias, enabled=False)

View File

@ -14,7 +14,7 @@ from flask_wtf import FlaskForm
from flask_wtf.file import FileField from flask_wtf.file import FileField
from wtforms import StringField, validators from wtforms import StringField, validators
from app import s3 from app import s3, user_settings
from app.config import ( from app.config import (
FIRST_ALIAS_DOMAIN, FIRST_ALIAS_DOMAIN,
ALIAS_RANDOM_SUFFIX_LENGTH, ALIAS_RANDOM_SUFFIX_LENGTH,
@ -31,12 +31,10 @@ from app.models import (
PlanEnum, PlanEnum,
File, File,
EmailChange, EmailChange,
CustomDomain,
AliasGeneratorEnum, AliasGeneratorEnum,
AliasSuffixEnum, AliasSuffixEnum,
ManualSubscription, ManualSubscription,
SenderFormatEnum, SenderFormatEnum,
SLDomain,
CoinbaseSubscription, CoinbaseSubscription,
AppleSubscription, AppleSubscription,
PartnerUser, PartnerUser,
@ -166,38 +164,11 @@ def setting():
return redirect(url_for("dashboard.setting")) return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "change-random-alias-default-domain": elif request.form.get("form-name") == "change-random-alias-default-domain":
default_domain = request.form.get("random-alias-default-domain") default_domain = request.form.get("random-alias-default-domain")
try:
if default_domain: user_settings.set_default_alias_domain(current_user, default_domain)
sl_domain: SLDomain = SLDomain.get_by(domain=default_domain) except user_settings.CannotSetAlias as e:
if sl_domain: flash(e.msg, "error")
if sl_domain.premium_only and not current_user.is_premium(): return redirect(url_for("dashboard.setting"))
flash("You cannot use this domain", "error")
return redirect(url_for("dashboard.setting"))
current_user.default_alias_public_domain_id = sl_domain.id
current_user.default_alias_custom_domain_id = None
else:
custom_domain = CustomDomain.get_by(domain=default_domain)
if custom_domain:
# sanity check
if (
custom_domain.user_id != current_user.id
or not custom_domain.verified
):
LOG.w(
"%s cannot use domain %s", current_user, custom_domain
)
flash(f"Domain {default_domain} can't be used", "error")
return redirect(request.url)
else:
current_user.default_alias_custom_domain_id = (
custom_domain.id
)
current_user.default_alias_public_domain_id = None
else:
current_user.default_alias_custom_domain_id = None
current_user.default_alias_public_domain_id = None
Session.commit() Session.commit()
flash("Your preference has been updated", "success") flash("Your preference has been updated", "success")

View File

@ -1,4 +1,5 @@
from io import BytesIO from io import BytesIO
from urllib.parse import urlparse
from flask import request, render_template, redirect, url_for, flash from flask import request, render_template, redirect, url_for, flash
from flask_login import current_user, login_required from flask_login import current_user, login_required
@ -11,6 +12,7 @@ from app.config import ADMIN_EMAIL
from app.db import Session from app.db import Session
from app.developer.base import developer_bp from app.developer.base import developer_bp
from app.email_utils import send_email from app.email_utils import send_email
from app.image_validation import detect_image_format, ImageFormat
from app.log import LOG from app.log import LOG
from app.models import Client, RedirectUri, File, Referral from app.models import Client, RedirectUri, File, Referral
from app.utils import random_string from app.utils import random_string
@ -46,16 +48,25 @@ def client_detail(client_id):
approval_form.description.data = client.description approval_form.description.data = client.description
if action == "edit" and form.validate_on_submit(): if action == "edit" and form.validate_on_submit():
parsed_url = urlparse(form.url.data)
if parsed_url.scheme != "https":
flash("Only https urls are allowed", "error")
return redirect(url_for("developer.index"))
client.name = form.name.data client.name = form.name.data
client.home_url = form.url.data client.home_url = form.url.data
if form.icon.data: if form.icon.data:
# todo: remove current icon if any icon_data = form.icon.data.read(10240)
# todo: handle remove icon if detect_image_format(icon_data) == ImageFormat.Unknown:
flash("Unknown file format", "warning")
return redirect(url_for("developer.index"))
if client.icon:
s3.delete(client.icon_id)
File.delete(client.icon)
file_path = random_string(30) file_path = random_string(30)
file = File.create(path=file_path, user_id=client.user_id) file = File.create(path=file_path, user_id=client.user_id)
s3.upload_from_bytesio(file_path, BytesIO(form.icon.data.read())) s3.upload_from_bytesio(file_path, BytesIO(icon_data))
Session.flush() Session.flush()
LOG.d("upload file %s to s3", file) LOG.d("upload file %s to s3", file)

View File

@ -1,3 +1,5 @@
from urllib.parse import urlparse
from flask import render_template, redirect, url_for, flash from flask import render_template, redirect, url_for, flash
from flask_login import current_user, login_required from flask_login import current_user, login_required
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
@ -20,6 +22,10 @@ def new_client():
if form.validate_on_submit(): if form.validate_on_submit():
client = Client.create_new(form.name.data, current_user.id) client = Client.create_new(form.name.data, current_user.id)
parsed_url = urlparse(form.url.data)
if parsed_url.scheme != "https":
flash("Only https urls are allowed", "error")
return redirect(url_for("developer.new_client"))
client.home_url = form.url.data client.home_url = form.url.data
Session.commit() Session.commit()

View File

@ -33,6 +33,7 @@ from flanker.addresslib import address
from flanker.addresslib.address import EmailAddress from flanker.addresslib.address import EmailAddress
from jinja2 import Environment, FileSystemLoader from jinja2 import Environment, FileSystemLoader
from sqlalchemy import func from sqlalchemy import func
from flask_login import current_user
from app import config from app import config
from app.db import Session from app.db import Session
@ -68,17 +69,27 @@ VERP_TIME_START = 1640995200
VERP_HMAC_ALGO = "sha3-224" VERP_HMAC_ALGO = "sha3-224"
def render(template_name, **kwargs) -> str: def render(template_name: str, user: Optional[User], **kwargs) -> str:
templates_dir = os.path.join(config.ROOT_DIR, "templates", "emails") templates_dir = os.path.join(config.ROOT_DIR, "templates", "emails")
env = Environment(loader=FileSystemLoader(templates_dir)) env = Environment(loader=FileSystemLoader(templates_dir))
template = env.get_template(template_name) template = env.get_template(template_name)
if user is None:
if current_user and current_user.is_authenticated:
user = current_user
use_partner_template = False
if user:
use_partner_template = user.has_used_alias_from_partner()
kwargs["user"] = user
return template.render( return template.render(
MAX_NB_EMAIL_FREE_PLAN=config.MAX_NB_EMAIL_FREE_PLAN, MAX_NB_EMAIL_FREE_PLAN=config.MAX_NB_EMAIL_FREE_PLAN,
URL=config.URL, URL=config.URL,
LANDING_PAGE_URL=config.LANDING_PAGE_URL, LANDING_PAGE_URL=config.LANDING_PAGE_URL,
YEAR=arrow.now().year, YEAR=arrow.now().year,
USE_PARTNER_TEMPLATE=use_partner_template,
**kwargs, **kwargs,
) )
@ -111,53 +122,59 @@ def send_trial_end_soon_email(user):
) )
def send_activation_email(email, activation_link): def send_activation_email(user: User, activation_link):
send_email( send_email(
email, user.email,
"Just one more step to join SimpleLogin", "Just one more step to join SimpleLogin",
render( render(
"transactional/activation.txt", "transactional/activation.txt",
user=user,
activation_link=activation_link, activation_link=activation_link,
email=email, email=user.email,
), ),
render( render(
"transactional/activation.html", "transactional/activation.html",
user=user,
activation_link=activation_link, activation_link=activation_link,
email=email, email=user.email,
), ),
) )
def send_reset_password_email(email, reset_password_link): def send_reset_password_email(user: User, reset_password_link):
send_email( send_email(
email, user.email,
"Reset your password on SimpleLogin", "Reset your password on SimpleLogin",
render( render(
"transactional/reset-password.txt", "transactional/reset-password.txt",
user=user,
reset_password_link=reset_password_link, reset_password_link=reset_password_link,
), ),
render( render(
"transactional/reset-password.html", "transactional/reset-password.html",
user=user,
reset_password_link=reset_password_link, reset_password_link=reset_password_link,
), ),
) )
def send_change_email(new_email, current_email, link): def send_change_email(user: User, new_email, link):
send_email( send_email(
new_email, new_email,
"Confirm email update on SimpleLogin", "Confirm email update on SimpleLogin",
render( render(
"transactional/change-email.txt", "transactional/change-email.txt",
user=user,
link=link, link=link,
new_email=new_email, new_email=new_email,
current_email=current_email, current_email=user.email,
), ),
render( render(
"transactional/change-email.html", "transactional/change-email.html",
user=user,
link=link, link=link,
new_email=new_email, new_email=new_email,
current_email=current_email, current_email=user.email,
), ),
) )
@ -170,28 +187,32 @@ def send_invalid_totp_login_email(user, totp_type):
"Unsuccessful attempt to login to your SimpleLogin account", "Unsuccessful attempt to login to your SimpleLogin account",
render( render(
"transactional/invalid-totp-login.txt", "transactional/invalid-totp-login.txt",
user=user,
type=totp_type, type=totp_type,
), ),
render( render(
"transactional/invalid-totp-login.html", "transactional/invalid-totp-login.html",
user=user,
type=totp_type, type=totp_type,
), ),
1, 1,
) )
def send_test_email_alias(email, name): def send_test_email_alias(user: User, email: str):
send_email( send_email(
email, email,
f"This email is sent to {email}", f"This email is sent to {email}",
render( render(
"transactional/test-email.txt", "transactional/test-email.txt",
name=name, user=user,
name=user.name,
alias=email, alias=email,
), ),
render( render(
"transactional/test-email.html", "transactional/test-email.html",
name=name, user=user,
name=user.name,
alias=email, alias=email,
), ),
) )
@ -206,11 +227,13 @@ def send_cannot_create_directory_alias(user, alias_address, directory_name):
f"Alias {alias_address} cannot be created", f"Alias {alias_address} cannot be created",
render( render(
"transactional/cannot-create-alias-directory.txt", "transactional/cannot-create-alias-directory.txt",
user=user,
alias=alias_address, alias=alias_address,
directory=directory_name, directory=directory_name,
), ),
render( render(
"transactional/cannot-create-alias-directory.html", "transactional/cannot-create-alias-directory.html",
user=user,
alias=alias_address, alias=alias_address,
directory=directory_name, directory=directory_name,
), ),
@ -228,11 +251,13 @@ def send_cannot_create_directory_alias_disabled(user, alias_address, directory_n
f"Alias {alias_address} cannot be created", f"Alias {alias_address} cannot be created",
render( render(
"transactional/cannot-create-alias-directory-disabled.txt", "transactional/cannot-create-alias-directory-disabled.txt",
user=user,
alias=alias_address, alias=alias_address,
directory=directory_name, directory=directory_name,
), ),
render( render(
"transactional/cannot-create-alias-directory-disabled.html", "transactional/cannot-create-alias-directory-disabled.html",
user=user,
alias=alias_address, alias=alias_address,
directory=directory_name, directory=directory_name,
), ),
@ -248,11 +273,13 @@ def send_cannot_create_domain_alias(user, alias, domain):
f"Alias {alias} cannot be created", f"Alias {alias} cannot be created",
render( render(
"transactional/cannot-create-alias-domain.txt", "transactional/cannot-create-alias-domain.txt",
user=user,
alias=alias, alias=alias,
domain=domain, domain=domain,
), ),
render( render(
"transactional/cannot-create-alias-domain.html", "transactional/cannot-create-alias-domain.html",
user=user,
alias=alias, alias=alias,
domain=domain, domain=domain,
), ),
@ -919,10 +946,20 @@ def decode_text(text: str, encoding: EmailEncoding = EmailEncoding.NO) -> str:
return text return text
def add_header(msg: Message, text_header, html_header=None) -> Message: def add_header(
msg: Message, text_header, html_header=None, subject_prefix=None
) -> Message:
if not html_header: if not html_header:
html_header = text_header.replace("\n", "<br>") html_header = text_header.replace("\n", "<br>")
if subject_prefix is not None:
subject = msg[headers.SUBJECT]
if not subject:
msg.add_header(headers.SUBJECT, subject_prefix)
else:
subject = f"{subject_prefix} {subject}"
msg.replace_header(headers.SUBJECT, subject)
content_type = msg.get_content_type().lower() content_type = msg.get_content_type().lower()
if content_type == "text/plain": if content_type == "text/plain":
encoding = get_encoding(msg) encoding = get_encoding(msg)
@ -1253,6 +1290,7 @@ def spf_pass(
f"SimpleLogin Alert: attempt to send emails from your alias {alias.email} from unknown IP Address", f"SimpleLogin Alert: attempt to send emails from your alias {alias.email} from unknown IP Address",
render( render(
"transactional/spf-fail.txt", "transactional/spf-fail.txt",
user=user,
alias=alias.email, alias=alias.email,
ip=ip, ip=ip,
mailbox_url=config.URL + f"/dashboard/mailbox/{mailbox.id}#spf", mailbox_url=config.URL + f"/dashboard/mailbox/{mailbox.id}#spf",
@ -1262,6 +1300,7 @@ def spf_pass(
), ),
render( render(
"transactional/spf-fail.html", "transactional/spf-fail.html",
user=user,
ip=ip, ip=ip,
mailbox_url=config.URL + f"/dashboard/mailbox/{mailbox.id}#spf", mailbox_url=config.URL + f"/dashboard/mailbox/{mailbox.id}#spf",
to_email=contact_email, to_email=contact_email,

View File

@ -34,6 +34,9 @@ class EventDispatcher:
dispatcher: Dispatcher = PostgresDispatcher.get(), dispatcher: Dispatcher = PostgresDispatcher.get(),
skip_if_webhook_missing: bool = True, skip_if_webhook_missing: bool = True,
): ):
if config.EVENT_WEBHOOK_DISABLE:
return
if not config.EVENT_WEBHOOK and skip_if_webhook_missing: if not config.EVENT_WEBHOOK and skip_if_webhook_missing:
return return

View File

@ -1,12 +1,22 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
# Generated by the protocol buffer compiler. DO NOT EDIT! # Generated by the protocol buffer compiler. DO NOT EDIT!
# NO CHECKED-IN PROTOBUF GENCODE
# source: event.proto # source: event.proto
# Protobuf Python Version: 5.26.1 # Protobuf Python Version: 5.27.0
"""Generated protocol buffer code.""" """Generated protocol buffer code."""
from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor as _descriptor
from google.protobuf import descriptor_pool as _descriptor_pool from google.protobuf import descriptor_pool as _descriptor_pool
from google.protobuf import runtime_version as _runtime_version
from google.protobuf import symbol_database as _symbol_database from google.protobuf import symbol_database as _symbol_database
from google.protobuf.internal import builder as _builder from google.protobuf.internal import builder as _builder
_runtime_version.ValidateProtobufRuntimeVersion(
_runtime_version.Domain.PUBLIC,
5,
27,
0,
'',
'event.proto'
)
# @@protoc_insertion_point(imports) # @@protoc_insertion_point(imports)
_sym_db = _symbol_database.Default() _sym_db = _symbol_database.Default()
@ -14,25 +24,27 @@ _sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0b\x65vent.proto\x12\x12simplelogin_events\"\'\n\x0eUserPlanChange\x12\x15\n\rplan_end_time\x18\x01 \x01(\r\"\r\n\x0bUserDeleted\"Z\n\x0c\x41liasCreated\x12\x10\n\x08\x61lias_id\x18\x01 \x01(\r\x12\x13\n\x0b\x61lias_email\x18\x02 \x01(\t\x12\x12\n\nalias_note\x18\x03 \x01(\t\x12\x0f\n\x07\x65nabled\x18\x04 \x01(\x08\"K\n\x11\x41liasStatusChange\x12\x10\n\x08\x61lias_id\x18\x01 \x01(\r\x12\x13\n\x0b\x61lias_email\x18\x02 \x01(\t\x12\x0f\n\x07\x65nabled\x18\x03 \x01(\x08\"5\n\x0c\x41liasDeleted\x12\x10\n\x08\x61lias_id\x18\x01 \x01(\r\x12\x13\n\x0b\x61lias_email\x18\x02 \x01(\t\"\xce\x02\n\x0c\x45ventContent\x12>\n\x10user_plan_change\x18\x01 \x01(\x0b\x32\".simplelogin_events.UserPlanChangeH\x00\x12\x37\n\x0cuser_deleted\x18\x02 \x01(\x0b\x32\x1f.simplelogin_events.UserDeletedH\x00\x12\x39\n\ralias_created\x18\x03 \x01(\x0b\x32 .simplelogin_events.AliasCreatedH\x00\x12\x44\n\x13\x61lias_status_change\x18\x04 \x01(\x0b\x32%.simplelogin_events.AliasStatusChangeH\x00\x12\x39\n\ralias_deleted\x18\x05 \x01(\x0b\x32 .simplelogin_events.AliasDeletedH\x00\x42\t\n\x07\x63ontent\"y\n\x05\x45vent\x12\x0f\n\x07user_id\x18\x01 \x01(\r\x12\x18\n\x10\x65xternal_user_id\x18\x02 \x01(\t\x12\x12\n\npartner_id\x18\x03 \x01(\r\x12\x31\n\x07\x63ontent\x18\x04 \x01(\x0b\x32 .simplelogin_events.EventContentb\x06proto3') DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0b\x65vent.proto\x12\x12simplelogin_events\"(\n\x0fUserPlanChanged\x12\x15\n\rplan_end_time\x18\x01 \x01(\r\"\r\n\x0bUserDeleted\"Z\n\x0c\x41liasCreated\x12\x10\n\x08\x61lias_id\x18\x01 \x01(\r\x12\x13\n\x0b\x61lias_email\x18\x02 \x01(\t\x12\x12\n\nalias_note\x18\x03 \x01(\t\x12\x0f\n\x07\x65nabled\x18\x04 \x01(\x08\"L\n\x12\x41liasStatusChanged\x12\x10\n\x08\x61lias_id\x18\x01 \x01(\r\x12\x13\n\x0b\x61lias_email\x18\x02 \x01(\t\x12\x0f\n\x07\x65nabled\x18\x03 \x01(\x08\"5\n\x0c\x41liasDeleted\x12\x10\n\x08\x61lias_id\x18\x01 \x01(\r\x12\x13\n\x0b\x61lias_email\x18\x02 \x01(\t\"D\n\x10\x41liasCreatedList\x12\x30\n\x06\x65vents\x18\x01 \x03(\x0b\x32 .simplelogin_events.AliasCreated\"\x93\x03\n\x0c\x45ventContent\x12?\n\x10user_plan_change\x18\x01 \x01(\x0b\x32#.simplelogin_events.UserPlanChangedH\x00\x12\x37\n\x0cuser_deleted\x18\x02 \x01(\x0b\x32\x1f.simplelogin_events.UserDeletedH\x00\x12\x39\n\ralias_created\x18\x03 \x01(\x0b\x32 .simplelogin_events.AliasCreatedH\x00\x12\x45\n\x13\x61lias_status_change\x18\x04 \x01(\x0b\x32&.simplelogin_events.AliasStatusChangedH\x00\x12\x39\n\ralias_deleted\x18\x05 \x01(\x0b\x32 .simplelogin_events.AliasDeletedH\x00\x12\x41\n\x11\x61lias_create_list\x18\x06 \x01(\x0b\x32$.simplelogin_events.AliasCreatedListH\x00\x42\t\n\x07\x63ontent\"y\n\x05\x45vent\x12\x0f\n\x07user_id\x18\x01 \x01(\r\x12\x18\n\x10\x65xternal_user_id\x18\x02 \x01(\t\x12\x12\n\npartner_id\x18\x03 \x01(\r\x12\x31\n\x07\x63ontent\x18\x04 \x01(\x0b\x32 .simplelogin_events.EventContentb\x06proto3')
_globals = globals() _globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'event_pb2', _globals) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'event_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS: if not _descriptor._USE_C_DESCRIPTORS:
DESCRIPTOR._loaded_options = None DESCRIPTOR._loaded_options = None
_globals['_USERPLANCHANGE']._serialized_start=35 _globals['_USERPLANCHANGED']._serialized_start=35
_globals['_USERPLANCHANGE']._serialized_end=74 _globals['_USERPLANCHANGED']._serialized_end=75
_globals['_USERDELETED']._serialized_start=76 _globals['_USERDELETED']._serialized_start=77
_globals['_USERDELETED']._serialized_end=89 _globals['_USERDELETED']._serialized_end=90
_globals['_ALIASCREATED']._serialized_start=91 _globals['_ALIASCREATED']._serialized_start=92
_globals['_ALIASCREATED']._serialized_end=181 _globals['_ALIASCREATED']._serialized_end=182
_globals['_ALIASSTATUSCHANGE']._serialized_start=183 _globals['_ALIASSTATUSCHANGED']._serialized_start=184
_globals['_ALIASSTATUSCHANGE']._serialized_end=258 _globals['_ALIASSTATUSCHANGED']._serialized_end=260
_globals['_ALIASDELETED']._serialized_start=260 _globals['_ALIASDELETED']._serialized_start=262
_globals['_ALIASDELETED']._serialized_end=313 _globals['_ALIASDELETED']._serialized_end=315
_globals['_EVENTCONTENT']._serialized_start=316 _globals['_ALIASCREATEDLIST']._serialized_start=317
_globals['_EVENTCONTENT']._serialized_end=650 _globals['_ALIASCREATEDLIST']._serialized_end=385
_globals['_EVENT']._serialized_start=652 _globals['_EVENTCONTENT']._serialized_start=388
_globals['_EVENT']._serialized_end=773 _globals['_EVENTCONTENT']._serialized_end=791
_globals['_EVENT']._serialized_start=793
_globals['_EVENT']._serialized_end=914
# @@protoc_insertion_point(module_scope) # @@protoc_insertion_point(module_scope)

View File

@ -1,10 +1,11 @@
from google.protobuf.internal import containers as _containers
from google.protobuf import descriptor as _descriptor from google.protobuf import descriptor as _descriptor
from google.protobuf import message as _message from google.protobuf import message as _message
from typing import ClassVar as _ClassVar, Mapping as _Mapping, Optional as _Optional, Union as _Union from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Mapping, Optional as _Optional, Union as _Union
DESCRIPTOR: _descriptor.FileDescriptor DESCRIPTOR: _descriptor.FileDescriptor
class UserPlanChange(_message.Message): class UserPlanChanged(_message.Message):
__slots__ = ("plan_end_time",) __slots__ = ("plan_end_time",)
PLAN_END_TIME_FIELD_NUMBER: _ClassVar[int] PLAN_END_TIME_FIELD_NUMBER: _ClassVar[int]
plan_end_time: int plan_end_time: int
@ -26,7 +27,7 @@ class AliasCreated(_message.Message):
enabled: bool enabled: bool
def __init__(self, alias_id: _Optional[int] = ..., alias_email: _Optional[str] = ..., alias_note: _Optional[str] = ..., enabled: bool = ...) -> None: ... def __init__(self, alias_id: _Optional[int] = ..., alias_email: _Optional[str] = ..., alias_note: _Optional[str] = ..., enabled: bool = ...) -> None: ...
class AliasStatusChange(_message.Message): class AliasStatusChanged(_message.Message):
__slots__ = ("alias_id", "alias_email", "enabled") __slots__ = ("alias_id", "alias_email", "enabled")
ALIAS_ID_FIELD_NUMBER: _ClassVar[int] ALIAS_ID_FIELD_NUMBER: _ClassVar[int]
ALIAS_EMAIL_FIELD_NUMBER: _ClassVar[int] ALIAS_EMAIL_FIELD_NUMBER: _ClassVar[int]
@ -44,19 +45,27 @@ class AliasDeleted(_message.Message):
alias_email: str alias_email: str
def __init__(self, alias_id: _Optional[int] = ..., alias_email: _Optional[str] = ...) -> None: ... def __init__(self, alias_id: _Optional[int] = ..., alias_email: _Optional[str] = ...) -> None: ...
class AliasCreatedList(_message.Message):
__slots__ = ("events",)
EVENTS_FIELD_NUMBER: _ClassVar[int]
events: _containers.RepeatedCompositeFieldContainer[AliasCreated]
def __init__(self, events: _Optional[_Iterable[_Union[AliasCreated, _Mapping]]] = ...) -> None: ...
class EventContent(_message.Message): class EventContent(_message.Message):
__slots__ = ("user_plan_change", "user_deleted", "alias_created", "alias_status_change", "alias_deleted") __slots__ = ("user_plan_change", "user_deleted", "alias_created", "alias_status_change", "alias_deleted", "alias_create_list")
USER_PLAN_CHANGE_FIELD_NUMBER: _ClassVar[int] USER_PLAN_CHANGE_FIELD_NUMBER: _ClassVar[int]
USER_DELETED_FIELD_NUMBER: _ClassVar[int] USER_DELETED_FIELD_NUMBER: _ClassVar[int]
ALIAS_CREATED_FIELD_NUMBER: _ClassVar[int] ALIAS_CREATED_FIELD_NUMBER: _ClassVar[int]
ALIAS_STATUS_CHANGE_FIELD_NUMBER: _ClassVar[int] ALIAS_STATUS_CHANGE_FIELD_NUMBER: _ClassVar[int]
ALIAS_DELETED_FIELD_NUMBER: _ClassVar[int] ALIAS_DELETED_FIELD_NUMBER: _ClassVar[int]
user_plan_change: UserPlanChange ALIAS_CREATE_LIST_FIELD_NUMBER: _ClassVar[int]
user_plan_change: UserPlanChanged
user_deleted: UserDeleted user_deleted: UserDeleted
alias_created: AliasCreated alias_created: AliasCreated
alias_status_change: AliasStatusChange alias_status_change: AliasStatusChanged
alias_deleted: AliasDeleted alias_deleted: AliasDeleted
def __init__(self, user_plan_change: _Optional[_Union[UserPlanChange, _Mapping]] = ..., user_deleted: _Optional[_Union[UserDeleted, _Mapping]] = ..., alias_created: _Optional[_Union[AliasCreated, _Mapping]] = ..., alias_status_change: _Optional[_Union[AliasStatusChange, _Mapping]] = ..., alias_deleted: _Optional[_Union[AliasDeleted, _Mapping]] = ...) -> None: ... alias_create_list: AliasCreatedList
def __init__(self, user_plan_change: _Optional[_Union[UserPlanChanged, _Mapping]] = ..., user_deleted: _Optional[_Union[UserDeleted, _Mapping]] = ..., alias_created: _Optional[_Union[AliasCreated, _Mapping]] = ..., alias_status_change: _Optional[_Union[AliasStatusChanged, _Mapping]] = ..., alias_deleted: _Optional[_Union[AliasDeleted, _Mapping]] = ..., alias_create_list: _Optional[_Union[AliasCreatedList, _Mapping]] = ...) -> None: ...
class Event(_message.Message): class Event(_message.Message):
__slots__ = ("user_id", "external_user_id", "partner_id", "content") __slots__ = ("user_id", "external_user_id", "partner_id", "content")

View File

@ -64,6 +64,7 @@ More info on https://simplelogin.io/docs/getting-started/anti-phishing/
msg, msg,
warning_plain_text, warning_plain_text,
warning_html, warning_html,
subject_prefix="[Possible phishing attempt]",
) )
return changed_msg, None return changed_msg, None
@ -76,6 +77,7 @@ More info on https://simplelogin.io/docs/getting-started/anti-phishing/
msg, msg,
warning_plain_text, warning_plain_text,
warning_html, warning_html,
subject_prefix="[Possible phishing attempt]",
) )
return changed_msg, None return changed_msg, None
@ -104,12 +106,14 @@ More info on https://simplelogin.io/docs/getting-started/anti-phishing/
f"An email sent to {alias.email} has been quarantined", f"An email sent to {alias.email} has been quarantined",
render( render(
"transactional/message-quarantine-dmarc.txt.jinja2", "transactional/message-quarantine-dmarc.txt.jinja2",
user=user,
from_header=from_header, from_header=from_header,
alias=alias, alias=alias,
refused_email_url=email_log.get_dashboard_url(), refused_email_url=email_log.get_dashboard_url(),
), ),
render( render(
"transactional/message-quarantine-dmarc.html", "transactional/message-quarantine-dmarc.html",
user=user,
from_header=from_header, from_header=from_header,
alias=alias, alias=alias,
refused_email_url=email_log.get_dashboard_url(), refused_email_url=email_log.get_dashboard_url(),
@ -174,12 +178,14 @@ def apply_dmarc_policy_for_reply_phase(
f"Attempt to send an email to your contact {contact_recipient.email} from {envelope.mail_from}", f"Attempt to send an email to your contact {contact_recipient.email} from {envelope.mail_from}",
render( render(
"transactional/spoof-reply.txt.jinja2", "transactional/spoof-reply.txt.jinja2",
user=alias_from.user,
contact=contact_recipient, contact=contact_recipient,
alias=alias_from, alias=alias_from,
sender=envelope.mail_from, sender=envelope.mail_from,
), ),
render( render(
"transactional/spoof-reply.html", "transactional/spoof-reply.html",
user=alias_from.user,
contact=contact_recipient, contact=contact_recipient,
alias=alias_from, alias=alias_from,
sender=envelope.mail_from, sender=envelope.mail_from,

View File

@ -319,11 +319,13 @@ def report_complaint_to_user_in_forward_phase(
f"Abuse report from {capitalized_name}", f"Abuse report from {capitalized_name}",
render( render(
"transactional/provider-complaint-forward-phase.txt.jinja2", "transactional/provider-complaint-forward-phase.txt.jinja2",
user=user,
email=mailbox_email, email=mailbox_email,
provider=capitalized_name, provider=capitalized_name,
), ),
render( render(
"transactional/provider-complaint-forward-phase.html", "transactional/provider-complaint-forward-phase.html",
user=user,
email=mailbox_email, email=mailbox_email,
provider=capitalized_name, provider=capitalized_name,
), ),

View File

@ -102,6 +102,7 @@ class UnsubscribeHandler:
mailbox.email, alias mailbox.email, alias
): ):
return status.E509 return status.E509
LOG.i(f"User disabled alias {alias} via unsubscribe header")
alias_utils.change_alias_status(alias, enabled=False) alias_utils.change_alias_status(alias, enabled=False)
Session.commit() Session.commit()
enable_alias_url = config.URL + f"/dashboard/?highlight_alias_id={alias.id}" enable_alias_url = config.URL + f"/dashboard/?highlight_alias_id={alias.id}"

View File

@ -0,0 +1,40 @@
from app.events.event_dispatcher import EventDispatcher, Dispatcher
from app.events.generated.event_pb2 import EventContent, AliasCreated, AliasCreatedList
from app.log import LOG
from app.models import User, Alias
def send_alias_creation_events_for_user(
user: User, dispatcher: Dispatcher, chunk_size=50
):
if user.disabled:
LOG.i("User {user} is disabled. Skipping sending events for that user")
return
chunk_size = min(chunk_size, 50)
event_list = []
for alias in (
Alias.yield_per_query(chunk_size)
.filter_by(user_id=user.id)
.order_by(Alias.id.asc())
):
event_list.append(
AliasCreated(
alias_id=alias.id,
alias_email=alias.email,
alias_note=alias.note,
enabled=alias.enabled,
)
)
if len(event_list) >= chunk_size:
EventDispatcher.send_event(
user,
EventContent(alias_create_list=AliasCreatedList(events=event_list)),
dispatcher=dispatcher,
)
event_list = []
if len(event_list) > 0:
EventDispatcher.send_event(
user,
EventContent(alias_create_list=AliasCreatedList(events=event_list)),
dispatcher=dispatcher,
)

View File

@ -137,7 +137,9 @@ class ExportUserDataJob:
msg[headers.SUBJECT] = "Your SimpleLogin data" msg[headers.SUBJECT] = "Your SimpleLogin data"
msg[headers.FROM] = f'"SimpleLogin (noreply)" <{config.NOREPLY}>' msg[headers.FROM] = f'"SimpleLogin (noreply)" <{config.NOREPLY}>'
msg[headers.TO] = to_email msg[headers.TO] = to_email
msg.attach(MIMEText(render("transactional/user-report.html"), "html")) msg.attach(
MIMEText(render("transactional/user-report.html", user=self._user), "html")
)
attachment = MIMEApplication(zipped_contents.read()) attachment = MIMEApplication(zipped_contents.read())
attachment.add_header( attachment.add_header(
"Content-Disposition", "attachment", filename="user_report.zip" "Content-Disposition", "attachment", filename="user_report.zip"

View File

@ -76,7 +76,6 @@ class SendRequest:
file_path = os.path.join(config.SAVE_UNSENT_DIR, file_name) file_path = os.path.join(config.SAVE_UNSENT_DIR, file_name)
self.save_request_to_file(file_path) self.save_request_to_file(file_path)
@staticmethod
def save_request_to_failed_dir(self, prefix: str = "DeliveryRetryFail"): def save_request_to_failed_dir(self, prefix: str = "DeliveryRetryFail"):
file_name = ( file_name = (
f"{prefix}-{int(time.time())}-{uuid.uuid4()}.{SendRequest.SAVE_EXTENSION}" f"{prefix}-{int(time.time())}-{uuid.uuid4()}.{SendRequest.SAVE_EXTENSION}"

View File

@ -263,6 +263,15 @@ class UnsubscribeBehaviourEnum(EnumE):
PreserveOriginal = 2 PreserveOriginal = 2
class AliasDeleteReason(EnumE):
Unspecified = 0
UserHasBeenDeleted = 1
ManualAction = 2
DirectoryDeleted = 3
MailboxDeleted = 4
CustomDomainDeleted = 5
class IntEnumType(sa.types.TypeDecorator): class IntEnumType(sa.types.TypeDecorator):
impl = sa.Integer impl = sa.Integer
@ -330,6 +339,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
FLAG_FREE_DISABLE_CREATE_ALIAS = 1 << 0 FLAG_FREE_DISABLE_CREATE_ALIAS = 1 << 0
FLAG_CREATED_FROM_PARTNER = 1 << 1 FLAG_CREATED_FROM_PARTNER = 1 << 1
FLAG_FREE_OLD_ALIAS_LIMIT = 1 << 2 FLAG_FREE_OLD_ALIAS_LIMIT = 1 << 2
FLAG_CREATED_ALIAS_FROM_PARTNER = 1 << 3
email = sa.Column(sa.String(256), unique=True, nullable=False) email = sa.Column(sa.String(256), unique=True, nullable=False)
@ -666,6 +676,12 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
user: User = cls.get(obj_id) user: User = cls.get(obj_id)
EventDispatcher.send_event(user, EventContent(user_deleted=UserDeleted())) EventDispatcher.send_event(user, EventContent(user_deleted=UserDeleted()))
# Manually delete all aliases for the user that is about to be deleted
from app.alias_utils import delete_alias
for alias in Alias.filter_by(user_id=user.id):
delete_alias(alias, user, AliasDeleteReason.UserHasBeenDeleted)
res = super(User, cls).delete(obj_id) res = super(User, cls).delete(obj_id)
if commit: if commit:
Session.commit() Session.commit()
@ -969,8 +985,8 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
- the domain - the domain
""" """
res = [] res = []
for domain in self.available_sl_domains(alias_options=alias_options): for domain in self.get_sl_domains(alias_options=alias_options):
res.append((True, domain)) res.append((True, domain.domain))
for custom_domain in self.verified_custom_domains(): for custom_domain in self.verified_custom_domains():
res.append((False, custom_domain.domain)) res.append((False, custom_domain.domain))
@ -1112,7 +1128,10 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
- Verified custom domains - Verified custom domains
""" """
domains = self.available_sl_domains(alias_options=alias_options) domains = [
sl_domain.domain
for sl_domain in self.get_sl_domains(alias_options=alias_options)
]
for custom_domain in self.verified_custom_domains(): for custom_domain in self.verified_custom_domains():
domains.append(custom_domain.domain) domains.append(custom_domain.domain)
@ -1153,6 +1172,13 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
return True return True
return not config.DISABLE_CREATE_CONTACTS_FOR_FREE_USERS return not config.DISABLE_CREATE_CONTACTS_FOR_FREE_USERS
def has_used_alias_from_partner(self) -> bool:
return (
self.flags
& (User.FLAG_CREATED_ALIAS_FROM_PARTNER | User.FLAG_CREATED_FROM_PARTNER)
> 0
)
def __repr__(self): def __repr__(self):
return f"<User {self.id} {self.name} {self.email}>" return f"<User {self.id} {self.name} {self.email}>"
@ -1646,6 +1672,12 @@ class Alias(Base, ModelMixin):
) )
EventDispatcher.send_event(user, EventContent(alias_created=event)) EventDispatcher.send_event(user, EventContent(alias_created=event))
if (
new_alias.flags & cls.FLAG_PARTNER_CREATED > 0
and new_alias.user.flags & User.FLAG_CREATED_ALIAS_FROM_PARTNER == 0
):
user.flags = user.flags | User.FLAG_CREATED_ALIAS_FROM_PARTNER
if commit: if commit:
Session.commit() Session.commit()
@ -2247,6 +2279,12 @@ class DeletedAlias(Base, ModelMixin):
__tablename__ = "deleted_alias" __tablename__ = "deleted_alias"
email = sa.Column(sa.String(256), unique=True, nullable=False) email = sa.Column(sa.String(256), unique=True, nullable=False)
reason = sa.Column(
IntEnumType(AliasDeleteReason),
nullable=False,
default=AliasDeleteReason.Unspecified,
server_default=str(AliasDeleteReason.Unspecified.value),
)
@classmethod @classmethod
def create(cls, **kw): def create(cls, **kw):
@ -2434,6 +2472,13 @@ class CustomDomain(Base, ModelMixin):
if obj.is_sl_subdomain: if obj.is_sl_subdomain:
DeletedSubdomain.create(domain=obj.domain) DeletedSubdomain.create(domain=obj.domain)
from app import alias_utils
for alias in Alias.filter_by(custom_domain_id=obj_id):
alias_utils.delete_alias(
alias, obj.user, AliasDeleteReason.CustomDomainDeleted
)
return super(CustomDomain, cls).delete(obj_id) return super(CustomDomain, cls).delete(obj_id)
@property @property
@ -2441,7 +2486,7 @@ class CustomDomain(Base, ModelMixin):
return sorted(self._auto_create_rules, key=lambda rule: rule.order) return sorted(self._auto_create_rules, key=lambda rule: rule.order)
def __repr__(self): def __repr__(self):
return f"<Custom Domain {self.domain}>" return f"<Custom Domain {self.id} {self.domain}>"
class AutoCreateRule(Base, ModelMixin): class AutoCreateRule(Base, ModelMixin):
@ -2506,6 +2551,12 @@ class DomainDeletedAlias(Base, ModelMixin):
domain = orm.relationship(CustomDomain) domain = orm.relationship(CustomDomain)
user = orm.relationship(User, foreign_keys=[user_id]) user = orm.relationship(User, foreign_keys=[user_id])
reason = sa.Column(
IntEnumType(AliasDeleteReason),
nullable=False,
default=AliasDeleteReason.Unspecified,
server_default=str(AliasDeleteReason.Unspecified.value),
)
@classmethod @classmethod
def create(cls, **kw): def create(cls, **kw):
@ -2597,7 +2648,7 @@ class Directory(Base, ModelMixin):
for alias in Alias.filter_by(directory_id=obj_id): for alias in Alias.filter_by(directory_id=obj_id):
from app import alias_utils from app import alias_utils
alias_utils.delete_alias(alias, user) alias_utils.delete_alias(alias, user, AliasDeleteReason.DirectoryDeleted)
DeletedDirectory.create(name=obj.name) DeletedDirectory.create(name=obj.name)
cls.filter(cls.id == obj_id).delete() cls.filter(cls.id == obj_id).delete()
@ -2725,7 +2776,7 @@ class Mailbox(Base, ModelMixin):
from app import alias_utils from app import alias_utils
# only put aliases that have mailbox as a single mailbox into trash # only put aliases that have mailbox as a single mailbox into trash
alias_utils.delete_alias(alias, user) alias_utils.delete_alias(alias, user, AliasDeleteReason.MailboxDeleted)
Session.commit() Session.commit()
cls.filter(cls.id == obj_id).delete() cls.filter(cls.id == obj_id).delete()
@ -2971,11 +3022,7 @@ class RecoveryCode(Base, ModelMixin):
@classmethod @classmethod
def find_by_user_code(cls, user: User, code: str): def find_by_user_code(cls, user: User, code: str):
hashed_code = cls._hash_code(code) hashed_code = cls._hash_code(code)
# TODO: Only return hashed codes once there aren't unhashed codes in the db. return cls.get_by(user_id=user.id, code=hashed_code)
found_code = cls.get_by(user_id=user.id, code=hashed_code)
if found_code:
return found_code
return cls.get_by(user_id=user.id, code=code)
@classmethod @classmethod
def empty(cls, user): def empty(cls, user):
@ -3070,7 +3117,7 @@ class SLDomain(Base, ModelMixin):
) )
def __repr__(self): def __repr__(self):
return f"<SLDomain {self.domain} {'Premium' if self.premium_only else 'Free'}" return f"<SLDomain {self.id} {self.domain} {'Premium' if self.premium_only else 'Free'}>"
class Monitoring(Base, ModelMixin): class Monitoring(Base, ModelMixin):
@ -3440,6 +3487,7 @@ class AdminAuditLog(Base):
action=AuditLogActionEnum.stop_trial.value, action=AuditLogActionEnum.stop_trial.value,
model="User", model="User",
model_id=user_id, model_id=user_id,
data={},
) )
@classmethod @classmethod
@ -3685,6 +3733,7 @@ class SyncEvent(Base, ModelMixin):
taken_time = sa.Column( taken_time = sa.Column(
ArrowType, default=None, nullable=True, server_default=None, index=True ArrowType, default=None, nullable=True, server_default=None, index=True
) )
retry_count = sa.Column(sa.Integer, default=0, nullable=False, server_default="0")
__table_args__ = ( __table_args__ = (
sa.Index("ix_sync_event_created_at", "created_at"), sa.Index("ix_sync_event_created_at", "created_at"),
@ -3699,11 +3748,14 @@ class SyncEvent(Base, ModelMixin):
AND taken_time IS NULL AND taken_time IS NULL
""" """
args = {"taken_time": arrow.now().datetime, "sync_event_id": self.id} args = {"taken_time": arrow.now().datetime, "sync_event_id": self.id}
res = Session.execute(sql, args) res = Session.execute(sql, args)
Session.commit()
return res.rowcount > 0 return res.rowcount > 0
@classmethod @classmethod
def get_dead_letter(cls, older_than: Arrow) -> [SyncEvent]: def get_dead_letter(cls, older_than: Arrow, max_retries: int) -> [SyncEvent]:
return ( return (
SyncEvent.filter( SyncEvent.filter(
( (
@ -3716,6 +3768,7 @@ class SyncEvent(Base, ModelMixin):
& (SyncEvent.created_at < older_than) & (SyncEvent.created_at < older_than)
) )
) )
& (SyncEvent.retry_count < max_retries)
) )
.order_by(SyncEvent.id) .order_by(SyncEvent.id)
.limit(100) .limit(100)

View File

@ -20,7 +20,7 @@ def final():
if form.validate_on_submit(): if form.validate_on_submit():
alias = Alias.get_by(email=form.email.data) alias = Alias.get_by(email=form.email.data)
if alias and alias.user_id == current_user.id: if alias and alias.user_id == current_user.id:
send_test_email_alias(alias.email, current_user.name) send_test_email_alias(current_user, alias.email)
flash("An email is sent to your alias", "success") flash("An email is sent to your alias", "success")
return render_template( return render_template(

View File

@ -1,7 +1,13 @@
from app.onboarding.base import onboarding_bp from app.onboarding.base import onboarding_bp
from flask import render_template from flask import render_template, url_for, redirect
@onboarding_bp.route("/", methods=["GET"]) @onboarding_bp.route("/", methods=["GET"])
def index(): def index():
return render_template("onboarding/index.html") # Do the redirect to ensure cookies are set because they are SameSite=lax/strict
return redirect(url_for("onboarding.setup"))
@onboarding_bp.route("/setup", methods=["GET"])
def setup():
return render_template("onboarding/setup.html")

View File

@ -27,6 +27,7 @@ def failed_payment(sub: Subscription, subscription_id: str):
"SimpleLogin - your subscription has failed to be renewed", "SimpleLogin - your subscription has failed to be renewed",
render( render(
"transactional/subscription-cancel.txt", "transactional/subscription-cancel.txt",
user=user,
end_date=arrow.arrow.datetime.utcnow(), end_date=arrow.arrow.datetime.utcnow(),
), ),
) )

View File

@ -2,6 +2,7 @@ from newrelic import agent
from typing import Optional from typing import Optional
from app.db import Session from app.db import Session
from app.log import LOG
from app.errors import ProtonPartnerNotSetUp from app.errors import ProtonPartnerNotSetUp
from app.models import Partner, PartnerUser, User from app.models import Partner, PartnerUser, User
@ -30,6 +31,7 @@ def perform_proton_account_unlink(current_user: User):
user_id=current_user.id, partner_id=proton_partner.id user_id=current_user.id, partner_id=proton_partner.id
) )
if partner_user is not None: if partner_user is not None:
LOG.info(f"User {current_user} has unlinked the account from {partner_user}")
PartnerUser.delete(partner_user.id) PartnerUser.delete(partner_user.id)
Session.commit() Session.commit()
agent.record_custom_event("AccountUnlinked", {"partner": proton_partner.name}) agent.record_custom_event("AccountUnlinked", {"partner": proton_partner.name})

View File

@ -87,6 +87,7 @@ class RedisSessionStore(SessionInterface):
httponly = self.get_cookie_httponly(app) httponly = self.get_cookie_httponly(app)
secure = self.get_cookie_secure(app) secure = self.get_cookie_secure(app)
expires = self.get_expiration_time(app, session) expires = self.get_expiration_time(app, session)
samesite = self.get_cookie_samesite(app)
val = pickle.dumps(dict(session)) val = pickle.dumps(dict(session))
ttl = int(app.permanent_session_lifetime.total_seconds()) ttl = int(app.permanent_session_lifetime.total_seconds())
# Only 5 minutes for non-authenticated sessions. # Only 5 minutes for non-authenticated sessions.
@ -109,6 +110,7 @@ class RedisSessionStore(SessionInterface):
domain=domain, domain=domain,
path=path, path=path,
secure=secure, secure=secure,
samesite=samesite,
) )

View File

@ -3,7 +3,7 @@ from requests import RequestException
from app import config from app import config
from app.events.event_dispatcher import EventDispatcher from app.events.event_dispatcher import EventDispatcher
from app.events.generated.event_pb2 import EventContent, UserPlanChange from app.events.generated.event_pb2 import EventContent, UserPlanChanged
from app.log import LOG from app.log import LOG
from app.models import User from app.models import User
@ -34,5 +34,5 @@ def execute_subscription_webhook(user: User):
except RequestException as e: except RequestException as e:
LOG.error(f"Subscription request exception: {e}") LOG.error(f"Subscription request exception: {e}")
event = UserPlanChange(plan_end_time=sl_subscription_end) event = UserPlanChanged(plan_end_time=sl_subscription_end)
EventDispatcher.send_event(user, EventContent(user_plan_change=event)) EventDispatcher.send_event(user, EventContent(user_plan_change=event))

47
app/app/user_settings.py Normal file
View File

@ -0,0 +1,47 @@
from typing import Optional
from app.db import Session
from app.log import LOG
from app.models import User, SLDomain, CustomDomain
class CannotSetAlias(Exception):
def __init__(self, msg: str):
self.msg = msg
def set_default_alias_domain(user: User, domain_name: Optional[str]):
if domain_name is None:
LOG.i(f"User {user} has set no domain as default domain")
user.default_alias_public_domain_id = None
user.default_alias_custom_domain_id = None
Session.flush()
return
sl_domain: SLDomain = SLDomain.get_by(domain=domain_name)
if sl_domain:
if sl_domain.hidden:
LOG.i(f"User {user} has tried to set up a hidden domain as default domain")
raise CannotSetAlias("Domain does not exist")
if sl_domain.premium_only and not user.is_premium():
LOG.i(f"User {user} has tried to set up a premium domain as default domain")
raise CannotSetAlias("You cannot use this domain")
LOG.i(f"User {user} has set public {sl_domain} as default domain")
user.default_alias_public_domain_id = sl_domain.id
user.default_alias_custom_domain_id = None
Session.flush()
return
custom_domain = CustomDomain.get_by(domain=domain_name)
if not custom_domain:
LOG.i(
f"User {user} has tried to set up an non existing domain as default domain"
)
raise CannotSetAlias("Domain does not exist or it hasn't been verified")
if custom_domain.user_id != user.id or not custom_domain.verified:
LOG.i(
f"User {user} has tried to set domain {custom_domain} as default domain that does not belong to the user or that is not verified"
)
raise CannotSetAlias("Domain does not exist or it hasn't been verified")
LOG.i(f"User {user} has set custom {custom_domain} as default domain")
user.default_alias_public_domain_id = None
user.default_alias_custom_domain_id = custom_domain.id
Session.flush()

View File

@ -266,11 +266,13 @@ def notify_manual_sub_end():
"Your SimpleLogin subscription will end soon", "Your SimpleLogin subscription will end soon",
render( render(
"transactional/coinbase/reminder-subscription.txt", "transactional/coinbase/reminder-subscription.txt",
user=user,
coinbase_subscription=coinbase_subscription, coinbase_subscription=coinbase_subscription,
extend_subscription_url=extend_subscription_url, extend_subscription_url=extend_subscription_url,
), ),
render( render(
"transactional/coinbase/reminder-subscription.html", "transactional/coinbase/reminder-subscription.html",
user=user,
coinbase_subscription=coinbase_subscription, coinbase_subscription=coinbase_subscription,
extend_subscription_url=extend_subscription_url, extend_subscription_url=extend_subscription_url,
), ),
@ -826,10 +828,12 @@ def check_mailbox_valid_domain():
f"Mailbox {mailbox.email} is disabled", f"Mailbox {mailbox.email} is disabled",
render( render(
"transactional/disable-mailbox-warning.txt.jinja2", "transactional/disable-mailbox-warning.txt.jinja2",
user=mailbox.user,
mailbox=mailbox, mailbox=mailbox,
), ),
render( render(
"transactional/disable-mailbox-warning.html", "transactional/disable-mailbox-warning.html",
user=mailbox.user,
mailbox=mailbox, mailbox=mailbox,
), ),
retries=3, retries=3,
@ -884,6 +888,7 @@ def check_mailbox_valid_pgp_keys():
f"Mailbox {mailbox.email}'s PGP Key is invalid", f"Mailbox {mailbox.email}'s PGP Key is invalid",
render( render(
"transactional/invalid-mailbox-pgp-key.txt.jinja2", "transactional/invalid-mailbox-pgp-key.txt.jinja2",
user=mailbox.user,
mailbox=mailbox, mailbox=mailbox,
), ),
retries=3, retries=3,
@ -924,6 +929,7 @@ def check_single_custom_domain(custom_domain):
f"Please update {custom_domain.domain} DNS on SimpleLogin", f"Please update {custom_domain.domain} DNS on SimpleLogin",
render( render(
"transactional/custom-domain-dns-issue.txt.jinja2", "transactional/custom-domain-dns-issue.txt.jinja2",
user=user,
custom_domain=custom_domain, custom_domain=custom_domain,
domain_dns_url=domain_dns_url, domain_dns_url=domain_dns_url,
), ),

View File

@ -235,13 +235,14 @@ 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:
alias_id = alias.id
try: try:
contact_email_for_reply = ( contact_email_for_reply = (
contact_email if is_valid_email(contact_email) else "" contact_email if is_valid_email(contact_email) else ""
) )
contact = Contact.create( contact = Contact.create(
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=contact_name, name=contact_name,
mail_from=mail_from, mail_from=mail_from,
@ -261,9 +262,12 @@ def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Con
Session.commit() Session.commit()
except IntegrityError: except IntegrityError:
LOG.w(f"Contact with email {contact_email} for alias {alias} already exist") # If the tx has been rolled back, the connection is borked. Force close to try to get a new one and start fresh
Session.rollback() Session.close()
contact = Contact.get_by(alias_id=alias.id, website_email=contact_email) LOG.info(
f"Contact with email {contact_email} for alias_id {alias_id} already existed, fetching from DB"
)
contact = Contact.get_by(alias_id=alias_id, website_email=contact_email)
return contact return contact
@ -601,12 +605,14 @@ def handle_email_sent_to_ourself(alias, from_addr: str, msg: Message, user):
f"Email sent to {alias.email} from its own mailbox {from_addr}", f"Email sent to {alias.email} from its own mailbox {from_addr}",
render( render(
"transactional/cycle-email.txt.jinja2", "transactional/cycle-email.txt.jinja2",
user=user,
alias=alias, alias=alias,
from_addr=from_addr, from_addr=from_addr,
refused_email_url=refused_email_url, refused_email_url=refused_email_url,
), ),
render( render(
"transactional/cycle-email.html", "transactional/cycle-email.html",
user=user,
alias=alias, alias=alias,
from_addr=from_addr, from_addr=from_addr,
refused_email_url=refused_email_url, refused_email_url=refused_email_url,
@ -660,6 +666,9 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str
from_header = get_header_unicode(msg[headers.FROM]) from_header = get_header_unicode(msg[headers.FROM])
LOG.d("Create or get contact for from_header:%s", from_header) LOG.d("Create or get contact for from_header:%s", from_header)
contact = get_or_create_contact(from_header, envelope.mail_from, alias) contact = get_or_create_contact(from_header, envelope.mail_from, alias)
alias = (
contact.alias
) # In case the Session was closed in the get_or_create we re-fetch the alias
reply_to_contact = None reply_to_contact = None
if msg[headers.REPLY_TO]: if msg[headers.REPLY_TO]:
@ -728,12 +737,14 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str
f"Your mailbox {mailbox.email} is an alias", f"Your mailbox {mailbox.email} is an alias",
render( render(
"transactional/mailbox-invalid.txt.jinja2", "transactional/mailbox-invalid.txt.jinja2",
user=mailbox.user,
mailbox=mailbox, mailbox=mailbox,
mailbox_url=mailbox_url, mailbox_url=mailbox_url,
alias=alias, alias=alias,
), ),
render( render(
"transactional/mailbox-invalid.html", "transactional/mailbox-invalid.html",
user=mailbox.user,
mailbox=mailbox, mailbox=mailbox,
mailbox_url=mailbox_url, mailbox_url=mailbox_url,
alias=alias, alias=alias,
@ -786,12 +797,14 @@ def forward_email_to_mailbox(
f"Your mailbox {mailbox.email} and alias {alias.email} use the same domain", f"Your mailbox {mailbox.email} and alias {alias.email} use the same domain",
render( render(
"transactional/mailbox-invalid.txt.jinja2", "transactional/mailbox-invalid.txt.jinja2",
user=mailbox.user,
mailbox=mailbox, mailbox=mailbox,
mailbox_url=mailbox_url, mailbox_url=mailbox_url,
alias=alias, alias=alias,
), ),
render( render(
"transactional/mailbox-invalid.html", "transactional/mailbox-invalid.html",
user=mailbox.user,
mailbox=mailbox, mailbox=mailbox,
mailbox_url=mailbox_url, mailbox_url=mailbox_url,
alias=alias, alias=alias,
@ -1276,6 +1289,7 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str):
f"Email sent to {contact.email} contains non reverse-alias addresses", f"Email sent to {contact.email} contains non reverse-alias addresses",
render( render(
"transactional/non-reverse-alias-reply-phase.txt.jinja2", "transactional/non-reverse-alias-reply-phase.txt.jinja2",
user=alias.user,
destination=contact.email, destination=contact.email,
alias=alias.email, alias=alias.email,
subject=msg[headers.SUBJECT], subject=msg[headers.SUBJECT],
@ -1497,6 +1511,7 @@ def handle_unknown_mailbox(
f"Attempt to use your alias {alias.email} from {envelope.mail_from}", f"Attempt to use your alias {alias.email} from {envelope.mail_from}",
render( render(
"transactional/reply-must-use-personal-email.txt", "transactional/reply-must-use-personal-email.txt",
user=user,
alias=alias, alias=alias,
sender=envelope.mail_from, sender=envelope.mail_from,
authorize_address_link=authorize_address_link, authorize_address_link=authorize_address_link,
@ -1504,6 +1519,7 @@ def handle_unknown_mailbox(
), ),
render( render(
"transactional/reply-must-use-personal-email.html", "transactional/reply-must-use-personal-email.html",
user=user,
alias=alias, alias=alias,
sender=envelope.mail_from, sender=envelope.mail_from,
authorize_address_link=authorize_address_link, authorize_address_link=authorize_address_link,
@ -1604,12 +1620,14 @@ def handle_bounce_forward_phase(msg: Message, email_log: EmailLog):
f"Alias {alias.email} has been disabled due to multiple bounces", f"Alias {alias.email} has been disabled due to multiple bounces",
render( render(
"transactional/bounce/automatic-disable-alias.txt", "transactional/bounce/automatic-disable-alias.txt",
user=alias.user,
alias=alias, alias=alias,
refused_email_url=refused_email_url, refused_email_url=refused_email_url,
mailbox_email=mailbox.email, mailbox_email=mailbox.email,
), ),
render( render(
"transactional/bounce/automatic-disable-alias.html", "transactional/bounce/automatic-disable-alias.html",
user=alias.user,
alias=alias, alias=alias,
refused_email_url=refused_email_url, refused_email_url=refused_email_url,
mailbox_email=mailbox.email, mailbox_email=mailbox.email,
@ -1648,6 +1666,7 @@ def handle_bounce_forward_phase(msg: Message, email_log: EmailLog):
f"An email sent to {alias.email} cannot be delivered to your mailbox", f"An email sent to {alias.email} cannot be delivered to your mailbox",
render( render(
"transactional/bounce/bounced-email.txt.jinja2", "transactional/bounce/bounced-email.txt.jinja2",
user=alias.user,
alias=alias, alias=alias,
website_email=contact.website_email, website_email=contact.website_email,
disable_alias_link=disable_alias_link, disable_alias_link=disable_alias_link,
@ -1657,6 +1676,7 @@ def handle_bounce_forward_phase(msg: Message, email_log: EmailLog):
), ),
render( render(
"transactional/bounce/bounced-email.html", "transactional/bounce/bounced-email.html",
user=alias.user,
alias=alias, alias=alias,
website_email=contact.website_email, website_email=contact.website_email,
disable_alias_link=disable_alias_link, disable_alias_link=disable_alias_link,
@ -1749,12 +1769,14 @@ def handle_bounce_reply_phase(envelope, msg: Message, email_log: EmailLog):
f"Email cannot be sent to { contact.email } from your alias { alias.email }", f"Email cannot be sent to { contact.email } from your alias { alias.email }",
render( render(
"transactional/bounce/bounce-email-reply-phase.txt", "transactional/bounce/bounce-email-reply-phase.txt",
user=user,
alias=alias, alias=alias,
contact=contact, contact=contact,
refused_email_url=refused_email_url, refused_email_url=refused_email_url,
), ),
render( render(
"transactional/bounce/bounce-email-reply-phase.html", "transactional/bounce/bounce-email-reply-phase.html",
user=user,
alias=alias, alias=alias,
contact=contact, contact=contact,
refused_email_url=refused_email_url, refused_email_url=refused_email_url,
@ -1817,6 +1839,7 @@ def handle_spam(
f"Email from {alias.email} to {contact.website_email} is detected as spam", f"Email from {alias.email} to {contact.website_email} is detected as spam",
render( render(
"transactional/spam-email-reply-phase.txt", "transactional/spam-email-reply-phase.txt",
user=user,
alias=alias, alias=alias,
website_email=contact.website_email, website_email=contact.website_email,
disable_alias_link=disable_alias_link, disable_alias_link=disable_alias_link,
@ -1824,6 +1847,7 @@ def handle_spam(
), ),
render( render(
"transactional/spam-email-reply-phase.html", "transactional/spam-email-reply-phase.html",
user=user,
alias=alias, alias=alias,
website_email=contact.website_email, website_email=contact.website_email,
disable_alias_link=disable_alias_link, disable_alias_link=disable_alias_link,
@ -1846,6 +1870,7 @@ def handle_spam(
f"Email from {contact.website_email} to {alias.email} is detected as spam", f"Email from {contact.website_email} to {alias.email} is detected as spam",
render( render(
"transactional/spam-email.txt", "transactional/spam-email.txt",
user=user,
alias=alias, alias=alias,
website_email=contact.website_email, website_email=contact.website_email,
disable_alias_link=disable_alias_link, disable_alias_link=disable_alias_link,
@ -1853,6 +1878,7 @@ def handle_spam(
), ),
render( render(
"transactional/spam-email.html", "transactional/spam-email.html",
user=user,
alias=alias, alias=alias,
website_email=contact.website_email, website_email=contact.website_email,
disable_alias_link=disable_alias_link, disable_alias_link=disable_alias_link,
@ -2009,7 +2035,7 @@ def send_no_reply_response(mail_from: str, msg: Message):
ALERT_TO_NOREPLY, ALERT_TO_NOREPLY,
mailbox.user.email, mailbox.user.email,
"Auto: {}".format(msg[headers.SUBJECT] or "No subject"), "Auto: {}".format(msg[headers.SUBJECT] or "No subject"),
render("transactional/noreply.text.jinja2"), render("transactional/noreply.text.jinja2", user=mailbox.user),
) )
@ -2091,6 +2117,7 @@ def handle(envelope: Envelope, msg: Message) -> str:
"SimpleLogin shouldn't be used with another email forwarding system", "SimpleLogin shouldn't be used with another email forwarding system",
render( render(
"transactional/email-sent-from-reverse-alias.txt.jinja2", "transactional/email-sent-from-reverse-alias.txt.jinja2",
user=user,
), ),
) )

View File

@ -3,9 +3,12 @@ from enum import Enum
from sys import argv, exit from sys import argv, exit
from app.config import DB_URI from app.config import DB_URI
from app.log import LOG
from events.runner import Runner from events.runner import Runner
from events.event_source import DeadLetterEventSource, PostgresEventSource from events.event_source import DeadLetterEventSource, PostgresEventSource
from events.event_sink import ConsoleEventSink from events.event_sink import ConsoleEventSink, HttpEventSink
_DEFAULT_MAX_RETRIES = 100
class Mode(Enum): class Mode(Enum):
@ -22,18 +25,22 @@ class Mode(Enum):
raise ValueError(f"Invalid mode: {value}") raise ValueError(f"Invalid mode: {value}")
def main(mode: Mode, dry_run: bool): def main(mode: Mode, dry_run: bool, max_retries: int):
if mode == Mode.DEAD_LETTER: if mode == Mode.DEAD_LETTER:
source = DeadLetterEventSource() LOG.i("Using DeadLetterEventSource")
source = DeadLetterEventSource(max_retries)
elif mode == Mode.LISTENER: elif mode == Mode.LISTENER:
LOG.i("Using PostgresEventSource")
source = PostgresEventSource(DB_URI) source = PostgresEventSource(DB_URI)
else: else:
raise ValueError(f"Invalid mode: {mode}") raise ValueError(f"Invalid mode: {mode}")
if dry_run: if dry_run:
LOG.i("Starting with ConsoleEventSink")
sink = ConsoleEventSink() sink = ConsoleEventSink()
else: else:
sink = ConsoleEventSink() LOG.i("Starting with HttpEventSink")
sink = HttpEventSink()
runner = Runner(source=source, sink=sink) runner = Runner(source=source, sink=sink)
runner.run() runner.run()
@ -46,6 +53,13 @@ def args():
help="Mode to run", help="Mode to run",
choices=[Mode.DEAD_LETTER.value, Mode.LISTENER.value], choices=[Mode.DEAD_LETTER.value, Mode.LISTENER.value],
) )
parser.add_argument(
"max_retries",
help="Max retries to consider an event as error and not try to process it again",
type=int,
nargs="?",
default=_DEFAULT_MAX_RETRIES,
)
parser.add_argument("--dry-run", help="Dry run mode", action="store_true") parser.add_argument("--dry-run", help="Dry run mode", action="store_true")
return parser.parse_args() return parser.parse_args()
@ -56,4 +70,8 @@ if __name__ == "__main__":
exit(1) exit(1)
args = args() args = args()
main(Mode.from_str(args.mode), args.dry_run) main(
mode=Mode.from_str(args.mode),
dry_run=args.dry_run,
max_retries=args.max_retries,
)

View File

@ -1,19 +1,42 @@
import requests
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from app.config import EVENT_WEBHOOK, EVENT_WEBHOOK_SKIP_VERIFY_SSL
from app.log import LOG from app.log import LOG
from app.models import SyncEvent from app.models import SyncEvent
class EventSink(ABC): class EventSink(ABC):
@abstractmethod @abstractmethod
def process(self, event: SyncEvent): def process(self, event: SyncEvent) -> bool:
pass pass
class HttpEventSink(EventSink): class HttpEventSink(EventSink):
def process(self, event: SyncEvent): def process(self, event: SyncEvent) -> bool:
pass if not EVENT_WEBHOOK:
LOG.warning("Skipping sending event because there is no webhook configured")
return False
LOG.info(f"Sending event {event.id} to {EVENT_WEBHOOK}")
res = requests.post(
url=EVENT_WEBHOOK,
data=event.content,
headers={"Content-Type": "application/x-protobuf"},
verify=not EVENT_WEBHOOK_SKIP_VERIFY_SSL,
)
if res.status_code != 200:
LOG.warning(
f"Failed to send event to webhook: {res.status_code} {res.text}"
)
return False
else:
LOG.info(f"Event {event.id} sent successfully to webhook")
return True
class ConsoleEventSink(EventSink): class ConsoleEventSink(EventSink):
def process(self, event: SyncEvent): def process(self, event: SyncEvent) -> bool:
LOG.info(f"Handling event {event.id}") LOG.info(f"Handling event {event.id}")
return True

View File

@ -4,6 +4,8 @@ import psycopg2
import select import select
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from app.db import Session
from app.log import LOG from app.log import LOG
from app.models import SyncEvent from app.models import SyncEvent
from app.events.event_dispatcher import NOTIFICATION_CHANNEL from app.events.event_dispatcher import NOTIFICATION_CHANNEL
@ -13,6 +15,8 @@ from typing import Callable, NoReturn
_DEAD_LETTER_THRESHOLD_MINUTES = 10 _DEAD_LETTER_THRESHOLD_MINUTES = 10
_DEAD_LETTER_INTERVAL_SECONDS = 30 _DEAD_LETTER_INTERVAL_SECONDS = 30
_POSTGRES_RECONNECT_INTERVAL_SECONDS = 5
class EventSource(ABC): class EventSource(ABC):
@abstractmethod @abstractmethod
@ -22,9 +26,19 @@ class EventSource(ABC):
class PostgresEventSource(EventSource): class PostgresEventSource(EventSource):
def __init__(self, connection_string: str): def __init__(self, connection_string: str):
self.__connection = psycopg2.connect(connection_string) self.__connection_string = connection_string
self.__connect()
def run(self, on_event: Callable[[SyncEvent], NoReturn]): def run(self, on_event: Callable[[SyncEvent], NoReturn]):
while True:
try:
self.__listen(on_event)
except Exception as e:
LOG.warn(f"Error listening to events: {e}")
sleep(_POSTGRES_RECONNECT_INTERVAL_SECONDS)
self.__connect()
def __listen(self, on_event: Callable[[SyncEvent], NoReturn]):
self.__connection.set_isolation_level( self.__connection.set_isolation_level(
psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT
) )
@ -44,14 +58,30 @@ class PostgresEventSource(EventSource):
webhook_id = int(notify.payload) webhook_id = int(notify.payload)
event = SyncEvent.get_by(id=webhook_id) event = SyncEvent.get_by(id=webhook_id)
if event is not None: if event is not None:
on_event(event) if event.mark_as_taken():
on_event(event)
else:
LOG.info(
f"Event {event.id} was handled by another runner"
)
else: else:
LOG.info(f"Could not find event with id={notify.payload}") LOG.info(f"Could not find event with id={notify.payload}")
except Exception as e: except Exception as e:
LOG.warn(f"Error getting event: {e}") LOG.warn(f"Error getting event: {e}")
Session.close() # Ensure we get a new connection and we don't leave a dangling tx
def __connect(self):
self.__connection = psycopg2.connect(self.__connection_string)
from app.db import Session
Session.close()
class DeadLetterEventSource(EventSource): class DeadLetterEventSource(EventSource):
def __init__(self, max_retries: int):
self.__max_retries = max_retries
@newrelic.agent.background_task() @newrelic.agent.background_task()
def run(self, on_event: Callable[[SyncEvent], NoReturn]): def run(self, on_event: Callable[[SyncEvent], NoReturn]):
while True: while True:
@ -59,7 +89,9 @@ class DeadLetterEventSource(EventSource):
threshold = arrow.utcnow().shift( threshold = arrow.utcnow().shift(
minutes=-_DEAD_LETTER_THRESHOLD_MINUTES minutes=-_DEAD_LETTER_THRESHOLD_MINUTES
) )
events = SyncEvent.get_dead_letter(older_than=threshold) events = SyncEvent.get_dead_letter(
older_than=threshold, max_retries=self.__max_retries
)
if events: if events:
LOG.info(f"Got {len(events)} dead letter events") LOG.info(f"Got {len(events)} dead letter events")
if events: if events:
@ -68,8 +100,10 @@ class DeadLetterEventSource(EventSource):
) )
for event in events: for event in events:
on_event(event) on_event(event)
else: Session.close() # Ensure that we have a new connection and we don't have a dangling tx with a lock
if not events:
LOG.debug("No dead letter events") LOG.debug("No dead letter events")
sleep(_DEAD_LETTER_INTERVAL_SECONDS) sleep(_DEAD_LETTER_INTERVAL_SECONDS)
except Exception as e: except Exception as e:
LOG.warn(f"Error getting dead letter event: {e}") LOG.warn(f"Error getting dead letter event: {e}")
sleep(_DEAD_LETTER_INTERVAL_SECONDS)

View File

@ -2,6 +2,7 @@ import arrow
import newrelic.agent import newrelic.agent
from app.log import LOG from app.log import LOG
from app.db import Session
from app.models import SyncEvent from app.models import SyncEvent
from events.event_sink import EventSink from events.event_sink import EventSink
from events.event_source import EventSource from events.event_source import EventSource
@ -18,11 +19,10 @@ class Runner:
@newrelic.agent.background_task() @newrelic.agent.background_task()
def __on_event(self, event: SyncEvent): def __on_event(self, event: SyncEvent):
try: try:
can_process = event.mark_as_taken() event_created_at = event.created_at
if can_process: start_time = arrow.now()
event_created_at = event.created_at success = self.__sink.process(event)
start_time = arrow.now() if success:
self.__sink.process(event)
event_id = event.id event_id = event.id
SyncEvent.delete(event.id, commit=True) SyncEvent.delete(event.id, commit=True)
LOG.info(f"Marked {event_id} as done") LOG.info(f"Marked {event_id} as done")
@ -39,7 +39,8 @@ class Runner:
time_between_taken_and_created.total_seconds(), time_between_taken_and_created.total_seconds(),
) )
else: else:
LOG.info(f"{event.id} was handled by another runner") event.retry_count = event.retry_count + 1
Session.commit()
except Exception as e: except Exception as e:
LOG.warn(f"Exception processing event [id={event.id}]: {e}") LOG.warn(f"Exception processing event [id={event.id}]: {e}")
newrelic.agent.record_custom_metric("Custom/sync_event_failed", 1) newrelic.agent.record_custom_metric("Custom/sync_event_failed", 1)

View File

@ -15,6 +15,7 @@ from app.email_utils import (
render, render,
) )
from app.import_utils import handle_batch_import from app.import_utils import handle_batch_import
from app.jobs.event_jobs import send_alias_creation_events_for_user
from app.jobs.export_user_data_job import ExportUserDataJob from app.jobs.export_user_data_job import ExportUserDataJob
from app.log import LOG from app.log import LOG
from app.models import User, Job, BatchImport, Mailbox, CustomDomain, JobState from app.models import User, Job, BatchImport, Mailbox, CustomDomain, JobState
@ -197,13 +198,18 @@ def process_job(job: Job):
onboarding_mailbox(user) onboarding_mailbox(user)
elif job.name == config.JOB_ONBOARDING_4: elif job.name == config.JOB_ONBOARDING_4:
user_id = job.payload.get("user_id") user_id = job.payload.get("user_id")
user = User.get(user_id) user: User = User.get(user_id)
# user might delete their account in the meantime # user might delete their account in the meantime
# or disable the notification # or disable the notification
if user and user.notification and user.activated: if user and user.notification and user.activated:
LOG.d("send onboarding pgp email to user %s", user) # if user only has 1 mailbox which is Proton then do not send PGP onboarding email
onboarding_pgp(user) mailboxes = user.mailboxes()
if len(mailboxes) == 1 and mailboxes[0].is_proton():
LOG.d("Do not send onboarding PGP email to Proton mailbox")
else:
LOG.d("send onboarding pgp email to user %s", user)
onboarding_pgp(user)
elif job.name == config.JOB_BATCH_IMPORT: elif job.name == config.JOB_BATCH_IMPORT:
batch_import_id = job.payload.get("batch_import_id") batch_import_id = job.payload.get("batch_import_id")
@ -219,16 +225,15 @@ def process_job(job: Job):
user_email = user.email user_email = user.email
LOG.w("Delete user %s", user) LOG.w("Delete user %s", user)
User.delete(user.id)
Session.commit()
send_email( send_email(
user_email, user_email,
"Your SimpleLogin account has been deleted", "Your SimpleLogin account has been deleted",
render("transactional/account-delete.txt"), render("transactional/account-delete.txt", user=user),
render("transactional/account-delete.html"), render("transactional/account-delete.html", user=user),
retries=3, retries=3,
) )
User.delete(user.id)
Session.commit()
elif job.name == config.JOB_DELETE_MAILBOX: elif job.name == config.JOB_DELETE_MAILBOX:
delete_mailbox_job(job) delete_mailbox_job(job)
@ -264,8 +269,14 @@ SimpleLogin team.
user_id = job.payload.get("user_id") user_id = job.payload.get("user_id")
user = User.get(user_id) user = User.get(user_id)
if user and user.activated: if user and user.activated:
LOG.d("send proton welcome email to user %s", user) LOG.d("Send proton welcome email to user %s", user)
welcome_proton(user) welcome_proton(user)
elif job.name == config.JOB_SEND_ALIAS_CREATION_EVENTS:
user_id = job.payload.get("user_id")
user = User.get(user_id)
if user and user.activated:
LOG.d(f"Sending alias creation events for {user}")
send_alias_creation_events_for_user(user)
else: else:
LOG.e("Unknown job name %s", job.name) LOG.e("Unknown job name %s", job.name)

View File

@ -0,0 +1,31 @@
"""empty message
Revision ID: d608b8e48082
Revises: 06a9a7133445
Create Date: 2024-07-05 16:56:04.220173
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'd608b8e48082'
down_revision = '06a9a7133445'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('deleted_alias', sa.Column('reason', sa.Integer(), default=0, server_default='0', nullable=False))
op.add_column('domain_deleted_alias', sa.Column('reason', sa.Integer(), default=0, server_default='0', nullable=False))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('domain_deleted_alias', 'reason')
op.drop_column('deleted_alias', 'reason')
# ### end Alembic commands ###

View File

@ -0,0 +1,28 @@
"""add retry count to sync event
Revision ID: 56d08955fcab
Revises: d608b8e48082
Create Date: 2024-07-19 08:21:19.979973
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '56d08955fcab'
down_revision = 'd608b8e48082'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('sync_event', sa.Column('retry_count', sa.Integer(), server_default='0', nullable=False, default=0))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('sync_event', 'retry_count')
# ### end Alembic commands ###

View File

@ -4,6 +4,7 @@ import subprocess
from time import sleep from time import sleep
from typing import List, Dict from typing import List, Dict
import arrow
import newrelic.agent import newrelic.agent
from app.db import Session from app.db import Session
@ -93,11 +94,44 @@ def log_nb_db_connection():
newrelic.agent.record_custom_metric("Custom/nb_db_connections", nb_connection) newrelic.agent.record_custom_metric("Custom/nb_db_connections", nb_connection)
@newrelic.agent.background_task()
def log_pending_to_process_events():
r = Session.execute("select count(*) from sync_event WHERE taken_time IS NULL;")
events_pending = list(r)[0][0]
LOG.d("number of events pending to process %s", events_pending)
newrelic.agent.record_custom_metric(
"Custom/sync_events_pending_to_process", events_pending
)
@newrelic.agent.background_task()
def log_events_pending_dead_letter():
since = arrow.now().shift(minutes=-10).datetime
r = Session.execute(
"""
SELECT COUNT(*)
FROM sync_event
WHERE (taken_time IS NOT NULL AND taken_time < :since)
OR (taken_time IS NULL AND created_at < :since)
""",
{"since": since},
)
events_pending = list(r)[0][0]
LOG.d("number of events pending dead letter %s", events_pending)
newrelic.agent.record_custom_metric(
"Custom/sync_events_pending_dead_letter", events_pending
)
if __name__ == "__main__": if __name__ == "__main__":
exporter = MetricExporter(get_newrelic_license()) exporter = MetricExporter(get_newrelic_license())
while True: while True:
log_postfix_metrics() log_postfix_metrics()
log_nb_db_connection() log_nb_db_connection()
log_pending_to_process_events()
log_events_pending_dead_letter()
Session.close() Session.close()
exporter.run() exporter.run()

View File

@ -0,0 +1,55 @@
#!/usr/bin/env python3
import argparse
import time
from sqlalchemy import func
from app.models import Alias, User
from app.db import Session
parser = argparse.ArgumentParser(
prog="Backfill alias", description="Backfill user flags for partner alias created"
)
parser.add_argument(
"-s", "--start_user_id", default=0, type=int, help="Initial user_id"
)
parser.add_argument("-e", "--end_user_id", default=0, type=int, help="Last user_id")
args = parser.parse_args()
user_id_start = args.start_user_id
max_user_id = args.end_user_id
if max_user_id == 0:
max_user_id = Session.query(func.max(User.id)).scalar()
print(f"Checking user {user_id_start} to {max_user_id}")
step = 1000
el_query = "SELECT user_id, count(id) from alias where user_id>=:start AND user_id < :end AND flags & :alias_flag > 0 GROUP BY user_id"
user_update_query = "UPDATE users set flags = flags | :user_flag where id = :user_id"
updated = 0
start_time = time.time()
for batch_start in range(user_id_start, max_user_id, step):
rows = Session.execute(
el_query,
{
"start": batch_start,
"end": batch_start + step,
"alias_flag": Alias.FLAG_PARTNER_CREATED,
},
)
for row in rows:
if row[1] > 0:
Session.execute(
user_update_query,
{"user_id": row[0], "user_flag": User.FLAG_CREATED_ALIAS_FROM_PARTNER},
)
Session.commit()
updated += 1
elapsed = time.time() - start_time
time_per_alias = elapsed / (updated + 1)
last_batch_id = batch_start + step
remaining = max_user_id - last_batch_id
time_remaining = (max_user_id - last_batch_id) * time_per_alias
hours_remaining = time_remaining / 3600.0
print(
f"\rUser {batch_start}/{max_user_id} {updated} {hours_remaining:.2f}hrs remaining"
)
print("")

28
app/poetry.lock generated
View File

@ -2150,24 +2150,22 @@ wcwidth = "*"
[[package]] [[package]]
name = "protobuf" name = "protobuf"
version = "4.24.3" version = "5.27.1"
description = "" description = ""
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.8"
files = [ files = [
{file = "protobuf-4.24.3-cp310-abi3-win32.whl", hash = "sha256:20651f11b6adc70c0f29efbe8f4a94a74caf61b6200472a9aea6e19898f9fcf4"}, {file = "protobuf-5.27.1-cp310-abi3-win32.whl", hash = "sha256:3adc15ec0ff35c5b2d0992f9345b04a540c1e73bfee3ff1643db43cc1d734333"},
{file = "protobuf-4.24.3-cp310-abi3-win_amd64.whl", hash = "sha256:3d42e9e4796a811478c783ef63dc85b5a104b44aaaca85d4864d5b886e4b05e3"}, {file = "protobuf-5.27.1-cp310-abi3-win_amd64.whl", hash = "sha256:25236b69ab4ce1bec413fd4b68a15ef8141794427e0b4dc173e9d5d9dffc3bcd"},
{file = "protobuf-4.24.3-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:6e514e8af0045be2b56e56ae1bb14f43ce7ffa0f68b1c793670ccbe2c4fc7d2b"}, {file = "protobuf-5.27.1-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4e38fc29d7df32e01a41cf118b5a968b1efd46b9c41ff515234e794011c78b17"},
{file = "protobuf-4.24.3-cp37-abi3-manylinux2014_aarch64.whl", hash = "sha256:ba53c2f04798a326774f0e53b9c759eaef4f6a568ea7072ec6629851c8435959"}, {file = "protobuf-5.27.1-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:917ed03c3eb8a2d51c3496359f5b53b4e4b7e40edfbdd3d3f34336e0eef6825a"},
{file = "protobuf-4.24.3-cp37-abi3-manylinux2014_x86_64.whl", hash = "sha256:f6ccbcf027761a2978c1406070c3788f6de4a4b2cc20800cc03d52df716ad675"}, {file = "protobuf-5.27.1-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:ee52874a9e69a30271649be88ecbe69d374232e8fd0b4e4b0aaaa87f429f1631"},
{file = "protobuf-4.24.3-cp37-cp37m-win32.whl", hash = "sha256:1b182c7181a2891e8f7f3a1b5242e4ec54d1f42582485a896e4de81aa17540c2"}, {file = "protobuf-5.27.1-cp38-cp38-win32.whl", hash = "sha256:7a97b9c5aed86b9ca289eb5148df6c208ab5bb6906930590961e08f097258107"},
{file = "protobuf-4.24.3-cp37-cp37m-win_amd64.whl", hash = "sha256:b0271a701e6782880d65a308ba42bc43874dabd1a0a0f41f72d2dac3b57f8e76"}, {file = "protobuf-5.27.1-cp38-cp38-win_amd64.whl", hash = "sha256:f6abd0f69968792da7460d3c2cfa7d94fd74e1c21df321eb6345b963f9ec3d8d"},
{file = "protobuf-4.24.3-cp38-cp38-win32.whl", hash = "sha256:e29d79c913f17a60cf17c626f1041e5288e9885c8579832580209de8b75f2a52"}, {file = "protobuf-5.27.1-cp39-cp39-win32.whl", hash = "sha256:dfddb7537f789002cc4eb00752c92e67885badcc7005566f2c5de9d969d3282d"},
{file = "protobuf-4.24.3-cp38-cp38-win_amd64.whl", hash = "sha256:067f750169bc644da2e1ef18c785e85071b7c296f14ac53e0900e605da588719"}, {file = "protobuf-5.27.1-cp39-cp39-win_amd64.whl", hash = "sha256:39309898b912ca6febb0084ea912e976482834f401be35840a008da12d189340"},
{file = "protobuf-4.24.3-cp39-cp39-win32.whl", hash = "sha256:2da777d34b4f4f7613cdf85c70eb9a90b1fbef9d36ae4a0ccfe014b0b07906f1"}, {file = "protobuf-5.27.1-py3-none-any.whl", hash = "sha256:4ac7249a1530a2ed50e24201d6630125ced04b30619262f06224616e0030b6cf"},
{file = "protobuf-4.24.3-cp39-cp39-win_amd64.whl", hash = "sha256:f631bb982c5478e0c1c70eab383af74a84be66945ebf5dd6b06fc90079668d0b"}, {file = "protobuf-5.27.1.tar.gz", hash = "sha256:df5e5b8e39b7d1c25b186ffdf9f44f40f810bbcc9d2b71d9d3156fee5a9adf15"},
{file = "protobuf-4.24.3-py3-none-any.whl", hash = "sha256:f6f8dc65625dadaad0c8545319c2e2f0424fede988368893ca3844261342c11a"},
{file = "protobuf-4.24.3.tar.gz", hash = "sha256:12e9ad2ec079b833176d2921be2cb24281fa591f0b119b208b788adc48c2561d"},
] ]
[[package]] [[package]]

View File

@ -2,7 +2,7 @@ syntax = "proto3";
package simplelogin_events; package simplelogin_events;
message UserPlanChange { message UserPlanChanged {
uint32 plan_end_time = 1; uint32 plan_end_time = 1;
} }
@ -16,7 +16,7 @@ message AliasCreated {
bool enabled = 4; bool enabled = 4;
} }
message AliasStatusChange { message AliasStatusChanged {
uint32 alias_id = 1; uint32 alias_id = 1;
string alias_email = 2; string alias_email = 2;
bool enabled = 3; bool enabled = 3;
@ -27,13 +27,18 @@ message AliasDeleted {
string alias_email = 2; string alias_email = 2;
} }
message AliasCreatedList {
repeated AliasCreated events = 1;
}
message EventContent { message EventContent {
oneof content { oneof content {
UserPlanChange user_plan_change = 1; UserPlanChanged user_plan_change = 1;
UserDeleted user_deleted = 2; UserDeleted user_deleted = 2;
AliasCreated alias_created = 3; AliasCreated alias_created = 3;
AliasStatusChange alias_status_change = 4; AliasStatusChanged alias_status_change = 4;
AliasDeleted alias_deleted = 5; AliasDeleted alias_deleted = 5;
AliasCreatedList alias_create_list = 6;
} }
} }
@ -42,4 +47,4 @@ message Event {
string external_user_id = 2; string external_user_id = 2;
uint32 partner_id = 3; uint32 partner_id = 3;
EventContent content = 4; EventContent content = 4;
} }

View File

@ -12,10 +12,10 @@ docker run -p 25432:5432 --name ${container_name} -e POSTGRES_PASSWORD=postgres
sleep 3 sleep 3
# upgrade the DB to the latest stage and # upgrade the DB to the latest stage and
env DB_URI=postgresql://postgres:postgres@127.0.0.1:25432/sl poetry run alembic upgrade head env DB_URI=postgresql://postgres:postgres@127.0.0.1:25432/sl rye run alembic upgrade head
# generate the migration script. # generate the migration script.
env DB_URI=postgresql://postgres:postgres@127.0.0.1:25432/sl poetry run alembic revision --autogenerate $@ env DB_URI=postgresql://postgres:postgres@127.0.0.1:25432/sl rye run alembic revision --autogenerate $@
# remove the db # remove the db
docker rm -f ${container_name} docker rm -f ${container_name}

View File

@ -3,5 +3,5 @@
export DB_URI=postgresql://myuser:mypassword@localhost:15432/simplelogin export DB_URI=postgresql://myuser:mypassword@localhost:15432/simplelogin
echo 'drop schema public cascade; create schema public;' | psql $DB_URI echo 'drop schema public cascade; create schema public;' | psql $DB_URI
poetry run alembic upgrade head rye run alembic upgrade head
poetry run flask dummy-data rye run flask dummy-data

View File

@ -3,4 +3,4 @@
export DB_URI=postgresql://myuser:mypassword@localhost:15432/test export DB_URI=postgresql://myuser:mypassword@localhost:15432/test
echo 'drop schema public cascade; create schema public;' | psql $DB_URI echo 'drop schema public cascade; create schema public;' | psql $DB_URI
poetry run alembic upgrade head rye run alembic upgrade head

View File

@ -29,7 +29,7 @@ from sentry_sdk.integrations.flask import FlaskIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
from app import paddle_utils, config, paddle_callback from app import paddle_utils, config, paddle_callback, constants
from app.admin_model import ( from app.admin_model import (
SLAdminIndexView, SLAdminIndexView,
UserAdmin, UserAdmin,
@ -283,6 +283,7 @@ def set_index_page(app):
and not request.path.startswith("/git") and not request.path.startswith("/git")
and not request.path.startswith("/favicon.ico") and not request.path.startswith("/favicon.ico")
): ):
start_time = g.start_time or time.time()
LOG.d( LOG.d(
"%s %s %s %s %s, takes %s", "%s %s %s %s %s, takes %s",
request.remote_addr, request.remote_addr,
@ -290,7 +291,7 @@ def set_index_page(app):
request.path, request.path,
request.args, request.args,
res.status_code, res.status_code,
time.time() - g.start_time, time.time() - start_time,
) )
return res return res
@ -430,6 +431,7 @@ def jinja2_filter(app):
PAGE_LIMIT=PAGE_LIMIT, PAGE_LIMIT=PAGE_LIMIT,
ZENDESK_ENABLED=ZENDESK_ENABLED, ZENDESK_ENABLED=ZENDESK_ENABLED,
MAX_NB_EMAIL_FREE_PLAN=MAX_NB_EMAIL_FREE_PLAN, MAX_NB_EMAIL_FREE_PLAN=MAX_NB_EMAIL_FREE_PLAN,
HEADER_ALLOW_API_COOKIES=constants.HEADER_ALLOW_API_COOKIES,
) )
@ -542,6 +544,7 @@ def setup_paddle_callback(app: Flask):
"SimpleLogin - your subscription is canceled", "SimpleLogin - your subscription is canceled",
render( render(
"transactional/subscription-cancel.txt", "transactional/subscription-cancel.txt",
user=user,
end_date=request.form.get("cancellation_effective_date"), end_date=request.form.get("cancellation_effective_date"),
), ),
) )
@ -701,7 +704,12 @@ def setup_coinbase_commerce(app):
def handle_coinbase_event(event) -> bool: def handle_coinbase_event(event) -> bool:
user_id = int(event["data"]["metadata"]["user_id"]) server_user_id = event["data"]["metadata"]["user_id"]
try:
user_id = int(server_user_id)
except ValueError:
user_id = int(float(server_user_id))
code = event["data"]["code"] code = event["data"]["code"]
user = User.get(user_id) user = User.get(user_id)
if not user: if not user:
@ -722,10 +730,12 @@ def handle_coinbase_event(event) -> bool:
"Your SimpleLogin account has been upgraded", "Your SimpleLogin account has been upgraded",
render( render(
"transactional/coinbase/new-subscription.txt", "transactional/coinbase/new-subscription.txt",
user=user,
coinbase_subscription=coinbase_subscription, coinbase_subscription=coinbase_subscription,
), ),
render( render(
"transactional/coinbase/new-subscription.html", "transactional/coinbase/new-subscription.html",
user=user,
coinbase_subscription=coinbase_subscription, coinbase_subscription=coinbase_subscription,
), ),
) )
@ -746,10 +756,12 @@ def handle_coinbase_event(event) -> bool:
"Your SimpleLogin account has been extended", "Your SimpleLogin account has been extended",
render( render(
"transactional/coinbase/extend-subscription.txt", "transactional/coinbase/extend-subscription.txt",
user=user,
coinbase_subscription=coinbase_subscription, coinbase_subscription=coinbase_subscription,
), ),
render( render(
"transactional/coinbase/extend-subscription.html", "transactional/coinbase/extend-subscription.html",
user=user,
coinbase_subscription=coinbase_subscription, coinbase_subscription=coinbase_subscription,
), ),
) )

View File

@ -51,14 +51,19 @@ $(".enable-disable-alias").change(async function () {
await disableAlias(aliasId, alias); await disableAlias(aliasId, alias);
}); });
function getHeaders() {
return {
"Content-Type": "application/json",
"X-Sl-Allowcookies": 'allow',
}
}
async function disableAlias(aliasId, alias) { async function disableAlias(aliasId, alias) {
let oldValue; let oldValue;
try { try {
let res = await fetch(`/api/aliases/${aliasId}/toggle`, { let res = await fetch(`/api/aliases/${aliasId}/toggle`, {
method: "POST", method: "POST",
headers: { headers: getHeaders()
"Content-Type": "application/json",
}
}); });
if (res.ok) { if (res.ok) {
@ -94,9 +99,7 @@ $(".enable-disable-pgp").change(async function (e) {
try { try {
let res = await fetch(`/api/aliases/${aliasId}`, { let res = await fetch(`/api/aliases/${aliasId}`, {
method: "PUT", method: "PUT",
headers: { headers: getHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify({ body: JSON.stringify({
disable_pgp: oldValue, disable_pgp: oldValue,
}), }),
@ -129,9 +132,7 @@ $(".pin-alias").change(async function () {
try { try {
let res = await fetch(`/api/aliases/${aliasId}`, { let res = await fetch(`/api/aliases/${aliasId}`, {
method: "PUT", method: "PUT",
headers: { headers: getHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify({ body: JSON.stringify({
pinned: newValue, pinned: newValue,
}), }),
@ -161,9 +162,7 @@ async function handleNoteChange(aliasId, aliasEmail) {
try { try {
let res = await fetch(`/api/aliases/${aliasId}`, { let res = await fetch(`/api/aliases/${aliasId}`, {
method: "PUT", method: "PUT",
headers: { headers: getHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify({ body: JSON.stringify({
note: note, note: note,
}), }),
@ -200,9 +199,7 @@ async function handleMailboxChange(aliasId, aliasEmail) {
try { try {
let res = await fetch(`/api/aliases/${aliasId}`, { let res = await fetch(`/api/aliases/${aliasId}`, {
method: "PUT", method: "PUT",
headers: { headers: getHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify({ body: JSON.stringify({
mailbox_ids: mailbox_ids, mailbox_ids: mailbox_ids,
}), }),
@ -225,9 +222,7 @@ async function handleDisplayNameChange(aliasId, aliasEmail) {
try { try {
let res = await fetch(`/api/aliases/${aliasId}`, { let res = await fetch(`/api/aliases/${aliasId}`, {
method: "PUT", method: "PUT",
headers: { headers: getHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify({ body: JSON.stringify({
name: name, name: name,
}), }),

BIN
app/static/logo-proton.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@ -264,6 +264,7 @@
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
'{{HEADER_ALLOW_API_COOKIES}}': 'allow'
} }
}); });

View File

@ -80,7 +80,10 @@
}, },
methods: { methods: {
generateRandomAlias: async function (event) { generateRandomAlias: async function (event) {
let result = await fetch('/api/alias/random/new', {method: 'POST'}); let result = await fetch('/api/alias/random/new', {method: 'POST',
headers: {
'{{HEADER_ALLOW_API_COOKIES}}': 'allow'
}});
if (result.ok) { if (result.ok) {
let data = await result.json(); let data = await result.json();
this.ticket_email = data.alias; this.ticket_email = data.alias;

View File

@ -1,623 +1,8 @@
{% from "_emailhelpers.html" import render_text, text, render_button, raw_url, grey_section, section %} {% if USE_PARTNER_TEMPLATE %}
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="x-apple-disable-message-reformatting" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<style type="text/css" rel="stylesheet" media="all">
/* Base ------------------------------ */
body {
width: 100% !important;
height: 100%;
margin: 0;
-webkit-text-size-adjust: none;
line-height: 1.6;
}
img { {% extends "base_partner.html" %}
max-width: 100%;
}
a { {% else %}
color: #3869D4; {% extends "base_sl.html" %}
}
a img { {% endif %}
border: none;
}
td {
word-break: break-word;
}
.preheader {
display: none !important;
visibility: hidden;
mso-hide: all;
font-size: 1px;
line-height: 1px;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
}
/* Type ------------------------------ */
body,
td,
th {
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
}
h1 {
margin-top: 0;
color: #333333;
font-size: 22px;
font-weight: bold;
text-align: left;
}
h2 {
margin-top: 0;
color: #333333;
font-size: 16px;
font-weight: bold;
text-align: left;
}
h3 {
margin-top: 0;
color: #333333;
font-size: 14px;
font-weight: bold;
text-align: left;
}
td,
th {
font-size: 16px;
}
p,
ul,
ol,
blockquote {
margin: .4em 0 1.1875em;
font-size: 16px;
line-height: 1.625;
}
p.sub {
font-size: 13px;
}
/* Utilities ------------------------------ */
.align-right {
text-align: right;
}
.align-left {
text-align: left;
}
.align-center {
text-align: center;
}
/* Buttons ------------------------------ */
.button {
background-color: #3869D4;
border-top: 10px solid #3869D4;
border-right: 18px solid #3869D4;
border-bottom: 10px solid #3869D4;
border-left: 18px solid #3869D4;
display: inline-block;
color: #FFF;
text-decoration: none;
border-radius: 3px;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
-webkit-text-size-adjust: none;
box-sizing: border-box;
}
.button--green {
background-color: #22BC66;
border-top: 10px solid #22BC66;
border-right: 18px solid #22BC66;
border-bottom: 10px solid #22BC66;
border-left: 18px solid #22BC66;
}
.button--red {
background-color: #FF6136;
border-top: 10px solid #FF6136;
border-right: 18px solid #FF6136;
border-bottom: 10px solid #FF6136;
border-left: 18px solid #FF6136;
}
@media only screen and (max-width: 500px) {
.button {
width: 100% !important;
text-align: center !important;
}
}
/* Attribute list ------------------------------ */
.attributes {
margin: 0 0 21px;
}
.attributes_content {
background-color: #F4F4F7;
padding: 16px;
}
.attributes_item {
padding: 0;
}
/* Related Items ------------------------------ */
.related {
width: 100%;
margin: 0;
padding: 25px 0 0 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.related_item {
padding: 10px 0;
color: #CBCCCF;
font-size: 15px;
line-height: 18px;
}
.related_item-title {
display: block;
margin: .5em 0 0;
}
.related_item-thumb {
display: block;
padding-bottom: 10px;
}
.related_heading {
border-top: 1px solid #CBCCCF;
text-align: center;
padding: 25px 0 10px;
}
/* Discount Code ------------------------------ */
.discount {
width: 100%;
margin: 0;
padding: 24px;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #F4F4F7;
border: 2px dashed #CBCCCF;
}
.discount_heading {
text-align: center;
}
.discount_body {
text-align: center;
font-size: 15px;
}
/* Social Icons ------------------------------ */
.social {
width: auto;
}
.social td {
padding: 0;
width: auto;
}
.social_icon {
height: 20px;
margin: 0 8px 10px 8px;
padding: 0;
}
/* Data table ------------------------------ */
.purchase {
width: 100%;
margin: 0;
padding: 35px 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.purchase_content {
width: 100%;
margin: 0;
padding: 25px 0 0 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.purchase_item {
padding: 10px 0;
color: #51545E;
font-size: 15px;
line-height: 18px;
}
.purchase_heading {
padding-bottom: 8px;
border-bottom: 1px solid #EAEAEC;
}
.purchase_heading p {
margin: 0;
color: #85878E;
font-size: 12px;
}
.purchase_footer {
padding-top: 15px;
border-top: 1px solid #EAEAEC;
}
.purchase_total {
margin: 0;
text-align: right;
font-weight: bold;
color: #333333;
}
.purchase_total--label {
padding: 0 15px 0 0;
}
body {
background-color: #F2F4F6;
color: #51545E;
}
p {
color: #51545E;
}
.email-wrapper {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #F2F4F6;
}
.email-content {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
/* Masthead ----------------------- */
.email-masthead {
padding: 25px 0;
text-align: center;
}
.email-masthead_logo {
width: 94px;
}
.email-masthead_name {
font-size: 16px;
font-weight: bold;
color: #A8AAAF;
text-decoration: none;
text-shadow: 0 1px 0 white;
}
/* Body ------------------------------ */
.email-body {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.email-body_inner {
width: 750px;
margin: 0 auto;
padding: 0;
-premailer-width: 750px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #FFFFFF;
}
.email-footer {
width: 750px;
margin: 0 auto;
padding: 0;
-premailer-width: 750px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
}
.email-footer p {
color: #A8AAAF;
}
.body-action {
width: 100%;
margin: 30px auto;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
}
.body-sub {
margin-top: 25px;
padding-top: 25px;
border-top: 1px solid #EAEAEC;
}
.content-cell {
padding: 30px;
}
/*Media Queries ------------------------------ */
@media only screen and (max-width: 600px) {
.email-body_inner,
.email-footer {
width: 100% !important;
}
}
@media (prefers-color-scheme: dark) {
body,
.email-body,
.email-body_inner,
.email-content,
.email-wrapper,
.email-masthead,
.email-footer {
background-color: #333333 !important;
color: #FFF !important;
}
p,
ul,
ol,
blockquote,
h1,
h2,
h3 {
color: #FFF !important;
}
.attributes_content,
.discount {
background-color: #222 !important;
}
.email-masthead_name {
text-shadow: none !important;
}
}
</style>
<!--[if mso]>
<style type="text/css">
.f-fallback {
font-family: Arial, sans-serif;
}
</style>
<![endif]-->
<style type="text/css" rel="stylesheet" media="all">
body {
width: 100% !important;
height: 100%;
margin: 0;
-webkit-text-size-adjust: none;
}
body {
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
}
body {
background-color: #F2F4F6;
color: #51545E;
}
</style>
</head>
<body style="width: 100% !important;
height: 100%;
-webkit-text-size-adjust: none;
font-family: Helvetica, Arial, sans-serif;
background-color: #F2F4F6;
color: #51545E;
margin: 0;"
bgcolor="#F2F4F6">
<span class="preheader"
style="display: none !important;
visibility: hidden;
mso-hide: all;
font-size: 1px;
line-height: 1px;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;">{{ pre_header }}</span>
<table class="email-wrapper"
width="100%"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 100%;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #F2F4F6;
margin: 0;
padding: 0;"
bgcolor="#F2F4F6">
<tr>
<td align="center"
style="word-break: break-word;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;">
<table class="email-content"
width="100%"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 100%;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
margin: 0;
padding: 0;">
<tr>
<td class="email-masthead"
style="word-break: break-word;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;
text-align: center;
padding: 25px 0;"
align="center">
<a href="{{ LANDING_PAGE_URL }}"
class="f-fallback email-masthead_name"
style="color: #A8AAAF;
font-size: 16px;
font-weight: bold;
text-decoration: none;
text-shadow: 0 1px 0 white;">
{% block logo %}<img src="{{ URL }}/static/logo.png" style="width: 150px; margin: auto">{% endblock %}
</a>
</td>
</tr>
<!-- Email Body -->
<tr>
<td class="email-body"
width="750"
cellpadding="0"
cellspacing="0"
style="word-break: break-word;
margin: 0;
padding: 0;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;
width: 100%;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;">
<table class="email-body_inner"
align="center"
width="750"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 750px;
-premailer-width: 750px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #FFFFFF;
margin: 0 auto;
padding: 0;"
bgcolor="#FFFFFF">
<!-- Body content -->
<tr>
<td class="content-cell"
style="word-break: break-word;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;
padding: 30px;">
<div class="f-fallback">
{% block greeting %}{% endblock %}
{% block content %}{% endblock %}
<!-- Sub copy -->
{% block sub_copy %}{% endblock %}
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="word-break: break-word;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;">
<table class="email-footer"
align="center"
width="750"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 750px;
-premailer-width: 750px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
margin: 0 auto;
padding: 0;">
<tr>
<td class="content-cell"
align="center"
style="word-break: break-word;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;
padding: 30px;">
<p class="f-fallback sub align-center"
style="font-size: 13px;
line-height: 1.625;
text-align: center;
color: #A8AAAF;
margin: .4em 0 1.1875em;"
align="center">
© {{ YEAR }} SimpleLogin - a Proton product. All rights reserved.
<br />
{% block footer %}{% endblock %}
</p>
{% if unsubscribe_oneclick is defined %}
<p class="f-fallback sub align-center"
style="font-size: 13px;
line-height: 1.625;
text-align: center;
margin: .4em 0 1.1875em;">
<a href="{{ unsubscribe_oneclick }}">Unsubscribe from our newsletter</a>
</p>
{% endif %}
<p class="f-fallback sub align-center"
style="font-size: 13px;
line-height: 1.625;
text-align: center;
color: #A8AAAF;
margin: .4em 0 1.1875em;"
align="center">
<a href="https://app.simplelogin.io/dashboard/support">Do you have a question?</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,646 @@
{% from "_emailhelpers.html" import render_text, text, render_button, raw_url, grey_section, section %}
<!doctype html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:v="urn:schemas-microsoft-com:vml"
xmlns:o="urn:schemas-microsoft-com:office:office">
<head>
<!-- NAME: 1 COLUMN -->
<!--[if gte mso 15]>
<xml>
<o:OfficeDocumentSettings>
<o:AllowPNG/>
<o:PixelsPerInch>96</o:PixelsPerInch>
</o:OfficeDocumentSettings>
</xml>
<![endif]-->
<meta charset="UTF-8">
<meta name="x-apple-disable-message-reformatting">
<meta name="format-detection"
content="telephone=no, date=no, address=no, email=no, url=no">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="author" content="Proton">
<style type="text/css">
p {
margin: 12px 0;
padding: 0;
}
table {
border-collapse: collapse;
}
h1,
h2,
h3,
h4,
h5,
h6 {
display: block;
margin: 0;
padding: 0;
}
img,
a img {
border: 0;
height: auto;
outline: none;
text-decoration: none;
}
body,
#bodyTable,
#bodyCell {
height: 100%;
margin: 0;
padding: 0;
width: 100%;
}
.mcnPreviewText {
display: none !important;
}
#outlook a {
padding: 0;
}
img {
-ms-interpolation-mode: bicubic;
}
table {
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
}
.ReadMsgBody {
width: 100%;
}
.ExternalClass {
width: 100%;
}
p,
a,
li,
td,
blockquote {
mso-line-height-rule: exactly;
}
a[href^=tel],
a[href^=sms] {
color: inherit;
cursor: default;
text-decoration: none;
}
p,
a,
li,
td,
body,
table,
blockquote {
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass td,
.ExternalClass div,
.ExternalClass span,
.ExternalClass font {
line-height: 100%;
}
.no-link a,
a[x-apple-data-detectors],
a[href^="x-apple-data-detectors:"] {
color: inherit !important;
text-decoration: none !important;
font-size: inherit !important;
font-family: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
}
#bodyCell {
padding: 10px;
}
.templateContainer {
max-width: 600px !important;
}
a.mcnButton {
display: block;
}
.mcnImage,
.mcnRetinaImage {
vertical-align: bottom;
}
.mcnTextContent {
word-break: break-word;
}
.mcnTextContent img {
height: auto !important;
}
.mcnDividerBlock {
table-layout: fixed !important;
}
.mcnHalfTextRight {
border: 1px solid red;
}
@media only screen and (min-width:768px) {
.templateContainer {
width: 600px !important;
}
}
@media only screen and (max-width: 480px) {
body,
table,
td,
p,
a,
li,
blockquote {
-webkit-text-size-adjust: none !important;
}
body {
width: 100% !important;
min-width: 100% !important;
}
.mcnRetinaImage {
max-width: 100% !important;
}
.mcnImage {
width: 100% !important;
}
.mcnCaptionLeftImageContent .mcnImage,
.mcnCaptionRightImageContent .mcnImage {
width: 176px !important;
}
.mcnHalfCaptionLeftImageContent .mcnImage,
.mcnHalfCaptionRightImageContent .mcnImage {
width: 268px !important;
}
.mcnBoxContentColumnBoxed {
padding: 8px !important;
margin: 0 !important;
}
.mcnButtonContentContainer,
.mcnCartContainer,
.mcnCaptionTopContent,
.mcnRecContentContainer,
.mcnCaptionBottomContent,
.mcnTextContentContainer,
.mcnBoxedTextContentContainer,
.mcnImageGroupContentContainer,
.mcnCaptionLeftTextContentContainer,
.mcnCaptionRightTextContentContainer,
.mcnCaptionLeftImageContentContainer,
.mcnCaptionRightImageContentContainer,
.mcnImageCardLeftTextContentContainer,
.mcnImageCardRightTextContentContainer,
.mcnImageCardLeftImageContentContainer,
.mcnImageCardRightImageContentContainer {
max-width: 100% !important;
width: 100% !important;
}
.mcnBoxedTextContentContainer {
min-width: 100% !important;
}
.mcnImageGroupContent {
padding: 16px !important;
}
.mcnCaptionLeftContentOuter .mcnTextContent,
.mcnCaptionRightContentOuter .mcnTextContent {
padding-top: 16px !important;
}
.mcnImageCardTopImageContent,
.mcnCaptionBottomContent:last-child .mcnCaptionBottomImageContent,
.mcnCaptionBlockInner .mcnCaptionTopContent:last-child .mcnTextContent {
padding-top: 32px !important;
}
.mcnHalfCaptionLeftImageContent,
.mcnHalfCaptionRightImageContent {
text-align: center;
}
.mcnImageCardBottomImageContent {
padding-bottom: 16px !important;
}
.mcnImageGroupBlockInner {
padding-top: 0 !important;
padding-bottom: 0 !important;
}
.mcnImageGroupBlockOuter {
padding-top: 16px !important;
padding-bottom: 16px !important;
}
.mcnTextContent,
.mcnBoxedTextContentColumn {
padding-right: 32px !important;
padding-left: 32px !important;
}
.mcnCaptionBottomContent .mcnTextContent {
padding-left: 16px !important;
padding-right: 16px !important;
}
.mcnCaptionLeftTextContentContainer .mcnTextContent,
.mcnCaptionRightTextContentContainer .mcnTextContent {
padding-right: 0 !important;
padding-left: 0 !important;
}
.mcnImageCardLeftImageContent,
.mcnImageCardRightImageContent {
padding-right: 32px !important;
padding-bottom: 0 !important;
padding-left: 32px !important;
}
.mcnTextContent ul,
.mcnTextContent ol {
padding-inline-start: 24px !important;
}
.mcnButtonContent {
padding-left: 24px !important;
padding-right: 24px !important;
}
.mcnButtonHint {
padding-left: 16px !important;
padding-right: 16px !important;
}
.mcnButtonHint,
.mcnButtonHint * {
text-align: center !important;
}
.mcpreview-image-uploader {
display: none !important;
width: 100% !important;
}
.hide-on-mobile {
display: none !important;
}
.flex-stack-on-mobile {
flex-direction: column;
}
.flex-stack-on-mobile .mcnCaptionBottomContent,
.flex-stack-on-mobile .mcnBoxContentContainer {
height: auto !important;
}
/*
@tab Mobile Styles
@section Heading 1
@tip Make the first-level headings larger in size for better readability on small screens.
*/
h1 {
font-size: 22px !important;
line-height: 1.25em !important;
}
/*
@tab Mobile Styles
@section Heading 2
@tip Make the second-level headings larger in size for better readability on small screens.
*/
h2 {
font-size: 20px !important;
line-height: 1.25em !important;
}
/*
@tab Mobile Styles
@section Heading 3
@tip Make the third-level headings larger in size for better readability on small screens.
*/
h3 {
font-size: 18px !important;
line-height: 1.25em !important;
}
/*
@tab Mobile Styles
@section Heading 4
@tip Make the fourth-level headings larger in size for better readability on small screens.
*/
h4 {
font-size: 16px !important;
line-height: 1.5em !important;
}
/*
@tab Mobile Styles
@section Boxed Text
@tip Make the boxed text larger in size for better readability on small screens. We recommend a font size of at least 16px.
*/
.mcnBoxedTextContentContainer .mcnTextContent,
.mcnBoxedTextContentContainer .mcnTextContent p {
font-size: 14px !important;
line-height: 1.5em !important;
}
/*
@tab Mobile Styles
@section Preheader Visibility
@tip Set the visibility of the email's preheader on small screens. You can hide it to save space.
*/
#templatePreheader {
display: block !important;
}
/*
@tab Mobile Styles
@section Preheader Text
@tip Make the preheader text larger in size for better readability on small screens.
*/
#templatePreheader .mcnTextContent,
#templatePreheader .mcnTextContent p,
#templateBody .templatePreheader .mcnTextContent,
#templateBody .templatePreheader .mcnTextContent p {
font-size: 13px !important;
line-height: 1.5em !important;
}
/*
@tab Mobile Styles
@section Header Text
@tip Make the header text larger in size for better readability on small screens.
*/
#templateHeader .mcnTextContent,
#templateHeader .mcnTextContent p {
font-size: 16px !important;
line-height: 1.5em !important;
}
/*
@tab Mobile Styles
@section Body Text
@tip Make the body text larger in size for better readability on small screens. We recommend a font size of at least 16px.
*/
#templateBody .mcnTextContent,
#templateBody .mcnTextContent p {
font-size: 16px !important;
line-height: 1.5em !important;
}
/*
@tab Mobile Styles
@section Body Caption Text
@tip Make the body text larger in size for better readability on small screens. We recommend a font size of at least 16px.
*/
#templateBody .templateBodyCaption,
#templateBody .templateBodyCaption p {
font-size: 14px !important;
line-height: 1.5em !important;
}
/*
@tab Mobile Styles
@section Footer Text
@tip Make the footer content text larger in size for better readability on small screens.
*/
#templateFooter .mcnTextContent,
#templateFooter .mcnTextContent p {
font-size: 14px !important;
line-height: 1.5em !important;
}
/*
@tab Mobile Styles
@section Footer Follow icons
@tip Reduce the spacing between the footer icons to avoid a line-break them on small screens.
*/
#templateFooter .mcnFollowContentItemContainer {
padding-left: 2px !important;
padding-right: 2px !important;
}
/*
@tab Mobile Styles
@section Footer Follow icons
@tip Reduce the spacing between the footer icons to avoid a line-break them on small screens.
*/
#templateFooter .mcnFollowContentItemContainerSmall {
padding-left: 8px !important;
padding-right: 8px !important;
}
/*
@text Mobile Style
*/
.mcnCaptionRightImageContent,
.mcnCaptionLeftImageContent {
text-align: center;
}
}
@media only screen and (max-width: 352px) {
/*
@tab Mobile Styles
@section Footer Follow icons
@tip Reduce the icon size on very small screens.
*/
.mcnFollowIconContent,
.mcnFollowIconContent img.social-icon {
width: 38px !important;
height: 38px !important;
}
/*
@tab Mobile Styles
@section Footer Follow icons
@tip Remove the spacing between the footer icons to avoid a line-break them on very small screens.
*/
#templateFooter .mcnFollowContentItemContainer {
padding-left: 0 !important;
padding-right: 0 !important;
}
}
</style>
</head>
<body style="height: 100%;
margin: 0;
padding: 0;
width: 100%;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;">
<!--[if !gte mso 9]><!----><span class="mcnPreviewText"
style="display:none;
font-size:0px;
line-height:0px;
max-height:0px;
max-width:0px;
opacity:0;
overflow:hidden;
visibility:hidden;
mso-hide:all;"></span><!--<![endif]-->
<center>
<table align="center"
border="0"
cellpadding="0"
cellspacing="0"
height="100%"
width="100%"
id="bodyTable"
style="border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
height: 100%;
margin: 0;
padding: 0;
width: 100%;">
<tr>
<td align="center"
valign="top"
id="bodyCell"
style="mso-line-height-rule: exactly;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
height: 100%;
margin: 0;
padding: 8px;
width: 100%;">
<!-- BEGIN TEMPLATE // -->
<!--[if (gte mso 9)|(IE)]>
<table align="center" border="0" cellspacing="0" cellpadding="0" width="600" style="width:600px;"><tr><td align="center" valign="top" width="600" style="width:600px;">
<![endif]-->
<table border="0"
cellpadding="0"
cellspacing="0"
width="100%"
class="templateContainer"
style="border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;
max-width: 600px !important;">
<tr>
<td valign="top"
id="templateHeader"
style="mso-line-height-rule: exactly;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;">
<table border="0"
cellpadding="0"
cellspacing="0"
width="100%"
class="mcnImageBlock"
style="min-width: 100%;
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;">
<tbody class="mcnImageBlockOuter">
<tr>
<td valign="top"
style="padding: 16px;
mso-line-height-rule: exactly;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;"
class="mcnImageBlockInner">
<table align="left"
width="100%"
border="0"
cellpadding="0"
cellspacing="0"
class="mcnImageContentContainer"
style="min-width: 100%;
border-collapse: collapse;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;">
<tbody>
<tr>
<td class="mcnImageContent"
valign="top"
style="padding: 16px;
text-align: center;
mso-line-height-rule: exactly;
-ms-text-size-adjust: 100%;
-webkit-text-size-adjust: 100%;">
<a href="https://proton.me/" target="_blank" style="">
<img align="center"
alt="Proton"
src="{{ URL }}/static/logo-proton.png"
width="190"
style="width:35.4477%; max-width: 380px; padding-bottom: 0; display: inline !important; vertical-align: bottom; border: 0; height: auto; outline: none; text-decoration: none; -ms-interpolation-mode: bicubic; ">
</a>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
<tr>
<td valign="top"
id="templateBody"
style="mso-line-height-rule: exactly; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%; ">
{% block greeting %}{% endblock %}
{% block content %}{% endblock %}
<!-- Sub copy -->
{% block sub_copy %}{% endblock %}
</td>
</tr>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</table>
<![endif]-->
<!-- // END TEMPLATE -->
</td>
</tr>
</table>
</center>
</body>
</html>

View File

@ -0,0 +1,623 @@
{% from "_emailhelpers.html" import render_text, text, render_button, raw_url, grey_section, section %}
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="en">
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="x-apple-disable-message-reformatting" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<style type="text/css" rel="stylesheet" media="all">
/* Base ------------------------------ */
body {
width: 100% !important;
height: 100%;
margin: 0;
-webkit-text-size-adjust: none;
line-height: 1.6;
}
img {
max-width: 100%;
}
a {
color: #3869D4;
}
a img {
border: none;
}
td {
word-break: break-word;
}
.preheader {
display: none !important;
visibility: hidden;
mso-hide: all;
font-size: 1px;
line-height: 1px;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;
}
/* Type ------------------------------ */
body,
td,
th {
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
}
h1 {
margin-top: 0;
color: #333333;
font-size: 22px;
font-weight: bold;
text-align: left;
}
h2 {
margin-top: 0;
color: #333333;
font-size: 16px;
font-weight: bold;
text-align: left;
}
h3 {
margin-top: 0;
color: #333333;
font-size: 14px;
font-weight: bold;
text-align: left;
}
td,
th {
font-size: 16px;
}
p,
ul,
ol,
blockquote {
margin: .4em 0 1.1875em;
font-size: 16px;
line-height: 1.625;
}
p.sub {
font-size: 13px;
}
/* Utilities ------------------------------ */
.align-right {
text-align: right;
}
.align-left {
text-align: left;
}
.align-center {
text-align: center;
}
/* Buttons ------------------------------ */
.button {
background-color: #3869D4;
border-top: 10px solid #3869D4;
border-right: 18px solid #3869D4;
border-bottom: 10px solid #3869D4;
border-left: 18px solid #3869D4;
display: inline-block;
color: #FFF;
text-decoration: none;
border-radius: 3px;
box-shadow: 0 2px 3px rgba(0, 0, 0, 0.16);
-webkit-text-size-adjust: none;
box-sizing: border-box;
}
.button--green {
background-color: #22BC66;
border-top: 10px solid #22BC66;
border-right: 18px solid #22BC66;
border-bottom: 10px solid #22BC66;
border-left: 18px solid #22BC66;
}
.button--red {
background-color: #FF6136;
border-top: 10px solid #FF6136;
border-right: 18px solid #FF6136;
border-bottom: 10px solid #FF6136;
border-left: 18px solid #FF6136;
}
@media only screen and (max-width: 500px) {
.button {
width: 100% !important;
text-align: center !important;
}
}
/* Attribute list ------------------------------ */
.attributes {
margin: 0 0 21px;
}
.attributes_content {
background-color: #F4F4F7;
padding: 16px;
}
.attributes_item {
padding: 0;
}
/* Related Items ------------------------------ */
.related {
width: 100%;
margin: 0;
padding: 25px 0 0 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.related_item {
padding: 10px 0;
color: #CBCCCF;
font-size: 15px;
line-height: 18px;
}
.related_item-title {
display: block;
margin: .5em 0 0;
}
.related_item-thumb {
display: block;
padding-bottom: 10px;
}
.related_heading {
border-top: 1px solid #CBCCCF;
text-align: center;
padding: 25px 0 10px;
}
/* Discount Code ------------------------------ */
.discount {
width: 100%;
margin: 0;
padding: 24px;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #F4F4F7;
border: 2px dashed #CBCCCF;
}
.discount_heading {
text-align: center;
}
.discount_body {
text-align: center;
font-size: 15px;
}
/* Social Icons ------------------------------ */
.social {
width: auto;
}
.social td {
padding: 0;
width: auto;
}
.social_icon {
height: 20px;
margin: 0 8px 10px 8px;
padding: 0;
}
/* Data table ------------------------------ */
.purchase {
width: 100%;
margin: 0;
padding: 35px 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.purchase_content {
width: 100%;
margin: 0;
padding: 25px 0 0 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.purchase_item {
padding: 10px 0;
color: #51545E;
font-size: 15px;
line-height: 18px;
}
.purchase_heading {
padding-bottom: 8px;
border-bottom: 1px solid #EAEAEC;
}
.purchase_heading p {
margin: 0;
color: #85878E;
font-size: 12px;
}
.purchase_footer {
padding-top: 15px;
border-top: 1px solid #EAEAEC;
}
.purchase_total {
margin: 0;
text-align: right;
font-weight: bold;
color: #333333;
}
.purchase_total--label {
padding: 0 15px 0 0;
}
body {
background-color: #F2F4F6;
color: #51545E;
}
p {
color: #51545E;
}
.email-wrapper {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #F2F4F6;
}
.email-content {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
/* Masthead ----------------------- */
.email-masthead {
padding: 25px 0;
text-align: center;
}
.email-masthead_logo {
width: 94px;
}
.email-masthead_name {
font-size: 16px;
font-weight: bold;
color: #A8AAAF;
text-decoration: none;
text-shadow: 0 1px 0 white;
}
/* Body ------------------------------ */
.email-body {
width: 100%;
margin: 0;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
}
.email-body_inner {
width: 750px;
margin: 0 auto;
padding: 0;
-premailer-width: 750px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #FFFFFF;
}
.email-footer {
width: 750px;
margin: 0 auto;
padding: 0;
-premailer-width: 750px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
}
.email-footer p {
color: #A8AAAF;
}
.body-action {
width: 100%;
margin: 30px auto;
padding: 0;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
}
.body-sub {
margin-top: 25px;
padding-top: 25px;
border-top: 1px solid #EAEAEC;
}
.content-cell {
padding: 30px;
}
/*Media Queries ------------------------------ */
@media only screen and (max-width: 600px) {
.email-body_inner,
.email-footer {
width: 100% !important;
}
}
@media (prefers-color-scheme: dark) {
body,
.email-body,
.email-body_inner,
.email-content,
.email-wrapper,
.email-masthead,
.email-footer {
background-color: #333333 !important;
color: #FFF !important;
}
p,
ul,
ol,
blockquote,
h1,
h2,
h3 {
color: #FFF !important;
}
.attributes_content,
.discount {
background-color: #222 !important;
}
.email-masthead_name {
text-shadow: none !important;
}
}
</style>
<!--[if mso]>
<style type="text/css">
.f-fallback {
font-family: Arial, sans-serif;
}
</style>
<![endif]-->
<style type="text/css" rel="stylesheet" media="all">
body {
width: 100% !important;
height: 100%;
margin: 0;
-webkit-text-size-adjust: none;
}
body {
font-family: "Nunito Sans", Helvetica, Arial, sans-serif;
}
body {
background-color: #F2F4F6;
color: #51545E;
}
</style>
</head>
<body style="width: 100% !important;
height: 100%;
-webkit-text-size-adjust: none;
font-family: Helvetica, Arial, sans-serif;
background-color: #F2F4F6;
color: #51545E;
margin: 0;"
bgcolor="#F2F4F6">
<span class="preheader"
style="display: none !important;
visibility: hidden;
mso-hide: all;
font-size: 1px;
line-height: 1px;
max-height: 0;
max-width: 0;
opacity: 0;
overflow: hidden;">{{ pre_header }}</span>
<table class="email-wrapper"
width="100%"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 100%;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #F2F4F6;
margin: 0;
padding: 0;"
bgcolor="#F2F4F6">
<tr>
<td align="center"
style="word-break: break-word;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;">
<table class="email-content"
width="100%"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 100%;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
margin: 0;
padding: 0;">
<tr>
<td class="email-masthead"
style="word-break: break-word;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;
text-align: center;
padding: 25px 0;"
align="center">
<a href="{{ LANDING_PAGE_URL }}"
class="f-fallback email-masthead_name"
style="color: #A8AAAF;
font-size: 16px;
font-weight: bold;
text-decoration: none;
text-shadow: 0 1px 0 white;">
{% block logo %}<img src="{{ URL }}/static/logo.png" style="width: 150px; margin: auto">{% endblock %}
</a>
</td>
</tr>
<!-- Email Body -->
<tr>
<td class="email-body"
width="750"
cellpadding="0"
cellspacing="0"
style="word-break: break-word;
margin: 0;
padding: 0;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;
width: 100%;
-premailer-width: 100%;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;">
<table class="email-body_inner"
align="center"
width="750"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 750px;
-premailer-width: 750px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
background-color: #FFFFFF;
margin: 0 auto;
padding: 0;"
bgcolor="#FFFFFF">
<!-- Body content -->
<tr>
<td class="content-cell"
style="word-break: break-word;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;
padding: 30px;">
<div class="f-fallback">
{% block greeting %}{% endblock %}
{% block content %}{% endblock %}
<!-- Sub copy -->
{% block sub_copy %}{% endblock %}
</div>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="word-break: break-word;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;">
<table class="email-footer"
align="center"
width="750"
cellpadding="0"
cellspacing="0"
role="presentation"
style="width: 750px;
-premailer-width: 750px;
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
text-align: center;
margin: 0 auto;
padding: 0;">
<tr>
<td class="content-cell"
align="center"
style="word-break: break-word;
font-family: Helvetica, Arial, sans-serif;
font-size: 16px;
padding: 30px;">
<p class="f-fallback sub align-center"
style="font-size: 13px;
line-height: 1.625;
text-align: center;
color: #A8AAAF;
margin: .4em 0 1.1875em;"
align="center">
© {{ YEAR }} SimpleLogin - a Proton product. All rights reserved.
<br />
{% block footer %}{% endblock %}
</p>
{% if unsubscribe_oneclick is defined %}
<p class="f-fallback sub align-center"
style="font-size: 13px;
line-height: 1.625;
text-align: center;
margin: .4em 0 1.1875em;">
<a href="{{ unsubscribe_oneclick }}">Unsubscribe from our newsletter</a>
</p>
{% endif %}
<p class="f-fallback sub align-center"
style="font-size: 13px;
line-height: 1.625;
text-align: center;
color: #A8AAAF;
margin: .4em 0 1.1875em;"
align="center">
<a href="https://app.simplelogin.io/dashboard/support">Do you have a question?</a>
</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -6,6 +6,7 @@
{{ render_text("Your subscription will end on " + next_bill_date + ".") }} {{ render_text("Your subscription will end on " + next_bill_date + ".") }}
{{ render_text("When the subscription ends:") }} {{ render_text("When the subscription ends:") }}
{{ render_text("- All aliases/domains/directories you have created are <b>kept</b> and continue working normally.") }} {{ render_text("- All aliases/domains/directories you have created are <b>kept</b> and continue working normally.") }}
{{ render_text("- You cannot create new reverse aliases.") }}
{% call text() %} {% call text() %}
- You cannot create new aliases if you exceed the free plan limit, i.e. have more than {{ MAX_NB_EMAIL_FREE_PLAN }} aliases. - You cannot create new aliases if you exceed the free plan limit, i.e. have more than {{ MAX_NB_EMAIL_FREE_PLAN }} aliases.
{% endcall %} {% endcall %}

View File

@ -9,6 +9,7 @@ When the subscription ends:
- All aliases/domains/directories you have created are kept and continue working. - All aliases/domains/directories you have created are kept and continue working.
- You cannot create new aliases if you exceed the free plan limit, i.e. have more than {{MAX_NB_EMAIL_FREE_PLAN}} aliases. - You cannot create new aliases if you exceed the free plan limit, i.e. have more than {{MAX_NB_EMAIL_FREE_PLAN}} aliases.
- You cannot create new reverse aliases.
- As features like "catch-all" or "directory" allow you to create aliases on-the-fly, - As features like "catch-all" or "directory" allow you to create aliases on-the-fly,
those aliases cannot be automatically created if you have more than {{MAX_NB_EMAIL_FREE_PLAN}} aliases. those aliases cannot be automatically created if you have more than {{MAX_NB_EMAIL_FREE_PLAN}} aliases.
- You cannot add new domain or directory. - You cannot add new domain or directory.

View File

@ -14,6 +14,7 @@
{{ render_text("- You cannot add new domain or directory.") }} {{ render_text("- You cannot add new domain or directory.") }}
{{ render_text("- You cannot add new mailbox.") }} {{ render_text("- You cannot add new mailbox.") }}
{{ render_text("- You cannot create new reverse aliases.") }}
{{ render_text("- If you enable PGP Encryption, forwarded emails are not encrypted anymore.") }} {{ render_text("- If you enable PGP Encryption, forwarded emails are not encrypted anymore.") }}
{{ render_text('You can upgrade today to continue using all these Premium features (and much more coming).') }} {{ render_text('You can upgrade today to continue using all these Premium features (and much more coming).') }}
{{ render_button("Upgrade your account", URL ~ "/dashboard/pricing") }} {{ render_button("Upgrade your account", URL ~ "/dashboard/pricing") }}

View File

@ -8,6 +8,7 @@ When the trial ends:
- All aliases/domains/directories you have created are kept and continue working. - All aliases/domains/directories you have created are kept and continue working.
- You cannot create new aliases if you exceed the free plan limit, i.e. have more than {{MAX_NB_EMAIL_FREE_PLAN}} aliases. - You cannot create new aliases if you exceed the free plan limit, i.e. have more than {{MAX_NB_EMAIL_FREE_PLAN}} aliases.
- You cannot add new domain or directory. - You cannot add new domain or directory.
- You cannot create new reverse aliases.
- You cannot add new mailbox. - You cannot add new mailbox.
- If you enable PGP Encryption, forwarded emails are not encrypted anymore. - If you enable PGP Encryption, forwarded emails are not encrypted anymore.

View File

@ -216,6 +216,7 @@
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
'{{HEADER_ALLOW_API_COOKIES}}': 'allow'
} }
}); });
@ -232,6 +233,7 @@
method: "GET", method: "GET",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
'{{HEADER_ALLOW_API_COOKIES}}': 'allow'
} }
}); });
if (res.ok) { if (res.ok) {
@ -249,6 +251,7 @@
method: "GET", method: "GET",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
'{{HEADER_ALLOW_API_COOKIES}}': 'allow'
} }
}); });
if (res.ok) { if (res.ok) {

View File

@ -19,7 +19,10 @@
<div class="mt-8 text-center"> <div class="mt-8 text-center">
{% if current_user != None and current_user.is_authenticated %} {% if current_user != None and current_user.is_authenticated %}
<h2 class="text-black-50" style="font-size:2rem">Performing the extension setup...</h2> <h2 class="text-black-50" style="font-size:2rem">
Automatically performing extension setup.
If the setup doesn't start in a couple seconds click <a onclick="sendSetupMessage()" class="text-primary">here</a>
</h2>
{% else %} {% else %}
<a class="mx-6 p-4 text-decoration-none" <a class="mx-6 p-4 text-decoration-none"
style="background:black; style="background:black;
@ -41,6 +44,10 @@
{% if current_user != None and current_user.is_authenticated %} {% if current_user != None and current_user.is_authenticated %}
<script type="text/javascript"> <script type="text/javascript">
function sendSetupMessage(){
const data = { tag: "PERFORM_EXTENSION_SETUP" };
window.postMessage(data, "/");
}
let counterIterations = 5; let counterIterations = 5;
let extensionSetupIntervalId = setInterval(function() { let extensionSetupIntervalId = setInterval(function() {
counterIterations--; counterIterations--;
@ -48,9 +55,7 @@
clearInterval(extensionSetupIntervalId); clearInterval(extensionSetupIntervalId);
return; return;
} }
sendSetupMessage()
const data = { tag: "PERFORM_EXTENSION_SETUP" };
window.postMessage(data, "/");
}, 300); // Send it many times, in case the extension had not registered the listener yet }, 300); // Send it many times, in case the extension had not registered the listener yet
</script> </script>
{% endif %} {% endif %}

View File

@ -87,6 +87,7 @@
method: "GET", method: "GET",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
'{{HEADER_ALLOW_API_COOKIES}}': 'allow'
} }
}); });
if (res.ok) { if (res.ok) {

View File

@ -44,6 +44,9 @@ def test_update_settings_alias_generator(flask_client):
def test_update_settings_random_alias_default_domain(flask_client): def test_update_settings_random_alias_default_domain(flask_client):
user = login(flask_client) user = login(flask_client)
custom_domain = CustomDomain.create(
domain=random_domain(), verified=True, user_id=user.id, flush=True
)
assert user.default_random_alias_domain() == "sl.local" assert user.default_random_alias_domain() == "sl.local"
r = flask_client.patch( r = flask_client.patch(
@ -57,6 +60,12 @@ def test_update_settings_random_alias_default_domain(flask_client):
assert r.status_code == 200 assert r.status_code == 200
assert user.default_random_alias_domain() == "d1.test" assert user.default_random_alias_domain() == "d1.test"
r = flask_client.patch(
"/api/setting", json={"random_alias_default_domain": custom_domain.domain}
)
assert r.status_code == 200
assert user.default_random_alias_domain() == custom_domain.domain
def test_update_settings_sender_format(flask_client): def test_update_settings_sender_format(flask_client):
user = login(flask_client) user = login(flask_client)

View File

@ -1,5 +1,7 @@
import os import os
from flask import testing
# use the tests/test.env config fle # use the tests/test.env config fle
# flake8: noqa: E402 # flake8: noqa: E402
@ -42,7 +44,16 @@ def flask_app():
yield app yield app
from app import config from app import config, constants
class CustomTestClient(testing.FlaskClient):
def open(self, *args, **kwargs):
if isinstance(args[0], str):
headers = kwargs.pop("headers", {})
headers.update({constants.HEADER_ALLOW_API_COOKIES: "allow"})
kwargs["headers"] = headers
return super().open(*args, **kwargs)
@pytest.fixture @pytest.fixture
@ -53,7 +64,9 @@ def flask_client():
# disable rate limit during test # disable rate limit during test
config.DISABLE_RATE_LIMIT = True config.DISABLE_RATE_LIMIT = True
try: try:
app.test_client_class = CustomTestClient
client = app.test_client() client = app.test_client()
client.environ_base[constants.HEADER_ALLOW_API_COOKIES] = "allow"
yield client yield client
finally: finally:
# disable rate limit again as some tests might enable rate limit # disable rate limit again as some tests might enable rate limit

View File

@ -0,0 +1,46 @@
from app import config
from app.db import Session
from app.events.event_dispatcher import Dispatcher
from app.events.generated import event_pb2
from app.jobs.event_jobs import send_alias_creation_events_for_user
from app.models import Alias
from tests.utils import create_partner_linked_user
class MemStoreDispatcher(Dispatcher):
def __init__(self):
self.events = []
def send(self, event: bytes):
self.events.append(event)
def setup_module():
config.EVENT_WEBHOOK = True
def teardown_module():
config.EVENT_WEBHOOK = False
def test_send_alias_creation_events():
[user, partner_user] = create_partner_linked_user()
aliases = [Alias.create_new_random(user) for i in range(2)]
Session.flush()
dispatcher = MemStoreDispatcher()
send_alias_creation_events_for_user(user, dispatcher=dispatcher, chunk_size=2)
# 2 batches. 1st newsletter + first alias. 2nd last alias
assert len(dispatcher.events) == 2
decoded_event = event_pb2.Event.FromString(dispatcher.events[0])
assert decoded_event.user_id == user.id
assert decoded_event.external_user_id == partner_user.external_user_id
event_list = decoded_event.content.alias_create_list.events
assert len(event_list) == 2
# 0 is newsletter alias
assert event_list[1].alias_id == aliases[0].id
decoded_event = event_pb2.Event.FromString(dispatcher.events[1])
assert decoded_event.user_id == user.id
assert decoded_event.external_user_id == partner_user.external_user_id
event_list = decoded_event.content.alias_create_list.events
assert len(event_list) == 1
assert event_list[0].alias_id == aliases[1].id

View File

@ -1,5 +1,5 @@
from app.db import Session from app.db import Session
from app.models import Alias, Mailbox, AliasMailbox from app.models import Alias, Mailbox, AliasMailbox, User
from tests.utils import create_new_user, random_email from tests.utils import create_new_user, random_email
@ -15,3 +15,17 @@ def test_duplicated_mailbox_is_returned_only_once():
alias_mailbox_id = [mailbox.id for mailbox in alias_mailboxes] alias_mailbox_id = [mailbox.id for mailbox in alias_mailboxes]
assert user.default_mailbox_id in alias_mailbox_id assert user.default_mailbox_id in alias_mailbox_id
assert other_mailbox.id in alias_mailbox_id assert other_mailbox.id in alias_mailbox_id
def test_alias_create_from_partner_flags_also_the_user():
user = create_new_user()
Session.flush()
email = random_email()
alias = Alias.create(
user_id=user.id,
email=email,
mailbox_id=user.default_mailbox_id,
flags=Alias.FLAG_PARTNER_CREATED,
flush=True,
)
assert alias.user.flags & User.FLAG_CREATED_ALIAS_FROM_PARTNER > 0

View File

@ -9,6 +9,7 @@ import pytest
from app import config from app import config
from app.config import MAX_ALERT_24H, ROOT_DIR from app.config import MAX_ALERT_24H, ROOT_DIR
from app.db import Session from app.db import Session
from app.email import headers
from app.email_utils import ( from app.email_utils import (
get_email_domain_part, get_email_domain_part,
can_create_directory_for_address, can_create_directory_for_address,
@ -354,6 +355,33 @@ def test_is_valid_email():
assert not is_valid_email("emoji👌@gmail.com") assert not is_valid_email("emoji👌@gmail.com")
def test_add_subject_prefix():
msg = email.message_from_string(
"""Subject: Potato
Content-Transfer-Encoding: 7bit
hello
"""
)
new_msg = add_header(msg, "text header", "html header", subject_prefix="[TEST]")
assert "text header" in new_msg.as_string()
assert "html header" not in new_msg.as_string()
assert new_msg[headers.SUBJECT] == "[TEST] Potato"
def test_add_subject_prefix_with_no_header():
msg = email.message_from_string(
"""Content-Transfer-Encoding: 7bit
hello
"""
)
new_msg = add_header(msg, "text header", "html header", subject_prefix="[TEST]")
assert "text header" in new_msg.as_string()
assert "html header" not in new_msg.as_string()
assert new_msg[headers.SUBJECT] == "[TEST]"
def test_add_header_plain_text(): def test_add_header_plain_text():
msg = email.message_from_string( msg = email.message_from_string(
"""Content-Type: text/plain; charset=us-ascii """Content-Type: text/plain; charset=us-ascii

View File

@ -23,7 +23,7 @@ _MAX_PER_MINUTE = 3
_ENDPOINT, _ENDPOINT,
methods=["GET"], methods=["GET"],
) )
@limiter.limit(f"{_MAX_PER_MINUTE}/minute") @limiter.limit(f"{_MAX_PER_MINUTE}/hour")
def rate_limited_endpoint_1(): def rate_limited_endpoint_1():
return "Working", HTTPStatus.OK return "Working", HTTPStatus.OK

View File

@ -365,12 +365,22 @@ def test_sync_event_dead_letter():
commit=True, commit=True,
) )
# create event with too many retries
max_retries = 5
e5 = SyncEvent.create(
content=b"content",
retry_count=max_retries + 1,
created_at=arrow.now(),
commit=True,
)
# get dead letter events # get dead letter events
dead_letter_events = SyncEvent.get_dead_letter( dead_letter_events = SyncEvent.get_dead_letter(
older_than=arrow.now().shift(minutes=-10) older_than=arrow.now().shift(minutes=-10), max_retries=max_retries
) )
assert len(dead_letter_events) == 2 assert len(dead_letter_events) == 2
assert e1 in dead_letter_events assert e1 in dead_letter_events
assert e2 in dead_letter_events assert e2 in dead_letter_events
assert e3 not in dead_letter_events assert e3 not in dead_letter_events
assert e4 not in dead_letter_events assert e4 not in dead_letter_events
assert e5 not in dead_letter_events

View File

View File

@ -0,0 +1,128 @@
import pytest
from app import user_settings
from app.db import Session
from app.models import User, CustomDomain, SLDomain
from tests.utils import random_token, create_new_user
user_id: int = 0
custom_domain_name: str = ""
sl_domain_name: str = ""
def setup_module():
global user_id, custom_domain_name, sl_domain_name
user = create_new_user()
user.trial_end = None
user_id = user.id
custom_domain_name = CustomDomain.create(
user_id=user_id,
catch_all=True,
domain=random_token() + ".com",
verified=True,
flush=True,
).domain
sl_domain_name = SLDomain.create(
domain=random_token() + ".com",
premium_only=False,
flush=True,
order=5,
hidden=False,
).domain
def test_set_default_no_domain():
user = User.get(user_id)
user.default_alias_public_domain_id = SLDomain.get_by(domain=sl_domain_name).id
user.default_alias_private_domain_id = CustomDomain.get_by(
domain=custom_domain_name
).id
Session.flush()
user_settings.set_default_alias_domain(user, None)
assert user.default_alias_public_domain_id is None
assert user.default_alias_custom_domain_id is None
def test_set_premium_sl_domain_with_non_premium_user():
user = User.get(user_id)
user.lifetime = False
domain = SLDomain.get_by(domain=sl_domain_name)
domain.premium_only = True
Session.flush()
with pytest.raises(user_settings.CannotSetAlias):
user_settings.set_default_alias_domain(user, sl_domain_name)
def test_set_hidden_sl_domain():
user = User.get(user_id)
domain = SLDomain.get_by(domain=sl_domain_name)
domain.hidden = True
domain.premium_only = False
Session.flush()
with pytest.raises(user_settings.CannotSetAlias):
user_settings.set_default_alias_domain(user, sl_domain_name)
def test_set_sl_domain():
user = User.get(user_id)
user.lifetime = False
domain = SLDomain.get_by(domain=sl_domain_name)
domain.hidden = False
domain.premium_only = False
Session.flush()
user_settings.set_default_alias_domain(user, sl_domain_name)
assert user.default_alias_public_domain_id == domain.id
assert user.default_alias_custom_domain_id is None
def test_set_sl_premium_domain():
user = User.get(user_id)
user.lifetime = True
domain = SLDomain.get_by(domain=sl_domain_name)
domain.hidden = False
domain.premium_only = True
Session.flush()
user_settings.set_default_alias_domain(user, sl_domain_name)
assert user.default_alias_public_domain_id == domain.id
assert user.default_alias_custom_domain_id is None
def test_set_other_user_custom_domain():
user = User.get(user_id)
user.lifetime = True
other_user_domain_name = CustomDomain.create(
user_id=create_new_user().id,
catch_all=True,
domain=random_token() + ".com",
verified=True,
).domain
Session.flush()
with pytest.raises(user_settings.CannotSetAlias):
user_settings.set_default_alias_domain(user, other_user_domain_name)
def test_set_unverified_custom_domain():
user = User.get(user_id)
user.lifetime = True
domain = CustomDomain.get_by(domain=custom_domain_name)
domain.verified = False
Session.flush()
with pytest.raises(user_settings.CannotSetAlias):
user_settings.set_default_alias_domain(user, custom_domain_name)
def test_set_custom_domain():
user = User.get(user_id)
user.lifetime = True
domain = CustomDomain.get_by(domain=custom_domain_name)
domain.verified = True
Session.flush()
user_settings.set_default_alias_domain(user, custom_domain_name)
assert user.default_alias_public_domain_id is None
assert user.default_alias_custom_domain_id == domain.id
def test_set_invalid_custom_domain():
user = User.get(user_id)
with pytest.raises(user_settings.CannotSetAlias):
user_settings.set_default_alias_domain(user, "invalid_nop" + random_token())

View File

@ -9,7 +9,8 @@ from typing import Optional, Dict
import jinja2 import jinja2
from flask import url_for from flask import url_for
from app.models import User from app.models import User, PartnerUser
from app.proton.utils import get_proton_partner
from app.utils import random_string from app.utils import random_string
@ -30,6 +31,18 @@ def create_new_user(email: Optional[str] = None, name: Optional[str] = None) ->
return user return user
def create_partner_linked_user() -> tuple[User, PartnerUser]:
user = create_new_user()
partner_user = PartnerUser.create(
partner_id=get_proton_partner().id,
user_id=user.id,
external_user_id=random_token(10),
flush=True,
)
return user, partner_user
def login(flask_client, user: Optional[User] = None) -> User: def login(flask_client, user: Optional[User] = None) -> User:
if not user: if not user:
user = create_new_user() user = create_new_user()