Compare commits

..

20 commits

Author SHA1 Message Date
Son
d1856b9f0e reset transfer token 2023-03-16 21:25:01 +01:00
Son
580e60f71d update doc to use 4.6.5-beta 2022-12-29 17:14:09 +01:00
Son
90b5a42cfa handle the case root user might own a SL domain 2022-12-29 17:07:45 +01:00
Son
6c6add6b28 if adding EMAIL_DOMAIN, mark it as MX, SPF, DKIM verified 2022-12-29 16:34:16 +01:00
Son
b80e3bb25a update doc to use 4.6.4-beta 2022-12-26 18:12:13 +01:00
Son
236d1e0162 remove unused import 2022-12-26 17:11:52 +01:00
Son
d20c96c837 remove the built-in domain check when adding a custom domain 2022-12-26 16:47:54 +01:00
Son
212463d688 4.6.3-beta 2022-10-31 10:38:47 +01:00
Son
54ab438f09 use the new dockerfile 2022-10-31 10:38:31 +01:00
Son
0fd00d8dd8 enable pool_pre_ping if SQLALCHEMY_POOL_PRE_PING is set 2022-10-31 09:26:51 +01:00
Son
0fb5f377b2 fix the duplicated alias issue 2022-08-29 11:47:47 +02:00
Spitfireap
af5f31fe5f
mitigate dns issue with dnsbl (#1206) 2022-08-01 16:25:08 +02:00
Spitfireap
e9a738d669
Updated version numbers (#1109) 2022-06-23 09:13:23 +02:00
Son
563e1ed780 Update /etc/postfix/pgsql-relay-domains.cf and /etc/postfix/pgsql-transport-maps.cf in upgrade 2022-06-15 17:36:02 +02:00
Son
8e3febdd7f also remove sl-job-runner docker container when upgrade 2022-06-15 17:31:19 +02:00
Son
58a42d882d use alembic instead of flask db 2022-06-15 17:21:21 +02:00
Son
fe7db5ec3a fix 2022-06-15 17:20:10 +02:00
Son
c184a0054d use simplelogin/app:4.6.2-beta instead of simplelogin/app:3.4.0. Use alembic instead of flask sqlalchemy 2022-06-15 17:15:21 +02:00
Son
3021d831f1 update changelog 2022-06-15 16:47:40 +02:00
Son
3e0497ff59 take into account public domain in pgsql-relay-domains.cf and /etc/postfix/pgsql-transport-maps.cf 2022-06-15 16:47:34 +02:00
428 changed files with 334725 additions and 26708 deletions

View file

@ -7,12 +7,12 @@ assignees: ''
---
Please note that this is only for bug report.
Please note that this is only for bug report.
For help on your account, please reach out to us at hi[at]simplelogin.io. Please make sure to check out [our FAQ](https://simplelogin.io/faq/) that contains frequently asked questions.
For feature request, you can use our [forum](https://github.com/simple-login/app/discussions/categories/feature-request).
For feature request, you can use our [forum](https://github.com/simple-login/app/discussions/categories/feature-request).
For self-hosted question/issue, please ask in [self-hosted forum](https://github.com/simple-login/app/discussions/categories/self-hosting-question)

View file

@ -1,5 +1,5 @@
{
"template": "${{CHANGELOG}}\n\n<details>\n<summary>Uncategorized</summary>\n\n${{UNCATEGORIZED}}\n</details>",
"template": "${{CHANGELOG}}",
"pr_template": "- ${{TITLE}} #${{NUMBER}}",
"empty_template": "- no changes",
"categories": [
@ -18,6 +18,6 @@
],
"ignore_labels": ["ignore"],
"tag_resolver": {
"method": "semver"
"method": "sort"
}
}
}

View file

@ -1,44 +1,15 @@
name: Test and lint
name: Run tests & Publish to Docker Registry
on:
on:
push:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Check out repo
uses: actions/checkout@v3
- name: Install poetry
run: pipx install poetry
- uses: actions/setup-python@v4
with:
python-version: '3.10'
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
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
run: poetry install --no-interaction
- name: Check formatting & linting
run: |
poetry run pre-commit run --all-files
test:
runs-on: ubuntu-latest
strategy:
max-parallel: 4
matrix:
python-version: ["3.10"]
python-version: [3.7, "3.10"]
# service containers to run with `postgres-job`
services:
@ -67,16 +38,27 @@ jobs:
--health-retries 5
steps:
- name: Check out repo
uses: actions/checkout@v3
- name: Check out repository
uses: actions/checkout@v2
- name: Install poetry
run: pipx install poetry
- uses: actions/setup-python@v4
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
cache: 'poetry'
- name: Install poetry
uses: snok/install-poetry@v1
with:
virtualenvs-create: true
virtualenvs-in-project: true
installer-parallel: true
- name: Run caching
id: cached-poetry-dependencies
uses: actions/cache@v2
with:
path: .venv
key: venv-${{ runner.os }}-${{ matrix.python-version }}-${{ hashFiles('**/poetry.lock') }}
- name: Install OS dependencies
if: ${{ matrix.python-version }} == '3.10'
@ -86,13 +68,15 @@ jobs:
- name: Install dependencies
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
run: poetry install --no-interaction --no-root
- name: Install library
run: poetry install --no-interaction
- name: Start Redis v6
uses: superchargejs/redis-github-action@1.1.0
with:
redis-version: 6
- name: Check formatting & linting
run: |
poetry run black --check .
poetry run flake8
- name: Run db migration
run: |
@ -117,7 +101,7 @@ jobs:
build:
runs-on: ubuntu-latest
needs: ['test', 'lint']
needs: ['test']
if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v'))
steps:
@ -135,19 +119,7 @@ jobs:
# We need to checkout the repository in order for the "Create Sentry release" to work
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Create Sentry release
uses: getsentry/action-release@v1
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
with:
ignore_missing: true
ignore_empty: true
uses: actions/checkout@v2
- name: Prepare version file
run: |
@ -161,13 +133,19 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
- name: Create Sentry release
uses: getsentry/action-release@v1
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
#- name: Send Telegram message
# uses: appleboy/telegram-action@master
# with:
# to: ${{ secrets.TELEGRAM_TO }}
# token: ${{ secrets.TELEGRAM_TOKEN }}
# args: Docker image pushed on ${{ github.ref }}
- name: Send Telegram message
uses: appleboy/telegram-action@master
with:
to: ${{ secrets.TELEGRAM_TO }}
token: ${{ secrets.TELEGRAM_TOKEN }}
args: Docker image pushed on ${{ github.ref }}
# If we have generated a tag, generate the changelog, send a notification to slack and create the GitHub release
- name: Build Changelog
@ -235,4 +213,4 @@ jobs:
release_name: ${{ github.ref }}
body: ${{ steps.build_changelog.outputs.changelog }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

1
.gitignore vendored
View file

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

View file

@ -1,25 +1,10 @@
exclude: "(migrations|static/node_modules|static/assets|static/vendor)"
default_language_version:
python: python3
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.2.0
- repo: https://github.com/psf/black
rev: 22.1.0
hooks:
- id: check-yaml
- id: trailing-whitespace
- repo: https://github.com/Riverside-Healthcare/djLint
rev: v1.3.0
- id: black
language_version: python3.7
- repo: https://github.com/pycqa/flake8
rev: 4.0.1
hooks:
- id: djlint-jinja
files: '.*\.html'
entry: djlint --reformat
- repo: https://github.com/astral-sh/ruff-pre-commit
# Ruff version.
rev: v0.1.5
hooks:
# Run the linter.
- id: ruff
args: [ --fix ]
# Run the formatter.
- id: ruff-format
- id: flake8

227
.pylintrc
View file

@ -1,227 +0,0 @@
[MASTER]
extension-pkg-allow-list=re2
fail-under=7.0
ignore=CVS
ignore-paths=migrations
ignore-patterns=^\.#
jobs=0
[MESSAGES CONTROL]
disable=missing-function-docstring,
missing-module-docstring,
duplicate-code,
#import-error,
missing-class-docstring,
useless-object-inheritance,
use-dict-literal,
logging-format-interpolation,
consider-using-f-string,
unnecessary-comprehension,
inconsistent-return-statements,
wrong-import-order,
line-too-long,
invalid-name,
global-statement,
no-else-return,
unspecified-encoding,
logging-fstring-interpolation,
too-few-public-methods,
bare-except,
fixme,
unnecessary-pass,
f-string-without-interpolation,
super-init-not-called,
unused-argument,
ungrouped-imports,
too-many-locals,
consider-using-with,
too-many-statements,
consider-using-set-comprehension,
unidiomatic-typecheck,
useless-else-on-loop,
too-many-return-statements,
broad-except,
protected-access,
consider-using-enumerate,
too-many-nested-blocks,
too-many-branches,
simplifiable-if-expression,
possibly-unused-variable,
pointless-string-statement,
wrong-import-position,
redefined-outer-name,
raise-missing-from,
logging-too-few-args,
redefined-builtin,
too-many-arguments,
import-outside-toplevel,
redefined-argument-from-local,
logging-too-many-args,
too-many-instance-attributes,
unreachable,
no-name-in-module,
no-member,
consider-using-ternary,
too-many-lines,
arguments-differ,
too-many-public-methods,
unused-variable,
consider-using-dict-items,
consider-using-in,
reimported,
too-many-boolean-expressions,
cyclic-import,
not-callable, # (paddle_utils.py) verifier.verify cannot be called (although it can)
abstract-method, # (models.py)
[BASIC]
# Naming style matching correct argument names.
argument-naming-style=snake_case
# Regular expression matching correct argument names. Overrides argument-
# naming-style. If left empty, argument names will be checked with the set
# naming style.
#argument-rgx=
# Naming style matching correct attribute names.
attr-naming-style=snake_case
# Regular expression matching correct attribute names. Overrides attr-naming-
# style. If left empty, attribute names will be checked with the set naming
# style.
#attr-rgx=
# Bad variable names which should always be refused, separated by a comma.
bad-names=foo,
bar,
baz,
toto,
tutu,
tata
# Bad variable names regexes, separated by a comma. If names match any regex,
# they will always be refused
bad-names-rgxs=
# Naming style matching correct class attribute names.
class-attribute-naming-style=any
# Regular expression matching correct class attribute names. Overrides class-
# attribute-naming-style. If left empty, class attribute names will be checked
# with the set naming style.
#class-attribute-rgx=
# Naming style matching correct class constant names.
class-const-naming-style=UPPER_CASE
# Regular expression matching correct class constant names. Overrides class-
# const-naming-style. If left empty, class constant names will be checked with
# the set naming style.
#class-const-rgx=
# Naming style matching correct class names.
class-naming-style=PascalCase
# Regular expression matching correct class names. Overrides class-naming-
# style. If left empty, class names will be checked with the set naming style.
#class-rgx=
# Naming style matching correct constant names.
const-naming-style=UPPER_CASE
# Regular expression matching correct constant names. Overrides const-naming-
# style. If left empty, constant names will be checked with the set naming
# style.
#const-rgx=
# Minimum line length for functions/classes that require docstrings, shorter
# ones are exempt.
docstring-min-length=-1
# Naming style matching correct function names.
function-naming-style=snake_case
# Regular expression matching correct function names. Overrides function-
# naming-style. If left empty, function names will be checked with the set
# naming style.
#function-rgx=
# Good variable names which should always be accepted, separated by a comma.
good-names=i,
j,
k,
ex,
Run,
_
# Good variable names regexes, separated by a comma. If names match any regex,
# they will always be accepted
good-names-rgxs=
# Include a hint for the correct naming format with invalid-name.
include-naming-hint=no
# Naming style matching correct inline iteration names.
inlinevar-naming-style=any
# Regular expression matching correct inline iteration names. Overrides
# inlinevar-naming-style. If left empty, inline iteration names will be checked
# with the set naming style.
#inlinevar-rgx=
# Naming style matching correct method names.
method-naming-style=snake_case
# Regular expression matching correct method names. Overrides method-naming-
# style. If left empty, method names will be checked with the set naming style.
#method-rgx=
# Naming style matching correct module names.
module-naming-style=snake_case
# Regular expression matching correct module names. Overrides module-naming-
# style. If left empty, module names will be checked with the set naming style.
#module-rgx=
# Colon-delimited sets of names that determine each other's naming style when
# the name regexes allow several styles.
name-group=
# Regular expression which should only match function or class names that do
# not require a docstring.
no-docstring-rgx=^_
# List of decorators that produce properties, such as abc.abstractproperty. Add
# to this list to register other decorators that produce valid properties.
# These decorators are taken in consideration only for invalid-name.
property-classes=abc.abstractproperty
# Regular expression matching correct type variable names. If left empty, type
# variable names will be checked with the set naming style.
#typevar-rgx=
# Naming style matching correct variable names.
variable-naming-style=snake_case
# Regular expression matching correct variable names. Overrides variable-
# naming-style. If left empty, variable names will be checked with the set
# naming style.
#variable-rgx=
[STRING]
# This flag controls whether inconsistent-quotes generates a warning when the
# character used as a quote delimiter is used inconsistently within a module.
check-quote-consistency=no
# This flag controls whether the implicit-str-concat should generate a warning
# on implicit string concatenation in sequences defined over several lines.
check-str-concat-over-line-jumps=no
[FORMAT]
max-line-length=88
single-line-if-stmt=yes

View file

@ -6,6 +6,23 @@ The version corresponds to SimpleLogin Docker `image tag`.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [4.6.2] - 2022-06-15
- support dot in alias prefix
- Set the "X-SimpleLogin-Envelope-To" to the alias during forward
- Use a different format for VERP
- use same footer as landing page
- remove obsolete endpoints /alias/custom/new, /alias/options, /v2/alias/options, /v3/alias/options
- support search on contact page
- Better search using Postgres fulltext search
- Use AGPL license instead of MIT
- auto-create alias rule for custom domain
- use re2 instead of re to avoid ReDOS attack
- Use alembic instead of flask migrate which depends on flask-sqlalchemy
- Remove flask-sqlalchemy
- Able to block sender
- subdomain
- GPDR data export
## [3.4.0] - 2021-04-06
Support ARM arch
Remove unused config like DEBUG, CLOUDWATCH, DKIM_PUBLIC_KEY_PATH, DKIM_DNS_VALUE
@ -117,7 +134,7 @@ Add SUPPORT_NAME param to set a support email name.
## [1.0.1] - 2020-01-28
Simplify config file.
Simplify config file.
## [1.0.0] - 2020-01-22

View file

@ -1,9 +1,9 @@
Thanks for taking the time to contribute! 🎉👍
Before working on a new feature, please get in touch with us at dev[at]simplelogin.io to avoid duplication.
We can also discuss the best way to implement it.
Before working on a new feature, please get in touch with us at dev[at]simplelogin.io to avoid duplication.
We can also discuss the best way to implement it.
The project uses Flask, Python3.7+ and requires Postgres 12+ as dependency.
The project uses Flask, Python3.7+ and requires Postgres 12+ as dependency.
## General Architecture
@ -34,7 +34,7 @@ poetry install
On Mac, sometimes you might need to install some other packages via `brew`:
```bash
brew install pkg-config libffi openssl postgresql@13
brew install pkg-config libffi openssl postgresql
```
You also need to install `gpg` tool, on Mac it can be done with:
@ -43,27 +43,15 @@ You also need to install `gpg` tool, on Mac it can be done with:
brew install gnupg
```
If you see the `pyre2` package in the error message, you might need to install its dependencies with `brew`.
If you see the `pyre2` package in the error message, you might need to install its dependencies with `brew`.
More info on https://github.com/andreasvc/pyre2
```bash
brew install -s re2 pybind11
```
## Linting and static analysis
We use pre-commit to run all our linting and static analysis checks. Please run
```bash
poetry run pre-commit install
```
To install it in your development environment.
## Run tests
For most tests, you will need to have ``redis`` installed and started on your machine (listening on port 6379).
```bash
sh scripts/run-test.sh
```
@ -82,16 +70,10 @@ To run the code locally, please create a local setting file based on `example.en
cp example.env .env
```
You need to edit your .env to reflect the postgres exposed port, edit the `DB_URI` to:
```
DB_URI=postgresql://myuser:mypassword@localhost:35432/simplelogin
```
Run the postgres database:
```bash
docker run -e POSTGRES_PASSWORD=mypassword -e POSTGRES_USER=myuser -e POSTGRES_DB=simplelogin -p 15432:5432 postgres:13
docker run -e POSTGRES_PASSWORD=mypassword -e POSTGRES_USER=myuser -e POSTGRES_DB=simplelogin -p 35432:5432 postgres:13
```
To run the server:
@ -163,17 +145,7 @@ The code is also checked with `flake8`, make sure to run `flake8` before creatin
poetry run flake8
```
For HTML templates, we use `djlint`. Before creating a pull request, please run
```bash
poetry run djlint --check templates
```
If some files aren't properly formatted, you can format all files with
```bash
poetry run djlint --reformat .
```
Nice to have: as we haven't found a good enough HTML code formatter, please reformat any HTML code with PyCharm.
## Test sending email
@ -212,11 +184,4 @@ python email_handler.py
swaks --to e1@sl.local --from hey@google.com --server 127.0.0.1:20381
```
Now open http://localhost:1080/ (or http://localhost:1080/ for MailHog), you should see the forwarded email.
## Job runner
Some features require a job handler (such as GDPR data export). To test such feature you need to run the job_runner
```bash
python job_runner.py
```
Now open http://localhost:1080/ (or http://localhost:1080/ for MailHog), you should see the forwarded email.

View file

@ -2,7 +2,7 @@
FROM node:10.17.0-alpine AS npm
WORKDIR /code
COPY ./static/package*.json /code/static/
RUN cd /code/static && npm ci
RUN cd /code/static && npm install
# Main image
FROM python:3.10
@ -23,15 +23,15 @@ COPY poetry.lock pyproject.toml ./
# Install and setup poetry
RUN pip install -U pip \
&& apt-get update \
&& apt install -y curl netcat-traditional gcc python3-dev gnupg git libre2-dev cmake ninja-build\
&& apt install -y curl netcat gcc python3-dev gnupg git libre2-dev \
&& curl -sSL https://install.python-poetry.org | python3 - \
# Remove curl and netcat from the image
&& apt-get purge -y curl netcat-traditional \
&& apt-get purge -y curl netcat \
# Run poetry
&& poetry config virtualenvs.create false \
&& poetry install --no-interaction --no-ansi --no-root \
# Clear apt cache \
&& apt-get purge -y libre2-dev cmake ninja-build\
&& apt-get purge -y libre2-dev \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*

View file

@ -15,8 +15,8 @@
<img src="https://img.shields.io/github/license/simple-login/app">
</a>
<a href="https://twitter.com/simplelogin">
<img src="https://img.shields.io/twitter/follow/simplelogin?style=social">
<a href="https://twitter.com/simple_login">
<img src="https://img.shields.io/twitter/follow/simple_login?style=social">
</a>
</p>
@ -29,12 +29,12 @@
---
Your email address is your **online identity**. When you use the same email address everywhere, you can be easily tracked.
More information on https://simplelogin.io
Your email address is your **online identity**. When you use the same email address everywhere, you can be easily tracked.
More information on https://simplelogin.io
This README contains instructions on how to self host SimpleLogin.
Once you have your own SimpleLogin instance running, you can change the `API URL` in SimpleLogin's Chrome/Firefox extension, Android/iOS app to your server.
Once you have your own SimpleLogin instance running, you can change the `API URL` in SimpleLogin's Chrome/Firefox extension, Android/iOS app to your server.
SimpleLogin roadmap is at https://github.com/simple-login/app/projects/1 and our forum at https://github.com/simple-login/app/discussions, feel free to submit new ideas or vote on features.
@ -329,17 +329,11 @@ smtpd_recipient_restrictions =
reject_unknown_recipient_domain,
permit_mynetworks,
reject_unauth_destination,
reject_rbl_client zen.spamhaus.org,
reject_rbl_client bl.spamcop.net,
reject_rbl_client zen.spamhaus.org=127.0.0.[2..11],
reject_rbl_client bl.spamcop.net=127.0.0.2,
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.
Make sure that the database config is correctly set, replace `mydomain.com` with your domain, update 'myuser' and 'mypassword' with your postgres credentials.
@ -351,6 +345,7 @@ password = mypassword
dbname = simplelogin
query = SELECT domain FROM custom_domain WHERE domain='%s' AND verified=true
UNION SELECT domain FROM public_domain WHERE domain='%s'
UNION SELECT '%s' WHERE '%s' = 'mydomain.com' LIMIT 1;
```
@ -366,6 +361,7 @@ dbname = simplelogin
# forward to smtp:127.0.0.1:20381 for custom domain AND email domain
query = SELECT 'smtp:127.0.0.1:20381' FROM custom_domain WHERE domain = '%s' AND verified=true
UNION SELECT 'smtp:127.0.0.1:20381' FROM public_domain WHERE domain = '%s'
UNION SELECT 'smtp:127.0.0.1:20381' WHERE '%s' = 'mydomain.com' LIMIT 1;
```
@ -380,10 +376,10 @@ sudo systemctl restart postfix
To run SimpleLogin, you need a config file at `$(pwd)/simplelogin.env`. Below is an example that you can use right away, make sure to
- replace `mydomain.com` by your domain,
- set `FLASK_SECRET` to a secret string,
- set `FLASK_SECRET` to a secret string,
- update 'myuser' and 'mypassword' with your database credentials used in previous step.
All possible parameters can be found in [config example](example.env). Some are optional and are commented out by default.
All possible parameters can be found in [config example](example.env). Some are optional and are commented out by default.
Some have "dummy" values, fill them up if you want to enable these features (Paddle, AWS, etc).
```.env
@ -430,7 +426,7 @@ docker run --rm \
-v $(pwd)/dkim.pub.key:/dkim.pub.key \
-v $(pwd)/simplelogin.env:/code/.env \
--network="sl-network" \
simplelogin/app:3.4.0 flask db upgrade
simplelogin/app:4.6.5-beta alembic upgrade head
```
This command could take a while to download the `simplelogin/app` docker image.
@ -445,7 +441,7 @@ docker run --rm \
-v $(pwd)/dkim.key:/dkim.key \
-v $(pwd)/dkim.pub.key:/dkim.pub.key \
--network="sl-network" \
simplelogin/app:3.4.0 python init_app.py
simplelogin/app:4.6.5-beta python init_app.py
```
Now, it's time to run the `webapp` container!
@ -461,7 +457,7 @@ docker run -d \
-p 127.0.0.1:7777:7777 \
--restart always \
--network="sl-network" \
simplelogin/app:3.4.0
simplelogin/app:4.6.5-beta
```
Next run the `email handler`
@ -477,7 +473,7 @@ docker run -d \
-p 127.0.0.1:20381:20381 \
--restart always \
--network="sl-network" \
simplelogin/app:3.4.0 python email_handler.py
simplelogin/app:4.6.5-beta python email_handler.py
```
And finally the `job runner`
@ -492,7 +488,7 @@ docker run -d \
-v $(pwd)/dkim.pub.key:/dkim.pub.key \
--restart always \
--network="sl-network" \
simplelogin/app:3.4.0 python job_runner.py
simplelogin/app:4.6.5-beta python job_runner.py
```
### Nginx

View file

@ -2,13 +2,13 @@
## Supported Versions
We only add security updates to the latest MAJOR.MINOR version of the project. No security updates are backported to previous versions.
We only add security updates to the latest MAJOR.MINOR version of the project. No security updates are backported to previous versions.
If you want be up to date on security patches, make sure your SimpleLogin image is up to date.
## Reporting a Vulnerability
If you've found a security vulnerability, you can disclose it responsibly by sending a summary to security@simplelogin.io.
We will review the potential threat and fix it as fast as we can.
If you've found a security vulnerability, you can disclose it responsibly by sending a summary to security@simplelogin.io.
We will review the potential threat and fix it as fast as we can.
We are incredibly thankful for people who disclose vulnerabilities, unfortunately we do not have a bounty program in place yet.

View file

@ -1,28 +1,13 @@
from abc import ABC, abstractmethod
from arrow import Arrow
from dataclasses import dataclass
from enum import Enum
from typing import Optional
from arrow import Arrow
from newrelic import agent
from sqlalchemy import or_
from app.db import Session
from app.email_utils import send_welcome_email
from app.utils import sanitize_email, canonicalize_email
from app.errors import (
AccountAlreadyLinkedToAnotherPartnerException,
AccountIsUsingAliasAsEmail,
AccountAlreadyLinkedToAnotherUserException,
)
from app.errors import AccountAlreadyLinkedToAnotherPartnerException
from app.log import LOG
from app.models import (
PartnerSubscription,
Partner,
PartnerUser,
User,
Alias,
)
from app.models import PartnerSubscription, Partner, PartnerUser, User
from app.utils import random_string
@ -43,7 +28,6 @@ class PartnerLinkRequest:
email: str
external_user_id: str
plan: SLPlan
from_partner: bool
@dataclass
@ -60,7 +44,6 @@ def set_plan_for_partner_user(partner_user: PartnerUser, plan: SLPlan):
f"Deleting partner_subscription [user_id={partner_user.user_id}] [partner_id={partner_user.partner_id}]"
)
PartnerSubscription.delete(sub.id)
agent.record_custom_event("PlanChange", {"plan": "free"})
else:
if sub is None:
LOG.i(
@ -70,16 +53,11 @@ def set_plan_for_partner_user(partner_user: PartnerUser, plan: SLPlan):
partner_user_id=partner_user.id,
end_at=plan.expiration,
)
agent.record_custom_event("PlanChange", {"plan": "premium", "type": "new"})
else:
if sub.end_at != plan.expiration:
LOG.i(
f"Updating partner_subscription [user_id={partner_user.user_id}] [partner_id={partner_user.partner_id}]"
)
agent.record_custom_event(
"PlanChange", {"plan": "premium", "type": "extension"}
)
sub.end_at = plan.expiration
LOG.i(
f"Updating partner_subscription [user_id={partner_user.user_id}] [partner_id={partner_user.partner_id}]"
)
sub.end_at = plan.expiration
Session.commit()
@ -132,13 +110,10 @@ class ClientMergeStrategy(ABC):
class NewUserStrategy(ClientMergeStrategy):
def process(self) -> LinkResult:
# Will create a new SL User with a random password
canonical_email = canonicalize_email(self.link_request.email)
new_user = User.create(
email=canonical_email,
email=self.link_request.email,
name=self.link_request.name,
password=random_string(20),
activated=True,
from_partner=self.link_request.from_partner,
)
partner_user = PartnerUser.create(
user_id=new_user.id,
@ -155,19 +130,15 @@ class NewUserStrategy(ClientMergeStrategy):
)
Session.commit()
if not new_user.created_by_partner:
send_welcome_email(new_user)
agent.record_custom_event("PartnerUserCreation", {"partner": self.partner.name})
return LinkResult(
user=new_user,
strategy=self.__class__.__name__,
)
class ExistingUnlinkedUserStrategy(ClientMergeStrategy):
class ExistingUnlinedUserStrategy(ClientMergeStrategy):
def process(self) -> LinkResult:
partner_user = ensure_partner_user_exists_for_user(
self.link_request, self.user, self.partner
)
@ -181,7 +152,7 @@ class ExistingUnlinkedUserStrategy(ClientMergeStrategy):
class LinkedWithAnotherPartnerUserStrategy(ClientMergeStrategy):
def process(self) -> LinkResult:
raise AccountAlreadyLinkedToAnotherUserException()
raise AccountAlreadyLinkedToAnotherPartnerException()
def get_login_strategy(
@ -195,40 +166,20 @@ def get_login_strategy(
if other_partner_user is not None:
return LinkedWithAnotherPartnerUserStrategy(link_request, user, partner)
# There is a SimpleLogin user with the partner_user's e-mail
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()
return ExistingUnlinedUserStrategy(link_request, user, partner)
def process_login_case(
link_request: PartnerLinkRequest, partner: Partner
) -> LinkResult:
# Sanitize email just in case
link_request.email = sanitize_email(link_request.email)
# Try to find a SimpleLogin user registered with that partner user id
partner_user = PartnerUser.get_by(
partner_id=partner.id, external_user_id=link_request.external_user_id
)
if partner_user is None:
canonical_email = canonicalize_email(link_request.email)
# We didn't find any SimpleLogin user registered with that partner user id
# Make sure they aren't using an alias as their link email
check_alias(link_request.email)
check_alias(canonical_email)
# Try to find it using the partner's e-mail address
users = User.filter(
or_(User.email == link_request.email, User.email == canonical_email)
).all()
if len(users) > 1:
user = [user for user in users if user.email == canonical_email][0]
elif len(users) == 1:
user = users[0]
else:
user = None
user = User.get_by(email=link_request.email)
return get_login_strategy(link_request, user, partner).process()
else:
# We found the SL user registered with that partner user id
@ -244,14 +195,11 @@ def process_login_case(
def link_user(
link_request: PartnerLinkRequest, current_user: User, partner: Partner
) -> LinkResult:
# Sanitize email just in case
link_request.email = sanitize_email(link_request.email)
partner_user = ensure_partner_user_exists_for_user(
link_request, current_user, partner
)
set_plan_for_partner_user(partner_user, link_request.plan)
agent.record_custom_event("AccountLinked", {"partner": partner.name})
Session.commit()
return LinkResult(
user=current_user,
@ -289,8 +237,6 @@ def process_link_case(
current_user: User,
partner: Partner,
) -> LinkResult:
# Sanitize email just in case
link_request.email = sanitize_email(link_request.email)
# Try to find a SimpleLogin user linked with this Partner account
partner_user = PartnerUser.get_by(
partner_id=partner.id, external_user_id=link_request.external_user_id
@ -300,7 +246,7 @@ def process_link_case(
return link_user(link_request, current_user, partner)
# There is a SL user registered with the partner. Check if is the current one
if partner_user.user_id == current_user.id:
if partner_user.id == current_user.id:
# Update plan
set_plan_for_partner_user(partner_user, link_request.plan)
# It's the same user. No need to do anything
@ -309,4 +255,5 @@ def process_link_case(
strategy="Link",
)
else:
return switch_already_linked_user(link_request, partner_user, current_user)

View file

@ -24,17 +24,12 @@ from app.models import (
ProviderComplaintState,
Phase,
ProviderComplaint,
Alias,
Newsletter,
PADDLE_SUBSCRIPTION_GRACE_DAYS,
)
from app.newsletter_utils import send_newsletter_to_user, send_newsletter_to_address
class SLModelView(sqla.ModelView):
column_default_sort = ("id", True)
column_display_pk = True
page_size = 100
can_edit = False
can_create = False
@ -94,10 +89,6 @@ class SLAdminIndexView(AdminIndexView):
return redirect("/admin/user")
def _user_upgrade_channel_formatter(view, context, model, name):
return Markup(model.upgrade_channel)
class UserAdmin(SLModelView):
column_searchable_list = ["email", "id"]
column_exclude_list = [
@ -115,38 +106,6 @@ class UserAdmin(SLModelView):
ret.insert(0, "upgrade_channel")
return ret
column_formatters = {
"upgrade_channel": _user_upgrade_channel_formatter,
}
@action(
"disable_user",
"Disable user",
"Are you sure you want to disable the selected users?",
)
def action_disable_user(self, ids):
for user in User.filter(User.id.in_(ids)):
user.disabled = True
flash(f"Disabled user {user.id}")
AdminAuditLog.disable_user(current_user.id, user.id)
Session.commit()
@action(
"enable_user",
"Enable user",
"Are you sure you want to enable the selected users?",
)
def action_enable_user(self, ids):
for user in User.filter(User.id.in_(ids)):
user.disabled = False
flash(f"Enabled user {user.id}")
AdminAuditLog.enable_user(current_user.id, user.id)
Session.commit()
@action(
"education_upgrade",
"Education upgrade",
@ -237,36 +196,6 @@ class UserAdmin(SLModelView):
Session.commit()
@action(
"stop_paddle_sub",
"Stop user Paddle subscription",
"This will stop the current user Paddle subscription so if user doesn't have Proton sub, they will lose all SL benefits immediately",
)
def stop_paddle_sub(self, ids):
for user in User.filter(User.id.in_(ids)):
sub: Subscription = user.get_paddle_subscription()
if not sub:
flash(f"No Paddle sub for {user}", "warning")
continue
flash(f"{user} sub will end now, instead of {sub.next_bill_date}", "info")
sub.next_bill_date = (
arrow.now().shift(days=-PADDLE_SUBSCRIPTION_GRACE_DAYS).date()
)
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(
# "login_as",
# "Login as this user",
@ -290,7 +219,7 @@ def manual_upgrade(way: str, ids: [int], is_giveaway: bool):
flash(f"user {user} already has a lifetime license", "warning")
continue
sub: Subscription = user.get_paddle_subscription()
sub: Subscription = user.get_subscription()
if sub and not sub.cancelled:
flash(
f"user {user} already has a Paddle license, they have to cancel it first",
@ -340,26 +269,6 @@ class AliasAdmin(SLModelView):
column_searchable_list = ["id", "user.email", "email", "mailbox.email"]
column_filters = ["id", "user.email", "email", "mailbox.email"]
@action(
"disable_email_spoofing_check",
"Disable email spoofing protection",
"Disable email spoofing protection?",
)
def disable_email_spoofing_check_for(self, ids):
for alias in Alias.filter(Alias.id.in_(ids)):
if alias.disable_email_spoofing_check:
flash(
f"Email spoofing protection is already disabled on {alias.email}",
"warning",
)
else:
alias.disable_email_spoofing_check = True
flash(
f"Email spoofing protection is disabled on {alias.email}", "success"
)
Session.commit()
class MailboxAdmin(SLModelView):
column_searchable_list = ["id", "user.email", "email"]
@ -539,120 +448,3 @@ class ProviderComplaintAdmin(SLModelView):
)
},
)
def _newsletter_plain_text_formatter(view, context, model: Newsletter, name):
# to display newsletter plain_text with linebreaks in the list view
return Markup(model.plain_text.replace("\n", "<br>"))
def _newsletter_html_formatter(view, context, model: Newsletter, name):
# to display newsletter html with linebreaks in the list view
return Markup(model.html.replace("\n", "<br>"))
class NewsletterAdmin(SLModelView):
list_template = "admin/model/newsletter-list.html"
edit_template = "admin/model/newsletter-edit.html"
edit_modal = False
can_edit = True
can_create = True
column_formatters = {
"plain_text": _newsletter_plain_text_formatter,
"html": _newsletter_html_formatter,
}
@action(
"send_newsletter_to_user",
"Send this newsletter to myself or the specified userID",
)
def send_newsletter_to_user(self, newsletter_ids):
user_id = request.form["user_id"]
if user_id:
user = User.get(user_id)
if not user:
flash(f"No such user with ID {user_id}", "error")
return
else:
flash("use the current user", "info")
user = current_user
for newsletter_id in newsletter_ids:
newsletter = Newsletter.get(newsletter_id)
sent, error_msg = send_newsletter_to_user(newsletter, user)
if sent:
flash(f"{newsletter} sent to {user}", "success")
else:
flash(error_msg, "error")
@action(
"send_newsletter_to_address",
"Send this newsletter to a specific address",
)
def send_newsletter_to_address(self, newsletter_ids):
to_address = request.form["to_address"]
if not to_address:
flash("to_address missing", "error")
return
for newsletter_id in newsletter_ids:
newsletter = Newsletter.get(newsletter_id)
# use the current_user for rendering email
sent, error_msg = send_newsletter_to_address(
newsletter, current_user, to_address
)
if sent:
flash(
f"{newsletter} sent to {to_address} with {current_user} context",
"success",
)
else:
flash(error_msg, "error")
@action(
"clone_newsletter",
"Clone this newsletter",
)
def clone_newsletter(self, newsletter_ids):
if len(newsletter_ids) != 1:
flash("you can only select 1 newsletter", "error")
return
newsletter_id = newsletter_ids[0]
newsletter: Newsletter = Newsletter.get(newsletter_id)
new_newsletter = Newsletter.create(
subject=newsletter.subject,
html=newsletter.html,
plain_text=newsletter.plain_text,
commit=True,
)
flash(f"Newsletter {new_newsletter.subject} has been cloned", "success")
class NewsletterUserAdmin(SLModelView):
column_searchable_list = ["id"]
column_filters = ["id", "user.email", "newsletter.subject"]
column_exclude_list = ["created_at", "updated_at", "id"]
can_edit = False
can_create = False
class DailyMetricAdmin(SLModelView):
column_exclude_list = ["created_at", "updated_at", "id"]
can_export = True
class MetricAdmin(SLModelView):
column_exclude_list = ["created_at", "updated_at", "id"]
can_export = True
class InvalidMailboxDomainAdmin(SLModelView):
can_create = True
can_delete = True

View file

@ -1,190 +0,0 @@
from __future__ import annotations
import json
from dataclasses import asdict, dataclass
from typing import Optional
import itsdangerous
from app import config
from app.log import LOG
from app.models import User, AliasOptions, SLDomain
signer = itsdangerous.TimestampSigner(config.CUSTOM_ALIAS_SECRET)
@dataclass
class AliasSuffix:
# whether this is a custom domain
is_custom: bool
# Suffix
suffix: str
# Suffix signature
signed_suffix: str
# whether this is a premium SL domain. Not apply to custom domain
is_premium: bool
# can be either Custom or SL domain
domain: str
# if custom domain, whether the custom domain has MX verified, i.e. can receive emails
mx_verified: bool = True
def serialize(self):
return json.dumps(asdict(self))
@classmethod
def deserialize(cls, data: str) -> AliasSuffix:
return AliasSuffix(**json.loads(data))
def check_suffix_signature(signed_suffix: str) -> Optional[str]:
# hypothesis: user will click on the button in the 600 secs
try:
return signer.unsign(signed_suffix, max_age=600).decode()
except itsdangerous.BadSignature:
return None
def verify_prefix_suffix(
user: User, alias_prefix, alias_suffix, alias_options: Optional[AliasOptions] = None
) -> bool:
"""verify if user could create an alias with the given prefix and suffix"""
if not alias_prefix or not alias_suffix: # should be caught on frontend
return False
user_custom_domains = [cd.domain for cd in user.verified_custom_domains()]
# make sure alias_suffix is either .random_word@simplelogin.co or @my-domain.com
alias_suffix = alias_suffix.strip()
# alias_domain_prefix is either a .random_word or ""
alias_domain_prefix, alias_domain = alias_suffix.split("@", 1)
# alias_domain must be either one of user custom domains or built-in domains
if alias_domain not in user.available_alias_domains(alias_options=alias_options):
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
return False
# SimpleLogin domain case:
# 1) alias_suffix must start with "." and
# 2) alias_domain_prefix must come from the word list
if (
alias_domain in user.available_sl_domains(alias_options=alias_options)
and alias_domain not in user_custom_domains
# when DISABLE_ALIAS_SUFFIX is true, alias_domain_prefix is empty
and not config.DISABLE_ALIAS_SUFFIX
):
if not alias_domain_prefix.startswith("."):
LOG.e("User %s submits a wrong alias suffix %s", user, alias_suffix)
return False
else:
if alias_domain not in user_custom_domains:
if not config.DISABLE_ALIAS_SUFFIX:
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
return False
if alias_domain not in user.available_sl_domains(
alias_options=alias_options
):
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
return False
return True
def get_alias_suffixes(
user: User, alias_options: Optional[AliasOptions] = None
) -> [AliasSuffix]:
"""
Similar to as get_available_suffixes() but also return custom domain that doesn't have MX set up.
"""
user_custom_domains = user.verified_custom_domains()
alias_suffixes: [AliasSuffix] = []
# put custom domain first
# for each user domain, generate both the domain and a random suffix version
for custom_domain in user_custom_domains:
if custom_domain.random_prefix_generation:
suffix = (
f".{user.get_random_alias_suffix(custom_domain)}@{custom_domain.domain}"
)
alias_suffix = AliasSuffix(
is_custom=True,
suffix=suffix,
signed_suffix=signer.sign(suffix).decode(),
is_premium=False,
domain=custom_domain.domain,
mx_verified=custom_domain.verified,
)
if user.default_alias_custom_domain_id == custom_domain.id:
alias_suffixes.insert(0, alias_suffix)
else:
alias_suffixes.append(alias_suffix)
suffix = f"@{custom_domain.domain}"
alias_suffix = AliasSuffix(
is_custom=True,
suffix=suffix,
signed_suffix=signer.sign(suffix).decode(),
is_premium=False,
domain=custom_domain.domain,
mx_verified=custom_domain.verified,
)
# put the default domain to top
# only if random_prefix_generation isn't enabled
if (
user.default_alias_custom_domain_id == custom_domain.id
and not custom_domain.random_prefix_generation
):
alias_suffixes.insert(0, alias_suffix)
else:
alias_suffixes.append(alias_suffix)
# then SimpleLogin domain
sl_domains = user.get_sl_domains(alias_options=alias_options)
default_domain_found = False
for sl_domain in sl_domains:
prefix = (
"" if config.DISABLE_ALIAS_SUFFIX else f".{user.get_random_alias_suffix()}"
)
suffix = f"{prefix}@{sl_domain.domain}"
alias_suffix = AliasSuffix(
is_custom=False,
suffix=suffix,
signed_suffix=signer.sign(suffix).decode(),
is_premium=sl_domain.premium_only,
domain=sl_domain.domain,
mx_verified=True,
)
# No default or this is not the default
if (
user.default_alias_public_domain_id is None
or user.default_alias_public_domain_id != sl_domain.id
):
alias_suffixes.append(alias_suffix)
else:
default_domain_found = True
alias_suffixes.insert(0, alias_suffix)
if not default_domain_found:
domain_conditions = {"id": user.default_alias_public_domain_id, "hidden": False}
if not user.is_premium():
domain_conditions["premium_only"] = False
sl_domain = SLDomain.get_by(**domain_conditions)
if sl_domain:
prefix = (
""
if config.DISABLE_ALIAS_SUFFIX
else f".{user.get_random_alias_suffix()}"
)
suffix = f"{prefix}@{sl_domain.domain}"
alias_suffix = AliasSuffix(
is_custom=False,
suffix=suffix,
signed_suffix=signer.sign(suffix).decode(),
is_premium=sl_domain.premium_only,
domain=sl_domain.domain,
mx_verified=True,
)
alias_suffixes.insert(0, alias_suffix)
return alias_suffixes

View file

@ -1,11 +1,8 @@
import csv
from io import StringIO
import re
from typing import Optional, Tuple
from email_validator import validate_email, EmailNotValidError
from sqlalchemy.exc import IntegrityError, DataError
from flask import make_response
from app.config import (
BOUNCE_PREFIX_FOR_REPLY_PHASE,
@ -21,8 +18,6 @@ from app.email_utils import (
send_cannot_create_directory_alias_disabled,
get_email_local_part,
send_cannot_create_domain_alias,
send_email,
render,
)
from app.errors import AliasInTrashError
from app.log import LOG
@ -38,8 +33,6 @@ from app.models import (
EmailLog,
Contact,
AutoCreateRule,
AliasUsedOn,
ClientUser,
)
from app.regex_utils import regex_match
@ -61,8 +54,6 @@ def get_user_if_alias_would_auto_create(
domain_and_rule = check_if_alias_can_be_auto_created_for_custom_domain(
address, notify_user=notify_user
)
if DomainDeletedAlias.get_by(email=address):
return None
if domain_and_rule:
return domain_and_rule[0].user
directory = check_if_alias_can_be_auto_created_for_a_directory(
@ -94,7 +85,6 @@ def check_if_alias_can_be_auto_created_for_custom_domain(
return None
if not user.can_create_new_alias():
LOG.d(f"{user} can't create new custom-domain alias {address}")
if notify_user:
send_cannot_create_domain_alias(custom_domain.user, address, alias_domain)
return None
@ -156,7 +146,6 @@ def check_if_alias_can_be_auto_created_for_a_directory(
return None
if not user.can_create_new_alias():
LOG.d(f"{user} can't create new directory alias {address}")
if notify_user:
send_cannot_create_directory_alias(user, address, directory_name)
return None
@ -373,88 +362,3 @@ def check_alias_prefix(alias_prefix) -> bool:
return False
return True
def alias_export_csv(user, csv_direct_export=False):
"""
Get user aliases as importable CSV file
Output:
Importable CSV file
"""
data = [["alias", "note", "enabled", "mailboxes"]]
for alias in Alias.filter_by(user_id=user.id).all(): # type: Alias
# Always put the main mailbox first
# It is seen a primary while importing
alias_mailboxes = alias.mailboxes
alias_mailboxes.insert(
0, alias_mailboxes.pop(alias_mailboxes.index(alias.mailbox))
)
mailboxes = " ".join([mailbox.email for mailbox in alias_mailboxes])
data.append([alias.email, alias.note, alias.enabled, mailboxes])
si = StringIO()
cw = csv.writer(si)
cw.writerows(data)
if csv_direct_export:
return si.getvalue()
output = make_response(si.getvalue())
output.headers["Content-Disposition"] = "attachment; filename=aliases.csv"
output.headers["Content-type"] = "text/csv"
return output
def transfer_alias(alias, new_user, new_mailboxes: [Mailbox]):
# cannot transfer alias which is used for receiving newsletter
if User.get_by(newsletter_alias_id=alias.id):
raise Exception("Cannot transfer alias that's used to receive newsletter")
# update user_id
Session.query(Contact).filter(Contact.alias_id == alias.id).update(
{"user_id": new_user.id}
)
Session.query(AliasUsedOn).filter(AliasUsedOn.alias_id == alias.id).update(
{"user_id": new_user.id}
)
Session.query(ClientUser).filter(ClientUser.alias_id == alias.id).update(
{"user_id": new_user.id}
)
# remove existing mailboxes from the alias
Session.query(AliasMailbox).filter(AliasMailbox.alias_id == alias.id).delete()
# set mailboxes
alias.mailbox_id = new_mailboxes.pop().id
for mb in new_mailboxes:
AliasMailbox.create(alias_id=alias.id, mailbox_id=mb.id)
# alias has never been transferred before
if not alias.original_owner_id:
alias.original_owner_id = alias.user_id
# inform previous owner
old_user = alias.user
send_email(
old_user.email,
f"Alias {alias.email} has been received",
render(
"transactional/alias-transferred.txt",
alias=alias,
),
render(
"transactional/alias-transferred.html",
alias=alias,
),
)
# now the alias belongs to the new user
alias.user_id = new_user.id
# set some fields back to default
alias.disable_pgp = False
alias.pinned = False
Session.commit()

View file

@ -13,25 +13,4 @@ from .views import (
setting,
export,
phone,
sudo,
user,
)
__all__ = [
"alias_options",
"new_custom_alias",
"custom_domain",
"new_random_alias",
"user_info",
"auth",
"auth_mfa",
"alias",
"apple",
"mailbox",
"notification",
"setting",
"export",
"phone",
"sudo",
"user",
]

View file

@ -1,5 +1,4 @@
from functools import wraps
from typing import Tuple, Optional
import arrow
from flask import Blueprint, request, jsonify, g
@ -10,58 +9,30 @@ from app.models import ApiKey
api_bp = Blueprint(name="api", import_name=__name__, url_prefix="/api")
SUDO_MODE_MINUTES_VALID = 5
def authorize_request() -> Optional[Tuple[str, int]]:
api_code = request.headers.get("Authentication")
api_key = ApiKey.get_by(code=api_code)
if not api_key:
if current_user.is_authenticated:
g.user = current_user
else:
return jsonify(error="Wrong api key"), 401
else:
# Update api key stats
api_key.last_used = arrow.now()
api_key.times += 1
Session.commit()
g.user = api_key.user
if g.user.disabled:
return jsonify(error="Disabled account"), 403
g.api_key = api_key
return None
def check_sudo_mode_is_active(api_key: ApiKey) -> bool:
return api_key.sudo_mode_at and g.api_key.sudo_mode_at >= arrow.now().shift(
minutes=-SUDO_MODE_MINUTES_VALID
)
def require_api_auth(f):
@wraps(f)
def decorated(*args, **kwargs):
error_return = authorize_request()
if error_return:
return error_return
return f(*args, **kwargs)
return decorated
def require_api_sudo(f):
@wraps(f)
def decorated(*args, **kwargs):
error_return = authorize_request()
if error_return:
return error_return
if not check_sudo_mode_is_active(g.api_key):
return jsonify(error="Need sudo"), 440
api_code = request.headers.get("Authentication")
api_key = ApiKey.get_by(code=api_code)
if not api_key:
# if user is authenticated, the request is authorized
if current_user.is_authenticated:
g.user = current_user
else:
return jsonify(error="Wrong api key"), 401
else:
# Update api key stats
api_key.last_used = arrow.now()
api_key.times += 1
Session.commit()
g.user = api_key.user
if g.user.disabled:
return jsonify(error="Disabled account"), 403
return f(*args, **kwargs)
return decorated

View file

@ -24,7 +24,6 @@ from app.errors import (
ErrContactAlreadyExists,
ErrAddressInvalid,
)
from app.extensions import limiter
from app.models import Alias, Contact, Mailbox, AliasMailbox
@ -72,9 +71,6 @@ def get_aliases():
@api_bp.route("/v2/aliases", methods=["GET", "POST"])
@limiter.limit(
"5/minute",
)
@require_api_auth
def get_aliases_v2():
"""

View file

@ -2,8 +2,10 @@ import tldextract
from flask import jsonify, request, g
from sqlalchemy import desc
from app.alias_suffix import get_alias_suffixes
from app.api.base import api_bp, require_api_auth
from app.dashboard.views.custom_alias import (
get_available_suffixes,
)
from app.db import Session
from app.log import LOG
from app.models import AliasUsedOn, Alias, User
@ -66,7 +68,7 @@ def options_v4():
prefix_suggestion = convert_to_id(prefix_suggestion)
ret["prefix_suggestion"] = prefix_suggestion
suffixes = get_alias_suffixes(user)
suffixes = get_available_suffixes(user)
# custom domain should be put first
ret["suffixes"] = list([suffix.suffix, suffix.signed_suffix] for suffix in suffixes)
@ -137,7 +139,7 @@ def options_v5():
prefix_suggestion = convert_to_id(prefix_suggestion)
ret["prefix_suggestion"] = prefix_suggestion
suffixes = get_alias_suffixes(user)
suffixes = get_available_suffixes(user)
# custom domain should be put first
ret["suffixes"] = [

View file

@ -9,7 +9,6 @@ from requests import RequestException
from app.api.base import api_bp, require_api_auth
from app.config import APPLE_API_SECRET, MACAPP_APPLE_API_SECRET
from app.subscription_webhook import execute_subscription_webhook
from app.db import Session
from app.log import LOG
from app.models import PlanEnum, AppleSubscription
@ -41,17 +40,15 @@ def apple_process_payment():
LOG.d("request for /apple/process_payment from %s", user)
data = request.get_json()
receipt_data = data.get("receipt_data")
is_macapp = "is_macapp" in data and data["is_macapp"] is True
is_macapp = "is_macapp" in data
if is_macapp:
LOG.d("Use Macapp secret")
password = MACAPP_APPLE_API_SECRET
else:
password = APPLE_API_SECRET
apple_sub = verify_receipt(receipt_data, user, password)
if apple_sub:
execute_subscription_webhook(user)
return jsonify(ok=True), 200
return jsonify(error="Processing failed"), 400
@ -284,7 +281,6 @@ def apple_update_notification():
apple_sub.plan = plan
apple_sub.product_id = transaction["product_id"]
Session.commit()
execute_subscription_webhook(user)
return jsonify(ok=True), 200
else:
LOG.w(
@ -478,7 +474,7 @@ def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]:
# }
if data["status"] != 0:
LOG.e(
LOG.w(
"verifyReceipt status !=0, probably invalid receipt. User %s, data %s",
user,
data,
@ -525,10 +521,9 @@ def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]:
if apple_sub:
LOG.d(
"Update AppleSubscription for user %s, expired at %s (%s), plan %s",
"Update AppleSubscription for user %s, expired at %s, plan %s",
user,
expires_date,
expires_date.humanize(),
plan,
)
apple_sub.receipt_data = receipt_data
@ -557,7 +552,6 @@ def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]:
product_id=latest_transaction["product_id"],
)
execute_subscription_webhook(user)
Session.commit()
return apple_sub

View file

@ -23,7 +23,7 @@ from app.events.auth_event import LoginEvent, RegisterEvent
from app.extensions import limiter
from app.log import LOG
from app.models import User, ApiKey, SocialAuth, AccountActivation
from app.utils import sanitize_email, canonicalize_email
from app.utils import sanitize_email
@api_bp.route("/auth/login", methods=["POST"])
@ -49,13 +49,11 @@ def auth_login():
if not data:
return jsonify(error="request body cannot be empty"), 400
email = sanitize_email(data.get("email"))
password = data.get("password")
device = data.get("device")
email = sanitize_email(data.get("email"))
canonical_email = canonicalize_email(data.get("email"))
user = User.get_by(email=email) or User.get_by(email=canonical_email)
user = User.filter_by(email=email).first()
if not user or not user.check_password(password):
LoginEvent(LoginEvent.ActionType.failed, LoginEvent.Source.api).send()
@ -63,11 +61,6 @@ def auth_login():
elif user.disabled:
LoginEvent(LoginEvent.ActionType.disabled_login, LoginEvent.Source.api).send()
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:
LoginEvent(LoginEvent.ActionType.not_activated, LoginEvent.Source.api).send()
return jsonify(error="Account not activated"), 422
@ -96,8 +89,7 @@ def auth_register():
if not data:
return jsonify(error="request body cannot be empty"), 400
dirty_email = data.get("email")
email = canonicalize_email(dirty_email)
email = sanitize_email(data.get("email"))
password = data.get("password")
if DISABLE_REGISTRATION:
@ -118,7 +110,7 @@ def auth_register():
return jsonify(error="password too long"), 400
LOG.d("create user %s", email)
user = User.create(email=email, name=dirty_email, password=password)
user = User.create(email=email, name="", password=password)
Session.flush()
# create activation code
@ -156,10 +148,9 @@ def auth_activate():
return jsonify(error="request body cannot be empty"), 400
email = sanitize_email(data.get("email"))
canonical_email = canonicalize_email(data.get("email"))
code = data.get("code")
user = User.get_by(email=email) or User.get_by(email=canonical_email)
user = User.get_by(email=email)
# do not use a different message to avoid exposing existing email
if not user or user.activated:
@ -205,9 +196,7 @@ def auth_reactivate():
return jsonify(error="request body cannot be empty"), 400
email = sanitize_email(data.get("email"))
canonical_email = canonicalize_email(data.get("email"))
user = User.get_by(email=email) or User.get_by(email=canonical_email)
user = User.get_by(email=email)
# do not use a different message to avoid exposing existing email
if not user or user.activated:
@ -362,7 +351,7 @@ def auth_payload(user, device) -> dict:
@api_bp.route("/auth/forgot_password", methods=["POST"])
@limiter.limit("2/minute")
@limiter.limit("10/minute")
def forgot_password():
"""
User forgot password
@ -378,9 +367,8 @@ def forgot_password():
return jsonify(error="request body must contain email"), 400
email = sanitize_email(data.get("email"))
canonical_email = canonicalize_email(data.get("email"))
user = User.get_by(email=email) or User.get_by(email=canonical_email)
user = User.get_by(email=email)
if user:
send_reset_password_email(user)

View file

@ -55,7 +55,7 @@ def auth_mfa():
)
totp = pyotp.TOTP(user.otp_secret)
if not totp.verify(mfa_token, valid_window=2):
if not totp.verify(mfa_token):
send_invalid_totp_login_email(user, "TOTP")
return jsonify(error="Wrong TOTP Token"), 400

View file

@ -1,9 +1,12 @@
import csv
from io import StringIO
from flask import g
from flask import jsonify
from flask import make_response
from app.api.base import api_bp, require_api_auth
from app.models import Alias, Client, CustomDomain
from app.alias_utils import alias_export_csv
@api_bp.route("/export/data", methods=["GET"])
@ -46,4 +49,24 @@ def export_aliases():
Importable CSV file
"""
return alias_export_csv(g.user)
user = g.user
data = [["alias", "note", "enabled", "mailboxes"]]
for alias in Alias.filter_by(user_id=user.id).all(): # type: Alias
# Always put the main mailbox first
# It is seen a primary while importing
alias_mailboxes = alias.mailboxes
alias_mailboxes.insert(
0, alias_mailboxes.pop(alias_mailboxes.index(alias.mailbox))
)
mailboxes = " ".join([mailbox.email for mailbox in alias_mailboxes])
data.append([alias.email, alias.note, alias.enabled, mailboxes])
si = StringIO()
cw = csv.writer(si)
cw.writerows(data)
output = make_response(si.getvalue())
output.headers["Content-Disposition"] = "attachment; filename=aliases.csv"
output.headers["Content-type"] = "text/csv"
return output

View file

@ -13,8 +13,8 @@ from app.db import Session
from app.email_utils import (
mailbox_already_used,
email_can_be_used_as_mailbox,
is_valid_email,
)
from app.email_validation import is_valid_email
from app.log import LOG
from app.models import Mailbox, Job
from app.utils import sanitize_email
@ -45,7 +45,7 @@ def create_mailbox():
mailbox_email = sanitize_email(request.get_json().get("email"))
if not user.is_premium():
return jsonify(error="Only premium plan can add additional mailbox"), 400
return jsonify(error=f"Only premium plan can add additional mailbox"), 400
if not is_valid_email(mailbox_email):
return jsonify(error=f"{mailbox_email} invalid"), 400
@ -71,16 +71,13 @@ def create_mailbox():
)
@api_bp.route("/mailboxes/<int:mailbox_id>", methods=["DELETE"])
@api_bp.route("/mailboxes/<mailbox_id>", methods=["DELETE"])
@require_api_auth
def delete_mailbox(mailbox_id):
"""
Delete mailbox
Input:
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:
200 if deleted successfully
@ -94,36 +91,11 @@ def delete_mailbox(mailbox_id):
if mailbox.id == user.default_mailbox_id:
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
LOG.w("schedule delete mailbox job for %s", mailbox)
Job.create(
name=JOB_DELETE_MAILBOX,
payload={
"mailbox_id": mailbox.id,
"transfer_mailbox_id": transfer_mailbox_id,
},
payload={"mailbox_id": mailbox.id},
run_at=arrow.now(),
commit=True,
)
@ -131,7 +103,7 @@ def delete_mailbox(mailbox_id):
return jsonify(deleted=True), 200
@api_bp.route("/mailboxes/<int:mailbox_id>", methods=["PUT"])
@api_bp.route("/mailboxes/<mailbox_id>", methods=["PUT"])
@require_api_auth
def update_mailbox(mailbox_id):
"""

View file

@ -1,8 +1,7 @@
from flask import g
from flask import jsonify, request
from itsdangerous import SignatureExpired
from app import parallel_limiter
from app.alias_suffix import check_suffix_signature, verify_prefix_suffix
from app.alias_utils import check_alias_prefix
from app.api.base import api_bp, require_api_auth
from app.api.serializer import (
@ -10,6 +9,7 @@ from app.api.serializer import (
get_alias_info_v2,
)
from app.config import MAX_NB_EMAIL_FREE_PLAN, ALIAS_LIMIT
from app.dashboard.views.custom_alias import verify_prefix_suffix, signer
from app.db import Session
from app.extensions import limiter
from app.log import LOG
@ -28,7 +28,6 @@ from app.utils import convert_to_id
@api_bp.route("/v2/alias/custom/new", methods=["POST"])
@limiter.limit(ALIAS_LIMIT)
@require_api_auth
@parallel_limiter.lock(name="alias_creation")
def new_custom_alias_v2():
"""
Create a new custom alias
@ -66,11 +65,12 @@ def new_custom_alias_v2():
note = data.get("note")
alias_prefix = convert_to_id(alias_prefix)
# hypothesis: user will click on the button in the 600 secs
try:
alias_suffix = check_suffix_signature(signed_suffix)
if not alias_suffix:
LOG.w("Alias creation time expired for %s", user)
return jsonify(error="Alias creation time is expired, please retry"), 412
alias_suffix = signer.unsign(signed_suffix, max_age=600).decode()
except SignatureExpired:
LOG.w("Alias creation time expired for %s", user)
return jsonify(error="Alias creation time is expired, please retry"), 412
except Exception:
LOG.w("Alias suffix is tampered, user %s", user)
return jsonify(error="Tampered suffix"), 400
@ -115,7 +115,6 @@ def new_custom_alias_v2():
@api_bp.route("/v3/alias/custom/new", methods=["POST"])
@limiter.limit(ALIAS_LIMIT)
@require_api_auth
@parallel_limiter.lock(name="alias_creation")
def new_custom_alias_v3():
"""
Create a new custom alias
@ -150,7 +149,7 @@ def new_custom_alias_v3():
if not data:
return jsonify(error="request body cannot be empty"), 400
if not isinstance(data, dict):
if type(data) is not dict:
return jsonify(error="request body does not follow the required format"), 400
alias_prefix = data.get("alias_prefix", "").strip().lower().replace(" ", "")
@ -168,7 +167,7 @@ def new_custom_alias_v3():
return jsonify(error="alias prefix invalid format or too long"), 400
# check if mailbox is not tempered with
if not isinstance(mailbox_ids, list):
if type(mailbox_ids) is not list:
return jsonify(error="mailbox_ids must be an array of id"), 400
mailboxes = []
for mailbox_id in mailbox_ids:
@ -182,10 +181,10 @@ def new_custom_alias_v3():
# hypothesis: user will click on the button in the 600 secs
try:
alias_suffix = check_suffix_signature(signed_suffix)
if not alias_suffix:
LOG.w("Alias creation time expired for %s", user)
return jsonify(error="Alias creation time is expired, please retry"), 412
alias_suffix = signer.unsign(signed_suffix, max_age=600).decode()
except SignatureExpired:
LOG.w("Alias creation time expired for %s", user)
return jsonify(error="Alias creation time is expired, please retry"), 412
except Exception:
LOG.w("Alias suffix is tampered, user %s", user)
return jsonify(error="Tampered suffix"), 400

View file

@ -2,14 +2,13 @@ import tldextract
from flask import g
from flask import jsonify, request
from app import parallel_limiter
from app.alias_suffix import get_alias_suffixes
from app.api.base import api_bp, require_api_auth
from app.api.serializer import (
get_alias_info_v2,
serialize_alias_info_v2,
)
from app.config import MAX_NB_EMAIL_FREE_PLAN, ALIAS_LIMIT
from app.dashboard.views.custom_alias import get_available_suffixes
from app.db import Session
from app.errors import AliasInTrashError
from app.extensions import limiter
@ -21,7 +20,6 @@ from app.utils import convert_to_id
@api_bp.route("/alias/random/new", methods=["POST"])
@limiter.limit(ALIAS_LIMIT)
@require_api_auth
@parallel_limiter.lock(name="alias_creation")
def new_random_alias():
"""
Create a new random alias
@ -59,7 +57,7 @@ def new_random_alias():
prefix_suggestion = ext.domain
prefix_suggestion = convert_to_id(prefix_suggestion)
suffixes = get_alias_suffixes(user)
suffixes = get_available_suffixes(user)
# use the first suffix
suggested_alias = prefix_suggestion + suffixes[0].suffix
@ -107,9 +105,8 @@ def new_random_alias():
Session.commit()
if hostname and not AliasUsedOn.get_by(alias_id=alias.id, hostname=hostname):
AliasUsedOn.create(
alias_id=alias.id, hostname=hostname, user_id=alias.user_id, commit=True
)
AliasUsedOn.create(alias_id=alias.id, hostname=hostname, user_id=alias.user_id)
Session.commit()
return (
jsonify(alias=alias.email, **serialize_alias_info_v2(get_alias_info_v2(alias))),

View file

@ -60,7 +60,7 @@ def get_notifications():
)
@api_bp.route("/notifications/<int:notification_id>/read", methods=["POST"])
@api_bp.route("/notifications/<notification_id>/read", methods=["POST"])
@require_api_auth
def mark_as_read(notification_id):
"""

View file

@ -9,7 +9,7 @@ from app.models import (
)
@api_bp.route("/phone/reservations/<int:reservation_id>", methods=["GET", "POST"])
@api_bp.route("/phone/reservations/<reservation_id>", methods=["GET", "POST"])
@require_api_auth
def phone_messages(reservation_id):
"""

View file

@ -12,7 +12,6 @@ from app.models import (
SenderFormatEnum,
AliasSuffixEnum,
)
from app.proton.utils import perform_proton_account_unlink
def setting_to_dict(user: User):
@ -138,11 +137,3 @@ def get_available_domains_for_random_alias_v2():
]
return jsonify(ret)
@api_bp.route("/setting/unlink_proton_account", methods=["DELETE"])
@require_api_auth
def unlink_proton_account():
user = g.user
perform_proton_account_unlink(user)
return jsonify({"ok": True})

View file

@ -1,27 +0,0 @@
from flask import jsonify, g, request
from sqlalchemy_utils.types.arrow import arrow
from app.api.base import api_bp, require_api_auth
from app.db import Session
@api_bp.route("/sudo", methods=["PATCH"])
@require_api_auth
def enter_sudo():
"""
Enter sudo mode
Input
- password: user password to validate request to enter sudo mode
"""
user = g.user
data = request.get_json() or {}
if "password" not in data:
return jsonify(error="Invalid password"), 403
if not user.check_password(data["password"]):
return jsonify(error="Invalid password"), 403
g.api_key.sudo_mode_at = arrow.now()
Session.commit()
return jsonify(ok=True)

View file

@ -1,46 +0,0 @@
from flask import jsonify, g
from sqlalchemy_utils.types.arrow import arrow
from app.api.base import api_bp, require_api_sudo, require_api_auth
from app import config
from app.extensions import limiter
from app.log import LOG
from app.models import Job, ApiToCookieToken
@api_bp.route("/user", methods=["DELETE"])
@require_api_sudo
def delete_user():
"""
Delete the user. Requires sudo mode.
"""
# Schedule delete account job
LOG.w("schedule delete account job for %s", g.user)
Job.create(
name=config.JOB_DELETE_ACCOUNT,
payload={"user_id": g.user.id},
run_at=arrow.now(),
commit=True,
)
return jsonify(ok=True)
@api_bp.route("/user/cookie_token", methods=["GET"])
@require_api_auth
@limiter.limit("5/minute")
def get_api_session_token():
"""
Get a temporary token to exchange it for a cookie based session
Output:
200 and a temporary random token
{
token: "asdli3ldq39h9hd3",
}
"""
token = ApiToCookieToken.create(
user=g.user,
api_key_id=g.api_key.id,
commit=True,
)
return jsonify({"token": token.code})

View file

@ -1,42 +1,25 @@
import base64
import dataclasses
from io import BytesIO
from typing import Optional
from flask import jsonify, g, request, make_response
from flask_login import logout_user
from app import s3, config
from app import s3
from app.api.base import api_bp, require_api_auth
from app.config import SESSION_COOKIE_NAME
from app.dashboard.views.index import get_stats
from app.db import Session
from app.models import ApiKey, File, PartnerUser, User
from app.proton.utils import get_proton_partner
from app.session import logout_session
from app.models import ApiKey, File, User
from app.utils import random_string
def get_connected_proton_address(user: User) -> Optional[str]:
proton_partner = get_proton_partner()
partner_user = PartnerUser.get_by(user_id=user.id, partner_id=proton_partner.id)
if partner_user is None:
return None
return partner_user.partner_email
def user_to_dict(user: User) -> dict:
ret = {
"name": user.name or "",
"is_premium": user.is_premium(),
"email": user.email,
"in_trial": user.in_trial(),
"max_alias_free_plan": user.max_alias_for_free_account(),
"connected_proton_address": None,
}
if config.CONNECT_WITH_PROTON:
ret["connected_proton_address"] = get_connected_proton_address(user)
if user.profile_picture_id:
ret["profile_picture_url"] = user.profile_picture.get_url()
else:
@ -50,14 +33,6 @@ def user_to_dict(user: User) -> dict:
def user_info():
"""
Return user info given the api-key
Output as json
- name
- is_premium
- email
- in_trial
- max_alias_free
- is_connected_with_proton
"""
user = g.user
@ -71,6 +46,7 @@ def update_user_info():
Input
- profile_picture (optional): base64 of the profile picture. Set to null to remove the profile picture
- name (optional)
"""
user = g.user
data = request.get_json() or {}
@ -133,27 +109,8 @@ def logout():
Output:
- 200
"""
logout_session()
logout_user()
response = make_response(jsonify(msg="User is logged out"), 200)
response.delete_cookie(SESSION_COOKIE_NAME)
return response
@api_bp.route("/stats")
@require_api_auth
def user_stats():
"""
Return stats
Output as json
- nb_alias
- nb_forward
- nb_reply
- nb_block
"""
user = g.user
stats = get_stats(user)
return jsonify(dataclasses.asdict(stats))

View file

@ -15,25 +15,4 @@ from .views import (
fido,
social,
recovery,
api_to_cookie,
)
__all__ = [
"login",
"logout",
"register",
"activate",
"resend_activation",
"reset_password",
"forgot_password",
"github",
"google",
"facebook",
"proton",
"change_email",
"mfa",
"fido",
"social",
"recovery",
"api_to_cookie",
]

View file

@ -1,30 +0,0 @@
import arrow
from flask import redirect, url_for, request, flash
from flask_login import login_user
from app.auth.base import auth_bp
from app.models import ApiToCookieToken
from app.utils import sanitize_next_url
@auth_bp.route("/api_to_cookie", methods=["GET"])
def api_to_cookie():
code = request.args.get("token")
if not code:
flash("Missing token", "error")
return redirect(url_for("auth.login"))
token = ApiToCookieToken.get_by(code=code)
if not token or token.created_at < arrow.now().shift(minutes=-5):
flash("Missing token", "error")
return redirect(url_for("auth.login"))
user = token.user
ApiToCookieToken.delete(token.id, commit=True)
login_user(user)
next_url = sanitize_next_url(request.args.get("next"))
if next_url:
return redirect(next_url)
else:
return redirect(url_for("dashboard.index"))

View file

@ -3,7 +3,7 @@ from flask_login import login_user
from app.auth.base import auth_bp
from app.db import Session
from app.models import EmailChange, ResetPasswordCode
from app.models import EmailChange
@auth_bp.route("/change_email", methods=["GET", "POST"])
@ -25,7 +25,6 @@ def change_email():
user.email = email_change.new_email
EmailChange.delete(email_change.id)
ResetPasswordCode.filter_by(user_id=user.id).delete()
Session.commit()
flash("Your new email has been updated", "success")

View file

@ -1,6 +1,5 @@
import json
import secrets
from time import time
import webauthn
from flask import (
@ -62,7 +61,7 @@ def fido():
browser = MfaBrowser.get_by(token=request.cookies.get("mfa"))
if browser and not browser.is_expired() and browser.user_id == user.id:
login_user(user)
flash("Welcome back!", "success")
flash(f"Welcome back!", "success")
# Redirect user to correct page
return redirect(next_url or url_for("dashboard.index"))
else:
@ -108,9 +107,8 @@ def fido():
Session.commit()
del session[MFA_USER_ID]
session["sudo_time"] = int(time())
login_user(user)
flash("Welcome back!", "success")
flash(f"Welcome back!", "success")
# Redirect user to correct page
response = make_response(redirect(next_url or url_for("dashboard.index")))

View file

@ -1,4 +1,4 @@
from flask import request, render_template, flash, g
from flask import request, render_template, redirect, url_for, flash, g
from flask_wtf import FlaskForm
from wtforms import StringField, validators
@ -7,7 +7,7 @@ from app.dashboard.views.setting import send_reset_password_email
from app.extensions import limiter
from app.log import LOG
from app.models import User
from app.utils import sanitize_email, canonicalize_email
from app.utils import sanitize_email
class ForgotPasswordForm(FlaskForm):
@ -16,7 +16,7 @@ class ForgotPasswordForm(FlaskForm):
@auth_bp.route("/forgot_password", methods=["GET", "POST"])
@limiter.limit(
"10/hour", deduct_when=lambda r: hasattr(g, "deduct_limit") and g.deduct_limit
"10/minute", deduct_when=lambda r: hasattr(g, "deduct_limit") and g.deduct_limit
)
def forgot_password():
form = ForgotPasswordForm(request.form)
@ -25,17 +25,16 @@ def forgot_password():
# Trigger rate limiter
g.deduct_limit = True
email = sanitize_email(form.email.data)
flash(
"If your email is correct, you are going to receive an email to reset your password",
"success",
)
email = sanitize_email(form.email.data)
canonical_email = canonicalize_email(email)
user = User.get_by(email=email) or User.get_by(email=canonical_email)
user = User.get_by(email=email)
if user:
LOG.d("Send forgot password email to %s", user)
send_reset_password_email(user)
return redirect(url_for("auth.forgot_password"))
return render_template("auth/forgot_password.html", form=form)

View file

@ -10,7 +10,7 @@ from app.events.auth_event import LoginEvent
from app.extensions import limiter
from app.log import LOG
from app.models import User
from app.utils import sanitize_email, sanitize_next_url, canonicalize_email
from app.utils import sanitize_email, sanitize_next_url
class LoginForm(FlaskForm):
@ -38,9 +38,7 @@ def login():
show_resend_activation = False
if form.validate_on_submit():
email = sanitize_email(form.email.data)
canonical_email = canonicalize_email(email)
user = User.get_by(email=email) or User.get_by(email=canonical_email)
user = User.filter_by(email=sanitize_email(form.email.data)).first()
if not user or not user.check_password(form.password.data):
# Trigger rate limiter
@ -54,12 +52,6 @@ def login():
"error",
)
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:
show_resend_activation = True
flash(

View file

@ -1,4 +1,3 @@
from time import time
from typing import Optional
from flask import session, redirect, url_for, request
@ -9,40 +8,37 @@ from app.log import LOG
from app.models import Referral
def after_login(user, next_url, login_from_proton: bool = False):
def after_login(user, next_url):
"""
Redirect to the correct page after login.
If the user is logged in with Proton, do not look at fido nor otp
If user enables MFA: redirect user to MFA page
Otherwise redirect to dashboard page if no next_url
"""
if not login_from_proton:
if user.fido_enabled():
# Use the same session for FIDO so that we can easily
# switch between these two 2FA option
session[MFA_USER_ID] = user.id
if next_url:
return redirect(url_for("auth.fido", next=next_url))
else:
return redirect(url_for("auth.fido"))
elif user.enable_otp:
session[MFA_USER_ID] = user.id
if next_url:
return redirect(url_for("auth.mfa", next=next_url))
else:
return redirect(url_for("auth.mfa"))
LOG.d("log user %s in", user)
login_user(user)
session["sudo_time"] = int(time())
# User comes to login page from another page
if next_url:
LOG.d("redirect user to %s", next_url)
return redirect(next_url)
if user.fido_enabled():
# Use the same session for FIDO so that we can easily
# switch between these two 2FA option
session[MFA_USER_ID] = user.id
if next_url:
return redirect(url_for("auth.fido", next=next_url))
else:
return redirect(url_for("auth.fido"))
elif user.enable_otp:
session[MFA_USER_ID] = user.id
if next_url:
return redirect(url_for("auth.mfa", next=next_url))
else:
return redirect(url_for("auth.mfa"))
else:
LOG.d("redirect user to dashboard")
return redirect(url_for("dashboard.index"))
LOG.d("log user %s in", user)
login_user(user)
# User comes to login page from another page
if next_url:
LOG.d("redirect user to %s", next_url)
return redirect(next_url)
else:
LOG.d("redirect user to dashboard")
return redirect(url_for("dashboard.index"))
# name of the cookie that stores the referral code

View file

@ -1,13 +1,13 @@
from flask import redirect, url_for, flash, make_response
from flask_login import logout_user
from app.auth.base import auth_bp
from app.config import SESSION_COOKIE_NAME
from app.session import logout_session
@auth_bp.route("/logout")
def logout():
logout_session()
logout_user()
flash("You are logged out", "success")
response = make_response(redirect(url_for("auth.login")))
response.delete_cookie(SESSION_COOKIE_NAME)

View file

@ -55,7 +55,7 @@ def mfa():
browser = MfaBrowser.get_by(token=request.cookies.get("mfa"))
if browser and not browser.is_expired() and browser.user_id == user.id:
login_user(user)
flash("Welcome back!", "success")
flash(f"Welcome back!", "success")
# Redirect user to correct page
return redirect(next_url or url_for("dashboard.index"))
else:
@ -67,13 +67,13 @@ def mfa():
token = otp_token_form.token.data.replace(" ", "")
if totp.verify(token, valid_window=2) and user.last_otp != token:
if totp.verify(token) and user.last_otp != token:
del session[MFA_USER_ID]
user.last_otp = token
Session.commit()
login_user(user)
flash("Welcome back!", "success")
flash(f"Welcome back!", "success")
# Redirect user to correct page
response = make_response(redirect(next_url or url_for("dashboard.index")))

View file

@ -3,7 +3,6 @@ from flask import request, session, redirect, flash, url_for
from flask_limiter.util import get_remote_address
from flask_login import current_user
from requests_oauthlib import OAuth2Session
from typing import Optional
from app.auth.base import auth_bp
from app.auth.views.login_utils import after_login
@ -11,20 +10,16 @@ from app.config import (
PROTON_BASE_URL,
PROTON_CLIENT_ID,
PROTON_CLIENT_SECRET,
PROTON_EXTRA_HEADER_NAME,
PROTON_EXTRA_HEADER_VALUE,
PROTON_VALIDATE_CERTS,
URL,
)
from app.log import LOG
from app.models import ApiKey, User
from app.proton.proton_client import HttpProtonClient, convert_access_token
from app.proton.proton_callback_handler import (
ProtonCallbackHandler,
Action,
get_proton_partner,
)
from app.proton.utils import get_proton_partner
from app.utils import sanitize_next_url, sanitize_scheme
from app.utils import sanitize_next_url
_authorization_base_url = PROTON_BASE_URL + "/oauth/authorize"
_token_url = PROTON_BASE_URL + "/oauth/token"
@ -33,35 +28,19 @@ _token_url = PROTON_BASE_URL + "/oauth/token"
# when served behind nginx, the redirect_uri is localhost... and not the real url
_redirect_uri = URL + "/auth/proton/callback"
SESSION_ACTION_KEY = "oauth_action"
SESSION_STATE_KEY = "oauth_state"
DEFAULT_SCHEME = "auth.simplelogin"
def get_api_key_for_user(user: User) -> str:
ak = ApiKey.create(
user_id=user.id,
name="Created via Login with Proton on mobile app",
commit=True,
)
return ak.code
def extract_action() -> Optional[Action]:
def extract_action() -> Action:
action = request.args.get("action")
if action is not None:
if action == "link":
return Action.Link
elif action == "login":
return Action.Login
else:
LOG.w(f"Unknown action received: {action}")
return None
raise Exception(f"Unknown action: {action}")
return Action.Login
def get_action_from_state() -> Action:
oauth_action = session[SESSION_ACTION_KEY]
oauth_action = session["oauth_action"]
if oauth_action == Action.Login.value:
return Action.Login
elif oauth_action == Action.Link.value:
@ -74,44 +53,20 @@ def proton_login():
if PROTON_CLIENT_ID is None or PROTON_CLIENT_SECRET is None:
return redirect(url_for("auth.login"))
action = extract_action()
if action is None:
return redirect(url_for("auth.login"))
if action == Action.Link and not current_user.is_authenticated:
return redirect(url_for("auth.login"))
next_url = sanitize_next_url(request.args.get("next"))
if next_url:
session["oauth_next"] = next_url
elif "oauth_next" in session:
del session["oauth_next"]
scheme = sanitize_scheme(request.args.get("scheme"))
if scheme:
session["oauth_scheme"] = scheme
elif "oauth_scheme" in session:
del session["oauth_scheme"]
mode = request.args.get("mode", "session")
if mode == "apikey":
session["oauth_mode"] = "apikey"
else:
session["oauth_mode"] = "session"
proton = OAuth2Session(PROTON_CLIENT_ID, redirect_uri=_redirect_uri)
authorization_url, state = proton.authorization_url(_authorization_base_url)
# State is used to prevent CSRF, keep this for later.
session[SESSION_STATE_KEY] = state
session[SESSION_ACTION_KEY] = action.value
session["oauth_state"] = state
session["oauth_action"] = extract_action().value
return redirect(authorization_url)
@auth_bp.route("/proton/callback")
def proton_callback():
if SESSION_STATE_KEY not in session or SESSION_STATE_KEY not in session:
flash("Invalid state, please retry", "error")
return redirect(url_for("auth.login"))
if PROTON_CLIENT_ID is None or PROTON_CLIENT_SECRET is None:
return redirect(url_for("auth.login"))
@ -122,7 +77,7 @@ def proton_callback():
proton = OAuth2Session(
PROTON_CLIENT_ID,
state=session[SESSION_STATE_KEY],
state=session["oauth_state"],
redirect_uri=_redirect_uri,
)
@ -134,26 +89,14 @@ def proton_callback():
return response
proton.register_compliance_hook("access_token_response", check_status_code)
headers = None
if PROTON_EXTRA_HEADER_NAME and PROTON_EXTRA_HEADER_VALUE:
headers = {PROTON_EXTRA_HEADER_NAME: PROTON_EXTRA_HEADER_VALUE}
try:
token = proton.fetch_token(
_token_url,
client_secret=PROTON_CLIENT_SECRET,
authorization_response=request.url,
verify=PROTON_VALIDATE_CERTS,
method="GET",
include_client_id=True,
headers=headers,
)
except Exception as e:
LOG.warning(f"Error fetching Proton token: {e}")
flash("There was an error in the login process", "error")
return redirect(url_for("auth.login"))
token = proton.fetch_token(
_token_url,
client_secret=PROTON_CLIENT_SECRET,
authorization_response=request.url,
verify=PROTON_VALIDATE_CERTS,
method="GET",
include_client_id=True,
)
credentials = convert_access_token(token["access_token"])
action = get_action_from_state()
@ -163,7 +106,6 @@ def proton_callback():
handler = ProtonCallbackHandler(proton_client)
proton_partner = get_proton_partner()
next_url = session.get("oauth_next")
if action == Action.Login:
res = handler.handle_login(proton_partner)
elif action == Action.Link:
@ -174,17 +116,11 @@ def proton_callback():
if res.flash_message is not None:
flash(res.flash_message, res.flash_category)
oauth_scheme = session.get("oauth_scheme")
if session.get("oauth_mode", "session") == "apikey":
apikey = get_api_key_for_user(res.user)
scheme = oauth_scheme or DEFAULT_SCHEME
return redirect(f"{scheme}:///login?apikey={apikey}")
if res.redirect_to_login:
return redirect(url_for("auth.login"))
if next_url and next_url[0] == "/" and oauth_scheme:
next_url = f"{oauth_scheme}://{next_url}"
if res.redirect:
return after_login(res.user, res.redirect)
redirect_url = next_url or res.redirect
return after_login(res.user, redirect_url, login_from_proton=True)
next_url = session.get("oauth_next")
return after_login(res.user, next_url)

View file

@ -42,7 +42,7 @@ def recovery_route():
if recovery_form.validate_on_submit():
code = recovery_form.code.data
recovery_code = RecoveryCode.find_by_user_code(user, code)
recovery_code = RecoveryCode.get_by(user_id=user.id, code=code)
if recovery_code:
if recovery_code.used:
@ -53,7 +53,7 @@ def recovery_route():
del session[MFA_USER_ID]
login_user(user)
flash("Welcome back!", "success")
flash(f"Welcome back!", "success")
recovery_code.used = True
recovery_code.used_at = arrow.now()

View file

@ -16,8 +16,8 @@ from app.email_utils import (
)
from app.events.auth_event import RegisterEvent
from app.log import LOG
from app.models import User, ActivationCode, DailyMetric
from app.utils import random_string, encode_url, sanitize_email, canonicalize_email
from app.models import User, ActivationCode
from app.utils import random_string, encode_url, sanitize_email
class RegisterForm(FlaskForm):
@ -70,22 +70,19 @@ def register():
HCAPTCHA_SITEKEY=HCAPTCHA_SITEKEY,
)
email = canonicalize_email(form.email.data)
email = sanitize_email(form.email.data)
if not email_can_be_used_as_mailbox(email):
flash("You cannot use this email address as your personal inbox.", "error")
RegisterEvent(RegisterEvent.ActionType.email_in_use).send()
else:
sanitized_email = sanitize_email(form.email.data)
if personal_email_already_used(email) or personal_email_already_used(
sanitized_email
):
if personal_email_already_used(email):
flash(f"Email {email} already used", "error")
RegisterEvent(RegisterEvent.ActionType.email_in_use).send()
else:
LOG.d("create user %s", email)
user = User.create(
email=email,
name=form.email.data,
name="",
password=form.password.data,
referral=get_referral(),
)
@ -94,8 +91,6 @@ def register():
try:
send_activation_email(user, next_url)
RegisterEvent(RegisterEvent.ActionType.success).send()
DailyMetric.get_or_create_today_metric().nb_new_web_non_proton_user += 1
Session.commit()
except Exception:
flash("Invalid email, are you sure the email is correct?", "error")
RegisterEvent(RegisterEvent.ActionType.invalid_email).send()

View file

@ -4,10 +4,9 @@ from wtforms import StringField, validators
from app.auth.base import auth_bp
from app.auth.views.register import send_activation_email
from app.extensions import limiter
from app.log import LOG
from app.models import User
from app.utils import sanitize_email, canonicalize_email
from app.utils import sanitize_email
class ResendActivationForm(FlaskForm):
@ -15,14 +14,11 @@ class ResendActivationForm(FlaskForm):
@auth_bp.route("/resend_activation", methods=["GET", "POST"])
@limiter.limit("10/hour")
def resend_activation():
form = ResendActivationForm(request.form)
if form.validate_on_submit():
email = sanitize_email(form.email.data)
canonical_email = canonicalize_email(email)
user = User.get_by(email=email) or User.get_by(email=canonical_email)
user = User.filter_by(email=sanitize_email(form.email.data)).first()
if not user:
flash("There is no such email", "warning")

View file

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

View file

@ -8,6 +8,7 @@ from urllib.parse import urlparse
from dotenv import load_dotenv
ROOT_DIR = os.path.abspath(os.path.dirname(os.path.dirname(__file__)))
@ -96,8 +97,6 @@ except Exception:
print("MAX_NB_EMAIL_FREE_PLAN is not set, use 5 as default value")
MAX_NB_EMAIL_FREE_PLAN = 5
MAX_NB_EMAIL_OLD_FREE_PLAN = int(os.environ.get("MAX_NB_EMAIL_OLD_FREE_PLAN", 15))
# maximum number of directory a premium user can create
MAX_NB_DIRECTORY = 50
MAX_NB_SUBDOMAIN = 5
@ -111,16 +110,13 @@ POSTFIX_SERVER = os.environ.get("POSTFIX_SERVER", "240.0.0.1")
DISABLE_REGISTRATION = "DISABLE_REGISTRATION" in os.environ
# allow using a different postfix port, useful when developing locally
POSTFIX_PORT = 25
if "POSTFIX_PORT" in os.environ:
POSTFIX_PORT = int(os.environ["POSTFIX_PORT"])
# Use port 587 instead of 25 when sending emails through Postfix
# Useful when calling Postfix from an external network
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)
# ["domain1.com", "domain2.com"]
OTHER_ALIAS_DOMAINS = sl_getenv("OTHER_ALIAS_DOMAINS", list)
@ -163,7 +159,6 @@ if "DKIM_PRIVATE_KEY_PATH" in os.environ:
# Database
DB_URI = os.environ["DB_URI"]
DB_CONN_NAME = os.environ.get("DB_CONN_NAME", "webapp")
# Flask secret
FLASK_SECRET = os.environ["FLASK_SECRET"]
@ -172,7 +167,6 @@ if not FLASK_SECRET:
SESSION_COOKIE_NAME = "slapp"
MAILBOX_SECRET = FLASK_SECRET + "mailbox"
CUSTOM_ALIAS_SECRET = FLASK_SECRET + "custom_alias"
UNSUBSCRIBE_SECRET = FLASK_SECRET + "unsub"
# AWS
AWS_REGION = os.environ.get("AWS_REGION") or "eu-west-3"
@ -250,8 +244,6 @@ PROTON_BASE_URL = os.environ.get(
)
PROTON_VALIDATE_CERTS = "PROTON_VALIDATE_CERTS" in os.environ
CONNECT_WITH_PROTON = "CONNECT_WITH_PROTON" in os.environ
PROTON_EXTRA_HEADER_NAME = os.environ.get("PROTON_EXTRA_HEADER_NAME")
PROTON_EXTRA_HEADER_VALUE = os.environ.get("PROTON_EXTRA_HEADER_VALUE")
# in seconds
AVATAR_URL_EXPIRATION = 3600 * 24 * 7 # 1h*24h/d*7d=1week
@ -272,7 +264,6 @@ JOB_DELETE_ACCOUNT = "delete-account"
JOB_DELETE_MAILBOX = "delete-mailbox"
JOB_DELETE_DOMAIN = "delete-domain"
JOB_SEND_USER_REPORT = "send-user-report"
JOB_SEND_PROTON_WELCOME_1 = "proton-welcome-1"
# for pagination
PAGE_LIMIT = 20
@ -356,9 +347,6 @@ ALERT_COMPLAINT_TRANSACTIONAL_PHASE = "alert_complaint_transactional_phase"
ALERT_QUARANTINE_DMARC = "alert_quarantine_dmarc"
ALERT_DUAL_SUBSCRIPTION_WITH_PARTNER = "alert_dual_sub_with_partner"
ALERT_WARN_MULTIPLE_SUBSCRIPTIONS = "alert_multiple_subscription"
# <<<<< END ALERT EMAIL >>>>
# Disable onboarding emails
@ -493,49 +481,4 @@ PARTNER_API_TOKEN_SECRET = os.environ.get("PARTNER_API_TOKEN_SECRET") or (
FLASK_SECRET + "partnerapitoken"
)
JOB_MAX_ATTEMPTS = 5
JOB_TAKEN_RETRY_WAIT_MINS = 30
# MEM_STORE
MEM_STORE_URI = os.environ.get("MEM_STORE_URI", None)
# Recovery codes hash salt
RECOVERY_CODE_HMAC_SECRET = os.environ.get("RECOVERY_CODE_HMAC_SECRET") or (
FLASK_SECRET + "generatearandomtoken"
)
if not RECOVERY_CODE_HMAC_SECRET or len(RECOVERY_CODE_HMAC_SECRET) < 16:
raise RuntimeError(
"Please define RECOVERY_CODE_HMAC_SECRET in your configuration with a random string at least 16 chars long"
)
# the minimum rspamd spam score above which emails that fail DMARC should be quarantined
if "MIN_RSPAMD_SCORE_FOR_FAILED_DMARC" in os.environ:
MIN_RSPAMD_SCORE_FOR_FAILED_DMARC = float(
os.environ["MIN_RSPAMD_SCORE_FOR_FAILED_DMARC"]
)
else:
MIN_RSPAMD_SCORE_FOR_FAILED_DMARC = None
# run over all reverse alias for an alias and replace them with sender address
ENABLE_ALL_REVERSE_ALIAS_REPLACEMENT = (
"ENABLE_ALL_REVERSE_ALIAS_REPLACEMENT" in os.environ
)
if ENABLE_ALL_REVERSE_ALIAS_REPLACEMENT:
# max number of reverse alias that can be replaced
MAX_NB_REVERSE_ALIAS_REPLACEMENT = int(
os.environ["MAX_NB_REVERSE_ALIAS_REPLACEMENT"]
)
# Only used for tests
SKIP_MX_LOOKUP_ON_CHECK = False
DISABLE_RATE_LIMIT = "DISABLE_RATE_LIMIT" in os.environ
SUBSCRIPTION_CHANGE_WEBHOOK = os.environ.get("SUBSCRIPTION_CHANGE_WEBHOOK", None)
MAX_API_KEYS = int(os.environ.get("MAX_API_KEYS", 30))
UPCLOUD_USERNAME = os.environ.get("UPCLOUD_USERNAME", None)
UPCLOUD_PASSWORD = os.environ.get("UPCLOUD_PASSWORD", None)
UPCLOUD_DB_ID = os.environ.get("UPCLOUD_DB_ID", None)
SQLALCHEMY_POOL_PRE_PING = "SQLALCHEMY_POOL_PRE_PING" in os.environ

View file

@ -1,37 +0,0 @@
from app.db import Session
from app.dns_utils import get_cname_record
from app.models import CustomDomain
class CustomDomainValidation:
def __init__(self, dkim_domain: str):
self.dkim_domain = dkim_domain
self._dkim_records = {
(f"{key}._domainkey", f"{key}._domainkey.{self.dkim_domain}")
for key in ("dkim", "dkim02", "dkim03")
}
def get_dkim_records(self) -> {str: str}:
"""
Get a list of dkim records to set up. It will be
"""
return self._dkim_records
def validate_dkim_records(self, custom_domain: CustomDomain) -> dict[str, str]:
"""
Check if dkim records are properly set for this custom domain.
Returns empty list if all records are ok. Other-wise return the records that aren't properly configured
"""
invalid_records = {}
for prefix, expected_record in self.get_dkim_records():
custom_record = f"{prefix}.{custom_domain.domain}"
dkim_record = get_cname_record(custom_record)
if dkim_record != expected_record:
invalid_records[custom_record] = dkim_record or "empty"
# HACK: If dkim is enabled, don't disable it to give users time to update their CNAMES
if custom_domain.dkim_verified:
return invalid_records
custom_domain.dkim_verified = len(invalid_records) == 0
Session.commit()
return invalid_records

View file

@ -6,7 +6,6 @@ from .views import (
subdomain,
billing,
alias_log,
alias_export,
unsubscribe,
api_key,
custom_domain,
@ -24,6 +23,7 @@ from .views import (
mailbox_detail,
refused_email,
referral,
recovery_code,
contact_detail,
setup_done,
batch_import,
@ -33,39 +33,3 @@ from .views import (
notification,
support,
)
__all__ = [
"index",
"pricing",
"setting",
"custom_alias",
"subdomain",
"billing",
"alias_log",
"alias_export",
"unsubscribe",
"api_key",
"custom_domain",
"alias_contact_manager",
"enter_sudo",
"mfa_setup",
"mfa_cancel",
"fido_setup",
"coupon",
"fido_manage",
"domain_detail",
"lifetime_licence",
"directory",
"mailbox",
"mailbox_detail",
"refused_email",
"referral",
"contact_detail",
"setup_done",
"batch_import",
"alias_transfer",
"app",
"delete_account",
"notification",
"support",
]

View file

@ -9,14 +9,14 @@ from sqlalchemy import and_, func, case
from wtforms import StringField, validators, ValidationError
# Need to import directly from config to allow modification from the tests
from app import config, parallel_limiter
from app import config
from app.dashboard.base import dashboard_bp
from app.db import Session
from app.email_utils import (
is_valid_email,
generate_reply_email,
parse_full_address,
)
from app.email_validation import is_valid_email
from app.errors import (
CannotCreateContactForReverseAlias,
ErrContactErrorUpgradeNeeded,
@ -25,7 +25,7 @@ from app.errors import (
)
from app.log import LOG
from app.models import Alias, Contact, EmailLog, User
from app.utils import sanitize_email, CSRFValidationForm
from app.utils import sanitize_email
def email_validator():
@ -90,7 +90,7 @@ def create_contact(user: User, alias: Alias, contact_address: str) -> Contact:
alias_id=alias.id,
website_email=contact_email,
name=contact_name,
reply_email=generate_reply_email(contact_email, alias),
reply_email=generate_reply_email(contact_email, user),
)
LOG.d(
@ -229,17 +229,12 @@ def delete_contact(alias: Alias, contact_id: int):
flash(f"Reverse-alias for {delete_contact_email} has been deleted", "success")
@dashboard_bp.route("/alias_contact_manager/<int:alias_id>/", methods=["GET", "POST"])
@dashboard_bp.route("/alias_contact_manager/<alias_id>/", methods=["GET", "POST"])
@login_required
@parallel_limiter.lock(name="contact_creation")
def alias_contact_manager(alias_id):
highlight_contact_id = None
if request.args.get("highlight_contact_id"):
try:
highlight_contact_id = int(request.args.get("highlight_contact_id"))
except ValueError:
flash("Invalid contact id", "error")
return redirect(url_for("dashboard.index"))
highlight_contact_id = int(request.args.get("highlight_contact_id"))
alias = Alias.get(alias_id)
@ -259,12 +254,8 @@ def alias_contact_manager(alias_id):
return redirect(url_for("dashboard.index"))
new_contact_form = NewContactForm()
csrf_form = CSRFValidationForm()
if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
if request.form.get("form-name") == "create":
if new_contact_form.validate():
contact_address = new_contact_form.email.data.strip()
@ -328,5 +319,4 @@ def alias_contact_manager(alias_id):
query=query,
nb_contact=nb_contact,
can_create_contacts=user_can_create_contacts(current_user),
csrf_form=csrf_form,
)

View file

@ -1,9 +0,0 @@
from app.dashboard.base import dashboard_bp
from flask_login import login_required, current_user
from app.alias_utils import alias_export_csv
@dashboard_bp.route("/alias_export", methods=["GET"])
@login_required
def alias_export_route():
return alias_export_csv(current_user)

View file

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

View file

@ -7,17 +7,76 @@ from flask import render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user
from app import config
from app.alias_utils import transfer_alias
from app.dashboard.base import dashboard_bp
from app.dashboard.views.enter_sudo import sudo_required
from app.db import Session
from app.email_utils import send_email, render
from app.extensions import limiter
from app.log import LOG
from app.models import (
Alias,
Contact,
AliasUsedOn,
AliasMailbox,
User,
ClientUser,
)
from app.models import Mailbox
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:
@ -46,12 +105,8 @@ def alias_transfer_send_route(alias_id):
return redirect(url_for("dashboard.index"))
alias_transfer_url = None
csrf_form = CSRFValidationForm()
if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
# generate a new transfer_token
if request.form.get("form-name") == "create":
transfer_token = f"{alias.id}.{secrets.token_urlsafe(32)}"
@ -78,7 +133,6 @@ def alias_transfer_send_route(alias_id):
alias_transfer_url=alias_transfer_url,
link_active=alias.transfer_token_expiration is not None
and alias.transfer_token_expiration > arrow.utcnow(),
csrf_form=csrf_form,
)
@ -154,7 +208,7 @@ def alias_transfer_receive_route():
mailboxes,
token,
)
transfer_alias(alias, current_user, mailboxes)
transfer(alias, current_user, mailboxes)
# reset transfer token
alias.transfer_token = None

View file

@ -3,47 +3,19 @@ from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import StringField, validators
from app import config
from app.dashboard.base import dashboard_bp
from app.dashboard.views.enter_sudo import sudo_required
from app.db import Session
from app.extensions import limiter
from app.models import ApiKey
from app.utils import CSRFValidationForm
class NewApiKeyForm(FlaskForm):
name = StringField("Name", validators=[validators.DataRequired()])
def clean_up_unused_or_old_api_keys(user_id: int):
total_keys = ApiKey.filter_by(user_id=user_id).count()
if total_keys <= config.MAX_API_KEYS:
return
# Remove oldest unused
for api_key in (
ApiKey.filter_by(user_id=user_id, last_used=None)
.order_by(ApiKey.created_at.asc())
.all()
):
Session.delete(api_key)
total_keys -= 1
if total_keys <= config.MAX_API_KEYS:
return
# Clean up oldest used
for api_key in (
ApiKey.filter_by(user_id=user_id).order_by(ApiKey.last_used.asc()).all()
):
Session.delete(api_key)
total_keys -= 1
if total_keys <= config.MAX_API_KEYS:
return
@dashboard_bp.route("/api_key", methods=["GET", "POST"])
@login_required
@sudo_required
@limiter.limit("10/hour")
def api_key():
api_keys = (
ApiKey.filter(ApiKey.user_id == current_user.id)
@ -51,13 +23,9 @@ def api_key():
.all()
)
csrf_form = CSRFValidationForm()
new_api_key_form = NewApiKeyForm()
if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
if request.form.get("form-name") == "delete":
api_key_id = request.form.get("api-key-id")
@ -77,7 +45,6 @@ def api_key():
elif request.form.get("form-name") == "create":
if new_api_key_form.validate():
clean_up_unused_or_old_api_keys(current_user.id)
new_api_key = ApiKey.create(
name=new_api_key_form.name.data, user_id=current_user.id
)
@ -95,8 +62,5 @@ def api_key():
return redirect(url_for("dashboard.api_key"))
return render_template(
"dashboard/api_key.html",
api_keys=api_keys,
new_api_key_form=new_api_key_form,
csrf_form=csrf_form,
"dashboard/api_key.html", api_keys=api_keys, new_api_key_form=new_api_key_form
)

View file

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

View file

@ -8,7 +8,7 @@ from app.dashboard.base import dashboard_bp
from app.db import Session
from app.log import LOG
from app.models import File, BatchImport, Job
from app.utils import random_string, CSRFValidationForm
from app.utils import random_string
@dashboard_bp.route("/batch_import", methods=["GET", "POST"])
@ -25,27 +25,9 @@ def batch_import_route():
)
return redirect(url_for("dashboard.index"))
batch_imports = BatchImport.filter_by(
user_id=current_user.id, processed=False
).all()
csrf_form = CSRFValidationForm()
batch_imports = BatchImport.filter_by(user_id=current_user.id).all()
if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
if len(batch_imports) > 10:
flash(
"You have too many imports already. Wait until some get cleaned up",
"error",
)
return render_template(
"dashboard/batch_import.html",
batch_imports=batch_imports,
csrf_form=csrf_form,
)
alias_file = request.files["alias-file"]
file_path = random_string(20) + ".csv"
@ -73,6 +55,4 @@ def batch_import_route():
return redirect(url_for("dashboard.batch_import_route"))
return render_template(
"dashboard/batch_import.html", batch_imports=batch_imports, csrf_form=csrf_form
)
return render_template("dashboard/batch_import.html", batch_imports=batch_imports)

View file

@ -13,7 +13,7 @@ from app.paddle_utils import cancel_subscription, change_plan
@login_required
def billing():
# sanity check: make sure this page is only for user who has paddle subscription
sub: Subscription = current_user.get_paddle_subscription()
sub: Subscription = current_user.get_subscription()
if not sub:
flash("You don't have any active subscription", "warning")

View file

@ -1,7 +1,5 @@
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import StringField, validators
from app.dashboard.base import dashboard_bp
from app.db import Session
@ -9,14 +7,6 @@ from app.models import Contact
from app.pgp_utils import PGPException, load_public_key_and_check
class PGPContactForm(FlaskForm):
action = StringField(
"action",
validators=[validators.DataRequired(), validators.AnyOf(("save", "remove"))],
)
pgp = StringField("pgp", validators=[validators.Optional()])
@dashboard_bp.route("/contact/<int:contact_id>/", methods=["GET", "POST"])
@login_required
def contact_detail_route(contact_id):
@ -26,41 +16,33 @@ def contact_detail_route(contact_id):
return redirect(url_for("dashboard.index"))
alias = contact.alias
pgp_form = PGPContactForm()
if request.method == "POST":
if request.form.get("form-name") == "pgp":
if not pgp_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
if pgp_form.action.data == "save":
if request.form.get("action") == "save":
if not current_user.is_premium():
flash("Only premium plan can add PGP Key", "warning")
return redirect(
url_for("dashboard.contact_detail_route", contact_id=contact_id)
)
if not pgp_form.pgp.data:
flash("Invalid pgp key")
contact.pgp_public_key = request.form.get("pgp")
try:
contact.pgp_finger_print = load_public_key_and_check(
contact.pgp_public_key
)
except PGPException:
flash("Cannot add the public key, please verify it", "error")
else:
contact.pgp_public_key = pgp_form.pgp.data
try:
contact.pgp_finger_print = load_public_key_and_check(
contact.pgp_public_key
)
except PGPException:
flash("Cannot add the public key, please verify it", "error")
else:
Session.commit()
flash(
f"PGP public key for {contact.email} is saved successfully",
"success",
)
return redirect(
url_for(
"dashboard.contact_detail_route", contact_id=contact_id
)
)
elif pgp_form.action.data == "remove":
Session.commit()
flash(
f"PGP public key for {contact.email} is saved successfully",
"success",
)
return redirect(
url_for("dashboard.contact_detail_route", contact_id=contact_id)
)
elif request.form.get("action") == "remove":
# Free user can decide to remove contact PGP key
contact.pgp_public_key = None
contact.pgp_finger_print = None
@ -71,5 +53,5 @@ def contact_detail_route(contact_id):
)
return render_template(
"dashboard/contact_detail.html", contact=contact, alias=alias, pgp_form=pgp_form
"dashboard/contact_detail.html", contact=contact, alias=alias
)

View file

@ -4,7 +4,6 @@ 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 PADDLE_VENDOR_ID, PADDLE_COUPON_ID
from app.dashboard.base import dashboard_bp
from app.db import Session
@ -25,7 +24,6 @@ class CouponForm(FlaskForm):
@dashboard_bp.route("/coupon", methods=["GET", "POST"])
@login_required
@parallel_limiter.lock()
def coupon_route():
coupon_form = CouponForm()
@ -42,7 +40,7 @@ def coupon_route():
if current_user.lifetime:
can_use_coupon = False
sub: Subscription = current_user.get_paddle_subscription()
sub: Subscription = current_user.get_subscription()
if sub:
can_use_coupon = False
@ -68,14 +66,9 @@ def coupon_route():
)
return redirect(request.url)
updated = (
Session.query(Coupon)
.filter_by(code=code, used=False)
.update({"used_by_user_id": current_user.id, "used": True})
)
if updated != 1:
flash("Coupon is not valid", "error")
return redirect(request.url)
coupon.used_by_user_id = current_user.id
coupon.used = True
Session.commit()
manual_sub: ManualSubscription = ManualSubscription.get_by(
user_id=current_user.id
@ -100,7 +93,7 @@ def coupon_route():
commit=True,
)
flash(
"Your account has been upgraded to Premium, thanks for your support!",
f"Your account has been upgraded to Premium, thanks for your support!",
"success",
)

View file

@ -1,16 +1,16 @@
import json
from dataclasses import dataclass, asdict
from email_validator import validate_email, EmailNotValidError
from flask import render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user
from itsdangerous import TimestampSigner, SignatureExpired
from sqlalchemy.exc import IntegrityError
from app import parallel_limiter
from app.alias_suffix import (
get_alias_suffixes,
check_suffix_signature,
verify_prefix_suffix,
)
from app.alias_utils import check_alias_prefix
from app.config import (
DISABLE_ALIAS_SUFFIX,
CUSTOM_ALIAS_SECRET,
ALIAS_LIMIT,
)
from app.dashboard.base import dashboard_bp
@ -19,17 +19,180 @@ from app.extensions import limiter
from app.log import LOG
from app.models import (
Alias,
CustomDomain,
DeletedAlias,
Mailbox,
User,
AliasMailbox,
DomainDeletedAlias,
)
signer = TimestampSigner(CUSTOM_ALIAS_SECRET)
@dataclass
class SuffixInfo:
"""
Alias suffix info
WARNING: should use AliasSuffix instead
"""
# whether this is a custom domain
is_custom: bool
suffix: str
signed_suffix: str
# whether this is a premium SL domain. Not apply to custom domain
is_premium: bool
def get_available_suffixes(user: User) -> [SuffixInfo]:
"""
WARNING: should use get_alias_suffixes() instead
"""
user_custom_domains = user.verified_custom_domains()
suffixes: [SuffixInfo] = []
# put custom domain first
# for each user domain, generate both the domain and a random suffix version
for custom_domain in user_custom_domains:
if custom_domain.random_prefix_generation:
suffix = "." + user.get_random_alias_suffix() + "@" + custom_domain.domain
suffix_info = SuffixInfo(True, suffix, signer.sign(suffix).decode(), False)
if user.default_alias_custom_domain_id == custom_domain.id:
suffixes.insert(0, suffix_info)
else:
suffixes.append(suffix_info)
suffix = "@" + custom_domain.domain
suffix_info = SuffixInfo(True, suffix, signer.sign(suffix).decode(), False)
# put the default domain to top
# only if random_prefix_generation isn't enabled
if (
user.default_alias_custom_domain_id == custom_domain.id
and not custom_domain.random_prefix_generation
):
suffixes.insert(0, suffix_info)
else:
suffixes.append(suffix_info)
# then SimpleLogin domain
for sl_domain in user.get_sl_domains():
suffix = (
("" if DISABLE_ALIAS_SUFFIX else "." + user.get_random_alias_suffix())
+ "@"
+ sl_domain.domain
)
suffix_info = SuffixInfo(
False, suffix, signer.sign(suffix).decode(), sl_domain.premium_only
)
# put the default domain to top
if user.default_alias_public_domain_id == sl_domain.id:
suffixes.insert(0, suffix_info)
else:
suffixes.append(suffix_info)
return suffixes
@dataclass
class AliasSuffix:
# whether this is a custom domain
is_custom: bool
suffix: str
# whether this is a premium SL domain. Not apply to custom domain
is_premium: bool
# can be either Custom or SL domain
domain: str
# if custom domain, whether the custom domain has MX verified, i.e. can receive emails
mx_verified: bool = True
def serialize(self):
return json.dumps(asdict(self))
@classmethod
def deserialize(cls, data: str) -> "AliasSuffix":
return AliasSuffix(**json.loads(data))
def get_alias_suffixes(user: User) -> [AliasSuffix]:
"""
Similar to as get_available_suffixes() but also return custom domain that doesn't have MX set up.
"""
user_custom_domains = CustomDomain.filter_by(
user_id=user.id, ownership_verified=True
).all()
alias_suffixes: [AliasSuffix] = []
# put custom domain first
# for each user domain, generate both the domain and a random suffix version
for custom_domain in user_custom_domains:
if custom_domain.random_prefix_generation:
suffix = "." + user.get_random_alias_suffix() + "@" + custom_domain.domain
alias_suffix = AliasSuffix(
is_custom=True,
suffix=suffix,
is_premium=False,
domain=custom_domain.domain,
mx_verified=custom_domain.verified,
)
if user.default_alias_custom_domain_id == custom_domain.id:
alias_suffixes.insert(0, alias_suffix)
else:
alias_suffixes.append(alias_suffix)
suffix = "@" + custom_domain.domain
alias_suffix = AliasSuffix(
is_custom=True,
suffix=suffix,
is_premium=False,
domain=custom_domain.domain,
mx_verified=custom_domain.verified,
)
# put the default domain to top
# only if random_prefix_generation isn't enabled
if (
user.default_alias_custom_domain_id == custom_domain.id
and not custom_domain.random_prefix_generation
):
alias_suffixes.insert(0, alias_suffix)
else:
alias_suffixes.append(alias_suffix)
# then SimpleLogin domain
for sl_domain in user.get_sl_domains():
suffix = (
("" if DISABLE_ALIAS_SUFFIX else "." + user.get_random_alias_suffix())
+ "@"
+ sl_domain.domain
)
alias_suffix = AliasSuffix(
is_custom=False,
suffix=suffix,
is_premium=sl_domain.premium_only,
domain=sl_domain.domain,
mx_verified=True,
)
# put the default domain to top
if user.default_alias_public_domain_id == sl_domain.id:
alias_suffixes.insert(0, alias_suffix)
else:
alias_suffixes.append(alias_suffix)
return alias_suffixes
@dashboard_bp.route("/custom_alias", methods=["GET", "POST"])
@limiter.limit(ALIAS_LIMIT, methods=["POST"])
@login_required
@parallel_limiter.lock(name="alias_creation")
def custom_alias():
# check if user has not exceeded the alias quota
if not current_user.can_create_new_alias():
@ -48,6 +211,11 @@ def custom_alias():
at_least_a_premium_domain = True
break
alias_suffixes_with_signature = [
(alias_suffix, signer.sign(alias_suffix.serialize()).decode())
for alias_suffix in alias_suffixes
]
mailboxes = current_user.mailboxes()
if request.method == "POST":
@ -81,19 +249,25 @@ def custom_alias():
flash("At least one mailbox must be selected", "error")
return redirect(request.url)
# hypothesis: user will click on the button in the 600 secs
try:
suffix = check_suffix_signature(signed_alias_suffix)
if not suffix:
LOG.w("Alias creation time expired for %s", current_user)
flash("Alias creation time is expired, please retry", "warning")
return redirect(request.url)
signed_alias_suffix_decoded = signer.unsign(
signed_alias_suffix, max_age=600
).decode()
alias_suffix: AliasSuffix = AliasSuffix.deserialize(
signed_alias_suffix_decoded
)
except SignatureExpired:
LOG.w("Alias creation time expired for %s", current_user)
flash("Alias creation time is expired, please retry", "warning")
return redirect(request.url)
except Exception:
LOG.w("Alias suffix is tampered, user %s", current_user)
flash("Unknown error, refresh the page", "error")
return redirect(request.url)
if verify_prefix_suffix(current_user, alias_prefix, suffix):
full_alias = alias_prefix + suffix
if verify_prefix_suffix(current_user, alias_prefix, alias_suffix.suffix):
full_alias = alias_prefix + alias_suffix.suffix
if ".." in full_alias:
flash("Your alias can't contain 2 consecutive dots (..)", "error")
@ -120,11 +294,18 @@ def custom_alias():
email=full_alias
)
custom_domain = domain_deleted_alias.domain
flash(
f"You have deleted this alias before. You can restore it on "
f"{custom_domain.domain} 'Deleted Alias' page",
"error",
)
if domain_deleted_alias.user_id == current_user.id:
flash(
f"You have deleted this alias before. You can restore it on "
f"{custom_domain.domain} 'Deleted Alias' page",
"error",
)
else:
# should never happen as user can only choose their domains
LOG.e(
"Deleted Alias %s does not belong to user %s",
domain_deleted_alias,
)
elif DeletedAlias.get_by(email=full_alias):
flash(general_error_msg, "error")
@ -161,7 +342,51 @@ def custom_alias():
return render_template(
"dashboard/custom_alias.html",
user_custom_domains=user_custom_domains,
alias_suffixes=alias_suffixes,
alias_suffixes_with_signature=alias_suffixes_with_signature,
at_least_a_premium_domain=at_least_a_premium_domain,
mailboxes=mailboxes,
)
def verify_prefix_suffix(user: User, alias_prefix, alias_suffix) -> bool:
"""verify if user could create an alias with the given prefix and suffix"""
if not alias_prefix or not alias_suffix: # should be caught on frontend
return False
user_custom_domains = [cd.domain for cd in user.verified_custom_domains()]
# make sure alias_suffix is either .random_word@simplelogin.co or @my-domain.com
alias_suffix = alias_suffix.strip()
# alias_domain_prefix is either a .random_word or ""
alias_domain_prefix, alias_domain = alias_suffix.split("@", 1)
# alias_domain must be either one of user custom domains or built-in domains
if alias_domain not in user.available_alias_domains():
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
return False
# SimpleLogin domain case:
# 1) alias_suffix must start with "." and
# 2) alias_domain_prefix must come from the word list
if (
alias_domain in user.available_sl_domains()
and alias_domain not in user_custom_domains
# when DISABLE_ALIAS_SUFFIX is true, alias_domain_prefix is empty
and not DISABLE_ALIAS_SUFFIX
):
if not alias_domain_prefix.startswith("."):
LOG.e("User %s submits a wrong alias suffix %s", user, alias_suffix)
return False
else:
if alias_domain not in user_custom_domains:
if not DISABLE_ALIAS_SUFFIX:
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
return False
if alias_domain not in user.available_sl_domains():
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
return False
return True

View file

@ -3,13 +3,12 @@ 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 EMAIL_SERVERS_WITH_PRIORITY
from app import config
from app.dashboard.base import dashboard_bp
from app.db import Session
from app.email_utils import get_email_domain_part
from app.log import LOG
from app.models import CustomDomain, Mailbox, DomainMailbox, SLDomain
from app.models import CustomDomain, Mailbox, DomainMailbox
class NewCustomDomainForm(FlaskForm):
@ -20,7 +19,6 @@ class NewCustomDomainForm(FlaskForm):
@dashboard_bp.route("/custom_domain", methods=["GET", "POST"])
@login_required
@parallel_limiter.lock(only_when=lambda: request.method == "POST")
def custom_domain():
custom_domains = CustomDomain.filter_by(
user_id=current_user.id, is_sl_subdomain=False
@ -45,9 +43,7 @@ def custom_domain():
if new_domain.startswith("https://"):
new_domain = new_domain[len("https://") :]
if SLDomain.get_by(domain=new_domain):
flash("A custom domain cannot be a built-in domain.", "error")
elif CustomDomain.get_by(domain=new_domain):
if CustomDomain.get_by(domain=new_domain):
flash(f"{new_domain} already used", "error")
elif get_email_domain_part(current_user.email) == new_domain:
flash(
@ -62,7 +58,7 @@ def custom_domain():
f"{new_domain} already used in a SimpleLogin mailbox", "error"
)
else:
new_custom_domain = CustomDomain.create(
new_custom_domain: CustomDomain = CustomDomain.create(
domain=new_domain, user_id=current_user.id
)
# new domain has ownership verified if its parent has the ownership verified
@ -78,6 +74,13 @@ def custom_domain():
)
new_custom_domain.ownership_verified = True
# in self-hosting, a custom domain can be the same as the root EMAIL_DOMAIN
if new_domain == config.EMAIL_DOMAIN:
new_custom_domain.ownership_verified = True
new_custom_domain.verified = True
new_custom_domain.spf_verified = True
new_custom_domain.dkim_verified = True
Session.commit()
mailbox_ids = request.form.getlist("mailbox_ids")
@ -117,7 +120,7 @@ def custom_domain():
"dashboard/custom_domain.html",
custom_domains=custom_domains,
new_custom_domain_form=new_custom_domain_form,
EMAIL_SERVERS_WITH_PRIORITY=EMAIL_SERVERS_WITH_PRIORITY,
EMAIL_SERVERS_WITH_PRIORITY=config.EMAIL_SERVERS_WITH_PRIORITY,
errors=errors,
mailboxes=mailboxes,
)

View file

@ -1,7 +1,6 @@
import arrow
from flask import flash, redirect, url_for, request, render_template
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from app.config import JOB_DELETE_ACCOUNT
from app.dashboard.base import dashboard_bp
@ -10,22 +9,12 @@ from app.log import LOG
from app.models import Subscription, Job
class DeleteDirForm(FlaskForm):
pass
@dashboard_bp.route("/delete_account", methods=["GET", "POST"])
@login_required
@sudo_required
def delete_account():
delete_form = DeleteDirForm()
if request.method == "POST" and request.form.get("form-name") == "delete-account":
if not delete_form.validate():
flash("Invalid request", "warning")
return render_template(
"dashboard/delete_account.html", delete_form=delete_form
)
sub: Subscription = current_user.get_paddle_subscription()
sub: Subscription = current_user.get_subscription()
# user who has canceled can also re-subscribe
if sub and not sub.cancelled:
flash("Please cancel your current subscription first", "warning")
@ -47,4 +36,6 @@ def delete_account():
)
return redirect(url_for("dashboard.setting"))
return render_template("dashboard/delete_account.html", delete_form=delete_form)
return render_template(
"dashboard/delete_account.html",
)

View file

@ -1,15 +1,8 @@
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import (
StringField,
validators,
SelectMultipleField,
BooleanField,
IntegerField,
)
from wtforms import StringField, validators
from app import parallel_limiter
from app.config import (
EMAIL_DOMAIN,
ALIAS_DOMAINS,
@ -28,25 +21,8 @@ class NewDirForm(FlaskForm):
)
class ToggleDirForm(FlaskForm):
directory_id = IntegerField(validators=[validators.DataRequired()])
directory_enabled = BooleanField(validators=[])
class UpdateDirForm(FlaskForm):
directory_id = IntegerField(validators=[validators.DataRequired()])
mailbox_ids = SelectMultipleField(
validators=[validators.DataRequired()], validate_choice=False, choices=[]
)
class DeleteDirForm(FlaskForm):
directory_id = IntegerField(validators=[validators.DataRequired()])
@dashboard_bp.route("/directory", methods=["GET", "POST"])
@login_required
@parallel_limiter.lock(only_when=lambda: request.method == "POST")
def directory():
dirs = (
Directory.filter_by(user_id=current_user.id)
@ -57,68 +33,54 @@ def directory():
mailboxes = current_user.mailboxes()
new_dir_form = NewDirForm()
toggle_dir_form = ToggleDirForm()
update_dir_form = UpdateDirForm()
update_dir_form.mailbox_ids.choices = [
(str(mailbox.id), str(mailbox.id)) for mailbox in mailboxes
]
delete_dir_form = DeleteDirForm()
if request.method == "POST":
if request.form.get("form-name") == "delete":
if not delete_dir_form.validate():
flash("Invalid request", "warning")
return redirect(url_for("dashboard.directory"))
dir_obj = Directory.get(delete_dir_form.directory_id.data)
dir_id = request.form.get("dir-id")
dir = Directory.get(dir_id)
if not dir_obj:
if not dir:
flash("Unknown error. Refresh the page", "warning")
return redirect(url_for("dashboard.directory"))
elif dir_obj.user_id != current_user.id:
elif dir.user_id != current_user.id:
flash("You cannot delete this directory", "warning")
return redirect(url_for("dashboard.directory"))
name = dir_obj.name
Directory.delete(dir_obj.id)
name = dir.name
Directory.delete(dir_id)
Session.commit()
flash(f"Directory {name} has been deleted", "success")
return redirect(url_for("dashboard.directory"))
if request.form.get("form-name") == "toggle-directory":
if not toggle_dir_form.validate():
flash("Invalid request", "warning")
return redirect(url_for("dashboard.directory"))
dir_id = toggle_dir_form.directory_id.data
dir_obj = Directory.get(dir_id)
dir_id = request.form.get("dir-id")
dir = Directory.get(dir_id)
if not dir_obj or dir_obj.user_id != current_user.id:
if not dir or dir.user_id != current_user.id:
flash("Unknown error. Refresh the page", "warning")
return redirect(url_for("dashboard.directory"))
if toggle_dir_form.directory_enabled.data:
dir_obj.disabled = False
flash(f"On-the-fly is enabled for {dir_obj.name}", "success")
if request.form.get("dir-status") == "on":
dir.disabled = False
flash(f"On-the-fly is enabled for {dir.name}", "success")
else:
dir_obj.disabled = True
flash(f"On-the-fly is disabled for {dir_obj.name}", "warning")
dir.disabled = True
flash(f"On-the-fly is disabled for {dir.name}", "warning")
Session.commit()
return redirect(url_for("dashboard.directory"))
elif request.form.get("form-name") == "update":
if not update_dir_form.validate():
flash("Invalid request", "warning")
return redirect(url_for("dashboard.directory"))
dir_id = update_dir_form.directory_id.data
dir_obj = Directory.get(dir_id)
dir_id = request.form.get("dir-id")
dir = Directory.get(dir_id)
if not dir_obj or dir_obj.user_id != current_user.id:
if not dir or dir.user_id != current_user.id:
flash("Unknown error. Refresh the page", "warning")
return redirect(url_for("dashboard.directory"))
mailbox_ids = update_dir_form.mailbox_ids.data
mailbox_ids = request.form.getlist("mailbox_ids")
# check if mailbox is not tempered with
mailboxes = []
for mailbox_id in mailbox_ids:
@ -137,14 +99,14 @@ def directory():
return redirect(url_for("dashboard.directory"))
# first remove all existing directory-mailboxes links
DirectoryMailbox.filter_by(directory_id=dir_obj.id).delete()
DirectoryMailbox.filter_by(directory_id=dir.id).delete()
Session.flush()
for mailbox in mailboxes:
DirectoryMailbox.create(directory_id=dir_obj.id, mailbox_id=mailbox.id)
DirectoryMailbox.create(directory_id=dir.id, mailbox_id=mailbox.id)
Session.commit()
flash(f"Directory {dir_obj.name} has been updated", "success")
flash(f"Directory {dir.name} has been updated", "success")
return redirect(url_for("dashboard.directory"))
elif request.form.get("form-name") == "create":
@ -219,9 +181,6 @@ def directory():
return render_template(
"dashboard/directory.html",
dirs=dirs,
toggle_dir_form=toggle_dir_form,
update_dir_form=update_dir_form,
delete_dir_form=delete_dir_form,
new_dir_form=new_dir_form,
mailboxes=mailboxes,
EMAIL_DOMAIN=EMAIL_DOMAIN,

View file

@ -7,13 +7,13 @@ from flask_wtf import FlaskForm
from wtforms import StringField, validators, IntegerField
from app.config import EMAIL_SERVERS_WITH_PRIORITY, EMAIL_DOMAIN, JOB_DELETE_DOMAIN
from app.custom_domain_validation import CustomDomainValidation
from app.dashboard.base import dashboard_bp
from app.db import Session
from app.dns_utils import (
get_mx_domains,
get_spf_domain,
get_txt_record,
get_cname_record,
is_mx_equivalent,
)
from app.log import LOG
@ -28,7 +28,7 @@ from app.models import (
Job,
)
from app.regex_utils import regex_match
from app.utils import random_string, CSRFValidationForm
from app.utils import random_string
@dashboard_bp.route("/domains/<int:custom_domain_id>/dns", methods=["GET", "POST"])
@ -46,8 +46,8 @@ def domain_detail_dns(custom_domain_id):
spf_record = f"v=spf1 include:{EMAIL_DOMAIN} ~all"
domain_validator = CustomDomainValidation(EMAIL_DOMAIN)
csrf_form = CSRFValidationForm()
# hardcode the DKIM selector here
dkim_cname = f"dkim._domainkey.{EMAIL_DOMAIN}"
dmarc_record = "v=DMARC1; p=quarantine; pct=100; adkim=s; aspf=s"
@ -55,9 +55,6 @@ def domain_detail_dns(custom_domain_id):
mx_errors = spf_errors = dkim_errors = dmarc_errors = ownership_errors = []
if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
if request.form.get("form-name") == "check-ownership":
txt_records = get_txt_record(custom_domain.domain)
@ -125,17 +122,23 @@ def domain_detail_dns(custom_domain_id):
spf_errors = get_txt_record(custom_domain.domain)
elif request.form.get("form-name") == "check-dkim":
dkim_errors = domain_validator.validate_dkim_records(custom_domain)
if len(dkim_errors) == 0:
dkim_record = get_cname_record("dkim._domainkey." + custom_domain.domain)
if dkim_record == dkim_cname:
flash("DKIM is setup correctly.", "success")
custom_domain.dkim_verified = True
Session.commit()
return redirect(
url_for(
"dashboard.domain_detail_dns", custom_domain_id=custom_domain.id
)
)
else:
dkim_ok = False
custom_domain.dkim_verified = False
Session.commit()
flash("DKIM: the CNAME record is not correctly set", "warning")
dkim_ok = False
dkim_errors = [dkim_record or "[Empty]"]
elif request.form.get("form-name") == "check-dmarc":
txt_records = get_txt_record("_dmarc." + custom_domain.domain)
@ -161,7 +164,6 @@ def domain_detail_dns(custom_domain_id):
return render_template(
"dashboard/domain_detail/dns.html",
EMAIL_SERVERS_WITH_PRIORITY=EMAIL_SERVERS_WITH_PRIORITY,
dkim_records=domain_validator.get_dkim_records(),
**locals(),
)
@ -169,7 +171,6 @@ def domain_detail_dns(custom_domain_id):
@dashboard_bp.route("/domains/<int:custom_domain_id>/info", methods=["GET", "POST"])
@login_required
def domain_detail(custom_domain_id):
csrf_form = CSRFValidationForm()
custom_domain: CustomDomain = CustomDomain.get(custom_domain_id)
mailboxes = current_user.mailboxes()
@ -178,9 +179,6 @@ def domain_detail(custom_domain_id):
return redirect(url_for("dashboard.index"))
if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
if request.form.get("form-name") == "switch-catch-all":
custom_domain.catch_all = not custom_domain.catch_all
Session.commit()
@ -309,16 +307,12 @@ def domain_detail(custom_domain_id):
@dashboard_bp.route("/domains/<int:custom_domain_id>/trash", methods=["GET", "POST"])
@login_required
def domain_detail_trash(custom_domain_id):
csrf_form = CSRFValidationForm()
custom_domain = CustomDomain.get(custom_domain_id)
if not custom_domain or custom_domain.user_id != current_user.id:
flash("You cannot see this page", "warning")
return redirect(url_for("dashboard.index"))
if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
if request.form.get("form-name") == "empty-all":
DomainDeletedAlias.filter_by(domain_id=custom_domain.id).delete()
Session.commit()
@ -362,7 +356,6 @@ def domain_detail_trash(custom_domain_id):
"dashboard/domain_detail/trash.html",
domain_deleted_aliases=domain_deleted_aliases,
custom_domain=custom_domain,
csrf_form=csrf_form,
)

View file

@ -6,12 +6,8 @@ from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import PasswordField, validators
from app.config import CONNECT_WITH_PROTON
from app.dashboard.base import dashboard_bp
from app.extensions import limiter
from app.log import LOG
from app.models import PartnerUser
from app.proton.utils import get_proton_partner
from app.utils import sanitize_next_url
_SUDO_GAP = 900
@ -22,7 +18,6 @@ class LoginForm(FlaskForm):
@dashboard_bp.route("/enter_sudo", methods=["GET", "POST"])
@limiter.limit("3/minute")
@login_required
def enter_sudo():
password_check_form = LoginForm()
@ -44,18 +39,8 @@ def enter_sudo():
else:
flash("Incorrect password", "warning")
proton_enabled = CONNECT_WITH_PROTON
if proton_enabled:
# Only for users that have the account linked
partner_user = PartnerUser.get_by(user_id=current_user.id)
if not partner_user or partner_user.partner_id != get_proton_partner().id:
proton_enabled = False
return render_template(
"dashboard/enter_sudo.html",
password_check_form=password_check_form,
next=request.args.get("next"),
connect_with_proton=proton_enabled,
"dashboard/enter_sudo.html", password_check_form=password_check_form
)

View file

@ -78,10 +78,10 @@ def fido_setup():
)
flash("Security key has been activated", "success")
recovery_codes = RecoveryCode.generate(current_user)
return render_template(
"dashboard/recovery_code.html", recovery_codes=recovery_codes
)
if not RecoveryCode.filter_by(user_id=current_user.id).all():
return redirect(url_for("dashboard.recovery_code_route"))
else:
return redirect(url_for("dashboard.fido_manage"))
# Prepare information for key registration process
fido_uuid = (

View file

@ -3,7 +3,7 @@ from dataclasses import dataclass
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from app import alias_utils, parallel_limiter
from app import alias_utils
from app.api.serializer import get_alias_infos_with_pagination_v3, get_alias_info_v3
from app.config import ALIAS_LIMIT, PAGE_LIMIT
from app.dashboard.base import dashboard_bp
@ -17,7 +17,6 @@ from app.models import (
EmailLog,
Contact,
)
from app.utils import CSRFValidationForm
@dataclass
@ -57,15 +56,7 @@ def get_stats(user: User) -> Stats:
methods=["POST"],
exempt_when=lambda: request.form.get("form-name") != "create-random-email",
)
@limiter.limit(
"5/minute",
methods=["GET"],
)
@login_required
@parallel_limiter.lock(
name="alias_creation",
only_when=lambda: request.form.get("form-name") == "create-random-email",
)
def index():
query = request.args.get("query") or ""
sort = request.args.get("sort") or ""
@ -84,12 +75,8 @@ def index():
"highlight_alias_id must be a number, received %s",
request.args.get("highlight_alias_id"),
)
csrf_form = CSRFValidationForm()
if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
if request.form.get("form-name") == "create-custom-email":
if current_user.can_create_new_alias():
return redirect(url_for("dashboard.custom_alias"))
@ -154,13 +141,7 @@ def index():
flash(f"Alias {alias.email} has been disabled", "success")
return redirect(
url_for(
"dashboard.index",
query=query,
sort=sort,
filter=alias_filter,
page=page,
)
url_for("dashboard.index", query=query, sort=sort, filter=alias_filter)
)
mailboxes = current_user.mailboxes()
@ -223,7 +204,6 @@ def index():
sort=sort,
filter=alias_filter,
stats=stats,
csrf_form=csrf_form,
)

View file

@ -23,7 +23,7 @@ def lifetime_licence():
# user needs to cancel active subscription first
# to avoid being charged
sub = current_user.get_paddle_subscription()
sub = current_user.get_subscription()
if sub and not sub.cancelled:
flash("Please cancel your current subscription first", "warning")
return redirect(url_for("dashboard.index"))

View file

@ -1,16 +1,11 @@
import base64
import binascii
import json
import arrow
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from itsdangerous import TimestampSigner
from wtforms import validators, IntegerField
from itsdangerous import Signer
from wtforms import validators
from wtforms.fields.html5 import EmailField
from app import parallel_limiter
from app.config import MAILBOX_SECRET, URL, JOB_DELETE_MAILBOX
from app.dashboard.base import dashboard_bp
from app.db import Session
@ -19,11 +14,10 @@ from app.email_utils import (
mailbox_already_used,
render,
send_email,
is_valid_email,
)
from app.email_validation import is_valid_email
from app.log import LOG
from app.models import Mailbox, Job
from app.utils import CSRFValidationForm
class NewMailboxForm(FlaskForm):
@ -32,16 +26,8 @@ class NewMailboxForm(FlaskForm):
)
class DeleteMailboxForm(FlaskForm):
mailbox_id = IntegerField(
validators=[validators.DataRequired()],
)
transfer_mailbox_id = IntegerField()
@dashboard_bp.route("/mailbox", methods=["GET", "POST"])
@login_required
@parallel_limiter.lock(only_when=lambda: request.method == "POST")
def mailbox_route():
mailboxes = (
Mailbox.filter_by(user_id=current_user.id)
@ -50,57 +36,25 @@ def mailbox_route():
)
new_mailbox_form = NewMailboxForm()
csrf_form = CSRFValidationForm()
delete_mailbox_form = DeleteMailboxForm()
if request.method == "POST":
if request.form.get("form-name") == "delete":
if not delete_mailbox_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
mailbox = Mailbox.get(delete_mailbox_form.mailbox_id.data)
mailbox_id = request.form.get("mailbox-id")
mailbox = Mailbox.get(mailbox_id)
if not mailbox or mailbox.user_id != current_user.id:
flash("Invalid mailbox. Refresh the page", "warning")
flash("Unknown error. Refresh the page", "warning")
return redirect(url_for("dashboard.mailbox_route"))
if mailbox.id == current_user.default_mailbox_id:
flash("You cannot delete default mailbox", "error")
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
LOG.w(
f"schedule delete mailbox job for {mailbox.id} with transfer to mailbox {transfer_mailbox_id}"
)
LOG.w("schedule delete mailbox job for %s", mailbox)
Job.create(
name=JOB_DELETE_MAILBOX,
payload={
"mailbox_id": mailbox.id,
"transfer_mailbox_id": transfer_mailbox_id
if transfer_mailbox_id > 0
else None,
},
payload={"mailbox_id": mailbox.id},
run_at=arrow.now(),
commit=True,
)
@ -113,10 +67,7 @@ def mailbox_route():
return redirect(url_for("dashboard.mailbox_route"))
if request.form.get("form-name") == "set-default":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
mailbox_id = request.form.get("mailbox_id")
mailbox_id = request.form.get("mailbox-id")
mailbox = Mailbox.get(mailbox_id)
if not mailbox or mailbox.user_id != current_user.id:
@ -168,8 +119,7 @@ def mailbox_route():
return redirect(
url_for(
"dashboard.mailbox_detail_route",
mailbox_id=new_mailbox.id,
"dashboard.mailbox_detail_route", mailbox_id=new_mailbox.id
)
)
@ -177,24 +127,46 @@ def mailbox_route():
"dashboard/mailbox.html",
mailboxes=mailboxes,
new_mailbox_form=new_mailbox_form,
delete_mailbox_form=delete_mailbox_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):
s = TimestampSigner(MAILBOX_SECRET)
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()
s = Signer(MAILBOX_SECRET)
mailbox_id_signed = s.sign(str(mailbox.id)).decode()
verification_url = (
URL + "/dashboard/mailbox_verify" + f"?mailbox_id={mailbox_id_signed}"
)
send_email(
mailbox.email,
f"Please confirm your mailbox {mailbox.email}",
f"Please confirm your email {mailbox.email}",
render(
"transactional/verify-mailbox.txt.jinja2",
"transactional/verify-mailbox.txt",
user=user,
link=verification_url,
mailbox_email=mailbox.email,
@ -210,35 +182,23 @@ def send_verification_email(user, mailbox):
@dashboard_bp.route("/mailbox_verify")
def mailbox_verify():
s = TimestampSigner(MAILBOX_SECRET)
mailbox_verify_request = request.args.get("mailbox_id")
s = Signer(MAILBOX_SECRET)
mailbox_id = request.args.get("mailbox_id")
try:
mailbox_raw_data = s.unsign(mailbox_verify_request, max_age=900)
r_id = int(s.unsign(mailbox_id))
except Exception:
flash("Invalid link. Please delete and re-add your mailbox", "error")
return redirect(url_for("dashboard.mailbox_route"))
try:
decoded_data = base64.urlsafe_b64decode(mailbox_raw_data)
except binascii.Error:
flash("Invalid link. Please delete and re-add your mailbox", "error")
return redirect(url_for("dashboard.mailbox_route"))
mailbox_data = json.loads(decoded_data)
if not isinstance(mailbox_data, list) or len(mailbox_data) != 2:
flash("Invalid link. Please delete and re-add your mailbox", "error")
return redirect(url_for("dashboard.mailbox_route"))
mailbox_id = mailbox_data[0]
mailbox = Mailbox.get(mailbox_id)
if not mailbox:
flash("Invalid link", "error")
return redirect(url_for("dashboard.mailbox_route"))
mailbox_email = mailbox_data[1]
if mailbox_email != mailbox.email:
flash("Invalid link", "error")
return redirect(url_for("dashboard.mailbox_route"))
else:
mailbox = Mailbox.get(r_id)
if not mailbox:
flash("Invalid link", "error")
return redirect(url_for("dashboard.mailbox_route"))
mailbox.verified = True
Session.commit()
mailbox.verified = True
Session.commit()
LOG.d("Mailbox %s is verified", mailbox)
LOG.d("Mailbox %s is verified", mailbox)
return render_template("dashboard/mailbox_validation.html", mailbox=mailbox)
return render_template("dashboard/mailbox_validation.html", mailbox=mailbox)

View file

@ -1,10 +1,9 @@
from smtplib import SMTPRecipientsRefused
from email_validator import validate_email, EmailNotValidError
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from itsdangerous import TimestampSigner
from itsdangerous import Signer
from wtforms import validators
from wtforms.fields.html5 import EmailField
@ -18,7 +17,7 @@ from app.log import LOG
from app.models import Alias, AuthorizedAddress
from app.models import Mailbox
from app.pgp_utils import PGPException, load_public_key_and_check
from app.utils import sanitize_email, CSRFValidationForm
from app.utils import sanitize_email
class ChangeEmailForm(FlaskForm):
@ -30,13 +29,12 @@ class ChangeEmailForm(FlaskForm):
@dashboard_bp.route("/mailbox/<int:mailbox_id>/", methods=["GET", "POST"])
@login_required
def mailbox_detail_route(mailbox_id):
mailbox: Mailbox = Mailbox.get(mailbox_id)
mailbox = Mailbox.get(mailbox_id)
if not mailbox or mailbox.user_id != current_user.id:
flash("You cannot see this page", "warning")
return redirect(url_for("dashboard.index"))
change_email_form = ChangeEmailForm()
csrf_form = CSRFValidationForm()
if mailbox.new_email:
pending_email = mailbox.new_email
@ -44,9 +42,6 @@ def mailbox_detail_route(mailbox_id):
pending_email = None
if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
if (
request.form.get("form-name") == "update-email"
and change_email_form.validate_on_submit()
@ -99,23 +94,16 @@ def mailbox_detail_route(mailbox_id):
)
elif request.form.get("form-name") == "add-authorized-address":
address = sanitize_email(request.form.get("email"))
try:
validate_email(
address, check_deliverability=False, allow_smtputf8=False
).domain
except EmailNotValidError:
flash(f"invalid {address}", "error")
if AuthorizedAddress.get_by(mailbox_id=mailbox.id, email=address):
flash(f"{address} already added", "error")
else:
if AuthorizedAddress.get_by(mailbox_id=mailbox.id, email=address):
flash(f"{address} already added", "error")
else:
AuthorizedAddress.create(
user_id=current_user.id,
mailbox_id=mailbox.id,
email=address,
commit=True,
)
flash(f"{address} added as authorized address", "success")
AuthorizedAddress.create(
user_id=current_user.id,
mailbox_id=mailbox.id,
email=address,
commit=True,
)
flash(f"{address} added as authorized address", "success")
return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
@ -144,15 +132,6 @@ def mailbox_detail_route(mailbox_id):
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
)
if mailbox.is_proton():
flash(
"Enabling PGP for a Proton Mail mailbox is redundant and does not add any security benefit",
"info",
)
return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
)
mailbox.pgp_public_key = request.form.get("pgp")
try:
mailbox.pgp_finger_print = load_public_key_and_check(
@ -191,16 +170,25 @@ def mailbox_detail_route(mailbox_id):
)
elif request.form.get("form-name") == "generic-subject":
if request.form.get("action") == "save":
if not mailbox.pgp_enabled():
flash(
"Generic subject can only be used on PGP-enabled mailbox",
"error",
)
return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
)
mailbox.generic_subject = request.form.get("generic-subject")
Session.commit()
flash("Generic subject is enabled", "success")
flash("Generic subject for PGP-encrypted email is enabled", "success")
return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
)
elif request.form.get("action") == "remove":
mailbox.generic_subject = None
Session.commit()
flash("Generic subject is disabled", "success")
flash("Generic subject for PGP-encrypted email is disabled", "success")
return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
)
@ -210,7 +198,7 @@ def mailbox_detail_route(mailbox_id):
def verify_mailbox_change(user, mailbox, new_email):
s = TimestampSigner(MAILBOX_SECRET)
s = Signer(MAILBOX_SECRET)
mailbox_id_signed = s.sign(str(mailbox.id)).decode()
verification_url = (
f"{URL}/dashboard/mailbox/confirm_change?mailbox_id={mailbox_id_signed}"
@ -220,7 +208,7 @@ def verify_mailbox_change(user, mailbox, new_email):
new_email,
"Confirm mailbox change on SimpleLogin",
render(
"transactional/verify-mailbox-change.txt.jinja2",
"transactional/verify-mailbox-change.txt",
user=user,
link=verification_url,
mailbox_email=mailbox.email,
@ -262,11 +250,11 @@ def cancel_mailbox_change_route(mailbox_id):
@dashboard_bp.route("/mailbox/confirm_change")
def mailbox_confirm_change_route():
s = TimestampSigner(MAILBOX_SECRET)
s = Signer(MAILBOX_SECRET)
signed_mailbox_id = request.args.get("mailbox_id")
try:
mailbox_id = int(s.unsign(signed_mailbox_id, max_age=900))
mailbox_id = int(s.unsign(signed_mailbox_id))
except Exception:
flash("Invalid link", "error")
return redirect(url_for("dashboard.index"))

View file

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

View file

@ -8,7 +8,6 @@ from app.dashboard.base import dashboard_bp
from app.dashboard.views.enter_sudo import sudo_required
from app.db import Session
from app.log import LOG
from app.models import RecoveryCode
class OtpTokenForm(FlaskForm):
@ -40,10 +39,8 @@ def mfa_setup():
current_user.last_otp = token
Session.commit()
flash("MFA has been activated", "success")
recovery_codes = RecoveryCode.generate(current_user)
return render_template(
"dashboard/recovery_code.html", recovery_codes=recovery_codes
)
return redirect(url_for("dashboard.recovery_code_route"))
else:
flash("Incorrect token", "warning")

View file

@ -19,10 +19,7 @@ from app.models import (
Subscription,
ManualSubscription,
CoinbaseSubscription,
PartnerUser,
PartnerSubscription,
)
from app.proton.utils import get_proton_partner
@dashboard_bp.route("/pricing", methods=["GET", "POST"])
@ -32,9 +29,9 @@ def pricing():
flash("You already have a lifetime subscription", "error")
return redirect(url_for("dashboard.index"))
paddle_sub: Subscription = current_user.get_paddle_subscription()
sub: Subscription = current_user.get_subscription()
# user who has canceled can re-subscribe
if paddle_sub and not paddle_sub.cancelled:
if sub and not sub.cancelled:
flash("You already have an active subscription", "error")
return redirect(url_for("dashboard.index"))
@ -52,18 +49,6 @@ def pricing():
if apple_sub and apple_sub.is_valid():
flash("Please make sure to cancel your subscription on Apple first", "warning")
proton_upgrade = False
partner_user = PartnerUser.get_by(user_id=current_user.id)
if partner_user:
partner_sub = PartnerSubscription.get_by(partner_user_id=partner_user.id)
if partner_sub and partner_sub.is_active():
flash(
f"You already have a subscription provided by {partner_user.partner.name}",
"error",
)
return redirect(url_for("dashboard.index"))
proton_upgrade = partner_user.partner_id == get_proton_partner().id
return render_template(
"dashboard/pricing.html",
PADDLE_VENDOR_ID=PADDLE_VENDOR_ID,
@ -73,16 +58,14 @@ def pricing():
manual_sub=manual_sub,
coinbase_sub=coinbase_sub,
now=now,
proton_upgrade=proton_upgrade,
)
@dashboard_bp.route("/subscription_success")
@login_required
def subscription_success():
return render_template(
"dashboard/thank-you.html",
)
flash("Thanks so much for supporting SimpleLogin!", "success")
return redirect(url_for("dashboard.index"))
@dashboard_bp.route("/coinbase_checkout")

View file

@ -0,0 +1,30 @@
from flask import render_template, flash, redirect, url_for, request
from flask_login import login_required, current_user
from app.dashboard.base import dashboard_bp
from app.log import LOG
from app.models import RecoveryCode
@dashboard_bp.route("/recovery_code", methods=["GET", "POST"])
@login_required
def recovery_code_route():
if not current_user.two_factor_authentication_enabled():
flash("you need to enable either TOTP or WebAuthn", "warning")
return redirect(url_for("dashboard.index"))
recovery_codes = RecoveryCode.filter_by(user_id=current_user.id).all()
if request.method == "GET" and not recovery_codes:
# user arrives at this page for the first time
LOG.d("%s has no recovery keys, generate", current_user)
RecoveryCode.generate(current_user)
recovery_codes = RecoveryCode.filter_by(user_id=current_user.id).all()
if request.method == "POST":
RecoveryCode.generate(current_user)
flash("New recovery codes generated", "success")
return redirect(url_for("dashboard.recovery_code_route"))
return render_template(
"dashboard/recovery_code.html", recovery_codes=recovery_codes
)

View file

@ -1,5 +1,4 @@
from io import BytesIO
from typing import Optional, Tuple
import arrow
from flask import (
@ -12,6 +11,7 @@ from flask import (
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from flask_wtf.file import FileField
from typing import Optional
from wtforms import StringField, validators
from wtforms.fields.html5 import EmailField
@ -29,8 +29,6 @@ from app.email_utils import (
personal_email_already_used,
)
from app.errors import ProtonPartnerNotSetUp
from app.extensions import limiter
from app.image_validation import detect_image_format, ImageFormat
from app.jobs.export_user_data_job import ExportUserDataJob
from app.log import LOG
from app.models import (
@ -50,15 +48,9 @@ from app.models import (
CoinbaseSubscription,
AppleSubscription,
PartnerUser,
PartnerSubscription,
UnsubscribeBehaviourEnum,
)
from app.proton.utils import get_proton_partner, perform_proton_account_unlink
from app.utils import (
random_string,
CSRFValidationForm,
canonicalize_email,
)
from app.proton.proton_callback_handler import get_proton_partner
from app.utils import random_string, sanitize_email
class SettingForm(FlaskForm):
@ -92,25 +84,12 @@ def get_proton_linked_account() -> Optional[str]:
return proton_linked_account.partner_email
def get_partner_subscription_and_name(
user_id: int,
) -> Optional[Tuple[PartnerSubscription, str]]:
partner_sub = PartnerSubscription.find_by_user_id(user_id)
if not partner_sub or not partner_sub.is_active():
return None
partner = partner_sub.partner_user.partner
return (partner_sub, partner.name)
@dashboard_bp.route("/setting", methods=["GET", "POST"])
@login_required
@limiter.limit("5/minute", methods=["POST"])
def setting():
form = SettingForm()
promo_form = PromoCodeForm()
change_email_form = ChangeEmailForm()
csrf_form = CSRFValidationForm()
email_change = EmailChange.get_by(user_id=current_user.id)
if email_change:
@ -119,15 +98,16 @@ def setting():
pending_email = None
if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(url_for("dashboard.setting"))
if request.form.get("form-name") == "update-email":
if change_email_form.validate():
# whether user can proceed with the email update
new_email_valid = True
new_email = canonicalize_email(change_email_form.email.data)
if new_email != current_user.email and not pending_email:
if (
sanitize_email(change_email_form.email.data) != current_user.email
and not pending_email
):
new_email = sanitize_email(change_email_form.email.data)
# check if this email is not already used
if personal_email_already_used(new_email) or Alias.get_by(
email=new_email
@ -189,28 +169,12 @@ def setting():
profile_updated = True
if form.profile_picture.data:
image_contents = form.profile_picture.data.read()
if detect_image_format(image_contents) == ImageFormat.Unknown:
flash(
"This image format is not supported",
"error",
)
return redirect(url_for("dashboard.setting"))
if current_user.profile_picture_id is not None:
current_profile_file = File.get_by(
id=current_user.profile_picture_id
)
if (
current_profile_file is not None
and current_profile_file.user_id == current_user.id
):
s3.delete(current_profile_file.path)
file_path = random_string(30)
file = File.create(user_id=current_user.id, path=file_path)
s3.upload_from_bytesio(file_path, BytesIO(image_contents))
s3.upload_from_bytesio(
file_path, BytesIO(form.profile_picture.data.read())
)
Session.flush()
LOG.d("upload file %s to s3", file)
@ -345,16 +309,11 @@ def setting():
flash("Your preference has been updated", "success")
return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "one-click-unsubscribe":
choose = request.form.get("unsubscribe-behaviour")
if choose == UnsubscribeBehaviourEnum.PreserveOriginal.name:
current_user.unsub_behaviour = UnsubscribeBehaviourEnum.PreserveOriginal
elif choose == UnsubscribeBehaviourEnum.DisableAlias.name:
current_user.unsub_behaviour = UnsubscribeBehaviourEnum.DisableAlias
elif choose == UnsubscribeBehaviourEnum.BlockContact.name:
current_user.unsub_behaviour = UnsubscribeBehaviourEnum.BlockContact
choose = request.form.get("enable")
if choose == "on":
current_user.one_click_unsubscribe_block_sender = True
else:
flash("There was an error. Please try again", "warning")
return redirect(url_for("dashboard.setting"))
current_user.one_click_unsubscribe_block_sender = False
Session.commit()
flash("Your preference has been updated", "success")
return redirect(url_for("dashboard.setting"))
@ -399,19 +358,10 @@ def setting():
manual_sub = ManualSubscription.get_by(user_id=current_user.id)
apple_sub = AppleSubscription.get_by(user_id=current_user.id)
coinbase_sub = CoinbaseSubscription.get_by(user_id=current_user.id)
paddle_sub = current_user.get_paddle_subscription()
partner_sub = None
partner_name = None
partner_sub_name = get_partner_subscription_and_name(current_user.id)
if partner_sub_name:
partner_sub, partner_name = partner_sub_name
proton_linked_account = get_proton_linked_account()
return render_template(
"dashboard/setting.html",
csrf_form=csrf_form,
form=form,
PlanEnum=PlanEnum,
SenderFormatEnum=SenderFormatEnum,
@ -420,12 +370,8 @@ def setting():
change_email_form=change_email_form,
pending_email=pending_email,
AliasGeneratorEnum=AliasGeneratorEnum,
UnsubscribeBehaviourEnum=UnsubscribeBehaviourEnum,
manual_sub=manual_sub,
partner_sub=partner_sub,
partner_name=partner_name,
apple_sub=apple_sub,
paddle_sub=paddle_sub,
coinbase_sub=coinbase_sub,
FIRST_ALIAS_DOMAIN=FIRST_ALIAS_DOMAIN,
ALIAS_RAND_SUFFIX_LENGTH=ALIAS_RANDOM_SUFFIX_LENGTH,
@ -460,13 +406,8 @@ def send_change_email_confirmation(user: User, email_change: EmailChange):
@dashboard_bp.route("/resend_email_change", methods=["GET", "POST"])
@limiter.limit("5/hour")
@login_required
def resend_email_change():
form = CSRFValidationForm()
if not form.validate():
flash("Invalid request. Please try again", "warning")
return redirect(url_for("dashboard.setting"))
email_change = EmailChange.get_by(user_id=current_user.id)
if email_change:
# extend email change expiration
@ -486,10 +427,6 @@ def resend_email_change():
@dashboard_bp.route("/cancel_email_change", methods=["GET", "POST"])
@login_required
def cancel_email_change():
form = CSRFValidationForm()
if not form.validate():
flash("Invalid request. Please try again", "warning")
return redirect(url_for("dashboard.setting"))
email_change = EmailChange.get_by(user_id=current_user.id)
if email_change:
EmailChange.delete(email_change.id)
@ -503,14 +440,14 @@ def cancel_email_change():
return redirect(url_for("dashboard.setting"))
@dashboard_bp.route("/unlink_proton_account", methods=["POST"])
@dashboard_bp.route("/unlink_proton_account", methods=["GET", "POST"])
@login_required
def unlink_proton_account():
csrf_form = CSRFValidationForm()
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(url_for("dashboard.setting"))
perform_proton_account_unlink(current_user)
partner_user = PartnerUser.get_by(
user_id=current_user.id, partner_id=get_proton_partner().id
)
if partner_user is not None:
PartnerUser.delete(partner_user.id)
Session.commit()
flash("Your Proton account has been unlinked", "success")
return redirect(url_for("dashboard.setting"))

View file

@ -2,10 +2,7 @@ import re
from flask import render_template, request, redirect, url_for, flash
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.dashboard.base import dashboard_bp
from app.errors import SubdomainInTrashError
@ -16,18 +13,8 @@ from app.models import CustomDomain, Mailbox, SLDomain
_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"])
@login_required
@parallel_limiter.lock(only_when=lambda: request.method == "POST")
def subdomain_route():
if not current_user.subdomain_is_available():
flash("Unknown error, redirect to the home page", "error")
@ -39,13 +26,9 @@ def subdomain_route():
).all()
errors = {}
new_subdomain_form = NewSubdomainForm()
if request.method == "POST":
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():
flash("Only premium plan can add subdomain", "warning")
return redirect(request.url)
@ -56,8 +39,8 @@ def subdomain_route():
)
return redirect(request.url)
subdomain = new_subdomain_form.subdomain.data.lower().strip()
domain = new_subdomain_form.domain.data.lower().strip()
subdomain = request.form.get("subdomain").lower().strip()
domain = request.form.get("domain").lower().strip()
if len(subdomain) < 3:
flash("Subdomain must have at least 3 characters", "error")
@ -125,5 +108,4 @@ def subdomain_route():
sl_domains=sl_domains,
errors=errors,
subdomains=subdomains,
new_subdomain_form=new_subdomain_form,
)

View file

@ -9,12 +9,10 @@ from flask import redirect, url_for, flash, request, render_template
from flask_login import login_required, current_user
from app.dashboard.base import dashboard_bp
from app.handler.unsubscribe_encoder import UnsubscribeAction
from app.handler.unsubscribe_handler import UnsubscribeHandler
from app.models import Alias, Contact
@dashboard_bp.route("/unsubscribe/<int:alias_id>", methods=["GET", "POST"])
@dashboard_bp.route("/unsubscribe/<alias_id>", methods=["GET", "POST"])
@login_required
def unsubscribe(alias_id):
alias = Alias.get(alias_id)
@ -40,7 +38,7 @@ def unsubscribe(alias_id):
return render_template("dashboard/unsubscribe.html", alias=alias.email)
@dashboard_bp.route("/block_contact/<int:contact_id>", methods=["GET", "POST"])
@dashboard_bp.route("/block_contact/<contact_id>", methods=["GET", "POST"])
@login_required
def block_contact(contact_id):
contact = Contact.get(contact_id)
@ -70,43 +68,3 @@ def block_contact(contact_id):
)
else: # ask user confirmation
return render_template("dashboard/block_contact.html", contact=contact)
@dashboard_bp.route("/unsubscribe/encoded/<encoded_request>", methods=["GET"])
@login_required
def encoded_unsubscribe(encoded_request: str):
unsub_data = UnsubscribeHandler().handle_unsubscribe_from_request(
current_user, encoded_request
)
if not unsub_data:
flash("Invalid unsubscribe request", "error")
return redirect(url_for("dashboard.index"))
if unsub_data.action == UnsubscribeAction.DisableAlias:
alias = Alias.get(unsub_data.data)
flash(f"Alias {alias.email} has been blocked", "success")
return redirect(url_for("dashboard.index", highlight_alias_id=alias.id))
if unsub_data.action == UnsubscribeAction.DisableContact:
contact = Contact.get(unsub_data.data)
flash(f"Emails sent from {contact.website_email} are now blocked", "success")
return redirect(
url_for(
"dashboard.alias_contact_manager",
alias_id=contact.alias_id,
highlight_contact_id=contact.id,
)
)
if unsub_data.action == UnsubscribeAction.UnsubscribeNewsletter:
flash("You've unsubscribed from the newsletter", "success")
return redirect(
url_for(
"dashboard.index",
)
)
if unsub_data.action == UnsubscribeAction.OriginalUnsubscribeMailto:
flash("The original unsubscribe request has been forwarded", "success")
return redirect(
url_for(
"dashboard.index",
)
)
return redirect(url_for("dashboard.index"))

View file

@ -3,12 +3,9 @@ from sqlalchemy import create_engine
from sqlalchemy.orm import scoped_session
from sqlalchemy.orm import sessionmaker
from app import config
from app.config import DB_URI
engine = create_engine(
config.DB_URI, connect_args={"application_name": config.DB_CONN_NAME}
)
engine = create_engine(DB_URI)
connection = engine.connect()
Session = scoped_session(sessionmaker(bind=connection))

View file

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

View file

@ -87,7 +87,7 @@ def client_detail(client_id):
)
flash(
"Thanks for submitting, we are informed and will come back to you asap!",
f"Thanks for submitting, we are informed and will come back to you asap!",
"success",
)

View file

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

View file

@ -34,7 +34,7 @@ def get_cname_record(hostname) -> Optional[str]:
def get_mx_domains(hostname) -> [(int, str)]:
"""return list of (priority, domain name) sorted by priority (lowest priority first)
"""return list of (priority, domain name).
domain name ends with a "." at the end.
"""
try:
@ -50,7 +50,7 @@ def get_mx_domains(hostname) -> [(int, str)]:
ret.append((int(parts[0]), parts[1]))
return sorted(ret, key=lambda prio_domain: prio_domain[0])
return ret
_include_spf = "include:"

View file

@ -20,7 +20,6 @@ X_SPAM_STATUS = "X-Spam-Status"
LIST_UNSUBSCRIBE = "List-Unsubscribe"
LIST_UNSUBSCRIBE_POST = "List-Unsubscribe-Post"
RETURN_PATH = "Return-Path"
AUTHENTICATION_RESULTS = "Authentication-Results"
# headers used to DKIM sign in order of preference
DKIM_HEADERS = [
@ -33,7 +32,6 @@ DKIM_HEADERS = [
SL_DIRECTION = "X-SimpleLogin-Type"
SL_EMAIL_LOG_ID = "X-SimpleLogin-EmailLog-ID"
SL_ENVELOPE_FROM = "X-SimpleLogin-Envelope-From"
SL_ORIGINAL_FROM = "X-SimpleLogin-Original-From"
SL_ENVELOPE_TO = "X-SimpleLogin-Envelope-To"
SL_CLIENT_IP = "X-SimpleLogin-Client-IP"

View file

@ -31,7 +31,11 @@ E402 = "421 SL E402 Encryption failed - Retry later"
# E403 = "421 SL E403 Retry later"
E404 = "421 SL E404 Unexpected error - Retry later"
E405 = "421 SL E405 Mailbox domain problem - Retry later"
E406 = "421 SL E406 Retry later"
E407 = "421 SL E407 Retry later"
E408 = "421 SL E408 Retry later"
E409 = "421 SL E409 Retry later"
E410 = "421 SL E410 Retry later"
# endregion
# region 5** errors
@ -60,5 +64,4 @@ E522 = (
)
E523 = "550 SL E523 Unknown error"
E524 = "550 SL E524 Wrong use of reverse-alias"
E525 = "550 SL E525 Alias loop"
# endregion

View file

@ -14,7 +14,7 @@ from email.header import decode_header, Header
from email.message import Message, EmailMessage
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.utils import make_msgid, formatdate, formataddr
from email.utils import make_msgid, formatdate
from smtplib import SMTP, SMTPException
from typing import Tuple, List, Optional, Union
@ -34,7 +34,30 @@ from flanker.addresslib.address import EmailAddress
from jinja2 import Environment, FileSystemLoader
from sqlalchemy import func
from app import config
from app.config import (
ROOT_DIR,
POSTFIX_SERVER,
DKIM_SELECTOR,
DKIM_PRIVATE_KEY,
ALIAS_DOMAINS,
POSTFIX_SUBMISSION_TLS,
MAX_NB_EMAIL_FREE_PLAN,
MAX_ALERT_24H,
POSTFIX_PORT,
URL,
LANDING_PAGE_URL,
EMAIL_DOMAIN,
ALERT_DIRECTORY_DISABLED_ALIAS_CREATION,
ALERT_SPF,
ALERT_INVALID_TOTP_LOGIN,
TEMP_DIR,
ALIAS_AUTOMATIC_DISABLE,
RSPAMD_SIGN_DKIM,
NOREPLY,
VERP_PREFIX,
VERP_MESSAGE_LIFETIME,
VERP_EMAIL_SECRET,
)
from app.db import Session
from app.dns_utils import get_mx_domains
from app.email import headers
@ -54,7 +77,6 @@ from app.models import (
IgnoreBounceSender,
InvalidMailboxDomain,
VerpType,
available_sl_email,
)
from app.utils import (
random_string,
@ -69,31 +91,31 @@ VERP_HMAC_ALGO = "sha3-224"
def render(template_name, **kwargs) -> str:
templates_dir = os.path.join(config.ROOT_DIR, "templates", "emails")
templates_dir = os.path.join(ROOT_DIR, "templates", "emails")
env = Environment(loader=FileSystemLoader(templates_dir))
template = env.get_template(template_name)
return template.render(
MAX_NB_EMAIL_FREE_PLAN=config.MAX_NB_EMAIL_FREE_PLAN,
URL=config.URL,
LANDING_PAGE_URL=config.LANDING_PAGE_URL,
MAX_NB_EMAIL_FREE_PLAN=MAX_NB_EMAIL_FREE_PLAN,
URL=URL,
LANDING_PAGE_URL=LANDING_PAGE_URL,
YEAR=arrow.now().year,
**kwargs,
)
def send_welcome_email(user):
comm_email, unsubscribe_link, via_email = user.get_communication_email()
if not comm_email:
to_email, unsubscribe_link, via_email = user.get_communication_email()
if not to_email:
return
# whether this email is sent to an alias
alias = comm_email if comm_email != user.email else None
alias = to_email if to_email != user.email else None
send_email(
comm_email,
"Welcome to SimpleLogin",
to_email,
f"Welcome to SimpleLogin",
render("com/welcome.txt", user=user, alias=alias),
render("com/welcome.html", user=user, alias=alias),
unsubscribe_link,
@ -104,7 +126,7 @@ def send_welcome_email(user):
def send_trial_end_soon_email(user):
send_email(
user.email,
"Your trial will end soon",
f"Your trial will end soon",
render("transactional/trial-end.txt.jinja2", user=user),
render("transactional/trial-end.html", user=user),
ignore_smtp_error=True,
@ -114,7 +136,7 @@ def send_trial_end_soon_email(user):
def send_activation_email(email, activation_link):
send_email(
email,
"Just one more step to join SimpleLogin",
f"Just one more step to join SimpleLogin",
render(
"transactional/activation.txt",
activation_link=activation_link,
@ -165,7 +187,7 @@ def send_change_email(new_email, current_email, link):
def send_invalid_totp_login_email(user, totp_type):
send_email_with_rate_control(
user,
config.ALERT_INVALID_TOTP_LOGIN,
ALERT_INVALID_TOTP_LOGIN,
user.email,
"Unsuccessful attempt to login to your SimpleLogin account",
render(
@ -223,7 +245,7 @@ def send_cannot_create_directory_alias_disabled(user, alias_address, directory_n
"""
send_email_with_rate_control(
user,
config.ALERT_DIRECTORY_DISABLED_ALIAS_CREATION,
ALERT_DIRECTORY_DISABLED_ALIAS_CREATION,
user.email,
f"Alias {alias_address} cannot be created",
render(
@ -275,9 +297,8 @@ def send_email(
LOG.d("send email to %s, subject '%s'", to_email, subject)
from_name = from_name or config.NOREPLY
from_addr = from_addr or config.NOREPLY
from_domain = get_email_domain_part(from_addr)
from_name = from_name or NOREPLY
from_addr = from_addr or NOREPLY
if html:
msg = MIMEMultipart("alternative")
@ -292,14 +313,13 @@ def send_email(
msg[headers.FROM] = f'"{from_name}" <{from_addr}>'
msg[headers.TO] = to_email
msg_id_header = make_msgid(domain=config.EMAIL_DOMAIN)
msg_id_header = make_msgid(domain=EMAIL_DOMAIN)
msg[headers.MESSAGE_ID] = msg_id_header
date_header = formatdate()
msg[headers.DATE] = date_header
if headers.MIME_VERSION not in msg:
msg[headers.MIME_VERSION] = "1.0"
msg[headers.MIME_VERSION] = "1.0"
if unsubscribe_link:
add_or_replace_header(msg, headers.LIST_UNSUBSCRIBE, f"<{unsubscribe_link}>")
@ -316,7 +336,7 @@ def send_email(
# use a different envelope sender for each transactional email (aka VERP)
sl_sendmail(
generate_verp_email(VerpType.transactional, transaction.id, from_domain),
generate_verp_email(VerpType.transactional, transaction.id),
to_email,
msg,
retries=retries,
@ -331,7 +351,7 @@ def send_email_with_rate_control(
subject,
plaintext,
html=None,
max_nb_alert=config.MAX_ALERT_24H,
max_nb_alert=MAX_ALERT_24H,
nb_day=1,
ignore_smtp_error=False,
retries=0,
@ -428,7 +448,7 @@ def get_email_domain_part(address):
def add_dkim_signature(msg: Message, email_domain: str):
if config.RSPAMD_SIGN_DKIM:
if RSPAMD_SIGN_DKIM:
LOG.d("DKIM signature will be added by rspamd")
msg[headers.SL_WANT_SIGNING] = "yes"
return
@ -443,9 +463,9 @@ def add_dkim_signature(msg: Message, email_domain: str):
continue
# To investigate why some emails can't be DKIM signed. todo: remove
if config.TEMP_DIR:
if TEMP_DIR:
file_name = str(uuid.uuid4()) + ".eml"
with open(os.path.join(config.TEMP_DIR, file_name), "wb") as f:
with open(os.path.join(TEMP_DIR, file_name), "wb") as f:
f.write(msg.as_bytes())
LOG.w("email saved to %s", file_name)
@ -460,12 +480,12 @@ def add_dkim_signature_with_header(
# Specify headers in "byte" form
# Generate message signature
if config.DKIM_PRIVATE_KEY:
if DKIM_PRIVATE_KEY:
sig = dkim.sign(
message_to_bytes(msg),
config.DKIM_SELECTOR,
DKIM_SELECTOR,
email_domain.encode(),
config.DKIM_PRIVATE_KEY.encode(),
DKIM_PRIVATE_KEY.encode(),
include_headers=dkim_headers,
)
sig = sig.decode()
@ -517,7 +537,7 @@ def delete_all_headers_except(msg: Message, headers: [str]):
def can_create_directory_for_address(email_address: str) -> bool:
"""return True if an email ends with one of the alias domains provided by SimpleLogin"""
# not allow creating directory with premium domain
for domain in config.ALIAS_DOMAINS:
for domain in ALIAS_DOMAINS:
if email_address.endswith("@" + domain):
return True
@ -574,7 +594,7 @@ def email_can_be_used_as_mailbox(email_address: str) -> bool:
mx_domains = get_mx_domain_list(domain)
# if no MX record, email is not valid
if not config.SKIP_MX_LOOKUP_ON_CHECK and not mx_domains:
if not mx_domains:
LOG.d("No MX record for domain %s", domain)
return False
@ -768,7 +788,7 @@ def get_header_unicode(header: Union[str, Header]) -> str:
ret = ""
for to_decoded_str, charset in decode_header(header):
if charset is None:
if isinstance(to_decoded_str, bytes):
if type(to_decoded_str) is bytes:
decoded_str = to_decoded_str.decode()
else:
decoded_str = to_decoded_str
@ -805,13 +825,13 @@ def to_bytes(msg: Message):
for generator_policy in [None, policy.SMTP, policy.SMTPUTF8]:
try:
return msg.as_bytes(policy=generator_policy)
except Exception:
except:
LOG.w("as_bytes() fails with %s policy", policy, exc_info=True)
msg_string = msg.as_string()
try:
return msg_string.encode()
except Exception:
except:
LOG.w("as_string().encode() fails", exc_info=True)
return msg_string.encode(errors="replace")
@ -828,6 +848,19 @@ def should_add_dkim_signature(domain: str) -> bool:
return False
def is_valid_email(email_address: str) -> bool:
"""
Used to check whether an email address is valid
NOT run MX check.
NOT allow unicode.
"""
try:
validate_email(email_address, check_deliverability=False, allow_smtputf8=False)
return True
except EmailNotValidError:
return False
class EmailEncoding(enum.Enum):
BASE64 = "base64"
QUOTED = "quoted-printable"
@ -841,24 +874,8 @@ def get_encoding(msg: Message) -> EmailEncoding:
- base64
- 7bit: default if unknown or empty
"""
cte = (
str(msg.get(headers.CONTENT_TRANSFER_ENCODING, ""))
.lower()
.strip()
.strip('"')
.strip("'")
)
if cte in (
"",
"7bit",
"7-bit",
"7bits",
"8bit",
"8bits",
"binary",
"8bit;",
"utf-8",
):
cte = str(msg.get(headers.CONTENT_TRANSFER_ENCODING, "")).lower().strip()
if cte in ("", "7bit", "7-bit", "8bit", "binary", "8bit;", "utf-8"):
return EmailEncoding.NO
if cte == "base64":
@ -898,25 +915,22 @@ def decode_text(text: str, encoding: EmailEncoding = EmailEncoding.NO) -> str:
return text
def add_header(msg: Message, text_header, html_header=None) -> Message:
if not html_header:
html_header = text_header.replace("\n", "<br>")
def add_header(msg: Message, text_header, html_header) -> Message:
content_type = msg.get_content_type().lower()
if content_type == "text/plain":
encoding = get_encoding(msg)
payload = msg.get_payload()
if isinstance(payload, str):
if type(payload) is str:
clone_msg = copy(msg)
new_payload = f"""{text_header}
------------------------------
---
{decode_text(payload, encoding)}"""
clone_msg.set_payload(encode_text(new_payload, encoding))
return clone_msg
elif content_type == "text/html":
encoding = get_encoding(msg)
payload = msg.get_payload()
if isinstance(payload, str):
if type(payload) is str:
new_payload = f"""<table width="100%" style="width: 100%; -premailer-width: 100%; -premailer-cellpadding: 0;
-premailer-cellspacing: 0; margin: 0; padding: 0;">
<tr>
@ -938,8 +952,6 @@ def add_header(msg: Message, text_header, html_header=None) -> Message:
for part in msg.get_payload():
if isinstance(part, Message):
new_parts.append(add_header(part, text_header, html_header))
elif isinstance(part, str):
new_parts.append(MIMEText(part))
else:
new_parts.append(part)
clone_msg = copy(msg)
@ -948,14 +960,7 @@ def add_header(msg: Message, text_header, html_header=None) -> Message:
elif content_type in ("multipart/mixed", "multipart/signed"):
new_parts = []
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)
parts = list(msg.get_payload())
LOG.d("only add header for the first part for %s", content_type)
for ix, part in enumerate(parts):
if ix == 0:
@ -971,11 +976,7 @@ def add_header(msg: Message, text_header, html_header=None) -> Message:
return msg
def replace(msg: Union[Message, str], old, new) -> Union[Message, str]:
if isinstance(msg, str):
msg = msg.replace(old, new)
return msg
def replace(msg: Message, old, new) -> Message:
content_type = msg.get_content_type()
if (
@ -995,7 +996,7 @@ def replace(msg: Union[Message, str], old, new) -> Union[Message, str]:
if content_type in ("text/plain", "text/html"):
encoding = get_encoding(msg)
payload = msg.get_payload()
if isinstance(payload, str):
if type(payload) is str:
if encoding == EmailEncoding.QUOTED:
LOG.d("handle quoted-printable replace %s -> %s", old, new)
# first decode the payload
@ -1040,7 +1041,7 @@ def replace(msg: Union[Message, str], old, new) -> Union[Message, str]:
return msg
def generate_reply_email(contact_email: str, alias: Alias) -> str:
def generate_reply_email(contact_email: str, user: User) -> str:
"""
generate a reply_email (aka reverse-alias), make sure it isn't used by any contact
"""
@ -1051,7 +1052,6 @@ def generate_reply_email(contact_email: str, alias: Alias) -> str:
include_sender_in_reverse_alias = False
user = alias.user
# user has set this option explicitly
if user.include_sender_in_reverse_alias is not None:
include_sender_in_reverse_alias = user.include_sender_in_reverse_alias
@ -1066,28 +1066,22 @@ def generate_reply_email(contact_email: str, alias: Alias) -> str:
contact_email = contact_email.replace(".", "_")
contact_email = convert_to_alphanumeric(contact_email)
reply_domain = config.EMAIL_DOMAIN
alias_domain = get_email_domain_part(alias.email)
sl_domain = SLDomain.get_by(domain=alias_domain)
if sl_domain and sl_domain.use_as_reverse_alias:
reply_domain = alias_domain
# not use while to avoid infinite loop
for _ in range(1000):
if include_sender_in_reverse_alias and contact_email:
random_length = random.randint(5, 10)
reply_email = (
# do not use the ra+ anymore
# f"ra+{contact_email}+{random_string(random_length)}@{config.EMAIL_DOMAIN}"
f"{contact_email}_{random_string(random_length)}@{reply_domain}"
# f"ra+{contact_email}+{random_string(random_length)}@{EMAIL_DOMAIN}"
f"{contact_email}_{random_string(random_length)}@{EMAIL_DOMAIN}"
)
else:
random_length = random.randint(20, 50)
# do not use the ra+ anymore
# reply_email = f"ra+{random_string(random_length)}@{config.EMAIL_DOMAIN}"
reply_email = f"{random_string(random_length)}@{reply_domain}"
# reply_email = f"ra+{random_string(random_length)}@{EMAIL_DOMAIN}"
reply_email = f"{random_string(random_length)}@{EMAIL_DOMAIN}"
if available_sl_email(reply_email):
if not Contact.get_by(reply_email=reply_email):
return reply_email
raise Exception("Cannot generate reply email")
@ -1098,11 +1092,31 @@ def is_reverse_alias(address: str) -> bool:
if Contact.get_by(reply_email=address):
return True
return address.endswith(f"@{config.EMAIL_DOMAIN}") and (
return address.endswith(f"@{EMAIL_DOMAIN}") and (
address.startswith("reply+") or address.startswith("ra+")
)
# allow also + and @ that are present in a reply address
_ALLOWED_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-.+@"
def normalize_reply_email(reply_email: str) -> str:
"""Handle the case where reply email contains *strange* char that was wrongly generated in the past"""
if not reply_email.isascii():
reply_email = convert_to_id(reply_email)
ret = []
# drop all control characters like shift, separator, etc
for c in reply_email:
if c not in _ALLOWED_CHARS:
ret.append("_")
else:
ret.append(c)
return "".join(ret)
def should_disable(alias: Alias) -> (bool, str):
"""
Return whether an alias should be disabled and if yes, the reason why
@ -1112,7 +1126,7 @@ def should_disable(alias: Alias) -> (bool, str):
LOG.w("%s cannot be disabled", alias)
return False, ""
if not config.ALIAS_AUTOMATIC_DISABLE:
if not ALIAS_AUTOMATIC_DISABLE:
return False, ""
yesterday = arrow.now().shift(days=-1)
@ -1227,14 +1241,14 @@ def spf_pass(
subject = get_header_unicode(msg[headers.SUBJECT])
send_email_with_rate_control(
user,
config.ALERT_SPF,
ALERT_SPF,
mailbox.email,
f"SimpleLogin Alert: attempt to send emails from your alias {alias.email} from unknown IP Address",
render(
"transactional/spf-fail.txt",
alias=alias.email,
ip=ip,
mailbox_url=config.URL + f"/dashboard/mailbox/{mailbox.id}#spf",
mailbox_url=URL + f"/dashboard/mailbox/{mailbox.id}#spf",
to_email=contact_email,
subject=subject,
time=arrow.now(),
@ -1242,7 +1256,7 @@ def spf_pass(
render(
"transactional/spf-fail.html",
ip=ip,
mailbox_url=config.URL + f"/dashboard/mailbox/{mailbox.id}#spf",
mailbox_url=URL + f"/dashboard/mailbox/{mailbox.id}#spf",
to_email=contact_email,
subject=subject,
time=arrow.now(),
@ -1265,11 +1279,11 @@ def spf_pass(
@cached(cache=TTLCache(maxsize=2, ttl=20))
def get_smtp_server():
LOG.d("get a smtp server")
if config.POSTFIX_SUBMISSION_TLS:
smtp = SMTP(config.POSTFIX_SERVER, 587)
if POSTFIX_SUBMISSION_TLS:
smtp = SMTP(POSTFIX_SERVER, 587)
smtp.starttls()
else:
smtp = SMTP(config.POSTFIX_SERVER, config.POSTFIX_PORT)
smtp = SMTP(POSTFIX_SERVER, POSTFIX_PORT)
return smtp
@ -1341,12 +1355,12 @@ def save_email_for_debugging(msg: Message, file_name_prefix=None) -> str:
"""Save email for debugging to temporary location
Return the file path
"""
if config.TEMP_DIR:
if TEMP_DIR:
file_name = str(uuid.uuid4()) + ".eml"
if file_name_prefix:
file_name = "{}-{}".format(file_name_prefix, file_name)
with open(os.path.join(config.TEMP_DIR, file_name), "wb") as f:
with open(os.path.join(TEMP_DIR, file_name), "wb") as f:
f.write(msg.as_bytes())
LOG.d("email saved to %s", file_name)
@ -1359,12 +1373,12 @@ def save_envelope_for_debugging(envelope: Envelope, file_name_prefix=None) -> st
"""Save envelope for debugging to temporary location
Return the file path
"""
if config.TEMP_DIR:
if TEMP_DIR:
file_name = str(uuid.uuid4()) + ".eml"
if file_name_prefix:
file_name = "{}-{}".format(file_name_prefix, file_name)
with open(os.path.join(config.TEMP_DIR, file_name), "wb") as f:
with open(os.path.join(TEMP_DIR, file_name), "wb") as f:
f.write(envelope.original_content)
LOG.d("envelope saved to %s", file_name)
@ -1390,15 +1404,12 @@ def generate_verp_email(
# Signing without itsdangereous because it uses base64 that includes +/= symbols and lower and upper case letters.
# We need to encode in base32
payload_hmac = hmac.new(
config.VERP_EMAIL_SECRET.encode("utf-8"), json_payload, VERP_HMAC_ALGO
VERP_EMAIL_SECRET.encode("utf-8"), json_payload, VERP_HMAC_ALGO
).digest()[:8]
encoded_payload = base64.b32encode(json_payload).rstrip(b"=").decode("utf-8")
encoded_signature = base64.b32encode(payload_hmac).rstrip(b"=").decode("utf-8")
return "{}.{}.{}@{}".format(
config.VERP_PREFIX,
encoded_payload,
encoded_signature,
sender_domain or config.EMAIL_DOMAIN,
VERP_PREFIX, encoded_payload, encoded_signature, sender_domain or EMAIL_DOMAIN
).lower()
@ -1411,7 +1422,7 @@ def get_verp_info_from_email(email: str) -> Optional[Tuple[VerpType, int]]:
return None
username = email[:idx]
fields = username.split(".")
if len(fields) != 3 or fields[0] != config.VERP_PREFIX:
if len(fields) != 3 or fields[0] != VERP_PREFIX:
return None
try:
padding = (8 - (len(fields[1]) % 8)) % 8
@ -1423,7 +1434,7 @@ def get_verp_info_from_email(email: str) -> Optional[Tuple[VerpType, int]]:
except binascii.Error:
return None
expected_signature = hmac.new(
config.VERP_EMAIL_SECRET.encode("utf-8"), payload, VERP_HMAC_ALGO
VERP_EMAIL_SECRET.encode("utf-8"), payload, VERP_HMAC_ALGO
).digest()[:8]
if expected_signature != signature:
return None
@ -1431,13 +1442,6 @@ def get_verp_info_from_email(email: str) -> Optional[Tuple[VerpType, int]]:
# verp type, object_id, time
if len(data) != 3:
return None
if data[2] > (time.time() + config.VERP_MESSAGE_LIFETIME - VERP_TIME_START) / 60:
if data[2] > (time.time() + VERP_MESSAGE_LIFETIME - VERP_TIME_START) / 60:
return None
return VerpType(data[0]), data[1]
def sl_formataddr(name_address_tuple: Tuple[str, str]):
"""Same as formataddr but use utf-8 encoding by default and always return str (and never Header)"""
name, addr = name_address_tuple
# formataddr can return Header, make sure to convert to str
return str(formataddr((name, Header(addr, "utf-8"))))

View file

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

View file

@ -71,7 +71,7 @@ class ErrContactErrorUpgradeNeeded(SLException):
"""raised when user cannot create a contact because the plan doesn't allow it"""
def error_for_user(self) -> str:
return "Please upgrade to premium to create reverse-alias"
return f"Please upgrade to premium to create reverse-alias"
class ErrAddressInvalid(SLException):
@ -84,14 +84,6 @@ class ErrAddressInvalid(SLException):
return f"{self.address} is not a valid email address"
class InvalidContactEmailError(SLException):
def __init__(self, website_email: str): # noqa: F821
self.website_email = website_email
def error_for_user(self) -> str:
return f"Cannot create contact with invalid email {self.website_email}"
class ErrContactAlreadyExists(SLException):
"""raised when a contact already exists"""
@ -116,15 +108,3 @@ class AccountAlreadyLinkedToAnotherPartnerException(LinkException):
class AccountAlreadyLinkedToAnotherUserException(LinkException):
def __init__(self):
super().__init__("This account is linked to another user")
class AccountIsUsingAliasAsEmail(LinkException):
def __init__(self):
super().__init__("Your account has an alias as it's email address")
class ProtonAccountNotVerified(LinkException):
def __init__(self):
super().__init__(
"The Proton account you are trying to use has not been verified"
)

View file

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

View file

@ -1,31 +1,12 @@
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
from flask_login import current_user, LoginManager
from app import config
from flask_login import LoginManager
login_manager = LoginManager()
login_manager.session_protection = "strong"
# We want to rate limit based on:
# - If the user is not logged in: request source IP
# - If the user is logged in: user_id
def __key_func():
if current_user.is_authenticated:
return f"userid:{current_user.id}"
else:
ip_addr = get_remote_address()
return f"ip:{ip_addr}"
# Setup rate limit facility
limiter = Limiter(key_func=__key_func)
@limiter.request_filter
def disable_rate_limit():
return config.DISABLE_RATE_LIMIT
limiter = Limiter(key_func=get_remote_address)
# @limiter.request_filter

View file

@ -5,7 +5,7 @@ from typing import Optional, Tuple
from aiosmtpd.handlers import Message
from aiosmtpd.smtp import Envelope
from app import s3, config
from app import s3
from app.config import (
DMARC_CHECK_ENABLED,
ALERT_QUARANTINE_DMARC,
@ -34,37 +34,6 @@ def apply_dmarc_policy_for_forward_phase(
from_header = get_header_unicode(msg[headers.FROM])
warning_plain_text = """This email failed anti-phishing checks when it was received by SimpleLogin, be careful with its content.
More info on https://simplelogin.io/docs/getting-started/anti-phishing/
"""
warning_html = """
<p style="color:red">
This email failed anti-phishing checks when it was received by SimpleLogin, be careful with its content.
More info on <a href="https://simplelogin.io/docs/getting-started/anti-phishing/">anti-phishing measure</a>
</p>
"""
# do not quarantine an email if fails DMARC but has a small rspamd score
if (
config.MIN_RSPAMD_SCORE_FOR_FAILED_DMARC is not None
and spam_result.rspamd_score < config.MIN_RSPAMD_SCORE_FOR_FAILED_DMARC
and spam_result.dmarc
in (
DmarcCheckResult.quarantine,
DmarcCheckResult.reject,
)
):
LOG.w(
f"email fails DMARC but has a small rspamd score, from contact {contact.email} to alias {alias.email}."
f"mail_from:{envelope.mail_from}, from_header: {from_header}"
)
changed_msg = add_header(
msg,
warning_plain_text,
warning_html,
)
return changed_msg, None
if spam_result.dmarc == DmarcCheckResult.soft_fail:
LOG.w(
f"dmarc forward: soft_fail from contact {contact.email} to alias {alias.email}."
@ -72,8 +41,15 @@ More info on https://simplelogin.io/docs/getting-started/anti-phishing/
)
changed_msg = add_header(
msg,
warning_plain_text,
warning_html,
f"""This email failed anti-phishing checks when it was received by SimpleLogin, be careful with its content.
More info on https://simplelogin.io/docs/getting-started/anti-phishing/
""",
f"""
<p style="color:red">
This email failed anti-phishing checks when it was received by SimpleLogin, be careful with its content.
More info on <a href="https://simplelogin.io/docs/getting-started/anti-phishing/">anti-phishing measure</a>
</p>
""",
)
return changed_msg, None
@ -157,7 +133,6 @@ def apply_dmarc_policy_for_reply_phase(
DmarcCheckResult.soft_fail,
):
return None
LOG.w(
f"dmarc reply: Put email from {alias_from.email} to {contact_recipient} into quarantine. {spam_result.event_data()}, "
f"mail_from:{envelope.mail_from}, from_header: {msg[headers.FROM]}"

View file

@ -3,7 +3,7 @@ from abc import ABC, abstractmethod
from dataclasses import dataclass
from io import BytesIO
from mailbox import Message
from typing import Optional, Union
from typing import Optional
from app import s3
from app.config import (
@ -189,7 +189,7 @@ def handle_yahoo_complaint(message: Message) -> bool:
return handle_complaint(message, ProviderComplaintYahoo())
def find_alias_with_address(address: str) -> Optional[Union[Alias, DomainDeletedAlias]]:
def find_alias_with_address(address: str) -> Optional[Alias]:
return Alias.get_by(email=address) or DomainDeletedAlias.get_by(email=address)
@ -221,7 +221,7 @@ def handle_complaint(message: Message, origin: ProviderComplaintOrigin) -> bool:
return True
if is_deleted_alias(msg_info.sender_address):
LOG.i("Complaint is for deleted alias. Do nothing")
LOG.i(f"Complaint is for deleted alias. Do nothing")
return True
contact = Contact.get_by(reply_email=msg_info.sender_address)
@ -231,7 +231,7 @@ def handle_complaint(message: Message, origin: ProviderComplaintOrigin) -> bool:
alias = find_alias_with_address(msg_info.rcpt_address)
if is_deleted_alias(msg_info.rcpt_address):
LOG.i("Complaint is for deleted alias. Do nothing")
LOG.i(f"Complaint is for deleted alias. Do nothing")
return True
if not alias:
@ -245,22 +245,16 @@ def handle_complaint(message: Message, origin: ProviderComplaintOrigin) -> bool:
def report_complaint_to_user_in_reply_phase(
alias: Union[Alias, DomainDeletedAlias],
alias: Alias,
to_address: str,
origin: ProviderComplaintOrigin,
msg_info: OriginalMessageInformation,
):
capitalized_name = origin.name().capitalize()
mailbox_email = msg_info.mailbox_address
if not mailbox_email:
if type(alias) is Alias:
mailbox_email = alias.mailbox.email
else:
mailbox_email = alias.domain.mailboxes[0].email
send_email_with_rate_control(
alias.user,
f"{ALERT_COMPLAINT_REPLY_PHASE}_{origin.name()}",
mailbox_email,
msg_info.mailbox_address or alias.mailbox.email,
f"Abuse report from {capitalized_name}",
render(
"transactional/provider-complaint-reply-phase.txt.jinja2",
@ -299,19 +293,11 @@ def report_complaint_to_user_in_transactional_phase(
def report_complaint_to_user_in_forward_phase(
alias: Union[Alias, DomainDeletedAlias],
origin: ProviderComplaintOrigin,
msg_info: OriginalMessageInformation,
alias: Alias, origin: ProviderComplaintOrigin, msg_info: OriginalMessageInformation
):
capitalized_name = origin.name().capitalize()
user = alias.user
mailbox_email = msg_info.mailbox_address
if not mailbox_email:
if type(alias) is Alias:
mailbox_email = alias.mailbox.email
else:
mailbox_email = alias.domain.mailboxes[0].email
mailbox_email = msg_info.mailbox_address or alias.mailbox.email
send_email_with_rate_control(
user,
f"{ALERT_COMPLAINT_FORWARD_PHASE}_{origin.name()}",

View file

@ -4,7 +4,6 @@ from typing import Dict, Optional
import newrelic.agent
from app.email import headers
from app.log import LOG
from app.models import EnumE, Phase
from email.message import Message
@ -56,7 +55,6 @@ class SpamdResult:
self.phase: Phase = phase
self.dmarc: DmarcCheckResult = DmarcCheckResult.not_available
self.spf: SPFCheckResult = SPFCheckResult.not_available
self.rspamd_score = -1
def set_dmarc_result(self, dmarc_result: DmarcCheckResult):
self.dmarc = dmarc_result
@ -87,7 +85,6 @@ class SpamdResult:
spam_entries = [
entry.strip() for entry in str(spam_result_header[-1]).split("\n")
]
for entry_pos in range(len(spam_entries)):
sep = spam_entries[entry_pos].find("(")
if sep > -1:
@ -104,17 +101,6 @@ class SpamdResult:
spamd_result.set_spf_result(spf_result)
break
# parse the rspamd score
try:
score_line = spam_entries[0] # e.g. "default: False [2.30 / 13.00];"
spamd_result.rspamd_score = float(
score_line[(score_line.find("[") + 1) : score_line.find("]")]
.split("/")[0]
.strip()
)
except (IndexError, ValueError):
LOG.e("cannot parse rspamd score")
cls._store_in_message(spamd_result, msg)
return spamd_result

View file

@ -1,150 +0,0 @@
import base64
import enum
import hashlib
import json
from dataclasses import dataclass
from typing import Optional, Union
import itsdangerous
from app import config
from app.log import LOG
UNSUB_PREFIX = "un"
class UnsubscribeAction(enum.Enum):
UnsubscribeNewsletter = 1
DisableAlias = 2
DisableContact = 3
OriginalUnsubscribeMailto = 4
@dataclass
class UnsubscribeOriginalData:
alias_id: int
recipient: str
subject: str
@dataclass
class UnsubscribeData:
action: UnsubscribeAction
data: Union[UnsubscribeOriginalData, int]
@dataclass
class UnsubscribeLink:
link: str
via_email: bool
class UnsubscribeEncoder:
@staticmethod
def encode(
action: UnsubscribeAction,
data: Union[int, UnsubscribeOriginalData],
force_web: bool = False,
) -> UnsubscribeLink:
if config.UNSUBSCRIBER and not force_web:
return UnsubscribeLink(UnsubscribeEncoder.encode_mailto(action, data), True)
return UnsubscribeLink(UnsubscribeEncoder.encode_url(action, data), False)
@classmethod
def encode_subject(
cls, action: UnsubscribeAction, data: Union[int, UnsubscribeOriginalData]
) -> str:
if action != UnsubscribeAction.OriginalUnsubscribeMailto and not isinstance(
data, int
):
raise ValueError(f"Data has to be an int for an action of type {action}")
if action == UnsubscribeAction.OriginalUnsubscribeMailto:
if type(data) is not UnsubscribeOriginalData:
raise ValueError(
f"Data has to be an UnsubscribeOriginalData for an action of type {action}"
)
# Initial 0 is the version number. If we need to add support for extra use-cases we can bump up this number
data = (0, data.alias_id, data.recipient, data.subject)
payload = (action.value, data)
serialized_data = (
base64.urlsafe_b64encode(json.dumps(payload).encode("utf-8"))
.rstrip(b"=")
.decode("utf-8")
)
signed_data = cls._get_signer().sign(serialized_data).decode("utf-8")
encoded_request = f"{UNSUB_PREFIX}.{signed_data}"
if len(encoded_request) > 512:
LOG.w("Encoded request is longer than 512 chars")
return encoded_request
@staticmethod
def encode_mailto(
action: UnsubscribeAction, data: Union[int, UnsubscribeOriginalData]
) -> str:
subject = UnsubscribeEncoder.encode_subject(action, data)
return f"mailto:{config.UNSUBSCRIBER}?subject={subject}"
@staticmethod
def encode_url(
action: UnsubscribeAction, data: Union[int, UnsubscribeOriginalData]
) -> str:
if action == UnsubscribeAction.DisableAlias:
return f"{config.URL}/dashboard/unsubscribe/{data}"
if action == UnsubscribeAction.DisableContact:
return f"{config.URL}/dashboard/block_contact/{data}"
if action in (
UnsubscribeAction.UnsubscribeNewsletter,
UnsubscribeAction.OriginalUnsubscribeMailto,
):
encoded = UnsubscribeEncoder.encode_subject(action, data)
return f"{config.URL}/dashboard/unsubscribe/encoded?data={encoded}"
@staticmethod
def _get_signer() -> itsdangerous.Signer:
return itsdangerous.Signer(
config.UNSUBSCRIBE_SECRET, digest_method=hashlib.sha3_224
)
@classmethod
def decode_subject(cls, data: str) -> Optional[UnsubscribeData]:
if data.find(UNSUB_PREFIX) == -1:
try:
# subject has the format {alias.id}=
if data.endswith("="):
alias_id = int(data[:-1])
return UnsubscribeData(UnsubscribeAction.DisableAlias, alias_id)
# {contact.id}_
elif data.endswith("_"):
contact_id = int(data[:-1])
return UnsubscribeData(UnsubscribeAction.DisableContact, contact_id)
# {user.id}*
elif data.endswith("*"):
user_id = int(data[:-1])
return UnsubscribeData(
UnsubscribeAction.UnsubscribeNewsletter, user_id
)
else:
# some email providers might strip off the = suffix
alias_id = int(data)
return UnsubscribeData(UnsubscribeAction.DisableAlias, alias_id)
except ValueError:
return None
signer = cls._get_signer()
try:
verified_data = signer.unsign(data[len(UNSUB_PREFIX) + 1 :])
except itsdangerous.BadSignature:
return None
try:
padded_data = verified_data + (b"=" * (-len(verified_data) % 4))
payload = json.loads(base64.urlsafe_b64decode(padded_data))
except ValueError:
return None
action = UnsubscribeAction(payload[0])
action_data = payload[1]
if action == UnsubscribeAction.OriginalUnsubscribeMailto:
# Skip version number in action_data[0] for now it's always 0
action_data = UnsubscribeOriginalData(
action_data[1], action_data[2], action_data[3]
)
return UnsubscribeData(action, action_data)

View file

@ -1,106 +0,0 @@
import urllib
from email.header import Header
from email.message import Message
from app.email import headers
from app.email_utils import add_or_replace_header, delete_header
from app.handler.unsubscribe_encoder import (
UnsubscribeEncoder,
UnsubscribeAction,
UnsubscribeData,
UnsubscribeOriginalData,
)
from app.log import LOG
from app.models import Alias, Contact, UnsubscribeBehaviourEnum
class UnsubscribeGenerator:
def _generate_header_with_original_behaviour(
self, alias: Alias, message: Message
) -> Message:
"""
Generate a header that will encode the original unsub request. To do so
1. Look if there's an original List_Unsubscribe headers, otherwise do nothing
2. Header has the form <method1>, <method2>, .. where each method is either
- mailto:s@b.c?subject=something
- http(s)://somewhere.com
3. Check if there are http unsub requests in the header. If there are, reserve them and remove all mailto
methods to avoid leaking the real mailbox. We forward the message with only http(s) methods.
4. If there aren't neither https nor mailto methods, strip the header from the message and that's it.
It could happen if the header is malformed.
5. Encode in our unsub request the first original mail and subject to unsub, and use that as our unsub header.
"""
unsubscribe_data = message[headers.LIST_UNSUBSCRIBE]
if not unsubscribe_data:
LOG.info("Email has no unsubscribe header")
return message
if isinstance(unsubscribe_data, Header):
unsubscribe_data = str(unsubscribe_data.encode())
raw_methods = [method.strip() for method in unsubscribe_data.split(",")]
mailto_unsubs = None
other_unsubs = []
for raw_method in raw_methods:
start = raw_method.find("<")
end = raw_method.rfind(">")
if start == -1 or end == -1 or start >= end:
continue
method = raw_method[start + 1 : end]
url_data = urllib.parse.urlparse(method)
if url_data.scheme == "mailto":
query_data = urllib.parse.parse_qs(url_data.query)
mailto_unsubs = (url_data.path, query_data.get("subject", [""])[0])
LOG.debug(f"Unsub is mailto to {mailto_unsubs}")
else:
LOG.debug(f"Unsub has {url_data.scheme} scheme")
other_unsubs.append(method)
# If there are non mailto unsubscribe methods, use those in the header
if other_unsubs:
add_or_replace_header(
message,
headers.LIST_UNSUBSCRIBE,
", ".join([f"<{method}>" for method in other_unsubs]),
)
add_or_replace_header(
message, headers.LIST_UNSUBSCRIBE_POST, "List-Unsubscribe=One-Click"
)
LOG.debug(f"Adding click unsub methods to header {other_unsubs}")
return message
elif not mailto_unsubs:
LOG.debug("No unsubs. Deleting all unsub headers")
delete_header(message, headers.LIST_UNSUBSCRIBE)
delete_header(message, headers.LIST_UNSUBSCRIBE_POST)
return message
unsub_data = UnsubscribeData(
UnsubscribeAction.OriginalUnsubscribeMailto,
UnsubscribeOriginalData(alias.id, mailto_unsubs[0], mailto_unsubs[1]),
)
LOG.debug(f"Adding unsub data {unsub_data}")
return self._add_unsubscribe_header(message, unsub_data)
def _add_unsubscribe_header(
self, message: Message, unsub: UnsubscribeData
) -> Message:
unsub_link = UnsubscribeEncoder.encode(unsub.action, unsub.data)
add_or_replace_header(message, headers.LIST_UNSUBSCRIBE, f"<{unsub_link.link}>")
if not unsub_link.via_email:
add_or_replace_header(
message, headers.LIST_UNSUBSCRIBE_POST, "List-Unsubscribe=One-Click"
)
return message
def add_header_to_message(
self, alias: Alias, contact: Contact, message: Message
) -> Message:
"""
Add List-Unsubscribe header based on the user preference.
"""
unsub_behaviour = alias.user.unsub_behaviour
if unsub_behaviour == UnsubscribeBehaviourEnum.PreserveOriginal:
return self._generate_header_with_original_behaviour(alias, message)
elif unsub_behaviour == UnsubscribeBehaviourEnum.DisableAlias:
unsub = UnsubscribeData(UnsubscribeAction.DisableAlias, alias.id)
return self._add_unsubscribe_header(message, unsub)
else:
unsub = UnsubscribeData(UnsubscribeAction.DisableContact, contact.id)
return self._add_unsubscribe_header(message, unsub)

View file

@ -1,250 +0,0 @@
from email.message import Message, EmailMessage
from email.utils import make_msgid, formatdate
from typing import Optional
from aiosmtpd.smtp import Envelope
from app import config
from app.db import Session
from app.email import headers, status
from app.email_utils import (
send_email,
render,
get_email_domain_part,
add_dkim_signature,
generate_verp_email,
)
from app.handler.unsubscribe_encoder import (
UnsubscribeData,
UnsubscribeEncoder,
UnsubscribeAction,
UnsubscribeOriginalData,
)
from app.log import LOG
from app.mail_sender import sl_sendmail
from app.models import (
Alias,
Contact,
User,
Mailbox,
TransactionalEmail,
VerpType,
)
from app.utils import sanitize_email
class UnsubscribeHandler:
def _extract_unsub_info_from_message(
self, message: Message
) -> Optional[UnsubscribeData]:
header_value = message[headers.SUBJECT]
if not header_value:
return None
return UnsubscribeEncoder.decode_subject(header_value)
def handle_unsubscribe_from_message(self, envelope: Envelope, msg: Message) -> str:
unsub_data = self._extract_unsub_info_from_message(msg)
if not unsub_data:
LOG.w("Wrong format subject %s", msg[headers.SUBJECT])
return status.E507
mailbox = Mailbox.get_by(email=envelope.mail_from)
if not mailbox:
LOG.w("Unknown mailbox %s", envelope.mail_from)
return status.E507
if unsub_data.action == UnsubscribeAction.DisableAlias:
return self._disable_alias(unsub_data.data, mailbox.user, mailbox)
elif unsub_data.action == UnsubscribeAction.DisableContact:
return self._disable_contact(unsub_data.data, mailbox.user, mailbox)
elif unsub_data.action == UnsubscribeAction.UnsubscribeNewsletter:
return self._unsubscribe_user_from_newsletter(unsub_data.data, mailbox.user)
elif unsub_data.action == UnsubscribeAction.OriginalUnsubscribeMailto:
return self._unsubscribe_original_behaviour(unsub_data.data, mailbox.user)
else:
raise Exception(f"Unknown unsubscribe action {unsub_data.action}")
def handle_unsubscribe_from_request(
self, user: User, unsub_request: str
) -> Optional[UnsubscribeData]:
unsub_data = UnsubscribeEncoder.decode_subject(unsub_request)
if not unsub_data:
LOG.w("Wrong request %s", unsub_request)
return None
if unsub_data.action == UnsubscribeAction.DisableAlias:
response_code = self._disable_alias(unsub_data.data, user)
elif unsub_data.action == UnsubscribeAction.DisableContact:
response_code = self._disable_contact(unsub_data.data, user)
elif unsub_data.action == UnsubscribeAction.UnsubscribeNewsletter:
response_code = self._unsubscribe_user_from_newsletter(
unsub_data.data, user
)
elif unsub_data.action == UnsubscribeAction.OriginalUnsubscribeMailto:
response_code = self._unsubscribe_original_behaviour(unsub_data.data, user)
else:
raise Exception(f"Unknown unsubscribe action {unsub_data.action}")
if response_code == status.E202:
return unsub_data
return None
def _disable_alias(
self, alias_id: int, user: User, mailbox: Optional[Mailbox] = None
) -> str:
alias = Alias.get(alias_id)
if not alias:
return status.E508
if alias.user_id != user.id:
LOG.w("Alias doesn't belong to user")
return status.E508
# Only alias's owning mailbox can send the unsubscribe request
if mailbox and not self._check_email_is_authorized_for_alias(
mailbox.email, alias
):
return status.E509
alias.enabled = False
Session.commit()
enable_alias_url = config.URL + f"/dashboard/?highlight_alias_id={alias.id}"
for mailbox in alias.mailboxes:
send_email(
mailbox.email,
f"Alias {alias.email} has been disabled successfully",
render(
"transactional/unsubscribe-disable-alias.txt",
user=alias.user,
alias=alias.email,
enable_alias_url=enable_alias_url,
),
render(
"transactional/unsubscribe-disable-alias.html",
user=alias.user,
alias=alias.email,
enable_alias_url=enable_alias_url,
),
)
return status.E202
def _disable_contact(
self, contact_id: int, user: User, mailbox: Optional[Mailbox] = None
) -> str:
contact = Contact.get(contact_id)
if not contact:
return status.E508
if contact.user_id != user.id:
LOG.w("Contact doesn't belong to user")
return status.E508
# Only contact's owning mailbox can send the unsubscribe request
if mailbox and not self._check_email_is_authorized_for_alias(
mailbox.email, contact.alias
):
return status.E509
alias = contact.alias
contact.block_forward = True
Session.commit()
unblock_contact_url = (
config.URL
+ f"/dashboard/alias_contact_manager/{alias.id}?highlight_contact_id={contact.id}"
)
for mailbox in alias.mailboxes:
send_email(
mailbox.email,
f"Emails from {contact.website_email} to {alias.email} are now blocked",
render(
"transactional/unsubscribe-block-contact.txt.jinja2",
user=alias.user,
alias=alias,
contact=contact,
unblock_contact_url=unblock_contact_url,
),
)
return status.E202
def _unsubscribe_user_from_newsletter(
self, user_id: int, request_user: User
) -> str:
"""return the SMTP status"""
user = User.get(user_id)
if not user:
LOG.w("No such user %s", user_id)
return status.E510
if user.id != request_user.id:
LOG.w("Unauthorized unsubscribe user from", request_user)
return status.E511
user.notification = False
Session.commit()
send_email(
user.email,
"You have been unsubscribed from SimpleLogin newsletter",
render(
"transactional/unsubscribe-newsletter.txt",
user=user,
),
render(
"transactional/unsubscribe-newsletter.html",
user=user,
),
)
return status.E202
def _check_email_is_authorized_for_alias(
self, email_address: str, alias: Alias
) -> bool:
"""return if the email_address is authorized to unsubscribe from an alias or block a contact
Usually the mail_from=mailbox.email but it can also be one of the authorized address
"""
for mailbox in alias.mailboxes:
if mailbox.email == email_address:
return True
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 True
LOG.d(
"%s cannot disable alias %s. Alias authorized addresses:%s",
email_address,
alias,
alias.authorized_addresses,
)
return False
def _unsubscribe_original_behaviour(
self, original_unsub_data: UnsubscribeOriginalData, user: User
) -> str:
alias = Alias.get(original_unsub_data.alias_id)
if not alias:
return status.E508
if alias.user_id != user.id:
return status.E509
email_domain = get_email_domain_part(alias.email)
to_email = sanitize_email(original_unsub_data.recipient)
msg = EmailMessage()
msg[headers.TO] = to_email
msg[headers.SUBJECT] = original_unsub_data.subject
msg[headers.FROM] = alias.email
msg[headers.MESSAGE_ID] = make_msgid(domain=email_domain)
msg[headers.DATE] = formatdate()
msg[headers.CONTENT_TYPE] = "text/plain"
msg[headers.MIME_VERSION] = "1.0"
msg.set_payload("")
add_dkim_signature(msg, email_domain)
transaction = TransactionalEmail.create(email=to_email, commit=True)
sl_sendmail(
generate_verp_email(
VerpType.transactional, transaction.id, sender_domain=email_domain
),
to_email,
msg,
retries=3,
ignore_smtp_error=True,
)
return status.E202

View file

@ -1,25 +0,0 @@
from enum import Enum
class ImageFormat(Enum):
Png = 1
Jpg = 2
Webp = 3
Unknown = 9
magic_numbers = {
ImageFormat.Png: bytes([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]),
ImageFormat.Jpg: bytes([0xFF, 0xD8, 0xFF, 0xE0]),
ImageFormat.Webp: bytes([0x52, 0x49, 0x46, 0x46]),
}
def detect_image_format(image: bytes) -> ImageFormat:
# Detect image based on magic number
for fmt, header in magic_numbers.items():
if image.startswith(header):
return fmt
# We don't know the type
return ImageFormat.Unknown

View file

@ -15,7 +15,7 @@ from app.models import (
Mailbox,
User,
)
from app.utils import sanitize_email, canonicalize_email
from app.utils import sanitize_email
from .log import LOG
@ -30,7 +30,7 @@ def handle_batch_import(batch_import: BatchImport):
LOG.d("Download file %s from %s", batch_import.file, file_url)
r = requests.get(file_url)
lines = [line.decode("utf-8") for line in r.iter_lines()]
lines = [line.decode() for line in r.iter_lines()]
import_from_csv(batch_import, user, lines)
@ -69,7 +69,7 @@ def import_from_csv(batch_import: BatchImport, user: User, lines):
if "mailboxes" in row:
for mailbox_email in row["mailboxes"].split():
mailbox_email = canonicalize_email(mailbox_email)
mailbox_email = sanitize_email(mailbox_email)
mailbox = Mailbox.get_by(email=mailbox_email)
if not mailbox or not mailbox.verified or mailbox.user_id != user.id:

View file

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

View file

@ -1,8 +0,0 @@
from flask import Blueprint
internal_bp = Blueprint(
name="internal",
import_name=__name__,
url_prefix="/internal",
template_folder="templates",
)

View file

@ -1,10 +0,0 @@
from flask import session, redirect, url_for, flash
from app.internal.base import internal_bp
@internal_bp.route("/exit-sudo-mode")
def exit_sudo_mode():
session["sudo_time"] = 0
flash("Exited sudo mode", "info")
return redirect(url_for("dashboard.index"))

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