Compare commits
38 Commits
Author | SHA1 | Date | |
---|---|---|---|
0a52e32972 | |||
703dcbd0eb | |||
ce7ed69547 | |||
4f5564df16 | |||
2fee569131 | |||
7ea45d6f5d | |||
6d24db50bd | |||
88f270c6a1 | |||
0962b1cf29 | |||
6051d72691 | |||
c31a75a9ef | |||
ef289385ff | |||
9b12a2ad33 | |||
8eb19d88f3 | |||
e36e9d3077 | |||
b2430cbc5b | |||
1258115397 | |||
38c134d903 | |||
cd77e4cc2d | |||
87aedf3207 | |||
3523c9fc15 | |||
a6f4995cb5 | |||
727f61a35e | |||
ce5124605a | |||
2c82b03f8d | |||
1b7a6223ac | |||
75331c62a4 | |||
3f68a3e640 | |||
8ee4f9462e | |||
822855d584 | |||
1a6a7e079b | |||
5210cb6515 | |||
b643f0644b | |||
5d093db4f6 | |||
0b16fcac67 | |||
a0d294da53 | |||
c3f755aede | |||
0aea62c222 |
@ -17,6 +17,7 @@ steps:
|
|||||||
image: thegeeklab/drone-docker-buildx
|
image: thegeeklab/drone-docker-buildx
|
||||||
privileged: true
|
privileged: true
|
||||||
settings:
|
settings:
|
||||||
|
provenance: false
|
||||||
dockerfile: app/Dockerfile
|
dockerfile: app/Dockerfile
|
||||||
context: app
|
context: app
|
||||||
registry: git.mrmeeb.stream
|
registry: git.mrmeeb.stream
|
||||||
@ -31,9 +32,16 @@ steps:
|
|||||||
|
|
||||||
- name: notify
|
- name: notify
|
||||||
image: plugins/slack
|
image: plugins/slack
|
||||||
|
when:
|
||||||
|
status:
|
||||||
|
- success
|
||||||
|
- failure
|
||||||
|
- killed
|
||||||
settings:
|
settings:
|
||||||
webhook:
|
webhook:
|
||||||
from_secret: slack_webhook
|
from_secret: slack_webhook
|
||||||
|
icon_url:
|
||||||
|
from_secret: slack_avatar
|
||||||
|
|
||||||
trigger:
|
trigger:
|
||||||
event:
|
event:
|
||||||
|
14
README.md
14
README.md
@ -1,7 +1,7 @@
|
|||||||
# Simple Login
|
# SimpleLogin
|
||||||
|
|
||||||
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 the simplelogin/app GitHub repo once a day, and builds the latest release automatically if it is newer than the currently 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.
|
I did this to simplify deployment of my self-hosted SimpleLogin instance. SimpleLogin do not provide an up-to-date version for self-hosting, leaving you with the options of either running a very outdated version with no app support, a beta version, or their `simplelogin/app-ci` version. This last option works well if you use an x86 machine, but I'm running SimpleLogin on an ARM machine. Since I don't want to have to build containers on the machine itself, this repo handles that for me.
|
||||||
|
|
||||||
The image is built for amd64 and arm64 devices.
|
As a result, this image is built for both amd64 and arm64 devices.
|
8
app/.github/workflows/main.yml
vendored
8
app/.github/workflows/main.yml
vendored
@ -15,9 +15,15 @@ jobs:
|
|||||||
|
|
||||||
- uses: actions/setup-python@v4
|
- uses: actions/setup-python@v4
|
||||||
with:
|
with:
|
||||||
python-version: '3.9'
|
python-version: '3.10'
|
||||||
cache: 'poetry'
|
cache: 'poetry'
|
||||||
|
|
||||||
|
- name: Install OS dependencies
|
||||||
|
if: ${{ matrix.python-version }} == '3.10'
|
||||||
|
run: |
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y libre2-dev libpq-dev
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
|
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
|
||||||
run: poetry install --no-interaction
|
run: poetry install --no-interaction
|
||||||
|
1
app/.gitignore
vendored
1
app/.gitignore
vendored
@ -15,3 +15,4 @@ venv/
|
|||||||
.coverage
|
.coverage
|
||||||
htmlcov
|
htmlcov
|
||||||
adhoc
|
adhoc
|
||||||
|
.env.*
|
@ -21,3 +21,4 @@ repos:
|
|||||||
- id: djlint-jinja
|
- id: djlint-jinja
|
||||||
files: '.*\.html'
|
files: '.*\.html'
|
||||||
entry: djlint --reformat
|
entry: djlint --reformat
|
||||||
|
|
||||||
|
@ -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:
|
||||||
@ -169,6 +169,12 @@ For HTML templates, we use `djlint`. Before creating a pull request, please run
|
|||||||
poetry run djlint --check templates
|
poetry run djlint --check templates
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If some files aren't properly formatted, you can format all files with
|
||||||
|
|
||||||
|
```bash
|
||||||
|
poetry run djlint --reformat .
|
||||||
|
```
|
||||||
|
|
||||||
## Test sending email
|
## Test sending email
|
||||||
|
|
||||||
[swaks](http://www.jetmore.org/john/code/swaks/) is used for sending test emails to the `email_handler`.
|
[swaks](http://www.jetmore.org/john/code/swaks/) is used for sending test emails to the `email_handler`.
|
||||||
|
@ -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
|
||||||
@ -23,10 +23,10 @@ COPY poetry.lock pyproject.toml ./
|
|||||||
# Install and setup poetry
|
# Install and setup poetry
|
||||||
RUN pip install -U pip \
|
RUN pip install -U pip \
|
||||||
&& apt-get update \
|
&& apt-get update \
|
||||||
&& apt install -y curl netcat gcc python3-dev gnupg git libre2-dev \
|
&& apt install -y curl netcat-traditional gcc python3-dev gnupg git libre2-dev \
|
||||||
&& curl -sSL https://install.python-poetry.org | python3 - \
|
&& curl -sSL https://install.python-poetry.org | python3 - \
|
||||||
# Remove curl and netcat from the image
|
# Remove curl and netcat from the image
|
||||||
&& apt-get purge -y curl netcat \
|
&& apt-get purge -y curl netcat-traditional \
|
||||||
# Run poetry
|
# Run poetry
|
||||||
&& poetry config virtualenvs.create false \
|
&& poetry config virtualenvs.create false \
|
||||||
&& poetry install --no-interaction --no-ansi --no-root \
|
&& poetry install --no-interaction --no-ansi --no-root \
|
||||||
|
@ -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.
|
||||||
|
|
||||||
|
@ -9,13 +9,18 @@ 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,
|
||||||
|
AccountAlreadyLinkedToAnotherUserException,
|
||||||
|
)
|
||||||
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
|
||||||
|
|
||||||
@ -175,7 +180,7 @@ class ExistingUnlinkedUserStrategy(ClientMergeStrategy):
|
|||||||
|
|
||||||
class LinkedWithAnotherPartnerUserStrategy(ClientMergeStrategy):
|
class LinkedWithAnotherPartnerUserStrategy(ClientMergeStrategy):
|
||||||
def process(self) -> LinkResult:
|
def process(self) -> LinkResult:
|
||||||
raise AccountAlreadyLinkedToAnotherPartnerException()
|
raise AccountAlreadyLinkedToAnotherUserException()
|
||||||
|
|
||||||
|
|
||||||
def get_login_strategy(
|
def get_login_strategy(
|
||||||
@ -192,6 +197,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 +214,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()
|
||||||
|
@ -256,6 +256,17 @@ class UserAdmin(SLModelView):
|
|||||||
|
|
||||||
Session.commit()
|
Session.commit()
|
||||||
|
|
||||||
|
@action(
|
||||||
|
"clear_delete_on",
|
||||||
|
"Remove scheduled deletion of user",
|
||||||
|
"This will remove the scheduled deletion for this users",
|
||||||
|
)
|
||||||
|
def clean_delete_on(self, ids):
|
||||||
|
for user in User.filter(User.id.in_(ids)):
|
||||||
|
user.delete_on = None
|
||||||
|
|
||||||
|
Session.commit()
|
||||||
|
|
||||||
# @action(
|
# @action(
|
||||||
# "login_as",
|
# "login_as",
|
||||||
# "Login as this user",
|
# "Login as this user",
|
||||||
@ -620,3 +631,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
|
||||||
|
@ -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,36 @@ 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)
|
||||||
|
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
|
||||||
|
@ -21,6 +21,8 @@ from app.email_utils import (
|
|||||||
send_cannot_create_directory_alias_disabled,
|
send_cannot_create_directory_alias_disabled,
|
||||||
get_email_local_part,
|
get_email_local_part,
|
||||||
send_cannot_create_domain_alias,
|
send_cannot_create_domain_alias,
|
||||||
|
send_email,
|
||||||
|
render,
|
||||||
)
|
)
|
||||||
from app.errors import AliasInTrashError
|
from app.errors import AliasInTrashError
|
||||||
from app.log import LOG
|
from app.log import LOG
|
||||||
@ -36,6 +38,8 @@ from app.models import (
|
|||||||
EmailLog,
|
EmailLog,
|
||||||
Contact,
|
Contact,
|
||||||
AutoCreateRule,
|
AutoCreateRule,
|
||||||
|
AliasUsedOn,
|
||||||
|
ClientUser,
|
||||||
)
|
)
|
||||||
from app.regex_utils import regex_match
|
from app.regex_utils import regex_match
|
||||||
|
|
||||||
@ -57,6 +61,8 @@ def get_user_if_alias_would_auto_create(
|
|||||||
domain_and_rule = check_if_alias_can_be_auto_created_for_custom_domain(
|
domain_and_rule = check_if_alias_can_be_auto_created_for_custom_domain(
|
||||||
address, notify_user=notify_user
|
address, notify_user=notify_user
|
||||||
)
|
)
|
||||||
|
if DomainDeletedAlias.get_by(email=address):
|
||||||
|
return None
|
||||||
if domain_and_rule:
|
if domain_and_rule:
|
||||||
return domain_and_rule[0].user
|
return domain_and_rule[0].user
|
||||||
directory = check_if_alias_can_be_auto_created_for_a_directory(
|
directory = check_if_alias_can_be_auto_created_for_a_directory(
|
||||||
@ -397,3 +403,58 @@ def alias_export_csv(user, csv_direct_export=False):
|
|||||||
output.headers["Content-Disposition"] = "attachment; filename=aliases.csv"
|
output.headers["Content-Disposition"] = "attachment; filename=aliases.csv"
|
||||||
output.headers["Content-type"] = "text/csv"
|
output.headers["Content-type"] = "text/csv"
|
||||||
return output
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def transfer_alias(alias, new_user, new_mailboxes: [Mailbox]):
|
||||||
|
# cannot transfer alias which is used for receiving newsletter
|
||||||
|
if User.get_by(newsletter_alias_id=alias.id):
|
||||||
|
raise Exception("Cannot transfer alias that's used to receive newsletter")
|
||||||
|
|
||||||
|
# update user_id
|
||||||
|
Session.query(Contact).filter(Contact.alias_id == alias.id).update(
|
||||||
|
{"user_id": new_user.id}
|
||||||
|
)
|
||||||
|
|
||||||
|
Session.query(AliasUsedOn).filter(AliasUsedOn.alias_id == alias.id).update(
|
||||||
|
{"user_id": new_user.id}
|
||||||
|
)
|
||||||
|
|
||||||
|
Session.query(ClientUser).filter(ClientUser.alias_id == alias.id).update(
|
||||||
|
{"user_id": new_user.id}
|
||||||
|
)
|
||||||
|
|
||||||
|
# remove existing mailboxes from the alias
|
||||||
|
Session.query(AliasMailbox).filter(AliasMailbox.alias_id == alias.id).delete()
|
||||||
|
|
||||||
|
# set mailboxes
|
||||||
|
alias.mailbox_id = new_mailboxes.pop().id
|
||||||
|
for mb in new_mailboxes:
|
||||||
|
AliasMailbox.create(alias_id=alias.id, mailbox_id=mb.id)
|
||||||
|
|
||||||
|
# alias has never been transferred before
|
||||||
|
if not alias.original_owner_id:
|
||||||
|
alias.original_owner_id = alias.user_id
|
||||||
|
|
||||||
|
# inform previous owner
|
||||||
|
old_user = alias.user
|
||||||
|
send_email(
|
||||||
|
old_user.email,
|
||||||
|
f"Alias {alias.email} has been received",
|
||||||
|
render(
|
||||||
|
"transactional/alias-transferred.txt",
|
||||||
|
alias=alias,
|
||||||
|
),
|
||||||
|
render(
|
||||||
|
"transactional/alias-transferred.html",
|
||||||
|
alias=alias,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# now the alias belongs to the new user
|
||||||
|
alias.user_id = new_user.id
|
||||||
|
|
||||||
|
# set some fields back to default
|
||||||
|
alias.disable_pgp = False
|
||||||
|
alias.pinned = False
|
||||||
|
|
||||||
|
Session.commit()
|
||||||
|
@ -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
|
||||||
|
@ -63,6 +63,11 @@ def auth_login():
|
|||||||
elif user.disabled:
|
elif user.disabled:
|
||||||
LoginEvent(LoginEvent.ActionType.disabled_login, LoginEvent.Source.api).send()
|
LoginEvent(LoginEvent.ActionType.disabled_login, LoginEvent.Source.api).send()
|
||||||
return jsonify(error="Account disabled"), 400
|
return jsonify(error="Account disabled"), 400
|
||||||
|
elif user.delete_on is not None:
|
||||||
|
LoginEvent(
|
||||||
|
LoginEvent.ActionType.scheduled_to_be_deleted, LoginEvent.Source.api
|
||||||
|
).send()
|
||||||
|
return jsonify(error="Account scheduled for deletion"), 400
|
||||||
elif not user.activated:
|
elif not user.activated:
|
||||||
LoginEvent(LoginEvent.ActionType.not_activated, LoginEvent.Source.api).send()
|
LoginEvent(LoginEvent.ActionType.not_activated, LoginEvent.Source.api).send()
|
||||||
return jsonify(error="Account not activated"), 422
|
return jsonify(error="Account not activated"), 422
|
||||||
@ -357,7 +362,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
|
||||||
|
@ -13,8 +13,8 @@ from app.db import Session
|
|||||||
from app.email_utils import (
|
from app.email_utils import (
|
||||||
mailbox_already_used,
|
mailbox_already_used,
|
||||||
email_can_be_used_as_mailbox,
|
email_can_be_used_as_mailbox,
|
||||||
is_valid_email,
|
|
||||||
)
|
)
|
||||||
|
from app.email_validation import is_valid_email
|
||||||
from app.log import LOG
|
from app.log import LOG
|
||||||
from app.models import Mailbox, Job
|
from app.models import Mailbox, Job
|
||||||
from app.utils import sanitize_email
|
from app.utils import sanitize_email
|
||||||
@ -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,
|
||||||
)
|
)
|
||||||
|
@ -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))
|
||||||
|
@ -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)
|
||||||
|
@ -54,6 +54,12 @@ def login():
|
|||||||
"error",
|
"error",
|
||||||
)
|
)
|
||||||
LoginEvent(LoginEvent.ActionType.disabled_login).send()
|
LoginEvent(LoginEvent.ActionType.disabled_login).send()
|
||||||
|
elif user.delete_on is not None:
|
||||||
|
flash(
|
||||||
|
f"Your account is scheduled to be deleted on {user.delete_on}",
|
||||||
|
"error",
|
||||||
|
)
|
||||||
|
LoginEvent(LoginEvent.ActionType.scheduled_to_be_deleted).send()
|
||||||
elif not user.activated:
|
elif not user.activated:
|
||||||
show_resend_activation = True
|
show_resend_activation = True
|
||||||
flash(
|
flash(
|
||||||
|
@ -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())
|
||||||
|
@ -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,10 @@ 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)
|
||||||
|
MAX_API_KEYS = int(os.environ.get("MAX_API_KEYS", 30))
|
||||||
|
|
||||||
|
UPCLOUD_USERNAME = os.environ.get("UPCLOUD_USERNAME", None)
|
||||||
|
UPCLOUD_PASSWORD = os.environ.get("UPCLOUD_PASSWORD", None)
|
||||||
|
UPCLOUD_DB_ID = os.environ.get("UPCLOUD_DB_ID", None)
|
||||||
|
@ -13,10 +13,10 @@ from app import config, parallel_limiter
|
|||||||
from app.dashboard.base import dashboard_bp
|
from app.dashboard.base import dashboard_bp
|
||||||
from app.db import Session
|
from app.db import Session
|
||||||
from app.email_utils import (
|
from app.email_utils import (
|
||||||
is_valid_email,
|
|
||||||
generate_reply_email,
|
generate_reply_email,
|
||||||
parse_full_address,
|
parse_full_address,
|
||||||
)
|
)
|
||||||
|
from app.email_validation import is_valid_email
|
||||||
from app.errors import (
|
from app.errors import (
|
||||||
CannotCreateContactForReverseAlias,
|
CannotCreateContactForReverseAlias,
|
||||||
ErrContactErrorUpgradeNeeded,
|
ErrContactErrorUpgradeNeeded,
|
||||||
@ -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(
|
||||||
|
@ -7,79 +7,19 @@ from flask import render_template, redirect, url_for, flash, request
|
|||||||
from flask_login import login_required, current_user
|
from flask_login import login_required, current_user
|
||||||
|
|
||||||
from app import config
|
from app import config
|
||||||
|
from app.alias_utils import transfer_alias
|
||||||
from app.dashboard.base import dashboard_bp
|
from app.dashboard.base import dashboard_bp
|
||||||
from app.dashboard.views.enter_sudo import sudo_required
|
from app.dashboard.views.enter_sudo import sudo_required
|
||||||
from app.db import Session
|
from app.db import Session
|
||||||
from app.email_utils import send_email, render
|
|
||||||
from app.extensions import limiter
|
from app.extensions import limiter
|
||||||
from app.log import LOG
|
from app.log import LOG
|
||||||
from app.models import (
|
from app.models import (
|
||||||
Alias,
|
Alias,
|
||||||
Contact,
|
|
||||||
AliasUsedOn,
|
|
||||||
AliasMailbox,
|
|
||||||
User,
|
|
||||||
ClientUser,
|
|
||||||
)
|
)
|
||||||
from app.models import Mailbox
|
from app.models import Mailbox
|
||||||
from app.utils import CSRFValidationForm
|
from app.utils import CSRFValidationForm
|
||||||
|
|
||||||
|
|
||||||
def transfer(alias, new_user, new_mailboxes: [Mailbox]):
|
|
||||||
# cannot transfer alias which is used for receiving newsletter
|
|
||||||
if User.get_by(newsletter_alias_id=alias.id):
|
|
||||||
raise Exception("Cannot transfer alias that's used to receive newsletter")
|
|
||||||
|
|
||||||
# update user_id
|
|
||||||
Session.query(Contact).filter(Contact.alias_id == alias.id).update(
|
|
||||||
{"user_id": new_user.id}
|
|
||||||
)
|
|
||||||
|
|
||||||
Session.query(AliasUsedOn).filter(AliasUsedOn.alias_id == alias.id).update(
|
|
||||||
{"user_id": new_user.id}
|
|
||||||
)
|
|
||||||
|
|
||||||
Session.query(ClientUser).filter(ClientUser.alias_id == alias.id).update(
|
|
||||||
{"user_id": new_user.id}
|
|
||||||
)
|
|
||||||
|
|
||||||
# remove existing mailboxes from the alias
|
|
||||||
Session.query(AliasMailbox).filter(AliasMailbox.alias_id == alias.id).delete()
|
|
||||||
|
|
||||||
# set mailboxes
|
|
||||||
alias.mailbox_id = new_mailboxes.pop().id
|
|
||||||
for mb in new_mailboxes:
|
|
||||||
AliasMailbox.create(alias_id=alias.id, mailbox_id=mb.id)
|
|
||||||
|
|
||||||
# alias has never been transferred before
|
|
||||||
if not alias.original_owner_id:
|
|
||||||
alias.original_owner_id = alias.user_id
|
|
||||||
|
|
||||||
# inform previous owner
|
|
||||||
old_user = alias.user
|
|
||||||
send_email(
|
|
||||||
old_user.email,
|
|
||||||
f"Alias {alias.email} has been received",
|
|
||||||
render(
|
|
||||||
"transactional/alias-transferred.txt",
|
|
||||||
alias=alias,
|
|
||||||
),
|
|
||||||
render(
|
|
||||||
"transactional/alias-transferred.html",
|
|
||||||
alias=alias,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
# now the alias belongs to the new user
|
|
||||||
alias.user_id = new_user.id
|
|
||||||
|
|
||||||
# set some fields back to default
|
|
||||||
alias.disable_pgp = False
|
|
||||||
alias.pinned = False
|
|
||||||
|
|
||||||
Session.commit()
|
|
||||||
|
|
||||||
|
|
||||||
def hmac_alias_transfer_token(transfer_token: str) -> str:
|
def hmac_alias_transfer_token(transfer_token: str) -> str:
|
||||||
alias_hmac = hmac.new(
|
alias_hmac = hmac.new(
|
||||||
config.ALIAS_TRANSFER_TOKEN_SECRET.encode("utf-8"),
|
config.ALIAS_TRANSFER_TOKEN_SECRET.encode("utf-8"),
|
||||||
@ -214,7 +154,13 @@ def alias_transfer_receive_route():
|
|||||||
mailboxes,
|
mailboxes,
|
||||||
token,
|
token,
|
||||||
)
|
)
|
||||||
transfer(alias, current_user, mailboxes)
|
transfer_alias(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))
|
||||||
|
|
||||||
|
@ -3,19 +3,47 @@ from flask_login import login_required, current_user
|
|||||||
from flask_wtf import FlaskForm
|
from flask_wtf import FlaskForm
|
||||||
from wtforms import StringField, validators
|
from wtforms import StringField, validators
|
||||||
|
|
||||||
|
from app import config
|
||||||
from app.dashboard.base import dashboard_bp
|
from app.dashboard.base import dashboard_bp
|
||||||
from app.dashboard.views.enter_sudo import sudo_required
|
from app.dashboard.views.enter_sudo import sudo_required
|
||||||
from app.db import Session
|
from app.db import Session
|
||||||
|
from app.extensions import limiter
|
||||||
from app.models import ApiKey
|
from app.models import ApiKey
|
||||||
|
from app.utils import CSRFValidationForm
|
||||||
|
|
||||||
|
|
||||||
class NewApiKeyForm(FlaskForm):
|
class NewApiKeyForm(FlaskForm):
|
||||||
name = StringField("Name", validators=[validators.DataRequired()])
|
name = StringField("Name", validators=[validators.DataRequired()])
|
||||||
|
|
||||||
|
|
||||||
|
def clean_up_unused_or_old_api_keys(user_id: int):
|
||||||
|
total_keys = ApiKey.filter_by(user_id=user_id).count()
|
||||||
|
if total_keys <= config.MAX_API_KEYS:
|
||||||
|
return
|
||||||
|
# Remove oldest unused
|
||||||
|
for api_key in (
|
||||||
|
ApiKey.filter_by(user_id=user_id, last_used=None)
|
||||||
|
.order_by(ApiKey.created_at.asc())
|
||||||
|
.all()
|
||||||
|
):
|
||||||
|
Session.delete(api_key)
|
||||||
|
total_keys -= 1
|
||||||
|
if total_keys <= config.MAX_API_KEYS:
|
||||||
|
return
|
||||||
|
# Clean up oldest used
|
||||||
|
for api_key in (
|
||||||
|
ApiKey.filter_by(user_id=user_id).order_by(ApiKey.last_used.asc()).all()
|
||||||
|
):
|
||||||
|
Session.delete(api_key)
|
||||||
|
total_keys -= 1
|
||||||
|
if total_keys <= config.MAX_API_KEYS:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
@dashboard_bp.route("/api_key", methods=["GET", "POST"])
|
@dashboard_bp.route("/api_key", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
@sudo_required
|
@sudo_required
|
||||||
|
@limiter.limit("10/hour")
|
||||||
def api_key():
|
def api_key():
|
||||||
api_keys = (
|
api_keys = (
|
||||||
ApiKey.filter(ApiKey.user_id == current_user.id)
|
ApiKey.filter(ApiKey.user_id == current_user.id)
|
||||||
@ -23,9 +51,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")
|
||||||
|
|
||||||
@ -45,6 +77,7 @@ def api_key():
|
|||||||
|
|
||||||
elif request.form.get("form-name") == "create":
|
elif request.form.get("form-name") == "create":
|
||||||
if new_api_key_form.validate():
|
if new_api_key_form.validate():
|
||||||
|
clean_up_unused_or_old_api_keys(current_user.id)
|
||||||
new_api_key = ApiKey.create(
|
new_api_key = ApiKey.create(
|
||||||
name=new_api_key_form.name.data, user_id=current_user.id
|
name=new_api_key_form.name.data, user_id=current_user.id
|
||||||
)
|
)
|
||||||
@ -62,5 +95,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,
|
||||||
)
|
)
|
||||||
|
@ -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",
|
||||||
|
@ -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
|
||||||
|
@ -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")
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
@ -8,6 +8,7 @@ from wtforms import PasswordField, validators
|
|||||||
|
|
||||||
from app.config import CONNECT_WITH_PROTON
|
from app.config import CONNECT_WITH_PROTON
|
||||||
from app.dashboard.base import dashboard_bp
|
from app.dashboard.base import dashboard_bp
|
||||||
|
from app.extensions import limiter
|
||||||
from app.log import LOG
|
from app.log import LOG
|
||||||
from app.models import PartnerUser
|
from app.models import PartnerUser
|
||||||
from app.proton.utils import get_proton_partner
|
from app.proton.utils import get_proton_partner
|
||||||
@ -21,6 +22,7 @@ class LoginForm(FlaskForm):
|
|||||||
|
|
||||||
|
|
||||||
@dashboard_bp.route("/enter_sudo", methods=["GET", "POST"])
|
@dashboard_bp.route("/enter_sudo", methods=["GET", "POST"])
|
||||||
|
@limiter.limit("3/minute")
|
||||||
@login_required
|
@login_required
|
||||||
def enter_sudo():
|
def enter_sudo():
|
||||||
password_check_form = LoginForm()
|
password_check_form = LoginForm()
|
||||||
|
@ -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()
|
||||||
|
@ -1,11 +1,16 @@
|
|||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import json
|
||||||
|
|
||||||
import arrow
|
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
|
||||||
@ -14,8 +19,8 @@ from app.email_utils import (
|
|||||||
mailbox_already_used,
|
mailbox_already_used,
|
||||||
render,
|
render,
|
||||||
send_email,
|
send_email,
|
||||||
is_valid_email,
|
|
||||||
)
|
)
|
||||||
|
from app.email_validation import is_valid_email
|
||||||
from app.log import LOG
|
from app.log import LOG
|
||||||
from app.models import Mailbox, Job
|
from app.models import Mailbox, Job
|
||||||
from app.utils import CSRFValidationForm
|
from app.utils import CSRFValidationForm
|
||||||
@ -27,8 +32,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 +51,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 +113,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 +168,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,39 +177,16 @@ 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()
|
encoded_data = json.dumps([mailbox.id, mailbox.email]).encode("utf-8")
|
||||||
|
b64_data = base64.urlsafe_b64encode(encoded_data)
|
||||||
|
mailbox_id_signed = s.sign(b64_data).decode()
|
||||||
verification_url = (
|
verification_url = (
|
||||||
URL + "/dashboard/mailbox_verify" + f"?mailbox_id={mailbox_id_signed}"
|
URL + "/dashboard/mailbox_verify" + f"?mailbox_id={mailbox_id_signed}"
|
||||||
)
|
)
|
||||||
@ -188,23 +210,35 @@ 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_verify_request = request.args.get("mailbox_id")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
r_id = int(s.unsign(mailbox_id))
|
mailbox_raw_data = s.unsign(mailbox_verify_request, 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"))
|
||||||
else:
|
try:
|
||||||
mailbox = Mailbox.get(r_id)
|
decoded_data = base64.urlsafe_b64decode(mailbox_raw_data)
|
||||||
if not mailbox:
|
except binascii.Error:
|
||||||
flash("Invalid link", "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"))
|
||||||
|
mailbox_data = json.loads(decoded_data)
|
||||||
|
if not isinstance(mailbox_data, list) or len(mailbox_data) != 2:
|
||||||
|
flash("Invalid link. Please delete and re-add your mailbox", "error")
|
||||||
|
return redirect(url_for("dashboard.mailbox_route"))
|
||||||
|
mailbox_id = mailbox_data[0]
|
||||||
|
mailbox = Mailbox.get(mailbox_id)
|
||||||
|
if not mailbox:
|
||||||
|
flash("Invalid link", "error")
|
||||||
|
return redirect(url_for("dashboard.mailbox_route"))
|
||||||
|
mailbox_email = mailbox_data[1]
|
||||||
|
if mailbox_email != mailbox.email:
|
||||||
|
flash("Invalid link", "error")
|
||||||
|
return redirect(url_for("dashboard.mailbox_route"))
|
||||||
|
|
||||||
mailbox.verified = True
|
mailbox.verified = True
|
||||||
Session.commit()
|
Session.commit()
|
||||||
|
|
||||||
LOG.d("Mailbox %s is verified", mailbox)
|
LOG.d("Mailbox %s is verified", mailbox)
|
||||||
|
|
||||||
return render_template("dashboard/mailbox_validation.html", mailbox=mailbox)
|
return render_template("dashboard/mailbox_validation.html", mailbox=mailbox)
|
||||||
|
@ -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
|
||||||
|
|
||||||
@ -30,7 +30,7 @@ class ChangeEmailForm(FlaskForm):
|
|||||||
@dashboard_bp.route("/mailbox/<int:mailbox_id>/", methods=["GET", "POST"])
|
@dashboard_bp.route("/mailbox/<int:mailbox_id>/", methods=["GET", "POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def mailbox_detail_route(mailbox_id):
|
def mailbox_detail_route(mailbox_id):
|
||||||
mailbox = Mailbox.get(mailbox_id)
|
mailbox: Mailbox = Mailbox.get(mailbox_id)
|
||||||
if not mailbox or mailbox.user_id != current_user.id:
|
if not mailbox or mailbox.user_id != current_user.id:
|
||||||
flash("You cannot see this page", "warning")
|
flash("You cannot see this page", "warning")
|
||||||
return redirect(url_for("dashboard.index"))
|
return redirect(url_for("dashboard.index"))
|
||||||
@ -144,6 +144,15 @@ def mailbox_detail_route(mailbox_id):
|
|||||||
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
|
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if mailbox.is_proton():
|
||||||
|
flash(
|
||||||
|
"Enabling PGP for a Proton Mail mailbox is redundant and does not add any security benefit",
|
||||||
|
"info",
|
||||||
|
)
|
||||||
|
return redirect(
|
||||||
|
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
|
||||||
|
)
|
||||||
|
|
||||||
mailbox.pgp_public_key = request.form.get("pgp")
|
mailbox.pgp_public_key = request.form.get("pgp")
|
||||||
try:
|
try:
|
||||||
mailbox.pgp_finger_print = load_public_key_and_check(
|
mailbox.pgp_finger_print = load_public_key_and_check(
|
||||||
@ -182,25 +191,16 @@ def mailbox_detail_route(mailbox_id):
|
|||||||
)
|
)
|
||||||
elif request.form.get("form-name") == "generic-subject":
|
elif request.form.get("form-name") == "generic-subject":
|
||||||
if request.form.get("action") == "save":
|
if request.form.get("action") == "save":
|
||||||
if not mailbox.pgp_enabled():
|
|
||||||
flash(
|
|
||||||
"Generic subject can only be used on PGP-enabled mailbox",
|
|
||||||
"error",
|
|
||||||
)
|
|
||||||
return redirect(
|
|
||||||
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
mailbox.generic_subject = request.form.get("generic-subject")
|
mailbox.generic_subject = request.form.get("generic-subject")
|
||||||
Session.commit()
|
Session.commit()
|
||||||
flash("Generic subject for PGP-encrypted email is enabled", "success")
|
flash("Generic subject is enabled", "success")
|
||||||
return redirect(
|
return redirect(
|
||||||
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
|
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
|
||||||
)
|
)
|
||||||
elif request.form.get("action") == "remove":
|
elif request.form.get("action") == "remove":
|
||||||
mailbox.generic_subject = None
|
mailbox.generic_subject = None
|
||||||
Session.commit()
|
Session.commit()
|
||||||
flash("Generic subject for PGP-encrypted email is disabled", "success")
|
flash("Generic subject is disabled", "success")
|
||||||
return redirect(
|
return redirect(
|
||||||
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
|
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
|
||||||
)
|
)
|
||||||
@ -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"))
|
||||||
|
@ -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)
|
||||||
|
@ -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")
|
||||||
|
@ -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)
|
||||||
|
@ -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,
|
||||||
)
|
)
|
||||||
|
@ -34,7 +34,7 @@ def get_cname_record(hostname) -> Optional[str]:
|
|||||||
|
|
||||||
|
|
||||||
def get_mx_domains(hostname) -> [(int, str)]:
|
def get_mx_domains(hostname) -> [(int, str)]:
|
||||||
"""return list of (priority, domain name).
|
"""return list of (priority, domain name) sorted by priority (lowest priority first)
|
||||||
domain name ends with a "." at the end.
|
domain name ends with a "." at the end.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
@ -50,7 +50,7 @@ def get_mx_domains(hostname) -> [(int, str)]:
|
|||||||
|
|
||||||
ret.append((int(parts[0]), parts[1]))
|
ret.append((int(parts[0]), parts[1]))
|
||||||
|
|
||||||
return ret
|
return sorted(ret, key=lambda prio_domain: prio_domain[0])
|
||||||
|
|
||||||
|
|
||||||
_include_spf = "include:"
|
_include_spf = "include:"
|
||||||
|
@ -20,6 +20,7 @@ X_SPAM_STATUS = "X-Spam-Status"
|
|||||||
LIST_UNSUBSCRIBE = "List-Unsubscribe"
|
LIST_UNSUBSCRIBE = "List-Unsubscribe"
|
||||||
LIST_UNSUBSCRIBE_POST = "List-Unsubscribe-Post"
|
LIST_UNSUBSCRIBE_POST = "List-Unsubscribe-Post"
|
||||||
RETURN_PATH = "Return-Path"
|
RETURN_PATH = "Return-Path"
|
||||||
|
AUTHENTICATION_RESULTS = "Authentication-Results"
|
||||||
|
|
||||||
# headers used to DKIM sign in order of preference
|
# headers used to DKIM sign in order of preference
|
||||||
DKIM_HEADERS = [
|
DKIM_HEADERS = [
|
||||||
@ -32,6 +33,7 @@ DKIM_HEADERS = [
|
|||||||
SL_DIRECTION = "X-SimpleLogin-Type"
|
SL_DIRECTION = "X-SimpleLogin-Type"
|
||||||
SL_EMAIL_LOG_ID = "X-SimpleLogin-EmailLog-ID"
|
SL_EMAIL_LOG_ID = "X-SimpleLogin-EmailLog-ID"
|
||||||
SL_ENVELOPE_FROM = "X-SimpleLogin-Envelope-From"
|
SL_ENVELOPE_FROM = "X-SimpleLogin-Envelope-From"
|
||||||
|
SL_ORIGINAL_FROM = "X-SimpleLogin-Original-From"
|
||||||
SL_ENVELOPE_TO = "X-SimpleLogin-Envelope-To"
|
SL_ENVELOPE_TO = "X-SimpleLogin-Envelope-To"
|
||||||
SL_CLIENT_IP = "X-SimpleLogin-Client-IP"
|
SL_CLIENT_IP = "X-SimpleLogin-Client-IP"
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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,
|
||||||
@ -827,19 +828,6 @@ def should_add_dkim_signature(domain: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def is_valid_email(email_address: str) -> bool:
|
|
||||||
"""
|
|
||||||
Used to check whether an email address is valid
|
|
||||||
NOT run MX check.
|
|
||||||
NOT allow unicode.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
validate_email(email_address, check_deliverability=False, allow_smtputf8=False)
|
|
||||||
return True
|
|
||||||
except EmailNotValidError:
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class EmailEncoding(enum.Enum):
|
class EmailEncoding(enum.Enum):
|
||||||
BASE64 = "base64"
|
BASE64 = "base64"
|
||||||
QUOTED = "quoted-printable"
|
QUOTED = "quoted-printable"
|
||||||
@ -950,6 +938,8 @@ def add_header(msg: Message, text_header, html_header=None) -> Message:
|
|||||||
for part in msg.get_payload():
|
for part in msg.get_payload():
|
||||||
if isinstance(part, Message):
|
if isinstance(part, Message):
|
||||||
new_parts.append(add_header(part, text_header, html_header))
|
new_parts.append(add_header(part, text_header, html_header))
|
||||||
|
elif isinstance(part, str):
|
||||||
|
new_parts.append(MIMEText(part))
|
||||||
else:
|
else:
|
||||||
new_parts.append(part)
|
new_parts.append(part)
|
||||||
clone_msg = copy(msg)
|
clone_msg = copy(msg)
|
||||||
@ -958,7 +948,14 @@ def add_header(msg: Message, text_header, html_header=None) -> Message:
|
|||||||
|
|
||||||
elif content_type in ("multipart/mixed", "multipart/signed"):
|
elif content_type in ("multipart/mixed", "multipart/signed"):
|
||||||
new_parts = []
|
new_parts = []
|
||||||
parts = list(msg.get_payload())
|
payload = msg.get_payload()
|
||||||
|
if isinstance(payload, str):
|
||||||
|
# The message is badly formatted inject as new
|
||||||
|
new_parts = [MIMEText(text_header, "plain"), MIMEText(payload, "plain")]
|
||||||
|
clone_msg = copy(msg)
|
||||||
|
clone_msg.set_payload(new_parts)
|
||||||
|
return clone_msg
|
||||||
|
parts = list(payload)
|
||||||
LOG.d("only add header for the first part for %s", content_type)
|
LOG.d("only add header for the first part for %s", content_type)
|
||||||
for ix, part in enumerate(parts):
|
for ix, part in enumerate(parts):
|
||||||
if ix == 0:
|
if ix == 0:
|
||||||
@ -1043,7 +1040,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 +1051,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 +1066,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 +1079,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")
|
||||||
@ -1099,26 +1103,6 @@ def is_reverse_alias(address: str) -> bool:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# allow also + and @ that are present in a reply address
|
|
||||||
_ALLOWED_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.+@"
|
|
||||||
|
|
||||||
|
|
||||||
def normalize_reply_email(reply_email: str) -> str:
|
|
||||||
"""Handle the case where reply email contains *strange* char that was wrongly generated in the past"""
|
|
||||||
if not reply_email.isascii():
|
|
||||||
reply_email = convert_to_id(reply_email)
|
|
||||||
|
|
||||||
ret = []
|
|
||||||
# drop all control characters like shift, separator, etc
|
|
||||||
for c in reply_email:
|
|
||||||
if c not in _ALLOWED_CHARS:
|
|
||||||
ret.append("_")
|
|
||||||
else:
|
|
||||||
ret.append(c)
|
|
||||||
|
|
||||||
return "".join(ret)
|
|
||||||
|
|
||||||
|
|
||||||
def should_disable(alias: Alias) -> (bool, str):
|
def should_disable(alias: Alias) -> (bool, str):
|
||||||
"""
|
"""
|
||||||
Return whether an alias should be disabled and if yes, the reason why
|
Return whether an alias should be disabled and if yes, the reason why
|
||||||
|
38
app/app/email_validation.py
Normal file
38
app/app/email_validation.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
from email_validator import (
|
||||||
|
validate_email,
|
||||||
|
EmailNotValidError,
|
||||||
|
)
|
||||||
|
|
||||||
|
from app.utils import convert_to_id
|
||||||
|
|
||||||
|
# allow also + and @ that are present in a reply address
|
||||||
|
_ALLOWED_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.+@"
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_email(email_address: str) -> bool:
|
||||||
|
"""
|
||||||
|
Used to check whether an email address is valid
|
||||||
|
NOT run MX check.
|
||||||
|
NOT allow unicode.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
validate_email(email_address, check_deliverability=False, allow_smtputf8=False)
|
||||||
|
return True
|
||||||
|
except EmailNotValidError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_reply_email(reply_email: str) -> str:
|
||||||
|
"""Handle the case where reply email contains *strange* char that was wrongly generated in the past"""
|
||||||
|
if not reply_email.isascii():
|
||||||
|
reply_email = convert_to_id(reply_email)
|
||||||
|
|
||||||
|
ret = []
|
||||||
|
# drop all control characters like shift, separator, etc
|
||||||
|
for c in reply_email:
|
||||||
|
if c not in _ALLOWED_CHARS:
|
||||||
|
ret.append("_")
|
||||||
|
else:
|
||||||
|
ret.append(c)
|
||||||
|
|
||||||
|
return "".join(ret)
|
@ -71,7 +71,7 @@ class ErrContactErrorUpgradeNeeded(SLException):
|
|||||||
"""raised when user cannot create a contact because the plan doesn't allow it"""
|
"""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):
|
||||||
@ -84,6 +84,14 @@ class ErrAddressInvalid(SLException):
|
|||||||
return f"{self.address} is not a valid email address"
|
return f"{self.address} is not a valid email address"
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidContactEmailError(SLException):
|
||||||
|
def __init__(self, website_email: str): # noqa: F821
|
||||||
|
self.website_email = website_email
|
||||||
|
|
||||||
|
def error_for_user(self) -> str:
|
||||||
|
return f"Cannot create contact with invalid email {self.website_email}"
|
||||||
|
|
||||||
|
|
||||||
class ErrContactAlreadyExists(SLException):
|
class ErrContactAlreadyExists(SLException):
|
||||||
"""raised when a contact already exists"""
|
"""raised when a contact already exists"""
|
||||||
|
|
||||||
@ -108,3 +116,15 @@ 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")
|
||||||
|
|
||||||
|
|
||||||
|
class ProtonAccountNotVerified(LinkException):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(
|
||||||
|
"The Proton account you are trying to use has not been verified"
|
||||||
|
)
|
||||||
|
@ -9,6 +9,7 @@ class LoginEvent:
|
|||||||
failed = 1
|
failed = 1
|
||||||
disabled_login = 2
|
disabled_login = 2
|
||||||
not_activated = 3
|
not_activated = 3
|
||||||
|
scheduled_to_be_deleted = 4
|
||||||
|
|
||||||
class Source(EnumE):
|
class Source(EnumE):
|
||||||
web = 0
|
web = 0
|
||||||
|
@ -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)
|
||||||
|
|
||||||
@ -72,8 +74,8 @@ class UnsubscribeEncoder:
|
|||||||
)
|
)
|
||||||
signed_data = cls._get_signer().sign(serialized_data).decode("utf-8")
|
signed_data = cls._get_signer().sign(serialized_data).decode("utf-8")
|
||||||
encoded_request = f"{UNSUB_PREFIX}.{signed_data}"
|
encoded_request = f"{UNSUB_PREFIX}.{signed_data}"
|
||||||
if len(encoded_request) > 256:
|
if len(encoded_request) > 512:
|
||||||
LOG.e("Encoded request is longer than 256 chars")
|
LOG.w("Encoded request is longer than 512 chars")
|
||||||
return encoded_request
|
return encoded_request
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import urllib
|
import urllib
|
||||||
|
from email.header import Header
|
||||||
from email.message import Message
|
from email.message import Message
|
||||||
|
|
||||||
from app.email import headers
|
from app.email import headers
|
||||||
@ -9,6 +10,7 @@ from app.handler.unsubscribe_encoder import (
|
|||||||
UnsubscribeData,
|
UnsubscribeData,
|
||||||
UnsubscribeOriginalData,
|
UnsubscribeOriginalData,
|
||||||
)
|
)
|
||||||
|
from app.log import LOG
|
||||||
from app.models import Alias, Contact, UnsubscribeBehaviourEnum
|
from app.models import Alias, Contact, UnsubscribeBehaviourEnum
|
||||||
|
|
||||||
|
|
||||||
@ -30,7 +32,10 @@ class UnsubscribeGenerator:
|
|||||||
"""
|
"""
|
||||||
unsubscribe_data = message[headers.LIST_UNSUBSCRIBE]
|
unsubscribe_data = message[headers.LIST_UNSUBSCRIBE]
|
||||||
if not unsubscribe_data:
|
if not unsubscribe_data:
|
||||||
|
LOG.info("Email has no unsubscribe header")
|
||||||
return message
|
return message
|
||||||
|
if isinstance(unsubscribe_data, Header):
|
||||||
|
unsubscribe_data = str(unsubscribe_data.encode())
|
||||||
raw_methods = [method.strip() for method in unsubscribe_data.split(",")]
|
raw_methods = [method.strip() for method in unsubscribe_data.split(",")]
|
||||||
mailto_unsubs = None
|
mailto_unsubs = None
|
||||||
other_unsubs = []
|
other_unsubs = []
|
||||||
@ -44,7 +49,9 @@ class UnsubscribeGenerator:
|
|||||||
if url_data.scheme == "mailto":
|
if url_data.scheme == "mailto":
|
||||||
query_data = urllib.parse.parse_qs(url_data.query)
|
query_data = urllib.parse.parse_qs(url_data.query)
|
||||||
mailto_unsubs = (url_data.path, query_data.get("subject", [""])[0])
|
mailto_unsubs = (url_data.path, query_data.get("subject", [""])[0])
|
||||||
|
LOG.debug(f"Unsub is mailto to {mailto_unsubs}")
|
||||||
else:
|
else:
|
||||||
|
LOG.debug(f"Unsub has {url_data.scheme} scheme")
|
||||||
other_unsubs.append(method)
|
other_unsubs.append(method)
|
||||||
# If there are non mailto unsubscribe methods, use those in the header
|
# If there are non mailto unsubscribe methods, use those in the header
|
||||||
if other_unsubs:
|
if other_unsubs:
|
||||||
@ -56,18 +63,19 @@ class UnsubscribeGenerator:
|
|||||||
add_or_replace_header(
|
add_or_replace_header(
|
||||||
message, headers.LIST_UNSUBSCRIBE_POST, "List-Unsubscribe=One-Click"
|
message, headers.LIST_UNSUBSCRIBE_POST, "List-Unsubscribe=One-Click"
|
||||||
)
|
)
|
||||||
|
LOG.debug(f"Adding click unsub methods to header {other_unsubs}")
|
||||||
return message
|
return message
|
||||||
if not mailto_unsubs:
|
elif not mailto_unsubs:
|
||||||
message = delete_header(message, headers.LIST_UNSUBSCRIBE)
|
LOG.debug("No unsubs. Deleting all unsub headers")
|
||||||
message = delete_header(message, headers.LIST_UNSUBSCRIBE_POST)
|
delete_header(message, headers.LIST_UNSUBSCRIBE)
|
||||||
|
delete_header(message, headers.LIST_UNSUBSCRIBE_POST)
|
||||||
return message
|
return message
|
||||||
return self._add_unsubscribe_header(
|
unsub_data = UnsubscribeData(
|
||||||
message,
|
UnsubscribeAction.OriginalUnsubscribeMailto,
|
||||||
UnsubscribeData(
|
UnsubscribeOriginalData(alias.id, mailto_unsubs[0], mailto_unsubs[1]),
|
||||||
UnsubscribeAction.OriginalUnsubscribeMailto,
|
|
||||||
UnsubscribeOriginalData(alias.id, mailto_unsubs[0], mailto_unsubs[1]),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
LOG.debug(f"Adding unsub data {unsub_data}")
|
||||||
|
return self._add_unsubscribe_header(message, unsub_data)
|
||||||
|
|
||||||
def _add_unsubscribe_header(
|
def _add_unsubscribe_header(
|
||||||
self, message: Message, unsub: UnsubscribeData
|
self, message: Message, unsub: UnsubscribeData
|
||||||
|
@ -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:
|
||||||
|
@ -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",),
|
||||||
}
|
}
|
||||||
|
@ -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:
|
||||||
@ -45,6 +46,7 @@ class SendRequest:
|
|||||||
"mail_options": self.mail_options,
|
"mail_options": self.mail_options,
|
||||||
"rcpt_options": self.rcpt_options,
|
"rcpt_options": self.rcpt_options,
|
||||||
"is_forward": self.is_forward,
|
"is_forward": self.is_forward,
|
||||||
|
"retries": self.retries,
|
||||||
}
|
}
|
||||||
return json.dumps(data).encode("utf-8")
|
return json.dumps(data).encode("utf-8")
|
||||||
|
|
||||||
@ -65,8 +67,33 @@ class SendRequest:
|
|||||||
mail_options=decoded_data["mail_options"],
|
mail_options=decoded_data["mail_options"],
|
||||||
rcpt_options=decoded_data["rcpt_options"],
|
rcpt_options=decoded_data["rcpt_options"],
|
||||||
is_forward=decoded_data["is_forward"],
|
is_forward=decoded_data["is_forward"],
|
||||||
|
retries=decoded_data.get("retries", 1),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def save_request_to_unsent_dir(self, prefix: str = "DeliveryFail"):
|
||||||
|
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 +144,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 +195,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 +234,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 +246,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 +282,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,
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
@ -29,6 +30,8 @@ from sqlalchemy_utils import ArrowType
|
|||||||
from app import config
|
from app import config
|
||||||
from app import s3
|
from app import s3
|
||||||
from app.db import Session
|
from app.db import Session
|
||||||
|
from app.dns_utils import get_mx_domains
|
||||||
|
|
||||||
from app.errors import (
|
from app.errors import (
|
||||||
AliasInTrashError,
|
AliasInTrashError,
|
||||||
DirectoryInTrashError,
|
DirectoryInTrashError,
|
||||||
@ -44,7 +47,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 +276,13 @@ 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
|
||||||
|
show_partner_premium: Optional[bool] = 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 +301,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)
|
||||||
@ -333,7 +344,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
|||||||
sa.Boolean, default=True, nullable=False, server_default="1"
|
sa.Boolean, default=True, nullable=False, server_default="1"
|
||||||
)
|
)
|
||||||
|
|
||||||
activated = sa.Column(sa.Boolean, default=False, nullable=False)
|
activated = sa.Column(sa.Boolean, default=False, nullable=False, index=True)
|
||||||
|
|
||||||
# an account can be disabled if having harmful behavior
|
# an account can be disabled if having harmful behavior
|
||||||
disabled = sa.Column(sa.Boolean, default=False, nullable=False, server_default="0")
|
disabled = sa.Column(sa.Boolean, default=False, nullable=False, server_default="0")
|
||||||
@ -403,7 +414,10 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
|||||||
)
|
)
|
||||||
|
|
||||||
referral_id = sa.Column(
|
referral_id = sa.Column(
|
||||||
sa.ForeignKey("referral.id", ondelete="SET NULL"), nullable=True, default=None
|
sa.ForeignKey("referral.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
default=None,
|
||||||
|
index=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
referral = orm.relationship("Referral", foreign_keys=[referral_id])
|
referral = orm.relationship("Referral", foreign_keys=[referral_id])
|
||||||
@ -420,7 +434,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
|
||||||
@ -434,7 +451,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
|||||||
random_alias_suffix = sa.Column(
|
random_alias_suffix = sa.Column(
|
||||||
sa.Integer,
|
sa.Integer,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
default=AliasSuffixEnum.random_string.value,
|
default=AliasSuffixEnum.word.value,
|
||||||
server_default=str(AliasSuffixEnum.random_string.value),
|
server_default=str(AliasSuffixEnum.random_string.value),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -503,9 +520,8 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
|||||||
server_default=BlockBehaviourEnum.return_2xx.name,
|
server_default=BlockBehaviourEnum.return_2xx.name,
|
||||||
)
|
)
|
||||||
|
|
||||||
# to keep existing behavior, the server default is TRUE whereas for new user, the default value is FALSE
|
|
||||||
include_header_email_header = sa.Column(
|
include_header_email_header = sa.Column(
|
||||||
sa.Boolean, default=False, nullable=False, server_default="1"
|
sa.Boolean, default=True, nullable=False, server_default="1"
|
||||||
)
|
)
|
||||||
|
|
||||||
# bitwise flags. Allow for future expansion
|
# bitwise flags. Allow for future expansion
|
||||||
@ -519,11 +535,21 @@ 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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Trigger hard deletion of the account at this time
|
||||||
|
delete_on = sa.Column(ArrowType, default=None)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
sa.Index(
|
||||||
|
"ix_users_activated_trial_end_lifetime", activated, trial_end, lifetime
|
||||||
|
),
|
||||||
|
sa.Index("ix_users_delete_on", delete_on),
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def directory_quota(self):
|
def directory_quota(self):
|
||||||
return min(
|
return min(
|
||||||
@ -558,7 +584,8 @@ 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)
|
email = sanitize_email(email)
|
||||||
|
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 +596,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 +614,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 +652,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 +680,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 +742,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:
|
||||||
@ -790,6 +838,17 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
|
|||||||
< self.max_alias_for_free_account()
|
< self.max_alias_for_free_account()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def can_send_or_receive(self) -> bool:
|
||||||
|
if self.disabled:
|
||||||
|
LOG.i(f"User {self} is disabled. Cannot receive or send emails")
|
||||||
|
return False
|
||||||
|
if self.delete_on is not None:
|
||||||
|
LOG.i(
|
||||||
|
f"User {self} is scheduled to be deleted. Cannot receive or send emails"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
def profile_picture_url(self):
|
def profile_picture_url(self):
|
||||||
if self.profile_picture_id:
|
if self.profile_picture_id:
|
||||||
return self.profile_picture.get_url()
|
return self.profile_picture.get_url()
|
||||||
@ -868,14 +927,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 +1021,65 @@ 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 alias_options is None:
|
||||||
|
alias_options = AliasOptions()
|
||||||
|
top_conds = [SLDomain.hidden == False] # noqa: E712
|
||||||
|
or_conds = [] # noqa:E711
|
||||||
|
if self.default_alias_public_domain_id is not None:
|
||||||
|
default_domain_conds = [SLDomain.id == self.default_alias_public_domain_id]
|
||||||
|
if not self.is_premium():
|
||||||
|
default_domain_conds.append(
|
||||||
|
SLDomain.premium_only == False # noqa: E712
|
||||||
|
)
|
||||||
|
or_conds.append(and_(*default_domain_conds).self_group())
|
||||||
|
if alias_options.show_partner_domains is not None:
|
||||||
|
partner_user = PartnerUser.filter_by(
|
||||||
|
user_id=self.id, partner_id=alias_options.show_partner_domains.id
|
||||||
|
).first()
|
||||||
|
if partner_user is not None:
|
||||||
|
partner_domain_cond = [SLDomain.partner_id == partner_user.partner_id]
|
||||||
|
if alias_options.show_partner_premium is None:
|
||||||
|
alias_options.show_partner_premium = self.is_premium()
|
||||||
|
if not alias_options.show_partner_premium:
|
||||||
|
partner_domain_cond.append(
|
||||||
|
SLDomain.premium_only == False # noqa: E712
|
||||||
|
)
|
||||||
|
or_conds.append(and_(*partner_domain_cond).self_group())
|
||||||
|
if alias_options.show_sl_domains:
|
||||||
|
sl_conds = [SLDomain.partner_id == None] # noqa: E711
|
||||||
|
if not self.is_premium():
|
||||||
|
sl_conds.append(SLDomain.premium_only == False) # noqa: E712
|
||||||
|
or_conds.append(and_(*sl_conds).self_group())
|
||||||
|
top_conds.append(or_(*or_conds))
|
||||||
|
query = Session.query(SLDomain).filter(*top_conds).order_by(SLDomain.order)
|
||||||
|
return query.all()
|
||||||
|
|
||||||
if self.is_premium():
|
def available_alias_domains(
|
||||||
return query.all()
|
self, alias_options: Optional[AliasOptions] = None
|
||||||
else:
|
) -> [str]:
|
||||||
return query.filter_by(premium_only=False).all()
|
|
||||||
|
|
||||||
def available_alias_domains(self) -> [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 +1097,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 +1356,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):
|
||||||
@ -1364,7 +1479,7 @@ class Alias(Base, ModelMixin):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# have I been pwned
|
# have I been pwned
|
||||||
hibp_last_check = sa.Column(ArrowType, default=None)
|
hibp_last_check = sa.Column(ArrowType, default=None, index=True)
|
||||||
hibp_breaches = orm.relationship("Hibp", secondary="alias_hibp")
|
hibp_breaches = orm.relationship("Hibp", secondary="alias_hibp")
|
||||||
|
|
||||||
# to use Postgres full text search. Only applied on "note" column for now
|
# to use Postgres full text search. Only applied on "note" column for now
|
||||||
@ -1481,7 +1596,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 +1625,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 +1633,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 +1672,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 +1758,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 +1793,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)
|
||||||
@ -1828,6 +1947,7 @@ class Contact(Base, ModelMixin):
|
|||||||
|
|
||||||
class EmailLog(Base, ModelMixin):
|
class EmailLog(Base, ModelMixin):
|
||||||
__tablename__ = "email_log"
|
__tablename__ = "email_log"
|
||||||
|
__table_args__ = (Index("ix_email_log_created_at", "created_at"),)
|
||||||
|
|
||||||
user_id = sa.Column(
|
user_id = sa.Column(
|
||||||
sa.ForeignKey(User.id, ondelete="cascade"), nullable=False, index=True
|
sa.ForeignKey(User.id, ondelete="cascade"), nullable=False, index=True
|
||||||
@ -2085,7 +2205,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)
|
||||||
@ -2204,6 +2326,7 @@ class CustomDomain(Base, ModelMixin):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def create(cls, **kwargs):
|
def create(cls, **kwargs):
|
||||||
domain = kwargs.get("domain")
|
domain = kwargs.get("domain")
|
||||||
|
kwargs["domain"] = domain.replace("\n", "")
|
||||||
if DeletedSubdomain.get_by(domain=domain):
|
if DeletedSubdomain.get_by(domain=domain):
|
||||||
raise SubdomainInTrashError
|
raise SubdomainInTrashError
|
||||||
|
|
||||||
@ -2471,6 +2594,28 @@ class Mailbox(Base, ModelMixin):
|
|||||||
+ Alias.filter_by(mailbox_id=self.id).count()
|
+ Alias.filter_by(mailbox_id=self.id).count()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def is_proton(self) -> bool:
|
||||||
|
if (
|
||||||
|
self.email.endswith("@proton.me")
|
||||||
|
or self.email.endswith("@protonmail.com")
|
||||||
|
or self.email.endswith("@protonmail.ch")
|
||||||
|
or self.email.endswith("@proton.ch")
|
||||||
|
or self.email.endswith("@pm.me")
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
|
||||||
|
from app.email_utils import get_email_local_part
|
||||||
|
|
||||||
|
mx_domains: [(int, str)] = get_mx_domains(get_email_local_part(self.email))
|
||||||
|
# Proton is the first domain
|
||||||
|
if mx_domains and mx_domains[0][1] in (
|
||||||
|
"mail.protonmail.ch.",
|
||||||
|
"mailsec.protonmail.ch.",
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def delete(cls, obj_id):
|
def delete(cls, obj_id):
|
||||||
mailbox: Mailbox = cls.get(obj_id)
|
mailbox: Mailbox = cls.get(obj_id)
|
||||||
@ -2503,6 +2648,12 @@ class Mailbox(Base, ModelMixin):
|
|||||||
|
|
||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(cls, **kw):
|
||||||
|
if "email" in kw:
|
||||||
|
kw["email"] = sanitize_email(kw["email"])
|
||||||
|
return super().create(**kw)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<Mailbox {self.id} {self.email}>"
|
return f"<Mailbox {self.id} {self.email}>"
|
||||||
|
|
||||||
@ -2762,6 +2913,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 +2955,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'}"
|
||||||
|
|
||||||
@ -2805,6 +2992,8 @@ class Monitoring(Base, ModelMixin):
|
|||||||
active_queue = sa.Column(sa.Integer, nullable=False)
|
active_queue = sa.Column(sa.Integer, nullable=False)
|
||||||
deferred_queue = sa.Column(sa.Integer, nullable=False)
|
deferred_queue = sa.Column(sa.Integer, nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (Index("ix_monitoring_created_at", "created_at"),)
|
||||||
|
|
||||||
|
|
||||||
class BatchImport(Base, ModelMixin):
|
class BatchImport(Base, ModelMixin):
|
||||||
__tablename__ = "batch_import"
|
__tablename__ = "batch_import"
|
||||||
@ -2930,6 +3119,8 @@ class Bounce(Base, ModelMixin):
|
|||||||
email = sa.Column(sa.String(256), nullable=False, index=True)
|
email = sa.Column(sa.String(256), nullable=False, index=True)
|
||||||
info = sa.Column(sa.Text, nullable=True)
|
info = sa.Column(sa.Text, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (sa.Index("ix_bounce_created_at", "created_at"),)
|
||||||
|
|
||||||
|
|
||||||
class TransactionalEmail(Base, ModelMixin):
|
class TransactionalEmail(Base, ModelMixin):
|
||||||
"""Storing all email addresses that receive transactional emails, including account email and mailboxes.
|
"""Storing all email addresses that receive transactional emails, including account email and mailboxes.
|
||||||
@ -2939,6 +3130,8 @@ class TransactionalEmail(Base, ModelMixin):
|
|||||||
__tablename__ = "transactional_email"
|
__tablename__ = "transactional_email"
|
||||||
email = sa.Column(sa.String(256), nullable=False, unique=False)
|
email = sa.Column(sa.String(256), nullable=False, unique=False)
|
||||||
|
|
||||||
|
__table_args__ = (sa.Index("ix_transactional_email_created_at", "created_at"),)
|
||||||
|
|
||||||
|
|
||||||
class Payout(Base, ModelMixin):
|
class Payout(Base, ModelMixin):
|
||||||
"""Referral payouts"""
|
"""Referral payouts"""
|
||||||
@ -2991,7 +3184,7 @@ class MessageIDMatching(Base, ModelMixin):
|
|||||||
|
|
||||||
# to track what email_log that has created this matching
|
# to track what email_log that has created this matching
|
||||||
email_log_id = sa.Column(
|
email_log_id = sa.Column(
|
||||||
sa.ForeignKey("email_log.id", ondelete="cascade"), nullable=True
|
sa.ForeignKey("email_log.id", ondelete="cascade"), nullable=True, index=True
|
||||||
)
|
)
|
||||||
|
|
||||||
email_log = orm.relationship("EmailLog")
|
email_log = orm.relationship("EmailLog")
|
||||||
@ -3225,31 +3418,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 +3487,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)
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -7,11 +7,12 @@ from typing import Optional
|
|||||||
|
|
||||||
from app.account_linking import SLPlan, SLPlanType
|
from app.account_linking import SLPlan, SLPlanType
|
||||||
from app.config import PROTON_EXTRA_HEADER_NAME, PROTON_EXTRA_HEADER_VALUE
|
from app.config import PROTON_EXTRA_HEADER_NAME, PROTON_EXTRA_HEADER_VALUE
|
||||||
|
from app.errors import ProtonAccountNotVerified
|
||||||
from app.log import LOG
|
from app.log import LOG
|
||||||
|
|
||||||
_APP_VERSION = "OauthClient_1.0.0"
|
_APP_VERSION = "OauthClient_1.0.0"
|
||||||
|
|
||||||
PROTON_ERROR_CODE_NOT_EXISTS = 2501
|
PROTON_ERROR_CODE_HV_NEEDED = 9001
|
||||||
|
|
||||||
PLAN_FREE = 1
|
PLAN_FREE = 1
|
||||||
PLAN_PREMIUM = 2
|
PLAN_PREMIUM = 2
|
||||||
@ -57,6 +58,15 @@ def convert_access_token(access_token_response: str) -> AccessCredentials:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def handle_response_not_ok(status: int, body: dict, text: str) -> Exception:
|
||||||
|
if status == HTTPStatus.UNPROCESSABLE_ENTITY:
|
||||||
|
res_code = body.get("Code")
|
||||||
|
if res_code == PROTON_ERROR_CODE_HV_NEEDED:
|
||||||
|
return ProtonAccountNotVerified()
|
||||||
|
|
||||||
|
return Exception(f"Unexpected status code. Wanted 200 and got {status}: " + text)
|
||||||
|
|
||||||
|
|
||||||
class ProtonClient(ABC):
|
class ProtonClient(ABC):
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def get_user(self) -> Optional[UserInformation]:
|
def get_user(self) -> Optional[UserInformation]:
|
||||||
@ -124,11 +134,11 @@ class HttpProtonClient(ProtonClient):
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def __validate_response(res: Response) -> dict:
|
def __validate_response(res: Response) -> dict:
|
||||||
status = res.status_code
|
status = res.status_code
|
||||||
if status != HTTPStatus.OK:
|
|
||||||
raise Exception(
|
|
||||||
f"Unexpected status code. Wanted 200 and got {status}: " + res.text
|
|
||||||
)
|
|
||||||
as_json = res.json()
|
as_json = res.json()
|
||||||
|
if status != HTTPStatus.OK:
|
||||||
|
raise HttpProtonClient.__handle_response_not_ok(
|
||||||
|
status=status, body=as_json, text=res.text
|
||||||
|
)
|
||||||
res_code = as_json.get("Code")
|
res_code = as_json.get("Code")
|
||||||
if not res_code or res_code != 1000:
|
if not res_code or res_code != 1000:
|
||||||
raise Exception(
|
raise Exception(
|
||||||
|
@ -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)
|
||||||
|
33
app/app/subscription_webhook.py
Normal file
33
app/app/subscription_webhook.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import requests
|
||||||
|
from requests import RequestException
|
||||||
|
|
||||||
|
from app import config
|
||||||
|
from app.log import LOG
|
||||||
|
from app.models import User
|
||||||
|
|
||||||
|
|
||||||
|
def execute_subscription_webhook(user: User):
|
||||||
|
webhook_url = config.SUBSCRIPTION_CHANGE_WEBHOOK
|
||||||
|
if webhook_url is None:
|
||||||
|
return
|
||||||
|
subscription_end = user.get_active_subscription_end(
|
||||||
|
include_partner_subscription=False
|
||||||
|
)
|
||||||
|
sl_subscription_end = None
|
||||||
|
if subscription_end:
|
||||||
|
sl_subscription_end = subscription_end.timestamp
|
||||||
|
payload = {
|
||||||
|
"user_id": user.id,
|
||||||
|
"is_premium": user.is_premium(),
|
||||||
|
"active_subscription_end": sl_subscription_end,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
response = requests.post(webhook_url, json=payload, timeout=2)
|
||||||
|
if response.status_code == 200:
|
||||||
|
LOG.i("Sent request to subscription update webhook successfully")
|
||||||
|
else:
|
||||||
|
LOG.i(
|
||||||
|
f"Request to webhook failed with statue {response.status_code}: {response.text}"
|
||||||
|
)
|
||||||
|
except RequestException as e:
|
||||||
|
LOG.error(f"Subscription request exception: {e}")
|
@ -1,3 +1,4 @@
|
|||||||
|
import random
|
||||||
import re
|
import 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):
|
||||||
@ -93,7 +99,7 @@ def sanitize_email(email_address: str, not_lower=False) -> str:
|
|||||||
email_address = email_address.strip().replace(" ", "").replace("\n", " ")
|
email_address = email_address.strip().replace(" ", "").replace("\n", " ")
|
||||||
if not not_lower:
|
if not not_lower:
|
||||||
email_address = email_address.lower()
|
email_address = email_address.lower()
|
||||||
return email_address
|
return email_address.replace("\u200f", "")
|
||||||
|
|
||||||
|
|
||||||
class NextUrlSanitizer:
|
class NextUrlSanitizer:
|
||||||
|
79
app/cron.py
79
app/cron.py
@ -5,11 +5,11 @@ from typing import List, Tuple
|
|||||||
|
|
||||||
import arrow
|
import arrow
|
||||||
import requests
|
import requests
|
||||||
from sqlalchemy import func, desc, or_
|
from sqlalchemy import func, desc, or_, and_
|
||||||
from sqlalchemy.ext.compiler import compiles
|
from sqlalchemy.ext.compiler import compiles
|
||||||
from sqlalchemy.orm import joinedload
|
from sqlalchemy.orm import joinedload
|
||||||
from sqlalchemy.orm.exc import ObjectDeletedError
|
from sqlalchemy.orm.exc import ObjectDeletedError
|
||||||
from sqlalchemy.sql import Insert
|
from sqlalchemy.sql import Insert, text
|
||||||
|
|
||||||
from app import s3, config
|
from app import s3, config
|
||||||
from app.alias_utils import nb_email_log_for_mailbox
|
from app.alias_utils import nb_email_log_for_mailbox
|
||||||
@ -22,10 +22,9 @@ from app.email_utils import (
|
|||||||
render,
|
render,
|
||||||
email_can_be_used_as_mailbox,
|
email_can_be_used_as_mailbox,
|
||||||
send_email_with_rate_control,
|
send_email_with_rate_control,
|
||||||
normalize_reply_email,
|
|
||||||
is_valid_email,
|
|
||||||
get_email_domain_part,
|
get_email_domain_part,
|
||||||
)
|
)
|
||||||
|
from app.email_validation import is_valid_email, normalize_reply_email
|
||||||
from app.errors import ProtonPartnerNotSetUp
|
from app.errors import ProtonPartnerNotSetUp
|
||||||
from app.log import LOG
|
from app.log import LOG
|
||||||
from app.mail_sender import load_unsent_mails_from_fs_and_resend
|
from app.mail_sender import load_unsent_mails_from_fs_and_resend
|
||||||
@ -66,12 +65,14 @@ from server import create_light_app
|
|||||||
|
|
||||||
def notify_trial_end():
|
def notify_trial_end():
|
||||||
for user in User.filter(
|
for user in User.filter(
|
||||||
User.activated.is_(True), User.trial_end.isnot(None), User.lifetime.is_(False)
|
User.activated.is_(True),
|
||||||
|
User.trial_end.isnot(None),
|
||||||
|
User.trial_end >= arrow.now().shift(days=2),
|
||||||
|
User.trial_end < arrow.now().shift(days=3),
|
||||||
|
User.lifetime.is_(False),
|
||||||
).all():
|
).all():
|
||||||
try:
|
try:
|
||||||
if user.in_trial() and arrow.now().shift(
|
if user.in_trial():
|
||||||
days=3
|
|
||||||
) > user.trial_end >= arrow.now().shift(days=2):
|
|
||||||
LOG.d("Send trial end email to user %s", user)
|
LOG.d("Send trial end email to user %s", user)
|
||||||
send_trial_end_soon_email(user)
|
send_trial_end_soon_email(user)
|
||||||
# happens if user has been deleted in the meantime
|
# happens if user has been deleted in the meantime
|
||||||
@ -84,27 +85,49 @@ def delete_logs():
|
|||||||
delete_refused_emails()
|
delete_refused_emails()
|
||||||
delete_old_monitoring()
|
delete_old_monitoring()
|
||||||
|
|
||||||
for t in TransactionalEmail.filter(
|
for t_email in TransactionalEmail.filter(
|
||||||
TransactionalEmail.created_at < arrow.now().shift(days=-7)
|
TransactionalEmail.created_at < arrow.now().shift(days=-7)
|
||||||
):
|
):
|
||||||
TransactionalEmail.delete(t.id)
|
TransactionalEmail.delete(t_email.id)
|
||||||
|
|
||||||
for b in Bounce.filter(Bounce.created_at < arrow.now().shift(days=-7)):
|
for b in Bounce.filter(Bounce.created_at < arrow.now().shift(days=-7)):
|
||||||
Bounce.delete(b.id)
|
Bounce.delete(b.id)
|
||||||
|
|
||||||
Session.commit()
|
Session.commit()
|
||||||
|
|
||||||
LOG.d("Delete EmailLog older than 2 weeks")
|
LOG.d("Deleting EmailLog older than 2 weeks")
|
||||||
|
|
||||||
max_dt = arrow.now().shift(weeks=-2)
|
total_deleted = 0
|
||||||
nb_deleted = EmailLog.filter(EmailLog.created_at < max_dt).delete()
|
batch_size = 500
|
||||||
Session.commit()
|
Session.execute("set session statement_timeout=30000").rowcount
|
||||||
|
queries_done = 0
|
||||||
|
cutoff_time = arrow.now().shift(days=-14)
|
||||||
|
rows_to_delete = EmailLog.filter(EmailLog.created_at < cutoff_time).count()
|
||||||
|
expected_queries = int(rows_to_delete / batch_size)
|
||||||
|
sql = text(
|
||||||
|
f"DELETE FROM email_log WHERE id IN (SELECT id FROM email_log WHERE created_at < :cutoff_time order by created_at limit :batch_size)"
|
||||||
|
)
|
||||||
|
str_cutoff_time = cutoff_time.isoformat()
|
||||||
|
while total_deleted < rows_to_delete:
|
||||||
|
deleted_count = Session.execute(
|
||||||
|
sql, {"cutoff_time": str_cutoff_time, "batch_size": batch_size}
|
||||||
|
).rowcount
|
||||||
|
Session.commit()
|
||||||
|
total_deleted += deleted_count
|
||||||
|
queries_done += 1
|
||||||
|
LOG.i(
|
||||||
|
f"[{queries_done}/{expected_queries}] Deleted {total_deleted} EmailLog entries"
|
||||||
|
)
|
||||||
|
if deleted_count < batch_size:
|
||||||
|
break
|
||||||
|
|
||||||
LOG.i("Delete %s email logs", nb_deleted)
|
LOG.i("Deleted %s email logs", total_deleted)
|
||||||
|
|
||||||
|
|
||||||
def delete_refused_emails():
|
def delete_refused_emails():
|
||||||
for refused_email in RefusedEmail.filter_by(deleted=False).all():
|
for refused_email in (
|
||||||
|
RefusedEmail.filter_by(deleted=False).order_by(RefusedEmail.id).all()
|
||||||
|
):
|
||||||
if arrow.now().shift(days=1) > refused_email.delete_at >= arrow.now():
|
if arrow.now().shift(days=1) > refused_email.delete_at >= arrow.now():
|
||||||
LOG.d("Delete refused email %s", refused_email)
|
LOG.d("Delete refused email %s", refused_email)
|
||||||
if refused_email.path:
|
if refused_email.path:
|
||||||
@ -272,7 +295,11 @@ def compute_metric2() -> Metric2:
|
|||||||
_24h_ago = now.shift(days=-1)
|
_24h_ago = now.shift(days=-1)
|
||||||
|
|
||||||
nb_referred_user_paid = 0
|
nb_referred_user_paid = 0
|
||||||
for user in User.filter(User.referral_id.isnot(None)):
|
for user in (
|
||||||
|
User.filter(User.referral_id.isnot(None))
|
||||||
|
.yield_per(500)
|
||||||
|
.enable_eagerloads(False)
|
||||||
|
):
|
||||||
if user.is_paid():
|
if user.is_paid():
|
||||||
nb_referred_user_paid += 1
|
nb_referred_user_paid += 1
|
||||||
|
|
||||||
@ -1020,7 +1047,8 @@ async def check_hibp():
|
|||||||
)
|
)
|
||||||
.filter(Alias.enabled)
|
.filter(Alias.enabled)
|
||||||
.order_by(Alias.hibp_last_check.asc())
|
.order_by(Alias.hibp_last_check.asc())
|
||||||
.all()
|
.yield_per(500)
|
||||||
|
.enable_eagerloads(False)
|
||||||
):
|
):
|
||||||
await queue.put(alias.id)
|
await queue.put(alias.id)
|
||||||
|
|
||||||
@ -1098,6 +1126,18 @@ def notify_hibp():
|
|||||||
Session.commit()
|
Session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def clear_users_scheduled_to_be_deleted():
|
||||||
|
users = User.filter(
|
||||||
|
and_(User.delete_on.isnot(None), User.delete_on < arrow.now())
|
||||||
|
).all()
|
||||||
|
for user in users:
|
||||||
|
LOG.i(
|
||||||
|
f"Scheduled deletion of user {user} with scheduled delete on {user.delete_on}"
|
||||||
|
)
|
||||||
|
User.delete(user.id)
|
||||||
|
Session.commit()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
LOG.d("Start running cronjob")
|
LOG.d("Start running cronjob")
|
||||||
parser = argparse.ArgumentParser()
|
parser = argparse.ArgumentParser()
|
||||||
@ -1164,3 +1204,6 @@ if __name__ == "__main__":
|
|||||||
elif args.job == "send_undelivered_mails":
|
elif args.job == "send_undelivered_mails":
|
||||||
LOG.d("Sending undelivered emails")
|
LOG.d("Sending undelivered emails")
|
||||||
load_unsent_mails_from_fs_and_resend()
|
load_unsent_mails_from_fs_and_resend()
|
||||||
|
elif args.job == "delete_scheduled_users":
|
||||||
|
LOG.d("Deleting users scheduled to be deleted")
|
||||||
|
clear_users_scheduled_to_be_deleted()
|
||||||
|
@ -5,65 +5,66 @@ jobs:
|
|||||||
schedule: "0 0 * * *"
|
schedule: "0 0 * * *"
|
||||||
captureStderr: true
|
captureStderr: true
|
||||||
|
|
||||||
- name: SimpleLogin Notify Trial Ends
|
|
||||||
command: python /code/cron.py -j notify_trial_end
|
|
||||||
shell: /bin/bash
|
|
||||||
schedule: "0 8 * * *"
|
|
||||||
captureStderr: true
|
|
||||||
|
|
||||||
- name: SimpleLogin Notify Manual Subscription Ends
|
|
||||||
command: python /code/cron.py -j notify_manual_subscription_end
|
|
||||||
shell: /bin/bash
|
|
||||||
schedule: "0 9 * * *"
|
|
||||||
captureStderr: true
|
|
||||||
|
|
||||||
- name: SimpleLogin Notify Premium Ends
|
|
||||||
command: python /code/cron.py -j notify_premium_end
|
|
||||||
shell: /bin/bash
|
|
||||||
schedule: "0 10 * * *"
|
|
||||||
captureStderr: true
|
|
||||||
|
|
||||||
- name: SimpleLogin Delete Logs
|
|
||||||
command: python /code/cron.py -j delete_logs
|
|
||||||
shell: /bin/bash
|
|
||||||
schedule: "0 11 * * *"
|
|
||||||
captureStderr: true
|
|
||||||
|
|
||||||
- name: SimpleLogin Poll Apple Subscriptions
|
|
||||||
command: python /code/cron.py -j poll_apple_subscription
|
|
||||||
shell: /bin/bash
|
|
||||||
schedule: "0 12 * * *"
|
|
||||||
captureStderr: true
|
|
||||||
|
|
||||||
- name: SimpleLogin Sanity Check
|
|
||||||
command: python /code/cron.py -j sanity_check
|
|
||||||
shell: /bin/bash
|
|
||||||
schedule: "0 2 * * *"
|
|
||||||
captureStderr: true
|
|
||||||
|
|
||||||
- name: SimpleLogin Delete Old Monitoring records
|
- name: SimpleLogin Delete Old Monitoring records
|
||||||
command: python /code/cron.py -j delete_old_monitoring
|
command: python /code/cron.py -j delete_old_monitoring
|
||||||
shell: /bin/bash
|
shell: /bin/bash
|
||||||
schedule: "0 14 * * *"
|
schedule: "15 1 * * *"
|
||||||
captureStderr: true
|
captureStderr: true
|
||||||
|
|
||||||
- name: SimpleLogin Custom Domain check
|
- name: SimpleLogin Custom Domain check
|
||||||
command: python /code/cron.py -j check_custom_domain
|
command: python /code/cron.py -j check_custom_domain
|
||||||
shell: /bin/bash
|
shell: /bin/bash
|
||||||
schedule: "0 15 * * *"
|
schedule: "15 2 * * *"
|
||||||
captureStderr: true
|
captureStderr: true
|
||||||
|
|
||||||
- name: SimpleLogin HIBP check
|
- name: SimpleLogin HIBP check
|
||||||
command: python /code/cron.py -j check_hibp
|
command: python /code/cron.py -j check_hibp
|
||||||
shell: /bin/bash
|
shell: /bin/bash
|
||||||
schedule: "0 18 * * *"
|
schedule: "15 3 * * *"
|
||||||
captureStderr: true
|
captureStderr: true
|
||||||
concurrencyPolicy: Forbid
|
concurrencyPolicy: Forbid
|
||||||
|
|
||||||
- name: SimpleLogin Notify HIBP breaches
|
- name: SimpleLogin Notify HIBP breaches
|
||||||
command: python /code/cron.py -j notify_hibp
|
command: python /code/cron.py -j notify_hibp
|
||||||
shell: /bin/bash
|
shell: /bin/bash
|
||||||
schedule: "0 19 * * *"
|
schedule: "15 4 * * *"
|
||||||
|
captureStderr: true
|
||||||
|
concurrencyPolicy: Forbid
|
||||||
|
|
||||||
|
- name: SimpleLogin Delete Logs
|
||||||
|
command: python /code/cron.py -j delete_logs
|
||||||
|
shell: /bin/bash
|
||||||
|
schedule: "15 5 * * *"
|
||||||
|
captureStderr: true
|
||||||
|
|
||||||
|
- name: SimpleLogin Poll Apple Subscriptions
|
||||||
|
command: python /code/cron.py -j poll_apple_subscription
|
||||||
|
shell: /bin/bash
|
||||||
|
schedule: "15 6 * * *"
|
||||||
|
captureStderr: true
|
||||||
|
|
||||||
|
- name: SimpleLogin Notify Trial Ends
|
||||||
|
command: python /code/cron.py -j notify_trial_end
|
||||||
|
shell: /bin/bash
|
||||||
|
schedule: "15 8 * * *"
|
||||||
|
captureStderr: true
|
||||||
|
|
||||||
|
- name: SimpleLogin Notify Manual Subscription Ends
|
||||||
|
command: python /code/cron.py -j notify_manual_subscription_end
|
||||||
|
shell: /bin/bash
|
||||||
|
schedule: "15 9 * * *"
|
||||||
|
captureStderr: true
|
||||||
|
|
||||||
|
- name: SimpleLogin Notify Premium Ends
|
||||||
|
command: python /code/cron.py -j notify_premium_end
|
||||||
|
shell: /bin/bash
|
||||||
|
schedule: "15 10 * * *"
|
||||||
|
captureStderr: true
|
||||||
|
|
||||||
|
- name: SimpleLogin delete users scheduled to be deleted
|
||||||
|
command: echo disabled_user_deletion #python /code/cron.py -j delete_scheduled_users
|
||||||
|
shell: /bin/bash
|
||||||
|
schedule: "15 11 * * *"
|
||||||
captureStderr: true
|
captureStderr: true
|
||||||
concurrencyPolicy: Forbid
|
concurrencyPolicy: Forbid
|
||||||
|
|
||||||
|
@ -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:
|
||||||
|
|
||||||
|
123
app/docs/ssl.md
123
app/docs/ssl.md
@ -1,4 +1,4 @@
|
|||||||
# SSL, HTTPS, and HSTS
|
# SSL, HTTPS, HSTS and additional security measures
|
||||||
|
|
||||||
It's highly recommended to enable SSL/TLS on your server, both for the web app and email server.
|
It's highly recommended to enable SSL/TLS on your server, both for the web app and email server.
|
||||||
|
|
||||||
@ -58,3 +58,124 @@ Now, reload Nginx:
|
|||||||
```bash
|
```bash
|
||||||
sudo systemctl reload nginx
|
sudo systemctl reload nginx
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Additional security measures
|
||||||
|
|
||||||
|
For additional security, we recommend you take some extra steps.
|
||||||
|
|
||||||
|
### Enable Certificate Authority Authorization (CAA)
|
||||||
|
|
||||||
|
[Certificate Authority Authorization](https://letsencrypt.org/docs/caa/) is a step you can take to restrict the list of certificate authorities that are allowed to issue certificates for your domains.
|
||||||
|
|
||||||
|
Use [SSLMate’s CAA Record Generator](https://sslmate.com/caa/) to create a **CAA record** with the following configuration:
|
||||||
|
|
||||||
|
- `flags`: `0`
|
||||||
|
- `tag`: `issue`
|
||||||
|
- `value`: `"letsencrypt.org"`
|
||||||
|
|
||||||
|
To verify if the DNS works, the following command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dig @1.1.1.1 mydomain.com caa
|
||||||
|
```
|
||||||
|
|
||||||
|
should return:
|
||||||
|
|
||||||
|
```
|
||||||
|
mydomain.com. 3600 IN CAA 0 issue "letsencrypt.org"
|
||||||
|
```
|
||||||
|
|
||||||
|
### SMTP MTA Strict Transport Security (MTA-STS)
|
||||||
|
|
||||||
|
[MTA-STS](https://datatracker.ietf.org/doc/html/rfc8461) is an extra step you can take to broadcast the ability of your instance to receive and, optionally enforce, TSL-secure SMTP connections to protect email traffic.
|
||||||
|
|
||||||
|
Enabling MTA-STS requires you serve a specific file from subdomain `mta-sts.domain.com` on a well-known route.
|
||||||
|
|
||||||
|
Create a text file `/var/www/.well-known/mta-sts.txt` with the content:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
version: STSv1
|
||||||
|
mode: testing
|
||||||
|
mx: app.mydomain.com
|
||||||
|
max_age: 86400
|
||||||
|
```
|
||||||
|
|
||||||
|
It is recommended to start with `mode: testing` for starters to get time to review failure reports. Add as many `mx:` domain entries as you have matching **MX records** in your DNS configuration.
|
||||||
|
|
||||||
|
Create a **TXT record** for `_mta-sts.mydomain.com.` with the following value:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
v=STSv1; id=UNIX_TIMESTAMP
|
||||||
|
```
|
||||||
|
|
||||||
|
With `UNIX_TIMESTAMP` being the current date/time.
|
||||||
|
|
||||||
|
Use the following command to generate the record:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
echo "v=STSv1; id=$(date +%s)"
|
||||||
|
```
|
||||||
|
|
||||||
|
To verify if the DNS works, the following command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dig @1.1.1.1 _mta-sts.mydomain.com txt
|
||||||
|
```
|
||||||
|
|
||||||
|
should return a result similar to this one:
|
||||||
|
|
||||||
|
```
|
||||||
|
_mta-sts.mydomain.com. 3600 IN TXT "v=STSv1; id=1689416399"
|
||||||
|
```
|
||||||
|
|
||||||
|
Create an additional Nginx configuration in `/etc/nginx/sites-enabled/mta-sts` with the following content:
|
||||||
|
|
||||||
|
```
|
||||||
|
server {
|
||||||
|
server_name mta-sts.mydomain.com;
|
||||||
|
root /var/www;
|
||||||
|
listen 80;
|
||||||
|
|
||||||
|
location ^~ /.well-known {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart Nginx with the following command:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo service nginx restart
|
||||||
|
```
|
||||||
|
|
||||||
|
A correct configuration of MTA-STS, however, requires that the certificate used to host the `mta-sts` subdomain matches that of the subdomain referred to by the **MX record** from the DNS. In other words, both `mta-sts.mydomain.com` and `app.mydomain.com` must share the same certificate.
|
||||||
|
|
||||||
|
The easiest way to do this is to _expand_ the certificate associated with `app.mydomain.com` to also support the `mta-sts` subdomain using the following command:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
certbot --expand --nginx -d app.mydomain.com,mta-sts.mydomain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
## SMTP TLS Reporting
|
||||||
|
|
||||||
|
[TLSRPT](https://datatracker.ietf.org/doc/html/rfc8460) is used by SMTP systems to report failures in establishing TLS-secure sessions as broadcast by the MTA-STS configuration.
|
||||||
|
|
||||||
|
Configuring MTA-STS in `mode: testing` as shown in the previous section gives you time to review failures from some SMTP senders.
|
||||||
|
|
||||||
|
Create a **TXT record** for `_smtp._tls.mydomain.com.` with the following value:
|
||||||
|
|
||||||
|
```txt
|
||||||
|
v=TSLRPTv1; rua=mailto:YOUR_EMAIL
|
||||||
|
```
|
||||||
|
|
||||||
|
The TLSRPT configuration at the DNS level allows SMTP senders that fail to initiate TLS-secure sessions to send reports to a particular email address. We suggest creating a `tls-reports` alias in SimpleLogin for this purpose.
|
||||||
|
|
||||||
|
To verify if the DNS works, the following command
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dig @1.1.1.1 _smtp._tls.mydomain.com txt
|
||||||
|
```
|
||||||
|
|
||||||
|
should return a result similar to this one:
|
||||||
|
|
||||||
|
```
|
||||||
|
_smtp._tls.mydomain.com. 3600 IN TXT "v=TSLRPTv1; rua=mailto:tls-reports@mydomain.com"
|
||||||
|
```
|
||||||
|
@ -106,8 +106,6 @@ from app.email_utils import (
|
|||||||
get_header_unicode,
|
get_header_unicode,
|
||||||
generate_reply_email,
|
generate_reply_email,
|
||||||
is_reverse_alias,
|
is_reverse_alias,
|
||||||
normalize_reply_email,
|
|
||||||
is_valid_email,
|
|
||||||
replace,
|
replace,
|
||||||
should_disable,
|
should_disable,
|
||||||
parse_id_from_bounce,
|
parse_id_from_bounce,
|
||||||
@ -123,6 +121,7 @@ from app.email_utils import (
|
|||||||
generate_verp_email,
|
generate_verp_email,
|
||||||
sl_formataddr,
|
sl_formataddr,
|
||||||
)
|
)
|
||||||
|
from app.email_validation import is_valid_email, normalize_reply_email
|
||||||
from app.errors import (
|
from app.errors import (
|
||||||
NonReverseAliasInReplyPhase,
|
NonReverseAliasInReplyPhase,
|
||||||
VERPTransactional,
|
VERPTransactional,
|
||||||
@ -161,6 +160,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 +168,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 +182,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 +243,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,
|
||||||
@ -257,7 +261,7 @@ def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Con
|
|||||||
|
|
||||||
Session.commit()
|
Session.commit()
|
||||||
except IntegrityError:
|
except IntegrityError:
|
||||||
LOG.w("Contact %s %s already exist", alias, contact_email)
|
LOG.w(f"Contact with email {contact_email} for alias {alias} already exist")
|
||||||
Session.rollback()
|
Session.rollback()
|
||||||
contact = Contact.get_by(alias_id=alias.id, website_email=contact_email)
|
contact = Contact.get_by(alias_id=alias.id, website_email=contact_email)
|
||||||
|
|
||||||
@ -275,6 +279,9 @@ def get_or_create_reply_to_contact(
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
if len(contact_name) >= Contact.MAX_NAME_LENGTH:
|
||||||
|
contact_name = contact_name[0 : Contact.MAX_NAME_LENGTH]
|
||||||
|
|
||||||
if not is_valid_email(contact_address):
|
if not is_valid_email(contact_address):
|
||||||
LOG.w(
|
LOG.w(
|
||||||
"invalid reply-to address %s. Parse from %s",
|
"invalid reply-to address %s. Parse from %s",
|
||||||
@ -300,7 +307,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()
|
||||||
@ -343,6 +350,10 @@ def replace_header_when_forward(msg: Message, alias: Alias, header: str):
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
contact = Contact.get_by(alias_id=alias.id, website_email=contact_email)
|
contact = Contact.get_by(alias_id=alias.id, website_email=contact_email)
|
||||||
|
contact_name = full_address.display_name
|
||||||
|
if len(contact_name) >= Contact.MAX_NAME_LENGTH:
|
||||||
|
contact_name = contact_name[0 : Contact.MAX_NAME_LENGTH]
|
||||||
|
|
||||||
if contact:
|
if contact:
|
||||||
# update the contact name if needed
|
# update the contact name if needed
|
||||||
if contact.name != full_address.display_name:
|
if contact.name != full_address.display_name:
|
||||||
@ -350,9 +361,9 @@ def replace_header_when_forward(msg: Message, alias: Alias, header: str):
|
|||||||
"Update contact %s name %s to %s",
|
"Update contact %s name %s to %s",
|
||||||
contact,
|
contact,
|
||||||
contact.name,
|
contact.name,
|
||||||
full_address.display_name,
|
contact_name,
|
||||||
)
|
)
|
||||||
contact.name = full_address.display_name
|
contact.name = contact_name
|
||||||
Session.commit()
|
Session.commit()
|
||||||
else:
|
else:
|
||||||
LOG.d(
|
LOG.d(
|
||||||
@ -367,8 +378,8 @@ def replace_header_when_forward(msg: Message, alias: Alias, header: str):
|
|||||||
user_id=alias.user_id,
|
user_id=alias.user_id,
|
||||||
alias_id=alias.id,
|
alias_id=alias.id,
|
||||||
website_email=contact_email,
|
website_email=contact_email,
|
||||||
name=full_address.display_name,
|
name=contact_name,
|
||||||
reply_email=generate_reply_email(contact_email, alias.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,
|
||||||
)
|
)
|
||||||
@ -536,12 +547,20 @@ def sign_msg(msg: Message) -> Message:
|
|||||||
signature.add_header("Content-Disposition", 'attachment; filename="signature.asc"')
|
signature.add_header("Content-Disposition", 'attachment; filename="signature.asc"')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
signature.set_payload(sign_data(message_to_bytes(msg).replace(b"\n", b"\r\n")))
|
payload = sign_data(message_to_bytes(msg).replace(b"\n", b"\r\n"))
|
||||||
|
|
||||||
|
if not payload:
|
||||||
|
raise PGPException("Empty signature by gnupg")
|
||||||
|
|
||||||
|
signature.set_payload(payload)
|
||||||
except Exception:
|
except Exception:
|
||||||
LOG.e("Cannot sign, try using pgpy")
|
LOG.e("Cannot sign, try using pgpy")
|
||||||
signature.set_payload(
|
payload = sign_data_with_pgpy(message_to_bytes(msg).replace(b"\n", b"\r\n"))
|
||||||
sign_data_with_pgpy(message_to_bytes(msg).replace(b"\n", b"\r\n"))
|
|
||||||
)
|
if not payload:
|
||||||
|
raise PGPException("Empty signature by pgpy")
|
||||||
|
|
||||||
|
signature.set_payload(payload)
|
||||||
|
|
||||||
container.attach(signature)
|
container.attach(signature)
|
||||||
|
|
||||||
@ -618,8 +637,8 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str
|
|||||||
|
|
||||||
user = alias.user
|
user = alias.user
|
||||||
|
|
||||||
if user.disabled:
|
if not user.can_send_or_receive():
|
||||||
LOG.w("User %s disabled, disable forwarding emails for %s", user, alias)
|
LOG.i(f"User {user} cannot receive emails")
|
||||||
if should_ignore_bounce(envelope.mail_from):
|
if should_ignore_bounce(envelope.mail_from):
|
||||||
return [(True, status.E207)]
|
return [(True, status.E207)]
|
||||||
else:
|
else:
|
||||||
@ -689,6 +708,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(
|
||||||
@ -811,36 +860,40 @@ def forward_email_to_mailbox(
|
|||||||
f"""Email sent to {alias.email} from an invalid address and cannot be replied""",
|
f"""Email sent to {alias.email} from an invalid address and cannot be replied""",
|
||||||
)
|
)
|
||||||
|
|
||||||
delete_all_headers_except(
|
headers_to_keep = [
|
||||||
msg,
|
headers.FROM,
|
||||||
[
|
headers.TO,
|
||||||
headers.FROM,
|
headers.CC,
|
||||||
headers.TO,
|
headers.SUBJECT,
|
||||||
headers.CC,
|
headers.DATE,
|
||||||
headers.SUBJECT,
|
# do not delete original message id
|
||||||
headers.DATE,
|
headers.MESSAGE_ID,
|
||||||
# do not delete original message id
|
# References and In-Reply-To are used for keeping the email thread
|
||||||
headers.MESSAGE_ID,
|
headers.REFERENCES,
|
||||||
# References and In-Reply-To are used for keeping the email thread
|
headers.IN_REPLY_TO,
|
||||||
headers.REFERENCES,
|
headers.LIST_UNSUBSCRIBE,
|
||||||
headers.IN_REPLY_TO,
|
headers.LIST_UNSUBSCRIBE_POST,
|
||||||
]
|
] + headers.MIME_HEADERS
|
||||||
+ headers.MIME_HEADERS,
|
if user.include_header_email_header:
|
||||||
)
|
headers_to_keep.append(headers.AUTHENTICATION_RESULTS)
|
||||||
|
delete_all_headers_except(msg, headers_to_keep)
|
||||||
|
|
||||||
|
if mailbox.generic_subject:
|
||||||
|
LOG.d("Use a generic subject for %s", mailbox)
|
||||||
|
orig_subject = msg[headers.SUBJECT]
|
||||||
|
orig_subject = get_header_unicode(orig_subject)
|
||||||
|
add_or_replace_header(msg, "Subject", mailbox.generic_subject)
|
||||||
|
sender = msg[headers.FROM]
|
||||||
|
sender = get_header_unicode(sender)
|
||||||
|
msg = add_header(
|
||||||
|
msg,
|
||||||
|
f"""Forwarded by SimpleLogin to {alias.email} from "{sender}" with "{orig_subject}" as subject""",
|
||||||
|
f"""Forwarded by SimpleLogin to {alias.email} from "{sender}" with <b>{orig_subject}</b> as subject""",
|
||||||
|
)
|
||||||
|
|
||||||
# create PGP email if needed
|
# create PGP email if needed
|
||||||
if mailbox.pgp_enabled() and user.is_premium() and not alias.disable_pgp:
|
if mailbox.pgp_enabled() and user.is_premium() and not alias.disable_pgp:
|
||||||
LOG.d("Encrypt message using mailbox %s", mailbox)
|
LOG.d("Encrypt message using mailbox %s", mailbox)
|
||||||
if mailbox.generic_subject:
|
|
||||||
LOG.d("Use a generic subject for %s", mailbox)
|
|
||||||
orig_subject = msg[headers.SUBJECT]
|
|
||||||
orig_subject = get_header_unicode(orig_subject)
|
|
||||||
add_or_replace_header(msg, "Subject", mailbox.generic_subject)
|
|
||||||
msg = add_header(
|
|
||||||
msg,
|
|
||||||
f"""Forwarded by SimpleLogin to {alias.email} with "{orig_subject}" as subject""",
|
|
||||||
f"""Forwarded by SimpleLogin to {alias.email} with <b>{orig_subject}</b> as subject""",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
msg = prepare_pgp_message(
|
msg = prepare_pgp_message(
|
||||||
@ -861,6 +914,11 @@ def forward_email_to_mailbox(
|
|||||||
msg[headers.SL_EMAIL_LOG_ID] = str(email_log.id)
|
msg[headers.SL_EMAIL_LOG_ID] = str(email_log.id)
|
||||||
if user.include_header_email_header:
|
if user.include_header_email_header:
|
||||||
msg[headers.SL_ENVELOPE_FROM] = envelope.mail_from
|
msg[headers.SL_ENVELOPE_FROM] = envelope.mail_from
|
||||||
|
if contact.name:
|
||||||
|
original_from = f"{contact.name} <{contact.website_email}>"
|
||||||
|
else:
|
||||||
|
original_from = contact.website_email
|
||||||
|
msg[headers.SL_ORIGINAL_FROM] = original_from
|
||||||
# when an alias isn't in the To: header, there's no way for users to know what alias has received the email
|
# when an alias isn't in the To: header, there's no way for users to know what alias has received the email
|
||||||
msg[headers.SL_ENVELOPE_TO] = alias.email
|
msg[headers.SL_ENVELOPE_TO] = alias.email
|
||||||
|
|
||||||
@ -909,10 +967,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 +1040,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 +1059,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
|
||||||
@ -1007,13 +1070,8 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str):
|
|||||||
user = alias.user
|
user = alias.user
|
||||||
mail_from = envelope.mail_from
|
mail_from = envelope.mail_from
|
||||||
|
|
||||||
if user.disabled:
|
if not user.can_send_or_receive():
|
||||||
LOG.e(
|
LOG.i(f"User {user} cannot send emails")
|
||||||
"User %s disabled, disable sending emails from %s to %s",
|
|
||||||
user,
|
|
||||||
alias,
|
|
||||||
contact,
|
|
||||||
)
|
|
||||||
return False, status.E504
|
return False, status.E504
|
||||||
|
|
||||||
# Check if we need to reject or quarantine based on dmarc
|
# Check if we need to reject or quarantine based on dmarc
|
||||||
@ -1194,7 +1252,6 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str):
|
|||||||
if str(msg[headers.TO]).lower() == "undisclosed-recipients:;":
|
if str(msg[headers.TO]).lower() == "undisclosed-recipients:;":
|
||||||
# no need to replace TO header
|
# no need to replace TO header
|
||||||
LOG.d("email is sent in BCC mode")
|
LOG.d("email is sent in BCC mode")
|
||||||
del msg[headers.TO]
|
|
||||||
else:
|
else:
|
||||||
replace_header_when_reply(msg, alias, headers.TO)
|
replace_header_when_reply(msg, alias, headers.TO)
|
||||||
|
|
||||||
@ -1384,21 +1441,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(
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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")
|
||||||
|
@ -3552,7 +3552,6 @@ impute
|
|||||||
inaner
|
inaner
|
||||||
inborn
|
inborn
|
||||||
inbred
|
inbred
|
||||||
incest
|
|
||||||
inched
|
inched
|
||||||
inches
|
inches
|
||||||
incing
|
incing
|
||||||
|
332593
app/local_data/words.txt
332593
app/local_data/words.txt
File diff suppressed because it is too large
Load Diff
@ -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
|
||||||
@ -370100,4 +370090,4 @@ zwinglianism
|
|||||||
zwinglianist
|
zwinglianist
|
||||||
zwitter
|
zwitter
|
||||||
zwitterion
|
zwitterion
|
||||||
zwitterionic
|
zwitterionic
|
||||||
|
31
app/migrations/versions/2023_040318_5f4a5625da66_.py
Normal file
31
app/migrations/versions/2023_040318_5f4a5625da66_.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 5f4a5625da66
|
||||||
|
Revises: 2c2093c82bc0
|
||||||
|
Create Date: 2023-04-03 18:30:46.488231
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy_utils
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '5f4a5625da66'
|
||||||
|
down_revision = '2c2093c82bc0'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('public_domain', sa.Column('partner_id', sa.Integer(), nullable=True))
|
||||||
|
op.create_foreign_key(None, 'public_domain', 'partner', ['partner_id'], ['id'], ondelete='cascade')
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_constraint(None, 'public_domain', type_='foreignkey')
|
||||||
|
op.drop_column('public_domain', 'partner_id')
|
||||||
|
# ### end Alembic commands ###
|
29
app/migrations/versions/2023_041418_893c0d18475f_.py
Normal file
29
app/migrations/versions/2023_041418_893c0d18475f_.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 893c0d18475f
|
||||||
|
Revises: 5f4a5625da66
|
||||||
|
Create Date: 2023-04-14 18:20:03.807367
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy_utils
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '893c0d18475f'
|
||||||
|
down_revision = '5f4a5625da66'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_index(op.f('ix_contact_pgp_finger_print'), 'contact', ['pgp_finger_print'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_contact_pgp_finger_print'), table_name='contact')
|
||||||
|
# ### end Alembic commands ###
|
35
app/migrations/versions/2023_041419_bc496c0a0279_.py
Normal file
35
app/migrations/versions/2023_041419_bc496c0a0279_.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: bc496c0a0279
|
||||||
|
Revises: 893c0d18475f
|
||||||
|
Create Date: 2023-04-14 19:09:38.540514
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy_utils
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'bc496c0a0279'
|
||||||
|
down_revision = '893c0d18475f'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_index(op.f('ix_alias_used_on_alias_id'), 'alias_used_on', ['alias_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_client_user_alias_id'), 'client_user', ['alias_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_hibp_notified_alias_alias_id'), 'hibp_notified_alias', ['alias_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_users_newsletter_alias_id'), 'users', ['newsletter_alias_id'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_users_newsletter_alias_id'), table_name='users')
|
||||||
|
op.drop_index(op.f('ix_hibp_notified_alias_alias_id'), table_name='hibp_notified_alias')
|
||||||
|
op.drop_index(op.f('ix_client_user_alias_id'), table_name='client_user')
|
||||||
|
op.drop_index(op.f('ix_alias_used_on_alias_id'), table_name='alias_used_on')
|
||||||
|
# ### end Alembic commands ###
|
29
app/migrations/versions/2023_041520_2d89315ac650_.py
Normal file
29
app/migrations/versions/2023_041520_2d89315ac650_.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 2d89315ac650
|
||||||
|
Revises: bc496c0a0279
|
||||||
|
Create Date: 2023-04-15 20:43:44.218020
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy_utils
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '2d89315ac650'
|
||||||
|
down_revision = 'bc496c0a0279'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_index(op.f('ix_partner_subscription_end_at'), 'partner_subscription', ['end_at'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_partner_subscription_end_at'), table_name='partner_subscription')
|
||||||
|
# ### end Alembic commands ###
|
29
app/migrations/versions/2023_041916_01e2997e90d3_.py
Normal file
29
app/migrations/versions/2023_041916_01e2997e90d3_.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 01e2997e90d3
|
||||||
|
Revises: 893c0d18475f
|
||||||
|
Create Date: 2023-04-19 16:09:11.851588
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy_utils
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '01e2997e90d3'
|
||||||
|
down_revision = '893c0d18475f'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('public_domain', sa.Column('use_as_reverse_alias', sa.Boolean(), server_default='0', nullable=False))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('public_domain', 'use_as_reverse_alias')
|
||||||
|
# ### end Alembic commands ###
|
25
app/migrations/versions/2023_042011_2634b41f54db_.py
Normal file
25
app/migrations/versions/2023_042011_2634b41f54db_.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 2634b41f54db
|
||||||
|
Revises: 01e2997e90d3, 2d89315ac650
|
||||||
|
Create Date: 2023-04-20 11:47:43.048536
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy_utils
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '2634b41f54db'
|
||||||
|
down_revision = ('01e2997e90d3', '2d89315ac650')
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
pass
|
42
app/migrations/versions/2023_072819_01827104004b_.py
Normal file
42
app/migrations/versions/2023_072819_01827104004b_.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 01827104004b
|
||||||
|
Revises: 2634b41f54db
|
||||||
|
Create Date: 2023-07-28 19:39:28.675490
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy_utils
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '01827104004b'
|
||||||
|
down_revision = '2634b41f54db'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
with op.get_context().autocommit_block():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_index(op.f('ix_alias_hibp_last_check'), 'alias', ['hibp_last_check'], unique=False, postgresql_concurrently=True)
|
||||||
|
op.create_index('ix_bounce_created_at', 'bounce', ['created_at'], unique=False, postgresql_concurrently=True)
|
||||||
|
op.create_index('ix_monitoring_created_at', 'monitoring', ['created_at'], unique=False, postgresql_concurrently=True)
|
||||||
|
op.create_index('ix_transactional_email_created_at', 'transactional_email', ['created_at'], unique=False, postgresql_concurrently=True)
|
||||||
|
op.create_index(op.f('ix_users_activated'), 'users', ['activated'], unique=False, postgresql_concurrently=True)
|
||||||
|
op.create_index('ix_users_activated_trial_end_lifetime', 'users', ['activated', 'trial_end', 'lifetime'], unique=False, postgresql_concurrently=True)
|
||||||
|
op.create_index(op.f('ix_users_referral_id'), 'users', ['referral_id'], unique=False, postgresql_concurrently=True)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_users_referral_id'), table_name='users')
|
||||||
|
op.drop_index('ix_users_activated_trial_end_lifetime', table_name='users')
|
||||||
|
op.drop_index(op.f('ix_users_activated'), table_name='users')
|
||||||
|
op.drop_index('ix_transactional_email_created_at', table_name='transactional_email')
|
||||||
|
op.drop_index('ix_monitoring_created_at', table_name='monitoring')
|
||||||
|
op.drop_index('ix_bounce_created_at', table_name='bounce')
|
||||||
|
op.drop_index(op.f('ix_alias_hibp_last_check'), table_name='alias')
|
||||||
|
# ### end Alembic commands ###
|
33
app/migrations/versions/2023_090715_0a5701a4f5e4_.py
Normal file
33
app/migrations/versions/2023_090715_0a5701a4f5e4_.py
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 0a5701a4f5e4
|
||||||
|
Revises: 01827104004b
|
||||||
|
Create Date: 2023-09-07 15:28:10.122756
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy_utils
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '0a5701a4f5e4'
|
||||||
|
down_revision = '01827104004b'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.add_column('users', sa.Column('delete_on', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True))
|
||||||
|
with op.get_context().autocommit_block():
|
||||||
|
op.create_index('ix_users_delete_on', 'users', ['delete_on'], unique=False, postgresql_concurrently=True)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.get_context().autocommit_block():
|
||||||
|
op.drop_index('ix_users_delete_on', table_name='users', postgresql_concurrently=True)
|
||||||
|
op.drop_column('users', 'delete_on')
|
||||||
|
# ### end Alembic commands ###
|
34
app/migrations/versions/2023_092818_ec7fdde8da9f_.py
Normal file
34
app/migrations/versions/2023_092818_ec7fdde8da9f_.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: ec7fdde8da9f
|
||||||
|
Revises: 0a5701a4f5e4
|
||||||
|
Create Date: 2023-09-28 18:09:48.016620
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy_utils
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "ec7fdde8da9f"
|
||||||
|
down_revision = "0a5701a4f5e4"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.get_context().autocommit_block():
|
||||||
|
op.create_index(
|
||||||
|
"ix_email_log_created_at", "email_log", ["created_at"], unique=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.get_context().autocommit_block():
|
||||||
|
op.drop_index("ix_email_log_created_at", table_name="email_log")
|
||||||
|
# ### end Alembic commands ###
|
39
app/migrations/versions/2023_100510_46ecb648a47e_.py
Normal file
39
app/migrations/versions/2023_100510_46ecb648a47e_.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
"""empty message
|
||||||
|
|
||||||
|
Revision ID: 46ecb648a47e
|
||||||
|
Revises: ec7fdde8da9f
|
||||||
|
Create Date: 2023-10-05 10:43:35.668902
|
||||||
|
|
||||||
|
"""
|
||||||
|
import sqlalchemy_utils
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "46ecb648a47e"
|
||||||
|
down_revision = "ec7fdde8da9f"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.get_context().autocommit_block():
|
||||||
|
op.create_index(
|
||||||
|
op.f("ix_message_id_matching_email_log_id"),
|
||||||
|
"message_id_matching",
|
||||||
|
["email_log_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.get_context().autocommit_block():
|
||||||
|
op.drop_index(
|
||||||
|
op.f("ix_message_id_matching_email_log_id"),
|
||||||
|
table_name="message_id_matching",
|
||||||
|
)
|
||||||
|
# ### end Alembic commands ###
|
0
app/monitor/__init__.py
Normal file
0
app/monitor/__init__.py
Normal file
21
app/monitor/metric.py
Normal file
21
app/monitor/metric.py
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UpcloudRecord:
|
||||||
|
db_role: str
|
||||||
|
label: str
|
||||||
|
time: str
|
||||||
|
value: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UpcloudMetric:
|
||||||
|
metric_name: str
|
||||||
|
records: List[UpcloudRecord]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UpcloudMetrics:
|
||||||
|
metrics: List[UpcloudMetric]
|
20
app/monitor/metric_exporter.py
Normal file
20
app/monitor/metric_exporter.py
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
from app.config import UPCLOUD_DB_ID, UPCLOUD_PASSWORD, UPCLOUD_USERNAME
|
||||||
|
from app.log import LOG
|
||||||
|
from monitor.newrelic import NewRelicClient
|
||||||
|
from monitor.upcloud import UpcloudClient
|
||||||
|
|
||||||
|
|
||||||
|
class MetricExporter:
|
||||||
|
def __init__(self, newrelic_license: str):
|
||||||
|
self.__upcloud = UpcloudClient(
|
||||||
|
username=UPCLOUD_USERNAME, password=UPCLOUD_PASSWORD
|
||||||
|
)
|
||||||
|
self.__newrelic = NewRelicClient(newrelic_license)
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
try:
|
||||||
|
metrics = self.__upcloud.get_metrics(UPCLOUD_DB_ID)
|
||||||
|
self.__newrelic.send(metrics)
|
||||||
|
LOG.info("Upcloud metrics sent to NewRelic")
|
||||||
|
except Exception as e:
|
||||||
|
LOG.warn(f"Could not export metrics: {e}")
|
26
app/monitor/newrelic.py
Normal file
26
app/monitor/newrelic.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
from monitor.metric import UpcloudMetrics
|
||||||
|
|
||||||
|
from newrelic_telemetry_sdk import GaugeMetric, MetricClient
|
||||||
|
|
||||||
|
_NEWRELIC_BASE_HOST = "metric-api.eu.newrelic.com"
|
||||||
|
|
||||||
|
|
||||||
|
class NewRelicClient:
|
||||||
|
def __init__(self, license_key: str):
|
||||||
|
self.__client = MetricClient(license_key=license_key, host=_NEWRELIC_BASE_HOST)
|
||||||
|
|
||||||
|
def send(self, metrics: UpcloudMetrics):
|
||||||
|
batch = []
|
||||||
|
|
||||||
|
for metric in metrics.metrics:
|
||||||
|
for record in metric.records:
|
||||||
|
batch.append(
|
||||||
|
GaugeMetric(
|
||||||
|
name=f"upcloud.db.{metric.metric_name}",
|
||||||
|
value=record.value,
|
||||||
|
tags={"host": record.label, "db_role": record.db_role},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
response = self.__client.send_batch(batch)
|
||||||
|
response.raise_for_status()
|
82
app/monitor/upcloud.py
Normal file
82
app/monitor/upcloud.py
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
from app.log import LOG
|
||||||
|
from monitor.metric import UpcloudMetric, UpcloudMetrics, UpcloudRecord
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import requests
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
BASE_URL = "https://api.upcloud.com"
|
||||||
|
|
||||||
|
|
||||||
|
def get_metric(json: Any, metric: str) -> UpcloudMetric:
|
||||||
|
records = []
|
||||||
|
|
||||||
|
if metric in json:
|
||||||
|
metric_data = json[metric]
|
||||||
|
data = metric_data["data"]
|
||||||
|
cols = list(map(lambda x: x["label"], data["cols"][1:]))
|
||||||
|
latest = data["rows"][-1]
|
||||||
|
time = latest[0]
|
||||||
|
for column_idx in range(len(cols)):
|
||||||
|
value = latest[1 + column_idx]
|
||||||
|
|
||||||
|
# If the latest value is None, try to fetch the second to last
|
||||||
|
if value is None:
|
||||||
|
value = data["rows"][-2][1 + column_idx]
|
||||||
|
|
||||||
|
if value is not None:
|
||||||
|
label = cols[column_idx]
|
||||||
|
if "(master)" in label:
|
||||||
|
db_role = "master"
|
||||||
|
else:
|
||||||
|
db_role = "standby"
|
||||||
|
records.append(
|
||||||
|
UpcloudRecord(time=time, db_role=db_role, label=label, value=value)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
LOG.warn(f"Could not get value for metric {metric}")
|
||||||
|
|
||||||
|
return UpcloudMetric(metric_name=metric, records=records)
|
||||||
|
|
||||||
|
|
||||||
|
def get_metrics(json: Any) -> UpcloudMetrics:
|
||||||
|
return UpcloudMetrics(
|
||||||
|
metrics=[
|
||||||
|
get_metric(json, "cpu_usage"),
|
||||||
|
get_metric(json, "disk_usage"),
|
||||||
|
get_metric(json, "diskio_reads"),
|
||||||
|
get_metric(json, "diskio_writes"),
|
||||||
|
get_metric(json, "load_average"),
|
||||||
|
get_metric(json, "mem_usage"),
|
||||||
|
get_metric(json, "net_receive"),
|
||||||
|
get_metric(json, "net_send"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UpcloudClient:
|
||||||
|
def __init__(self, username: str, password: str):
|
||||||
|
if not username:
|
||||||
|
raise Exception("UpcloudClient username must be set")
|
||||||
|
if not password:
|
||||||
|
raise Exception("UpcloudClient password must be set")
|
||||||
|
|
||||||
|
client = requests.Session()
|
||||||
|
encoded_auth = base64.b64encode(
|
||||||
|
f"{username}:{password}".encode("utf-8")
|
||||||
|
).decode("utf-8")
|
||||||
|
client.headers = {"Authorization": f"Basic {encoded_auth}"}
|
||||||
|
self.__client = client
|
||||||
|
|
||||||
|
def get_metrics(self, db_uuid: str) -> UpcloudMetrics:
|
||||||
|
url = f"{BASE_URL}/1.3/database/{db_uuid}/metrics?period=hour"
|
||||||
|
LOG.d(f"Performing request to {url}")
|
||||||
|
response = self.__client.get(url)
|
||||||
|
LOG.d(f"Status code: {response.status_code}")
|
||||||
|
if response.status_code != 200:
|
||||||
|
return UpcloudMetrics(metrics=[])
|
||||||
|
|
||||||
|
as_json = response.json()
|
||||||
|
|
||||||
|
return get_metrics(as_json)
|
@ -1,3 +1,4 @@
|
|||||||
|
import configparser
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
from time import sleep
|
from time import sleep
|
||||||
@ -7,6 +8,7 @@ import newrelic.agent
|
|||||||
|
|
||||||
from app.db import Session
|
from app.db import Session
|
||||||
from app.log import LOG
|
from app.log import LOG
|
||||||
|
from monitor.metric_exporter import MetricExporter
|
||||||
|
|
||||||
# the number of consecutive fails
|
# the number of consecutive fails
|
||||||
# if more than _max_nb_fails, alert
|
# if more than _max_nb_fails, alert
|
||||||
@ -19,6 +21,18 @@ _max_nb_fails = 10
|
|||||||
# the maximum number of emails in incoming & active queue
|
# the maximum number of emails in incoming & active queue
|
||||||
_max_incoming = 50
|
_max_incoming = 50
|
||||||
|
|
||||||
|
_NR_CONFIG_FILE_LOCATION_VAR = "NEW_RELIC_CONFIG_FILE"
|
||||||
|
|
||||||
|
|
||||||
|
def get_newrelic_license() -> str:
|
||||||
|
nr_file = os.environ.get(_NR_CONFIG_FILE_LOCATION_VAR, None)
|
||||||
|
if nr_file is None:
|
||||||
|
raise Exception(f"{_NR_CONFIG_FILE_LOCATION_VAR} not defined")
|
||||||
|
|
||||||
|
config = configparser.ConfigParser()
|
||||||
|
config.read(nr_file)
|
||||||
|
return config["newrelic"]["license_key"]
|
||||||
|
|
||||||
|
|
||||||
@newrelic.agent.background_task()
|
@newrelic.agent.background_task()
|
||||||
def log_postfix_metrics():
|
def log_postfix_metrics():
|
||||||
@ -80,10 +94,13 @@ def log_nb_db_connection():
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
exporter = MetricExporter(get_newrelic_license())
|
||||||
while True:
|
while True:
|
||||||
log_postfix_metrics()
|
log_postfix_metrics()
|
||||||
log_nb_db_connection()
|
log_nb_db_connection()
|
||||||
Session.close()
|
Session.close()
|
||||||
|
|
||||||
|
exporter.run()
|
||||||
|
|
||||||
# 1 min
|
# 1 min
|
||||||
sleep(60)
|
sleep(60)
|
||||||
|
@ -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
|
||||||
"""
|
"""
|
||||||
|
5321
app/poetry.lock
generated
5321
app/poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@ -53,7 +53,7 @@ packages = [
|
|||||||
include = ["templates/*", "templates/**/*", "local_data/*.txt"]
|
include = ["templates/*", "templates/**/*", "local_data/*.txt"]
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = "^3.7.2"
|
python = "^3.10"
|
||||||
flask = "^1.1.2"
|
flask = "^1.1.2"
|
||||||
flask_login = "^0.5.0"
|
flask_login = "^0.5.0"
|
||||||
wtforms = "^2.3.3"
|
wtforms = "^2.3.3"
|
||||||
@ -95,13 +95,12 @@ 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"
|
|
||||||
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 +109,9 @@ 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"
|
||||||
|
newrelic-telemetry-sdk = "^0.5.0"
|
||||||
|
aiospamc = "0.10"
|
||||||
|
|
||||||
[tool.poetry.dev-dependencies]
|
[tool.poetry.dev-dependencies]
|
||||||
pytest = "^7.0.0"
|
pytest = "^7.0.0"
|
||||||
|
@ -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):
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -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
16
app/static/package-lock.json
generated
vendored
@ -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"
|
||||||
}
|
}
|
||||||
|
5
app/static/style.css
vendored
5
app/static/style.css
vendored
@ -217,4 +217,9 @@ textarea.parsley-error {
|
|||||||
|
|
||||||
.italic {
|
.italic {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* dashed outline to indicate droppable area */
|
||||||
|
.dashed-outline {
|
||||||
|
outline: 4px dashed gray;
|
||||||
}
|
}
|
@ -9,10 +9,13 @@
|
|||||||
<h1 class="card-title">Create new account</h1>
|
<h1 class="card-title">Create new account</h1>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Email address</label>
|
<label class="form-label">Email address</label>
|
||||||
{{ form.email(class="form-control", type="email") }}
|
{{ form.email(class="form-control", type="email", placeholder="YourName@protonmail.com") }}
|
||||||
<div class="small-text alert alert-info" style="margin-top: 1px">
|
<div class="small-text alert alert-info" style="margin-top: 1px">
|
||||||
Emails sent to your alias will be forwarded to this email address.
|
Emails sent to your alias will be forwarded to this email address.
|
||||||
|
<br>
|
||||||
It can't be a disposable or forwarding email address.
|
It can't be a disposable or forwarding email address.
|
||||||
|
<br>
|
||||||
|
We recommend using a <a href="https://proton.me/mail" target="_blank">Proton Mail</a> address
|
||||||
</div>
|
</div>
|
||||||
{{ render_field_errors(form.email) }}
|
{{ render_field_errors(form.email) }}
|
||||||
</div>
|
</div>
|
||||||
|
@ -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 }}" />
|
||||||
|
@ -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>
|
||||||
@ -131,6 +133,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<span>
|
<span>
|
||||||
<a href="{{ 'mailto:' + contact.website_send_to() }}"
|
<a href="{{ 'mailto:' + contact.website_send_to() }}"
|
||||||
|
target="_blank"
|
||||||
data-toggle="tooltip"
|
data-toggle="tooltip"
|
||||||
title="You can click on this to open your email client. Or use the copy button 👉"
|
title="You can click on this to open your email client. Or use the copy button 👉"
|
||||||
class="font-weight-bold">
|
class="font-weight-bold">
|
||||||
|
@ -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 <i class="fe fe-trash"></i>
|
Delete All <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") }}
|
||||||
|
@ -48,7 +48,7 @@
|
|||||||
{% if scope == "email" %}
|
{% if scope == "email" %}
|
||||||
|
|
||||||
Email:
|
Email:
|
||||||
<a href="mailto:{{ val }}">{{ val }}</a>
|
<a href="mailto:{{ val }}" target="_blank">{{ val }}</a>
|
||||||
{% elif scope == "name" %}
|
{% elif scope == "name" %}
|
||||||
Name: {{ val }}
|
Name: {{ val }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -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) -----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">
|
||||||
|
@ -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>
|
||||||
|
@ -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">
|
||||||
|
@ -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</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.
|
||||||
|
@ -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() %}
|
||||||
|
@ -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 %}
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user