Compare commits

...

17 Commits

Author SHA1 Message Date
a6f4995cb5 4.29.3 2023-06-01 11:00:05 +00:00
727f61a35e 4.28.2 2023-05-16 11:00:09 +00:00
ce5124605a 4.28.1 2023-05-10 11:00:05 +00:00
2c82b03f8d 4.27.0 2023-04-25 11:00:05 +00:00
1b7a6223ac 4.26.1 2023-04-20 11:00:06 +00:00
75331c62a4 4.25.1 2023-04-15 11:00:05 +00:00
3f68a3e640 4.24.0 2023-04-11 11:00:05 +00:00
8ee4f9462e 4.23.0 2023-03-24 12:00:07 +00:00
822855d584 4.22.5 2023-03-14 12:00:06 +00:00
1a6a7e079b Update '.drone.yml' 2023-03-08 18:32:53 +00:00
5210cb6515 4.22.4 2023-03-08 12:00:06 +00:00
b643f0644b 4.22.3 2023-03-01 12:00:06 +00:00
5d093db4f6 4.22.2 2023-02-16 12:00:05 +00:00
0b16fcac67 Update 'README.md' 2023-02-10 13:00:46 +00:00
a0d294da53 Update 'README.md' 2023-01-27 16:29:12 +00:00
c3f755aede Update '.drone.yml' 2023-01-27 16:26:22 +00:00
0aea62c222 4.22.0 2023-01-17 12:00:04 +00:00
116 changed files with 13243 additions and 327020 deletions

View File

@ -31,9 +31,15 @@ steps:
- name: notify - name: notify
image: plugins/slack image: plugins/slack
when:
status:
- success
- failure
settings: settings:
webhook: webhook:
from_secret: slack_webhook from_secret: slack_webhook
icon_url:
from_secret: slack_avatar
trigger: trigger:
event: event:

View File

@ -1,5 +1,7 @@
# Simple Login # Simple Login
[![Build Status](https://drone.mrmeeb.stream/api/badges/MrMeeb/simple-login/status.svg?ref=refs/heads/main)](https://drone.mrmeeb.stream/MrMeeb/simple-login)
This repo exists to automatically capture any releases of the SaaS edition of SimpleLogin. It checks once a day, and builds the latest one automatically if it is newer than the currentlty 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.
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. 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.

1
app/.gitignore vendored
View File

@ -15,3 +15,4 @@ venv/
.coverage .coverage
htmlcov htmlcov
adhoc adhoc
.env.*

View File

@ -21,3 +21,4 @@ repos:
- id: djlint-jinja - id: djlint-jinja
files: '.*\.html' files: '.*\.html'
entry: djlint --reformat entry: djlint --reformat

View File

@ -34,7 +34,7 @@ poetry install
On Mac, sometimes you might need to install some other packages via `brew`: On Mac, sometimes you might need to install some other packages via `brew`:
```bash ```bash
brew install pkg-config libffi openssl postgresql brew install pkg-config libffi openssl postgresql@13
``` ```
You also need to install `gpg` tool, on Mac it can be done with: You also need to install `gpg` tool, on Mac it can be done with:

View File

@ -2,7 +2,7 @@
FROM node:10.17.0-alpine AS npm FROM node:10.17.0-alpine AS npm
WORKDIR /code WORKDIR /code
COPY ./static/package*.json /code/static/ COPY ./static/package*.json /code/static/
RUN cd /code/static && npm install RUN cd /code/static && npm ci
# Main image # Main image
FROM python:3.10 FROM python:3.10

View File

@ -334,6 +334,12 @@ smtpd_recipient_restrictions =
permit permit
``` ```
Check that the ssl certificates `/etc/ssl/certs/ssl-cert-snakeoil.pem` and `/etc/ssl/private/ssl-cert-snakeoil.key` exist. Depending on the linux distribution you are using they may or may not be present. If they are not, you will need to generate them with this command:
```bash
openssl req -x509 -nodes -days 3650 -newkey rsa:2048 -keyout /etc/ssl/private/ssl-cert-snakeoil.key -out /etc/ssl/certs/ssl-cert-snakeoil.pem
```
Create the `/etc/postfix/pgsql-relay-domains.cf` file with the following content. Create the `/etc/postfix/pgsql-relay-domains.cf` file with the following content.
Make sure that the database config is correctly set, replace `mydomain.com` with your domain, update 'myuser' and 'mypassword' with your postgres credentials. Make sure that the database config is correctly set, replace `mydomain.com` with your domain, update 'myuser' and 'mypassword' with your postgres credentials.

View File

@ -9,13 +9,17 @@ from newrelic import agent
from app.db import Session from app.db import Session
from app.email_utils import send_welcome_email from app.email_utils import send_welcome_email
from app.utils import sanitize_email from app.utils import sanitize_email
from app.errors import AccountAlreadyLinkedToAnotherPartnerException from app.errors import (
AccountAlreadyLinkedToAnotherPartnerException,
AccountIsUsingAliasAsEmail,
)
from app.log import LOG from app.log import LOG
from app.models import ( from app.models import (
PartnerSubscription, PartnerSubscription,
Partner, Partner,
PartnerUser, PartnerUser,
User, User,
Alias,
) )
from app.utils import random_string from app.utils import random_string
@ -192,6 +196,12 @@ def get_login_strategy(
return ExistingUnlinkedUserStrategy(link_request, user, partner) 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( def process_login_case(
link_request: PartnerLinkRequest, partner: Partner link_request: PartnerLinkRequest, partner: Partner
) -> LinkResult: ) -> LinkResult:
@ -203,6 +213,8 @@ def process_login_case(
) )
if partner_user is None: if partner_user is None:
# We didn't find any SimpleLogin user registered with that partner user id # We didn't find any SimpleLogin user registered with that partner user id
# Make sure they aren't using an alias as their link email
check_alias(link_request.email)
# Try to find it using the partner's e-mail address # Try to find it using the partner's e-mail address
user = User.get_by(email=link_request.email) user = User.get_by(email=link_request.email)
return get_login_strategy(link_request, user, partner).process() return get_login_strategy(link_request, user, partner).process()

View File

@ -620,3 +620,8 @@ class MetricAdmin(SLModelView):
column_exclude_list = ["created_at", "updated_at", "id"] column_exclude_list = ["created_at", "updated_at", "id"]
can_export = True can_export = True
class InvalidMailboxDomainAdmin(SLModelView):
can_create = True
can_delete = True

View File

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

View File

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

View File

@ -357,7 +357,7 @@ def auth_payload(user, device) -> dict:
@api_bp.route("/auth/forgot_password", methods=["POST"]) @api_bp.route("/auth/forgot_password", methods=["POST"])
@limiter.limit("10/minute") @limiter.limit("2/minute")
def forgot_password(): def forgot_password():
""" """
User forgot password User forgot password

View File

@ -78,6 +78,9 @@ def delete_mailbox(mailbox_id):
Delete mailbox Delete mailbox
Input: Input:
mailbox_id: in url mailbox_id: in url
(optional) transfer_aliases_to: in body. Id of the new mailbox for the aliases.
If omitted or the value is set to -1,
the aliases of the mailbox will be deleted too.
Output: Output:
200 if deleted successfully 200 if deleted successfully
@ -91,11 +94,36 @@ def delete_mailbox(mailbox_id):
if mailbox.id == user.default_mailbox_id: if mailbox.id == user.default_mailbox_id:
return jsonify(error="You cannot delete the default mailbox"), 400 return jsonify(error="You cannot delete the default mailbox"), 400
data = request.get_json() or {}
transfer_mailbox_id = data.get("transfer_aliases_to")
if transfer_mailbox_id and int(transfer_mailbox_id) >= 0:
transfer_mailbox = Mailbox.get(transfer_mailbox_id)
if not transfer_mailbox or transfer_mailbox.user_id != user.id:
return (
jsonify(error="You must transfer the aliases to a mailbox you own."),
403,
)
if transfer_mailbox_id == mailbox_id:
return (
jsonify(
error="You can not transfer the aliases to the mailbox you want to delete."
),
400,
)
if not transfer_mailbox.verified:
return jsonify(error="Your new mailbox is not verified"), 400
# Schedule delete account job # Schedule delete account job
LOG.w("schedule delete mailbox job for %s", mailbox) LOG.w("schedule delete mailbox job for %s", mailbox)
Job.create( Job.create(
name=JOB_DELETE_MAILBOX, name=JOB_DELETE_MAILBOX,
payload={"mailbox_id": mailbox.id}, payload={
"mailbox_id": mailbox.id,
"transfer_mailbox_id": transfer_mailbox_id,
},
run_at=arrow.now(), run_at=arrow.now(),
commit=True, commit=True,
) )

View File

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

View File

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

View File

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

View File

@ -111,11 +111,15 @@ POSTFIX_SERVER = os.environ.get("POSTFIX_SERVER", "240.0.0.1")
DISABLE_REGISTRATION = "DISABLE_REGISTRATION" in os.environ DISABLE_REGISTRATION = "DISABLE_REGISTRATION" in os.environ
# allow using a different postfix port, useful when developing locally # allow using a different postfix port, useful when developing locally
POSTFIX_PORT = int(os.environ.get("POSTFIX_PORT", 25))
# Use port 587 instead of 25 when sending emails through Postfix # Use port 587 instead of 25 when sending emails through Postfix
# Useful when calling Postfix from an external network # Useful when calling Postfix from an external network
POSTFIX_SUBMISSION_TLS = "POSTFIX_SUBMISSION_TLS" in os.environ POSTFIX_SUBMISSION_TLS = "POSTFIX_SUBMISSION_TLS" in os.environ
if POSTFIX_SUBMISSION_TLS:
default_postfix_port = 587
else:
default_postfix_port = 25
POSTFIX_PORT = int(os.environ.get("POSTFIX_PORT", default_postfix_port))
POSTFIX_TIMEOUT = os.environ.get("POSTFIX_TIMEOUT", 3) POSTFIX_TIMEOUT = os.environ.get("POSTFIX_TIMEOUT", 3)
# ["domain1.com", "domain2.com"] # ["domain1.com", "domain2.com"]
@ -353,6 +357,7 @@ ALERT_COMPLAINT_TRANSACTIONAL_PHASE = "alert_complaint_transactional_phase"
ALERT_QUARANTINE_DMARC = "alert_quarantine_dmarc" ALERT_QUARANTINE_DMARC = "alert_quarantine_dmarc"
ALERT_DUAL_SUBSCRIPTION_WITH_PARTNER = "alert_dual_sub_with_partner" ALERT_DUAL_SUBSCRIPTION_WITH_PARTNER = "alert_dual_sub_with_partner"
ALERT_WARN_MULTIPLE_SUBSCRIPTIONS = "alert_multiple_subscription"
# <<<<< END ALERT EMAIL >>>> # <<<<< END ALERT EMAIL >>>>
@ -527,3 +532,5 @@ if ENABLE_ALL_REVERSE_ALIAS_REPLACEMENT:
SKIP_MX_LOOKUP_ON_CHECK = False SKIP_MX_LOOKUP_ON_CHECK = False
DISABLE_RATE_LIMIT = "DISABLE_RATE_LIMIT" in os.environ DISABLE_RATE_LIMIT = "DISABLE_RATE_LIMIT" in os.environ
SUBSCRIPTION_CHANGE_WEBHOOK = os.environ.get("SUBSCRIPTION_CHANGE_WEBHOOK", None)

View File

@ -90,7 +90,7 @@ def create_contact(user: User, alias: Alias, contact_address: str) -> Contact:
alias_id=alias.id, alias_id=alias.id,
website_email=contact_email, website_email=contact_email,
name=contact_name, name=contact_name,
reply_email=generate_reply_email(contact_email, user), reply_email=generate_reply_email(contact_email, alias),
) )
LOG.d( LOG.d(

View File

@ -215,6 +215,12 @@ def alias_transfer_receive_route():
token, token,
) )
transfer(alias, current_user, mailboxes) 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") flash(f"You are now owner of {alias.email}", "success")
return redirect(url_for("dashboard.index", highlight_alias_id=alias.id)) return redirect(url_for("dashboard.index", highlight_alias_id=alias.id))

View File

@ -7,6 +7,7 @@ from app.dashboard.base import dashboard_bp
from app.dashboard.views.enter_sudo import sudo_required from app.dashboard.views.enter_sudo import sudo_required
from app.db import Session from app.db import Session
from app.models import ApiKey from app.models import ApiKey
from app.utils import CSRFValidationForm
class NewApiKeyForm(FlaskForm): class NewApiKeyForm(FlaskForm):
@ -23,9 +24,13 @@ def api_key():
.all() .all()
) )
csrf_form = CSRFValidationForm()
new_api_key_form = NewApiKeyForm() new_api_key_form = NewApiKeyForm()
if request.method == "POST": if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
if request.form.get("form-name") == "delete": if request.form.get("form-name") == "delete":
api_key_id = request.form.get("api-key-id") api_key_id = request.form.get("api-key-id")
@ -62,5 +67,8 @@ def api_key():
return redirect(url_for("dashboard.api_key")) return redirect(url_for("dashboard.api_key"))
return render_template( return render_template(
"dashboard/api_key.html", api_keys=api_keys, new_api_key_form=new_api_key_form "dashboard/api_key.html",
api_keys=api_keys,
new_api_key_form=new_api_key_form,
csrf_form=csrf_form,
) )

View File

@ -34,7 +34,7 @@ def batch_import_route():
if request.method == "POST": if request.method == "POST":
if not csrf_form.validate(): if not csrf_form.validate():
flash("Invalid request", "warning") flash("Invalid request", "warning")
redirect(request.url) return redirect(request.url)
if len(batch_imports) > 10: if len(batch_imports) > 10:
flash( flash(
"You have too many imports already. Wait until some get cleaned up", "You have too many imports already. Wait until some get cleaned up",

View File

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

View File

@ -120,18 +120,11 @@ def custom_alias():
email=full_alias email=full_alias
) )
custom_domain = domain_deleted_alias.domain custom_domain = domain_deleted_alias.domain
if domain_deleted_alias.user_id == current_user.id: flash(
flash( f"You have deleted this alias before. You can restore it on "
f"You have deleted this alias before. You can restore it on " f"{custom_domain.domain} 'Deleted Alias' page",
f"{custom_domain.domain} 'Deleted Alias' page", "error",
"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,
)
elif DeletedAlias.get_by(email=full_alias): elif DeletedAlias.get_by(email=full_alias):
flash(general_error_msg, "error") flash(general_error_msg, "error")

View File

@ -3,6 +3,7 @@ from flask_login import login_required, current_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from wtforms import StringField, validators from wtforms import StringField, validators
from app import parallel_limiter
from app.config import EMAIL_SERVERS_WITH_PRIORITY from app.config import EMAIL_SERVERS_WITH_PRIORITY
from app.dashboard.base import dashboard_bp from app.dashboard.base import dashboard_bp
from app.db import Session from app.db import Session
@ -19,6 +20,7 @@ class NewCustomDomainForm(FlaskForm):
@dashboard_bp.route("/custom_domain", methods=["GET", "POST"]) @dashboard_bp.route("/custom_domain", methods=["GET", "POST"])
@login_required @login_required
@parallel_limiter.lock(only_when=lambda: request.method == "POST")
def custom_domain(): def custom_domain():
custom_domains = CustomDomain.filter_by( custom_domains = CustomDomain.filter_by(
user_id=current_user.id, is_sl_subdomain=False user_id=current_user.id, is_sl_subdomain=False

View File

@ -9,6 +9,7 @@ from wtforms import (
IntegerField, IntegerField,
) )
from app import parallel_limiter
from app.config import ( from app.config import (
EMAIL_DOMAIN, EMAIL_DOMAIN,
ALIAS_DOMAINS, ALIAS_DOMAINS,
@ -45,6 +46,7 @@ class DeleteDirForm(FlaskForm):
@dashboard_bp.route("/directory", methods=["GET", "POST"]) @dashboard_bp.route("/directory", methods=["GET", "POST"])
@login_required @login_required
@parallel_limiter.lock(only_when=lambda: request.method == "POST")
def directory(): def directory():
dirs = ( dirs = (
Directory.filter_by(user_id=current_user.id) Directory.filter_by(user_id=current_user.id)

View File

@ -150,7 +150,13 @@ def index():
flash(f"Alias {alias.email} has been disabled", "success") flash(f"Alias {alias.email} has been disabled", "success")
return redirect( return redirect(
url_for("dashboard.index", query=query, sort=sort, filter=alias_filter) url_for(
"dashboard.index",
query=query,
sort=sort,
filter=alias_filter,
page=page,
)
) )
mailboxes = current_user.mailboxes() mailboxes = current_user.mailboxes()

View File

@ -2,10 +2,11 @@ import arrow
from flask import render_template, request, redirect, url_for, flash from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user from flask_login import login_required, current_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from itsdangerous import Signer from itsdangerous import TimestampSigner
from wtforms import validators from wtforms import validators, IntegerField
from wtforms.fields.html5 import EmailField from wtforms.fields.html5 import EmailField
from app import parallel_limiter
from app.config import MAILBOX_SECRET, URL, JOB_DELETE_MAILBOX from app.config import MAILBOX_SECRET, URL, JOB_DELETE_MAILBOX
from app.dashboard.base import dashboard_bp from app.dashboard.base import dashboard_bp
from app.db import Session from app.db import Session
@ -27,8 +28,16 @@ class NewMailboxForm(FlaskForm):
) )
class DeleteMailboxForm(FlaskForm):
mailbox_id = IntegerField(
validators=[validators.DataRequired()],
)
transfer_mailbox_id = IntegerField()
@dashboard_bp.route("/mailbox", methods=["GET", "POST"]) @dashboard_bp.route("/mailbox", methods=["GET", "POST"])
@login_required @login_required
@parallel_limiter.lock(only_when=lambda: request.method == "POST")
def mailbox_route(): def mailbox_route():
mailboxes = ( mailboxes = (
Mailbox.filter_by(user_id=current_user.id) Mailbox.filter_by(user_id=current_user.id)
@ -38,28 +47,56 @@ def mailbox_route():
new_mailbox_form = NewMailboxForm() new_mailbox_form = NewMailboxForm()
csrf_form = CSRFValidationForm() csrf_form = CSRFValidationForm()
delete_mailbox_form = DeleteMailboxForm()
if request.method == "POST": if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
if request.form.get("form-name") == "delete": if request.form.get("form-name") == "delete":
mailbox_id = request.form.get("mailbox-id") if not delete_mailbox_form.validate():
mailbox = Mailbox.get(mailbox_id) flash("Invalid request", "warning")
return redirect(request.url)
mailbox = Mailbox.get(delete_mailbox_form.mailbox_id.data)
if not mailbox or mailbox.user_id != current_user.id: if not mailbox or mailbox.user_id != current_user.id:
flash("Unknown error. Refresh the page", "warning") flash("Invalid mailbox. Refresh the page", "warning")
return redirect(url_for("dashboard.mailbox_route")) return redirect(url_for("dashboard.mailbox_route"))
if mailbox.id == current_user.default_mailbox_id: if mailbox.id == current_user.default_mailbox_id:
flash("You cannot delete default mailbox", "error") flash("You cannot delete default mailbox", "error")
return redirect(url_for("dashboard.mailbox_route")) return redirect(url_for("dashboard.mailbox_route"))
transfer_mailbox_id = delete_mailbox_form.transfer_mailbox_id.data
if transfer_mailbox_id and transfer_mailbox_id > 0:
transfer_mailbox = Mailbox.get(transfer_mailbox_id)
if not transfer_mailbox or transfer_mailbox.user_id != current_user.id:
flash(
"You must transfer the aliases to a mailbox you own.", "error"
)
return redirect(url_for("dashboard.mailbox_route"))
if transfer_mailbox.id == mailbox.id:
flash(
"You can not transfer the aliases to the mailbox you want to delete.",
"error",
)
return redirect(url_for("dashboard.mailbox_route"))
if not transfer_mailbox.verified:
flash("Your new mailbox is not verified", "error")
return redirect(url_for("dashboard.mailbox_route"))
# Schedule delete account job # Schedule delete account job
LOG.w("schedule delete mailbox job for %s", mailbox) LOG.w(
f"schedule delete mailbox job for {mailbox.id} with transfer to mailbox {transfer_mailbox_id}"
)
Job.create( Job.create(
name=JOB_DELETE_MAILBOX, name=JOB_DELETE_MAILBOX,
payload={"mailbox_id": mailbox.id}, payload={
"mailbox_id": mailbox.id,
"transfer_mailbox_id": transfer_mailbox_id
if transfer_mailbox_id > 0
else None,
},
run_at=arrow.now(), run_at=arrow.now(),
commit=True, commit=True,
) )
@ -72,7 +109,10 @@ def mailbox_route():
return redirect(url_for("dashboard.mailbox_route")) return redirect(url_for("dashboard.mailbox_route"))
if request.form.get("form-name") == "set-default": if request.form.get("form-name") == "set-default":
mailbox_id = request.form.get("mailbox-id") if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
mailbox_id = request.form.get("mailbox_id")
mailbox = Mailbox.get(mailbox_id) mailbox = Mailbox.get(mailbox_id)
if not mailbox or mailbox.user_id != current_user.id: if not mailbox or mailbox.user_id != current_user.id:
@ -124,7 +164,8 @@ def mailbox_route():
return redirect( return redirect(
url_for( url_for(
"dashboard.mailbox_detail_route", mailbox_id=new_mailbox.id "dashboard.mailbox_detail_route",
mailbox_id=new_mailbox.id,
) )
) )
@ -132,38 +173,13 @@ def mailbox_route():
"dashboard/mailbox.html", "dashboard/mailbox.html",
mailboxes=mailboxes, mailboxes=mailboxes,
new_mailbox_form=new_mailbox_form, new_mailbox_form=new_mailbox_form,
delete_mailbox_form=delete_mailbox_form,
csrf_form=csrf_form, csrf_form=csrf_form,
) )
def delete_mailbox(mailbox_id: int):
from server import create_light_app
with create_light_app().app_context():
mailbox = Mailbox.get(mailbox_id)
if not mailbox:
return
mailbox_email = mailbox.email
user = mailbox.user
Mailbox.delete(mailbox_id)
Session.commit()
LOG.d("Mailbox %s %s deleted", mailbox_id, mailbox_email)
send_email(
user.email,
f"Your mailbox {mailbox_email} has been deleted",
f"""Mailbox {mailbox_email} along with its aliases are deleted successfully.
Regards,
SimpleLogin team.
""",
)
def send_verification_email(user, mailbox): def send_verification_email(user, mailbox):
s = Signer(MAILBOX_SECRET) s = TimestampSigner(MAILBOX_SECRET)
mailbox_id_signed = s.sign(str(mailbox.id)).decode() mailbox_id_signed = s.sign(str(mailbox.id)).decode()
verification_url = ( verification_url = (
URL + "/dashboard/mailbox_verify" + f"?mailbox_id={mailbox_id_signed}" URL + "/dashboard/mailbox_verify" + f"?mailbox_id={mailbox_id_signed}"
@ -188,11 +204,11 @@ def send_verification_email(user, mailbox):
@dashboard_bp.route("/mailbox_verify") @dashboard_bp.route("/mailbox_verify")
def mailbox_verify(): def mailbox_verify():
s = Signer(MAILBOX_SECRET) s = TimestampSigner(MAILBOX_SECRET)
mailbox_id = request.args.get("mailbox_id") mailbox_id = request.args.get("mailbox_id")
try: try:
r_id = int(s.unsign(mailbox_id)) r_id = int(s.unsign(mailbox_id, max_age=900))
except Exception: except Exception:
flash("Invalid link. Please delete and re-add your mailbox", "error") flash("Invalid link. Please delete and re-add your mailbox", "error")
return redirect(url_for("dashboard.mailbox_route")) return redirect(url_for("dashboard.mailbox_route"))

View File

@ -4,7 +4,7 @@ from email_validator import validate_email, EmailNotValidError
from flask import render_template, request, redirect, url_for, flash from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user from flask_login import login_required, current_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from itsdangerous import Signer from itsdangerous import TimestampSigner
from wtforms import validators from wtforms import validators
from wtforms.fields.html5 import EmailField from wtforms.fields.html5 import EmailField
@ -210,7 +210,7 @@ def mailbox_detail_route(mailbox_id):
def verify_mailbox_change(user, mailbox, new_email): def verify_mailbox_change(user, mailbox, new_email):
s = Signer(MAILBOX_SECRET) s = TimestampSigner(MAILBOX_SECRET)
mailbox_id_signed = s.sign(str(mailbox.id)).decode() mailbox_id_signed = s.sign(str(mailbox.id)).decode()
verification_url = ( verification_url = (
f"{URL}/dashboard/mailbox/confirm_change?mailbox_id={mailbox_id_signed}" f"{URL}/dashboard/mailbox/confirm_change?mailbox_id={mailbox_id_signed}"
@ -262,11 +262,11 @@ def cancel_mailbox_change_route(mailbox_id):
@dashboard_bp.route("/mailbox/confirm_change") @dashboard_bp.route("/mailbox/confirm_change")
def mailbox_confirm_change_route(): def mailbox_confirm_change_route():
s = Signer(MAILBOX_SECRET) s = TimestampSigner(MAILBOX_SECRET)
signed_mailbox_id = request.args.get("mailbox_id") signed_mailbox_id = request.args.get("mailbox_id")
try: try:
mailbox_id = int(s.unsign(signed_mailbox_id)) mailbox_id = int(s.unsign(signed_mailbox_id, max_age=900))
except Exception: except Exception:
flash("Invalid link", "error") flash("Invalid link", "error")
return redirect(url_for("dashboard.index")) return redirect(url_for("dashboard.index"))

View File

@ -5,6 +5,7 @@ from app.dashboard.base import dashboard_bp
from app.dashboard.views.enter_sudo import sudo_required from app.dashboard.views.enter_sudo import sudo_required
from app.db import Session from app.db import Session
from app.models import RecoveryCode from app.models import RecoveryCode
from app.utils import CSRFValidationForm
@dashboard_bp.route("/mfa_cancel", methods=["GET", "POST"]) @dashboard_bp.route("/mfa_cancel", methods=["GET", "POST"])
@ -15,8 +16,13 @@ def mfa_cancel():
flash("you don't have MFA enabled", "warning") flash("you don't have MFA enabled", "warning")
return redirect(url_for("dashboard.index")) return redirect(url_for("dashboard.index"))
csrf_form = CSRFValidationForm()
# user cancels TOTP # user cancels TOTP
if request.method == "POST": if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
current_user.enable_otp = False current_user.enable_otp = False
current_user.otp_secret = None current_user.otp_secret = None
Session.commit() Session.commit()
@ -28,4 +34,4 @@ def mfa_cancel():
flash("TOTP is now disabled", "warning") flash("TOTP is now disabled", "warning")
return redirect(url_for("dashboard.index")) return redirect(url_for("dashboard.index"))
return render_template("dashboard/mfa_cancel.html") return render_template("dashboard/mfa_cancel.html", csrf_form=csrf_form)

View File

@ -80,8 +80,9 @@ def pricing():
@dashboard_bp.route("/subscription_success") @dashboard_bp.route("/subscription_success")
@login_required @login_required
def subscription_success(): def subscription_success():
flash("Thanks so much for supporting SimpleLogin!", "success") return render_template(
return redirect(url_for("dashboard.index")) "dashboard/thank-you.html",
)
@dashboard_bp.route("/coinbase_checkout") @dashboard_bp.route("/coinbase_checkout")

View File

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

View File

@ -2,7 +2,10 @@ import re
from flask import render_template, request, redirect, url_for, flash from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import StringField, validators
from app import parallel_limiter
from app.config import MAX_NB_SUBDOMAIN from app.config import MAX_NB_SUBDOMAIN
from app.dashboard.base import dashboard_bp from app.dashboard.base import dashboard_bp
from app.errors import SubdomainInTrashError from app.errors import SubdomainInTrashError
@ -13,8 +16,18 @@ from app.models import CustomDomain, Mailbox, SLDomain
_SUBDOMAIN_PATTERN = r"[0-9a-z-]{1,}" _SUBDOMAIN_PATTERN = r"[0-9a-z-]{1,}"
class NewSubdomainForm(FlaskForm):
domain = StringField(
"domain", validators=[validators.DataRequired(), validators.Length(max=64)]
)
subdomain = StringField(
"subdomain", validators=[validators.DataRequired(), validators.Length(max=64)]
)
@dashboard_bp.route("/subdomain", methods=["GET", "POST"]) @dashboard_bp.route("/subdomain", methods=["GET", "POST"])
@login_required @login_required
@parallel_limiter.lock(only_when=lambda: request.method == "POST")
def subdomain_route(): def subdomain_route():
if not current_user.subdomain_is_available(): if not current_user.subdomain_is_available():
flash("Unknown error, redirect to the home page", "error") flash("Unknown error, redirect to the home page", "error")
@ -26,9 +39,13 @@ def subdomain_route():
).all() ).all()
errors = {} errors = {}
new_subdomain_form = NewSubdomainForm()
if request.method == "POST": if request.method == "POST":
if request.form.get("form-name") == "create": if request.form.get("form-name") == "create":
if not new_subdomain_form.validate():
flash("Invalid new subdomain", "warning")
return redirect(url_for("dashboard.subdomain_route"))
if not current_user.is_premium(): if not current_user.is_premium():
flash("Only premium plan can add subdomain", "warning") flash("Only premium plan can add subdomain", "warning")
return redirect(request.url) return redirect(request.url)
@ -39,8 +56,8 @@ def subdomain_route():
) )
return redirect(request.url) return redirect(request.url)
subdomain = request.form.get("subdomain").lower().strip() subdomain = new_subdomain_form.subdomain.data.lower().strip()
domain = request.form.get("domain").lower().strip() domain = new_subdomain_form.domain.data.lower().strip()
if len(subdomain) < 3: if len(subdomain) < 3:
flash("Subdomain must have at least 3 characters", "error") flash("Subdomain must have at least 3 characters", "error")
@ -108,4 +125,5 @@ def subdomain_route():
sl_domains=sl_domains, sl_domains=sl_domains,
errors=errors, errors=errors,
subdomains=subdomains, subdomains=subdomains,
new_subdomain_form=new_subdomain_form,
) )

View File

@ -60,4 +60,5 @@ E522 = (
) )
E523 = "550 SL E523 Unknown error" E523 = "550 SL E523 Unknown error"
E524 = "550 SL E524 Wrong use of reverse-alias" E524 = "550 SL E524 Wrong use of reverse-alias"
E525 = "550 SL E525 Alias loop"
# endregion # endregion

View File

@ -54,6 +54,7 @@ from app.models import (
IgnoreBounceSender, IgnoreBounceSender,
InvalidMailboxDomain, InvalidMailboxDomain,
VerpType, VerpType,
available_sl_email,
) )
from app.utils import ( from app.utils import (
random_string, random_string,
@ -1043,7 +1044,7 @@ def replace(msg: Union[Message, str], old, new) -> Union[Message, str]:
return msg return msg
def generate_reply_email(contact_email: str, user: User) -> str: def generate_reply_email(contact_email: str, alias: Alias) -> str:
""" """
generate a reply_email (aka reverse-alias), make sure it isn't used by any contact generate a reply_email (aka reverse-alias), make sure it isn't used by any contact
""" """
@ -1054,6 +1055,7 @@ def generate_reply_email(contact_email: str, user: User) -> str:
include_sender_in_reverse_alias = False include_sender_in_reverse_alias = False
user = alias.user
# user has set this option explicitly # user has set this option explicitly
if user.include_sender_in_reverse_alias is not None: if user.include_sender_in_reverse_alias is not None:
include_sender_in_reverse_alias = user.include_sender_in_reverse_alias include_sender_in_reverse_alias = user.include_sender_in_reverse_alias
@ -1068,6 +1070,12 @@ def generate_reply_email(contact_email: str, user: User) -> str:
contact_email = contact_email.replace(".", "_") contact_email = contact_email.replace(".", "_")
contact_email = convert_to_alphanumeric(contact_email) contact_email = convert_to_alphanumeric(contact_email)
reply_domain = config.EMAIL_DOMAIN
alias_domain = get_email_domain_part(alias.email)
sl_domain = SLDomain.get_by(domain=alias_domain)
if sl_domain and sl_domain.use_as_reverse_alias:
reply_domain = alias_domain
# not use while to avoid infinite loop # not use while to avoid infinite loop
for _ in range(1000): for _ in range(1000):
if include_sender_in_reverse_alias and contact_email: if include_sender_in_reverse_alias and contact_email:
@ -1075,15 +1083,15 @@ def generate_reply_email(contact_email: str, user: User) -> str:
reply_email = ( reply_email = (
# do not use the ra+ anymore # do not use the ra+ anymore
# f"ra+{contact_email}+{random_string(random_length)}@{config.EMAIL_DOMAIN}" # f"ra+{contact_email}+{random_string(random_length)}@{config.EMAIL_DOMAIN}"
f"{contact_email}_{random_string(random_length)}@{config.EMAIL_DOMAIN}" f"{contact_email}_{random_string(random_length)}@{reply_domain}"
) )
else: else:
random_length = random.randint(20, 50) random_length = random.randint(20, 50)
# do not use the ra+ anymore # do not use the ra+ anymore
# reply_email = f"ra+{random_string(random_length)}@{config.EMAIL_DOMAIN}" # reply_email = f"ra+{random_string(random_length)}@{config.EMAIL_DOMAIN}"
reply_email = f"{random_string(random_length)}@{config.EMAIL_DOMAIN}" reply_email = f"{random_string(random_length)}@{reply_domain}"
if not Contact.get_by(reply_email=reply_email): if available_sl_email(reply_email):
return reply_email return reply_email
raise Exception("Cannot generate reply email") raise Exception("Cannot generate reply email")

View File

@ -71,7 +71,7 @@ class ErrContactErrorUpgradeNeeded(SLException):
"""raised when user cannot create a contact because the plan doesn't allow it""" """raised when user cannot create a contact because the plan doesn't allow it"""
def error_for_user(self) -> str: 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): class ErrAddressInvalid(SLException):
@ -108,3 +108,8 @@ class AccountAlreadyLinkedToAnotherPartnerException(LinkException):
class AccountAlreadyLinkedToAnotherUserException(LinkException): class AccountAlreadyLinkedToAnotherUserException(LinkException):
def __init__(self): def __init__(self):
super().__init__("This account is linked to another user") 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")

View File

@ -42,9 +42,11 @@ class UnsubscribeLink:
class UnsubscribeEncoder: class UnsubscribeEncoder:
@staticmethod @staticmethod
def encode( def encode(
action: UnsubscribeAction, data: Union[int, UnsubscribeOriginalData] action: UnsubscribeAction,
data: Union[int, UnsubscribeOriginalData],
force_web: bool = False,
) -> UnsubscribeLink: ) -> UnsubscribeLink:
if config.UNSUBSCRIBER: if config.UNSUBSCRIBER and not force_web:
return UnsubscribeLink(UnsubscribeEncoder.encode_mailto(action, data), True) return UnsubscribeLink(UnsubscribeEncoder.encode_mailto(action, data), True)
return UnsubscribeLink(UnsubscribeEncoder.encode_url(action, data), False) return UnsubscribeLink(UnsubscribeEncoder.encode_url(action, data), False)

View File

@ -49,7 +49,7 @@ class UnsubscribeHandler:
return status.E507 return status.E507
mailbox = Mailbox.get_by(email=envelope.mail_from) mailbox = Mailbox.get_by(email=envelope.mail_from)
if not mailbox: if not mailbox:
LOG.w("Unknown mailbox %s", msg[headers.SUBJECT]) LOG.w("Unknown mailbox %s", envelope.mail_from)
return status.E507 return status.E507
if unsub_data.action == UnsubscribeAction.DisableAlias: if unsub_data.action == UnsubscribeAction.DisableAlias:

View File

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

View File

@ -17,7 +17,7 @@ from attr import dataclass
from app import config from app import config
from app.email import headers from app.email import headers
from app.log import LOG from app.log import LOG
from app.message_utils import message_to_bytes from app.message_utils import message_to_bytes, message_format_base64_parts
@dataclass @dataclass
@ -32,6 +32,7 @@ class SendRequest:
rcpt_options: Dict = {} rcpt_options: Dict = {}
is_forward: bool = False is_forward: bool = False
ignore_smtp_errors: bool = False ignore_smtp_errors: bool = False
retries: int = 0
def to_bytes(self) -> bytes: def to_bytes(self) -> bytes:
if not config.SAVE_UNSENT_DIR: if not config.SAVE_UNSENT_DIR:
@ -67,6 +68,30 @@ class SendRequest:
is_forward=decoded_data["is_forward"], is_forward=decoded_data["is_forward"],
) )
def save_request_to_unsent_dir(self, prefix: str = "DeliveryFail"):
file_name = (
f"{prefix}-{int(time.time())}-{uuid.uuid4()}.{SendRequest.SAVE_EXTENSION}"
)
file_path = os.path.join(config.SAVE_UNSENT_DIR, file_name)
self.save_request_to_file(file_path)
@staticmethod
def save_request_to_failed_dir(self, prefix: str = "DeliveryRetryFail"):
file_name = (
f"{prefix}-{int(time.time())}-{uuid.uuid4()}.{SendRequest.SAVE_EXTENSION}"
)
dir_name = os.path.join(config.SAVE_UNSENT_DIR, "failed")
if not os.path.isdir(dir_name):
os.makedirs(dir_name)
file_path = os.path.join(dir_name, file_name)
self.save_request_to_file(file_path)
def save_request_to_file(self, file_path: str):
file_contents = self.to_bytes()
with open(file_path, "wb") as fd:
fd.write(file_contents)
LOG.i(f"Saved unsent message {file_path}")
class MailSender: class MailSender:
def __init__(self): def __init__(self):
@ -117,14 +142,12 @@ class MailSender:
return True return True
def _send_to_smtp(self, send_request: SendRequest, retries: int) -> bool: def _send_to_smtp(self, send_request: SendRequest, retries: int) -> bool:
if config.POSTFIX_SUBMISSION_TLS and config.POSTFIX_PORT == 25:
smtp_port = 587
else:
smtp_port = config.POSTFIX_PORT
try: try:
start = time.time() start = time.time()
with SMTP( with SMTP(
config.POSTFIX_SERVER, smtp_port, timeout=config.POSTFIX_TIMEOUT config.POSTFIX_SERVER,
config.POSTFIX_PORT,
timeout=config.POSTFIX_TIMEOUT,
) as smtp: ) as smtp:
if config.POSTFIX_SUBMISSION_TLS: if config.POSTFIX_SUBMISSION_TLS:
smtp.starttls() smtp.starttls()
@ -170,19 +193,12 @@ class MailSender:
LOG.e(f"Ignore smtp error {e}") LOG.e(f"Ignore smtp error {e}")
return False return False
LOG.e( LOG.e(
f"Could not send message to smtp server {config.POSTFIX_SERVER}:{smtp_port}" 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 return False
def _save_request_to_unsent_dir(self, send_request: SendRequest):
file_name = f"DeliveryFail-{int(time.time())}-{uuid.uuid4()}.{SendRequest.SAVE_EXTENSION}"
file_path = os.path.join(config.SAVE_UNSENT_DIR, file_name)
file_contents = send_request.to_bytes()
with open(file_path, "wb") as fd:
fd.write(file_contents)
LOG.i(f"Saved unsent message {file_path}")
mail_sender = MailSender() mail_sender = MailSender()
@ -216,6 +232,7 @@ def load_unsent_mails_from_fs_and_resend():
LOG.i(f"Trying to re-deliver email {filename}") LOG.i(f"Trying to re-deliver email {filename}")
try: try:
send_request = SendRequest.load_from_file(full_file_path) send_request = SendRequest.load_from_file(full_file_path)
send_request.retries += 1
except Exception as e: except Exception as e:
LOG.e(f"Cannot load {filename}. Error {e}") LOG.e(f"Cannot load {filename}. Error {e}")
continue continue
@ -227,6 +244,11 @@ def load_unsent_mails_from_fs_and_resend():
"DeliverUnsentEmail", {"delivered": "true"} "DeliverUnsentEmail", {"delivered": "true"}
) )
else: else:
if send_request.retries > 2:
os.unlink(full_file_path)
send_request.save_request_to_failed_dir()
else:
send_request.save_request_to_file(full_file_path)
newrelic.agent.record_custom_event( newrelic.agent.record_custom_event(
"DeliverUnsentEmail", {"delivered": "false"} "DeliverUnsentEmail", {"delivered": "false"}
) )
@ -258,7 +280,7 @@ def sl_sendmail(
send_request = SendRequest( send_request = SendRequest(
envelope_from, envelope_from,
envelope_to, envelope_to,
msg, message_format_base64_parts(msg),
mail_options, mail_options,
rcpt_options, rcpt_options,
is_forward, is_forward,

View File

@ -1,21 +1,42 @@
import re
from email import policy from email import policy
from email.message import Message from email.message import Message
from app.email import headers
from app.log import LOG from app.log import LOG
# Spam assassin might flag as spam with a different line length
BASE64_LINELENGTH = 76
def message_to_bytes(msg: Message) -> bytes: def message_to_bytes(msg: Message) -> bytes:
"""replace Message.as_bytes() method by trying different policies""" """replace Message.as_bytes() method by trying different policies"""
for generator_policy in [None, policy.SMTP, policy.SMTPUTF8]: for generator_policy in [None, policy.SMTP, policy.SMTPUTF8]:
try: try:
return msg.as_bytes(policy=generator_policy) return msg.as_bytes(policy=generator_policy)
except: except Exception:
LOG.w("as_bytes() fails with %s policy", policy, exc_info=True) LOG.w("as_bytes() fails with %s policy", policy, exc_info=True)
msg_string = msg.as_string() msg_string = msg.as_string()
try: try:
return msg_string.encode() return msg_string.encode()
except: except Exception:
LOG.w("as_string().encode() fails", exc_info=True) LOG.w("as_string().encode() fails", exc_info=True)
return msg_string.encode(errors="replace") return msg_string.encode(errors="replace")
def message_format_base64_parts(msg: Message) -> Message:
for part in msg.walk():
if part.get(
headers.CONTENT_TRANSFER_ENCODING
) == "base64" and part.get_content_type() in ("text/plain", "text/html"):
# Remove line breaks
body = re.sub("[\r\n]", "", part.get_payload())
# Split in 80 column lines
chunks = [
body[i : i + BASE64_LINELENGTH]
for i in range(0, len(body), BASE64_LINELENGTH)
]
part.set_payload("\r\n".join(chunks))
return msg

View File

@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import base64 import base64
import dataclasses
import enum import enum
import hashlib import hashlib
import hmac import hmac
@ -18,7 +19,7 @@ from flanker.addresslib import address
from flask import url_for from flask import url_for
from flask_login import UserMixin from flask_login import UserMixin
from jinja2 import FileSystemLoader, Environment from jinja2 import FileSystemLoader, Environment
from sqlalchemy import orm from sqlalchemy import orm, or_
from sqlalchemy import text, desc, CheckConstraint, Index, Column from sqlalchemy import text, desc, CheckConstraint, Index, Column
from sqlalchemy.dialects.postgresql import TSVECTOR from sqlalchemy.dialects.postgresql import TSVECTOR
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.ext.declarative import declarative_base
@ -44,7 +45,6 @@ from app.utils import (
random_string, random_string,
random_words, random_words,
sanitize_email, sanitize_email,
random_word,
) )
Base = declarative_base() Base = declarative_base()
@ -274,6 +274,12 @@ class IntEnumType(sa.types.TypeDecorator):
return self._enum_type(enum_value) return self._enum_type(enum_value)
@dataclasses.dataclass
class AliasOptions:
show_sl_domains: bool = True
show_partner_domains: Optional[Partner] = None
class Hibp(Base, ModelMixin): class Hibp(Base, ModelMixin):
__tablename__ = "hibp" __tablename__ = "hibp"
name = sa.Column(sa.String(), nullable=False, unique=True, index=True) name = sa.Column(sa.String(), nullable=False, unique=True, index=True)
@ -292,7 +298,9 @@ class HibpNotifiedAlias(Base, ModelMixin):
""" """
__tablename__ = "hibp_notified_alias" __tablename__ = "hibp_notified_alias"
alias_id = sa.Column(sa.ForeignKey("alias.id", ondelete="cascade"), nullable=False) alias_id = sa.Column(
sa.ForeignKey("alias.id", ondelete="cascade"), nullable=False, index=True
)
user_id = sa.Column(sa.ForeignKey("users.id", ondelete="cascade"), nullable=False) user_id = sa.Column(sa.ForeignKey("users.id", ondelete="cascade"), nullable=False)
notified_at = sa.Column(ArrowType, default=arrow.utcnow, nullable=False) notified_at = sa.Column(ArrowType, default=arrow.utcnow, nullable=False)
@ -420,7 +428,10 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
# newsletter is sent to this address # newsletter is sent to this address
newsletter_alias_id = sa.Column( newsletter_alias_id = sa.Column(
sa.ForeignKey("alias.id", ondelete="SET NULL"), nullable=True, default=None sa.ForeignKey("alias.id", ondelete="SET NULL"),
nullable=True,
default=None,
index=True,
) )
# whether to include the sender address in reverse-alias # whether to include the sender address in reverse-alias
@ -519,7 +530,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
# Keep original unsub behaviour # Keep original unsub behaviour
unsub_behaviour = sa.Column( unsub_behaviour = sa.Column(
IntEnumType(UnsubscribeBehaviourEnum), IntEnumType(UnsubscribeBehaviourEnum),
default=UnsubscribeBehaviourEnum.DisableAlias, default=UnsubscribeBehaviourEnum.PreserveOriginal,
server_default=str(UnsubscribeBehaviourEnum.DisableAlias.value), server_default=str(UnsubscribeBehaviourEnum.DisableAlias.value),
nullable=False, nullable=False,
) )
@ -558,7 +569,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
@classmethod @classmethod
def create(cls, email, name="", password=None, from_partner=False, **kwargs): def create(cls, email, name="", password=None, from_partner=False, **kwargs):
user: User = super(User, cls).create(email=email, name=name, **kwargs) user: User = super(User, cls).create(email=email, name=name[:100], **kwargs)
if password: if password:
user.set_password(password) user.set_password(password)
@ -569,19 +580,6 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
Session.flush() Session.flush()
user.default_mailbox_id = mb.id user.default_mailbox_id = mb.id
# create a first alias mail to show user how to use when they login
alias = Alias.create_new(
user,
prefix="simplelogin-newsletter",
mailbox_id=mb.id,
note="This is your first alias. It's used to receive SimpleLogin communications "
"like new features announcements, newsletters.",
)
Session.flush()
user.newsletter_alias_id = alias.id
Session.flush()
# generate an alternative_id if needed # generate an alternative_id if needed
if "alternative_id" not in kwargs: if "alternative_id" not in kwargs:
user.alternative_id = str(uuid.uuid4()) user.alternative_id = str(uuid.uuid4())
@ -600,6 +598,19 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
Session.flush() Session.flush()
return user return user
# create a first alias mail to show user how to use when they login
alias = Alias.create_new(
user,
prefix="simplelogin-newsletter",
mailbox_id=mb.id,
note="This is your first alias. It's used to receive SimpleLogin communications "
"like new features announcements, newsletters.",
)
Session.flush()
user.newsletter_alias_id = alias.id
Session.flush()
if config.DISABLE_ONBOARDING: if config.DISABLE_ONBOARDING:
LOG.d("Disable onboarding emails") LOG.d("Disable onboarding emails")
return user return user
@ -625,7 +636,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
return user return user
def get_active_subscription( def get_active_subscription(
self, self, include_partner_subscription: bool = True
) -> Optional[ ) -> Optional[
Union[ Union[
Subscription Subscription
@ -653,19 +664,40 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
if coinbase_subscription and coinbase_subscription.is_active(): if coinbase_subscription and coinbase_subscription.is_active():
return coinbase_subscription return coinbase_subscription
partner_sub: PartnerSubscription = PartnerSubscription.find_by_user_id(self.id) if include_partner_subscription:
if partner_sub and partner_sub.is_active(): partner_sub: PartnerSubscription = PartnerSubscription.find_by_user_id(
return partner_sub self.id
)
if partner_sub and partner_sub.is_active():
return partner_sub
return None return None
def get_active_subscription_end(
self, include_partner_subscription: bool = True
) -> Optional[arrow.Arrow]:
sub = self.get_active_subscription(
include_partner_subscription=include_partner_subscription
)
if isinstance(sub, Subscription):
return arrow.get(sub.next_bill_date)
if isinstance(sub, AppleSubscription):
return sub.expires_date
if isinstance(sub, ManualSubscription):
return sub.end_at
if isinstance(sub, CoinbaseSubscription):
return sub.end_at
return None
# region Billing # region Billing
def lifetime_or_active_subscription(self) -> bool: def lifetime_or_active_subscription(
self, include_partner_subscription: bool = True
) -> bool:
"""True if user has lifetime licence or active subscription""" """True if user has lifetime licence or active subscription"""
if self.lifetime: if self.lifetime:
return True return True
return self.get_active_subscription() is not None return self.get_active_subscription(include_partner_subscription) is not None
def is_paid(self) -> bool: def is_paid(self) -> bool:
"""same as _lifetime_or_active_subscription but not include free manual subscription""" """same as _lifetime_or_active_subscription but not include free manual subscription"""
@ -694,14 +726,14 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
return True return True
def is_premium(self) -> bool: def is_premium(self, include_partner_subscription: bool = True) -> bool:
""" """
user is premium if they: user is premium if they:
- have a lifetime deal or - have a lifetime deal or
- in trial period or - in trial period or
- active subscription - active subscription
""" """
if self.lifetime_or_active_subscription(): if self.lifetime_or_active_subscription(include_partner_subscription):
return True return True
if self.trial_end and arrow.now() < self.trial_end: if self.trial_end and arrow.now() < self.trial_end:
@ -868,14 +900,16 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
def custom_domains(self): def custom_domains(self):
return CustomDomain.filter_by(user_id=self.id, verified=True).all() return CustomDomain.filter_by(user_id=self.id, verified=True).all()
def available_domains_for_random_alias(self) -> List[Tuple[bool, str]]: def available_domains_for_random_alias(
self, alias_options: Optional[AliasOptions] = None
) -> List[Tuple[bool, str]]:
"""Return available domains for user to create random aliases """Return available domains for user to create random aliases
Each result record contains: Each result record contains:
- whether the domain belongs to SimpleLogin - whether the domain belongs to SimpleLogin
- the domain - the domain
""" """
res = [] res = []
for domain in self.available_sl_domains(): for domain in self.available_sl_domains(alias_options=alias_options):
res.append((True, domain)) res.append((True, domain))
for custom_domain in self.verified_custom_domains(): for custom_domain in self.verified_custom_domains():
@ -960,30 +994,55 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
return None, "", False return None, "", False
def available_sl_domains(self) -> [str]: def available_sl_domains(
self, alias_options: Optional[AliasOptions] = None
) -> [str]:
""" """
Return all SimpleLogin domains that user can use when creating a new alias, including: Return all SimpleLogin domains that user can use when creating a new alias, including:
- SimpleLogin public domains, available for all users (ALIAS_DOMAIN) - SimpleLogin public domains, available for all users (ALIAS_DOMAIN)
- SimpleLogin premium domains, only available for Premium accounts (PREMIUM_ALIAS_DOMAIN) - SimpleLogin premium domains, only available for Premium accounts (PREMIUM_ALIAS_DOMAIN)
""" """
return [sl_domain.domain for sl_domain in self.get_sl_domains()] return [
sl_domain.domain
for sl_domain in self.get_sl_domains(alias_options=alias_options)
]
def get_sl_domains(self) -> List["SLDomain"]: def get_sl_domains(
query = SLDomain.filter_by(hidden=False).order_by(SLDomain.order) self, alias_options: Optional[AliasOptions] = None
) -> list["SLDomain"]:
if self.is_premium(): if alias_options is None:
return query.all() 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 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: 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: """return all domains that user can use when creating a new alias, including:
- SimpleLogin public domains, available for all users (ALIAS_DOMAIN) - SimpleLogin public domains, available for all users (ALIAS_DOMAIN)
- SimpleLogin premium domains, only available for Premium accounts (PREMIUM_ALIAS_DOMAIN) - SimpleLogin premium domains, only available for Premium accounts (PREMIUM_ALIAS_DOMAIN)
- Verified custom domains - Verified custom domains
""" """
domains = self.available_sl_domains() domains = self.available_sl_domains(alias_options=alias_options)
for custom_domain in self.verified_custom_domains(): for custom_domain in self.verified_custom_domains():
domains.append(custom_domain.domain) domains.append(custom_domain.domain)
@ -1001,16 +1060,21 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
> 0 > 0
) )
def get_random_alias_suffix(self): def get_random_alias_suffix(self, custom_domain: Optional["CustomDomain"] = None):
"""Get random suffix for an alias based on user's preference. """Get random suffix for an alias based on user's preference.
Use a shorter suffix in case of custom domain
Returns: Returns:
str: the random suffix generated str: the random suffix generated
""" """
if self.random_alias_suffix == AliasSuffixEnum.random_string.value: if self.random_alias_suffix == AliasSuffixEnum.random_string.value:
return random_string(config.ALIAS_RANDOM_SUFFIX_LENGTH, include_digits=True) return random_string(config.ALIAS_RANDOM_SUFFIX_LENGTH, include_digits=True)
return random_word()
if custom_domain is None:
return random_words(1, 3)
return random_words(1)
def __repr__(self): def __repr__(self):
return f"<User {self.id} {self.name} {self.email}>" return f"<User {self.id} {self.name} {self.email}>"
@ -1255,34 +1319,48 @@ class OauthToken(Base, ModelMixin):
return self.expired < arrow.now() return self.expired < arrow.now()
def generate_email( def available_sl_email(email: str) -> bool:
if (
Alias.get_by(email=email)
or Contact.get_by(reply_email=email)
or DeletedAlias.get_by(email=email)
):
return False
return True
def generate_random_alias_email(
scheme: int = AliasGeneratorEnum.word.value, scheme: int = AliasGeneratorEnum.word.value,
in_hex: bool = False, in_hex: bool = False,
alias_domain=config.FIRST_ALIAS_DOMAIN, alias_domain: str = config.FIRST_ALIAS_DOMAIN,
retries: int = 10,
) -> str: ) -> str:
"""generate an email address that does not exist before """generate an email address that does not exist before
:param alias_domain: the domain used to generate the alias. :param alias_domain: the domain used to generate the alias.
:param scheme: int, value of AliasGeneratorEnum, indicate how the email is generated :param scheme: int, value of AliasGeneratorEnum, indicate how the email is generated
:param retries: int, How many times we can try to generate an alias in case of collision
:type in_hex: bool, if the generate scheme is uuid, is hex favorable? :type in_hex: bool, if the generate scheme is uuid, is hex favorable?
""" """
if retries <= 0:
raise Exception("Cannot generate alias after many retries")
if scheme == AliasGeneratorEnum.uuid.value: if scheme == AliasGeneratorEnum.uuid.value:
name = uuid.uuid4().hex if in_hex else uuid.uuid4().__str__() name = uuid.uuid4().hex if in_hex else uuid.uuid4().__str__()
random_email = name + "@" + alias_domain random_email = name + "@" + alias_domain
else: else:
random_email = random_words() + "@" + alias_domain random_email = random_words(2, 3) + "@" + alias_domain
random_email = random_email.lower().strip() random_email = random_email.lower().strip()
# check that the client does not exist yet # check that the client does not exist yet
if not Alias.get_by(email=random_email) and not DeletedAlias.get_by( if available_sl_email(random_email):
email=random_email
):
LOG.d("generate email %s", random_email) LOG.d("generate email %s", random_email)
return random_email return random_email
# Rerun the function # Rerun the function
LOG.w("email %s already exists, generate a new email", random_email) LOG.w("email %s already exists, generate a new email", random_email)
return generate_email(scheme=scheme, in_hex=in_hex) return generate_random_alias_email(
scheme=scheme, in_hex=in_hex, retries=retries - 1
)
class Alias(Base, ModelMixin): class Alias(Base, ModelMixin):
@ -1481,7 +1559,7 @@ class Alias(Base, ModelMixin):
suffix = user.get_random_alias_suffix() suffix = user.get_random_alias_suffix()
email = f"{prefix}.{suffix}@{config.FIRST_ALIAS_DOMAIN}" email = f"{prefix}.{suffix}@{config.FIRST_ALIAS_DOMAIN}"
if not cls.get_by(email=email) and not DeletedAlias.get_by(email=email): if available_sl_email(email):
break break
return Alias.create( return Alias.create(
@ -1510,7 +1588,7 @@ class Alias(Base, ModelMixin):
if user.default_alias_custom_domain_id: if user.default_alias_custom_domain_id:
custom_domain = CustomDomain.get(user.default_alias_custom_domain_id) custom_domain = CustomDomain.get(user.default_alias_custom_domain_id)
random_email = generate_email( random_email = generate_random_alias_email(
scheme=scheme, in_hex=in_hex, alias_domain=custom_domain.domain scheme=scheme, in_hex=in_hex, alias_domain=custom_domain.domain
) )
elif user.default_alias_public_domain_id: elif user.default_alias_public_domain_id:
@ -1518,12 +1596,12 @@ class Alias(Base, ModelMixin):
if sl_domain.premium_only and not user.is_premium(): if sl_domain.premium_only and not user.is_premium():
LOG.w("%s not premium, cannot use %s", user, sl_domain) LOG.w("%s not premium, cannot use %s", user, sl_domain)
else: else:
random_email = generate_email( random_email = generate_random_alias_email(
scheme=scheme, in_hex=in_hex, alias_domain=sl_domain.domain scheme=scheme, in_hex=in_hex, alias_domain=sl_domain.domain
) )
if not random_email: if not random_email:
random_email = generate_email(scheme=scheme, in_hex=in_hex) random_email = generate_random_alias_email(scheme=scheme, in_hex=in_hex)
alias = Alias.create( alias = Alias.create(
user_id=user.id, user_id=user.id,
@ -1557,7 +1635,9 @@ class ClientUser(Base, ModelMixin):
client_id = sa.Column(sa.ForeignKey(Client.id, ondelete="cascade"), nullable=False) client_id = sa.Column(sa.ForeignKey(Client.id, ondelete="cascade"), nullable=False)
# Null means client has access to user original email # Null means client has access to user original email
alias_id = sa.Column(sa.ForeignKey(Alias.id, ondelete="cascade"), nullable=True) alias_id = sa.Column(
sa.ForeignKey(Alias.id, ondelete="cascade"), nullable=True, index=True
)
# user can decide to send to client another name # user can decide to send to client another name
name = sa.Column( name = sa.Column(
@ -1641,6 +1721,8 @@ class Contact(Base, ModelMixin):
Store configuration of sender (website-email) and alias. Store configuration of sender (website-email) and alias.
""" """
MAX_NAME_LENGTH = 512
__tablename__ = "contact" __tablename__ = "contact"
__table_args__ = ( __table_args__ = (
@ -1674,7 +1756,7 @@ class Contact(Base, ModelMixin):
is_cc = sa.Column(sa.Boolean, nullable=False, default=False, server_default="0") is_cc = sa.Column(sa.Boolean, nullable=False, default=False, server_default="0")
pgp_public_key = sa.Column(sa.Text, nullable=True) pgp_public_key = sa.Column(sa.Text, nullable=True)
pgp_finger_print = sa.Column(sa.String(512), nullable=True) pgp_finger_print = sa.Column(sa.String(512), nullable=True, index=True)
alias = orm.relationship(Alias, backref="contacts") alias = orm.relationship(Alias, backref="contacts")
user = orm.relationship(User) user = orm.relationship(User)
@ -2085,7 +2167,9 @@ class AliasUsedOn(Base, ModelMixin):
sa.UniqueConstraint("alias_id", "hostname", name="uq_alias_used"), sa.UniqueConstraint("alias_id", "hostname", name="uq_alias_used"),
) )
alias_id = sa.Column(sa.ForeignKey(Alias.id, ondelete="cascade"), nullable=False) alias_id = sa.Column(
sa.ForeignKey(Alias.id, ondelete="cascade"), nullable=False, index=True
)
user_id = sa.Column(sa.ForeignKey(User.id, ondelete="cascade"), nullable=False) user_id = sa.Column(sa.ForeignKey(User.id, ondelete="cascade"), nullable=False)
alias = orm.relationship(Alias) alias = orm.relationship(Alias)
@ -2762,6 +2846,31 @@ class Notification(Base, ModelMixin):
) )
class Partner(Base, ModelMixin):
__tablename__ = "partner"
name = sa.Column(sa.String(128), unique=True, nullable=False)
contact_email = sa.Column(sa.String(128), unique=True, nullable=False)
@staticmethod
def find_by_token(token: str) -> Optional[Partner]:
hmaced = PartnerApiToken.hmac_token(token)
res = (
Session.query(Partner, PartnerApiToken)
.filter(
and_(
PartnerApiToken.token == hmaced,
Partner.id == PartnerApiToken.partner_id,
)
)
.first()
)
if res:
partner, partner_api_token = res
return partner
return None
class SLDomain(Base, ModelMixin): class SLDomain(Base, ModelMixin):
"""SimpleLogin domains""" """SimpleLogin domains"""
@ -2779,12 +2888,23 @@ class SLDomain(Base, ModelMixin):
sa.Boolean, nullable=False, default=False, server_default="0" sa.Boolean, nullable=False, default=False, server_default="0"
) )
partner_id = sa.Column(
sa.ForeignKey(Partner.id, ondelete="cascade"),
nullable=True,
default=None,
server_default="NULL",
)
# if enabled, do not show this domain when user creates a custom alias # if enabled, do not show this domain when user creates a custom alias
hidden = sa.Column(sa.Boolean, nullable=False, default=False, server_default="0") hidden = sa.Column(sa.Boolean, nullable=False, default=False, server_default="0")
# the order in which the domains are shown when user creates a custom alias # the order in which the domains are shown when user creates a custom alias
order = sa.Column(sa.Integer, nullable=False, default=0, server_default="0") order = sa.Column(sa.Integer, nullable=False, default=0, server_default="0")
use_as_reverse_alias = sa.Column(
sa.Boolean, nullable=False, default=False, server_default="0"
)
def __repr__(self): def __repr__(self):
return f"<SLDomain {self.domain} {'Premium' if self.premium_only else 'Free'}" return f"<SLDomain {self.domain} {'Premium' if self.premium_only else 'Free'}"
@ -3225,31 +3345,6 @@ class ProviderComplaint(Base, ModelMixin):
refused_email = orm.relationship(RefusedEmail, foreign_keys=[refused_email_id]) refused_email = orm.relationship(RefusedEmail, foreign_keys=[refused_email_id])
class Partner(Base, ModelMixin):
__tablename__ = "partner"
name = sa.Column(sa.String(128), unique=True, nullable=False)
contact_email = sa.Column(sa.String(128), unique=True, nullable=False)
@staticmethod
def find_by_token(token: str) -> Optional[Partner]:
hmaced = PartnerApiToken.hmac_token(token)
res = (
Session.query(Partner, PartnerApiToken)
.filter(
and_(
PartnerApiToken.token == hmaced,
Partner.id == PartnerApiToken.partner_id,
)
)
.first()
)
if res:
partner, partner_api_token = res
return partner
return None
class PartnerApiToken(Base, ModelMixin): class PartnerApiToken(Base, ModelMixin):
__tablename__ = "partner_api_token" __tablename__ = "partner_api_token"
@ -3319,7 +3414,7 @@ class PartnerSubscription(Base, ModelMixin):
) )
# when the partner subscription ends # when the partner subscription ends
end_at = sa.Column(ArrowType, nullable=False) end_at = sa.Column(ArrowType, nullable=False, index=True)
partner_user = orm.relationship(PartnerUser) partner_user = orm.relationship(PartnerUser)

View File

@ -27,13 +27,15 @@ def send_newsletter_to_user(newsletter, user) -> (bool, str):
comm_alias_id = comm_alias.id comm_alias_id = comm_alias.id
unsubscribe_oneclick = unsubscribe_link unsubscribe_oneclick = unsubscribe_link
if via_email: if via_email and comm_alias_id > -1:
unsubscribe_oneclick = UnsubscribeEncoder.encode( unsubscribe_oneclick = UnsubscribeEncoder.encode(
UnsubscribeAction.DisableAlias, comm_alias_id UnsubscribeAction.DisableAlias,
) comm_alias_id,
force_web=True,
).link
send_email( send_email(
comm_alias.email, comm_email,
newsletter.subject, newsletter.subject,
text_template.render( text_template.render(
user=user, user=user,

View File

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

View File

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

View File

@ -1,3 +1,4 @@
import random
import re import re
import secrets import secrets
import string import string
@ -25,11 +26,16 @@ def word_exist(word):
return word in _words 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""" """Generate a random words. Used to generate user-facing string, for ex email addresses"""
# nb_words = random.randint(2, 3) # nb_words = random.randint(2, 3)
nb_words = 2 fields = [secrets.choice(_words) for i in range(words)]
return "_".join([secrets.choice(_words) for i in range(nb_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): def random_string(length=10, include_digits=False):

View File

@ -15,6 +15,7 @@
- [GET /api/user/cookie_token](#get-apiusercookie_token): Get a one time use token to exchange it for a valid cookie - [GET /api/user/cookie_token](#get-apiusercookie_token): Get a one time use token to exchange it for a valid cookie
- [PATCH /api/user_info](#patch-apiuser_info): Update user's information. - [PATCH /api/user_info](#patch-apiuser_info): Update user's information.
- [POST /api/api_key](#post-apiapi_key): Create a new API key. - [POST /api/api_key](#post-apiapi_key): Create a new API key.
- [GET /api/stats](#get-apistats): Get user's stats.
- [GET /api/logout](#get-apilogout): Log out. - [GET /api/logout](#get-apilogout): Log out.
[Alias endpoints](#alias-endpoints) [Alias endpoints](#alias-endpoints)
@ -226,6 +227,22 @@ Input:
Output: same as GET /api/user_info Output: same as GET /api/user_info
#### GET /api/stats
Given the API Key, return stats about the number of aliases, number of emails forwarded/replied/blocked
Input:
- `Authentication` header that contains the api key
Output: if api key is correct, return a json with the following fields:
```json
{"nb_alias": 1, "nb_block": 0, "nb_forward": 0, "nb_reply": 0}
```
If api key is incorrect, return 401.
#### PATCH /api/sudo #### PATCH /api/sudo
Enable sudo mode Enable sudo mode
@ -387,7 +404,7 @@ Input:
- `Authentication` header that contains the api key - `Authentication` header that contains the api key
- (Optional but recommended) `hostname` passed in query string - (Optional but recommended) `hostname` passed in query string
- (Optional) mode: either `uuid` or `word`. By default, use the user setting when creating new random alias. - (Optional) mode: either `uuid` or `word` passed in query string. By default, use the user setting when creating new random alias.
- Request Message Body in json (`Content-Type` is `application/json`) - Request Message Body in json (`Content-Type` is `application/json`)
- (Optional) note: alias note - (Optional) note: alias note
@ -694,7 +711,7 @@ Return 200 and `existed=true` if contact is already added.
It can return 403 with an error if the user cannot create reverse alias. It can return 403 with an error if the user cannot create reverse alias.
``json ```json
{ {
"error": "Please upgrade to create a reverse-alias" "error": "Please upgrade to create a reverse-alias"
} }
@ -764,6 +781,7 @@ Input:
- `Authentication` header that contains the api key - `Authentication` header that contains the api key
- `mailbox_id`: in url - `mailbox_id`: in url
- (optional) `transfer_aliases_to`: in body as json. id of the new mailbox for the aliases. If omitted or set to -1, the aliases will be delete with the mailbox.
Output: Output:

View File

@ -161,6 +161,7 @@ from app.models import (
MessageIDMatching, MessageIDMatching,
Notification, Notification,
VerpType, VerpType,
SLDomain,
) )
from app.pgp_utils import ( from app.pgp_utils import (
PGPException, PGPException,
@ -168,7 +169,7 @@ from app.pgp_utils import (
sign_data, sign_data,
load_public_key_and_check, load_public_key_and_check,
) )
from app.utils import sanitize_email from app.utils import sanitize_email, canonicalize_email
from init_app import load_pgp_public_keys from init_app import load_pgp_public_keys
from server import create_light_app from server import create_light_app
@ -182,6 +183,10 @@ def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Con
except ValueError: except ValueError:
contact_name, contact_email = "", "" contact_name, contact_email = "", ""
# Ensure contact_name is within limits
if len(contact_name) >= Contact.MAX_NAME_LENGTH:
contact_name = contact_name[0 : Contact.MAX_NAME_LENGTH]
if not is_valid_email(contact_email): if not is_valid_email(contact_email):
# From header is wrongly formatted, try with mail_from # From header is wrongly formatted, try with mail_from
if mail_from and mail_from != "<>": if mail_from and mail_from != "<>":
@ -239,7 +244,7 @@ def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Con
website_email=contact_email, website_email=contact_email,
name=contact_name, name=contact_name,
mail_from=mail_from, mail_from=mail_from,
reply_email=generate_reply_email(contact_email, alias.user) reply_email=generate_reply_email(contact_email, alias)
if is_valid_email(contact_email) if is_valid_email(contact_email)
else NOREPLY, else NOREPLY,
automatic_created=True, automatic_created=True,
@ -300,7 +305,7 @@ def get_or_create_reply_to_contact(
alias_id=alias.id, alias_id=alias.id,
website_email=contact_address, website_email=contact_address,
name=contact_name, name=contact_name,
reply_email=generate_reply_email(contact_address, alias.user), reply_email=generate_reply_email(contact_address, alias),
automatic_created=True, automatic_created=True,
) )
Session.commit() Session.commit()
@ -368,7 +373,7 @@ def replace_header_when_forward(msg: Message, alias: Alias, header: str):
alias_id=alias.id, alias_id=alias.id,
website_email=contact_email, website_email=contact_email,
name=full_address.display_name, name=full_address.display_name,
reply_email=generate_reply_email(contact_email, alias.user), reply_email=generate_reply_email(contact_email, alias),
is_cc=header.lower() == "cc", is_cc=header.lower() == "cc",
automatic_created=True, automatic_created=True,
) )
@ -689,6 +694,36 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str
LOG.d("%s unverified, do not forward", mailbox) LOG.d("%s unverified, do not forward", mailbox)
ret.append((False, status.E517)) ret.append((False, status.E517))
else: 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 # create a copy of message for each forward
ret.append( ret.append(
forward_email_to_mailbox( forward_email_to_mailbox(
@ -836,10 +871,12 @@ def forward_email_to_mailbox(
orig_subject = msg[headers.SUBJECT] orig_subject = msg[headers.SUBJECT]
orig_subject = get_header_unicode(orig_subject) orig_subject = get_header_unicode(orig_subject)
add_or_replace_header(msg, "Subject", mailbox.generic_subject) add_or_replace_header(msg, "Subject", mailbox.generic_subject)
sender = msg[headers.FROM]
sender = get_header_unicode(sender)
msg = add_header( msg = add_header(
msg, msg,
f"""Forwarded by SimpleLogin to {alias.email} with "{orig_subject}" as subject""", f"""Forwarded by SimpleLogin to {alias.email} from "{sender}" with "{orig_subject}" as subject""",
f"""Forwarded by SimpleLogin to {alias.email} with <b>{orig_subject}</b> as subject""", f"""Forwarded by SimpleLogin to {alias.email} from "{sender}" with <b>{orig_subject}</b> as subject""",
) )
try: try:
@ -909,10 +946,11 @@ def forward_email_to_mailbox(
envelope.rcpt_options, envelope.rcpt_options,
) )
contact_domain = get_email_domain_part(contact.reply_email)
try: try:
sl_sendmail( sl_sendmail(
# use a different envelope sender for each forward (aka VERP) # use a different envelope sender for each forward (aka VERP)
generate_verp_email(VerpType.bounce_forward, email_log.id), generate_verp_email(VerpType.bounce_forward, email_log.id, contact_domain),
mailbox.email, mailbox.email,
msg, msg,
envelope.mail_options, envelope.mail_options,
@ -981,10 +1019,14 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str):
reply_email = rcpt_to reply_email = rcpt_to
# reply_email must end with EMAIL_DOMAIN reply_domain = get_email_domain_part(reply_email)
# reply_email must end with EMAIL_DOMAIN or a domain that can be used as reverse alias domain
if not reply_email.endswith(EMAIL_DOMAIN): if not reply_email.endswith(EMAIL_DOMAIN):
LOG.w(f"Reply email {reply_email} has wrong domain") sl_domain: SLDomain = SLDomain.get_by(domain=reply_domain)
return False, status.E501 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 # handle case where reply email is generated with non-allowed char
reply_email = normalize_reply_email(reply_email) reply_email = normalize_reply_email(reply_email)
@ -996,7 +1038,7 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str):
alias = contact.alias alias = contact.alias
alias_address: str = contact.alias.email alias_address: str = contact.alias.email
alias_domain = alias_address[alias_address.find("@") + 1 :] alias_domain = get_email_domain_part(alias_address)
# Sanity check: verify alias domain is managed by SimpleLogin # Sanity check: verify alias domain is managed by SimpleLogin
# scenario: a user have removed a domain but due to a bug, the aliases are still there # scenario: a user have removed a domain but due to a bug, the aliases are still there
@ -1384,21 +1426,26 @@ def get_mailbox_from_mail_from(mail_from: str, alias) -> Optional[Mailbox]:
"""return the corresponding mailbox given the mail_from and alias """return the corresponding mailbox given the mail_from and alias
Usually the mail_from=mailbox.email but it can also be one of the authorized address Usually the mail_from=mailbox.email but it can also be one of the authorized address
""" """
for mailbox in alias.mailboxes:
if mailbox.email == mail_from:
return mailbox
for authorized_address in mailbox.authorized_addresses: def __check(email_address: str, alias: Alias) -> Optional[Mailbox]:
if authorized_address.email == mail_from: for mailbox in alias.mailboxes:
LOG.d( if mailbox.email == email_address:
"Found an authorized address for %s %s %s",
alias,
mailbox,
authorized_address,
)
return mailbox return mailbox
return None for authorized_address in mailbox.authorized_addresses:
if authorized_address.email == email_address:
LOG.d(
"Found an authorized address for %s %s %s",
alias,
mailbox,
authorized_address,
)
return mailbox
return None
# We need to first check for the uncanonicalized version because we still have users in the db with the
# email non canonicalized. So if it matches the already existing one use that, otherwise check the canonical one
return __check(mail_from, alias) or __check(canonicalize_email(mail_from), alias)
def handle_unknown_mailbox( def handle_unknown_mailbox(

View File

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

View File

@ -124,6 +124,58 @@ def welcome_proton(user):
) )
def delete_mailbox_job(job: Job):
mailbox_id = job.payload.get("mailbox_id")
mailbox = Mailbox.get(mailbox_id)
if not mailbox:
return
transfer_mailbox_id = job.payload.get("transfer_mailbox_id")
alias_transferred_to = None
if transfer_mailbox_id:
transfer_mailbox = Mailbox.get(transfer_mailbox_id)
if transfer_mailbox:
alias_transferred_to = transfer_mailbox.email
for alias in mailbox.aliases:
if alias.mailbox_id == mailbox.id:
alias.mailbox_id = transfer_mailbox.id
if transfer_mailbox in alias._mailboxes:
alias._mailboxes.remove(transfer_mailbox)
else:
alias._mailboxes.remove(mailbox)
if transfer_mailbox not in alias._mailboxes:
alias._mailboxes.append(transfer_mailbox)
Session.commit()
mailbox_email = mailbox.email
user = mailbox.user
Mailbox.delete(mailbox_id)
Session.commit()
LOG.d("Mailbox %s %s deleted", mailbox_id, mailbox_email)
if alias_transferred_to:
send_email(
user.email,
f"Your mailbox {mailbox_email} has been deleted",
f"""Mailbox {mailbox_email} and its alias have been transferred to {alias_transferred_to}.
Regards,
SimpleLogin team.
""",
retries=3,
)
else:
send_email(
user.email,
f"Your mailbox {mailbox_email} has been deleted",
f"""Mailbox {mailbox_email} along with its aliases have been deleted successfully.
Regards,
SimpleLogin team.
""",
retries=3,
)
def process_job(job: Job): def process_job(job: Job):
if job.name == config.JOB_ONBOARDING_1: if job.name == config.JOB_ONBOARDING_1:
user_id = job.payload.get("user_id") user_id = job.payload.get("user_id")
@ -178,27 +230,7 @@ def process_job(job: Job):
retries=3, retries=3,
) )
elif job.name == config.JOB_DELETE_MAILBOX: elif job.name == config.JOB_DELETE_MAILBOX:
mailbox_id = job.payload.get("mailbox_id") delete_mailbox_job(job)
mailbox = Mailbox.get(mailbox_id)
if not mailbox:
return
mailbox_email = mailbox.email
user = mailbox.user
Mailbox.delete(mailbox_id)
Session.commit()
LOG.d("Mailbox %s %s deleted", mailbox_id, mailbox_email)
send_email(
user.email,
f"Your mailbox {mailbox_email} has been deleted",
f"""Mailbox {mailbox_email} along with its aliases are deleted successfully.
Regards,
SimpleLogin team.
""",
retries=3,
)
elif job.name == config.JOB_DELETE_DOMAIN: elif job.name == config.JOB_DELETE_DOMAIN:
custom_domain_id = job.payload.get("custom_domain_id") custom_domain_id = job.payload.get("custom_domain_id")

View File

@ -3552,7 +3552,6 @@ impute
inaner inaner
inborn inborn
inbred inbred
incest
inched inched
inches inches
incing incing

File diff suppressed because it is too large Load Diff

View File

@ -149803,11 +149803,6 @@ incessant
incessantly incessantly
incessantness incessantness
incession incession
incest
incests
incestuous
incestuously
incestuousness
incgrporate incgrporate
inch inch
inchain inchain
@ -204633,9 +204628,6 @@ nonincandescent
nonincandescently nonincandescently
nonincarnate nonincarnate
nonincarnated nonincarnated
nonincestuous
nonincestuously
nonincestuousness
nonincident nonincident
nonincidental nonincidental
nonincidentally nonincidentally
@ -344408,8 +344400,6 @@ unincarnated
unincensed unincensed
uninceptive uninceptive
uninceptively uninceptively
unincestuous
unincestuously
uninchoative uninchoative
unincidental unincidental
unincidentally unincidentally

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
""" """
This is an example on how to integrate SimpleLogin This is an example on how to integrate SimpleLogin
with Requests-OAuthlib, a popular library to work with OAuth in Python. 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 This example is based on
https://requests-oauthlib.readthedocs.io/en/latest/examples/real_world_example.html https://requests-oauthlib.readthedocs.io/en/latest/examples/real_world_example.html
""" """

4167
app/poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -95,13 +95,13 @@ webauthn = "^0.4.7"
pyspf = "^2.0.14" pyspf = "^2.0.14"
Flask-Limiter = "^1.4" Flask-Limiter = "^1.4"
memory_profiler = "^0.57.0" memory_profiler = "^0.57.0"
gevent = "^21.12.0" gevent = "22.10.2"
aiospamc = "^0.6.1" aiospamc = "^0.6.1"
email_validator = "^1.1.1" email_validator = "^1.1.1"
PGPy = "0.5.4" PGPy = "0.5.4"
coinbase-commerce = "^1.0.1" coinbase-commerce = "^1.0.1"
requests = "^2.25.1" requests = "^2.25.1"
newrelic = "^7.10.0" newrelic = "8.8.0"
flanker = "^0.9.11" flanker = "^0.9.11"
pyre2 = "^0.3.6" pyre2 = "^0.3.6"
tldextract = "^3.1.2" tldextract = "^3.1.2"
@ -110,7 +110,7 @@ twilio = "^7.3.2"
Deprecated = "^1.2.13" Deprecated = "^1.2.13"
cryptography = "37.0.1" cryptography = "37.0.1"
SQLAlchemy = "1.3.24" SQLAlchemy = "1.3.24"
redis = "^4.3.4" redis = "^4.5.3"
[tool.poetry.dev-dependencies] [tool.poetry.dev-dependencies]
pytest = "^7.0.0" pytest = "^7.0.0"

View File

@ -44,6 +44,7 @@ from app.admin_model import (
NewsletterUserAdmin, NewsletterUserAdmin,
DailyMetricAdmin, DailyMetricAdmin,
MetricAdmin, MetricAdmin,
InvalidMailboxDomainAdmin,
) )
from app.api.base import api_bp from app.api.base import api_bp
from app.auth.base import auth_bp from app.auth.base import auth_bp
@ -78,6 +79,7 @@ from app.config import (
MEM_STORE_URI, MEM_STORE_URI,
) )
from app.dashboard.base import dashboard_bp from app.dashboard.base import dashboard_bp
from app.subscription_webhook import execute_subscription_webhook
from app.db import Session from app.db import Session
from app.developer.base import developer_bp from app.developer.base import developer_bp
from app.discover.base import discover_bp from app.discover.base import discover_bp
@ -105,6 +107,7 @@ from app.models import (
NewsletterUser, NewsletterUser,
DailyMetric, DailyMetric,
Metric2, Metric2,
InvalidMailboxDomain,
) )
from app.monitor.base import monitor_bp from app.monitor.base import monitor_bp
from app.newsletter_utils import send_newsletter_to_user from app.newsletter_utils import send_newsletter_to_user
@ -489,6 +492,7 @@ def setup_paddle_callback(app: Flask):
# in case user cancels a plan and subscribes a new plan # in case user cancels a plan and subscribes a new plan
sub.cancelled = False sub.cancelled = False
execute_subscription_webhook(user)
LOG.d("User %s upgrades!", user) LOG.d("User %s upgrades!", user)
Session.commit() Session.commit()
@ -507,6 +511,7 @@ def setup_paddle_callback(app: Flask):
).date() ).date()
Session.commit() Session.commit()
execute_subscription_webhook(sub.user)
elif request.form.get("alert_name") == "subscription_cancelled": elif request.form.get("alert_name") == "subscription_cancelled":
subscription_id = request.form.get("subscription_id") subscription_id = request.form.get("subscription_id")
@ -536,6 +541,7 @@ def setup_paddle_callback(app: Flask):
end_date=request.form.get("cancellation_effective_date"), end_date=request.form.get("cancellation_effective_date"),
), ),
) )
execute_subscription_webhook(sub.user)
else: else:
# user might have deleted their account # user might have deleted their account
@ -578,6 +584,7 @@ def setup_paddle_callback(app: Flask):
sub.cancelled = False sub.cancelled = False
Session.commit() Session.commit()
execute_subscription_webhook(sub.user)
else: else:
LOG.w( LOG.w(
f"update non-exist subscription {subscription_id}. {request.form}" f"update non-exist subscription {subscription_id}. {request.form}"
@ -594,6 +601,7 @@ def setup_paddle_callback(app: Flask):
Subscription.delete(sub.id) Subscription.delete(sub.id)
Session.commit() Session.commit()
LOG.e("%s requests a refund", user) LOG.e("%s requests a refund", user)
execute_subscription_webhook(sub.user)
elif request.form.get("alert_name") == "subscription_payment_refunded": elif request.form.get("alert_name") == "subscription_payment_refunded":
subscription_id = request.form.get("subscription_id") subscription_id = request.form.get("subscription_id")
@ -627,6 +635,7 @@ def setup_paddle_callback(app: Flask):
LOG.e("Unknown plan_id %s", plan_id) LOG.e("Unknown plan_id %s", plan_id)
else: else:
LOG.w("partial subscription_payment_refunded, not handled") LOG.w("partial subscription_payment_refunded, not handled")
execute_subscription_webhook(sub.user)
return "OK" return "OK"
@ -740,6 +749,7 @@ def handle_coinbase_event(event) -> bool:
coinbase_subscription=coinbase_subscription, coinbase_subscription=coinbase_subscription,
), ),
) )
execute_subscription_webhook(user)
return True return True
@ -764,6 +774,7 @@ def init_admin(app):
admin.add_view(NewsletterUserAdmin(NewsletterUser, Session)) admin.add_view(NewsletterUserAdmin(NewsletterUser, Session))
admin.add_view(DailyMetricAdmin(DailyMetric, Session)) admin.add_view(DailyMetricAdmin(DailyMetric, Session))
admin.add_view(MetricAdmin(Metric2, Session)) admin.add_view(MetricAdmin(Metric2, Session))
admin.add_view(InvalidMailboxDomainAdmin(InvalidMailboxDomain, Session))
def register_custom_commands(app): def register_custom_commands(app):

View File

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

View File

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

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

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

View File

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

View File

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

View File

@ -50,7 +50,9 @@
</p> </p>
<p> <p>
This Youtube video can also quickly walk you through the steps: This Youtube video can also quickly walk you through the steps:
<a href="https://www.youtube.com/watch?v=VsypF-DBaow" target="_blank"> <a href="https://www.youtube.com/watch?v=VsypF-DBaow"
target="_blank"
rel="noopener noreferrer">
How to send emails from an alias <i class="fe fe-external-link"></i> How to send emails from an alias <i class="fe fe-external-link"></i>
</a> </a>
</p> </p>

View File

@ -43,6 +43,7 @@
<div class="row"> <div class="row">
<div class="col"> <div class="col">
<form method="post"> <form method="post">
{{ csrf_form.csrf_token }}
<input type="hidden" name="form-name" value="delete"> <input type="hidden" name="form-name" value="delete">
<input type="hidden" name="api-key-id" value="{{ api_key.id }}"> <input type="hidden" name="api-key-id" value="{{ api_key.id }}">
<span class="card-link btn btn-link float-right text-danger delete-api-key">Delete</span> <span class="card-link btn btn-link float-right text-danger delete-api-key">Delete</span>
@ -57,6 +58,7 @@
{% if api_keys|length > 0 %} {% if api_keys|length > 0 %}
<form method="post"> <form method="post">
{{ csrf_form.csrf_token }}
<input type="hidden" name="form-name" value="delete-all"> <input type="hidden" name="form-name" value="delete-all">
<span class="delete btn btn-outline-danger delete-all-api-keys float-right"> <span class="delete btn btn-outline-danger delete-all-api-keys float-right">
Delete All &nbsp; &nbsp; <i class="fe fe-trash"></i> Delete All &nbsp; &nbsp; <i class="fe fe-trash"></i>
@ -66,7 +68,7 @@
{% endif %} {% endif %}
<hr /> <hr />
<form method="post"> <form method="post">
{{ new_api_key_form.csrf_token }} {{ csrf_form.csrf_token }}
<input type="hidden" name="form-name" value="create"> <input type="hidden" name="form-name" value="create">
<h2 class="h4">New API Key</h2> <h2 class="h4">New API Key</h2>
{{ new_api_key_form.name(class="form-control", placeholder="Chrome") }} {{ new_api_key_form.name(class="form-control", placeholder="Chrome") }}

View File

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

View File

@ -23,7 +23,9 @@
<div class="alert alert-danger" role="alert"> <div class="alert alert-danger" role="alert">
This feature is only available on Premium plan. This feature is only available on Premium plan.
<a href="{{ URL }}/dashboard/pricing" target="_blank" rel="noopener"> <a href="{{ URL }}/dashboard/pricing"
target="_blank"
rel="noopener noreferrer">
Upgrade<i class="fe fe-external-link"></i> Upgrade<i class="fe fe-external-link"></i>
</a> </a>
</div> </div>

View File

@ -78,7 +78,7 @@
data-clipboard-text=".*suffix">.*suffix</em> data-clipboard-text=".*suffix">.*suffix</em>
<br /> <br />
To test out regex, we recommend using regex tester tool like To test out regex, we recommend using regex tester tool like
<a href="https://regex101.com" target="_blank">https://regex101.com↗</a> <a href="https://regex101.com" target="_blank" rel="noopener noreferrer">https://regex101.com↗</a>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">

View File

@ -158,7 +158,7 @@
SPF SPF
<a href="https://en.wikipedia.org/wiki/Sender_Policy_Framework" <a href="https://en.wikipedia.org/wiki/Sender_Policy_Framework"
target="_blank" target="_blank"
rel="noopener">(Wikipedia↗)</a> rel="noopener noreferrer">(Wikipedia↗)</a>
is an email is an email
authentication method authentication method
designed to detect forging sender addresses during the delivery of the email. designed to detect forging sender addresses during the delivery of the email.
@ -229,7 +229,7 @@
DKIM DKIM
<a href="https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail" <a href="https://en.wikipedia.org/wiki/DomainKeys_Identified_Mail"
target="_blank" target="_blank"
rel="noopener">(Wikipedia↗)</a> rel="noopener noreferrer">(Wikipedia↗)</a>
is an is an
email email
authentication method authentication method
@ -266,7 +266,9 @@
<i>dkim._domainkey.{{ custom_domain.domain }}</i> as domain value instead. <i>dkim._domainkey.{{ custom_domain.domain }}</i> as domain value instead.
<br /> <br />
If you are using a subdomain, e.g. <i>subdomain.domain.com</i>, If you are using a subdomain, e.g. <i>subdomain.domain.com</i>,
you need to use <i>dkim._domainkey.subdomain</i> as domain value instead. you need to use <i>dkim._domainkey.subdomain</i> as the domain instead.
<br />
That means, if your domain is <i>mail.domain.com</i> you should enter <i>dkim._domainkey.mail.domain.com</i> as the Domain.
<br /> <br />
</div> </div>
<div class="alert alert-info"> <div class="alert alert-info">
@ -335,7 +337,7 @@
DMARC DMARC
<a href="https://en.wikipedia.org/wiki/DMARC" <a href="https://en.wikipedia.org/wiki/DMARC"
target="_blank" target="_blank"
rel="noopener"> rel="noopener noreferrer">
(Wikipedia↗) (Wikipedia↗)
</a> </a>
is designed to protect the domain from unauthorized use, commonly known as email spoofing. is designed to protect the domain from unauthorized use, commonly known as email spoofing.

View File

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

View File

@ -25,17 +25,18 @@
<div class="alert alert-primary collapse {% if mailboxes|length == 1 %} show{% endif %}" <div class="alert alert-primary collapse {% if mailboxes|length == 1 %} show{% endif %}"
id="howtouse" id="howtouse"
role="alert"> role="alert">
A <em>mailbox</em> is just another personal email address. When creating a new alias, you could choose the A <em>mailbox</em> is just another personal email address. When creating a new alias, you could choose
the
mailbox that <em>owns</em> this alias, i.e: mailbox that <em>owns</em> this alias, i.e:
<br /> <br/>
- all emails sent to this alias will be forwarded to this mailbox - all emails sent to this alias will be forwarded to this mailbox
<br /> <br/>
- from this mailbox, you can reply/send emails from the alias. - from this mailbox, you can reply/send emails from the alias.
<br /> <br/>
<br /> <br/>
When you signed up, a mailbox is automatically created with your email <b>{{ current_user.email }}</b> When you signed up, a mailbox is automatically created with your email <b>{{ current_user.email }}</b>
<br /> <br/>
<br /> <br/>
The mailbox doesn't have to be your email: it can be your friend's email The mailbox doesn't have to be your email: it can be your friend's email
if you want to create aliases for your buddy. if you want to create aliases for your buddy.
</div> </div>
@ -74,11 +75,12 @@
</h5> </h5>
<h6 class="card-subtitle mb-2 text-muted"> <h6 class="card-subtitle mb-2 text-muted">
Created {{ mailbox.created_at | dt }} Created {{ mailbox.created_at | dt }}
<br /> <br/>
<span class="font-weight-bold">{{ mailbox.nb_alias() }}</span> aliases. <span class="font-weight-bold">{{ mailbox.nb_alias() }}</span> aliases.
<br /> <br/>
</h6> </h6>
<a href="{{ url_for('dashboard.mailbox_detail_route', mailbox_id=mailbox.id) }}">Edit</a> <a href="{{ url_for('dashboard.mailbox_detail_route', mailbox_id=mailbox.id) }}">Edit
</a>
</div> </div>
<div class="card-footer p-0"> <div class="card-footer p-0">
<div class="row"> <div class="row">
@ -89,7 +91,7 @@
{{ csrf_form.csrf_token }} {{ csrf_form.csrf_token }}
<input type="hidden" name="form-name" value="set-default"> <input type="hidden" name="form-name" value="set-default">
<input type="hidden" class="mailbox" value="{{ mailbox.email }}"> <input type="hidden" class="mailbox" value="{{ mailbox.email }}">
<input type="hidden" name="mailbox-id" value="{{ mailbox.id }}"> <input type="hidden" name="mailbox_id" value="{{ mailbox.id }}">
<button class="card-link btn btn-link {% if mailbox.id == current_user.default_mailbox_id %} disabled{% endif %}"> <button class="card-link btn btn-link {% if mailbox.id == current_user.default_mailbox_id %} disabled{% endif %}">
Set As Default Mailbox Set As Default Mailbox
</button> </button>
@ -98,10 +100,24 @@
{% endif %} {% endif %}
<div class="col"> <div class="col">
<form method="post"> <form method="post">
{{ csrf_form.csrf_token }} {{ delete_mailbox_form.csrf_token }}
<input type="hidden" name="form-name" value="delete"> <input type="hidden" name="form-name" value="delete">
<input type="hidden" class="mailbox" value="{{ mailbox.email }}"> <input type="hidden" class="mailbox" value="{{ mailbox.email }}">
<input type="hidden" name="mailbox-id" value="{{ mailbox.id }}"> <input type="hidden" name="mailbox_id" value="{{ mailbox.id }}">
<select hidden name="transfer_mailbox_id" value="">
<option value="-1">
Delete my aliases
</option>
{% for mailbox_opt in mailboxes %}
{% if mailbox_opt.verified and mailbox_opt.id != mailbox.id %}
<option value="{{ mailbox_opt.id }}">
{{ mailbox_opt.email }}
</option>
{% endif %}
{% endfor %}
</select>
<span class="card-link btn btn-link text-danger float-right delete-mailbox {% if mailbox.id == current_user.default_mailbox_id %} disabled{% endif %}"> <span class="card-link btn btn-link text-danger float-right delete-mailbox {% if mailbox.id == current_user.default_mailbox_id %} disabled{% endif %}">
Delete Delete
</span> </span>
@ -128,31 +144,39 @@
{% block script %} {% block script %}
<script> <script>
$(".delete-mailbox").on("click", function (e) { $(".delete-mailbox").on("click", function (e) {
let mailbox = $(this).parent().find(".mailbox").val(); let mailbox = $(this).parent().find(".mailbox").val();
let that = $(this); let new_mailboxes = $(this).parent().find("select[name='transfer_mailbox_id']").find("option")
let message = `All aliases owned by this mailbox <b>${mailbox}</b> will be also deleted, ` + let inputOptions = new_mailboxes.map((index, option) => { return {["value"]: option.value, ["text"]: option.text}}).toArray()
" please confirm.";
bootbox.confirm({ let that = $(this);
message: message, let message = `All aliases owned by the mailbox <b>${mailbox}</b> will be also deleted.<br>` +
buttons: { "You can choose to transfer them to a different mailbox:<br><br>";
confirm: {
label: 'Yes, delete it', bootbox.prompt({
className: 'btn-danger' title: '<b>Delete Mailbox</b>',
}, message: message,
cancel: { value: ["-1"],
label: 'Cancel', inputType: 'select',
className: 'btn-outline-primary' inputOptions: inputOptions,
} buttons: {
}, confirm: {
callback: function (result) { label: 'Yes, delete it',
if (result) { className: 'btn-danger'
that.closest("form").submit(); },
} cancel: {
} label: 'Cancel',
}) className: 'btn-outline-primary mr-auto'
}); }
},
callback: function (result) {
if (result) {
that.closest("form").find("select[name='transfer_mailbox_id']").val(result)
that.closest("form").submit();
}
}
})
});
</script> </script>
{% endblock %} {% endblock %}

View File

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

View File

@ -12,6 +12,7 @@
or use WebAuthn (FIDO). or use WebAuthn (FIDO).
</div> </div>
<form method="post"> <form method="post">
{{ csrf_form.csrf_token }}
<button class="btn btn-danger mt-2">Disable TOTP</button> <button class="btn btn-danger mt-2">Disable TOTP</button>
</form> </form>
</div> </div>

View File

@ -8,10 +8,11 @@
<script> <script>
if (window.Paddle === undefined) { if (window.Paddle === undefined) {
console.log("cannot load Paddle from CDN"); console.log("cannot load Paddle from CDN");
document.write('<script src="/static/vendor/paddle.js"><\/script>') // split string to avoid djlint incorrectly formatting the file
document.write('<' + 'script src="/static/vendor/paddle.js"><\/script' + '>');
} }
</script> </script>
<style type="text/css"> <style type="text/css">
html.mvc__a.mvc__lot.mvc__of.mvc__classes.mvc__to.mvc__increase.mvc__the.mvc__odds.mvc__of.mvc__winning.mvc__specificity, html.mvc__a.mvc__lot.mvc__of.mvc__classes.mvc__to.mvc__increase.mvc__the.mvc__odds.mvc__of.mvc__winning.mvc__specificity > body { html.mvc__a.mvc__lot.mvc__of.mvc__classes.mvc__to.mvc__increase.mvc__the.mvc__odds.mvc__of.mvc__winning.mvc__specificity, html.mvc__a.mvc__lot.mvc__of.mvc__classes.mvc__to.mvc__increase.mvc__the.mvc__odds.mvc__of.mvc__winning.mvc__specificity > body {
position: static; position: static;
} }
@ -25,194 +26,737 @@
[data-toggle="collapse"]:not(.collapsed) .if-collapsed { [data-toggle="collapse"]:not(.collapsed) .if-collapsed {
display: none; display: none;
} }
</style>
.btn-no-pointer {
pointer-events: none !important;
}
.tab-yearly__badge {
top: -8px !important;
left: 52px !important;
}
.border-2 {
border-width: 2px !important;
}
.text-start {
text-align: start !important;
}
</style>
{% endblock %} {% endblock %}
{% block announcement %} {% block announcement %}
{# TODO: to remove#} {# TODO: to remove#}
{# <div class="alert alert-danger text-center mb-0" role="alert">#} {# <div class="alert alert-danger text-center mb-0" role="alert">#}
{# Our payment provider Paddle is experiencing#} {# Our payment provider Paddle is experiencing#}
{# <a href="https://paddle.status.io" target="_blank">server issue <i class="fe fe-external-link"></i></a>#} {# <a href="https://paddle.status.io" target="_blank">server issue <i class="fe fe-external-link"></i></a>#}
{# that can make our checkout page unusable. <br />#} {# that can make our checkout page unusable. <br />#}
{# Please retry later and sorry for this issue!#} {# Please retry later and sorry for this issue!#}
{# </div>#} {# </div>#}
{% endblock %} {% endblock %}
{% block default_content %} {% block default_content %}
<div class="row"> <div class="pb-8">
<div class="col-sm-6 col-lg-6"> <div class="text-center mx-md-auto mb-8 mt-6">
<div class="card"> <h1>Upgrade to unlock premium features</h1>
<div class="card-body text-center"> </div>
<div class="h3">Premium</div> {% if manual_sub %}
<ul class="list-unstyled leading-loose mb-3">
<li>
<i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
Unlimited aliases
</li>
<li>
<i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
Unlimited custom domains
</li>
<li>
<i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
Catch-all (or wildcard) aliases
</li>
<li>
<i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
Up to 50 directories (or usernames)
</li>
<li>
<i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
Unlimited mailboxes
</li>
<li>
<i class="fe fe-check text-success mr-2" aria-hidden="true"></i>
PGP Encryption
</li>
</ul>
<div class="small-text">
More information on our
<a href="https://simplelogin.io/pricing" target="_blank" rel="noopener">
Pricing
Page <i class="fe fe-external-link"></i>
</a>
</div>
</div>
</div>
</div>
<div class="col-sm-6 col-lg-6">
{% if manual_sub %}
<div class="alert alert-info"> <div class="alert alert-info mt-0 mb-6">
You currently have a subscription until <b>{{ manual_sub.end_at.format("YYYY-MM-DD") }}</b> You currently have a subscription until <b>{{ manual_sub.end_at.format("YYYY-MM-DD") }}</b>
({{ (manual_sub.end_at - now).days }} days left). ({{ (manual_sub.end_at - now).days }} days left).
<br /> <br />
Please note that the time left will <b>not</b> be taken into account in a new subscription. Please note that the time left will <b>not</b> be taken into account in a new subscription.
</div> </div>
<hr /> <hr />
{% endif %} {% endif %}
{% if proton_upgrade %} {% set sub = current_user.get_paddle_subscription() %}
{% if sub and sub.cancelled %}
<div id="proton-upgrade"> <div class="alert alert-primary mt-0 mb-6" role="alert">
<h4>Proton Unlimited, Business and Visionary plans include SimpleLogin premium and more!</h4> You have an active subscription until {{ sub.next_bill_date.strftime("%Y-%m-%d") }}.
<a class="btn btn-primary" role="button" href="https://account.proton.me/u/0/mail/upgrade"> <br />
<b>Upgrade your Proton account</b> Please note that if you re-subscribe now, this will be a completely
</a> new subscription and
<p class="mt-2 small"> your payment method will be charged <b>immediately</b>.
Starts at $9.99/month (billed yearly), starting with 500GB of storage, VPN, encrypted </div>
calendar & file storage and more. {% endif %}
</p> {% if coinbase_sub %}
<div class="middle-line my-5 h4">OR</div>
<div id="normal-upgrade-button">
<a class="btn btn-secondary collapsed" data-toggle="collapse" href="#normal-upgrade" role="button">
Upgrade your SimpleLogin account
<span class="if-collapsed">
<i class="fe fe-chevron-down"></i>
</span>
<span class="if-not-collapsed">
<i class="fe fe-chevron-up"></i>
</span>
</a>
<p class="mt-2 small">Starts at $2.5/month (billed yearly)</p>
</div>
</div>
{% endif %}
<div id="normal-upgrade" class="{% if proton_upgrade %} collapse{% endif %}">
<div class="display-6 my-3">
🔐 Secure payments by
<a href="https://paddle.com" target="_blank" rel="noopener">
Paddle <i class="fe fe-external-link"></i>
</a>
</div>
{% set sub = current_user.get_paddle_subscription() %}
{% if sub and sub.cancelled %}
<div class="alert alert-primary" role="alert"> <div class="alert alert-info mt-0 mb-6">
You have an active subscription until {{ sub.next_bill_date.strftime("%Y-%m-%d") }}. You currently have a Coinbase subscription until <b>{{ coinbase_sub.end_at.format("YYYY-MM-DD") }}</b>
<br /> ({{ (coinbase_sub.end_at - now).days }} days left).
Please note that if you re-subscribe now, this will be a completely <br />
new subscription and Please note that the time left will <b>not</b> be taken into account in a new Paddle subscription.
your payment method will be charged <b>immediately</b>. </div>
</div> {% endif %}
{% endif %} <div class="nav btn-group mb-4 justify-content-center position-relative flex-nowrap d-flex"
{% if coinbase_sub %} id="pills-tab"
role="tablist">
<a class="btn btn-outline-primary flex-grow-0 px-8 py-2"
id="monthly-plan-tab"
data-toggle="tab"
href="#monthly-plan"
role="tab"
aria-controls="monthly-plan"
aria-selected="false">Monthly</a>
<a class="btn btn-outline-primary flex-grow-0 px-8 py-2 position-relative active"
id="yearly-plan-tab"
data-toggle="tab"
href="#yearly-plan"
role="tab"
aria-controls="yearly-plan"
aria-selected="true">Yearly<span class="badge badge-success position-absolute tab-yearly__badge"
style="font-size: 12px">Save $18</span></a>
</div>
<div class="tab-content mb-8">
<!-- monthly tab content -->
<div class="tab-pane"
id="monthly-plan"
role="tabpanel"
aria-labelledby="monthly-plan-tab">
<div class="row row-cards">
<!-- monthly free plan -->
<div class="{{ 'col-md-6 col-lg-4' if proton_upgrade else 'col-md-6' }}">
<div class="card card-md flex-grow-1">
<div class="card-body">
<div class="text-center">
<div class="h3">Free</div>
<div class="h3 my-3">$0</div>
<div class="text-center mt-4 mb-6">
{% set sub = current_user.get_paddle_subscription() %}
<button class="{{ 'invisible' if sub or manual_sub or coinbase_sub }} btn btn-lg btn-outline-secondary w-100 btn-no-pointer"
aria-disabled="true"
disabled>
Current plan
</button>
</div>
</div>
<ul class="list-unstyled">
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
10 aliases
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
1 mailbox
</li>
</ul>
</div>
</div>
</div>
<!-- END monthly free plan -->
<!-- monthly premium plan -->
<div class="{{ 'col-md-6 col-lg-4' if proton_upgrade else 'col-md-6' }}">
<div class="card card-md flex-grow-1 border-primary border-2">
<div class="card-body">
<div class="text-center">
<div class="h3">SimpleLogin Premium</div>
<div class="h3 my-3">$4 / month</div>
<div class="text-center mt-4 mb-6">
<button class="btn btn-primary btn-lg w-100"
onclick="upgradePaddle({{ PADDLE_MONTHLY_PRODUCT_ID }})">
Upgrade to Premium
</button>
</div>
</div>
<ul class="list-unstyled">
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
Unlimited aliases
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
Unlimited mailboxes
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
Custom domains: bring your own domain to create aliases like contact@your-domain.com
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
Catch-all (or wildcard) domain
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
Initiate a new email from your alias
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
5 subdomains
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
50 directories
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
PGP Encryption
</li>
</ul>
</div>
</div>
</div>
<!-- END monthly premium plan -->
<!-- monthly Proton plan -->
{% if proton_upgrade %}
<div class="alert alert-info"> <div class="col-md-6 col-lg-4">
You currently have a Coinbase subscription until <b>{{ coinbase_sub.end_at.format("YYYY-MM-DD") }}</b> <div class="card card-md flex-grow-1">
({{ (coinbase_sub.end_at - now).days }} days left). <div class="card-body">
<br /> <div class="text-center">
Please note that the time left will <b>not</b> be taken into account in a new Paddle subscription. <div class="h3">Proton plan</div>
</div> <div class="h3 my-3">Starts at $11.99 / month</div>
{% endif %} <div class="text-center mt-4 mb-6">
<div class="mb-3"> <a class="btn btn-lg btn-outline-primary w-100"
Paddle supports bank cards role="button"
(Mastercard, Visa, American Express, etc) and PayPal. href="https://account.proton.me/u/0/mail/upgrade"
</div> target="_blank">Upgrade your Proton account</a>
<button class="btn btn-primary" onclick="upgrade({{ PADDLE_YEARLY_PRODUCT_ID }})"> </div>
Yearly billing </div>
<span class="badge badge-success">Save $18</span> <p>Proton Unlimited / Business plans include:</p>
<br /> <ul class="list-unstyled">
<span style="font-size: 18px">$30/year</span> <li class="d-flex">
</button> <i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
<button class="btn btn-secondary" onclick="upgrade({{ PADDLE_MONTHLY_PRODUCT_ID }})"> SimpleLogin Premium
Monthly billing </li>
<br /> <li class="d-flex">
<b> <i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
$4/month 500 GB storage
</b> </li>
</button> <li class="d-flex">
<hr /> <i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
<i class="fa fa-bitcoin"></i> 15 email addresses
Payment via </li>
<a href="https://commerce.coinbase.com/?lang=en" target="_blank"> <li class="d-flex">
Coinbase Commerce<i class="fe fe-external-link"></i> <i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
</a> Unlimited folders, labels, and filters
<br /> </li>
Currently Bitcoin, Bitcoin Cash, Dai, Ethereum, Litecoin and USD Coin are supported. <li class="d-flex">
<br /> <i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
<a class="btn btn-outline-primary" href="{{ url_for('dashboard.coinbase_checkout_route') }}" target="_blank"> Unlimited messages per day
Yearly billing - Crypto </li>
<br /> <li class="d-flex">
$30/year <i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
<i class="fe fe-external-link"></i> 15 email addresses
</a> </li>
<hr /> <li class="d-flex">
For other payment options, please send us an email at <i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
<a href="mailto:hi@simplelogin.io">hi@simplelogin.io</a> 20 Calendars
. </li>
<br /> <li class="d-flex">
If you have bought a coupon, please go to the <i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
<a href="{{ url_for('dashboard.coupon_route') }}">coupon page</a> 10 high-speed VPN connections
to apply the coupon code. </li>
</div> <li class="d-flex">
</div> <i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
</div> 3 custom email domains
<script type="text/javascript"> </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>
15 email addresses/aliases
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
Unlimited folders, labels, and filters
</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>
15 email addresses/aliases
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
20 Calendars
</li>
<li class="d-flex">
<i class="fe fe-check text-success mr-2 mt-1" aria-hidden="true"></i>
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">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">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">support@simplelogin.zendesk.com</a> for more information.
</p>
</div>
</div>
</div>
<div class="card mb-3">
<div class="card-header card-collapse p-0"
id="pricing-faq-question-other-ways">
<h5 class="mb-0 w-100">
<button class="btn btn-link btn-block d-flex justify-content-between card-btn p-4 collapsed text-decoration-none"
data-toggle="collapse"
data-target="#pricing-faq-answer-other-ways"
aria-controls="pricing-faq-answer-other-ways"
aria-expanded="false">
<span class="text-start">Are there other ways to buy SimpleLogin subscriptions?</span>
<span class="if-collapsed">
<i class="fe fe-chevron-down"></i>
</span>
<span class="if-not-collapsed">
<i class="fe fe-chevron-up"></i>
</span>
</button>
</h5>
</div>
<div id="pricing-faq-answer-other-ways"
class="collapse"
aria-labelledby="pricing-faq-question-other-ways"
data-parent="#pricing-faq">
<div class="card-body">
<p>
Yes you can also buy SimpleLogin subscription coupon via <a href="https://proxysto.re/en/index.html" target="_blank">ProxyStore <i class="fe fe-external-link"></i></a>, our official reseller.
</p>
</div>
</div>
</div>
</div>
</div>
<!-- END FAQ section -->
</div>
<script type="text/javascript">
Paddle.Setup({vendor: {{ PADDLE_VENDOR_ID }}}); Paddle.Setup({vendor: {{ PADDLE_VENDOR_ID }}});
function upgrade(productId) { function upgradePaddle(productId) {
bootbox.dialog({ Paddle.Checkout.open({
title: `Payment with credit card or PayPal via Paddle`, product: productId,
message: `Paddle will ask for an email address for sending out the invoices, please feel free to use an alias. <br /> success: "{{ success_url }}",
You don't have to use your SimpleLogin account email address`, passthrough: "{\"user_id\": {{current_user.id}} }"
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}} }"
});
}
},
}
}); });
} }
</script> </script>
{% endblock %} {% endblock %}

View File

@ -15,9 +15,10 @@
<div class="col"> <div class="col">
<h1 class="h3 mb-5">Quarantine & Bounce</h1> <h1 class="h3 mb-5">Quarantine & Bounce</h1>
<div class="alert alert-info"> <div class="alert alert-info">
This page shows all emails that are either refused by your mailbox (bounced) or detected as spams/phishing (quarantine) via our This page shows all emails that are either refused by your mailbox (bounced) or detected as spam/phishing (quarantine) via our
<a href="https://simplelogin.io/docs/getting-started/anti-phishing/" <a href="https://simplelogin.io/docs/getting-started/anti-phishing/"
target="_blank">anti-phishing program ↗</a> target="_blank"
rel="noopener noreferrer">anti-phishing program ↗</a>
<ul class="p-4 mb-0"> <ul class="p-4 mb-0">
<li> <li>
If the email is indeed spam, this means the alias is now in the hands of a spammer, If the email is indeed spam, this means the alias is now in the hands of a spammer,
@ -26,10 +27,11 @@
<li> <li>
If the email isn't spam and your mailbox refuses the email, we recommend to create a <b>filter</b> to avoid your mailbox provider from blocking legitimate emails. Please refer to If the email isn't spam and your mailbox refuses the email, we recommend to create a <b>filter</b> to avoid your mailbox provider from blocking legitimate emails. Please refer to
<a href="https://simplelogin.io/docs/getting-started/troubleshooting/#emails-end-up-in-spam" <a href="https://simplelogin.io/docs/getting-started/troubleshooting/#emails-end-up-in-spam"
target="_blank">Setting up filter for SimpleLogin emails ↗</a> target="_blank"
rel="noopener noreferrer">Setting up filter for SimpleLogin emails ↗</a>
</li> </li>
<li> <li>
If the email is flagged as spams/phishing, this means that the sender explicitly states their emails should respect If the email is flagged as spam/phishing, this means that the sender explicitly states their emails should respect
<b>DMARC</b> (an email authentication protocol) <b>DMARC</b> (an email authentication protocol)
and any email that violates this should either be quarantined or rejected. If possible, please contact the sender and any email that violates this should either be quarantined or rejected. If possible, please contact the sender
so they can update their DMARC setting or fix their SPF/DKIM that cause the DMARC failure. so they can update their DMARC setting or fix their SPF/DKIM that cause the DMARC failure.

View File

@ -73,7 +73,8 @@
Yearly plan subscribed with cryptocurrency which expires on Yearly plan subscribed with cryptocurrency which expires on
{{ coinbase_sub.end_at.format("YYYY-MM-DD") }}. {{ coinbase_sub.end_at.format("YYYY-MM-DD") }}.
<a href="{{ url_for('dashboard.coinbase_checkout_route') }}" <a href="{{ url_for('dashboard.coinbase_checkout_route') }}"
target="_blank"> target="_blank"
rel="noopener noreferrer">
Extend Subscription <i class="fe fe-external-link"></i> Extend Subscription <i class="fe fe-external-link"></i>
</a> </a>
</div> </div>
@ -180,10 +181,10 @@
<!-- END change name & profile picture --> <!-- END change name & profile picture -->
<!-- Change email --> <!-- Change email -->
<div class="card"> <div class="card">
<form method="post" enctype="multipart/form-data"> <div class="card-body">
<input type="hidden" name="form-name" value="update-email"> <form method="post" enctype="multipart/form-data">
{{ change_email_form.csrf_token }} <input type="hidden" name="form-name" value="update-email">
<div class="card-body"> {{ change_email_form.csrf_token }}
<div class="card-title">Account Email</div> <div class="card-title">Account Email</div>
<div class="mb-3"> <div class="mb-3">
This email address is used to log in to SimpleLogin. This email address is used to log in to SimpleLogin.
@ -198,26 +199,30 @@
<!-- Not allow user to change email if there's a pending change --> <!-- Not allow user to change email if there's a pending change -->
{{ change_email_form.email(class="form-control", value=current_user.email, readonly=pending_email != None) }} {{ change_email_form.email(class="form-control", value=current_user.email, readonly=pending_email != None) }}
{{ render_field_errors(change_email_form.email) }} {{ render_field_errors(change_email_form.email) }}
{% 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> </div>
<button class="btn btn-outline-primary">Change Email</button> <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> </div>
<!-- END Change email --> <!-- END Change email -->
<!-- Connect with Proton --> <!-- Connect with Proton -->
@ -264,11 +269,15 @@
<div class="card" id="change_password"> <div class="card" id="change_password">
<div class="card-body"> <div class="card-body">
<div class="card-title">Password</div> <div class="card-title">Password</div>
<div class="mb-3">You will receive an email containing instructions on how to change your password.</div> <div class="mb-3">
You will receive an email containing instructions on how to change your password.
</div>
<form method="post"> <form method="post">
{{ csrf_form.csrf_token }} {{ csrf_form.csrf_token }}
<input type="hidden" name="form-name" value="change-password"> <input type="hidden" name="form-name" value="change-password">
<button class="btn btn-outline-primary">Change password</button> <button class="btn btn-outline-primary">
Change password
</button>
</form> </form>
</div> </div>
</div> </div>

View File

@ -25,7 +25,7 @@
This feature is only available on Premium plan. This feature is only available on Premium plan.
<a href="{{ url_for('dashboard.pricing') }}" <a href="{{ url_for('dashboard.pricing') }}"
target="_blank" target="_blank"
rel="noopener"> rel="noopener noreferrer">
Upgrade<i class="fe fe-external-link"></i> Upgrade<i class="fe fe-external-link"></i>
</a> </a>
</div> </div>
@ -38,7 +38,7 @@
Handy when you need to quickly give out an email address, for example on a phone call, in a meeting or just Handy when you need to quickly give out an email address, for example on a phone call, in a meeting or just
anywhere you want. anywhere you want.
<br /> <br />
After choosing a subdomain, simply use <b>anything@my-subdomain.simplelogin.co</b> After choosing a subdomain, simply use <b>anything@my-subdomain.simplelogin.com</b>
next time you need an alias: next time you need an alias:
it'll be <b>automatically created</b> the first time it receives an email. it'll be <b>automatically created</b> the first time it receives an email.
<br /> <br />
@ -72,6 +72,7 @@
<div class="card-body"> <div class="card-body">
<h2 class="h4 mb-1">New Subdomain</h2> <h2 class="h4 mb-1">New Subdomain</h2>
<form method="post" class="mt-2" data-parsley-validate> <form method="post" class="mt-2" data-parsley-validate>
{{ new_subdomain_form.csrf_token }}
<input type="hidden" name="form-name" value="create"> <input type="hidden" name="form-name" value="create">
<div class="form-group"> <div class="form-group">
<label>Subdomain</label> <label>Subdomain</label>

View 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 %}

View File

@ -31,8 +31,9 @@
<span class="icon mr-3"><i class="fe fe-alert-octagon"></i></span>Danger <span class="icon mr-3"><i class="fe fe-alert-octagon"></i></span>Danger
</a> </a>
</div> </div>
<a href="https://docs.simplelogin.io" <a href="https://simplelogin.io/docs/siwsl/app/"
target="_blank" target="_blank"
rel="noopener noreferrer"
class="btn btn-block btn-secondary mt-4"> class="btn btn-block btn-secondary mt-4">
Documentation <i class="fe fe-external-link"></i> Documentation <i class="fe fe-external-link"></i>
</a> </a>

View File

@ -10,7 +10,9 @@
<h4 class="alert-heading">Well done!</h4> <h4 class="alert-heading">Well done!</h4>
<p> <p>
Please head to our Please head to our
<a href="https://docs.simplelogin.io" target="_blank" rel="noopener"> <a href="https://simplelogin.io/docs/siwsl/app/"
target="_blank"
rel="noopener noreferrer">
documentation <i class="fe fe-external-link"></i> documentation <i class="fe fe-external-link"></i>
</a> </a>
to see how to add SIWSL into your app. to see how to add SIWSL into your app.

View File

@ -47,8 +47,9 @@
<div class="col"> <div class="col">
<div class="btn-group" role="group" aria-label="Basic example"> <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="{{ 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" target="_blank"
rel="noopener noreferrer"
class="ml-2 btn btn-secondary"> class="ml-2 btn btn-secondary">
Docs <i class="fe fe-external-link"></i> Docs <i class="fe fe-external-link"></i>
</a> </a>

View File

@ -13,7 +13,9 @@
<div class="col-sm-4 col-xl-2"> <div class="col-sm-4 col-xl-2">
<div class="card"> <div class="card">
<a href="{{ client.home_url }}" target="_blank" rel="noopener"> <a href="{{ client.home_url }}"
target="_blank"
rel="noopener noreferrer">
<img class="card-img-top" src="{{ client.get_icon_url() }}"> <img class="card-img-top" src="{{ client.get_icon_url() }}">
</a> </a>
<div class="card-body d-flex flex-column"> <div class="card-body d-flex flex-column">

View File

@ -46,7 +46,7 @@ https://litmus.com/blog/a-guide-to-bulletproof-buttons-in-email-design -->
<a href="{{ link }}" <a href="{{ link }}"
class="f-fallback button" class="f-fallback button"
target="_blank" target="_blank"
rel="noopener" rel="noopener noreferrer"
style="color: #FFF; style="color: #FFF;
border-color: #3869d4; border-color: #3869d4;
border-style: solid; border-style: solid;

View File

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

View File

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

View File

@ -9,8 +9,7 @@
{% endcall %} {% endcall %}
{% call text() %} {% call text() %}
Please contact us at Please <a href="https://app.simplelogin.io/dashboard/support">contact us</a>
<a href="mailto:hi@simplelogin.io">hi@simplelogin.io</a>
to renew your subscription. to renew your subscription.
{% endcall %} {% endcall %}

View File

@ -2,4 +2,6 @@
{% block content %} {% block content %}
Your subscription will end on {{ manual_sub.end_at.format("YYYY-MM-DD") }} Your subscription will end on {{ manual_sub.end_at.format("YYYY-MM-DD") }}
Please contact us on https://app.simplelogin.io/dashboard/support to renew your subscription.
{% endblock %} {% endblock %}

View File

@ -6,6 +6,7 @@
{{ render_text("You recently requested to change mailbox <b>"+ mailbox_email +"</b> to <b>" + mailbox_new_email + "</b>.") }} {{ render_text("You recently requested to change mailbox <b>"+ mailbox_email +"</b> to <b>" + mailbox_new_email + "</b>.") }}
{{ render_text("To confirm, please click on the button below.") }} {{ render_text("To confirm, please click on the button below.") }}
{{ render_button("Confirm mailbox change", link) }} {{ render_button("Confirm mailbox change", link) }}
{{ render_text("This email will only be valid for the next 15 minutes.") }}
{{ render_text('Thanks, {{ render_text('Thanks,
<br /> <br />
SimpleLogin Team.') }} SimpleLogin Team.') }}

View File

@ -8,4 +8,6 @@ You recently requested to change mailbox {{mailbox_email}} to {{mailbox_new_emai
To confirm, please click on this link: To confirm, please click on this link:
{{link}} {{link}}
This link will only be valid during the next 15 minutes.
{% endblock %} {% endblock %}

View File

@ -6,6 +6,7 @@
{{ render_text("You have added <b>"+ mailbox_email +"</b> as an additional mailbox.") }} {{ render_text("You have added <b>"+ mailbox_email +"</b> as an additional mailbox.") }}
{{ render_text("To confirm, please click on the button below.") }} {{ render_text("To confirm, please click on the button below.") }}
{{ render_button("Confirm mailbox", link) }} {{ render_button("Confirm mailbox", link) }}
{{ render_text("This email will only be valid for the next 15 minutes.") }}
{{ render_text('Thanks, {{ render_text('Thanks,
<br /> <br />
SimpleLogin Team.') }} SimpleLogin Team.') }}

View File

@ -8,4 +8,6 @@ You have added {{mailbox_email}} as an additional mailbox.
To confirm, please click on this link: To confirm, please click on this link:
{{link}} {{link}}
This link will only be valid during the next 15 minutes.
{% endblock %} {% endblock %}

View File

@ -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 %}

View File

@ -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 %}

View File

@ -145,28 +145,28 @@
<ul class="list-group list-group-transparent list-group-white list-group-flush list-group-borderless mb-0 footer-list-group"> <ul class="list-group list-group-transparent list-group-white list-group-flush list-group-borderless mb-0 footer-list-group">
<li> <li>
<a class="list-group-item text-white footer-item " <a class="list-group-item text-white footer-item "
rel="noopener" rel="noopener noreferrer"
href="https://chrome.google.com/webstore/detail/dphilobhebphkdjbpfohgikllaljmgbn"> href="https://chrome.google.com/webstore/detail/dphilobhebphkdjbpfohgikllaljmgbn">
Chrome Extension Chrome Extension
</a> </a>
</li> </li>
<li> <li>
<a class="list-group-item text-white footer-item " <a class="list-group-item text-white footer-item "
rel="noopener" rel="noopener noreferrer"
href="https://addons.mozilla.org/firefox/addon/simplelogin/"> href="https://addons.mozilla.org/firefox/addon/simplelogin/">
Firefox Add-on Firefox Add-on
</a> </a>
</li> </li>
<li> <li>
<a class="list-group-item text-white footer-item " <a class="list-group-item text-white footer-item "
rel="noopener" rel="noopener noreferrer"
href="https://microsoftedge.microsoft.com/addons/detail/simpleloginreceive-sen/diacfpipniklenphgljfkmhinphjlfff"> href="https://microsoftedge.microsoft.com/addons/detail/simpleloginreceive-sen/diacfpipniklenphgljfkmhinphjlfff">
Edge Add-on Edge Add-on
</a> </a>
</li> </li>
<li> <li>
<a class="list-group-item text-white footer-item " <a class="list-group-item text-white footer-item "
rel="noopener" rel="noopener noreferrer"
href="https://apps.apple.com/app/id1494051017"> href="https://apps.apple.com/app/id1494051017">
Safari Safari
Extension Extension
@ -174,7 +174,7 @@
</li> </li>
<li> <li>
<a class="list-group-item text-white footer-item " <a class="list-group-item text-white footer-item "
rel="noopener" rel="noopener noreferrer"
href="https://apps.apple.com/app/id1494359858"> href="https://apps.apple.com/app/id1494359858">
iOS iOS
(App Store) (App Store)
@ -182,14 +182,14 @@
</li> </li>
<li> <li>
<a class="list-group-item text-white footer-item " <a class="list-group-item text-white footer-item "
rel="noopener" rel="noopener noreferrer"
href="https://play.google.com/store/apps/details?id=io.simplelogin.android"> href="https://play.google.com/store/apps/details?id=io.simplelogin.android">
Android (Play Store) Android (Play Store)
</a> </a>
</li> </li>
<li> <li>
<a class="list-group-item text-white footer-item " <a class="list-group-item text-white footer-item "
rel="noopener" rel="noopener noreferrer"
href="https://f-droid.org/en/packages/io.simplelogin.android.fdroid/"> href="https://f-droid.org/en/packages/io.simplelogin.android.fdroid/">
Android (F-Droid) Android (F-Droid)
</a> </a>

View File

@ -75,14 +75,17 @@
<a href="#" class="dropdown-toggle" data-toggle="dropdown">Help</a> <a href="#" class="dropdown-toggle" data-toggle="dropdown">Help</a>
<div class="dropdown-menu dropdown-menu-left dropdown-menu-arrow"> <div class="dropdown-menu dropdown-menu-left dropdown-menu-arrow">
<div class="dropdown-item"> <div class="dropdown-item">
<a href="https://simplelogin.io/docs/" target="_blank"> <a href="https://simplelogin.io/docs/"
target="_blank"
rel="noopener noreferrer">
Docs Docs
<i class="fa fa-external-link" aria-hidden="true"></i> <i class="fa fa-external-link" aria-hidden="true"></i>
</a> </a>
</div> </div>
<div class="dropdown-item"> <div class="dropdown-item">
<a href="https://github.com/simple-login/app/discussions" <a href="https://github.com/simple-login/app/discussions"
target="_blank"> target="_blank"
rel="noopener noreferrer">
Forum Forum
<i class="fa fa-external-link" aria-hidden="true"></i> <i class="fa fa-external-link" aria-hidden="true"></i>
</a> </a>
@ -94,7 +97,9 @@
</div> </div>
{% else %} {% else %}
<div class="nav-item"> <div class="nav-item">
<a href="https://simplelogin.io/docs/" target="_blank"> <a href="https://simplelogin.io/docs/"
target="_blank"
rel="noopener noreferrer">
Docs Docs
<i class="fa fa-external-link" aria-hidden="true"></i> <i class="fa fa-external-link" aria-hidden="true"></i>
</a> </a>

View File

@ -98,14 +98,17 @@
<a href="#" class="dropdown-toggle" data-toggle="dropdown">Help</a> <a href="#" class="dropdown-toggle" data-toggle="dropdown">Help</a>
<div class="dropdown-menu dropdown-menu-left dropdown-menu-arrow"> <div class="dropdown-menu dropdown-menu-left dropdown-menu-arrow">
<div class="dropdown-item"> <div class="dropdown-item">
<a href="https://simplelogin.io/docs/" target="_blank"> <a href="https://simplelogin.io/docs/"
target="_blank"
rel="noopener noreferrer">
Docs Docs
<i class="fa fa-external-link" aria-hidden="true"></i> <i class="fa fa-external-link" aria-hidden="true"></i>
</a> </a>
</div> </div>
<div class="dropdown-item"> <div class="dropdown-item">
<a href="https://github.com/simple-login/app/discussions" <a href="https://github.com/simple-login/app/discussions"
target="_blank"> target="_blank"
rel="noopener noreferrer">
Forum Forum
<i class="fa fa-external-link" aria-hidden="true"></i> <i class="fa fa-external-link" aria-hidden="true"></i>
</a> </a>

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