Compare commits
26 Commits
Author | SHA1 | Date | |
---|---|---|---|
7ea45d6f5d | |||
6d24db50bd | |||
88f270c6a1 | |||
0962b1cf29 | |||
6051d72691 | |||
c31a75a9ef | |||
ef289385ff | |||
9b12a2ad33 | |||
8eb19d88f3 | |||
e36e9d3077 | |||
b2430cbc5b | |||
1258115397 | |||
38c134d903 | |||
cd77e4cc2d | |||
87aedf3207 | |||
3523c9fc15 | |||
a6f4995cb5 | |||
727f61a35e | |||
ce5124605a | |||
2c82b03f8d | |||
1b7a6223ac | |||
75331c62a4 | |||
3f68a3e640 | |||
8ee4f9462e | |||
822855d584 | |||
1a6a7e079b |
@ -17,6 +17,7 @@ steps:
|
||||
image: thegeeklab/drone-docker-buildx
|
||||
privileged: true
|
||||
settings:
|
||||
provenance: false
|
||||
dockerfile: app/Dockerfile
|
||||
context: app
|
||||
registry: git.mrmeeb.stream
|
||||
@ -31,6 +32,11 @@ steps:
|
||||
|
||||
- name: notify
|
||||
image: plugins/slack
|
||||
when:
|
||||
status:
|
||||
- success
|
||||
- failure
|
||||
- killed
|
||||
settings:
|
||||
webhook:
|
||||
from_secret: slack_webhook
|
||||
|
10
README.md
10
README.md
@ -1,9 +1,7 @@
|
||||
# Simple Login
|
||||
# SimpleLogin
|
||||
|
||||
[](https://drone.mrmeeb.stream/MrMeeb/simple-login)
|
||||
This repo exists to automatically capture any releases of the SaaS edition of SimpleLogin. It checks the simplelogin/app GitHub repo once a day, and builds the latest release automatically if it is newer than the currently built version.
|
||||
|
||||
This repo exists to automatically capture any releases of the SaaS edition of SimpleLogin. It checks once a day, and builds the latest one automatically if it is newer than the currentlty built version.
|
||||
I did this to simplify deployment of my self-hosted SimpleLogin instance. SimpleLogin do not provide an up-to-date version for self-hosting, leaving you with the options of either running a very outdated version with no app support, a beta version, or their `simplelogin/app-ci` version. This last option works well if you use an x86 machine, but I'm running SimpleLogin on an ARM machine. Since I don't want to have to build containers on the machine itself, this repo handles that for me.
|
||||
|
||||
This exists to simplify deployment of SimpleLogin in a self-hosted capacity, while also allowing the use of the latest version; SimpleLogin do not provide an up-to-date version for this use.
|
||||
|
||||
The image is built for amd64 and arm64 devices.
|
||||
As a result, this image is built for both amd64 and arm64 devices.
|
@ -34,7 +34,7 @@ poetry install
|
||||
On Mac, sometimes you might need to install some other packages via `brew`:
|
||||
|
||||
```bash
|
||||
brew install pkg-config libffi openssl postgresql
|
||||
brew install pkg-config libffi openssl postgresql@13
|
||||
```
|
||||
|
||||
You also need to install `gpg` tool, on Mac it can be done with:
|
||||
@ -169,6 +169,12 @@ For HTML templates, we use `djlint`. Before creating a pull request, please run
|
||||
poetry run djlint --check templates
|
||||
```
|
||||
|
||||
If some files aren't properly formatted, you can format all files with
|
||||
|
||||
```bash
|
||||
poetry run djlint --reformat .
|
||||
```
|
||||
|
||||
## Test sending email
|
||||
|
||||
[swaks](http://www.jetmore.org/john/code/swaks/) is used for sending test emails to the `email_handler`.
|
||||
|
@ -23,10 +23,10 @@ COPY poetry.lock pyproject.toml ./
|
||||
# Install and setup poetry
|
||||
RUN pip install -U pip \
|
||||
&& apt-get update \
|
||||
&& apt install -y curl netcat gcc python3-dev gnupg git libre2-dev \
|
||||
&& apt install -y curl netcat-traditional gcc python3-dev gnupg git libre2-dev \
|
||||
&& curl -sSL https://install.python-poetry.org | python3 - \
|
||||
# Remove curl and netcat from the image
|
||||
&& apt-get purge -y curl netcat \
|
||||
&& apt-get purge -y curl netcat-traditional \
|
||||
# Run poetry
|
||||
&& poetry config virtualenvs.create false \
|
||||
&& poetry install --no-interaction --no-ansi --no-root \
|
||||
|
@ -9,13 +9,17 @@ from newrelic import agent
|
||||
from app.db import Session
|
||||
from app.email_utils import send_welcome_email
|
||||
from app.utils import sanitize_email
|
||||
from app.errors import AccountAlreadyLinkedToAnotherPartnerException
|
||||
from app.errors import (
|
||||
AccountAlreadyLinkedToAnotherPartnerException,
|
||||
AccountIsUsingAliasAsEmail,
|
||||
)
|
||||
from app.log import LOG
|
||||
from app.models import (
|
||||
PartnerSubscription,
|
||||
Partner,
|
||||
PartnerUser,
|
||||
User,
|
||||
Alias,
|
||||
)
|
||||
from app.utils import random_string
|
||||
|
||||
@ -192,6 +196,12 @@ def get_login_strategy(
|
||||
return ExistingUnlinkedUserStrategy(link_request, user, partner)
|
||||
|
||||
|
||||
def check_alias(email: str) -> bool:
|
||||
alias = Alias.get_by(email=email)
|
||||
if alias is not None:
|
||||
raise AccountIsUsingAliasAsEmail()
|
||||
|
||||
|
||||
def process_login_case(
|
||||
link_request: PartnerLinkRequest, partner: Partner
|
||||
) -> LinkResult:
|
||||
@ -203,6 +213,8 @@ def process_login_case(
|
||||
)
|
||||
if partner_user is None:
|
||||
# We didn't find any SimpleLogin user registered with that partner user id
|
||||
# Make sure they aren't using an alias as their link email
|
||||
check_alias(link_request.email)
|
||||
# Try to find it using the partner's e-mail address
|
||||
user = User.get_by(email=link_request.email)
|
||||
return get_login_strategy(link_request, user, partner).process()
|
||||
|
@ -6,8 +6,7 @@ from typing import Optional
|
||||
import itsdangerous
|
||||
from app import config
|
||||
from app.log import LOG
|
||||
from app.models import User
|
||||
|
||||
from app.models import User, AliasOptions, SLDomain
|
||||
|
||||
signer = itsdangerous.TimestampSigner(config.CUSTOM_ALIAS_SECRET)
|
||||
|
||||
@ -43,7 +42,9 @@ def check_suffix_signature(signed_suffix: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def verify_prefix_suffix(user: User, alias_prefix, alias_suffix) -> bool:
|
||||
def verify_prefix_suffix(
|
||||
user: User, alias_prefix, alias_suffix, alias_options: Optional[AliasOptions] = None
|
||||
) -> bool:
|
||||
"""verify if user could create an alias with the given prefix and suffix"""
|
||||
if not alias_prefix or not alias_suffix: # should be caught on frontend
|
||||
return False
|
||||
@ -56,7 +57,7 @@ def verify_prefix_suffix(user: User, alias_prefix, alias_suffix) -> bool:
|
||||
alias_domain_prefix, alias_domain = alias_suffix.split("@", 1)
|
||||
|
||||
# alias_domain must be either one of user custom domains or built-in domains
|
||||
if alias_domain not in user.available_alias_domains():
|
||||
if alias_domain not in user.available_alias_domains(alias_options=alias_options):
|
||||
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
|
||||
return False
|
||||
|
||||
@ -64,7 +65,7 @@ def verify_prefix_suffix(user: User, alias_prefix, alias_suffix) -> bool:
|
||||
# 1) alias_suffix must start with "." and
|
||||
# 2) alias_domain_prefix must come from the word list
|
||||
if (
|
||||
alias_domain in user.available_sl_domains()
|
||||
alias_domain in user.available_sl_domains(alias_options=alias_options)
|
||||
and alias_domain not in user_custom_domains
|
||||
# when DISABLE_ALIAS_SUFFIX is true, alias_domain_prefix is empty
|
||||
and not config.DISABLE_ALIAS_SUFFIX
|
||||
@ -80,14 +81,18 @@ def verify_prefix_suffix(user: User, alias_prefix, alias_suffix) -> bool:
|
||||
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
|
||||
return False
|
||||
|
||||
if alias_domain not in user.available_sl_domains():
|
||||
if alias_domain not in user.available_sl_domains(
|
||||
alias_options=alias_options
|
||||
):
|
||||
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def get_alias_suffixes(user: User) -> [AliasSuffix]:
|
||||
def get_alias_suffixes(
|
||||
user: User, alias_options: Optional[AliasOptions] = None
|
||||
) -> [AliasSuffix]:
|
||||
"""
|
||||
Similar to as get_available_suffixes() but also return custom domain that doesn't have MX set up.
|
||||
"""
|
||||
@ -99,7 +104,9 @@ def get_alias_suffixes(user: User) -> [AliasSuffix]:
|
||||
# for each user domain, generate both the domain and a random suffix version
|
||||
for custom_domain in user_custom_domains:
|
||||
if custom_domain.random_prefix_generation:
|
||||
suffix = "." + user.get_random_alias_suffix() + "@" + custom_domain.domain
|
||||
suffix = (
|
||||
f".{user.get_random_alias_suffix(custom_domain)}@{custom_domain.domain}"
|
||||
)
|
||||
alias_suffix = AliasSuffix(
|
||||
is_custom=True,
|
||||
suffix=suffix,
|
||||
@ -113,7 +120,7 @@ def get_alias_suffixes(user: User) -> [AliasSuffix]:
|
||||
else:
|
||||
alias_suffixes.append(alias_suffix)
|
||||
|
||||
suffix = "@" + custom_domain.domain
|
||||
suffix = f"@{custom_domain.domain}"
|
||||
alias_suffix = AliasSuffix(
|
||||
is_custom=True,
|
||||
suffix=suffix,
|
||||
@ -134,16 +141,13 @@ def get_alias_suffixes(user: User) -> [AliasSuffix]:
|
||||
alias_suffixes.append(alias_suffix)
|
||||
|
||||
# then SimpleLogin domain
|
||||
for sl_domain in user.get_sl_domains():
|
||||
suffix = (
|
||||
(
|
||||
""
|
||||
if config.DISABLE_ALIAS_SUFFIX
|
||||
else "." + user.get_random_alias_suffix()
|
||||
)
|
||||
+ "@"
|
||||
+ sl_domain.domain
|
||||
sl_domains = user.get_sl_domains(alias_options=alias_options)
|
||||
default_domain_found = False
|
||||
for sl_domain in sl_domains:
|
||||
prefix = (
|
||||
"" if config.DISABLE_ALIAS_SUFFIX else f".{user.get_random_alias_suffix()}"
|
||||
)
|
||||
suffix = f"{prefix}@{sl_domain.domain}"
|
||||
alias_suffix = AliasSuffix(
|
||||
is_custom=False,
|
||||
suffix=suffix,
|
||||
@ -152,11 +156,36 @@ def get_alias_suffixes(user: User) -> [AliasSuffix]:
|
||||
domain=sl_domain.domain,
|
||||
mx_verified=True,
|
||||
)
|
||||
|
||||
# put the default domain to top
|
||||
if user.default_alias_public_domain_id == sl_domain.id:
|
||||
alias_suffixes.insert(0, alias_suffix)
|
||||
else:
|
||||
# No default or this is not the default
|
||||
if (
|
||||
user.default_alias_public_domain_id is None
|
||||
or user.default_alias_public_domain_id != sl_domain.id
|
||||
):
|
||||
alias_suffixes.append(alias_suffix)
|
||||
else:
|
||||
default_domain_found = True
|
||||
alias_suffixes.insert(0, alias_suffix)
|
||||
|
||||
if not default_domain_found:
|
||||
domain_conditions = {"id": user.default_alias_public_domain_id, "hidden": False}
|
||||
if not user.is_premium():
|
||||
domain_conditions["premium_only"] = False
|
||||
sl_domain = SLDomain.get_by(**domain_conditions)
|
||||
if sl_domain:
|
||||
prefix = (
|
||||
""
|
||||
if config.DISABLE_ALIAS_SUFFIX
|
||||
else f".{user.get_random_alias_suffix()}"
|
||||
)
|
||||
suffix = f"{prefix}@{sl_domain.domain}"
|
||||
alias_suffix = AliasSuffix(
|
||||
is_custom=False,
|
||||
suffix=suffix,
|
||||
signed_suffix=signer.sign(suffix).decode(),
|
||||
is_premium=sl_domain.premium_only,
|
||||
domain=sl_domain.domain,
|
||||
mx_verified=True,
|
||||
)
|
||||
alias_suffixes.insert(0, alias_suffix)
|
||||
|
||||
return alias_suffixes
|
||||
|
@ -57,6 +57,8 @@ def get_user_if_alias_would_auto_create(
|
||||
domain_and_rule = check_if_alias_can_be_auto_created_for_custom_domain(
|
||||
address, notify_user=notify_user
|
||||
)
|
||||
if DomainDeletedAlias.get_by(email=address):
|
||||
return None
|
||||
if domain_and_rule:
|
||||
return domain_and_rule[0].user
|
||||
directory = check_if_alias_can_be_auto_created_for_a_directory(
|
||||
|
@ -9,6 +9,7 @@ from requests import RequestException
|
||||
|
||||
from app.api.base import api_bp, require_api_auth
|
||||
from app.config import APPLE_API_SECRET, MACAPP_APPLE_API_SECRET
|
||||
from app.subscription_webhook import execute_subscription_webhook
|
||||
from app.db import Session
|
||||
from app.log import LOG
|
||||
from app.models import PlanEnum, AppleSubscription
|
||||
@ -50,6 +51,7 @@ def apple_process_payment():
|
||||
|
||||
apple_sub = verify_receipt(receipt_data, user, password)
|
||||
if apple_sub:
|
||||
execute_subscription_webhook(user)
|
||||
return jsonify(ok=True), 200
|
||||
|
||||
return jsonify(error="Processing failed"), 400
|
||||
@ -282,6 +284,7 @@ def apple_update_notification():
|
||||
apple_sub.plan = plan
|
||||
apple_sub.product_id = transaction["product_id"]
|
||||
Session.commit()
|
||||
execute_subscription_webhook(user)
|
||||
return jsonify(ok=True), 200
|
||||
else:
|
||||
LOG.w(
|
||||
@ -554,6 +557,7 @@ def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]:
|
||||
product_id=latest_transaction["product_id"],
|
||||
)
|
||||
|
||||
execute_subscription_webhook(user)
|
||||
Session.commit()
|
||||
|
||||
return apple_sub
|
||||
|
@ -357,7 +357,7 @@ def auth_payload(user, device) -> dict:
|
||||
|
||||
|
||||
@api_bp.route("/auth/forgot_password", methods=["POST"])
|
||||
@limiter.limit("10/minute")
|
||||
@limiter.limit("2/minute")
|
||||
def forgot_password():
|
||||
"""
|
||||
User forgot password
|
||||
|
@ -13,8 +13,8 @@ from app.db import Session
|
||||
from app.email_utils import (
|
||||
mailbox_already_used,
|
||||
email_can_be_used_as_mailbox,
|
||||
is_valid_email,
|
||||
)
|
||||
from app.email_validation import is_valid_email
|
||||
from app.log import LOG
|
||||
from app.models import Mailbox, Job
|
||||
from app.utils import sanitize_email
|
||||
|
@ -1,4 +1,5 @@
|
||||
import base64
|
||||
import dataclasses
|
||||
from io import BytesIO
|
||||
from typing import Optional
|
||||
|
||||
@ -7,6 +8,7 @@ from flask import jsonify, g, request, make_response
|
||||
from app import s3, config
|
||||
from app.api.base import api_bp, require_api_auth
|
||||
from app.config import SESSION_COOKIE_NAME
|
||||
from app.dashboard.views.index import get_stats
|
||||
from app.db import Session
|
||||
from app.models import ApiKey, File, PartnerUser, User
|
||||
from app.proton.utils import get_proton_partner
|
||||
@ -136,3 +138,22 @@ def logout():
|
||||
response.delete_cookie(SESSION_COOKIE_NAME)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@api_bp.route("/stats")
|
||||
@require_api_auth
|
||||
def user_stats():
|
||||
"""
|
||||
Return stats
|
||||
|
||||
Output as json
|
||||
- nb_alias
|
||||
- nb_forward
|
||||
- nb_reply
|
||||
- nb_block
|
||||
|
||||
"""
|
||||
user = g.user
|
||||
stats = get_stats(user)
|
||||
|
||||
return jsonify(dataclasses.asdict(stats))
|
||||
|
@ -1,4 +1,4 @@
|
||||
from flask import request, render_template, redirect, url_for, flash, g
|
||||
from flask import request, render_template, flash, g
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, validators
|
||||
|
||||
@ -16,7 +16,7 @@ class ForgotPasswordForm(FlaskForm):
|
||||
|
||||
@auth_bp.route("/forgot_password", methods=["GET", "POST"])
|
||||
@limiter.limit(
|
||||
"10/minute", deduct_when=lambda r: hasattr(g, "deduct_limit") and g.deduct_limit
|
||||
"10/hour", deduct_when=lambda r: hasattr(g, "deduct_limit") and g.deduct_limit
|
||||
)
|
||||
def forgot_password():
|
||||
form = ForgotPasswordForm(request.form)
|
||||
@ -37,6 +37,5 @@ def forgot_password():
|
||||
if user:
|
||||
LOG.d("Send forgot password email to %s", user)
|
||||
send_reset_password_email(user)
|
||||
return redirect(url_for("auth.forgot_password"))
|
||||
|
||||
return render_template("auth/forgot_password.html", form=form)
|
||||
|
@ -60,8 +60,8 @@ def reset_password():
|
||||
# this can be served to activate user too
|
||||
user.activated = True
|
||||
|
||||
# remove the reset password code
|
||||
ResetPasswordCode.delete(reset_password_code.id)
|
||||
# remove all reset password codes
|
||||
ResetPasswordCode.filter_by(user_id=user.id).delete()
|
||||
|
||||
# change the alternative_id to log user out on other browsers
|
||||
user.alternative_id = str(uuid.uuid4())
|
||||
|
@ -357,6 +357,7 @@ ALERT_COMPLAINT_TRANSACTIONAL_PHASE = "alert_complaint_transactional_phase"
|
||||
ALERT_QUARANTINE_DMARC = "alert_quarantine_dmarc"
|
||||
|
||||
ALERT_DUAL_SUBSCRIPTION_WITH_PARTNER = "alert_dual_sub_with_partner"
|
||||
ALERT_WARN_MULTIPLE_SUBSCRIPTIONS = "alert_multiple_subscription"
|
||||
|
||||
# <<<<< END ALERT EMAIL >>>>
|
||||
|
||||
@ -531,3 +532,10 @@ if ENABLE_ALL_REVERSE_ALIAS_REPLACEMENT:
|
||||
SKIP_MX_LOOKUP_ON_CHECK = False
|
||||
|
||||
DISABLE_RATE_LIMIT = "DISABLE_RATE_LIMIT" in os.environ
|
||||
|
||||
SUBSCRIPTION_CHANGE_WEBHOOK = os.environ.get("SUBSCRIPTION_CHANGE_WEBHOOK", None)
|
||||
MAX_API_KEYS = int(os.environ.get("MAX_API_KEYS", 30))
|
||||
|
||||
UPCLOUD_USERNAME = os.environ.get("UPCLOUD_USERNAME", None)
|
||||
UPCLOUD_PASSWORD = os.environ.get("UPCLOUD_PASSWORD", None)
|
||||
UPCLOUD_DB_ID = os.environ.get("UPCLOUD_DB_ID", None)
|
||||
|
@ -13,10 +13,10 @@ from app import config, parallel_limiter
|
||||
from app.dashboard.base import dashboard_bp
|
||||
from app.db import Session
|
||||
from app.email_utils import (
|
||||
is_valid_email,
|
||||
generate_reply_email,
|
||||
parse_full_address,
|
||||
)
|
||||
from app.email_validation import is_valid_email
|
||||
from app.errors import (
|
||||
CannotCreateContactForReverseAlias,
|
||||
ErrContactErrorUpgradeNeeded,
|
||||
@ -90,7 +90,7 @@ def create_contact(user: User, alias: Alias, contact_address: str) -> Contact:
|
||||
alias_id=alias.id,
|
||||
website_email=contact_email,
|
||||
name=contact_name,
|
||||
reply_email=generate_reply_email(contact_email, user),
|
||||
reply_email=generate_reply_email(contact_email, alias),
|
||||
)
|
||||
|
||||
LOG.d(
|
||||
|
@ -215,6 +215,12 @@ def alias_transfer_receive_route():
|
||||
token,
|
||||
)
|
||||
transfer(alias, current_user, mailboxes)
|
||||
|
||||
# reset transfer token
|
||||
alias.transfer_token = None
|
||||
alias.transfer_token_expiration = None
|
||||
Session.commit()
|
||||
|
||||
flash(f"You are now owner of {alias.email}", "success")
|
||||
return redirect(url_for("dashboard.index", highlight_alias_id=alias.id))
|
||||
|
||||
|
@ -3,9 +3,11 @@ from flask_login import login_required, current_user
|
||||
from flask_wtf import FlaskForm
|
||||
from wtforms import StringField, validators
|
||||
|
||||
from app import config
|
||||
from app.dashboard.base import dashboard_bp
|
||||
from app.dashboard.views.enter_sudo import sudo_required
|
||||
from app.db import Session
|
||||
from app.extensions import limiter
|
||||
from app.models import ApiKey
|
||||
from app.utils import CSRFValidationForm
|
||||
|
||||
@ -14,9 +16,34 @@ class NewApiKeyForm(FlaskForm):
|
||||
name = StringField("Name", validators=[validators.DataRequired()])
|
||||
|
||||
|
||||
def clean_up_unused_or_old_api_keys(user_id: int):
|
||||
total_keys = ApiKey.filter_by(user_id=user_id).count()
|
||||
if total_keys <= config.MAX_API_KEYS:
|
||||
return
|
||||
# Remove oldest unused
|
||||
for api_key in (
|
||||
ApiKey.filter_by(user_id=user_id, last_used=None)
|
||||
.order_by(ApiKey.created_at.asc())
|
||||
.all()
|
||||
):
|
||||
Session.delete(api_key)
|
||||
total_keys -= 1
|
||||
if total_keys <= config.MAX_API_KEYS:
|
||||
return
|
||||
# Clean up oldest used
|
||||
for api_key in (
|
||||
ApiKey.filter_by(user_id=user_id).order_by(ApiKey.last_used.asc()).all()
|
||||
):
|
||||
Session.delete(api_key)
|
||||
total_keys -= 1
|
||||
if total_keys <= config.MAX_API_KEYS:
|
||||
return
|
||||
|
||||
|
||||
@dashboard_bp.route("/api_key", methods=["GET", "POST"])
|
||||
@login_required
|
||||
@sudo_required
|
||||
@limiter.limit("10/hour")
|
||||
def api_key():
|
||||
api_keys = (
|
||||
ApiKey.filter(ApiKey.user_id == current_user.id)
|
||||
@ -50,6 +77,7 @@ def api_key():
|
||||
|
||||
elif request.form.get("form-name") == "create":
|
||||
if new_api_key_form.validate():
|
||||
clean_up_unused_or_old_api_keys(current_user.id)
|
||||
new_api_key = ApiKey.create(
|
||||
name=new_api_key_form.name.data, user_id=current_user.id
|
||||
)
|
||||
|
@ -68,9 +68,14 @@ def coupon_route():
|
||||
)
|
||||
return redirect(request.url)
|
||||
|
||||
coupon.used_by_user_id = current_user.id
|
||||
coupon.used = True
|
||||
Session.commit()
|
||||
updated = (
|
||||
Session.query(Coupon)
|
||||
.filter_by(code=code, used=False)
|
||||
.update({"used_by_user_id": current_user.id, "used": True})
|
||||
)
|
||||
if updated != 1:
|
||||
flash("Coupon is not valid", "error")
|
||||
return redirect(request.url)
|
||||
|
||||
manual_sub: ManualSubscription = ManualSubscription.get_by(
|
||||
user_id=current_user.id
|
||||
|
@ -120,18 +120,11 @@ def custom_alias():
|
||||
email=full_alias
|
||||
)
|
||||
custom_domain = domain_deleted_alias.domain
|
||||
if domain_deleted_alias.user_id == current_user.id:
|
||||
flash(
|
||||
f"You have deleted this alias before. You can restore it on "
|
||||
f"{custom_domain.domain} 'Deleted Alias' page",
|
||||
"error",
|
||||
)
|
||||
else:
|
||||
# should never happen as user can only choose their domains
|
||||
LOG.e(
|
||||
"Deleted Alias %s does not belong to user %s",
|
||||
domain_deleted_alias,
|
||||
)
|
||||
flash(
|
||||
f"You have deleted this alias before. You can restore it on "
|
||||
f"{custom_domain.domain} 'Deleted Alias' page",
|
||||
"error",
|
||||
)
|
||||
|
||||
elif DeletedAlias.get_by(email=full_alias):
|
||||
flash(general_error_msg, "error")
|
||||
|
@ -8,6 +8,7 @@ from wtforms import PasswordField, validators
|
||||
|
||||
from app.config import CONNECT_WITH_PROTON
|
||||
from app.dashboard.base import dashboard_bp
|
||||
from app.extensions import limiter
|
||||
from app.log import LOG
|
||||
from app.models import PartnerUser
|
||||
from app.proton.utils import get_proton_partner
|
||||
@ -21,6 +22,7 @@ class LoginForm(FlaskForm):
|
||||
|
||||
|
||||
@dashboard_bp.route("/enter_sudo", methods=["GET", "POST"])
|
||||
@limiter.limit("3/minute")
|
||||
@login_required
|
||||
def enter_sudo():
|
||||
password_check_form = LoginForm()
|
||||
|
@ -1,3 +1,7 @@
|
||||
import base64
|
||||
import binascii
|
||||
import json
|
||||
|
||||
import arrow
|
||||
from flask import render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_required, current_user
|
||||
@ -15,8 +19,8 @@ from app.email_utils import (
|
||||
mailbox_already_used,
|
||||
render,
|
||||
send_email,
|
||||
is_valid_email,
|
||||
)
|
||||
from app.email_validation import is_valid_email
|
||||
from app.log import LOG
|
||||
from app.models import Mailbox, Job
|
||||
from app.utils import CSRFValidationForm
|
||||
@ -180,7 +184,9 @@ def mailbox_route():
|
||||
|
||||
def send_verification_email(user, mailbox):
|
||||
s = TimestampSigner(MAILBOX_SECRET)
|
||||
mailbox_id_signed = s.sign(str(mailbox.id)).decode()
|
||||
encoded_data = json.dumps([mailbox.id, mailbox.email]).encode("utf-8")
|
||||
b64_data = base64.urlsafe_b64encode(encoded_data)
|
||||
mailbox_id_signed = s.sign(b64_data).decode()
|
||||
verification_url = (
|
||||
URL + "/dashboard/mailbox_verify" + f"?mailbox_id={mailbox_id_signed}"
|
||||
)
|
||||
@ -205,22 +211,34 @@ def send_verification_email(user, mailbox):
|
||||
@dashboard_bp.route("/mailbox_verify")
|
||||
def mailbox_verify():
|
||||
s = TimestampSigner(MAILBOX_SECRET)
|
||||
mailbox_id = request.args.get("mailbox_id")
|
||||
|
||||
mailbox_verify_request = request.args.get("mailbox_id")
|
||||
try:
|
||||
r_id = int(s.unsign(mailbox_id, max_age=900))
|
||||
mailbox_raw_data = s.unsign(mailbox_verify_request, max_age=900)
|
||||
except Exception:
|
||||
flash("Invalid link. Please delete and re-add your mailbox", "error")
|
||||
return redirect(url_for("dashboard.mailbox_route"))
|
||||
else:
|
||||
mailbox = Mailbox.get(r_id)
|
||||
if not mailbox:
|
||||
flash("Invalid link", "error")
|
||||
return redirect(url_for("dashboard.mailbox_route"))
|
||||
try:
|
||||
decoded_data = base64.urlsafe_b64decode(mailbox_raw_data)
|
||||
except binascii.Error:
|
||||
flash("Invalid link. Please delete and re-add your mailbox", "error")
|
||||
return redirect(url_for("dashboard.mailbox_route"))
|
||||
mailbox_data = json.loads(decoded_data)
|
||||
if not isinstance(mailbox_data, list) or len(mailbox_data) != 2:
|
||||
flash("Invalid link. Please delete and re-add your mailbox", "error")
|
||||
return redirect(url_for("dashboard.mailbox_route"))
|
||||
mailbox_id = mailbox_data[0]
|
||||
mailbox = Mailbox.get(mailbox_id)
|
||||
if not mailbox:
|
||||
flash("Invalid link", "error")
|
||||
return redirect(url_for("dashboard.mailbox_route"))
|
||||
mailbox_email = mailbox_data[1]
|
||||
if mailbox_email != mailbox.email:
|
||||
flash("Invalid link", "error")
|
||||
return redirect(url_for("dashboard.mailbox_route"))
|
||||
|
||||
mailbox.verified = True
|
||||
Session.commit()
|
||||
mailbox.verified = True
|
||||
Session.commit()
|
||||
|
||||
LOG.d("Mailbox %s is verified", mailbox)
|
||||
LOG.d("Mailbox %s is verified", mailbox)
|
||||
|
||||
return render_template("dashboard/mailbox_validation.html", mailbox=mailbox)
|
||||
return render_template("dashboard/mailbox_validation.html", mailbox=mailbox)
|
||||
|
@ -30,7 +30,7 @@ class ChangeEmailForm(FlaskForm):
|
||||
@dashboard_bp.route("/mailbox/<int:mailbox_id>/", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def mailbox_detail_route(mailbox_id):
|
||||
mailbox = Mailbox.get(mailbox_id)
|
||||
mailbox: Mailbox = Mailbox.get(mailbox_id)
|
||||
if not mailbox or mailbox.user_id != current_user.id:
|
||||
flash("You cannot see this page", "warning")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
@ -144,6 +144,15 @@ def mailbox_detail_route(mailbox_id):
|
||||
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
|
||||
)
|
||||
|
||||
if mailbox.is_proton():
|
||||
flash(
|
||||
"Enabling PGP for a Proton Mail mailbox is redundant and does not add any security benefit",
|
||||
"info",
|
||||
)
|
||||
return redirect(
|
||||
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
|
||||
)
|
||||
|
||||
mailbox.pgp_public_key = request.form.get("pgp")
|
||||
try:
|
||||
mailbox.pgp_finger_print = load_public_key_and_check(
|
||||
@ -182,25 +191,16 @@ def mailbox_detail_route(mailbox_id):
|
||||
)
|
||||
elif request.form.get("form-name") == "generic-subject":
|
||||
if request.form.get("action") == "save":
|
||||
if not mailbox.pgp_enabled():
|
||||
flash(
|
||||
"Generic subject can only be used on PGP-enabled mailbox",
|
||||
"error",
|
||||
)
|
||||
return redirect(
|
||||
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
|
||||
)
|
||||
|
||||
mailbox.generic_subject = request.form.get("generic-subject")
|
||||
Session.commit()
|
||||
flash("Generic subject for PGP-encrypted email is enabled", "success")
|
||||
flash("Generic subject is enabled", "success")
|
||||
return redirect(
|
||||
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
|
||||
)
|
||||
elif request.form.get("action") == "remove":
|
||||
mailbox.generic_subject = None
|
||||
Session.commit()
|
||||
flash("Generic subject for PGP-encrypted email is disabled", "success")
|
||||
flash("Generic subject is disabled", "success")
|
||||
return redirect(
|
||||
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
|
||||
)
|
||||
|
@ -80,8 +80,9 @@ def pricing():
|
||||
@dashboard_bp.route("/subscription_success")
|
||||
@login_required
|
||||
def subscription_success():
|
||||
flash("Thanks so much for supporting SimpleLogin!", "success")
|
||||
return redirect(url_for("dashboard.index"))
|
||||
return render_template(
|
||||
"dashboard/thank-you.html",
|
||||
)
|
||||
|
||||
|
||||
@dashboard_bp.route("/coinbase_checkout")
|
||||
|
@ -198,6 +198,16 @@ def setting():
|
||||
)
|
||||
return redirect(url_for("dashboard.setting"))
|
||||
|
||||
if current_user.profile_picture_id is not None:
|
||||
current_profile_file = File.get_by(
|
||||
id=current_user.profile_picture_id
|
||||
)
|
||||
if (
|
||||
current_profile_file is not None
|
||||
and current_profile_file.user_id == current_user.id
|
||||
):
|
||||
s3.delete(current_profile_file.path)
|
||||
|
||||
file_path = random_string(30)
|
||||
file = File.create(user_id=current_user.id, path=file_path)
|
||||
|
||||
@ -451,8 +461,13 @@ def send_change_email_confirmation(user: User, email_change: EmailChange):
|
||||
|
||||
|
||||
@dashboard_bp.route("/resend_email_change", methods=["GET", "POST"])
|
||||
@limiter.limit("5/hour")
|
||||
@login_required
|
||||
def resend_email_change():
|
||||
form = CSRFValidationForm()
|
||||
if not form.validate():
|
||||
flash("Invalid request. Please try again", "warning")
|
||||
return redirect(url_for("dashboard.setting"))
|
||||
email_change = EmailChange.get_by(user_id=current_user.id)
|
||||
if email_change:
|
||||
# extend email change expiration
|
||||
@ -472,6 +487,10 @@ def resend_email_change():
|
||||
@dashboard_bp.route("/cancel_email_change", methods=["GET", "POST"])
|
||||
@login_required
|
||||
def cancel_email_change():
|
||||
form = CSRFValidationForm()
|
||||
if not form.validate():
|
||||
flash("Invalid request. Please try again", "warning")
|
||||
return redirect(url_for("dashboard.setting"))
|
||||
email_change = EmailChange.get_by(user_id=current_user.id)
|
||||
if email_change:
|
||||
EmailChange.delete(email_change.id)
|
||||
|
@ -34,7 +34,7 @@ def get_cname_record(hostname) -> Optional[str]:
|
||||
|
||||
|
||||
def get_mx_domains(hostname) -> [(int, str)]:
|
||||
"""return list of (priority, domain name).
|
||||
"""return list of (priority, domain name) sorted by priority (lowest priority first)
|
||||
domain name ends with a "." at the end.
|
||||
"""
|
||||
try:
|
||||
@ -50,7 +50,7 @@ def get_mx_domains(hostname) -> [(int, str)]:
|
||||
|
||||
ret.append((int(parts[0]), parts[1]))
|
||||
|
||||
return ret
|
||||
return sorted(ret, key=lambda prio_domain: prio_domain[0])
|
||||
|
||||
|
||||
_include_spf = "include:"
|
||||
|
@ -20,6 +20,7 @@ X_SPAM_STATUS = "X-Spam-Status"
|
||||
LIST_UNSUBSCRIBE = "List-Unsubscribe"
|
||||
LIST_UNSUBSCRIBE_POST = "List-Unsubscribe-Post"
|
||||
RETURN_PATH = "Return-Path"
|
||||
AUTHENTICATION_RESULTS = "Authentication-Results"
|
||||
|
||||
# headers used to DKIM sign in order of preference
|
||||
DKIM_HEADERS = [
|
||||
@ -32,6 +33,7 @@ DKIM_HEADERS = [
|
||||
SL_DIRECTION = "X-SimpleLogin-Type"
|
||||
SL_EMAIL_LOG_ID = "X-SimpleLogin-EmailLog-ID"
|
||||
SL_ENVELOPE_FROM = "X-SimpleLogin-Envelope-From"
|
||||
SL_ORIGINAL_FROM = "X-SimpleLogin-Original-From"
|
||||
SL_ENVELOPE_TO = "X-SimpleLogin-Envelope-To"
|
||||
SL_CLIENT_IP = "X-SimpleLogin-Client-IP"
|
||||
|
||||
|
@ -60,4 +60,5 @@ E522 = (
|
||||
)
|
||||
E523 = "550 SL E523 Unknown error"
|
||||
E524 = "550 SL E524 Wrong use of reverse-alias"
|
||||
E525 = "550 SL E525 Alias loop"
|
||||
# endregion
|
||||
|
@ -54,6 +54,7 @@ from app.models import (
|
||||
IgnoreBounceSender,
|
||||
InvalidMailboxDomain,
|
||||
VerpType,
|
||||
available_sl_email,
|
||||
)
|
||||
from app.utils import (
|
||||
random_string,
|
||||
@ -827,19 +828,6 @@ def should_add_dkim_signature(domain: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def is_valid_email(email_address: str) -> bool:
|
||||
"""
|
||||
Used to check whether an email address is valid
|
||||
NOT run MX check.
|
||||
NOT allow unicode.
|
||||
"""
|
||||
try:
|
||||
validate_email(email_address, check_deliverability=False, allow_smtputf8=False)
|
||||
return True
|
||||
except EmailNotValidError:
|
||||
return False
|
||||
|
||||
|
||||
class EmailEncoding(enum.Enum):
|
||||
BASE64 = "base64"
|
||||
QUOTED = "quoted-printable"
|
||||
@ -950,6 +938,8 @@ def add_header(msg: Message, text_header, html_header=None) -> Message:
|
||||
for part in msg.get_payload():
|
||||
if isinstance(part, Message):
|
||||
new_parts.append(add_header(part, text_header, html_header))
|
||||
elif isinstance(part, str):
|
||||
new_parts.append(MIMEText(part))
|
||||
else:
|
||||
new_parts.append(part)
|
||||
clone_msg = copy(msg)
|
||||
@ -958,7 +948,14 @@ def add_header(msg: Message, text_header, html_header=None) -> Message:
|
||||
|
||||
elif content_type in ("multipart/mixed", "multipart/signed"):
|
||||
new_parts = []
|
||||
parts = list(msg.get_payload())
|
||||
payload = msg.get_payload()
|
||||
if isinstance(payload, str):
|
||||
# The message is badly formatted inject as new
|
||||
new_parts = [MIMEText(text_header, "plain"), MIMEText(payload, "plain")]
|
||||
clone_msg = copy(msg)
|
||||
clone_msg.set_payload(new_parts)
|
||||
return clone_msg
|
||||
parts = list(payload)
|
||||
LOG.d("only add header for the first part for %s", content_type)
|
||||
for ix, part in enumerate(parts):
|
||||
if ix == 0:
|
||||
@ -1043,7 +1040,7 @@ def replace(msg: Union[Message, str], old, new) -> Union[Message, str]:
|
||||
return msg
|
||||
|
||||
|
||||
def generate_reply_email(contact_email: str, user: User) -> str:
|
||||
def generate_reply_email(contact_email: str, alias: Alias) -> str:
|
||||
"""
|
||||
generate a reply_email (aka reverse-alias), make sure it isn't used by any contact
|
||||
"""
|
||||
@ -1054,6 +1051,7 @@ def generate_reply_email(contact_email: str, user: User) -> str:
|
||||
|
||||
include_sender_in_reverse_alias = False
|
||||
|
||||
user = alias.user
|
||||
# user has set this option explicitly
|
||||
if user.include_sender_in_reverse_alias is not None:
|
||||
include_sender_in_reverse_alias = user.include_sender_in_reverse_alias
|
||||
@ -1068,6 +1066,12 @@ def generate_reply_email(contact_email: str, user: User) -> str:
|
||||
contact_email = contact_email.replace(".", "_")
|
||||
contact_email = convert_to_alphanumeric(contact_email)
|
||||
|
||||
reply_domain = config.EMAIL_DOMAIN
|
||||
alias_domain = get_email_domain_part(alias.email)
|
||||
sl_domain = SLDomain.get_by(domain=alias_domain)
|
||||
if sl_domain and sl_domain.use_as_reverse_alias:
|
||||
reply_domain = alias_domain
|
||||
|
||||
# not use while to avoid infinite loop
|
||||
for _ in range(1000):
|
||||
if include_sender_in_reverse_alias and contact_email:
|
||||
@ -1075,15 +1079,15 @@ def generate_reply_email(contact_email: str, user: User) -> str:
|
||||
reply_email = (
|
||||
# do not use the ra+ anymore
|
||||
# f"ra+{contact_email}+{random_string(random_length)}@{config.EMAIL_DOMAIN}"
|
||||
f"{contact_email}_{random_string(random_length)}@{config.EMAIL_DOMAIN}"
|
||||
f"{contact_email}_{random_string(random_length)}@{reply_domain}"
|
||||
)
|
||||
else:
|
||||
random_length = random.randint(20, 50)
|
||||
# do not use the ra+ anymore
|
||||
# reply_email = f"ra+{random_string(random_length)}@{config.EMAIL_DOMAIN}"
|
||||
reply_email = f"{random_string(random_length)}@{config.EMAIL_DOMAIN}"
|
||||
reply_email = f"{random_string(random_length)}@{reply_domain}"
|
||||
|
||||
if not Contact.get_by(reply_email=reply_email):
|
||||
if available_sl_email(reply_email):
|
||||
return reply_email
|
||||
|
||||
raise Exception("Cannot generate reply email")
|
||||
@ -1099,26 +1103,6 @@ def is_reverse_alias(address: str) -> bool:
|
||||
)
|
||||
|
||||
|
||||
# allow also + and @ that are present in a reply address
|
||||
_ALLOWED_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.+@"
|
||||
|
||||
|
||||
def normalize_reply_email(reply_email: str) -> str:
|
||||
"""Handle the case where reply email contains *strange* char that was wrongly generated in the past"""
|
||||
if not reply_email.isascii():
|
||||
reply_email = convert_to_id(reply_email)
|
||||
|
||||
ret = []
|
||||
# drop all control characters like shift, separator, etc
|
||||
for c in reply_email:
|
||||
if c not in _ALLOWED_CHARS:
|
||||
ret.append("_")
|
||||
else:
|
||||
ret.append(c)
|
||||
|
||||
return "".join(ret)
|
||||
|
||||
|
||||
def should_disable(alias: Alias) -> (bool, str):
|
||||
"""
|
||||
Return whether an alias should be disabled and if yes, the reason why
|
||||
|
38
app/app/email_validation.py
Normal file
38
app/app/email_validation.py
Normal file
@ -0,0 +1,38 @@
|
||||
from email_validator import (
|
||||
validate_email,
|
||||
EmailNotValidError,
|
||||
)
|
||||
|
||||
from app.utils import convert_to_id
|
||||
|
||||
# allow also + and @ that are present in a reply address
|
||||
_ALLOWED_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.+@"
|
||||
|
||||
|
||||
def is_valid_email(email_address: str) -> bool:
|
||||
"""
|
||||
Used to check whether an email address is valid
|
||||
NOT run MX check.
|
||||
NOT allow unicode.
|
||||
"""
|
||||
try:
|
||||
validate_email(email_address, check_deliverability=False, allow_smtputf8=False)
|
||||
return True
|
||||
except EmailNotValidError:
|
||||
return False
|
||||
|
||||
|
||||
def normalize_reply_email(reply_email: str) -> str:
|
||||
"""Handle the case where reply email contains *strange* char that was wrongly generated in the past"""
|
||||
if not reply_email.isascii():
|
||||
reply_email = convert_to_id(reply_email)
|
||||
|
||||
ret = []
|
||||
# drop all control characters like shift, separator, etc
|
||||
for c in reply_email:
|
||||
if c not in _ALLOWED_CHARS:
|
||||
ret.append("_")
|
||||
else:
|
||||
ret.append(c)
|
||||
|
||||
return "".join(ret)
|
@ -71,7 +71,7 @@ class ErrContactErrorUpgradeNeeded(SLException):
|
||||
"""raised when user cannot create a contact because the plan doesn't allow it"""
|
||||
|
||||
def error_for_user(self) -> str:
|
||||
return f"Please upgrade to premium to create reverse-alias"
|
||||
return "Please upgrade to premium to create reverse-alias"
|
||||
|
||||
|
||||
class ErrAddressInvalid(SLException):
|
||||
@ -84,6 +84,14 @@ class ErrAddressInvalid(SLException):
|
||||
return f"{self.address} is not a valid email address"
|
||||
|
||||
|
||||
class InvalidContactEmailError(SLException):
|
||||
def __init__(self, website_email: str): # noqa: F821
|
||||
self.website_email = website_email
|
||||
|
||||
def error_for_user(self) -> str:
|
||||
return f"Cannot create contact with invalid email {self.website_email}"
|
||||
|
||||
|
||||
class ErrContactAlreadyExists(SLException):
|
||||
"""raised when a contact already exists"""
|
||||
|
||||
@ -108,3 +116,15 @@ class AccountAlreadyLinkedToAnotherPartnerException(LinkException):
|
||||
class AccountAlreadyLinkedToAnotherUserException(LinkException):
|
||||
def __init__(self):
|
||||
super().__init__("This account is linked to another user")
|
||||
|
||||
|
||||
class AccountIsUsingAliasAsEmail(LinkException):
|
||||
def __init__(self):
|
||||
super().__init__("Your account has an alias as it's email address")
|
||||
|
||||
|
||||
class ProtonAccountNotVerified(LinkException):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
"The Proton account you are trying to use has not been verified"
|
||||
)
|
||||
|
@ -74,8 +74,8 @@ class UnsubscribeEncoder:
|
||||
)
|
||||
signed_data = cls._get_signer().sign(serialized_data).decode("utf-8")
|
||||
encoded_request = f"{UNSUB_PREFIX}.{signed_data}"
|
||||
if len(encoded_request) > 256:
|
||||
LOG.e("Encoded request is longer than 256 chars")
|
||||
if len(encoded_request) > 512:
|
||||
LOG.w("Encoded request is longer than 512 chars")
|
||||
return encoded_request
|
||||
|
||||
@staticmethod
|
||||
|
@ -1,4 +1,5 @@
|
||||
import urllib
|
||||
from email.header import Header
|
||||
from email.message import Message
|
||||
|
||||
from app.email import headers
|
||||
@ -9,6 +10,7 @@ from app.handler.unsubscribe_encoder import (
|
||||
UnsubscribeData,
|
||||
UnsubscribeOriginalData,
|
||||
)
|
||||
from app.log import LOG
|
||||
from app.models import Alias, Contact, UnsubscribeBehaviourEnum
|
||||
|
||||
|
||||
@ -30,7 +32,10 @@ class UnsubscribeGenerator:
|
||||
"""
|
||||
unsubscribe_data = message[headers.LIST_UNSUBSCRIBE]
|
||||
if not unsubscribe_data:
|
||||
LOG.info("Email has no unsubscribe header")
|
||||
return message
|
||||
if isinstance(unsubscribe_data, Header):
|
||||
unsubscribe_data = str(unsubscribe_data.encode)
|
||||
raw_methods = [method.strip() for method in unsubscribe_data.split(",")]
|
||||
mailto_unsubs = None
|
||||
other_unsubs = []
|
||||
@ -44,7 +49,9 @@ class UnsubscribeGenerator:
|
||||
if url_data.scheme == "mailto":
|
||||
query_data = urllib.parse.parse_qs(url_data.query)
|
||||
mailto_unsubs = (url_data.path, query_data.get("subject", [""])[0])
|
||||
LOG.debug(f"Unsub is mailto to {mailto_unsubs}")
|
||||
else:
|
||||
LOG.debug(f"Unsub has {url_data.scheme} scheme")
|
||||
other_unsubs.append(method)
|
||||
# If there are non mailto unsubscribe methods, use those in the header
|
||||
if other_unsubs:
|
||||
@ -56,18 +63,19 @@ class UnsubscribeGenerator:
|
||||
add_or_replace_header(
|
||||
message, headers.LIST_UNSUBSCRIBE_POST, "List-Unsubscribe=One-Click"
|
||||
)
|
||||
LOG.debug(f"Adding click unsub methods to header {other_unsubs}")
|
||||
return message
|
||||
if not mailto_unsubs:
|
||||
message = delete_header(message, headers.LIST_UNSUBSCRIBE)
|
||||
message = delete_header(message, headers.LIST_UNSUBSCRIBE_POST)
|
||||
elif not mailto_unsubs:
|
||||
LOG.debug("No unsubs. Deleting all unsub headers")
|
||||
delete_header(message, headers.LIST_UNSUBSCRIBE)
|
||||
delete_header(message, headers.LIST_UNSUBSCRIBE_POST)
|
||||
return message
|
||||
return self._add_unsubscribe_header(
|
||||
message,
|
||||
UnsubscribeData(
|
||||
UnsubscribeAction.OriginalUnsubscribeMailto,
|
||||
UnsubscribeOriginalData(alias.id, mailto_unsubs[0], mailto_unsubs[1]),
|
||||
),
|
||||
unsub_data = UnsubscribeData(
|
||||
UnsubscribeAction.OriginalUnsubscribeMailto,
|
||||
UnsubscribeOriginalData(alias.id, mailto_unsubs[0], mailto_unsubs[1]),
|
||||
)
|
||||
LOG.debug(f"Adding unsub data {unsub_data}")
|
||||
return self._add_unsubscribe_header(message, unsub_data)
|
||||
|
||||
def _add_unsubscribe_header(
|
||||
self, message: Message, unsub: UnsubscribeData
|
||||
|
@ -41,7 +41,7 @@ from app.models import (
|
||||
class ExportUserDataJob:
|
||||
|
||||
REMOVE_FIELDS = {
|
||||
"User": ("otp_secret",),
|
||||
"User": ("otp_secret", "password"),
|
||||
"Alias": ("ts_vector", "transfer_token", "hibp_last_check"),
|
||||
"CustomDomain": ("ownership_txt_token",),
|
||||
}
|
||||
|
@ -32,6 +32,7 @@ class SendRequest:
|
||||
rcpt_options: Dict = {}
|
||||
is_forward: bool = False
|
||||
ignore_smtp_errors: bool = False
|
||||
retries: int = 0
|
||||
|
||||
def to_bytes(self) -> bytes:
|
||||
if not config.SAVE_UNSENT_DIR:
|
||||
@ -45,6 +46,7 @@ class SendRequest:
|
||||
"mail_options": self.mail_options,
|
||||
"rcpt_options": self.rcpt_options,
|
||||
"is_forward": self.is_forward,
|
||||
"retries": self.retries,
|
||||
}
|
||||
return json.dumps(data).encode("utf-8")
|
||||
|
||||
@ -65,8 +67,33 @@ class SendRequest:
|
||||
mail_options=decoded_data["mail_options"],
|
||||
rcpt_options=decoded_data["rcpt_options"],
|
||||
is_forward=decoded_data["is_forward"],
|
||||
retries=decoded_data.get("retries", 1),
|
||||
)
|
||||
|
||||
def save_request_to_unsent_dir(self, prefix: str = "DeliveryFail"):
|
||||
file_name = (
|
||||
f"{prefix}-{int(time.time())}-{uuid.uuid4()}.{SendRequest.SAVE_EXTENSION}"
|
||||
)
|
||||
file_path = os.path.join(config.SAVE_UNSENT_DIR, file_name)
|
||||
self.save_request_to_file(file_path)
|
||||
|
||||
@staticmethod
|
||||
def save_request_to_failed_dir(self, prefix: str = "DeliveryRetryFail"):
|
||||
file_name = (
|
||||
f"{prefix}-{int(time.time())}-{uuid.uuid4()}.{SendRequest.SAVE_EXTENSION}"
|
||||
)
|
||||
dir_name = os.path.join(config.SAVE_UNSENT_DIR, "failed")
|
||||
if not os.path.isdir(dir_name):
|
||||
os.makedirs(dir_name)
|
||||
file_path = os.path.join(dir_name, file_name)
|
||||
self.save_request_to_file(file_path)
|
||||
|
||||
def save_request_to_file(self, file_path: str):
|
||||
file_contents = self.to_bytes()
|
||||
with open(file_path, "wb") as fd:
|
||||
fd.write(file_contents)
|
||||
LOG.i(f"Saved unsent message {file_path}")
|
||||
|
||||
|
||||
class MailSender:
|
||||
def __init__(self):
|
||||
@ -170,21 +197,10 @@ class MailSender:
|
||||
LOG.e(
|
||||
f"Could not send message to smtp server {config.POSTFIX_SERVER}:{config.POSTFIX_PORT}"
|
||||
)
|
||||
self._save_request_to_unsent_dir(send_request)
|
||||
if config.SAVE_UNSENT_DIR:
|
||||
send_request.save_request_to_unsent_dir()
|
||||
return False
|
||||
|
||||
def _save_request_to_unsent_dir(
|
||||
self, send_request: SendRequest, prefix: str = "DeliveryFail"
|
||||
):
|
||||
file_name = (
|
||||
f"{prefix}-{int(time.time())}-{uuid.uuid4()}.{SendRequest.SAVE_EXTENSION}"
|
||||
)
|
||||
file_path = os.path.join(config.SAVE_UNSENT_DIR, file_name)
|
||||
file_contents = send_request.to_bytes()
|
||||
with open(file_path, "wb") as fd:
|
||||
fd.write(file_contents)
|
||||
LOG.i(f"Saved unsent message {file_path}")
|
||||
|
||||
|
||||
mail_sender = MailSender()
|
||||
|
||||
@ -218,6 +234,7 @@ def load_unsent_mails_from_fs_and_resend():
|
||||
LOG.i(f"Trying to re-deliver email {filename}")
|
||||
try:
|
||||
send_request = SendRequest.load_from_file(full_file_path)
|
||||
send_request.retries += 1
|
||||
except Exception as e:
|
||||
LOG.e(f"Cannot load {filename}. Error {e}")
|
||||
continue
|
||||
@ -229,6 +246,11 @@ def load_unsent_mails_from_fs_and_resend():
|
||||
"DeliverUnsentEmail", {"delivered": "true"}
|
||||
)
|
||||
else:
|
||||
if send_request.retries > 2:
|
||||
os.unlink(full_file_path)
|
||||
send_request.save_request_to_failed_dir()
|
||||
else:
|
||||
send_request.save_request_to_file(full_file_path)
|
||||
newrelic.agent.record_custom_event(
|
||||
"DeliverUnsentEmail", {"delivered": "false"}
|
||||
)
|
||||
|
@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import dataclasses
|
||||
import enum
|
||||
import hashlib
|
||||
import hmac
|
||||
@ -18,7 +19,7 @@ from flanker.addresslib import address
|
||||
from flask import url_for
|
||||
from flask_login import UserMixin
|
||||
from jinja2 import FileSystemLoader, Environment
|
||||
from sqlalchemy import orm
|
||||
from sqlalchemy import orm, or_
|
||||
from sqlalchemy import text, desc, CheckConstraint, Index, Column
|
||||
from sqlalchemy.dialects.postgresql import TSVECTOR
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
@ -29,6 +30,8 @@ from sqlalchemy_utils import ArrowType
|
||||
from app import config
|
||||
from app import s3
|
||||
from app.db import Session
|
||||
from app.dns_utils import get_mx_domains
|
||||
|
||||
from app.errors import (
|
||||
AliasInTrashError,
|
||||
DirectoryInTrashError,
|
||||
@ -44,7 +47,6 @@ from app.utils import (
|
||||
random_string,
|
||||
random_words,
|
||||
sanitize_email,
|
||||
random_word,
|
||||
)
|
||||
|
||||
Base = declarative_base()
|
||||
@ -274,6 +276,12 @@ class IntEnumType(sa.types.TypeDecorator):
|
||||
return self._enum_type(enum_value)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class AliasOptions:
|
||||
show_sl_domains: bool = True
|
||||
show_partner_domains: Optional[Partner] = None
|
||||
|
||||
|
||||
class Hibp(Base, ModelMixin):
|
||||
__tablename__ = "hibp"
|
||||
name = sa.Column(sa.String(), nullable=False, unique=True, index=True)
|
||||
@ -292,7 +300,9 @@ class HibpNotifiedAlias(Base, ModelMixin):
|
||||
"""
|
||||
|
||||
__tablename__ = "hibp_notified_alias"
|
||||
alias_id = sa.Column(sa.ForeignKey("alias.id", ondelete="cascade"), nullable=False)
|
||||
alias_id = sa.Column(
|
||||
sa.ForeignKey("alias.id", ondelete="cascade"), nullable=False, index=True
|
||||
)
|
||||
user_id = sa.Column(sa.ForeignKey("users.id", ondelete="cascade"), nullable=False)
|
||||
|
||||
notified_at = sa.Column(ArrowType, default=arrow.utcnow, nullable=False)
|
||||
@ -333,7 +343,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
||||
sa.Boolean, default=True, nullable=False, server_default="1"
|
||||
)
|
||||
|
||||
activated = sa.Column(sa.Boolean, default=False, nullable=False)
|
||||
activated = sa.Column(sa.Boolean, default=False, nullable=False, index=True)
|
||||
|
||||
# an account can be disabled if having harmful behavior
|
||||
disabled = sa.Column(sa.Boolean, default=False, nullable=False, server_default="0")
|
||||
@ -403,7 +413,10 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
||||
)
|
||||
|
||||
referral_id = sa.Column(
|
||||
sa.ForeignKey("referral.id", ondelete="SET NULL"), nullable=True, default=None
|
||||
sa.ForeignKey("referral.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
default=None,
|
||||
index=True,
|
||||
)
|
||||
|
||||
referral = orm.relationship("Referral", foreign_keys=[referral_id])
|
||||
@ -420,7 +433,10 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
||||
|
||||
# newsletter is sent to this address
|
||||
newsletter_alias_id = sa.Column(
|
||||
sa.ForeignKey("alias.id", ondelete="SET NULL"), nullable=True, default=None
|
||||
sa.ForeignKey("alias.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
default=None,
|
||||
index=True,
|
||||
)
|
||||
|
||||
# whether to include the sender address in reverse-alias
|
||||
@ -434,7 +450,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
||||
random_alias_suffix = sa.Column(
|
||||
sa.Integer,
|
||||
nullable=False,
|
||||
default=AliasSuffixEnum.random_string.value,
|
||||
default=AliasSuffixEnum.word.value,
|
||||
server_default=str(AliasSuffixEnum.random_string.value),
|
||||
)
|
||||
|
||||
@ -503,9 +519,8 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
||||
server_default=BlockBehaviourEnum.return_2xx.name,
|
||||
)
|
||||
|
||||
# to keep existing behavior, the server default is TRUE whereas for new user, the default value is FALSE
|
||||
include_header_email_header = sa.Column(
|
||||
sa.Boolean, default=False, nullable=False, server_default="1"
|
||||
sa.Boolean, default=True, nullable=False, server_default="1"
|
||||
)
|
||||
|
||||
# bitwise flags. Allow for future expansion
|
||||
@ -519,11 +534,17 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
||||
# Keep original unsub behaviour
|
||||
unsub_behaviour = sa.Column(
|
||||
IntEnumType(UnsubscribeBehaviourEnum),
|
||||
default=UnsubscribeBehaviourEnum.DisableAlias,
|
||||
default=UnsubscribeBehaviourEnum.PreserveOriginal,
|
||||
server_default=str(UnsubscribeBehaviourEnum.DisableAlias.value),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
sa.Index(
|
||||
"ix_users_activated_trial_end_lifetime", activated, trial_end, lifetime
|
||||
),
|
||||
)
|
||||
|
||||
@property
|
||||
def directory_quota(self):
|
||||
return min(
|
||||
@ -558,7 +579,8 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
||||
|
||||
@classmethod
|
||||
def create(cls, email, name="", password=None, from_partner=False, **kwargs):
|
||||
user: User = super(User, cls).create(email=email, name=name, **kwargs)
|
||||
email = sanitize_email(email)
|
||||
user: User = super(User, cls).create(email=email, name=name[:100], **kwargs)
|
||||
|
||||
if password:
|
||||
user.set_password(password)
|
||||
@ -569,19 +591,6 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
||||
Session.flush()
|
||||
user.default_mailbox_id = mb.id
|
||||
|
||||
# create a first alias mail to show user how to use when they login
|
||||
alias = Alias.create_new(
|
||||
user,
|
||||
prefix="simplelogin-newsletter",
|
||||
mailbox_id=mb.id,
|
||||
note="This is your first alias. It's used to receive SimpleLogin communications "
|
||||
"like new features announcements, newsletters.",
|
||||
)
|
||||
Session.flush()
|
||||
|
||||
user.newsletter_alias_id = alias.id
|
||||
Session.flush()
|
||||
|
||||
# generate an alternative_id if needed
|
||||
if "alternative_id" not in kwargs:
|
||||
user.alternative_id = str(uuid.uuid4())
|
||||
@ -600,6 +609,19 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
||||
Session.flush()
|
||||
return user
|
||||
|
||||
# create a first alias mail to show user how to use when they login
|
||||
alias = Alias.create_new(
|
||||
user,
|
||||
prefix="simplelogin-newsletter",
|
||||
mailbox_id=mb.id,
|
||||
note="This is your first alias. It's used to receive SimpleLogin communications "
|
||||
"like new features announcements, newsletters.",
|
||||
)
|
||||
Session.flush()
|
||||
|
||||
user.newsletter_alias_id = alias.id
|
||||
Session.flush()
|
||||
|
||||
if config.DISABLE_ONBOARDING:
|
||||
LOG.d("Disable onboarding emails")
|
||||
return user
|
||||
@ -625,7 +647,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
||||
return user
|
||||
|
||||
def get_active_subscription(
|
||||
self,
|
||||
self, include_partner_subscription: bool = True
|
||||
) -> Optional[
|
||||
Union[
|
||||
Subscription
|
||||
@ -653,19 +675,40 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
||||
if coinbase_subscription and coinbase_subscription.is_active():
|
||||
return coinbase_subscription
|
||||
|
||||
partner_sub: PartnerSubscription = PartnerSubscription.find_by_user_id(self.id)
|
||||
if partner_sub and partner_sub.is_active():
|
||||
return partner_sub
|
||||
if include_partner_subscription:
|
||||
partner_sub: PartnerSubscription = PartnerSubscription.find_by_user_id(
|
||||
self.id
|
||||
)
|
||||
if partner_sub and partner_sub.is_active():
|
||||
return partner_sub
|
||||
|
||||
return None
|
||||
|
||||
def get_active_subscription_end(
|
||||
self, include_partner_subscription: bool = True
|
||||
) -> Optional[arrow.Arrow]:
|
||||
sub = self.get_active_subscription(
|
||||
include_partner_subscription=include_partner_subscription
|
||||
)
|
||||
if isinstance(sub, Subscription):
|
||||
return arrow.get(sub.next_bill_date)
|
||||
if isinstance(sub, AppleSubscription):
|
||||
return sub.expires_date
|
||||
if isinstance(sub, ManualSubscription):
|
||||
return sub.end_at
|
||||
if isinstance(sub, CoinbaseSubscription):
|
||||
return sub.end_at
|
||||
return None
|
||||
|
||||
# region Billing
|
||||
def lifetime_or_active_subscription(self) -> bool:
|
||||
def lifetime_or_active_subscription(
|
||||
self, include_partner_subscription: bool = True
|
||||
) -> bool:
|
||||
"""True if user has lifetime licence or active subscription"""
|
||||
if self.lifetime:
|
||||
return True
|
||||
|
||||
return self.get_active_subscription() is not None
|
||||
return self.get_active_subscription(include_partner_subscription) is not None
|
||||
|
||||
def is_paid(self) -> bool:
|
||||
"""same as _lifetime_or_active_subscription but not include free manual subscription"""
|
||||
@ -694,14 +737,14 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
||||
|
||||
return True
|
||||
|
||||
def is_premium(self) -> bool:
|
||||
def is_premium(self, include_partner_subscription: bool = True) -> bool:
|
||||
"""
|
||||
user is premium if they:
|
||||
- have a lifetime deal or
|
||||
- in trial period or
|
||||
- active subscription
|
||||
"""
|
||||
if self.lifetime_or_active_subscription():
|
||||
if self.lifetime_or_active_subscription(include_partner_subscription):
|
||||
return True
|
||||
|
||||
if self.trial_end and arrow.now() < self.trial_end:
|
||||
@ -868,14 +911,16 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
||||
def custom_domains(self):
|
||||
return CustomDomain.filter_by(user_id=self.id, verified=True).all()
|
||||
|
||||
def available_domains_for_random_alias(self) -> List[Tuple[bool, str]]:
|
||||
def available_domains_for_random_alias(
|
||||
self, alias_options: Optional[AliasOptions] = None
|
||||
) -> List[Tuple[bool, str]]:
|
||||
"""Return available domains for user to create random aliases
|
||||
Each result record contains:
|
||||
- whether the domain belongs to SimpleLogin
|
||||
- the domain
|
||||
"""
|
||||
res = []
|
||||
for domain in self.available_sl_domains():
|
||||
for domain in self.available_sl_domains(alias_options=alias_options):
|
||||
res.append((True, domain))
|
||||
|
||||
for custom_domain in self.verified_custom_domains():
|
||||
@ -960,30 +1005,59 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
||||
|
||||
return None, "", False
|
||||
|
||||
def available_sl_domains(self) -> [str]:
|
||||
def available_sl_domains(
|
||||
self, alias_options: Optional[AliasOptions] = None
|
||||
) -> [str]:
|
||||
"""
|
||||
Return all SimpleLogin domains that user can use when creating a new alias, including:
|
||||
- SimpleLogin public domains, available for all users (ALIAS_DOMAIN)
|
||||
- SimpleLogin premium domains, only available for Premium accounts (PREMIUM_ALIAS_DOMAIN)
|
||||
"""
|
||||
return [sl_domain.domain for sl_domain in self.get_sl_domains()]
|
||||
return [
|
||||
sl_domain.domain
|
||||
for sl_domain in self.get_sl_domains(alias_options=alias_options)
|
||||
]
|
||||
|
||||
def get_sl_domains(self) -> List["SLDomain"]:
|
||||
query = SLDomain.filter_by(hidden=False).order_by(SLDomain.order)
|
||||
|
||||
if self.is_premium():
|
||||
return query.all()
|
||||
def get_sl_domains(
|
||||
self, alias_options: Optional[AliasOptions] = None
|
||||
) -> list["SLDomain"]:
|
||||
if alias_options is None:
|
||||
alias_options = AliasOptions()
|
||||
conditions = [SLDomain.hidden == False] # noqa: E712
|
||||
if not self.is_premium():
|
||||
conditions.append(SLDomain.premium_only == False) # noqa: E712
|
||||
partner_domain_cond = [] # noqa:E711
|
||||
if self.default_alias_public_domain_id is not None:
|
||||
partner_domain_cond.append(
|
||||
SLDomain.id == self.default_alias_public_domain_id
|
||||
)
|
||||
if alias_options.show_partner_domains is not None:
|
||||
partner_user = PartnerUser.filter_by(
|
||||
user_id=self.id, partner_id=alias_options.show_partner_domains.id
|
||||
).first()
|
||||
if partner_user is not None:
|
||||
partner_domain_cond.append(
|
||||
SLDomain.partner_id == partner_user.partner_id
|
||||
)
|
||||
if alias_options.show_sl_domains:
|
||||
partner_domain_cond.append(SLDomain.partner_id == None) # noqa:E711
|
||||
if len(partner_domain_cond) == 1:
|
||||
conditions.append(partner_domain_cond[0])
|
||||
else:
|
||||
return query.filter_by(premium_only=False).all()
|
||||
conditions.append(or_(*partner_domain_cond))
|
||||
query = Session.query(SLDomain).filter(*conditions).order_by(SLDomain.order)
|
||||
return query.all()
|
||||
|
||||
def available_alias_domains(self) -> [str]:
|
||||
def available_alias_domains(
|
||||
self, alias_options: Optional[AliasOptions] = None
|
||||
) -> [str]:
|
||||
"""return all domains that user can use when creating a new alias, including:
|
||||
- SimpleLogin public domains, available for all users (ALIAS_DOMAIN)
|
||||
- SimpleLogin premium domains, only available for Premium accounts (PREMIUM_ALIAS_DOMAIN)
|
||||
- Verified custom domains
|
||||
|
||||
"""
|
||||
domains = self.available_sl_domains()
|
||||
domains = self.available_sl_domains(alias_options=alias_options)
|
||||
|
||||
for custom_domain in self.verified_custom_domains():
|
||||
domains.append(custom_domain.domain)
|
||||
@ -1001,16 +1075,21 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
||||
> 0
|
||||
)
|
||||
|
||||
def get_random_alias_suffix(self):
|
||||
def get_random_alias_suffix(self, custom_domain: Optional["CustomDomain"] = None):
|
||||
"""Get random suffix for an alias based on user's preference.
|
||||
|
||||
Use a shorter suffix in case of custom domain
|
||||
|
||||
Returns:
|
||||
str: the random suffix generated
|
||||
"""
|
||||
if self.random_alias_suffix == AliasSuffixEnum.random_string.value:
|
||||
return random_string(config.ALIAS_RANDOM_SUFFIX_LENGTH, include_digits=True)
|
||||
return random_word()
|
||||
|
||||
if custom_domain is None:
|
||||
return random_words(1, 3)
|
||||
|
||||
return random_words(1)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User {self.id} {self.name} {self.email}>"
|
||||
@ -1255,34 +1334,48 @@ class OauthToken(Base, ModelMixin):
|
||||
return self.expired < arrow.now()
|
||||
|
||||
|
||||
def generate_email(
|
||||
def available_sl_email(email: str) -> bool:
|
||||
if (
|
||||
Alias.get_by(email=email)
|
||||
or Contact.get_by(reply_email=email)
|
||||
or DeletedAlias.get_by(email=email)
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def generate_random_alias_email(
|
||||
scheme: int = AliasGeneratorEnum.word.value,
|
||||
in_hex: bool = False,
|
||||
alias_domain=config.FIRST_ALIAS_DOMAIN,
|
||||
alias_domain: str = config.FIRST_ALIAS_DOMAIN,
|
||||
retries: int = 10,
|
||||
) -> str:
|
||||
"""generate an email address that does not exist before
|
||||
:param alias_domain: the domain used to generate the alias.
|
||||
:param scheme: int, value of AliasGeneratorEnum, indicate how the email is generated
|
||||
:param retries: int, How many times we can try to generate an alias in case of collision
|
||||
:type in_hex: bool, if the generate scheme is uuid, is hex favorable?
|
||||
"""
|
||||
if retries <= 0:
|
||||
raise Exception("Cannot generate alias after many retries")
|
||||
if scheme == AliasGeneratorEnum.uuid.value:
|
||||
name = uuid.uuid4().hex if in_hex else uuid.uuid4().__str__()
|
||||
random_email = name + "@" + alias_domain
|
||||
else:
|
||||
random_email = random_words() + "@" + alias_domain
|
||||
random_email = random_words(2, 3) + "@" + alias_domain
|
||||
|
||||
random_email = random_email.lower().strip()
|
||||
|
||||
# check that the client does not exist yet
|
||||
if not Alias.get_by(email=random_email) and not DeletedAlias.get_by(
|
||||
email=random_email
|
||||
):
|
||||
if available_sl_email(random_email):
|
||||
LOG.d("generate email %s", random_email)
|
||||
return random_email
|
||||
|
||||
# Rerun the function
|
||||
LOG.w("email %s already exists, generate a new email", random_email)
|
||||
return generate_email(scheme=scheme, in_hex=in_hex)
|
||||
return generate_random_alias_email(
|
||||
scheme=scheme, in_hex=in_hex, retries=retries - 1
|
||||
)
|
||||
|
||||
|
||||
class Alias(Base, ModelMixin):
|
||||
@ -1364,7 +1457,7 @@ class Alias(Base, ModelMixin):
|
||||
)
|
||||
|
||||
# have I been pwned
|
||||
hibp_last_check = sa.Column(ArrowType, default=None)
|
||||
hibp_last_check = sa.Column(ArrowType, default=None, index=True)
|
||||
hibp_breaches = orm.relationship("Hibp", secondary="alias_hibp")
|
||||
|
||||
# to use Postgres full text search. Only applied on "note" column for now
|
||||
@ -1481,7 +1574,7 @@ class Alias(Base, ModelMixin):
|
||||
suffix = user.get_random_alias_suffix()
|
||||
email = f"{prefix}.{suffix}@{config.FIRST_ALIAS_DOMAIN}"
|
||||
|
||||
if not cls.get_by(email=email) and not DeletedAlias.get_by(email=email):
|
||||
if available_sl_email(email):
|
||||
break
|
||||
|
||||
return Alias.create(
|
||||
@ -1510,7 +1603,7 @@ class Alias(Base, ModelMixin):
|
||||
|
||||
if user.default_alias_custom_domain_id:
|
||||
custom_domain = CustomDomain.get(user.default_alias_custom_domain_id)
|
||||
random_email = generate_email(
|
||||
random_email = generate_random_alias_email(
|
||||
scheme=scheme, in_hex=in_hex, alias_domain=custom_domain.domain
|
||||
)
|
||||
elif user.default_alias_public_domain_id:
|
||||
@ -1518,12 +1611,12 @@ class Alias(Base, ModelMixin):
|
||||
if sl_domain.premium_only and not user.is_premium():
|
||||
LOG.w("%s not premium, cannot use %s", user, sl_domain)
|
||||
else:
|
||||
random_email = generate_email(
|
||||
random_email = generate_random_alias_email(
|
||||
scheme=scheme, in_hex=in_hex, alias_domain=sl_domain.domain
|
||||
)
|
||||
|
||||
if not random_email:
|
||||
random_email = generate_email(scheme=scheme, in_hex=in_hex)
|
||||
random_email = generate_random_alias_email(scheme=scheme, in_hex=in_hex)
|
||||
|
||||
alias = Alias.create(
|
||||
user_id=user.id,
|
||||
@ -1557,7 +1650,9 @@ class ClientUser(Base, ModelMixin):
|
||||
client_id = sa.Column(sa.ForeignKey(Client.id, ondelete="cascade"), nullable=False)
|
||||
|
||||
# Null means client has access to user original email
|
||||
alias_id = sa.Column(sa.ForeignKey(Alias.id, ondelete="cascade"), nullable=True)
|
||||
alias_id = sa.Column(
|
||||
sa.ForeignKey(Alias.id, ondelete="cascade"), nullable=True, index=True
|
||||
)
|
||||
|
||||
# user can decide to send to client another name
|
||||
name = sa.Column(
|
||||
@ -1676,7 +1771,7 @@ class Contact(Base, ModelMixin):
|
||||
is_cc = sa.Column(sa.Boolean, nullable=False, default=False, server_default="0")
|
||||
|
||||
pgp_public_key = sa.Column(sa.Text, nullable=True)
|
||||
pgp_finger_print = sa.Column(sa.String(512), nullable=True)
|
||||
pgp_finger_print = sa.Column(sa.String(512), nullable=True, index=True)
|
||||
|
||||
alias = orm.relationship(Alias, backref="contacts")
|
||||
user = orm.relationship(User)
|
||||
@ -2087,7 +2182,9 @@ class AliasUsedOn(Base, ModelMixin):
|
||||
sa.UniqueConstraint("alias_id", "hostname", name="uq_alias_used"),
|
||||
)
|
||||
|
||||
alias_id = sa.Column(sa.ForeignKey(Alias.id, ondelete="cascade"), nullable=False)
|
||||
alias_id = sa.Column(
|
||||
sa.ForeignKey(Alias.id, ondelete="cascade"), nullable=False, index=True
|
||||
)
|
||||
user_id = sa.Column(sa.ForeignKey(User.id, ondelete="cascade"), nullable=False)
|
||||
|
||||
alias = orm.relationship(Alias)
|
||||
@ -2206,6 +2303,7 @@ class CustomDomain(Base, ModelMixin):
|
||||
@classmethod
|
||||
def create(cls, **kwargs):
|
||||
domain = kwargs.get("domain")
|
||||
kwargs["domain"] = domain.replace("\n", "")
|
||||
if DeletedSubdomain.get_by(domain=domain):
|
||||
raise SubdomainInTrashError
|
||||
|
||||
@ -2473,6 +2571,27 @@ class Mailbox(Base, ModelMixin):
|
||||
+ Alias.filter_by(mailbox_id=self.id).count()
|
||||
)
|
||||
|
||||
def is_proton(self) -> bool:
|
||||
if (
|
||||
self.email.endswith("@proton.me")
|
||||
or self.email.endswith("@protonmail.com")
|
||||
or self.email.endswith("@protonmail.ch")
|
||||
or self.email.endswith("@pm.me")
|
||||
):
|
||||
return True
|
||||
|
||||
from app.email_utils import get_email_local_part
|
||||
|
||||
mx_domains: [(int, str)] = get_mx_domains(get_email_local_part(self.email))
|
||||
# Proton is the first domain
|
||||
if mx_domains and mx_domains[0][1] in (
|
||||
"mail.protonmail.ch.",
|
||||
"mailsec.protonmail.ch.",
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def delete(cls, obj_id):
|
||||
mailbox: Mailbox = cls.get(obj_id)
|
||||
@ -2505,6 +2624,12 @@ class Mailbox(Base, ModelMixin):
|
||||
|
||||
return ret
|
||||
|
||||
@classmethod
|
||||
def create(cls, **kw):
|
||||
if "email" in kw:
|
||||
kw["email"] = sanitize_email(kw["email"])
|
||||
return super().create(**kw)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<Mailbox {self.id} {self.email}>"
|
||||
|
||||
@ -2764,6 +2889,31 @@ class Notification(Base, ModelMixin):
|
||||
)
|
||||
|
||||
|
||||
class Partner(Base, ModelMixin):
|
||||
__tablename__ = "partner"
|
||||
|
||||
name = sa.Column(sa.String(128), unique=True, nullable=False)
|
||||
contact_email = sa.Column(sa.String(128), unique=True, nullable=False)
|
||||
|
||||
@staticmethod
|
||||
def find_by_token(token: str) -> Optional[Partner]:
|
||||
hmaced = PartnerApiToken.hmac_token(token)
|
||||
res = (
|
||||
Session.query(Partner, PartnerApiToken)
|
||||
.filter(
|
||||
and_(
|
||||
PartnerApiToken.token == hmaced,
|
||||
Partner.id == PartnerApiToken.partner_id,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if res:
|
||||
partner, partner_api_token = res
|
||||
return partner
|
||||
return None
|
||||
|
||||
|
||||
class SLDomain(Base, ModelMixin):
|
||||
"""SimpleLogin domains"""
|
||||
|
||||
@ -2781,12 +2931,23 @@ class SLDomain(Base, ModelMixin):
|
||||
sa.Boolean, nullable=False, default=False, server_default="0"
|
||||
)
|
||||
|
||||
partner_id = sa.Column(
|
||||
sa.ForeignKey(Partner.id, ondelete="cascade"),
|
||||
nullable=True,
|
||||
default=None,
|
||||
server_default="NULL",
|
||||
)
|
||||
|
||||
# if enabled, do not show this domain when user creates a custom alias
|
||||
hidden = sa.Column(sa.Boolean, nullable=False, default=False, server_default="0")
|
||||
|
||||
# the order in which the domains are shown when user creates a custom alias
|
||||
order = sa.Column(sa.Integer, nullable=False, default=0, server_default="0")
|
||||
|
||||
use_as_reverse_alias = sa.Column(
|
||||
sa.Boolean, nullable=False, default=False, server_default="0"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<SLDomain {self.domain} {'Premium' if self.premium_only else 'Free'}"
|
||||
|
||||
@ -2807,6 +2968,8 @@ class Monitoring(Base, ModelMixin):
|
||||
active_queue = sa.Column(sa.Integer, nullable=False)
|
||||
deferred_queue = sa.Column(sa.Integer, nullable=False)
|
||||
|
||||
__table_args__ = (Index("ix_monitoring_created_at", "created_at"),)
|
||||
|
||||
|
||||
class BatchImport(Base, ModelMixin):
|
||||
__tablename__ = "batch_import"
|
||||
@ -2932,6 +3095,8 @@ class Bounce(Base, ModelMixin):
|
||||
email = sa.Column(sa.String(256), nullable=False, index=True)
|
||||
info = sa.Column(sa.Text, nullable=True)
|
||||
|
||||
__table_args__ = (sa.Index("ix_bounce_created_at", "created_at"),)
|
||||
|
||||
|
||||
class TransactionalEmail(Base, ModelMixin):
|
||||
"""Storing all email addresses that receive transactional emails, including account email and mailboxes.
|
||||
@ -2941,6 +3106,8 @@ class TransactionalEmail(Base, ModelMixin):
|
||||
__tablename__ = "transactional_email"
|
||||
email = sa.Column(sa.String(256), nullable=False, unique=False)
|
||||
|
||||
__table_args__ = (sa.Index("ix_transactional_email_created_at", "created_at"),)
|
||||
|
||||
|
||||
class Payout(Base, ModelMixin):
|
||||
"""Referral payouts"""
|
||||
@ -3227,31 +3394,6 @@ class ProviderComplaint(Base, ModelMixin):
|
||||
refused_email = orm.relationship(RefusedEmail, foreign_keys=[refused_email_id])
|
||||
|
||||
|
||||
class Partner(Base, ModelMixin):
|
||||
__tablename__ = "partner"
|
||||
|
||||
name = sa.Column(sa.String(128), unique=True, nullable=False)
|
||||
contact_email = sa.Column(sa.String(128), unique=True, nullable=False)
|
||||
|
||||
@staticmethod
|
||||
def find_by_token(token: str) -> Optional[Partner]:
|
||||
hmaced = PartnerApiToken.hmac_token(token)
|
||||
res = (
|
||||
Session.query(Partner, PartnerApiToken)
|
||||
.filter(
|
||||
and_(
|
||||
PartnerApiToken.token == hmaced,
|
||||
Partner.id == PartnerApiToken.partner_id,
|
||||
)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if res:
|
||||
partner, partner_api_token = res
|
||||
return partner
|
||||
return None
|
||||
|
||||
|
||||
class PartnerApiToken(Base, ModelMixin):
|
||||
__tablename__ = "partner_api_token"
|
||||
|
||||
@ -3321,7 +3463,7 @@ class PartnerSubscription(Base, ModelMixin):
|
||||
)
|
||||
|
||||
# when the partner subscription ends
|
||||
end_at = sa.Column(ArrowType, nullable=False)
|
||||
end_at = sa.Column(ArrowType, nullable=False, index=True)
|
||||
|
||||
partner_user = orm.relationship(PartnerUser)
|
||||
|
||||
|
@ -7,11 +7,12 @@ from typing import Optional
|
||||
|
||||
from app.account_linking import SLPlan, SLPlanType
|
||||
from app.config import PROTON_EXTRA_HEADER_NAME, PROTON_EXTRA_HEADER_VALUE
|
||||
from app.errors import ProtonAccountNotVerified
|
||||
from app.log import LOG
|
||||
|
||||
_APP_VERSION = "OauthClient_1.0.0"
|
||||
|
||||
PROTON_ERROR_CODE_NOT_EXISTS = 2501
|
||||
PROTON_ERROR_CODE_HV_NEEDED = 9001
|
||||
|
||||
PLAN_FREE = 1
|
||||
PLAN_PREMIUM = 2
|
||||
@ -57,6 +58,15 @@ def convert_access_token(access_token_response: str) -> AccessCredentials:
|
||||
)
|
||||
|
||||
|
||||
def handle_response_not_ok(status: int, body: dict, text: str) -> Exception:
|
||||
if status == HTTPStatus.UNPROCESSABLE_ENTITY:
|
||||
res_code = body.get("Code")
|
||||
if res_code == PROTON_ERROR_CODE_HV_NEEDED:
|
||||
return ProtonAccountNotVerified()
|
||||
|
||||
return Exception(f"Unexpected status code. Wanted 200 and got {status}: " + text)
|
||||
|
||||
|
||||
class ProtonClient(ABC):
|
||||
@abstractmethod
|
||||
def get_user(self) -> Optional[UserInformation]:
|
||||
@ -124,11 +134,11 @@ class HttpProtonClient(ProtonClient):
|
||||
@staticmethod
|
||||
def __validate_response(res: Response) -> dict:
|
||||
status = res.status_code
|
||||
if status != HTTPStatus.OK:
|
||||
raise Exception(
|
||||
f"Unexpected status code. Wanted 200 and got {status}: " + res.text
|
||||
)
|
||||
as_json = res.json()
|
||||
if status != HTTPStatus.OK:
|
||||
raise HttpProtonClient.__handle_response_not_ok(
|
||||
status=status, body=as_json, text=res.text
|
||||
)
|
||||
res_code = as_json.get("Code")
|
||||
if not res_code or res_code != 1000:
|
||||
raise Exception(
|
||||
|
33
app/app/subscription_webhook.py
Normal file
33
app/app/subscription_webhook.py
Normal file
@ -0,0 +1,33 @@
|
||||
import requests
|
||||
from requests import RequestException
|
||||
|
||||
from app import config
|
||||
from app.log import LOG
|
||||
from app.models import User
|
||||
|
||||
|
||||
def execute_subscription_webhook(user: User):
|
||||
webhook_url = config.SUBSCRIPTION_CHANGE_WEBHOOK
|
||||
if webhook_url is None:
|
||||
return
|
||||
subscription_end = user.get_active_subscription_end(
|
||||
include_partner_subscription=False
|
||||
)
|
||||
sl_subscription_end = None
|
||||
if subscription_end:
|
||||
sl_subscription_end = subscription_end.timestamp
|
||||
payload = {
|
||||
"user_id": user.id,
|
||||
"is_premium": user.is_premium(),
|
||||
"active_subscription_end": sl_subscription_end,
|
||||
}
|
||||
try:
|
||||
response = requests.post(webhook_url, json=payload, timeout=2)
|
||||
if response.status_code == 200:
|
||||
LOG.i("Sent request to subscription update webhook successfully")
|
||||
else:
|
||||
LOG.i(
|
||||
f"Request to webhook failed with statue {response.status_code}: {response.text}"
|
||||
)
|
||||
except RequestException as e:
|
||||
LOG.error(f"Subscription request exception: {e}")
|
@ -1,3 +1,4 @@
|
||||
import random
|
||||
import re
|
||||
import secrets
|
||||
import string
|
||||
@ -25,11 +26,16 @@ def word_exist(word):
|
||||
return word in _words
|
||||
|
||||
|
||||
def random_words():
|
||||
def random_words(words: int = 2, numbers: int = 0):
|
||||
"""Generate a random words. Used to generate user-facing string, for ex email addresses"""
|
||||
# nb_words = random.randint(2, 3)
|
||||
nb_words = 2
|
||||
return "_".join([secrets.choice(_words) for i in range(nb_words)])
|
||||
fields = [secrets.choice(_words) for i in range(words)]
|
||||
|
||||
if numbers > 0:
|
||||
digits = "".join([str(random.randint(0, 9)) for i in range(numbers)])
|
||||
return "_".join(fields) + digits
|
||||
else:
|
||||
return "_".join(fields)
|
||||
|
||||
|
||||
def random_string(length=10, include_digits=False):
|
||||
@ -93,7 +99,7 @@ def sanitize_email(email_address: str, not_lower=False) -> str:
|
||||
email_address = email_address.strip().replace(" ", "").replace("\n", " ")
|
||||
if not not_lower:
|
||||
email_address = email_address.lower()
|
||||
return email_address
|
||||
return email_address.replace("\u200f", "")
|
||||
|
||||
|
||||
class NextUrlSanitizer:
|
||||
|
26
app/cron.py
26
app/cron.py
@ -22,10 +22,9 @@ from app.email_utils import (
|
||||
render,
|
||||
email_can_be_used_as_mailbox,
|
||||
send_email_with_rate_control,
|
||||
normalize_reply_email,
|
||||
is_valid_email,
|
||||
get_email_domain_part,
|
||||
)
|
||||
from app.email_validation import is_valid_email, normalize_reply_email
|
||||
from app.errors import ProtonPartnerNotSetUp
|
||||
from app.log import LOG
|
||||
from app.mail_sender import load_unsent_mails_from_fs_and_resend
|
||||
@ -66,12 +65,14 @@ from server import create_light_app
|
||||
|
||||
def notify_trial_end():
|
||||
for user in User.filter(
|
||||
User.activated.is_(True), User.trial_end.isnot(None), User.lifetime.is_(False)
|
||||
User.activated.is_(True),
|
||||
User.trial_end.isnot(None),
|
||||
User.trial_end >= arrow.now().shift(days=2),
|
||||
User.trial_end < arrow.now().shift(days=3),
|
||||
User.lifetime.is_(False),
|
||||
).all():
|
||||
try:
|
||||
if user.in_trial() and arrow.now().shift(
|
||||
days=3
|
||||
) > user.trial_end >= arrow.now().shift(days=2):
|
||||
if user.in_trial():
|
||||
LOG.d("Send trial end email to user %s", user)
|
||||
send_trial_end_soon_email(user)
|
||||
# happens if user has been deleted in the meantime
|
||||
@ -104,7 +105,9 @@ def delete_logs():
|
||||
|
||||
|
||||
def delete_refused_emails():
|
||||
for refused_email in RefusedEmail.filter_by(deleted=False).all():
|
||||
for refused_email in (
|
||||
RefusedEmail.filter_by(deleted=False).order_by(RefusedEmail.id).all()
|
||||
):
|
||||
if arrow.now().shift(days=1) > refused_email.delete_at >= arrow.now():
|
||||
LOG.d("Delete refused email %s", refused_email)
|
||||
if refused_email.path:
|
||||
@ -272,7 +275,11 @@ def compute_metric2() -> Metric2:
|
||||
_24h_ago = now.shift(days=-1)
|
||||
|
||||
nb_referred_user_paid = 0
|
||||
for user in User.filter(User.referral_id.isnot(None)):
|
||||
for user in (
|
||||
User.filter(User.referral_id.isnot(None))
|
||||
.yield_per(500)
|
||||
.enable_eagerloads(False)
|
||||
):
|
||||
if user.is_paid():
|
||||
nb_referred_user_paid += 1
|
||||
|
||||
@ -1020,7 +1027,8 @@ async def check_hibp():
|
||||
)
|
||||
.filter(Alias.enabled)
|
||||
.order_by(Alias.hibp_last_check.asc())
|
||||
.all()
|
||||
.yield_per(500)
|
||||
.enable_eagerloads(False)
|
||||
):
|
||||
await queue.put(alias.id)
|
||||
|
||||
|
@ -5,68 +5,64 @@ jobs:
|
||||
schedule: "0 0 * * *"
|
||||
captureStderr: true
|
||||
|
||||
- name: SimpleLogin Notify Trial Ends
|
||||
command: python /code/cron.py -j notify_trial_end
|
||||
shell: /bin/bash
|
||||
schedule: "0 8 * * *"
|
||||
captureStderr: true
|
||||
|
||||
- name: SimpleLogin Notify Manual Subscription Ends
|
||||
command: python /code/cron.py -j notify_manual_subscription_end
|
||||
shell: /bin/bash
|
||||
schedule: "0 9 * * *"
|
||||
captureStderr: true
|
||||
|
||||
- name: SimpleLogin Notify Premium Ends
|
||||
command: python /code/cron.py -j notify_premium_end
|
||||
shell: /bin/bash
|
||||
schedule: "0 10 * * *"
|
||||
captureStderr: true
|
||||
|
||||
- name: SimpleLogin Delete Logs
|
||||
command: python /code/cron.py -j delete_logs
|
||||
shell: /bin/bash
|
||||
schedule: "0 11 * * *"
|
||||
captureStderr: true
|
||||
|
||||
- name: SimpleLogin Poll Apple Subscriptions
|
||||
command: python /code/cron.py -j poll_apple_subscription
|
||||
shell: /bin/bash
|
||||
schedule: "0 12 * * *"
|
||||
captureStderr: true
|
||||
|
||||
- name: SimpleLogin Sanity Check
|
||||
command: python /code/cron.py -j sanity_check
|
||||
shell: /bin/bash
|
||||
schedule: "0 2 * * *"
|
||||
captureStderr: true
|
||||
|
||||
- name: SimpleLogin Delete Old Monitoring records
|
||||
command: python /code/cron.py -j delete_old_monitoring
|
||||
shell: /bin/bash
|
||||
schedule: "0 14 * * *"
|
||||
schedule: "15 1 * * *"
|
||||
captureStderr: true
|
||||
|
||||
- name: SimpleLogin Custom Domain check
|
||||
command: python /code/cron.py -j check_custom_domain
|
||||
shell: /bin/bash
|
||||
schedule: "0 15 * * *"
|
||||
schedule: "15 2 * * *"
|
||||
captureStderr: true
|
||||
|
||||
- name: SimpleLogin HIBP check
|
||||
command: python /code/cron.py -j check_hibp
|
||||
shell: /bin/bash
|
||||
schedule: "0 18 * * *"
|
||||
schedule: "15 3 * * *"
|
||||
captureStderr: true
|
||||
concurrencyPolicy: Forbid
|
||||
|
||||
- name: SimpleLogin Notify HIBP breaches
|
||||
command: python /code/cron.py -j notify_hibp
|
||||
shell: /bin/bash
|
||||
schedule: "0 19 * * *"
|
||||
schedule: "15 4 * * *"
|
||||
captureStderr: true
|
||||
concurrencyPolicy: Forbid
|
||||
|
||||
- name: SimpleLogin Delete Logs
|
||||
command: python /code/cron.py -j delete_logs
|
||||
shell: /bin/bash
|
||||
schedule: "15 5 * * *"
|
||||
captureStderr: true
|
||||
|
||||
- name: SimpleLogin Poll Apple Subscriptions
|
||||
command: python /code/cron.py -j poll_apple_subscription
|
||||
shell: /bin/bash
|
||||
schedule: "15 6 * * *"
|
||||
captureStderr: true
|
||||
|
||||
- name: SimpleLogin Notify Trial Ends
|
||||
command: python /code/cron.py -j notify_trial_end
|
||||
shell: /bin/bash
|
||||
schedule: "15 8 * * *"
|
||||
captureStderr: true
|
||||
|
||||
- name: SimpleLogin Notify Manual Subscription Ends
|
||||
command: python /code/cron.py -j notify_manual_subscription_end
|
||||
shell: /bin/bash
|
||||
schedule: "15 9 * * *"
|
||||
captureStderr: true
|
||||
|
||||
- name: SimpleLogin Notify Premium Ends
|
||||
command: python /code/cron.py -j notify_premium_end
|
||||
shell: /bin/bash
|
||||
schedule: "15 10 * * *"
|
||||
captureStderr: true
|
||||
|
||||
|
||||
|
||||
- name: SimpleLogin send unsent emails
|
||||
command: python /code/cron.py -j send_undelivered_mails
|
||||
shell: /bin/bash
|
||||
|
@ -15,6 +15,7 @@
|
||||
- [GET /api/user/cookie_token](#get-apiusercookie_token): Get a one time use token to exchange it for a valid cookie
|
||||
- [PATCH /api/user_info](#patch-apiuser_info): Update user's information.
|
||||
- [POST /api/api_key](#post-apiapi_key): Create a new API key.
|
||||
- [GET /api/stats](#get-apistats): Get user's stats.
|
||||
- [GET /api/logout](#get-apilogout): Log out.
|
||||
|
||||
[Alias endpoints](#alias-endpoints)
|
||||
@ -226,6 +227,22 @@ Input:
|
||||
|
||||
Output: same as GET /api/user_info
|
||||
|
||||
#### GET /api/stats
|
||||
|
||||
Given the API Key, return stats about the number of aliases, number of emails forwarded/replied/blocked
|
||||
|
||||
Input:
|
||||
|
||||
- `Authentication` header that contains the api key
|
||||
|
||||
Output: if api key is correct, return a json with the following fields:
|
||||
|
||||
```json
|
||||
{"nb_alias": 1, "nb_block": 0, "nb_forward": 0, "nb_reply": 0}
|
||||
```
|
||||
|
||||
If api key is incorrect, return 401.
|
||||
|
||||
#### PATCH /api/sudo
|
||||
|
||||
Enable sudo mode
|
||||
@ -694,7 +711,7 @@ Return 200 and `existed=true` if contact is already added.
|
||||
|
||||
It can return 403 with an error if the user cannot create reverse alias.
|
||||
|
||||
``json
|
||||
```json
|
||||
{
|
||||
"error": "Please upgrade to create a reverse-alias"
|
||||
}
|
||||
|
123
app/docs/ssl.md
123
app/docs/ssl.md
@ -1,4 +1,4 @@
|
||||
# SSL, HTTPS, and HSTS
|
||||
# SSL, HTTPS, HSTS and additional security measures
|
||||
|
||||
It's highly recommended to enable SSL/TLS on your server, both for the web app and email server.
|
||||
|
||||
@ -58,3 +58,124 @@ Now, reload Nginx:
|
||||
```bash
|
||||
sudo systemctl reload nginx
|
||||
```
|
||||
|
||||
## Additional security measures
|
||||
|
||||
For additional security, we recommend you take some extra steps.
|
||||
|
||||
### Enable Certificate Authority Authorization (CAA)
|
||||
|
||||
[Certificate Authority Authorization](https://letsencrypt.org/docs/caa/) is a step you can take to restrict the list of certificate authorities that are allowed to issue certificates for your domains.
|
||||
|
||||
Use [SSLMate’s CAA Record Generator](https://sslmate.com/caa/) to create a **CAA record** with the following configuration:
|
||||
|
||||
- `flags`: `0`
|
||||
- `tag`: `issue`
|
||||
- `value`: `"letsencrypt.org"`
|
||||
|
||||
To verify if the DNS works, the following command
|
||||
|
||||
```bash
|
||||
dig @1.1.1.1 mydomain.com caa
|
||||
```
|
||||
|
||||
should return:
|
||||
|
||||
```
|
||||
mydomain.com. 3600 IN CAA 0 issue "letsencrypt.org"
|
||||
```
|
||||
|
||||
### SMTP MTA Strict Transport Security (MTA-STS)
|
||||
|
||||
[MTA-STS](https://datatracker.ietf.org/doc/html/rfc8461) is an extra step you can take to broadcast the ability of your instance to receive and, optionally enforce, TSL-secure SMTP connections to protect email traffic.
|
||||
|
||||
Enabling MTA-STS requires you serve a specific file from subdomain `mta-sts.domain.com` on a well-known route.
|
||||
|
||||
Create a text file `/var/www/.well-known/mta-sts.txt` with the content:
|
||||
|
||||
```txt
|
||||
version: STSv1
|
||||
mode: testing
|
||||
mx: app.mydomain.com
|
||||
max_age: 86400
|
||||
```
|
||||
|
||||
It is recommended to start with `mode: testing` for starters to get time to review failure reports. Add as many `mx:` domain entries as you have matching **MX records** in your DNS configuration.
|
||||
|
||||
Create a **TXT record** for `_mta-sts.mydomain.com.` with the following value:
|
||||
|
||||
```txt
|
||||
v=STSv1; id=UNIX_TIMESTAMP
|
||||
```
|
||||
|
||||
With `UNIX_TIMESTAMP` being the current date/time.
|
||||
|
||||
Use the following command to generate the record:
|
||||
|
||||
```bash
|
||||
echo "v=STSv1; id=$(date +%s)"
|
||||
```
|
||||
|
||||
To verify if the DNS works, the following command
|
||||
|
||||
```bash
|
||||
dig @1.1.1.1 _mta-sts.mydomain.com txt
|
||||
```
|
||||
|
||||
should return a result similar to this one:
|
||||
|
||||
```
|
||||
_mta-sts.mydomain.com. 3600 IN TXT "v=STSv1; id=1689416399"
|
||||
```
|
||||
|
||||
Create an additional Nginx configuration in `/etc/nginx/sites-enabled/mta-sts` with the following content:
|
||||
|
||||
```
|
||||
server {
|
||||
server_name mta-sts.mydomain.com;
|
||||
root /var/www;
|
||||
listen 80;
|
||||
|
||||
location ^~ /.well-known {}
|
||||
}
|
||||
```
|
||||
|
||||
Restart Nginx with the following command:
|
||||
|
||||
```sh
|
||||
sudo service nginx restart
|
||||
```
|
||||
|
||||
A correct configuration of MTA-STS, however, requires that the certificate used to host the `mta-sts` subdomain matches that of the subdomain referred to by the **MX record** from the DNS. In other words, both `mta-sts.mydomain.com` and `app.mydomain.com` must share the same certificate.
|
||||
|
||||
The easiest way to do this is to _expand_ the certificate associated with `app.mydomain.com` to also support the `mta-sts` subdomain using the following command:
|
||||
|
||||
```sh
|
||||
certbot --expand --nginx -d app.mydomain.com,mta-sts.mydomain.com
|
||||
```
|
||||
|
||||
## SMTP TLS Reporting
|
||||
|
||||
[TLSRPT](https://datatracker.ietf.org/doc/html/rfc8460) is used by SMTP systems to report failures in establishing TLS-secure sessions as broadcast by the MTA-STS configuration.
|
||||
|
||||
Configuring MTA-STS in `mode: testing` as shown in the previous section gives you time to review failures from some SMTP senders.
|
||||
|
||||
Create a **TXT record** for `_smtp._tls.mydomain.com.` with the following value:
|
||||
|
||||
```txt
|
||||
v=TSLRPTv1; rua=mailto:YOUR_EMAIL
|
||||
```
|
||||
|
||||
The TLSRPT configuration at the DNS level allows SMTP senders that fail to initiate TLS-secure sessions to send reports to a particular email address. We suggest creating a `tls-reports` alias in SimpleLogin for this purpose.
|
||||
|
||||
To verify if the DNS works, the following command
|
||||
|
||||
```bash
|
||||
dig @1.1.1.1 _smtp._tls.mydomain.com txt
|
||||
```
|
||||
|
||||
should return a result similar to this one:
|
||||
|
||||
```
|
||||
_smtp._tls.mydomain.com. 3600 IN TXT "v=TSLRPTv1; rua=mailto:tls-reports@mydomain.com"
|
||||
```
|
||||
|
@ -106,8 +106,6 @@ from app.email_utils import (
|
||||
get_header_unicode,
|
||||
generate_reply_email,
|
||||
is_reverse_alias,
|
||||
normalize_reply_email,
|
||||
is_valid_email,
|
||||
replace,
|
||||
should_disable,
|
||||
parse_id_from_bounce,
|
||||
@ -123,6 +121,7 @@ from app.email_utils import (
|
||||
generate_verp_email,
|
||||
sl_formataddr,
|
||||
)
|
||||
from app.email_validation import is_valid_email, normalize_reply_email
|
||||
from app.errors import (
|
||||
NonReverseAliasInReplyPhase,
|
||||
VERPTransactional,
|
||||
@ -161,6 +160,7 @@ from app.models import (
|
||||
MessageIDMatching,
|
||||
Notification,
|
||||
VerpType,
|
||||
SLDomain,
|
||||
)
|
||||
from app.pgp_utils import (
|
||||
PGPException,
|
||||
@ -243,7 +243,7 @@ def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Con
|
||||
website_email=contact_email,
|
||||
name=contact_name,
|
||||
mail_from=mail_from,
|
||||
reply_email=generate_reply_email(contact_email, alias.user)
|
||||
reply_email=generate_reply_email(contact_email, alias)
|
||||
if is_valid_email(contact_email)
|
||||
else NOREPLY,
|
||||
automatic_created=True,
|
||||
@ -261,7 +261,7 @@ def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Con
|
||||
|
||||
Session.commit()
|
||||
except IntegrityError:
|
||||
LOG.w("Contact %s %s already exist", alias, contact_email)
|
||||
LOG.w(f"Contact with email {contact_email} for alias {alias} already exist")
|
||||
Session.rollback()
|
||||
contact = Contact.get_by(alias_id=alias.id, website_email=contact_email)
|
||||
|
||||
@ -279,6 +279,9 @@ def get_or_create_reply_to_contact(
|
||||
except ValueError:
|
||||
return
|
||||
|
||||
if len(contact_name) >= Contact.MAX_NAME_LENGTH:
|
||||
contact_name = contact_name[0 : Contact.MAX_NAME_LENGTH]
|
||||
|
||||
if not is_valid_email(contact_address):
|
||||
LOG.w(
|
||||
"invalid reply-to address %s. Parse from %s",
|
||||
@ -304,7 +307,7 @@ def get_or_create_reply_to_contact(
|
||||
alias_id=alias.id,
|
||||
website_email=contact_address,
|
||||
name=contact_name,
|
||||
reply_email=generate_reply_email(contact_address, alias.user),
|
||||
reply_email=generate_reply_email(contact_address, alias),
|
||||
automatic_created=True,
|
||||
)
|
||||
Session.commit()
|
||||
@ -347,6 +350,10 @@ def replace_header_when_forward(msg: Message, alias: Alias, header: str):
|
||||
continue
|
||||
|
||||
contact = Contact.get_by(alias_id=alias.id, website_email=contact_email)
|
||||
contact_name = full_address.display_name
|
||||
if len(contact_name) >= Contact.MAX_NAME_LENGTH:
|
||||
contact_name = contact_name[0 : Contact.MAX_NAME_LENGTH]
|
||||
|
||||
if contact:
|
||||
# update the contact name if needed
|
||||
if contact.name != full_address.display_name:
|
||||
@ -354,9 +361,9 @@ def replace_header_when_forward(msg: Message, alias: Alias, header: str):
|
||||
"Update contact %s name %s to %s",
|
||||
contact,
|
||||
contact.name,
|
||||
full_address.display_name,
|
||||
contact_name,
|
||||
)
|
||||
contact.name = full_address.display_name
|
||||
contact.name = contact_name
|
||||
Session.commit()
|
||||
else:
|
||||
LOG.d(
|
||||
@ -371,8 +378,8 @@ def replace_header_when_forward(msg: Message, alias: Alias, header: str):
|
||||
user_id=alias.user_id,
|
||||
alias_id=alias.id,
|
||||
website_email=contact_email,
|
||||
name=full_address.display_name,
|
||||
reply_email=generate_reply_email(contact_email, alias.user),
|
||||
name=contact_name,
|
||||
reply_email=generate_reply_email(contact_email, alias),
|
||||
is_cc=header.lower() == "cc",
|
||||
automatic_created=True,
|
||||
)
|
||||
@ -540,12 +547,20 @@ def sign_msg(msg: Message) -> Message:
|
||||
signature.add_header("Content-Disposition", 'attachment; filename="signature.asc"')
|
||||
|
||||
try:
|
||||
signature.set_payload(sign_data(message_to_bytes(msg).replace(b"\n", b"\r\n")))
|
||||
payload = sign_data(message_to_bytes(msg).replace(b"\n", b"\r\n"))
|
||||
|
||||
if not payload:
|
||||
raise PGPException("Empty signature by gnupg")
|
||||
|
||||
signature.set_payload(payload)
|
||||
except Exception:
|
||||
LOG.e("Cannot sign, try using pgpy")
|
||||
signature.set_payload(
|
||||
sign_data_with_pgpy(message_to_bytes(msg).replace(b"\n", b"\r\n"))
|
||||
)
|
||||
payload = sign_data_with_pgpy(message_to_bytes(msg).replace(b"\n", b"\r\n"))
|
||||
|
||||
if not payload:
|
||||
raise PGPException("Empty signature by pgpy")
|
||||
|
||||
signature.set_payload(payload)
|
||||
|
||||
container.attach(signature)
|
||||
|
||||
@ -693,6 +708,36 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str
|
||||
LOG.d("%s unverified, do not forward", mailbox)
|
||||
ret.append((False, status.E517))
|
||||
else:
|
||||
# Check if the mailbox is also an alias and stop the loop
|
||||
mailbox_as_alias = Alias.get_by(email=mailbox.email)
|
||||
if mailbox_as_alias is not None:
|
||||
LOG.info(
|
||||
f"Mailbox {mailbox.id} has email {mailbox.email} that is also alias {alias.id}. Stopping loop"
|
||||
)
|
||||
mailbox.verified = False
|
||||
Session.commit()
|
||||
mailbox_url = f"{URL}/dashboard/mailbox/{mailbox.id}/"
|
||||
send_email_with_rate_control(
|
||||
user,
|
||||
ALERT_MAILBOX_IS_ALIAS,
|
||||
user.email,
|
||||
f"Your mailbox {mailbox.email} is an alias",
|
||||
render(
|
||||
"transactional/mailbox-invalid.txt.jinja2",
|
||||
mailbox=mailbox,
|
||||
mailbox_url=mailbox_url,
|
||||
alias=alias,
|
||||
),
|
||||
render(
|
||||
"transactional/mailbox-invalid.html",
|
||||
mailbox=mailbox,
|
||||
mailbox_url=mailbox_url,
|
||||
alias=alias,
|
||||
),
|
||||
max_nb_alert=1,
|
||||
)
|
||||
ret.append((False, status.E525))
|
||||
continue
|
||||
# create a copy of message for each forward
|
||||
ret.append(
|
||||
forward_email_to_mailbox(
|
||||
@ -815,38 +860,40 @@ def forward_email_to_mailbox(
|
||||
f"""Email sent to {alias.email} from an invalid address and cannot be replied""",
|
||||
)
|
||||
|
||||
delete_all_headers_except(
|
||||
msg,
|
||||
[
|
||||
headers.FROM,
|
||||
headers.TO,
|
||||
headers.CC,
|
||||
headers.SUBJECT,
|
||||
headers.DATE,
|
||||
# do not delete original message id
|
||||
headers.MESSAGE_ID,
|
||||
# References and In-Reply-To are used for keeping the email thread
|
||||
headers.REFERENCES,
|
||||
headers.IN_REPLY_TO,
|
||||
]
|
||||
+ headers.MIME_HEADERS,
|
||||
)
|
||||
headers_to_keep = [
|
||||
headers.FROM,
|
||||
headers.TO,
|
||||
headers.CC,
|
||||
headers.SUBJECT,
|
||||
headers.DATE,
|
||||
# do not delete original message id
|
||||
headers.MESSAGE_ID,
|
||||
# References and In-Reply-To are used for keeping the email thread
|
||||
headers.REFERENCES,
|
||||
headers.IN_REPLY_TO,
|
||||
headers.LIST_UNSUBSCRIBE,
|
||||
headers.LIST_UNSUBSCRIBE_POST,
|
||||
] + headers.MIME_HEADERS
|
||||
if user.include_header_email_header:
|
||||
headers_to_keep.append(headers.AUTHENTICATION_RESULTS)
|
||||
delete_all_headers_except(msg, headers_to_keep)
|
||||
|
||||
if mailbox.generic_subject:
|
||||
LOG.d("Use a generic subject for %s", mailbox)
|
||||
orig_subject = msg[headers.SUBJECT]
|
||||
orig_subject = get_header_unicode(orig_subject)
|
||||
add_or_replace_header(msg, "Subject", mailbox.generic_subject)
|
||||
sender = msg[headers.FROM]
|
||||
sender = get_header_unicode(sender)
|
||||
msg = add_header(
|
||||
msg,
|
||||
f"""Forwarded by SimpleLogin to {alias.email} from "{sender}" with "{orig_subject}" as subject""",
|
||||
f"""Forwarded by SimpleLogin to {alias.email} from "{sender}" with <b>{orig_subject}</b> as subject""",
|
||||
)
|
||||
|
||||
# create PGP email if needed
|
||||
if mailbox.pgp_enabled() and user.is_premium() and not alias.disable_pgp:
|
||||
LOG.d("Encrypt message using mailbox %s", mailbox)
|
||||
if mailbox.generic_subject:
|
||||
LOG.d("Use a generic subject for %s", mailbox)
|
||||
orig_subject = msg[headers.SUBJECT]
|
||||
orig_subject = get_header_unicode(orig_subject)
|
||||
add_or_replace_header(msg, "Subject", mailbox.generic_subject)
|
||||
sender = msg[headers.FROM]
|
||||
sender = get_header_unicode(sender)
|
||||
msg = add_header(
|
||||
msg,
|
||||
f"""Forwarded by SimpleLogin to {alias.email} from "{sender}" with "{orig_subject}" as subject""",
|
||||
f"""Forwarded by SimpleLogin to {alias.email} from "{sender}" with <b>{orig_subject}</b> as subject""",
|
||||
)
|
||||
|
||||
try:
|
||||
msg = prepare_pgp_message(
|
||||
@ -867,6 +914,11 @@ def forward_email_to_mailbox(
|
||||
msg[headers.SL_EMAIL_LOG_ID] = str(email_log.id)
|
||||
if user.include_header_email_header:
|
||||
msg[headers.SL_ENVELOPE_FROM] = envelope.mail_from
|
||||
if contact.name:
|
||||
original_from = f"{contact.name} <{contact.website_email}>"
|
||||
else:
|
||||
original_from = contact.website_email
|
||||
msg[headers.SL_ORIGINAL_FROM] = original_from
|
||||
# when an alias isn't in the To: header, there's no way for users to know what alias has received the email
|
||||
msg[headers.SL_ENVELOPE_TO] = alias.email
|
||||
|
||||
@ -915,10 +967,11 @@ def forward_email_to_mailbox(
|
||||
envelope.rcpt_options,
|
||||
)
|
||||
|
||||
contact_domain = get_email_domain_part(contact.reply_email)
|
||||
try:
|
||||
sl_sendmail(
|
||||
# use a different envelope sender for each forward (aka VERP)
|
||||
generate_verp_email(VerpType.bounce_forward, email_log.id),
|
||||
generate_verp_email(VerpType.bounce_forward, email_log.id, contact_domain),
|
||||
mailbox.email,
|
||||
msg,
|
||||
envelope.mail_options,
|
||||
@ -987,10 +1040,14 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str):
|
||||
|
||||
reply_email = rcpt_to
|
||||
|
||||
# reply_email must end with EMAIL_DOMAIN
|
||||
reply_domain = get_email_domain_part(reply_email)
|
||||
|
||||
# reply_email must end with EMAIL_DOMAIN or a domain that can be used as reverse alias domain
|
||||
if not reply_email.endswith(EMAIL_DOMAIN):
|
||||
LOG.w(f"Reply email {reply_email} has wrong domain")
|
||||
return False, status.E501
|
||||
sl_domain: SLDomain = SLDomain.get_by(domain=reply_domain)
|
||||
if sl_domain is None:
|
||||
LOG.w(f"Reply email {reply_email} has wrong domain")
|
||||
return False, status.E501
|
||||
|
||||
# handle case where reply email is generated with non-allowed char
|
||||
reply_email = normalize_reply_email(reply_email)
|
||||
@ -1002,7 +1059,7 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str):
|
||||
|
||||
alias = contact.alias
|
||||
alias_address: str = contact.alias.email
|
||||
alias_domain = alias_address[alias_address.find("@") + 1 :]
|
||||
alias_domain = get_email_domain_part(alias_address)
|
||||
|
||||
# Sanity check: verify alias domain is managed by SimpleLogin
|
||||
# scenario: a user have removed a domain but due to a bug, the aliases are still there
|
||||
|
@ -42,14 +42,16 @@ def add_sl_domains():
|
||||
LOG.d("%s is already a SL domain", alias_domain)
|
||||
else:
|
||||
LOG.i("Add %s to SL domain", alias_domain)
|
||||
SLDomain.create(domain=alias_domain)
|
||||
SLDomain.create(domain=alias_domain, use_as_reverse_alias=True)
|
||||
|
||||
for premium_domain in PREMIUM_ALIAS_DOMAINS:
|
||||
if SLDomain.get_by(domain=premium_domain):
|
||||
LOG.d("%s is already a SL domain", premium_domain)
|
||||
else:
|
||||
LOG.i("Add %s to SL domain", premium_domain)
|
||||
SLDomain.create(domain=premium_domain, premium_only=True)
|
||||
SLDomain.create(
|
||||
domain=premium_domain, premium_only=True, use_as_reverse_alias=True
|
||||
)
|
||||
|
||||
Session.commit()
|
||||
|
||||
|
329422
app/local_data/words.txt
329422
app/local_data/words.txt
File diff suppressed because it is too large
Load Diff
31
app/migrations/versions/2023_040318_5f4a5625da66_.py
Normal file
31
app/migrations/versions/2023_040318_5f4a5625da66_.py
Normal file
@ -0,0 +1,31 @@
|
||||
"""empty message
|
||||
|
||||
Revision ID: 5f4a5625da66
|
||||
Revises: 2c2093c82bc0
|
||||
Create Date: 2023-04-03 18:30:46.488231
|
||||
|
||||
"""
|
||||
import sqlalchemy_utils
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '5f4a5625da66'
|
||||
down_revision = '2c2093c82bc0'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('public_domain', sa.Column('partner_id', sa.Integer(), nullable=True))
|
||||
op.create_foreign_key(None, 'public_domain', 'partner', ['partner_id'], ['id'], ondelete='cascade')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(None, 'public_domain', type_='foreignkey')
|
||||
op.drop_column('public_domain', 'partner_id')
|
||||
# ### end Alembic commands ###
|
29
app/migrations/versions/2023_041418_893c0d18475f_.py
Normal file
29
app/migrations/versions/2023_041418_893c0d18475f_.py
Normal file
@ -0,0 +1,29 @@
|
||||
"""empty message
|
||||
|
||||
Revision ID: 893c0d18475f
|
||||
Revises: 5f4a5625da66
|
||||
Create Date: 2023-04-14 18:20:03.807367
|
||||
|
||||
"""
|
||||
import sqlalchemy_utils
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '893c0d18475f'
|
||||
down_revision = '5f4a5625da66'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_index(op.f('ix_contact_pgp_finger_print'), 'contact', ['pgp_finger_print'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_contact_pgp_finger_print'), table_name='contact')
|
||||
# ### end Alembic commands ###
|
35
app/migrations/versions/2023_041419_bc496c0a0279_.py
Normal file
35
app/migrations/versions/2023_041419_bc496c0a0279_.py
Normal file
@ -0,0 +1,35 @@
|
||||
"""empty message
|
||||
|
||||
Revision ID: bc496c0a0279
|
||||
Revises: 893c0d18475f
|
||||
Create Date: 2023-04-14 19:09:38.540514
|
||||
|
||||
"""
|
||||
import sqlalchemy_utils
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'bc496c0a0279'
|
||||
down_revision = '893c0d18475f'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_index(op.f('ix_alias_used_on_alias_id'), 'alias_used_on', ['alias_id'], unique=False)
|
||||
op.create_index(op.f('ix_client_user_alias_id'), 'client_user', ['alias_id'], unique=False)
|
||||
op.create_index(op.f('ix_hibp_notified_alias_alias_id'), 'hibp_notified_alias', ['alias_id'], unique=False)
|
||||
op.create_index(op.f('ix_users_newsletter_alias_id'), 'users', ['newsletter_alias_id'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_users_newsletter_alias_id'), table_name='users')
|
||||
op.drop_index(op.f('ix_hibp_notified_alias_alias_id'), table_name='hibp_notified_alias')
|
||||
op.drop_index(op.f('ix_client_user_alias_id'), table_name='client_user')
|
||||
op.drop_index(op.f('ix_alias_used_on_alias_id'), table_name='alias_used_on')
|
||||
# ### end Alembic commands ###
|
29
app/migrations/versions/2023_041520_2d89315ac650_.py
Normal file
29
app/migrations/versions/2023_041520_2d89315ac650_.py
Normal file
@ -0,0 +1,29 @@
|
||||
"""empty message
|
||||
|
||||
Revision ID: 2d89315ac650
|
||||
Revises: bc496c0a0279
|
||||
Create Date: 2023-04-15 20:43:44.218020
|
||||
|
||||
"""
|
||||
import sqlalchemy_utils
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '2d89315ac650'
|
||||
down_revision = 'bc496c0a0279'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_index(op.f('ix_partner_subscription_end_at'), 'partner_subscription', ['end_at'], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_partner_subscription_end_at'), table_name='partner_subscription')
|
||||
# ### end Alembic commands ###
|
29
app/migrations/versions/2023_041916_01e2997e90d3_.py
Normal file
29
app/migrations/versions/2023_041916_01e2997e90d3_.py
Normal file
@ -0,0 +1,29 @@
|
||||
"""empty message
|
||||
|
||||
Revision ID: 01e2997e90d3
|
||||
Revises: 893c0d18475f
|
||||
Create Date: 2023-04-19 16:09:11.851588
|
||||
|
||||
"""
|
||||
import sqlalchemy_utils
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '01e2997e90d3'
|
||||
down_revision = '893c0d18475f'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.add_column('public_domain', sa.Column('use_as_reverse_alias', sa.Boolean(), server_default='0', nullable=False))
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_column('public_domain', 'use_as_reverse_alias')
|
||||
# ### end Alembic commands ###
|
25
app/migrations/versions/2023_042011_2634b41f54db_.py
Normal file
25
app/migrations/versions/2023_042011_2634b41f54db_.py
Normal file
@ -0,0 +1,25 @@
|
||||
"""empty message
|
||||
|
||||
Revision ID: 2634b41f54db
|
||||
Revises: 01e2997e90d3, 2d89315ac650
|
||||
Create Date: 2023-04-20 11:47:43.048536
|
||||
|
||||
"""
|
||||
import sqlalchemy_utils
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '2634b41f54db'
|
||||
down_revision = ('01e2997e90d3', '2d89315ac650')
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
pass
|
||||
|
||||
|
||||
def downgrade():
|
||||
pass
|
42
app/migrations/versions/2023_072819_01827104004b_.py
Normal file
42
app/migrations/versions/2023_072819_01827104004b_.py
Normal file
@ -0,0 +1,42 @@
|
||||
"""empty message
|
||||
|
||||
Revision ID: 01827104004b
|
||||
Revises: 2634b41f54db
|
||||
Create Date: 2023-07-28 19:39:28.675490
|
||||
|
||||
"""
|
||||
import sqlalchemy_utils
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '01827104004b'
|
||||
down_revision = '2634b41f54db'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.get_context().autocommit_block():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_index(op.f('ix_alias_hibp_last_check'), 'alias', ['hibp_last_check'], unique=False, postgresql_concurrently=True)
|
||||
op.create_index('ix_bounce_created_at', 'bounce', ['created_at'], unique=False, postgresql_concurrently=True)
|
||||
op.create_index('ix_monitoring_created_at', 'monitoring', ['created_at'], unique=False, postgresql_concurrently=True)
|
||||
op.create_index('ix_transactional_email_created_at', 'transactional_email', ['created_at'], unique=False, postgresql_concurrently=True)
|
||||
op.create_index(op.f('ix_users_activated'), 'users', ['activated'], unique=False, postgresql_concurrently=True)
|
||||
op.create_index('ix_users_activated_trial_end_lifetime', 'users', ['activated', 'trial_end', 'lifetime'], unique=False, postgresql_concurrently=True)
|
||||
op.create_index(op.f('ix_users_referral_id'), 'users', ['referral_id'], unique=False, postgresql_concurrently=True)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index(op.f('ix_users_referral_id'), table_name='users')
|
||||
op.drop_index('ix_users_activated_trial_end_lifetime', table_name='users')
|
||||
op.drop_index(op.f('ix_users_activated'), table_name='users')
|
||||
op.drop_index('ix_transactional_email_created_at', table_name='transactional_email')
|
||||
op.drop_index('ix_monitoring_created_at', table_name='monitoring')
|
||||
op.drop_index('ix_bounce_created_at', table_name='bounce')
|
||||
op.drop_index(op.f('ix_alias_hibp_last_check'), table_name='alias')
|
||||
# ### end Alembic commands ###
|
0
app/monitor/__init__.py
Normal file
0
app/monitor/__init__.py
Normal file
21
app/monitor/metric.py
Normal file
21
app/monitor/metric.py
Normal file
@ -0,0 +1,21 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpcloudRecord:
|
||||
db_role: str
|
||||
label: str
|
||||
time: str
|
||||
value: float
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpcloudMetric:
|
||||
metric_name: str
|
||||
records: List[UpcloudRecord]
|
||||
|
||||
|
||||
@dataclass
|
||||
class UpcloudMetrics:
|
||||
metrics: List[UpcloudMetric]
|
20
app/monitor/metric_exporter.py
Normal file
20
app/monitor/metric_exporter.py
Normal file
@ -0,0 +1,20 @@
|
||||
from app.config import UPCLOUD_DB_ID, UPCLOUD_PASSWORD, UPCLOUD_USERNAME
|
||||
from app.log import LOG
|
||||
from monitor.newrelic import NewRelicClient
|
||||
from monitor.upcloud import UpcloudClient
|
||||
|
||||
|
||||
class MetricExporter:
|
||||
def __init__(self, newrelic_license: str):
|
||||
self.__upcloud = UpcloudClient(
|
||||
username=UPCLOUD_USERNAME, password=UPCLOUD_PASSWORD
|
||||
)
|
||||
self.__newrelic = NewRelicClient(newrelic_license)
|
||||
|
||||
def run(self):
|
||||
try:
|
||||
metrics = self.__upcloud.get_metrics(UPCLOUD_DB_ID)
|
||||
self.__newrelic.send(metrics)
|
||||
LOG.info("Upcloud metrics sent to NewRelic")
|
||||
except Exception as e:
|
||||
LOG.warn(f"Could not export metrics: {e}")
|
26
app/monitor/newrelic.py
Normal file
26
app/monitor/newrelic.py
Normal file
@ -0,0 +1,26 @@
|
||||
from monitor.metric import UpcloudMetrics
|
||||
|
||||
from newrelic_telemetry_sdk import GaugeMetric, MetricClient
|
||||
|
||||
_NEWRELIC_BASE_HOST = "metric-api.eu.newrelic.com"
|
||||
|
||||
|
||||
class NewRelicClient:
|
||||
def __init__(self, license_key: str):
|
||||
self.__client = MetricClient(license_key=license_key, host=_NEWRELIC_BASE_HOST)
|
||||
|
||||
def send(self, metrics: UpcloudMetrics):
|
||||
batch = []
|
||||
|
||||
for metric in metrics.metrics:
|
||||
for record in metric.records:
|
||||
batch.append(
|
||||
GaugeMetric(
|
||||
name=f"upcloud.db.{metric.metric_name}",
|
||||
value=record.value,
|
||||
tags={"host": record.label, "db_role": record.db_role},
|
||||
)
|
||||
)
|
||||
|
||||
response = self.__client.send_batch(batch)
|
||||
response.raise_for_status()
|
82
app/monitor/upcloud.py
Normal file
82
app/monitor/upcloud.py
Normal file
@ -0,0 +1,82 @@
|
||||
from app.log import LOG
|
||||
from monitor.metric import UpcloudMetric, UpcloudMetrics, UpcloudRecord
|
||||
|
||||
import base64
|
||||
import requests
|
||||
from typing import Any
|
||||
|
||||
|
||||
BASE_URL = "https://api.upcloud.com"
|
||||
|
||||
|
||||
def get_metric(json: Any, metric: str) -> UpcloudMetric:
|
||||
records = []
|
||||
|
||||
if metric in json:
|
||||
metric_data = json[metric]
|
||||
data = metric_data["data"]
|
||||
cols = list(map(lambda x: x["label"], data["cols"][1:]))
|
||||
latest = data["rows"][-1]
|
||||
time = latest[0]
|
||||
for column_idx in range(len(cols)):
|
||||
value = latest[1 + column_idx]
|
||||
|
||||
# If the latest value is None, try to fetch the second to last
|
||||
if value is None:
|
||||
value = data["rows"][-2][1 + column_idx]
|
||||
|
||||
if value is not None:
|
||||
label = cols[column_idx]
|
||||
if "(master)" in label:
|
||||
db_role = "master"
|
||||
else:
|
||||
db_role = "standby"
|
||||
records.append(
|
||||
UpcloudRecord(time=time, db_role=db_role, label=label, value=value)
|
||||
)
|
||||
else:
|
||||
LOG.warn(f"Could not get value for metric {metric}")
|
||||
|
||||
return UpcloudMetric(metric_name=metric, records=records)
|
||||
|
||||
|
||||
def get_metrics(json: Any) -> UpcloudMetrics:
|
||||
return UpcloudMetrics(
|
||||
metrics=[
|
||||
get_metric(json, "cpu_usage"),
|
||||
get_metric(json, "disk_usage"),
|
||||
get_metric(json, "diskio_reads"),
|
||||
get_metric(json, "diskio_writes"),
|
||||
get_metric(json, "load_average"),
|
||||
get_metric(json, "mem_usage"),
|
||||
get_metric(json, "net_receive"),
|
||||
get_metric(json, "net_send"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class UpcloudClient:
|
||||
def __init__(self, username: str, password: str):
|
||||
if not username:
|
||||
raise Exception("UpcloudClient username must be set")
|
||||
if not password:
|
||||
raise Exception("UpcloudClient password must be set")
|
||||
|
||||
client = requests.Session()
|
||||
encoded_auth = base64.b64encode(
|
||||
f"{username}:{password}".encode("utf-8")
|
||||
).decode("utf-8")
|
||||
client.headers = {"Authorization": f"Basic {encoded_auth}"}
|
||||
self.__client = client
|
||||
|
||||
def get_metrics(self, db_uuid: str) -> UpcloudMetrics:
|
||||
url = f"{BASE_URL}/1.3/database/{db_uuid}/metrics?period=hour"
|
||||
LOG.d(f"Performing request to {url}")
|
||||
response = self.__client.get(url)
|
||||
LOG.d(f"Status code: {response.status_code}")
|
||||
if response.status_code != 200:
|
||||
return UpcloudMetrics(metrics=[])
|
||||
|
||||
as_json = response.json()
|
||||
|
||||
return get_metrics(as_json)
|
@ -1,3 +1,4 @@
|
||||
import configparser
|
||||
import os
|
||||
import subprocess
|
||||
from time import sleep
|
||||
@ -7,6 +8,7 @@ import newrelic.agent
|
||||
|
||||
from app.db import Session
|
||||
from app.log import LOG
|
||||
from monitor.metric_exporter import MetricExporter
|
||||
|
||||
# the number of consecutive fails
|
||||
# if more than _max_nb_fails, alert
|
||||
@ -19,6 +21,18 @@ _max_nb_fails = 10
|
||||
# the maximum number of emails in incoming & active queue
|
||||
_max_incoming = 50
|
||||
|
||||
_NR_CONFIG_FILE_LOCATION_VAR = "NEW_RELIC_CONFIG_FILE"
|
||||
|
||||
|
||||
def get_newrelic_license() -> str:
|
||||
nr_file = os.environ.get(_NR_CONFIG_FILE_LOCATION_VAR, None)
|
||||
if nr_file is None:
|
||||
raise Exception(f"{_NR_CONFIG_FILE_LOCATION_VAR} not defined")
|
||||
|
||||
config = configparser.ConfigParser()
|
||||
config.read(nr_file)
|
||||
return config["newrelic"]["license_key"]
|
||||
|
||||
|
||||
@newrelic.agent.background_task()
|
||||
def log_postfix_metrics():
|
||||
@ -80,10 +94,13 @@ def log_nb_db_connection():
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exporter = MetricExporter(get_newrelic_license())
|
||||
while True:
|
||||
log_postfix_metrics()
|
||||
log_nb_db_connection()
|
||||
Session.close()
|
||||
|
||||
exporter.run()
|
||||
|
||||
# 1 min
|
||||
sleep(60)
|
||||
|
@ -1,7 +1,7 @@
|
||||
"""
|
||||
This is an example on how to integrate SimpleLogin
|
||||
with Requests-OAuthlib, a popular library to work with OAuth in Python.
|
||||
The step-to-step guide can be found on https://docs.simplelogin.io
|
||||
The step-to-step guide can be found on https://simplelogin.io/docs/siwsl/app/
|
||||
This example is based on
|
||||
https://requests-oauthlib.readthedocs.io/en/latest/examples/real_world_example.html
|
||||
"""
|
||||
|
5321
app/poetry.lock
generated
5321
app/poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -95,13 +95,13 @@ webauthn = "^0.4.7"
|
||||
pyspf = "^2.0.14"
|
||||
Flask-Limiter = "^1.4"
|
||||
memory_profiler = "^0.57.0"
|
||||
gevent = "^21.12.0"
|
||||
gevent = "22.10.2"
|
||||
aiospamc = "^0.6.1"
|
||||
email_validator = "^1.1.1"
|
||||
PGPy = "0.5.4"
|
||||
coinbase-commerce = "^1.0.1"
|
||||
requests = "^2.25.1"
|
||||
newrelic = "^7.10.0"
|
||||
newrelic = "8.8.0"
|
||||
flanker = "^0.9.11"
|
||||
pyre2 = "^0.3.6"
|
||||
tldextract = "^3.1.2"
|
||||
@ -110,7 +110,8 @@ twilio = "^7.3.2"
|
||||
Deprecated = "^1.2.13"
|
||||
cryptography = "37.0.1"
|
||||
SQLAlchemy = "1.3.24"
|
||||
redis = "^4.3.4"
|
||||
redis = "^4.5.3"
|
||||
newrelic-telemetry-sdk = "^0.5.0"
|
||||
|
||||
[tool.poetry.dev-dependencies]
|
||||
pytest = "^7.0.0"
|
||||
|
@ -79,6 +79,7 @@ from app.config import (
|
||||
MEM_STORE_URI,
|
||||
)
|
||||
from app.dashboard.base import dashboard_bp
|
||||
from app.subscription_webhook import execute_subscription_webhook
|
||||
from app.db import Session
|
||||
from app.developer.base import developer_bp
|
||||
from app.discover.base import discover_bp
|
||||
@ -491,6 +492,7 @@ def setup_paddle_callback(app: Flask):
|
||||
# in case user cancels a plan and subscribes a new plan
|
||||
sub.cancelled = False
|
||||
|
||||
execute_subscription_webhook(user)
|
||||
LOG.d("User %s upgrades!", user)
|
||||
|
||||
Session.commit()
|
||||
@ -509,6 +511,7 @@ def setup_paddle_callback(app: Flask):
|
||||
).date()
|
||||
|
||||
Session.commit()
|
||||
execute_subscription_webhook(sub.user)
|
||||
|
||||
elif request.form.get("alert_name") == "subscription_cancelled":
|
||||
subscription_id = request.form.get("subscription_id")
|
||||
@ -538,6 +541,7 @@ def setup_paddle_callback(app: Flask):
|
||||
end_date=request.form.get("cancellation_effective_date"),
|
||||
),
|
||||
)
|
||||
execute_subscription_webhook(sub.user)
|
||||
|
||||
else:
|
||||
# user might have deleted their account
|
||||
@ -580,6 +584,7 @@ def setup_paddle_callback(app: Flask):
|
||||
sub.cancelled = False
|
||||
|
||||
Session.commit()
|
||||
execute_subscription_webhook(sub.user)
|
||||
else:
|
||||
LOG.w(
|
||||
f"update non-exist subscription {subscription_id}. {request.form}"
|
||||
@ -596,6 +601,7 @@ def setup_paddle_callback(app: Flask):
|
||||
Subscription.delete(sub.id)
|
||||
Session.commit()
|
||||
LOG.e("%s requests a refund", user)
|
||||
execute_subscription_webhook(sub.user)
|
||||
|
||||
elif request.form.get("alert_name") == "subscription_payment_refunded":
|
||||
subscription_id = request.form.get("subscription_id")
|
||||
@ -629,6 +635,7 @@ def setup_paddle_callback(app: Flask):
|
||||
LOG.e("Unknown plan_id %s", plan_id)
|
||||
else:
|
||||
LOG.w("partial subscription_payment_refunded, not handled")
|
||||
execute_subscription_webhook(sub.user)
|
||||
|
||||
return "OK"
|
||||
|
||||
@ -742,6 +749,7 @@ def handle_coinbase_event(event) -> bool:
|
||||
coinbase_subscription=coinbase_subscription,
|
||||
),
|
||||
)
|
||||
execute_subscription_webhook(user)
|
||||
|
||||
return True
|
||||
|
||||
|
@ -155,10 +155,8 @@ $(".pin-alias").change(async function () {
|
||||
}
|
||||
});
|
||||
|
||||
$(".save-note").on("click", async function () {
|
||||
let oldValue;
|
||||
let aliasId = $(this).data("alias");
|
||||
let note = $(`#note-${aliasId}`).val();
|
||||
async function handleNoteChange(aliasId, aliasEmail) {
|
||||
const note = document.getElementById(`note-${aliasId}`).value;
|
||||
|
||||
try {
|
||||
let res = await fetch(`/api/aliases/${aliasId}`, {
|
||||
@ -172,26 +170,27 @@ $(".save-note").on("click", async function () {
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toastr.success(`Saved`);
|
||||
toastr.success(`Description saved for ${aliasEmail}`);
|
||||
} else {
|
||||
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
|
||||
// reset to the original value
|
||||
oldValue = !$(this).prop("checked");
|
||||
$(this).prop("checked", oldValue);
|
||||
}
|
||||
} catch (e) {
|
||||
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
|
||||
// reset to the original value
|
||||
oldValue = !$(this).prop("checked");
|
||||
$(this).prop("checked", oldValue);
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
$(".save-mailbox").on("click", async function () {
|
||||
let oldValue;
|
||||
let aliasId = $(this).data("alias");
|
||||
let mailbox_ids = $(`#mailbox-${aliasId}`).val();
|
||||
function handleNoteFocus(aliasId) {
|
||||
document.getElementById(`note-focus-message-${aliasId}`).classList.remove('d-none');
|
||||
}
|
||||
|
||||
function handleNoteBlur(aliasId) {
|
||||
document.getElementById(`note-focus-message-${aliasId}`).classList.add('d-none');
|
||||
}
|
||||
|
||||
async function handleMailboxChange(aliasId, aliasEmail) {
|
||||
const selectedOptions = document.getElementById(`mailbox-${aliasId}`).selectedOptions;
|
||||
const mailbox_ids = Array.from(selectedOptions).map((selectedOption) => selectedOption.value);
|
||||
|
||||
if (mailbox_ids.length === 0) {
|
||||
toastr.error("You must select at least a mailbox", "Error");
|
||||
@ -210,25 +209,18 @@ $(".save-mailbox").on("click", async function () {
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toastr.success(`Mailbox Updated`);
|
||||
toastr.success(`Mailbox updated for ${aliasEmail}`);
|
||||
} else {
|
||||
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
|
||||
// reset to the original value
|
||||
oldValue = !$(this).prop("checked");
|
||||
$(this).prop("checked", oldValue);
|
||||
}
|
||||
} catch (e) {
|
||||
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
|
||||
// reset to the original value
|
||||
oldValue = !$(this).prop("checked");
|
||||
$(this).prop("checked", oldValue);
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
$(".save-alias-name").on("click", async function () {
|
||||
let aliasId = $(this).data("alias");
|
||||
let name = $(`#alias-name-${aliasId}`).val();
|
||||
async function handleDisplayNameChange(aliasId, aliasEmail) {
|
||||
const name = document.getElementById(`alias-name-${aliasId}`).value;
|
||||
|
||||
try {
|
||||
let res = await fetch(`/api/aliases/${aliasId}`, {
|
||||
@ -242,7 +234,7 @@ $(".save-alias-name").on("click", async function () {
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
toastr.success(`Alias Name Saved`);
|
||||
toastr.success(`Display name saved for ${aliasEmail}`);
|
||||
} else {
|
||||
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
|
||||
}
|
||||
@ -250,24 +242,41 @@ $(".save-alias-name").on("click", async function () {
|
||||
toastr.error("Sorry for the inconvenience! Could you refresh the page & retry please?", "Unknown Error");
|
||||
}
|
||||
|
||||
});
|
||||
}
|
||||
|
||||
function handleDisplayNameFocus(aliasId) {
|
||||
document.getElementById(`display-name-focus-message-${aliasId}`).classList.remove('d-none');
|
||||
}
|
||||
|
||||
function handleDisplayNameBlur(aliasId) {
|
||||
document.getElementById(`display-name-focus-message-${aliasId}`).classList.add('d-none');
|
||||
}
|
||||
|
||||
new Vue({
|
||||
el: '#filter-app',
|
||||
delimiters: ["[[", "]]"], // necessary to avoid conflict with jinja
|
||||
data: {
|
||||
showFilter: false
|
||||
showFilter: false,
|
||||
showStats: false
|
||||
},
|
||||
methods: {
|
||||
async toggleFilter() {
|
||||
let that = this;
|
||||
that.showFilter = !that.showFilter;
|
||||
store.set('showFilter', that.showFilter);
|
||||
},
|
||||
|
||||
async toggleStats() {
|
||||
let that = this;
|
||||
that.showStats = !that.showStats;
|
||||
store.set('showStats', that.showStats);
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
if (store.get("showFilter"))
|
||||
this.showFilter = true;
|
||||
|
||||
if (store.get("showStats"))
|
||||
this.showStats = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
@ -8,7 +8,8 @@ function enableDragDropForPGPKeys(inputID) {
|
||||
let files = event.dataTransfer.files;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
let file = files[i];
|
||||
if(file.type !== 'text/plain'){
|
||||
const isValidPgpFile = file.type === 'text/plain' || file.name.endsWith('.asc') || file.name.endsWith('.pub') || file.name.endsWith('.pgp') || file.name.endsWith('.key');
|
||||
if (!isValidPgpFile) {
|
||||
toastr.warning(`File ${file.name} is not a public key file`);
|
||||
continue;
|
||||
}
|
||||
@ -16,6 +17,7 @@ function enableDragDropForPGPKeys(inputID) {
|
||||
reader.onloadend = onFileLoaded;
|
||||
reader.readAsBinaryString(file);
|
||||
}
|
||||
dropArea.classList.remove("dashed-outline");
|
||||
}
|
||||
|
||||
function onFileLoaded(event) {
|
||||
@ -24,5 +26,20 @@ function enableDragDropForPGPKeys(inputID) {
|
||||
}
|
||||
|
||||
const dropArea = $(inputID).get(0);
|
||||
dropArea.addEventListener("dragenter", (event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
dropArea.classList.add("dashed-outline");
|
||||
});
|
||||
dropArea.addEventListener("dragover", (event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
dropArea.classList.add("dashed-outline");
|
||||
});
|
||||
dropArea.addEventListener("dragleave", (event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
dropArea.classList.remove("dashed-outline");
|
||||
});
|
||||
dropArea.addEventListener("drop", drop, false);
|
||||
}
|
||||
}
|
||||
|
16
app/static/package-lock.json
generated
vendored
16
app/static/package-lock.json
generated
vendored
@ -69,12 +69,12 @@
|
||||
"font-awesome": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmjs.org/font-awesome/-/font-awesome-4.7.0.tgz",
|
||||
"integrity": "sha1-j6jPBBGhoxr9B7BtKQK7n8gVoTM="
|
||||
"integrity": "sha512-U6kGnykA/6bFmg1M/oT9EkFeIYv7JlX3bozwQJWiiLz6L0w3F5vBVPxHlwyX/vtNq1ckcpRKOB9f2Qal/VtFpg=="
|
||||
},
|
||||
"htmx.org": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-1.6.1.tgz",
|
||||
"integrity": "sha512-i+1k5ee2eFWaZbomjckyrDjUpa3FMDZWufatUSBmmsjXVksn89nsXvr1KLGIdAajiz+ZSL7TE4U/QaZVd2U2sA=="
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/htmx.org/-/htmx.org-1.7.0.tgz",
|
||||
"integrity": "sha512-wIQ3yNq7yiLTm+6BhV7Z8qKKTzEQv9xN/I4QsN5FvdGi69SNWTsSMlhH69HPa1rpZ8zSq1A/e7gTbTySxliP8g=="
|
||||
},
|
||||
"intro.js": {
|
||||
"version": "2.9.3",
|
||||
@ -82,9 +82,9 @@
|
||||
"integrity": "sha512-hC+EXWnEuJeA3CveGMat3XHePd2iaXNFJIVfvJh2E9IzBMGLTlhWvPIVHAgKlOpO4lNayCxEqzr4N02VmHFr9Q=="
|
||||
},
|
||||
"jquery": {
|
||||
"version": "3.5.1",
|
||||
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.5.1.tgz",
|
||||
"integrity": "sha512-XwIBPqcMn57FxfT+Go5pzySnm4KWkT1Tv7gjrpT1srtf8Weynl6R273VJ5GjkRb51IzMp5nbaPjJXMWeju2MKg=="
|
||||
"version": "3.6.4",
|
||||
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.6.4.tgz",
|
||||
"integrity": "sha512-v28EW9DWDFpzcD9O5iyJXg3R3+q+mET5JhnjJzQUZMHOv67bpSIHq81GEYpPNZHG+XXHsfSme3nxp/hndKEcsQ=="
|
||||
},
|
||||
"multiple-select": {
|
||||
"version": "1.5.2",
|
||||
@ -107,7 +107,7 @@
|
||||
"toastr": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/toastr/-/toastr-2.1.4.tgz",
|
||||
"integrity": "sha1-i0O+ZPudDEFIcURvLbjoyk6V8YE=",
|
||||
"integrity": "sha512-LIy77F5n+sz4tefMmFOntcJ6HL0Fv3k1TDnNmFZ0bU/GcvIIfy6eG2v7zQmMiYgaalAiUv75ttFrPn5s0gyqlA==",
|
||||
"requires": {
|
||||
"jquery": ">=1.12.0"
|
||||
}
|
||||
|
5
app/static/style.css
vendored
5
app/static/style.css
vendored
@ -217,4 +217,9 @@ textarea.parsley-error {
|
||||
|
||||
.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* dashed outline to indicate droppable area */
|
||||
.dashed-outline {
|
||||
outline: 4px dashed gray;
|
||||
}
|
@ -9,10 +9,13 @@
|
||||
<h1 class="card-title">Create new account</h1>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Email address</label>
|
||||
{{ form.email(class="form-control", type="email") }}
|
||||
{{ form.email(class="form-control", type="email", placeholder="YourName@protonmail.com") }}
|
||||
<div class="small-text alert alert-info" style="margin-top: 1px">
|
||||
Emails sent to your alias will be forwarded to this email address.
|
||||
<br>
|
||||
It can't be a disposable or forwarding email address.
|
||||
<br>
|
||||
We recommend using a <a href="https://proton.me/mail" target="_blank">Proton Mail</a> address
|
||||
</div>
|
||||
{{ render_field_errors(form.email) }}
|
||||
</div>
|
||||
|
@ -23,7 +23,7 @@
|
||||
<!-- Yandex -->
|
||||
<meta name="yandex-verification" content="c9e5d4d68bc983a1" />
|
||||
<meta name="description"
|
||||
content="Protect your email address with email ALIAS. Create a different email alias for each website. No more phishing, spams."/>
|
||||
content="Protect your email address with email ALIAS. Create a different email alias for each website. No more phishing, or spam."/>
|
||||
<link rel="icon" href="/static/favicon.ico" type="image/x-icon" />
|
||||
<link rel="shortcut icon" type="image/x-icon" href="/static/favicon.ico" />
|
||||
<link rel="canonical" href="{{ CANONICAL_URL }}" />
|
||||
|
@ -133,6 +133,7 @@
|
||||
<div>
|
||||
<span>
|
||||
<a href="{{ 'mailto:' + contact.website_send_to() }}"
|
||||
target="_blank"
|
||||
data-toggle="tooltip"
|
||||
title="You can click on this to open your email client. Or use the copy button 👉"
|
||||
class="font-weight-bold">
|
||||
|
@ -48,7 +48,7 @@
|
||||
{% if scope == "email" %}
|
||||
|
||||
Email:
|
||||
<a href="mailto:{{ val }}">{{ val }}</a>
|
||||
<a href="mailto:{{ val }}" target="_blank">{{ val }}</a>
|
||||
{% elif scope == "name" %}
|
||||
Name: {{ val }}
|
||||
{% endif %}
|
||||
|
@ -43,7 +43,7 @@
|
||||
{% endif %}
|
||||
<div class="form-group">
|
||||
<label class="form-label">PGP Public Key</label>
|
||||
<textarea name="pgp" {% if not current_user.is_premium() %} disabled {% endif %} class="form-control" rows=10 id="pgp-public-key" placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----">{{ contact.pgp_public_key or "" }}</textarea>
|
||||
<textarea name="pgp" {% if not current_user.is_premium() %} disabled {% endif %} class="form-control" rows=10 id="pgp-public-key" placeholder="(Drag and drop or paste your pgp public key here) -----BEGIN PGP PUBLIC KEY BLOCK-----">{{ contact.pgp_public_key or "" }}</textarea>
|
||||
</div>
|
||||
<button class="btn btn-primary" name="action" {% if not current_user.is_premium() %}
|
||||
disabled {% endif %} value="save">
|
||||
|
@ -266,7 +266,9 @@
|
||||
<i>dkim._domainkey.{{ custom_domain.domain }}</i> as domain value instead.
|
||||
<br />
|
||||
If you are using a subdomain, e.g. <i>subdomain.domain.com</i>,
|
||||
you need to use <i>dkim._domainkey.subdomain</i> as domain value instead.
|
||||
you need to use <i>dkim._domainkey.subdomain</i> as the domain instead.
|
||||
<br />
|
||||
That means, if your domain is <i>mail.domain.com</i> you should enter <i>dkim._domainkey.mail.domain.com</i> as the Domain.
|
||||
<br />
|
||||
</div>
|
||||
<div class="alert alert-info">
|
||||
|
@ -31,63 +31,11 @@
|
||||
{% block title %}Alias{% endblock %}
|
||||
{% block default_content %}
|
||||
|
||||
<!-- Global Stats -->
|
||||
<div class="row">
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="subheader">Aliases</div>
|
||||
<div class="text-muted"
|
||||
style="order: 2; margin-left: auto; font-size: .8rem">All time</div>
|
||||
</div>
|
||||
<div class="h1 m-0">{{ stats.nb_alias }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="subheader">Forwarded</div>
|
||||
<div class="text-muted"
|
||||
style="order: 2; margin-left: auto; font-size: .8rem">Last 14 days</div>
|
||||
</div>
|
||||
<div class="h1 m-0">{{ stats.nb_forward }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="subheader">Replies/Sent</div>
|
||||
<div class="text-muted"
|
||||
style="order: 2; margin-left: auto; font-size: .8rem">Last 14 days</div>
|
||||
</div>
|
||||
<div class="h1 m-0">{{ stats.nb_reply }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="subheader">Blocked</div>
|
||||
<div class="text-muted"
|
||||
style="order: 2; margin-left: auto; font-size: .8rem">Last 14 days</div>
|
||||
</div>
|
||||
<div class="h1 m-0">{{ stats.nb_block }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END Global Stats -->
|
||||
<!-- Controls: buttons & search -->
|
||||
<div id="filter-app">
|
||||
<div class="row mb-3">
|
||||
<div class="col d-flex">
|
||||
<div>
|
||||
<div class="col d-flex flex-wrap justify-content-between">
|
||||
<div class="mb-1">
|
||||
<div class="btn-group" role="group">
|
||||
<form method="post">
|
||||
{{ csrf_form.csrf_token }}
|
||||
@ -141,17 +89,86 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-left: auto">
|
||||
<div>
|
||||
<div class="btn-group">
|
||||
<a v-if="!showFilter"
|
||||
@click="toggleFilter()"
|
||||
class="btn btn-outline-secondary">
|
||||
<i class="fe fe-chevrons-down"></i> Filters
|
||||
<a @click="toggleStats()" class="btn btn-outline-secondary">
|
||||
<span v-if="!showStats">
|
||||
<i class="fe fe-chevrons-down"></i>
|
||||
Show stats
|
||||
</span>
|
||||
<span v-else>
|
||||
<i class="fe fe-chevrons-up"></i>
|
||||
Hide stats
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<a @click="toggleFilter()" class="btn btn-outline-secondary">
|
||||
<span v-if="!showFilter">
|
||||
<i class="fe fe-chevrons-down"></i>
|
||||
Show filters
|
||||
</span>
|
||||
<span v-else>
|
||||
<i class="fe fe-chevrons-up"></i>
|
||||
Hide filters
|
||||
</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Global Stats -->
|
||||
<div class="row" v-if="showStats">
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card mb-3">
|
||||
<div class="card-body py-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="subheader">Aliases</div>
|
||||
<div class="text-muted"
|
||||
style="order: 2; margin-left: auto; font-size: .8rem">All time</div>
|
||||
</div>
|
||||
<div class="h1 m-0">{{ stats.nb_alias }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card mb-3">
|
||||
<div class="card-body py-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="subheader">Forwarded</div>
|
||||
<div class="text-muted"
|
||||
style="order: 2; margin-left: auto; font-size: .8rem">Last 14 days</div>
|
||||
</div>
|
||||
<div class="h1 m-0">{{ stats.nb_forward }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card mb-3">
|
||||
<div class="card-body py-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="subheader">Replies/Sent</div>
|
||||
<div class="text-muted"
|
||||
style="order: 2; margin-left: auto; font-size: .8rem">Last 14 days</div>
|
||||
</div>
|
||||
<div class="h1 m-0">{{ stats.nb_reply }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-lg-3">
|
||||
<div class="card mb-3">
|
||||
<div class="card-body py-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<div class="subheader">Blocked</div>
|
||||
<div class="text-muted"
|
||||
style="order: 2; margin-left: auto; font-size: .8rem">Last 14 days</div>
|
||||
</div>
|
||||
<div class="h1 m-0">{{ stats.nb_block }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END Global Stats -->
|
||||
<div class="row mb-2" v-if="showFilter" id="filter-control">
|
||||
<!-- Filter Control -->
|
||||
<div class="col d-flex">
|
||||
@ -223,11 +240,6 @@
|
||||
<a href="{{ url_for('dashboard.index') }}"
|
||||
class="btn btn-outline-secondary">Reset</a>
|
||||
{% endif %}
|
||||
<a v-if="showFilter"
|
||||
@click="toggleFilter()"
|
||||
class="btn btn-outline-secondary">
|
||||
<i class="fe fe-chevrons-up"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -342,17 +354,11 @@
|
||||
</div>
|
||||
<!-- END Email Activity -->
|
||||
<div class="small-text mt-1">
|
||||
Alias description
|
||||
Alias description <span id="note-focus-message-{{ alias.id }}" class="d-none font-italic">(automatically saved when you click outside the field)</span>
|
||||
</div>
|
||||
<div class="d-flex mb-2">
|
||||
<div class="flex-grow-1 mr-2">
|
||||
<textarea id="note-{{ alias.id }}" name="note" class="form-control" style="font-size: 12px" rows="2" placeholder="e.g. where the alias is used or why is it created">{{ alias.note or "" }}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<a data-alias="{{ alias.id }}"
|
||||
class="save-note btn btn-sm btn-outline-success w-100">
|
||||
Save
|
||||
</a>
|
||||
<textarea id="note-{{ alias.id }}" name="note" class="form-control" style="font-size: 12px" rows="2" placeholder="e.g. where the alias is used or why is it created" onchange="handleNoteChange({{ alias.id }}, '{{ alias.email }}')" onfocus="handleNoteFocus({{ alias.id }})" onblur="handleNoteBlur({{ alias.id }})">{{ alias.note or "" }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Send Email && More button -->
|
||||
@ -421,7 +427,8 @@
|
||||
data-width="100%"
|
||||
class="mailbox-select"
|
||||
multiple
|
||||
name="mailbox">
|
||||
name="mailbox"
|
||||
onchange="handleMailboxChange({{ alias.id }}, '{{ alias.email }}')">
|
||||
{% for mailbox in mailboxes %}
|
||||
|
||||
<option value="{{ mailbox.id }}" {% if alias_info.contain_mailbox(mailbox.id) %}
|
||||
@ -431,12 +438,6 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<a data-alias="{{ alias.id }}"
|
||||
class="save-mailbox btn btn-sm btn-outline-info w-100">
|
||||
Update
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% elif alias_info.mailbox != None and alias_info.mailbox.email != current_user.email %}
|
||||
<div class="small-text">
|
||||
@ -448,19 +449,18 @@
|
||||
title="When sending an email from this alias, the email will have 'Display Name <{{ alias.email }}>' as sender.">
|
||||
Display name
|
||||
<i class="fe fe-help-circle"></i>
|
||||
<span id="display-name-focus-message-{{ alias.id }}"
|
||||
class="d-none font-italic">(automatically saved when you click outside the field or press Enter)</span>
|
||||
</div>
|
||||
<div class="d-flex">
|
||||
<div class="flex-grow-1 mr-2">
|
||||
<input id="alias-name-{{ alias.id }}"
|
||||
value="{{ alias.name or '' }}"
|
||||
class="form-control"
|
||||
placeholder="{{ alias.custom_domain.name or "Alias name" }}">
|
||||
</div>
|
||||
<div>
|
||||
<a data-alias="{{ alias.id }}"
|
||||
class="save-alias-name btn btn-sm btn-outline-primary w-100">
|
||||
Save
|
||||
</a>
|
||||
placeholder="{{ alias.custom_domain.name or "Alias name" }}"
|
||||
onchange="handleDisplayNameChange({{ alias.id }}, '{{ alias.email }}')"
|
||||
onfocus="handleDisplayNameFocus({{ alias.id }})"
|
||||
onblur="handleDisplayNameBlur({{ alias.id }})">
|
||||
</div>
|
||||
</div>
|
||||
{% if alias.mailbox_support_pgp() %}
|
||||
|
@ -71,177 +71,181 @@
|
||||
</form>
|
||||
</div>
|
||||
<!-- END Change email -->
|
||||
{% if mailbox.pgp_finger_print and not mailbox.disable_pgp and current_user.include_sender_in_reverse_alias %}
|
||||
<!-- Not show PGP option for Proton mailbox -->
|
||||
{% if mailbox.is_proton() and not mailbox.pgp_enabled() %}
|
||||
|
||||
<div class="alert alert-info">
|
||||
Email headers like <span class="italic">From, To, Subject</span> aren't encrypted by PGP.
|
||||
Currently, your reverse alias includes the sender address.
|
||||
You can disable this on <a href="/dashboard/setting#sender-in-ra">Settings</a>.
|
||||
As an email is always encrypted at rest in Proton Mail, having SimpleLogin also encrypt your email is redundant and does not add any security benefit.
|
||||
<br>
|
||||
The PGP option on SimpleLogin is instead useful for when your mailbox provider isn't encrypted by default like Gmail, Outlook, etc.
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
<div class="d-flex">
|
||||
Pretty Good Privacy (PGP)
|
||||
<div class="{% if mailbox.is_proton() and not mailbox.pgp_enabled() %}
|
||||
disabled-content{% endif %}">
|
||||
{% if mailbox.pgp_finger_print and not mailbox.disable_pgp and current_user.include_sender_in_reverse_alias and not mailbox.is_proton() %}
|
||||
|
||||
<div class="alert alert-info">
|
||||
Email headers like <span class="italic">From, To, Subject</span> aren't encrypted by PGP.
|
||||
Currently, your reverse alias includes the sender address.
|
||||
You can disable this on <a href="/dashboard/setting#sender-in-ra">Settings</a>.
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
<div class="d-flex">
|
||||
Pretty Good Privacy (PGP)
|
||||
{% if mailbox.pgp_finger_print %}
|
||||
|
||||
<form method="post">
|
||||
{{ csrf_form.csrf_token }}
|
||||
<input type="hidden" name="form-name" value="toggle-pgp">
|
||||
<label class="custom-switch cursor" style="padding-left: 1rem" data-toggle="tooltip" {% if mailbox.disable_pgp %}
|
||||
title="Enable PGP" {% else %} title="Disable PGP" {% endif %}>
|
||||
<input type="checkbox" class="custom-switch-input" name="pgp-enabled" {{ "" if mailbox.disable_pgp else "checked" }}>
|
||||
<span class="custom-switch-indicator"></span>
|
||||
</label>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="small-text mt-1">
|
||||
By importing your PGP Public Key into SimpleLogin, all emails sent to {{ mailbox.email }} are
|
||||
<b>encrypted</b> with your key.
|
||||
<br />
|
||||
{% if PGP_SIGNER %}All forwarded emails will be signed with <b>{{ PGP_SIGNER }}</b>.{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% if not current_user.is_premium() %}
|
||||
|
||||
<div class="alert alert-danger" role="alert">This feature is only available in premium plan.</div>
|
||||
{% endif %}
|
||||
<form method="post">
|
||||
{{ csrf_form.csrf_token }}
|
||||
<div class="form-group">
|
||||
<label class="form-label">PGP Public Key</label>
|
||||
<textarea name="pgp" {% if not current_user.is_premium() %} disabled {% endif %} class="form-control" rows=10 id="pgp-public-key" placeholder="(Drag and drop or paste your pgp public key here) -----BEGIN PGP PUBLIC KEY BLOCK-----">{{ mailbox.pgp_public_key or "" }}</textarea>
|
||||
</div>
|
||||
<input type="hidden" name="form-name" value="pgp">
|
||||
<button class="btn btn-primary" name="action" {% if not current_user.is_premium() %}
|
||||
disabled {% endif %} value="save">
|
||||
Save
|
||||
</button>
|
||||
{% if mailbox.pgp_finger_print %}
|
||||
|
||||
<form method="post">
|
||||
{{ csrf_form.csrf_token }}
|
||||
<input type="hidden" name="form-name" value="toggle-pgp">
|
||||
<label class="custom-switch cursor" style="padding-left: 1rem" data-toggle="tooltip" {% if mailbox.disable_pgp %}
|
||||
title="Enable PGP" {% else %} title="Disable PGP" {% endif %}>
|
||||
<input type="checkbox" class="custom-switch-input" name="pgp-enabled" {{ "" if mailbox.disable_pgp else "checked" }}>
|
||||
<span class="custom-switch-indicator"></span>
|
||||
</label>
|
||||
</form>
|
||||
<button class="btn btn-danger float-right" name="action" value="remove">Remove</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="small-text mt-1">
|
||||
By importing your PGP Public Key into SimpleLogin, all emails sent to {{ mailbox.email }} are
|
||||
<b>encrypted</b> with your key.
|
||||
<br />
|
||||
{% if PGP_SIGNER %}All forwarded emails will be signed with <b>{{ PGP_SIGNER }}</b>.{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% if not current_user.is_premium() %}
|
||||
|
||||
<div class="alert alert-danger" role="alert">This feature is only available in premium plan.</div>
|
||||
{% endif %}
|
||||
<form method="post">
|
||||
{{ csrf_form.csrf_token }}
|
||||
<div class="form-group">
|
||||
<label class="form-label">PGP Public Key</label>
|
||||
<textarea name="pgp" {% if not current_user.is_premium() %} disabled {% endif %} class="form-control" rows=10 id="pgp-public-key" placeholder="-----BEGIN PGP PUBLIC KEY BLOCK-----">{{ mailbox.pgp_public_key or "" }}</textarea>
|
||||
</div>
|
||||
<input type="hidden" name="form-name" value="pgp">
|
||||
<button class="btn btn-primary" name="action" {% if not current_user.is_premium() %}
|
||||
disabled {% endif %} value="save">
|
||||
Save
|
||||
</button>
|
||||
{% if mailbox.pgp_finger_print %}
|
||||
|
||||
<button class="btn btn-danger float-right" name="action" value="remove">Remove</button>
|
||||
{% endif %}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card" {% if not mailbox.pgp_enabled() %}
|
||||
disabled {% endif %}>
|
||||
<form method="post">
|
||||
<div class="card" id="generic-subject">
|
||||
<form method="post" action="#generic-subject">
|
||||
{{ csrf_form.csrf_token }}
|
||||
<input type="hidden" name="form-name" value="generic-subject">
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
Hide email subject when PGP is enabled
|
||||
Hide email subject
|
||||
<div class="small-text mt-1">
|
||||
When PGP is enabled, you can choose to use a <b>generic</b> subject for the forwarded emails.
|
||||
The original subject is then added into the email body.
|
||||
The original subject will be added to the email body and all forwarded emails will have the generic subject.
|
||||
<br />
|
||||
As PGP does not encrypt the email subject and the email subject might contain sensitive information,
|
||||
this option will allow a further protection of your email content.
|
||||
This option is often used when PGP is enabled.
|
||||
As PGP does not encrypt the email subject, it allows a further protection of your email content.
|
||||
</div>
|
||||
</div>
|
||||
<div class="alert alert-info">
|
||||
As the email is encrypted, a subject like "Email for you"
|
||||
will probably be rejected by your mailbox since it sounds like a spam.
|
||||
<br />
|
||||
Something like "Encrypted Email" would work much better :).
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Generic Subject</label>
|
||||
<input name="generic-subject" {% if not mailbox.pgp_enabled() %}
|
||||
disabled {% endif %} class="form-control" maxlength="78" placeholder="Generic Subject" value="{{ mailbox.generic_subject or "" }}">
|
||||
</div>
|
||||
<button class="btn btn-primary" name="action" {% if not mailbox.pgp_enabled() %}
|
||||
disabled {% endif %} value="save">
|
||||
Save
|
||||
</button>
|
||||
{% if mailbox.generic_subject %}
|
||||
<input name="generic-subject"
|
||||
class="form-control"
|
||||
maxlength="78"
|
||||
placeholder="Generic Subject"
|
||||
value="{{ mailbox.generic_subject or "" }}">
|
||||
</div>
|
||||
<button class="btn btn-primary" name="action" value="save">Save</button>
|
||||
{% if mailbox.generic_subject %}
|
||||
|
||||
<button class="btn btn-danger float-right" name="action" value="remove">Remove</button>
|
||||
{% endif %}
|
||||
<button class="btn btn-danger float-right" name="action" value="remove">Remove</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<hr />
|
||||
<h2 class="h4">Advanced Options</h2>
|
||||
{% if spf_available %}
|
||||
|
||||
<div class="card" id="spf">
|
||||
<form method="post">
|
||||
{{ csrf_form.csrf_token }}
|
||||
<input type="hidden" name="form-name" value="force-spf">
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
Enforce SPF
|
||||
<div class="small-text">
|
||||
To avoid email-spoofing, SimpleLogin blocks email that
|
||||
<em data-toggle="tooltip"
|
||||
title="Email that has your mailbox as envelope-sender address">seems</em> to come from your
|
||||
mailbox
|
||||
but sent from <em data-toggle="tooltip"
|
||||
title="IP Address that is not known by your mailbox email service">unknown</em>
|
||||
IP address.
|
||||
<br />
|
||||
Only turn off this option if you know what you're doing :).
|
||||
</div>
|
||||
</div>
|
||||
<label class="custom-switch cursor mt-2 pl-0" data-toggle="tooltip" {% if mailbox.force_spf %}
|
||||
title="Disable SPF enforcement" {% else %} title="Enable SPF enforcement" {% endif %}>
|
||||
<input type="checkbox" name="spf-status" class="custom-switch-input" {{ "checked" if mailbox.force_spf else "" }}>
|
||||
<span class="custom-switch-indicator"></span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<hr />
|
||||
<h2 class="h4">Advanced Options</h2>
|
||||
{% if spf_available %}
|
||||
|
||||
<div class="card" id="spf">
|
||||
<form method="post">
|
||||
{{ csrf_form.csrf_token }}
|
||||
<input type="hidden" name="form-name" value="force-spf">
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
Enforce SPF
|
||||
<div class="small-text">
|
||||
To avoid email-spoofing, SimpleLogin blocks email that
|
||||
<em data-toggle="tooltip"
|
||||
title="Email that has your mailbox as envelope-sender address">seems</em> to come from your
|
||||
mailbox
|
||||
but sent from <em data-toggle="tooltip"
|
||||
title="IP Address that is not known by your mailbox email service">unknown</em>
|
||||
IP address.
|
||||
<br />
|
||||
Only turn off this option if you know what you're doing :).
|
||||
</div>
|
||||
</div>
|
||||
<label class="custom-switch cursor mt-2 pl-0" data-toggle="tooltip" {% if mailbox.force_spf %}
|
||||
title="Disable SPF enforcement" {% else %} title="Enable SPF enforcement" {% endif %}>
|
||||
<input type="checkbox" name="spf-status" class="custom-switch-input" {{ "checked" if mailbox.force_spf else "" }}>
|
||||
<span class="custom-switch-indicator"></span>
|
||||
</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card" id="authorized-address">
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
Authorized addresses
|
||||
<div class="small-text">
|
||||
Emails sent from these addresses to a <b>reverse-alias</b> are considered as being sent
|
||||
from {{ mailbox.email }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card" id="authorized-address">
|
||||
<div class="card-body">
|
||||
<div class="card-title">
|
||||
Authorized addresses
|
||||
<div class="small-text">
|
||||
Emails sent from these addresses to a <b>reverse-alias</b> are considered as being sent
|
||||
from {{ mailbox.email }}
|
||||
</div>
|
||||
{% if mailbox.authorized_addresses | length == 0 %}
|
||||
|
||||
{% else %}
|
||||
<ul>
|
||||
{% for authorized_address in mailbox.authorized_addresses %}
|
||||
|
||||
<li>
|
||||
{{ authorized_address.email }}
|
||||
<form method="post" action="#authorized-address" style="display: inline">
|
||||
{{ csrf_form.csrf_token }}
|
||||
<input type="hidden" name="form-name" value="delete-authorized-address">
|
||||
<input type="hidden"
|
||||
name="authorized-address-id"
|
||||
value="{{ authorized_address.id }}">
|
||||
<input type="submit" class="btn btn-sm btn-outline-warning" value="Delete">
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
<form method="post" action="#authorized-address" class="form-inline">
|
||||
{{ csrf_form.csrf_token }}
|
||||
<input type="hidden" name="form-name" value="add-authorized-address">
|
||||
<input type="email" name="email" size="50" class="form-control" required>
|
||||
<input type="submit" class="btn btn-primary" value="Add">
|
||||
</form>
|
||||
</div>
|
||||
{% if mailbox.authorized_addresses | length == 0 %}
|
||||
|
||||
{% else %}
|
||||
<ul>
|
||||
{% for authorized_address in mailbox.authorized_addresses %}
|
||||
|
||||
<li>
|
||||
{{ authorized_address.email }}
|
||||
<form method="post" action="#authorized-address" style="display: inline">
|
||||
{{ csrf_form.csrf_token }}
|
||||
<input type="hidden" name="form-name" value="delete-authorized-address">
|
||||
<input type="hidden"
|
||||
name="authorized-address-id"
|
||||
value="{{ authorized_address.id }}">
|
||||
<input type="submit" class="btn btn-sm btn-outline-warning" value="Delete">
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
||||
<form method="post" action="#authorized-address" class="form-inline">
|
||||
{{ csrf_form.csrf_token }}
|
||||
<input type="hidden" name="form-name" value="add-authorized-address">
|
||||
<input type="email" name="email" size="50" class="form-control" required>
|
||||
<input type="submit" class="btn btn-primary" value="Add">
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script src="/static/js/utils/drag-drop-into-text.js"></script>
|
||||
<script>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block script %}
|
||||
<script src="/static/js/utils/drag-drop-into-text.js"></script>
|
||||
<script>
|
||||
$(".custom-switch-input").change(function (e) {
|
||||
$(this).closest("form").submit();
|
||||
});
|
||||
enableDragDropForPGPKeys('#pgp-public-key');
|
||||
</script>
|
||||
{% endblock %}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
@ -8,10 +8,11 @@
|
||||
<script>
|
||||
if (window.Paddle === undefined) {
|
||||
console.log("cannot load Paddle from CDN");
|
||||
document.write('<script src="/static/vendor/paddle.js"><\/script>')
|
||||
// split string to avoid djlint incorrectly formatting the file
|
||||
document.write('<' + 'script src="/static/vendor/paddle.js"><\/script' + '>');
|
||||
}
|
||||
</script>
|
||||
<style type="text/css">
|
||||
</script>
|
||||
<style type="text/css">
|
||||
html.mvc__a.mvc__lot.mvc__of.mvc__classes.mvc__to.mvc__increase.mvc__the.mvc__odds.mvc__of.mvc__winning.mvc__specificity, html.mvc__a.mvc__lot.mvc__of.mvc__classes.mvc__to.mvc__increase.mvc__the.mvc__odds.mvc__of.mvc__winning.mvc__specificity > body {
|
||||
position: static;
|
||||
}
|
||||
@ -25,190 +26,721 @@
|
||||
[data-toggle="collapse"]:not(.collapsed) .if-collapsed {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
.btn-no-pointer {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.tab-yearly__badge {
|
||||
top: -8px !important;
|
||||
left: 52px !important;
|
||||
}
|
||||
|
||||
.border-2 {
|
||||
border-width: 2px !important;
|
||||
}
|
||||
|
||||
.text-start {
|
||||
text-align: start !important;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
{% block announcement %}
|
||||
|
||||
{# TODO: to remove#}
|
||||
{# <div class="alert alert-danger text-center mb-0" role="alert">#}
|
||||
{# Our payment provider Paddle is experiencing#}
|
||||
{# <a href="https://paddle.status.io" target="_blank">server issue <i class="fe fe-external-link"></i></a>#}
|
||||
{# that can make our checkout page unusable. <br />#}
|
||||
{# Please retry later and sorry for this issue!#}
|
||||
{# </div>#}
|
||||
{# TODO: to remove#}
|
||||
{# <div class="alert alert-danger text-center mb-0" role="alert">#}
|
||||
{# Our payment provider Paddle is experiencing#}
|
||||
{# <a href="https://paddle.status.io" target="_blank">server issue <i class="fe fe-external-link"></i></a>#}
|
||||
{# that can make our checkout page unusable. <br />#}
|
||||
{# Please retry later and sorry for this issue!#}
|
||||
{# </div>#}
|
||||
{% endblock %}
|
||||
{% block default_content %}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-6 col-lg-6">
|
||||
<div class="card">
|
||||
<div class="card-body text-center">
|
||||
<div class="h3">Premium</div>
|
||||
<ul class="list-unstyled leading-loose mb-3">
|
||||
<li>
|
||||
<i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
|
||||
Unlimited aliases
|
||||
</li>
|
||||
<li>
|
||||
<i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
|
||||
Unlimited custom domains
|
||||
</li>
|
||||
<li>
|
||||
<i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
|
||||
Catch-all (or wildcard) aliases
|
||||
</li>
|
||||
<li>
|
||||
<i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
|
||||
Up to 50 directories (or usernames)
|
||||
</li>
|
||||
<li>
|
||||
<i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
|
||||
Unlimited mailboxes
|
||||
</li>
|
||||
<li>
|
||||
<i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
|
||||
PGP Encryption
|
||||
</li>
|
||||
</ul>
|
||||
<div class="small-text">
|
||||
More information on our
|
||||
<a href="https://simplelogin.io/pricing" target="_blank" rel="noopener noreferrer">
|
||||
Pricing
|
||||
Page <i class="fe fe-external-link"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-sm-6 col-lg-6">
|
||||
{% if manual_sub %}
|
||||
<div class="pb-8">
|
||||
<div class="text-center mx-md-auto mb-8 mt-6">
|
||||
<h1>Upgrade to unlock premium features</h1>
|
||||
</div>
|
||||
{% if manual_sub %}
|
||||
|
||||
<div class="alert alert-info">
|
||||
You currently have a subscription until <b>{{ manual_sub.end_at.format("YYYY-MM-DD") }}</b>
|
||||
({{ (manual_sub.end_at - now).days }} days left).
|
||||
<br />
|
||||
Please note that the time left will <b>not</b> be taken into account in a new subscription.
|
||||
</div>
|
||||
<hr />
|
||||
{% endif %}
|
||||
{% if proton_upgrade %}
|
||||
<div class="alert alert-info mt-0 mb-6">
|
||||
You currently have a subscription until <b>{{ manual_sub.end_at.format("YYYY-MM-DD") }}</b>
|
||||
({{ (manual_sub.end_at - now).days }} days left).
|
||||
<br />
|
||||
Please note that the time left will <b>not</b> be taken into account in a new subscription.
|
||||
</div>
|
||||
<hr />
|
||||
{% endif %}
|
||||
{% set sub = current_user.get_paddle_subscription() %}
|
||||
{% if sub and sub.cancelled %}
|
||||
|
||||
<div id="proton-upgrade">
|
||||
<h4>Proton Unlimited, Business and Visionary plans include SimpleLogin premium and more!</h4>
|
||||
<a class="btn btn-primary" role="button" href="https://account.proton.me/u/0/mail/upgrade">
|
||||
<b>Upgrade your Proton account</b>
|
||||
</a>
|
||||
<p class="mt-2 small">
|
||||
Starts at $9.99/month (billed yearly), starting with 500GB of storage, VPN, encrypted
|
||||
calendar & file storage and more.
|
||||
</p>
|
||||
<div class="middle-line my-5 h4">OR</div>
|
||||
<div id="normal-upgrade-button">
|
||||
<a class="btn btn-secondary collapsed" data-toggle="collapse" href="#normal-upgrade" role="button">
|
||||
Upgrade your SimpleLogin account
|
||||
<span class="if-collapsed">
|
||||
<i class="fe fe-chevron-down"></i>
|
||||
</span>
|
||||
<span class="if-not-collapsed">
|
||||
<i class="fe fe-chevron-up"></i>
|
||||
</span>
|
||||
</a>
|
||||
<p class="mt-2 small">Starts at $2.5/month (billed yearly)</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="normal-upgrade" class="{% if proton_upgrade %} collapse{% endif %}">
|
||||
<div class="display-6 my-3">
|
||||
🔐 Secure payments by
|
||||
<a href="https://paddle.com" target="_blank" rel="noopener noreferrer">
|
||||
Paddle <i class="fe fe-external-link"></i>
|
||||
</a>
|
||||
</div>
|
||||
{% set sub = current_user.get_paddle_subscription() %}
|
||||
{% if sub and sub.cancelled %}
|
||||
<div class="alert alert-primary mt-0 mb-6" role="alert">
|
||||
You have an active subscription until {{ sub.next_bill_date.strftime("%Y-%m-%d") }}.
|
||||
<br />
|
||||
Please note that if you re-subscribe now, this will be a completely
|
||||
new subscription and
|
||||
your payment method will be charged <b>immediately</b>.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if coinbase_sub %}
|
||||
|
||||
<div class="alert alert-primary" role="alert">
|
||||
You have an active subscription until {{ sub.next_bill_date.strftime("%Y-%m-%d") }}.
|
||||
<br />
|
||||
Please note that if you re-subscribe now, this will be a completely
|
||||
new subscription and
|
||||
your payment method will be charged <b>immediately</b>.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if coinbase_sub %}
|
||||
<div class="alert alert-info mt-0 mb-6">
|
||||
You currently have a Coinbase subscription until <b>{{ coinbase_sub.end_at.format("YYYY-MM-DD") }}</b>
|
||||
({{ (coinbase_sub.end_at - now).days }} days left).
|
||||
<br />
|
||||
Please note that the time left will <b>not</b> be taken into account in a new Paddle subscription.
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="nav btn-group mb-4 justify-content-center position-relative flex-nowrap d-flex"
|
||||
id="pills-tab"
|
||||
role="tablist">
|
||||
<a class="btn btn-outline-primary flex-grow-0 px-8 py-2"
|
||||
id="monthly-plan-tab"
|
||||
data-toggle="tab"
|
||||
href="#monthly-plan"
|
||||
role="tab"
|
||||
aria-controls="monthly-plan"
|
||||
aria-selected="false">Monthly</a>
|
||||
<a class="btn btn-outline-primary flex-grow-0 px-8 py-2 position-relative active"
|
||||
id="yearly-plan-tab"
|
||||
data-toggle="tab"
|
||||
href="#yearly-plan"
|
||||
role="tab"
|
||||
aria-controls="yearly-plan"
|
||||
aria-selected="true">Yearly<span class="badge badge-success position-absolute tab-yearly__badge"
|
||||
style="font-size: 12px">Save $18</span></a>
|
||||
</div>
|
||||
<div class="tab-content mb-8">
|
||||
<!-- monthly tab content -->
|
||||
<div class="tab-pane"
|
||||
id="monthly-plan"
|
||||
role="tabpanel"
|
||||
aria-labelledby="monthly-plan-tab">
|
||||
<div class="row row-cards">
|
||||
<!-- monthly free plan -->
|
||||
<div class="{{ 'col-md-6 col-lg-4' if proton_upgrade else 'col-md-6' }}">
|
||||
<div class="card card-md flex-grow-1">
|
||||
<div class="card-body">
|
||||
<div class="text-center">
|
||||
<div class="h3">Free</div>
|
||||
<div class="h3 my-3">$0</div>
|
||||
<div class="text-center mt-4 mb-6">
|
||||
{% set sub = current_user.get_paddle_subscription() %}
|
||||
<button class="{{ 'invisible' if sub or manual_sub or coinbase_sub }} btn btn-lg btn-outline-secondary w-100 btn-no-pointer"
|
||||
aria-disabled="true"
|
||||
disabled>
|
||||
Current plan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="list-unstyled">
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
10 aliases
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
1 mailbox
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END monthly free plan -->
|
||||
<!-- monthly premium plan -->
|
||||
<div class="{{ 'col-md-6 col-lg-4' if proton_upgrade else 'col-md-6' }}">
|
||||
<div class="card card-md flex-grow-1 border-primary border-2">
|
||||
<div class="card-body">
|
||||
<div class="text-center">
|
||||
<div class="h3">SimpleLogin Premium</div>
|
||||
<div class="h3 my-3">$4 / month</div>
|
||||
<div class="text-center mt-4 mb-6">
|
||||
<button class="btn btn-primary btn-lg w-100"
|
||||
onclick="upgradePaddle({{ PADDLE_MONTHLY_PRODUCT_ID }})">
|
||||
Upgrade to Premium
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="list-unstyled">
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
Unlimited aliases
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
Unlimited mailboxes
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
Custom domains: bring your own domain to create aliases like contact@your-domain.com
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
Catch-all (or wildcard) domain
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
Initiate a new email from your alias
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
5 subdomains
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
50 directories
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
PGP Encryption
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END monthly premium plan -->
|
||||
<!-- monthly Proton plan -->
|
||||
{% if proton_upgrade %}
|
||||
|
||||
<div class="alert alert-info">
|
||||
You currently have a Coinbase subscription until <b>{{ coinbase_sub.end_at.format("YYYY-MM-DD") }}</b>
|
||||
({{ (coinbase_sub.end_at - now).days }} days left).
|
||||
<br />
|
||||
Please note that the time left will <b>not</b> be taken into account in a new Paddle subscription.
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="mb-3">
|
||||
Paddle supports bank cards
|
||||
(Mastercard, Visa, American Express, etc) and PayPal.
|
||||
</div>
|
||||
<button class="btn btn-primary" onclick="upgrade({{ PADDLE_YEARLY_PRODUCT_ID }})">
|
||||
Yearly billing
|
||||
<span class="badge badge-success">Save $18</span>
|
||||
<br />
|
||||
<span style="font-size: 18px">$30/year</span>
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="upgrade({{ PADDLE_MONTHLY_PRODUCT_ID }})">
|
||||
Monthly billing
|
||||
<br />
|
||||
<b>
|
||||
$4/month
|
||||
</b>
|
||||
</button>
|
||||
<hr />
|
||||
<i class="fa fa-bitcoin"></i>
|
||||
Payment via
|
||||
<a href="https://commerce.coinbase.com/?lang=en" target="_blank" rel="noopener noreferrer">
|
||||
Coinbase Commerce<i class="fe fe-external-link"></i>
|
||||
</a>
|
||||
<br />
|
||||
Currently Bitcoin, Bitcoin Cash, Dai, Ethereum, Litecoin and USD Coin are supported.
|
||||
<br />
|
||||
<a class="btn btn-outline-primary" href="{{ url_for('dashboard.coinbase_checkout_route') }}" target="_blank" rel="noopener noreferrer">
|
||||
Yearly billing - Crypto
|
||||
<br />
|
||||
$30/year
|
||||
<i class="fe fe-external-link"></i>
|
||||
</a>
|
||||
<hr />
|
||||
If you have bought a coupon, please go to the
|
||||
<a href="{{ url_for('dashboard.coupon_route') }}">coupon page</a>
|
||||
to apply the coupon code.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card card-md flex-grow-1">
|
||||
<div class="card-body">
|
||||
<div class="text-center">
|
||||
<div class="h3">Proton plan</div>
|
||||
<div class="h3 my-3">Starts at $12.99 / month</div>
|
||||
<div class="text-center mt-4 mb-6">
|
||||
<a class="btn btn-lg btn-outline-primary w-100"
|
||||
role="button"
|
||||
href="https://account.proton.me/u/0/mail/upgrade"
|
||||
target="_blank">Upgrade your Proton account</a>
|
||||
</div>
|
||||
</div>
|
||||
<p>Proton Unlimited / Business plans include:</p>
|
||||
<ul class="list-unstyled">
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
SimpleLogin Premium
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
500 GB storage
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
Unlimited folders, labels, and filters
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
Unlimited messages per day
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
25 calendars
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
10 high-speed VPN connections
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
3 custom email domains
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- END monthly Proton plan -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- END monthly tab content -->
|
||||
<!-- yearly tab content -->
|
||||
<div class="tab-pane show active"
|
||||
id="yearly-plan"
|
||||
role="tabpanel"
|
||||
aria-labelledby="yearly-plan-tab">
|
||||
<div class="row row-cards">
|
||||
<!-- yearly free plan (identical to monthly) -->
|
||||
<div class="{{ 'col-md-6 col-lg-4' if proton_upgrade else 'col-md-6' }}">
|
||||
<div class="card card-md flex-grow-1">
|
||||
<div class="card-body">
|
||||
<div class="text-center">
|
||||
<div class="h3">Free</div>
|
||||
<div class="h3 my-3">$0</div>
|
||||
<div class="text-center mt-4 mb-6">
|
||||
{% set sub = current_user.get_paddle_subscription() %}
|
||||
<button class="{{ 'invisible' if sub or manual_sub or coinbase_sub }} btn btn-lg btn-outline-secondary w-100 btn-no-pointer"
|
||||
aria-disabled="true"
|
||||
disabled>
|
||||
Current plan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="list-unstyled">
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
10 aliases
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
1 mailbox
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END yearly free plan -->
|
||||
<!-- yearly premium plan -->
|
||||
<div class="{{ 'col-md-6 col-lg-4' if proton_upgrade else 'col-md-6' }}">
|
||||
<div class="card card-md flex-grow-1 border-primary border-2">
|
||||
<div class="card-body">
|
||||
<div class="text-center">
|
||||
<div class="h3">SimpleLogin Premium</div>
|
||||
<div class="h3 my-3">$30 / year</div>
|
||||
<div class="text-center mt-4 mb-6">
|
||||
<button class="btn btn-primary btn-lg w-100"
|
||||
onclick="upgradePaddle({{ PADDLE_YEARLY_PRODUCT_ID }})">
|
||||
Upgrade to Premium
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<ul class="list-unstyled">
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
Unlimited aliases
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
Unlimited mailboxes
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
Custom domains: bring your own domain to create aliases like contact@your-domain.com
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
Catch-all (or wildcard) domain
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
Initiate a new email from your alias
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
5 subdomains
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
50 directories
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
PGP Encryption
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END yearly premium plan -->
|
||||
<!-- yearly Proton plan -->
|
||||
{% if proton_upgrade %}
|
||||
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<div class="card card-md flex-grow-1">
|
||||
<div class="card-body">
|
||||
<div class="text-center">
|
||||
<div class="h3">Proton plan</div>
|
||||
<div class="h3 my-3">Starts at $119.88 / year</div>
|
||||
<div class="text-center mt-4 mb-6">
|
||||
<a class="btn btn-lg btn-outline-primary w-100"
|
||||
role="button"
|
||||
href="https://account.proton.me/u/0/mail/upgrade"
|
||||
target="_blank">Upgrade your Proton account</a>
|
||||
</div>
|
||||
</div>
|
||||
<p>Proton Unlimited / Business plans include:</p>
|
||||
<ul class="list-unstyled">
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
SimpleLogin Premium
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
500 GB storage
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
Unlimited folders, labels, and filters
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
Unlimited messages per day
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
25 calendars
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
10 high-speed VPN connections
|
||||
</li>
|
||||
<li class="d-flex">
|
||||
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
|
||||
3 custom email domains
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<!-- END yearly Proton plan -->
|
||||
</div>
|
||||
</div>
|
||||
<!-- END yearly tab content -->
|
||||
</div>
|
||||
<hr />
|
||||
<!-- FAQ section -->
|
||||
<div>
|
||||
<h3 class="text-center mb-5 mt-7">Frequently asked questions</h3>
|
||||
<div id="pricing-faq">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header card-collapse p-0"
|
||||
id="pricing-faq-question-payment-methods">
|
||||
<h5 class="mb-0 w-100">
|
||||
<button class="btn btn-link btn-block d-flex justify-content-between card-btn p-4 collapsed text-decoration-none"
|
||||
data-toggle="collapse"
|
||||
data-target="#pricing-faq-answer-payment-methods"
|
||||
aria-controls="pricing-faq-answer-payment-methods"
|
||||
aria-expanded="false">
|
||||
<span class="text-start">Which payment methods (credit cards, PayPal, cryptocurrencies...) do you support?</span>
|
||||
<span class="if-collapsed">
|
||||
<i class="fe fe-chevron-down"></i>
|
||||
</span>
|
||||
<span class="if-not-collapsed">
|
||||
<i class="fe fe-chevron-up"></i>
|
||||
</span>
|
||||
</button>
|
||||
</h5>
|
||||
</div>
|
||||
<div id="pricing-faq-answer-payment-methods"
|
||||
class="collapse"
|
||||
aria-labelledby="pricing-faq-question-payment-methods"
|
||||
data-parent="#pricing-faq">
|
||||
<div class="card-body">
|
||||
<p>
|
||||
We use <a href="https://paddle.com" target="_blank" rel="noopener noreferrer">Paddle <i class="fe fe-external-link"></i></a> by default for handling payments via credit cards and PayPal. Paddle currently supports the following payment methods:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
Cards (including Mastercard, Visa, Maestro, American Express, Discover, Diners Club, JCB, UnionPay, and Mada)
|
||||
</li>
|
||||
<li>
|
||||
PayPal
|
||||
</li>
|
||||
<li>
|
||||
Apple Pay
|
||||
</li>
|
||||
<li>
|
||||
Wire Transfers (ACH/SEPA/BACS)
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
More information can be found on
|
||||
<a href="https://paddle.com/support/which-payment-methods-do-you-support/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
Paddle supported payment methods <i class="fe fe-external-link"></i>
|
||||
</a>.
|
||||
</p>
|
||||
<hr />
|
||||
<p>
|
||||
Furthermore we also support cryptocurrencies for the yearly plan via
|
||||
<a href="https://commerce.coinbase.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
Coinbase Commerce <i class="fe fe-external-link"></i>
|
||||
</a>, which currently supports Bitcoin, Bitcoin Cash, DAI, ApeCoin, Dogecoin, Ethereum, Litecoin, SHIBA INU, Tether and USD Coin.
|
||||
</p>
|
||||
<p>
|
||||
In the future, we are going to support Monero as well. In the meantime, please send us an email at <a href="mailto:support@simplelogin.zendesk.com" target="_blank">support@simplelogin.zendesk.com</a> if you want to use this cryptocurrency.
|
||||
</p>
|
||||
<div class="d-flex justify-content-center">
|
||||
<a class="btn btn-outline-primary text-center"
|
||||
href="{{ url_for('dashboard.coinbase_checkout_route') }}"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
Upgrade to Premium - cryptocurrency
|
||||
<br />
|
||||
$30 / year
|
||||
<i class="fe fe-external-link"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header card-collapse p-0"
|
||||
id="pricing-faq-question-coupon">
|
||||
<h5 class="mb-0 w-100">
|
||||
<button class="btn btn-link btn-block d-flex justify-content-between card-btn p-4 collapsed text-decoration-none"
|
||||
data-toggle="collapse"
|
||||
data-target="#pricing-faq-answer-coupon"
|
||||
aria-controls="pricing-faq-answer-coupon"
|
||||
aria-expanded="false">
|
||||
<span class="text-start">Where can I redeem / buy a coupon?</span>
|
||||
<span class="if-collapsed">
|
||||
<i class="fe fe-chevron-down"></i>
|
||||
</span>
|
||||
<span class="if-not-collapsed">
|
||||
<i class="fe fe-chevron-up"></i>
|
||||
</span>
|
||||
</button>
|
||||
</h5>
|
||||
</div>
|
||||
<div id="pricing-faq-answer-coupon"
|
||||
class="collapse"
|
||||
aria-labelledby="pricing-faq-question-coupon"
|
||||
data-parent="#pricing-faq">
|
||||
<div class="card-body">
|
||||
<p>
|
||||
To redeem or buy a coupon, please go to the
|
||||
<a href="{{ url_for('dashboard.coupon_route') }}">coupon page</a>. The coupon code can be used by you or given to someone as a gift.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header card-collapse p-0"
|
||||
id="pricing-faq-question-aliases-sub-stopped">
|
||||
<h5 class="mb-0 w-100">
|
||||
<button class="btn btn-link btn-block d-flex justify-content-between card-btn p-4 collapsed text-decoration-none"
|
||||
data-toggle="collapse"
|
||||
data-target="#pricing-faq-answer-aliases-sub-stopped"
|
||||
aria-controls="pricing-faq-answer-aliases-sub-stopped"
|
||||
aria-expanded="false">
|
||||
<span class="text-start">What happens to my aliases when I stop the subscription?</span>
|
||||
<span class="if-collapsed">
|
||||
<i class="fe fe-chevron-down"></i>
|
||||
</span>
|
||||
<span class="if-not-collapsed">
|
||||
<i class="fe fe-chevron-up"></i>
|
||||
</span>
|
||||
</button>
|
||||
</h5>
|
||||
</div>
|
||||
<div id="pricing-faq-answer-aliases-sub-stopped"
|
||||
class="collapse"
|
||||
aria-labelledby="pricing-faq-question-aliases-sub-stopped"
|
||||
data-parent="#pricing-faq">
|
||||
<div class="card-body">
|
||||
<p>
|
||||
When your subscription ends, all aliases you created continue working normally, both on receiving and
|
||||
sending emails. Concretely:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
All aliases/domains/directories/mailboxes you have created are kept and continue working normally.
|
||||
</li>
|
||||
<li>
|
||||
You cannot create new aliases if you exceed the free plan limit, i.e. have more than 10 aliases.
|
||||
</li>
|
||||
<li>
|
||||
As features like catch-all or directory allow you to create aliases on-the-fly, those aliases cannot be automatically created if you have more than 10 aliases.
|
||||
</li>
|
||||
<li>
|
||||
You cannot add new domain, directory or mailbox.
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
For example, if you have 100 aliases by the time your subscription ends, these 100 aliases will continue receiving and sending emails normally. You cannot however create new aliases.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header card-collapse p-0"
|
||||
id="pricing-faq-question-aliases-max">
|
||||
<h5 class="mb-0 w-100">
|
||||
<button class="btn btn-link btn-block d-flex justify-content-between card-btn p-4 collapsed text-decoration-none"
|
||||
data-toggle="collapse"
|
||||
data-target="#pricing-faq-answer-aliases-max"
|
||||
aria-controls="pricing-faq-answer-aliases-max"
|
||||
aria-expanded="false">
|
||||
<span class="text-start">What happens when I reach the maximum number of alias in free plan?</span>
|
||||
<span class="if-collapsed">
|
||||
<i class="fe fe-chevron-down"></i>
|
||||
</span>
|
||||
<span class="if-not-collapsed">
|
||||
<i class="fe fe-chevron-up"></i>
|
||||
</span>
|
||||
</button>
|
||||
</h5>
|
||||
</div>
|
||||
<div id="pricing-faq-answer-aliases-max"
|
||||
class="collapse"
|
||||
aria-labelledby="pricing-faq-question-aliases-max"
|
||||
data-parent="#pricing-faq">
|
||||
<div class="card-body">
|
||||
<p>
|
||||
If you are in the free plan, you cannot create new aliases when you reach the maximum number of aliases
|
||||
(i.e. 10 aliases).
|
||||
<br>
|
||||
Aliases that would otherwise be created automatically via the catch-all domain or directory feature also cannot be created.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header card-collapse p-0"
|
||||
id="pricing-faq-question-discounts">
|
||||
<h5 class="mb-0 w-100">
|
||||
<button class="btn btn-link btn-block d-flex justify-content-between card-btn p-4 collapsed text-decoration-none"
|
||||
data-toggle="collapse"
|
||||
data-target="#pricing-faq-answer-discounts"
|
||||
aria-controls="pricing-faq-answer-discounts"
|
||||
aria-expanded="false">
|
||||
<span class="text-start">Do you offer discounts?</span>
|
||||
<span class="if-collapsed">
|
||||
<i class="fe fe-chevron-down"></i>
|
||||
</span>
|
||||
<span class="if-not-collapsed">
|
||||
<i class="fe fe-chevron-up"></i>
|
||||
</span>
|
||||
</button>
|
||||
</h5>
|
||||
</div>
|
||||
<div id="pricing-faq-answer-discounts"
|
||||
class="collapse"
|
||||
aria-labelledby="pricing-faq-question-discounts"
|
||||
data-parent="#pricing-faq">
|
||||
<div class="card-body">
|
||||
<p>
|
||||
We offer important discounts or free premium for:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
students, professors or technical staffs working at an educational institute
|
||||
</li>
|
||||
<li>
|
||||
activists, dissidents or journalists
|
||||
</li>
|
||||
<li>
|
||||
charity organizations
|
||||
</li>
|
||||
</ul>
|
||||
<p>
|
||||
Please send us an email at <a href="mailto:support@simplelogin.zendesk.com" target="_blank">support@simplelogin.zendesk.com</a> for more info.
|
||||
</p>
|
||||
<p>
|
||||
We used to offer free premium accounts for students but this program ended at June 17 2021. Please note this doesn't affect existing accounts who have already benefited from the program or requests sent before this date.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header card-collapse p-0"
|
||||
id="pricing-faq-question-refund">
|
||||
<h5 class="mb-0 w-100">
|
||||
<button class="btn btn-link btn-block d-flex justify-content-between card-btn p-4 collapsed text-decoration-none"
|
||||
data-toggle="collapse"
|
||||
data-target="#pricing-faq-answer-refund"
|
||||
aria-controls="pricing-faq-answer-refund"
|
||||
aria-expanded="false">
|
||||
<span class="text-start">Do you have a refund policy?</span>
|
||||
<span class="if-collapsed">
|
||||
<i class="fe fe-chevron-down"></i>
|
||||
</span>
|
||||
<span class="if-not-collapsed">
|
||||
<i class="fe fe-chevron-up"></i>
|
||||
</span>
|
||||
</button>
|
||||
</h5>
|
||||
</div>
|
||||
<div id="pricing-faq-answer-refund"
|
||||
class="collapse"
|
||||
aria-labelledby="pricing-faq-question-refund"
|
||||
data-parent="#pricing-faq">
|
||||
<div class="card-body">
|
||||
<p>
|
||||
No we don't have a refund policy because SimpleLogin has a trial period where you can try all premium features.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header card-collapse p-0"
|
||||
id="pricing-faq-question-family">
|
||||
<h5 class="mb-0 w-100">
|
||||
<button class="btn btn-link btn-block d-flex justify-content-between card-btn p-4 collapsed text-decoration-none"
|
||||
data-toggle="collapse"
|
||||
data-target="#pricing-faq-answer-family"
|
||||
aria-controls="pricing-faq-answer-family"
|
||||
aria-expanded="false">
|
||||
<span class="text-start">Do you have a family plan?</span>
|
||||
<span class="if-collapsed">
|
||||
<i class="fe fe-chevron-down"></i>
|
||||
</span>
|
||||
<span class="if-not-collapsed">
|
||||
<i class="fe fe-chevron-up"></i>
|
||||
</span>
|
||||
</button>
|
||||
</h5>
|
||||
</div>
|
||||
<div id="pricing-faq-answer-family"
|
||||
class="collapse"
|
||||
aria-labelledby="pricing-faq-question-family"
|
||||
data-parent="#pricing-faq">
|
||||
<div class="card-body">
|
||||
<p>
|
||||
No we don't have a family plan but offer 30% reduction for additional subscriptions. Please contact us at <a href="mailto:support@simplelogin.zendesk.com" target="_blank">support@simplelogin.zendesk.com</a> for more information.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-3">
|
||||
<div class="card-header card-collapse p-0"
|
||||
id="pricing-faq-question-other-ways">
|
||||
<h5 class="mb-0 w-100">
|
||||
<button class="btn btn-link btn-block d-flex justify-content-between card-btn p-4 collapsed text-decoration-none"
|
||||
data-toggle="collapse"
|
||||
data-target="#pricing-faq-answer-other-ways"
|
||||
aria-controls="pricing-faq-answer-other-ways"
|
||||
aria-expanded="false">
|
||||
<span class="text-start">Are there other ways to buy SimpleLogin subscriptions?</span>
|
||||
<span class="if-collapsed">
|
||||
<i class="fe fe-chevron-down"></i>
|
||||
</span>
|
||||
<span class="if-not-collapsed">
|
||||
<i class="fe fe-chevron-up"></i>
|
||||
</span>
|
||||
</button>
|
||||
</h5>
|
||||
</div>
|
||||
<div id="pricing-faq-answer-other-ways"
|
||||
class="collapse"
|
||||
aria-labelledby="pricing-faq-question-other-ways"
|
||||
data-parent="#pricing-faq">
|
||||
<div class="card-body">
|
||||
<p>
|
||||
Yes you can also buy SimpleLogin subscription coupon via <a href="https://proxysto.re/en/index.html" target="_blank">ProxyStore <i class="fe fe-external-link"></i></a>, our official reseller.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END FAQ section -->
|
||||
</div>
|
||||
<script type="text/javascript">
|
||||
Paddle.Setup({vendor: {{ PADDLE_VENDOR_ID }}});
|
||||
|
||||
function upgrade(productId) {
|
||||
bootbox.dialog({
|
||||
title: `Payment with credit card or PayPal via Paddle`,
|
||||
message: `Paddle will ask for an email address for sending out the invoices, please feel free to use an alias. <br />
|
||||
You don't have to use your SimpleLogin account email address`,
|
||||
size: 'large',
|
||||
onEscape: true,
|
||||
backdrop: true,
|
||||
buttons: {
|
||||
got_it: {
|
||||
label: 'Got it!',
|
||||
className: 'btn-outline-primary',
|
||||
callback: function () {
|
||||
Paddle.Checkout.open({
|
||||
product: productId,
|
||||
success: "{{ success_url }}",
|
||||
passthrough: "{\"user_id\": {{current_user.id}} }"
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
function upgradePaddle(productId) {
|
||||
Paddle.Checkout.open({
|
||||
product: productId,
|
||||
success: "{{ success_url }}",
|
||||
passthrough: "{\"user_id\": {{current_user.id}} }"
|
||||
});
|
||||
}
|
||||
|
||||
</script>
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
@ -22,7 +22,7 @@
|
||||
For every user who <b>upgrades</b> and stays with us at least 3 months, you'll get $5 :).
|
||||
<br />
|
||||
The payout can be initiated any time, just send us an email at
|
||||
<a href="mailto:hi@simplelogin.io">hi@simplelogin.io</a>
|
||||
<a href="mailto:hi@simplelogin.io" target="_blank">hi@simplelogin.io</a>
|
||||
when you want to receive the payout.
|
||||
</div>
|
||||
{% if referrals|length == 0 %}
|
||||
|
@ -15,7 +15,7 @@
|
||||
<div class="col">
|
||||
<h1 class="h3 mb-5">Quarantine & Bounce</h1>
|
||||
<div class="alert alert-info">
|
||||
This page shows all emails that are either refused by your mailbox (bounced) or detected as spams/phishing (quarantine) via our
|
||||
This page shows all emails that are either refused by your mailbox (bounced) or detected as spam/phishing (quarantine) via our
|
||||
<a href="https://simplelogin.io/docs/getting-started/anti-phishing/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">anti-phishing program ↗</a>
|
||||
@ -31,7 +31,7 @@
|
||||
rel="noopener noreferrer">Setting up filter for SimpleLogin emails ↗</a>
|
||||
</li>
|
||||
<li>
|
||||
If the email is flagged as spams/phishing, this means that the sender explicitly states their emails should respect
|
||||
If the email is flagged as spam/phishing, this means that the sender explicitly states their emails should respect
|
||||
<b>DMARC</b> (an email authentication protocol)
|
||||
and any email that violates this should either be quarantined or rejected. If possible, please contact the sender
|
||||
so they can update their DMARC setting or fix their SPF/DKIM that cause the DMARC failure.
|
||||
|
@ -181,10 +181,10 @@
|
||||
<!-- END change name & profile picture -->
|
||||
<!-- Change email -->
|
||||
<div class="card">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="form-name" value="update-email">
|
||||
{{ change_email_form.csrf_token }}
|
||||
<div class="card-body">
|
||||
<div class="card-body">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<input type="hidden" name="form-name" value="update-email">
|
||||
{{ change_email_form.csrf_token }}
|
||||
<div class="card-title">Account Email</div>
|
||||
<div class="mb-3">
|
||||
This email address is used to log in to SimpleLogin.
|
||||
@ -199,26 +199,30 @@
|
||||
<!-- Not allow user to change email if there's a pending change -->
|
||||
{{ change_email_form.email(class="form-control", value=current_user.email, readonly=pending_email != None) }}
|
||||
{{ render_field_errors(change_email_form.email) }}
|
||||
{% if pending_email %}
|
||||
|
||||
<div class="mt-2">
|
||||
<span class="text-danger">Pending email change: {{ pending_email }}</span>
|
||||
<a href="{{ url_for('dashboard.resend_email_change') }}"
|
||||
class="btn btn-secondary btn-sm">
|
||||
Resend
|
||||
confirmation email
|
||||
</a>
|
||||
<a href="{{ url_for('dashboard.cancel_email_change') }}"
|
||||
class="btn btn-secondary btn-sm">
|
||||
Cancel email
|
||||
change
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<button class="btn btn-outline-primary">Change Email</button>
|
||||
</div>
|
||||
</form>
|
||||
</form>
|
||||
{% if pending_email %}
|
||||
|
||||
<div class="mt-2">
|
||||
<span class="text-danger float-left">Pending email change: {{ pending_email }}</span>
|
||||
<form method="POST"
|
||||
action="{{ url_for('dashboard.resend_email_change') }}"
|
||||
class="float-left ml-2">
|
||||
{{ change_email_form.csrf_token }}
|
||||
<a onclick="this.closest('form').submit()"
|
||||
class="btn btn-secondary btn-sm">Resend confirmation email</a>
|
||||
</form>
|
||||
<form method="POST"
|
||||
action="{{ url_for('dashboard.cancel_email_change') }}"
|
||||
class="float-left ml-2">
|
||||
{{ change_email_form.csrf_token }}
|
||||
<a onclick="this.closest('form').submit()"
|
||||
class="btn btn-secondary btn-sm">Cancel email change</a>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<!-- END Change email -->
|
||||
<!-- Connect with Proton -->
|
||||
@ -265,11 +269,15 @@
|
||||
<div class="card" id="change_password">
|
||||
<div class="card-body">
|
||||
<div class="card-title">Password</div>
|
||||
<div class="mb-3">You will receive an email containing instructions on how to change your password.</div>
|
||||
<div class="mb-3">
|
||||
You will receive an email containing instructions on how to change your password.
|
||||
</div>
|
||||
<form method="post">
|
||||
{{ csrf_form.csrf_token }}
|
||||
<input type="hidden" name="form-name" value="change-password">
|
||||
<button class="btn btn-outline-primary">Change password</button>
|
||||
<button class="btn btn-outline-primary">
|
||||
Change password
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@ -676,7 +684,8 @@
|
||||
SimpleLogin forwards emails to your mailbox from the <b>reverse-alias</b> and not from the <b>original</b>
|
||||
sender address.
|
||||
<br />
|
||||
If this option is enabled, the original sender addresses is stored in the email header <b>X-SimpleLogin-Envelope-From</b>.
|
||||
If this option is enabled, the original sender addresses is stored in the email header <b>X-SimpleLogin-Envelope-From</b>
|
||||
and the original From header is stored in <b>X-SimpleLogin-Original-From<b>.
|
||||
You can choose to display this header in your email client.
|
||||
<br />
|
||||
As email headers aren't encrypted, your mailbox service can know the sender address via this header.
|
||||
|
@ -28,7 +28,7 @@
|
||||
<form id="supportZendeskForm" method="post" enctype="multipart/form-data">
|
||||
<div class="mt-4 mb-5">
|
||||
<label for="issueDescription" class="form-label font-weight-bold">What happened?</label>
|
||||
<textarea class="form-control" required name="ticket_content" id="issueDescription" rows="3" placeholder="Please provide as much information as possible. For example which alias(es), mailbox(es) ar affected, if this is a persistent issue...">{{- ticket_content or '' -}}</textarea>
|
||||
<textarea class="form-control" required name="ticket_content" id="issueDescription" rows="3" placeholder="Please provide as much information as possible. For example which alias(es), mailbox(es) are affected, if this is a persistent issue...">{{- ticket_content or '' -}}</textarea>
|
||||
</div>
|
||||
<div class="mt-5 font-weight-bold">Attach files to support request</div>
|
||||
<div class="text-muted">Only images, text and emails are accepted</div>
|
||||
|
18
app/templates/dashboard/thank-you.html
Normal file
18
app/templates/dashboard/thank-you.html
Normal file
@ -0,0 +1,18 @@
|
||||
{% extends "single.html" %}
|
||||
|
||||
{% set active_page = "dashboard" %}
|
||||
{% block title %}Thank you{% endblock %}
|
||||
{% block single_content %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<h1 class="h3">Thanks so much for supporting SimpleLogin!</h1>
|
||||
<p>
|
||||
SimpleLogin is 100% funded by the community.
|
||||
We do not use your data, track you or show you ads.
|
||||
</p>
|
||||
<p>Thanks to your support, we can keep the service running and develop new features.</p>
|
||||
<a class="btn btn-primary" href="/">Close</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@ -9,7 +9,7 @@
|
||||
<h1 class="h3">Block alias</h1>
|
||||
<p>
|
||||
You are about to block the alias
|
||||
<a href="mailto:{{ alias }}">{{ alias }}</a>
|
||||
<a href="mailto:{{ alias }}" target="_blank">{{ alias }}</a>
|
||||
</p>
|
||||
<p>After this, you will stop receiving all emails sent to this alias, please confirm.</p>
|
||||
<form method="post">
|
||||
|
@ -31,7 +31,7 @@
|
||||
<span class="icon mr-3"><i class="fe fe-alert-octagon"></i></span>Danger
|
||||
</a>
|
||||
</div>
|
||||
<a href="https://docs.simplelogin.io"
|
||||
<a href="https://simplelogin.io/docs/siwsl/app/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="btn btn-block btn-secondary mt-4">
|
||||
|
@ -10,7 +10,7 @@
|
||||
<h4 class="alert-heading">Well done!</h4>
|
||||
<p>
|
||||
Please head to our
|
||||
<a href="https://docs.simplelogin.io"
|
||||
<a href="https://simplelogin.io/docs/siwsl/app/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer">
|
||||
documentation <i class="fe fe-external-link"></i>
|
||||
|
@ -47,7 +47,7 @@
|
||||
<div class="col">
|
||||
<div class="btn-group" role="group" aria-label="Basic example">
|
||||
<a href="{{ url_for('developer.new_client') }}" class="btn btn-primary">New website</a>
|
||||
<a href="https://docs.simplelogin.io"
|
||||
<a href="https://simplelogin.io/docs/siwsl/app/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="ml-2 btn btn-secondary">
|
||||
|
@ -31,7 +31,7 @@ Please consider the following options:
|
||||
<a href="{{ disable_alias_link }}">disable the alias</a>
|
||||
or
|
||||
<a href="{{ block_sender_link }}">block the sender</a>
|
||||
if they send too many spams.
|
||||
if they send too many spam emails.
|
||||
</li>
|
||||
</ol>
|
||||
<br />
|
||||
|
@ -12,7 +12,7 @@ Please consider the following options:
|
||||
|
||||
2. If this email is spam, it means your alias {{alias}} is now in the hands of a spammer.
|
||||
You can either disable the alias on {{disable_alias_link}}
|
||||
or block the sender on {{ block_sender_link }} if they send too many spams.
|
||||
or block the sender on {{ block_sender_link }} if they send too many spam emails.
|
||||
|
||||
Please note that the alias can be automatically disabled if too many emails sent to it are bounced.
|
||||
|
||||
|
@ -0,0 +1,17 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
|
||||
{% call text() %}
|
||||
Hello,
|
||||
{% endcall %}
|
||||
|
||||
{% call text() %}
|
||||
Your have tried to register multiple times to {{ service }}, and this is against the terms of service of SimpleLogin. Please don't do that anymore.
|
||||
{% endcall %}
|
||||
|
||||
{% call text() %}
|
||||
If you continue registering multiple accounts to a single service we will have to disable your account.
|
||||
{% endcall %}
|
||||
|
||||
{% endblock %}
|
@ -0,0 +1,9 @@
|
||||
{% extends "base.txt.jinja2" %}
|
||||
|
||||
{% block content %}
|
||||
Hello,
|
||||
|
||||
Your have tried to register multiple times to {{service}}, and this is against the terms of service of SimpleLogin. Please don't do that anymore.
|
||||
|
||||
If you continue registering multiple accounts to a single service we will have to disable your account.
|
||||
{% endblock %}
|
@ -286,6 +286,7 @@
|
||||
|
||||
},
|
||||
async mounted() {
|
||||
Object.freeze(Object.prototype);
|
||||
let that = this;
|
||||
let res = await fetch(`/api/notifications?page=${that.page}`, {
|
||||
method: "GET",
|
||||
|
@ -19,7 +19,7 @@
|
||||
<a href="{{ disable_alias_link }}">disable the alias</a>
|
||||
or
|
||||
<a href="{{ block_sender_link }}">block the sender</a>
|
||||
if they send too many spams.
|
||||
if they send too many spam emails.
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
@ -61,7 +61,7 @@
|
||||
<img src="{{ user_info[scope.value] }}" class="avatar">
|
||||
{% elif scope == Scope.EMAIL %}
|
||||
{{ scope.value }}:
|
||||
<a href="mailto:{{ user_info[scope.value] }}">{{ user_info[scope.value] }}</a>
|
||||
<a href="mailto:{{ user_info[scope.value] }}" target="_blank">{{ user_info[scope.value] }}</a>
|
||||
{% elif scope == Scope.NAME %}
|
||||
{{ scope.value }}: <b>{{ user_info[scope.value] }}</b>
|
||||
{% endif %}
|
||||
|
@ -5,7 +5,7 @@
|
||||
<div class="page-single">
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col mx-auto" style="max-width: 28rem">
|
||||
<div class="col mx-auto" style="max-width: 32rem">
|
||||
<div class="text-center mb-6">
|
||||
<a href="{{ LANDING_PAGE_URL }}">
|
||||
<img src="/static/logo.svg"
|
||||
|
@ -17,7 +17,7 @@ def test_get_setting(flask_client):
|
||||
"notification": True,
|
||||
"random_alias_default_domain": "sl.local",
|
||||
"sender_format": "AT",
|
||||
"random_alias_suffix": "random_string",
|
||||
"random_alias_suffix": "word",
|
||||
}
|
||||
|
||||
|
||||
@ -95,11 +95,13 @@ def test_get_setting_domains_v2(flask_client):
|
||||
def test_update_settings_random_alias_suffix(flask_client):
|
||||
user = login(flask_client)
|
||||
# default random_alias_suffix is random_string
|
||||
assert user.random_alias_suffix == AliasSuffixEnum.random_string.value
|
||||
assert user.random_alias_suffix == AliasSuffixEnum.word.value
|
||||
|
||||
r = flask_client.patch("/api/setting", json={"random_alias_suffix": "invalid"})
|
||||
assert r.status_code == 400
|
||||
|
||||
r = flask_client.patch("/api/setting", json={"random_alias_suffix": "word"})
|
||||
r = flask_client.patch(
|
||||
"/api/setting", json={"random_alias_suffix": "random_string"}
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert user.random_alias_suffix == AliasSuffixEnum.word.value
|
||||
assert user.random_alias_suffix == AliasSuffixEnum.random_string.value
|
||||
|
@ -129,3 +129,12 @@ def test_change_name(flask_client):
|
||||
assert r.json["name"] == "new name"
|
||||
|
||||
assert user.name == "new name"
|
||||
|
||||
|
||||
def test_stats(flask_client):
|
||||
login(flask_client)
|
||||
|
||||
r = flask_client.get("/api/stats")
|
||||
|
||||
assert r.status_code == 200
|
||||
assert r.json == {"nb_alias": 1, "nb_block": 0, "nb_forward": 0, "nb_reply": 0}
|
||||
|
26
app/tests/auth/test_reset_password.py
Normal file
26
app/tests/auth/test_reset_password.py
Normal file
@ -0,0 +1,26 @@
|
||||
from flask import url_for
|
||||
|
||||
from app.db import Session
|
||||
from app.models import User, ResetPasswordCode
|
||||
from tests.utils import create_new_user, random_token
|
||||
|
||||
|
||||
def test_successful_reset_password(flask_client):
|
||||
user = create_new_user()
|
||||
original_pass_hash = user.password
|
||||
user_id = user.id
|
||||
reset_code = random_token()
|
||||
ResetPasswordCode.create(user_id=user.id, code=reset_code)
|
||||
ResetPasswordCode.create(user_id=user.id, code=random_token())
|
||||
Session.commit()
|
||||
|
||||
r = flask_client.post(
|
||||
url_for("auth.reset_password", code=reset_code),
|
||||
data={"password": "1231idsfjaads"},
|
||||
)
|
||||
|
||||
assert r.status_code == 302
|
||||
|
||||
assert ResetPasswordCode.get_by(user_id=user_id) is None
|
||||
user = User.get(user_id)
|
||||
assert user.password != original_pass_hash
|
@ -1,10 +1,13 @@
|
||||
from time import time
|
||||
|
||||
import arrow
|
||||
from flask import url_for
|
||||
|
||||
from app import config
|
||||
from app.dashboard.views.api_key import clean_up_unused_or_old_api_keys
|
||||
from app.db import Session
|
||||
from app.models import User, ApiKey
|
||||
from tests.utils import login
|
||||
from tests.utils import login, create_new_user
|
||||
|
||||
|
||||
def test_api_key_page_requires_password(flask_client):
|
||||
@ -34,6 +37,17 @@ def test_create_delete_api_key(flask_client):
|
||||
assert ApiKey.filter(ApiKey.user_id == user.id).count() == 1
|
||||
assert api_key.name == "for test"
|
||||
|
||||
# create second api_key
|
||||
create_r = flask_client.post(
|
||||
url_for("dashboard.api_key"),
|
||||
data={"form-name": "create", "name": "for test 2"},
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert create_r.status_code == 200
|
||||
api_key_2 = ApiKey.filter_by(user_id=user.id).order_by(ApiKey.id.desc()).first()
|
||||
assert ApiKey.filter(ApiKey.user_id == user.id).count() == 2
|
||||
assert api_key_2.name == "for test 2"
|
||||
|
||||
# delete api_key
|
||||
delete_r = flask_client.post(
|
||||
url_for("dashboard.api_key"),
|
||||
@ -41,7 +55,7 @@ def test_create_delete_api_key(flask_client):
|
||||
follow_redirects=True,
|
||||
)
|
||||
assert delete_r.status_code == 200
|
||||
assert ApiKey.count() == nb_api_key
|
||||
assert ApiKey.count() == nb_api_key + 1
|
||||
|
||||
|
||||
def test_delete_all_api_keys(flask_client):
|
||||
@ -87,3 +101,26 @@ def test_delete_all_api_keys(flask_client):
|
||||
assert (
|
||||
ApiKey.filter(ApiKey.user_id == user_2.id).count() == 1
|
||||
) # assert that user 2 still has 1 API key
|
||||
|
||||
|
||||
def test_cleanup_api_keys():
|
||||
user = create_new_user()
|
||||
ApiKey.create(
|
||||
user_id=user.id, name="used", last_used=arrow.utcnow().shift(days=-3), times=1
|
||||
)
|
||||
ApiKey.create(
|
||||
user_id=user.id, name="keep 1", last_used=arrow.utcnow().shift(days=-2), times=1
|
||||
)
|
||||
ApiKey.create(
|
||||
user_id=user.id, name="keep 2", last_used=arrow.utcnow().shift(days=-1), times=1
|
||||
)
|
||||
ApiKey.create(user_id=user.id, name="not used", last_used=None, times=1)
|
||||
Session.flush()
|
||||
old_max_api_keys = config.MAX_API_KEYS
|
||||
config.MAX_API_KEYS = 2
|
||||
clean_up_unused_or_old_api_keys(user.id)
|
||||
keys = ApiKey.filter_by(user_id=user.id).all()
|
||||
assert len(keys) == 2
|
||||
assert keys[0].name.find("keep") == 0
|
||||
assert keys[1].name.find("keep") == 0
|
||||
config.MAX_API_KEYS = old_max_api_keys
|
||||
|
20
app/tests/dashboard/test_coupon.py
Normal file
20
app/tests/dashboard/test_coupon.py
Normal file
@ -0,0 +1,20 @@
|
||||
from flask import url_for
|
||||
from app.models import Coupon
|
||||
from app.utils import random_string
|
||||
from tests.utils import login
|
||||
|
||||
|
||||
def test_use_coupon(flask_client):
|
||||
user = login(flask_client)
|
||||
code = random_string(10)
|
||||
Coupon.create(code=code, nb_year=1, commit=True)
|
||||
|
||||
r = flask_client.post(
|
||||
url_for("dashboard.coupon_route"),
|
||||
data={"code": code},
|
||||
)
|
||||
|
||||
assert r.status_code == 302
|
||||
coupon = Coupon.get_by(code=code)
|
||||
assert coupon.used
|
||||
assert coupon.used_by_user_id == user.id
|
@ -316,6 +316,10 @@ def test_add_alias_in_global_trash(flask_client):
|
||||
def test_add_alias_in_custom_domain_trash(flask_client):
|
||||
user = login(flask_client)
|
||||
|
||||
for deleted_domain in DomainDeletedAlias.all():
|
||||
Session.delete(deleted_domain)
|
||||
Session.flush()
|
||||
|
||||
domain = random_domain()
|
||||
custom_domain = CustomDomain.create(
|
||||
user_id=user.id, domain=domain, ownership_verified=True, commit=True
|
||||
|
21
app/tests/example_emls/add_header_multipart.eml
Normal file
21
app/tests/example_emls/add_header_multipart.eml
Normal file
@ -0,0 +1,21 @@
|
||||
Sender: somebody@somewhere.net
|
||||
Content-Type: multipart/mixed; boundary="----=_Part_3946_1099248058.1688752298149"
|
||||
|
||||
--0c916c9b5fe3c925d7bafeb988bb6794
|
||||
Content-Type: text/plain; charset="UTF-8"
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
notification test
|
||||
|
||||
--0c916c9b5fe3c925d7bafeb988bb6794
|
||||
Content-Type: text/html; charset="UTF-8"
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
<html><head><meta http-equiv=3D"Content-Type" content=3D"text/html; charset=
|
||||
=3DUTF-8"><meta http-equiv=3D"X-UA-Compatible" content=3D"IE=3Dedge"><meta =
|
||||
name=3D"format-detection" content=3D"telephone=3Dno"><meta name=3D"viewport=
|
||||
" content=3D"width=3Ddevice-width, initial-scale=3D1.0">
|
||||
|
||||
--0c916c9b5fe3c925d7bafeb988bb6794--
|
||||
|
||||
|
27
app/tests/example_emls/email_to_pgp_encrypt.eml
Normal file
27
app/tests/example_emls/email_to_pgp_encrypt.eml
Normal file
@ -0,0 +1,27 @@
|
||||
From: {{sender_address}}
|
||||
To: {{recipient_address}}
|
||||
Subject: Test subject
|
||||
Content-Type: multipart/alternative; boundary="MLF8fvg556fdhFDH7=_?:
|
||||
|
||||
--MLF8fvg556fdhFDH7=_?:
|
||||
Content-Type: text/plain;
|
||||
charset="utf-8"
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
*************************************************************************
|
||||
|
||||
This five-part limited series, based on the brilliant graphic novel by Me
|
||||
|
||||
--MLF8fvg556fdhFDH7=_?:
|
||||
Content-Type: text/html;
|
||||
charset="utf-8"
|
||||
Content-Transfer-Encoding: 8bit
|
||||
--MLF8fvg556fdhFDH7=_?:
|
||||
Content-Type: text/plain;
|
||||
charset="utf-8"
|
||||
Content-Transfer-Encoding: quoted-printable
|
||||
|
||||
*************************************************************************
|
||||
*************************************************************************
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user