Compare commits

...

19 Commits

Author SHA1 Message Date
a5801551d0 4.65.1
Some checks failed
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 20m30s
Build-Release-Image / Build-Image (linux/amd64) (push) Has been cancelled
Build-Release-Image / Merge-Images (push) Has been cancelled
Build-Release-Image / Create-Release (push) Has been cancelled
Build-Release-Image / Notify (push) Has been cancelled
2025-02-04 12:00:06 +00:00
9c2a35193c 4.64.4
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 2m50s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 20m44s
Build-Release-Image / Merge-Images (push) Successful in 26s
Build-Release-Image / Create-Release (push) Successful in 12s
Build-Release-Image / Notify (push) Successful in 17s
2025-01-28 12:00:06 +00:00
e47e5a5255 4.64.3
Some checks failed
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m8s
Build-Release-Image / Build-Image (linux/arm64) (push) Failing after 15m37s
Build-Release-Image / Merge-Images (push) Has been skipped
Build-Release-Image / Create-Release (push) Has been skipped
Build-Release-Image / Notify (push) Has been skipped
2025-01-27 12:00:07 +00:00
ed37325b32 4.64.1
Some checks failed
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 2m58s
Build-Release-Image / Build-Image (linux/arm64) (push) Failing after 17m22s
Build-Release-Image / Merge-Images (push) Has been skipped
Build-Release-Image / Create-Release (push) Has been skipped
Build-Release-Image / Notify (push) Has been skipped
2025-01-24 12:00:07 +00:00
dd6005ffdf 4.64.0
Some checks failed
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m25s
Build-Release-Image / Build-Image (linux/arm64) (push) Failing after 14m51s
Build-Release-Image / Merge-Images (push) Has been skipped
Build-Release-Image / Create-Release (push) Has been skipped
Build-Release-Image / Notify (push) Has been skipped
2025-01-21 12:00:08 +00:00
664cd32f81 4.63.0
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m54s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 23m12s
Build-Release-Image / Merge-Images (push) Successful in 46s
Build-Release-Image / Create-Release (push) Successful in 9s
Build-Release-Image / Notify (push) Successful in 3s
2025-01-20 12:00:06 +00:00
33f0eb6c41 4.62.0
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 4m44s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 5m31s
Build-Release-Image / Merge-Images (push) Successful in 46s
Build-Release-Image / Create-Release (push) Successful in 14s
Build-Release-Image / Notify (push) Successful in 2s
2024-12-20 12:00:08 +00:00
9fd2fa9a78 4.61.1
All checks were successful
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m41s
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 4m6s
Build-Release-Image / Merge-Images (push) Successful in 18s
Build-Release-Image / Create-Release (push) Successful in 11s
Build-Release-Image / Notify (push) Successful in 3s
2024-11-30 12:00:10 +00:00
3c77f8af4b 4.61.0
All checks were successful
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 4m9s
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 4m14s
Build-Release-Image / Merge-Images (push) Successful in 47s
Build-Release-Image / Create-Release (push) Successful in 16s
Build-Release-Image / Notify (push) Successful in 3s
2024-11-29 12:00:12 +00:00
545eeda79b 4.59.5
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m2s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m43s
Build-Release-Image / Merge-Images (push) Successful in 49s
Build-Release-Image / Create-Release (push) Successful in 21s
Build-Release-Image / Notify (push) Successful in 8s
2024-11-18 12:00:06 +00:00
01dba12ed0 4.59.3
All checks were successful
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m43s
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m57s
Build-Release-Image / Merge-Images (push) Successful in 53s
Build-Release-Image / Create-Release (push) Successful in 8s
Build-Release-Image / Notify (push) Successful in 3s
2024-11-16 12:00:07 +00:00
c872d43c3d 4.59.2
All checks were successful
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 4m7s
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 4m46s
Build-Release-Image / Merge-Images (push) Successful in 14s
Build-Release-Image / Create-Release (push) Successful in 9s
Build-Release-Image / Notify (push) Successful in 5s
2024-11-14 12:00:07 +00:00
3e6867bc17 4.58
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m7s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m49s
Build-Release-Image / Merge-Images (push) Successful in 15s
Build-Release-Image / Create-Release (push) Successful in 8s
Build-Release-Image / Notify (push) Successful in 3s
2024-11-07 12:00:06 +00:00
a829074584 4.57.2
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m6s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m48s
Build-Release-Image / Merge-Images (push) Successful in 20s
Build-Release-Image / Create-Release (push) Successful in 11s
Build-Release-Image / Notify (push) Successful in 2s
2024-11-06 12:00:08 +00:00
25834e8f61 4.56.3
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m15s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m45s
Build-Release-Image / Merge-Images (push) Successful in 15s
Build-Release-Image / Create-Release (push) Successful in 10s
Build-Release-Image / Notify (push) Successful in 21s
2024-11-05 12:00:07 +00:00
a62b43b7c4 4.56.1
All checks were successful
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m30s
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m37s
Build-Release-Image / Merge-Images (push) Successful in 22s
Build-Release-Image / Create-Release (push) Successful in 24s
Build-Release-Image / Notify (push) Successful in 3s
2024-10-25 12:00:05 +01:00
44fda2d94e 4.56.0
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m24s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m34s
Build-Release-Image / Merge-Images (push) Successful in 14s
Build-Release-Image / Create-Release (push) Successful in 9s
Build-Release-Image / Notify (push) Successful in 3s
2024-10-24 12:00:05 +01:00
bc48198bb1 4.55.1
All checks were successful
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 3m28s
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m31s
Build-Release-Image / Merge-Images (push) Successful in 16s
Build-Release-Image / Create-Release (push) Successful in 9s
Build-Release-Image / Notify (push) Successful in 4s
2024-10-19 12:00:05 +01:00
da6e56c4eb 4.55.0
All checks were successful
Build-Release-Image / Build-Image (linux/amd64) (push) Successful in 3m43s
Build-Release-Image / Build-Image (linux/arm64) (push) Successful in 4m10s
Build-Release-Image / Merge-Images (push) Successful in 27s
Build-Release-Image / Create-Release (push) Successful in 10s
Build-Release-Image / Notify (push) Successful in 3s
2024-10-18 12:00:06 +01:00
138 changed files with 8789 additions and 1718 deletions

View File

@ -1,6 +1,12 @@
name: Test and lint
name: SimpleLogin actions
on: [push, pull_request]
on:
push:
branches:
- master
tags:
- v*
pull_request:
jobs:
lint:
@ -9,35 +15,34 @@ jobs:
- name: Check out repo
uses: actions/checkout@v3
- name: Install poetry
run: pipx install poetry
- uses: actions/setup-python@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
python-version: '3.10'
cache: 'poetry'
# Install a specific version of uv.
version: "0.5.21"
enable-cache: true
- name: Install OS dependencies
if: ${{ matrix.python-version }} == '3.10'
run: |
sudo apt update
sudo apt install -y libre2-dev libpq-dev
- name: "Set up Python"
uses: actions/setup-python@v5
with:
python-version-file: "pyproject.toml"
- name: Install dependencies
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
run: poetry install --no-interaction
if: steps.setup-uv.outputs.cache-hit != 'true'
run: uv sync --locked --all-extras
- name: Check formatting & linting
run: |
poetry run pre-commit run --all-files
uv run pre-commit run --all-files
test:
runs-on: ubuntu-latest
strategy:
max-parallel: 4
matrix:
python-version: ["3.10"]
# service containers to run with `postgres-job`
services:
@ -69,23 +74,26 @@ jobs:
- name: Check out repo
uses: actions/checkout@v3
- name: Install poetry
run: pipx install poetry
- uses: actions/setup-python@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
python-version: ${{ matrix.python-version }}
cache: 'poetry'
# Install a specific version of uv.
version: "0.5.21"
enable-cache: true
- name: Install OS dependencies
if: ${{ matrix.python-version }} == '3.10'
run: |
sudo apt update
sudo apt install -y libre2-dev libpq-dev
- name: "Set up Python"
uses: actions/setup-python@v5
with:
python-version-file: "pyproject.toml"
- name: Install dependencies
if: steps.cached-poetry-dependencies.outputs.cache-hit != 'true'
run: poetry install --no-interaction
if: steps.setup-uv.outputs.cache-hit != 'true'
run: uv sync --locked --all-extras
- name: Start Redis v6
@ -95,7 +103,7 @@ jobs:
- name: Run db migration
run: |
CONFIG=tests/test.env poetry run alembic upgrade head
CONFIG=tests/test.env uv run alembic upgrade head
- name: Prepare version file
run: |
@ -104,7 +112,7 @@ jobs:
- name: Test with pytest
run: |
poetry run pytest
uv run pytest
env:
GITHUB_ACTIONS_TEST: true

1
app/.python-version Normal file
View File

@ -0,0 +1 @@
3.10.16

View File

@ -20,7 +20,7 @@ SimpleLogin backend consists of 2 main components:
## Install dependencies
The project requires:
- Python 3.10 and poetry to manage dependencies
- Python 3.10 and uv to manage dependencies
- Node v10 for front-end.
- Postgres 13+
@ -28,7 +28,7 @@ First, install all dependencies by running the following command.
Feel free to use `virtualenv` or similar tools to isolate development environment.
```bash
poetry sync
uv sync
```
On Mac, sometimes you might need to install some other packages via `brew`:
@ -55,7 +55,7 @@ brew install -s re2 pybind11
We use pre-commit to run all our linting and static analysis checks. Please run
```bash
poetry run pre-commit install
uv run pre-commit install
```
To install it in your development environment.
@ -160,25 +160,25 @@ Here are the small sum-ups of the directory structures and their roles:
The code is formatted using [ruff](https://github.com/astral-sh/ruff), to format the code, simply run
```
poetry run ruff format .
uv run ruff format .
```
The code is also checked with `flake8`, make sure to run `flake8` before creating the pull request by
```bash
poetry run flake8
uv run flake8
```
For HTML templates, we use `djlint`. Before creating a pull request, please run
```bash
poetry run djlint --check templates
uv run djlint --check templates
```
If some files aren't properly formatted, you can format all files with
```bash
poetry run djlint --reformat .
uv run djlint --reformat .
```
## Test sending email
@ -239,15 +239,15 @@ brew install python3.10
# make sure to update the PATH so python, pip point to Python3
# for us it can be done by adding "export PATH=/opt/homebrew/opt/python@3.10/libexec/bin:$PATH" to .zprofile
# Although pipx is the recommended way to install poetry,
# Although pipx is the recommended way to install uv,
# install pipx via brew will automatically install python 3.12
# and poetry will then use python 3.12
# so we recommend using poetry this way instead
curl -sSL https://install.python-poetry.org | python3 -
# and uv will then use python 3.12
# so we recommend using uv this way instead
curl -sSL https://install.python-uv.org | python3 -
poetry install
uv install
# activate the virtualenv and you should be good to go!
source .venv/bin/activate
```
```

View File

@ -4,43 +4,47 @@ WORKDIR /code
COPY ./static/package*.json /code/static/
RUN cd /code/static && npm ci
# Main image
FROM python:3.10
FROM --platform=linux/amd64 ubuntu:22.04
ARG UV_VERSION="0.5.21"
ARG UV_HASH="e108c300eafae22ad8e6d94519605530f18f8762eb58d2b98a617edfb5d088fc"
# Keeps Python from generating .pyc files in the container
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONDONTWRITEBYTECODE=1
# Turns off buffering for easier container logging
ENV PYTHONUNBUFFERED 1
ENV PYTHONUNBUFFERED=1
# Add poetry to PATH
ENV PATH="${PATH}:/root/.local/bin"
WORKDIR /code
# Copy poetry files
COPY poetry.lock pyproject.toml ./
# Copy dependency files
COPY pyproject.toml uv.lock .python-version ./
# 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\
&& curl -sSL https://install.python-poetry.org | python3 - \
# Remove curl and netcat from the image
&& apt-get purge -y curl netcat-traditional \
# 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\
# Install deps
RUN apt-get update \
&& apt-get install -y curl netcat-traditional gcc python3-dev gnupg git libre2-dev build-essential pkg-config cmake ninja-build bash clang \
&& curl -sSL "https://github.com/astral-sh/uv/releases/download/${UV_VERSION}/uv-x86_64-unknown-linux-gnu.tar.gz" > uv.tar.gz \
&& echo "${UV_HASH} uv.tar.gz" | sha256sum -c - \
&& tar xf uv.tar.gz -C /tmp/ \
&& mv /tmp/uv-x86_64-unknown-linux-gnu/uv /usr/bin/uv \
&& mv /tmp/uv-x86_64-unknown-linux-gnu/uvx /usr/bin/uvx \
&& rm -rf /tmp/uv* \
&& rm -f uv.tar.gz \
&& uv python install `cat .python-version` \
&& uv sync --locked \
&& apt-get autoremove -y \
&& apt-get purge -y curl netcat-traditional build-essential pkg-config cmake ninja-build python3-dev clang\
&& apt-get autoremove -y \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*
# Copy code
COPY . .
# copy npm packages
COPY --from=npm /code /code
# copy everything else into /code
COPY . .
ENV PATH="/code/.venv/bin:$PATH"
EXPOSE 7777
#gunicorn wsgi:app -b 0.0.0.0:7777 -w 2 --timeout 15 --log-level DEBUG

View File

@ -84,7 +84,7 @@ For email gurus, we have chosen 1024 key length instead of 2048 for DNS simplici
### DNS
Please note that DNS changes could take up to 24 hours to propagate. In practice, it's a lot faster though (~1 minute or so in our test). In DNS setup, we usually use domain with a trailing dot (`.`) at the end to to force using absolute domain.
Please note that DNS changes could take up to 24 hours to propagate. In practice, it's a lot faster though (~1 minute or so in our test). In DNS setup, we usually use domain with a trailing dot (`.`) at the end to force using absolute domain.
#### MX record

View File

@ -7,8 +7,4 @@ If you want be up to date on security patches, make sure your SimpleLogin image
## 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.
We are incredibly thankful for people who disclose vulnerabilities, unfortunately we do not have a bounty program in place yet.
If you want to report a vulnerability, please take a look at our bug bounty program at https://proton.me/security/bug-bounty.

View File

@ -3,12 +3,17 @@ from dataclasses import dataclass
from enum import Enum
from typing import Optional
import sqlalchemy.exc
from arrow import Arrow
from newrelic import agent
from psycopg2.errors import UniqueViolation
from sqlalchemy import or_
from app.db import Session
from app.email_utils import send_welcome_email
from app.events.event_dispatcher import EventDispatcher
from app.events.generated.event_pb2 import UserPlanChanged, EventContent
from app.partner_user_utils import create_partner_user, create_partner_subscription
from app.utils import sanitize_email, canonicalize_email
from app.errors import (
AccountAlreadyLinkedToAnotherPartnerException,
@ -23,12 +28,14 @@ from app.models import (
User,
Alias,
)
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
from app.utils import random_string
class SLPlanType(Enum):
Free = 1
Premium = 2
PremiumLifetime = 3
@dataclass
@ -52,8 +59,26 @@ class LinkResult:
strategy: str
def send_user_plan_changed_event(
partner_user: PartnerUser,
) -> UserPlanChanged:
subscription_end = partner_user.user.get_active_subscription_end(
include_partner_subscription=False
)
if partner_user.user.lifetime:
event = UserPlanChanged(lifetime=True)
elif subscription_end:
event = UserPlanChanged(plan_end_time=subscription_end.timestamp)
else:
event = UserPlanChanged(plan_end_time=None)
EventDispatcher.send_event(partner_user.user, EventContent(user_plan_change=event))
Session.flush()
return event
def set_plan_for_partner_user(partner_user: PartnerUser, plan: SLPlan):
sub = PartnerSubscription.get_by(partner_user_id=partner_user.id)
is_lifetime = plan.type == SLPlanType.PremiumLifetime
if plan.type == SLPlanType.Free:
if sub is not None:
LOG.i(
@ -62,24 +87,37 @@ def set_plan_for_partner_user(partner_user: PartnerUser, plan: SLPlan):
PartnerSubscription.delete(sub.id)
agent.record_custom_event("PlanChange", {"plan": "free"})
else:
end_time = plan.expiration
if plan.type == SLPlanType.PremiumLifetime:
end_time = None
if sub is None:
LOG.i(
f"Creating partner_subscription [user_id={partner_user.user_id}] [partner_id={partner_user.partner_id}]"
f"Creating partner_subscription [user_id={partner_user.user_id}] [partner_id={partner_user.partner_id}] with {end_time} / {is_lifetime}"
)
PartnerSubscription.create(
partner_user_id=partner_user.id,
end_at=plan.expiration,
create_partner_subscription(
partner_user=partner_user,
expiration=end_time,
lifetime=is_lifetime,
msg="Upgraded via partner. User did not have a previous partner subscription",
)
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}]"
)
if sub.end_at != plan.expiration or sub.lifetime != is_lifetime:
agent.record_custom_event(
"PlanChange", {"plan": "premium", "type": "extension"}
)
sub.end_at = plan.expiration
sub.end_at = plan.expiration if not is_lifetime else None
sub.lifetime = is_lifetime
LOG.i(
f"Updating partner_subscription [user_id={partner_user.user_id}] [partner_id={partner_user.partner_id}] to {sub.end_at} / {sub.lifetime} "
)
emit_user_audit_log(
user=partner_user.user,
action=UserAuditLogAction.SubscriptionExtended,
message="Extended partner subscription",
)
Session.flush()
send_user_plan_changed_event(partner_user)
Session.commit()
@ -98,12 +136,13 @@ def ensure_partner_user_exists_for_user(
if res and res.partner_id != partner.id:
raise AccountAlreadyLinkedToAnotherPartnerException()
if not res:
res = PartnerUser.create(
user_id=sl_user.id,
res = create_partner_user(
user=sl_user,
partner_id=partner.id,
partner_email=link_request.email,
external_user_id=link_request.external_user_id,
)
Session.commit()
LOG.i(
f"Created new partner_user for partner:{partner.id} user:{sl_user.id} external_user_id:{link_request.external_user_id}. PartnerUser.id is {res.id}"
@ -131,17 +170,59 @@ 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,
name=self.link_request.name,
password=random_string(20),
activated=True,
from_partner=self.link_request.from_partner,
try:
# Will create a new SL User with a random password
new_user = User.create(
email=canonical_email,
name=self.link_request.name,
password=random_string(20),
activated=True,
from_partner=self.link_request.from_partner,
)
self.create_partner_user(new_user)
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__,
)
except (UniqueViolation, sqlalchemy.exc.IntegrityError) as e:
Session.rollback()
LOG.debug(f"Got the duplicate user error: {e}")
return self.create_missing_link(canonical_email)
def create_missing_link(self, canonical_email: str):
# If there's a unique key violation due to race conditions try to create only the partner if needed
partner_user = PartnerUser.get_by(
external_user_id=self.link_request.external_user_id,
partner_id=self.partner.id,
)
partner_user = PartnerUser.create(
user_id=new_user.id,
if partner_user is None:
# Get the user by canonical email and if not by normal email
user = User.get_by(email=canonical_email) or User.get_by(
email=self.link_request.email
)
if not user:
raise RuntimeError(
"Tried to create only partner on UniqueViolation but cannot find the user"
)
partner_user = self.create_partner_user(user)
Session.commit()
return LinkResult(
user=partner_user.user, strategy=ExistingUnlinkedUserStrategy.__name__
)
def create_partner_user(self, new_user: User):
partner_user = create_partner_user(
user=new_user,
partner_id=self.partner.id,
external_user_id=self.link_request.external_user_id,
partner_email=self.link_request.email,
@ -153,17 +234,7 @@ class NewUserStrategy(ClientMergeStrategy):
partner_user,
self.link_request.plan,
)
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__,
)
return partner_user
class ExistingUnlinkedUserStrategy(ClientMergeStrategy):
@ -200,7 +271,7 @@ def get_login_strategy(
return ExistingUnlinkedUserStrategy(link_request, user, partner)
def check_alias(email: str) -> bool:
def check_alias(email: str):
alias = Alias.get_by(email=email)
if alias is not None:
raise AccountIsUsingAliasAsEmail()
@ -275,10 +346,26 @@ def switch_already_linked_user(
LOG.i(
f"Deleting previous partner_user:{other_partner_user.id} from user:{current_user.id}"
)
emit_user_audit_log(
user=other_partner_user.user,
action=UserAuditLogAction.UnlinkAccount,
message=f"Deleting partner_user {other_partner_user.id} (external_user_id={other_partner_user.external_user_id} | partner_email={other_partner_user.partner_email}) from user {current_user.id}, as we received a new link request for the same partner",
)
PartnerUser.delete(other_partner_user.id)
LOG.i(f"Linking partner_user:{partner_user.id} to user:{current_user.id}")
# Link this partner_user to the current user
emit_user_audit_log(
user=partner_user.user,
action=UserAuditLogAction.UnlinkAccount,
message=f"Unlinking from partner, as user will now be tied to another external account. old=(id={partner_user.user.id} | email={partner_user.user.email}) | new=(id={current_user.id} | email={current_user.email})",
)
partner_user.user_id = current_user.id
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.LinkAccount,
message=f"Linking user {current_user.id} ({current_user.email}) to partner_user:{partner_user.id} (external_user_id={partner_user.external_user_id} | partner_email={partner_user.partner_email})",
)
# Set plan
set_plan_for_partner_user(partner_user, link_request.plan)
Session.commit()

View File

@ -1,21 +1,29 @@
from __future__ import annotations
from typing import Optional
from typing import Optional, List
import arrow
import sqlalchemy
from flask_admin import BaseView
from flask_admin.form import SecureForm
from flask_admin.model.template import EndpointLinkRowAction
from markupsafe import Markup
from app import models, s3
from flask import redirect, url_for, request, flash, Response
from flask_admin import BaseView
from flask_admin import expose, AdminIndexView
from flask_admin.actions import action
from flask_admin.contrib import sqla
from flask_admin.form import SecureForm
from flask_admin.model.template import EndpointLinkRowAction
from flask_login import current_user
from markupsafe import Markup
from app import models, s3, config
from app.custom_domain_validation import (
CustomDomainValidation,
DomainValidationResult,
ExpectedValidationRecords,
)
from app.db import Session
from app.dns_utils import get_network_dns_client
from app.events.event_dispatcher import EventDispatcher
from app.events.generated.event_pb2 import EventContent, UserPlanChanged
from app.models import (
User,
ManualSubscription,
@ -35,8 +43,12 @@ from app.models import (
DomainDeletedAlias,
PartnerUser,
AliasMailbox,
AliasAuditLog,
UserAuditLog,
CustomDomain,
)
from app.newsletter_utils import send_newsletter_to_user, send_newsletter_to_address
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
def _admin_action_formatter(view, context, model, name):
@ -113,7 +125,7 @@ class SLAdminIndexView(AdminIndexView):
if not current_user.is_authenticated or not current_user.is_admin:
return redirect(url_for("auth.login", next=request.url))
return redirect("/admin/user")
return redirect("/admin/email_search")
class UserAdmin(SLModelView):
@ -349,17 +361,42 @@ def manual_upgrade(way: str, ids: [int], is_giveaway: bool):
manual_sub.end_at = manual_sub.end_at.shift(years=1)
else:
manual_sub.end_at = arrow.now().shift(years=1, days=1)
emit_user_audit_log(
user=user,
action=UserAuditLogAction.Upgrade,
message=f"Admin {current_user.email} extended manual subscription to user {user.email}",
)
EventDispatcher.send_event(
user=user,
content=EventContent(
user_plan_change=UserPlanChanged(
plan_end_time=manual_sub.end_at.timestamp
)
),
)
flash(f"Subscription extended to {manual_sub.end_at.humanize()}", "success")
continue
else:
emit_user_audit_log(
user=user,
action=UserAuditLogAction.Upgrade,
message=f"Admin {current_user.email} created manual subscription to user {user.email}",
)
manual_sub = ManualSubscription.create(
user_id=user.id,
end_at=arrow.now().shift(years=1, days=1),
comment=way,
is_giveaway=is_giveaway,
)
EventDispatcher.send_event(
user=user,
content=EventContent(
user_plan_change=UserPlanChanged(
plan_end_time=manual_sub.end_at.timestamp
)
),
)
ManualSubscription.create(
user_id=user.id,
end_at=arrow.now().shift(years=1, days=1),
comment=way,
is_giveaway=is_giveaway,
)
flash(f"New {way} manual subscription for {user} is created", "success")
flash(f"New {way} manual subscription for {user} is created", "success")
Session.commit()
@ -451,14 +488,7 @@ class ManualSubscriptionAdmin(SLModelView):
"Extend 1 year more?",
)
def extend_1y(self, ids):
for ms in ManualSubscription.filter(ManualSubscription.id.in_(ids)):
ms.end_at = ms.end_at.shift(years=1)
flash(f"Extend subscription for 1 year for {ms.user}", "success")
AdminAuditLog.extend_subscription(
current_user.id, ms.user.id, ms.end_at, "1 year"
)
Session.commit()
self.__extend_manual_subscription(ids, msg="1 year", years=1)
@action(
"extend_1m",
@ -466,11 +496,26 @@ class ManualSubscriptionAdmin(SLModelView):
"Extend 1 month more?",
)
def extend_1m(self, ids):
self.__extend_manual_subscription(ids, msg="1 month", months=1)
def __extend_manual_subscription(self, ids: List[int], msg: str, **kwargs):
for ms in ManualSubscription.filter(ManualSubscription.id.in_(ids)):
ms.end_at = ms.end_at.shift(months=1)
flash(f"Extend subscription for 1 month for {ms.user}", "success")
sub: ManualSubscription = ms
sub.end_at = sub.end_at.shift(**kwargs)
flash(f"Extend subscription for {msg} for {sub.user}", "success")
emit_user_audit_log(
user=sub.user,
action=UserAuditLogAction.Upgrade,
message=f"Admin {current_user.email} extended manual subscription for {msg} for {sub.user}",
)
AdminAuditLog.extend_subscription(
current_user.id, ms.user.id, ms.end_at, "1 month"
current_user.id, sub.user.id, sub.end_at, msg
)
EventDispatcher.send_event(
user=sub.user,
content=EventContent(
user_plan_change=UserPlanChanged(plan_end_time=sub.end_at.timestamp)
),
)
Session.commit()
@ -735,24 +780,54 @@ class InvalidMailboxDomainAdmin(SLModelView):
class EmailSearchResult:
no_match: bool = True
alias: Optional[Alias] = None
mailbox: list[Mailbox] = []
mailbox_count: int = 0
deleted_alias: Optional[DeletedAlias] = None
deleted_custom_alias: Optional[DomainDeletedAlias] = None
user: Optional[User] = None
def __init__(self):
self.no_match: bool = True
self.alias: Optional[Alias] = None
self.alias_audit_log: Optional[List[AliasAuditLog]] = None
self.mailbox: List[Mailbox] = []
self.mailbox_count: int = 0
self.deleted_alias: Optional[DeletedAlias] = None
self.deleted_alias_audit_log: Optional[List[AliasAuditLog]] = None
self.domain_deleted_alias: Optional[DomainDeletedAlias] = None
self.domain_deleted_alias_audit_log: Optional[List[AliasAuditLog]] = None
self.user: Optional[User] = None
self.user_audit_log: Optional[List[UserAuditLog]] = None
self.query: str
@staticmethod
def from_email(email: str) -> EmailSearchResult:
def from_request_email(email: str) -> EmailSearchResult:
output = EmailSearchResult()
output.query = email
alias = Alias.get_by(email=email)
if alias:
output.alias = alias
output.alias_audit_log = (
AliasAuditLog.filter_by(alias_id=alias.id)
.order_by(AliasAuditLog.created_at.desc())
.all()
)
output.no_match = False
user = User.get_by(email=email)
try:
user_id = int(email)
user = User.get(user_id)
except ValueError:
user = User.get_by(email=email)
if user:
output.user = user
output.user_audit_log = (
UserAuditLog.filter_by(user_id=user.id)
.order_by(UserAuditLog.created_at.desc())
.all()
)
output.no_match = False
user_audit_log = (
UserAuditLog.filter_by(user_email=email)
.order_by(UserAuditLog.created_at.desc())
.all()
)
if user_audit_log:
output.user_audit_log = user_audit_log
output.no_match = False
mailboxes = (
Mailbox.filter_by(email=email).order_by(Mailbox.id.desc()).limit(10).all()
@ -764,10 +839,20 @@ class EmailSearchResult:
deleted_alias = DeletedAlias.get_by(email=email)
if deleted_alias:
output.deleted_alias = deleted_alias
output.deleted_alias_audit_log = (
AliasAuditLog.filter_by(alias_email=deleted_alias.email)
.order_by(AliasAuditLog.created_at.desc())
.all()
)
output.no_match = False
domain_deleted_alias = DomainDeletedAlias.get_by(email=email)
if domain_deleted_alias:
output.domain_deleted_alias = domain_deleted_alias
output.domain_deleted_alias_audit_log = (
AliasAuditLog.filter_by(alias_email=domain_deleted_alias.email)
.order_by(AliasAuditLog.created_at.desc())
.all()
)
output.no_match = False
return output
@ -835,7 +920,7 @@ class EmailSearchAdmin(BaseView):
email = request.args.get("email")
if email is not None and len(email) > 0:
email = email.strip()
search = EmailSearchResult.from_email(email)
search = EmailSearchResult.from_request_email(email)
return self.render(
"admin/email_search.html",
@ -843,3 +928,104 @@ class EmailSearchAdmin(BaseView):
data=search,
helper=EmailSearchHelpers,
)
class CustomDomainWithValidationData:
def __init__(self, domain: CustomDomain):
self.domain: CustomDomain = domain
self.ownership_expected: Optional[ExpectedValidationRecords] = None
self.ownership_validation: Optional[DomainValidationResult] = None
self.mx_expected: Optional[dict[int, ExpectedValidationRecords]] = None
self.mx_validation: Optional[DomainValidationResult] = None
self.spf_expected: Optional[ExpectedValidationRecords] = None
self.spf_validation: Optional[DomainValidationResult] = None
self.dkim_expected: {str: ExpectedValidationRecords} = {}
self.dkim_validation: {str: str} = {}
class CustomDomainSearchResult:
def __init__(self):
self.no_match: bool = False
self.user: Optional[User] = None
self.domains: list[CustomDomainWithValidationData] = []
@staticmethod
def from_user(user: Optional[User]) -> CustomDomainSearchResult:
out = CustomDomainSearchResult()
if user is None:
out.no_match = True
return out
out.user = user
dns_client = get_network_dns_client()
validator = CustomDomainValidation(
dkim_domain=config.EMAIL_DOMAIN,
partner_domains=config.PARTNER_DNS_CUSTOM_DOMAINS,
partner_domains_validation_prefixes=config.PARTNER_CUSTOM_DOMAIN_VALIDATION_PREFIXES,
dns_client=dns_client,
)
for custom_domain in user.custom_domains:
validation_data = CustomDomainWithValidationData(custom_domain)
if not custom_domain.ownership_verified:
validation_data.ownership_expected = (
validator.get_ownership_verification_record(custom_domain)
)
validation_data.ownership_validation = (
validator.validate_domain_ownership(custom_domain)
)
if not custom_domain.verified:
validation_data.mx_expected = validator.get_expected_mx_records(
custom_domain
)
validation_data.mx_validation = validator.validate_mx_records(
custom_domain
)
if not custom_domain.spf_verified:
validation_data.spf_expected = validator.get_expected_spf_record(
custom_domain
)
validation_data.spf_validation = validator.validate_spf_records(
custom_domain
)
if not custom_domain.dkim_verified:
validation_data.dkim_expected = validator.get_dkim_records(
custom_domain
)
validation_data.dkim_validation = validator.validate_dkim_records(
custom_domain
)
out.domains.append(validation_data)
return out
class CustomDomainSearchAdmin(BaseView):
def is_accessible(self):
return current_user.is_authenticated and current_user.is_admin
def inaccessible_callback(self, name, **kwargs):
# redirect to login page if user doesn't have access
flash("You don't have access to the admin page", "error")
return redirect(url_for("dashboard.index", next=request.url))
@expose("/", methods=["GET", "POST"])
def index(self):
query = request.args.get("user")
if query is None:
search = CustomDomainSearchResult()
else:
try:
user_id = int(query)
user = User.get_by(id=user_id)
except ValueError:
user = User.get_by(email=query)
if user is None:
cd = CustomDomain.get_by(domain=query)
if cd is not None:
user = cd.user
search = CustomDomainSearchResult.from_user(user)
return self.render(
"admin/custom_domain_search.html",
data=search,
query=query,
)

View File

@ -0,0 +1,38 @@
from enum import Enum
from typing import Optional
from app.models import Alias, AliasAuditLog
class AliasAuditLogAction(Enum):
CreateAlias = "create"
ChangeAliasStatus = "change_status"
DeleteAlias = "delete"
UpdateAlias = "update"
InitiateTransferAlias = "initiate_transfer_alias"
AcceptTransferAlias = "accept_transfer_alias"
TransferredAlias = "transferred_alias"
ChangedMailboxes = "changed_mailboxes"
CreateContact = "create_contact"
UpdateContact = "update_contact"
DeleteContact = "delete_contact"
def emit_alias_audit_log(
alias: Alias,
action: AliasAuditLogAction,
message: str,
user_id: Optional[int] = None,
commit: bool = False,
):
AliasAuditLog.create(
user_id=user_id or alias.user_id,
alias_id=alias.id,
alias_email=alias.email,
action=action.value,
message=message,
commit=commit,
)

View File

@ -0,0 +1,62 @@
from dataclasses import dataclass
from enum import Enum
from typing import List, Optional
from app.alias_audit_log_utils import emit_alias_audit_log, AliasAuditLogAction
from app.db import Session
from app.models import Alias, AliasMailbox, Mailbox
_MAX_MAILBOXES_PER_ALIAS = 20
class CannotSetMailboxesForAliasCause(Enum):
Forbidden = "Forbidden"
EmptyMailboxes = "Must choose at least one mailbox"
TooManyMailboxes = "Too many mailboxes"
@dataclass
class SetMailboxesForAliasResult:
performed_change: bool
reason: Optional[CannotSetMailboxesForAliasCause]
def set_mailboxes_for_alias(
user_id: int, alias: Alias, mailbox_ids: List[int]
) -> Optional[CannotSetMailboxesForAliasCause]:
if len(mailbox_ids) == 0:
return CannotSetMailboxesForAliasCause.EmptyMailboxes
if len(mailbox_ids) > _MAX_MAILBOXES_PER_ALIAS:
return CannotSetMailboxesForAliasCause.TooManyMailboxes
mailboxes = (
Session.query(Mailbox)
.filter(
Mailbox.id.in_(mailbox_ids),
Mailbox.user_id == user_id,
Mailbox.verified == True, # noqa: E712
)
.order_by(Mailbox.id.asc())
.all()
)
if len(mailboxes) != len(mailbox_ids):
return CannotSetMailboxesForAliasCause.Forbidden
# first remove all existing alias-mailboxes links
AliasMailbox.filter_by(alias_id=alias.id).delete()
Session.flush()
# then add all new mailboxes, being the first the one associated with the alias
for i, mailbox in enumerate(mailboxes):
if i == 0:
alias.mailbox_id = mailboxes[0].id
else:
AliasMailbox.create(alias_id=alias.id, mailbox_id=mailbox.id)
emit_alias_audit_log(
alias=alias,
action=AliasAuditLogAction.ChangedMailboxes,
message=",".join([f"{mailbox.id} ({mailbox.email})" for mailbox in mailboxes]),
)
return None

View File

@ -58,7 +58,7 @@ def verify_prefix_suffix(
# 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)
LOG.i("wrong alias suffix %s, user %s", alias_suffix, user)
return False
# SimpleLogin domain case:
@ -75,17 +75,17 @@ def verify_prefix_suffix(
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)
LOG.i("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)
LOG.i("wrong alias suffix %s, user %s", alias_suffix, user)
return False
if alias_domain not in available_sl_domains:
LOG.e("wrong alias suffix %s, user %s", alias_suffix, user)
LOG.i("wrong alias suffix %s, user %s", alias_suffix, user)
return False
return True

View File

@ -8,6 +8,7 @@ from email_validator import validate_email, EmailNotValidError
from sqlalchemy.exc import IntegrityError, DataError
from flask import make_response
from app.alias_audit_log_utils import AliasAuditLogAction, emit_alias_audit_log
from app.config import (
BOUNCE_PREFIX_FOR_REPLY_PHASE,
BOUNCE_PREFIX,
@ -368,6 +369,10 @@ def delete_alias(
alias_id = alias.id
alias_email = alias.email
emit_alias_audit_log(
alias, AliasAuditLogAction.DeleteAlias, "Alias deleted by user action"
)
Alias.filter(Alias.id == alias.id).delete()
Session.commit()
@ -450,7 +455,7 @@ def alias_export_csv(user, csv_direct_export=False):
return output
def transfer_alias(alias, new_user, new_mailboxes: [Mailbox]):
def transfer_alias(alias: Alias, new_user: 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")
@ -504,6 +509,12 @@ def transfer_alias(alias, new_user, new_mailboxes: [Mailbox]):
alias.disable_pgp = False
alias.pinned = False
emit_alias_audit_log(
alias=alias,
action=AliasAuditLogAction.TransferredAlias,
message=f"Lost ownership of alias due to alias transfer confirmed. New owner is {new_user.id}",
user_id=old_user.id,
)
EventDispatcher.send_event(
old_user,
EventContent(
@ -513,6 +524,13 @@ def transfer_alias(alias, new_user, new_mailboxes: [Mailbox]):
)
),
)
emit_alias_audit_log(
alias=alias,
action=AliasAuditLogAction.AcceptTransferAlias,
message=f"Accepted alias transfer from user {old_user.id}",
user_id=new_user.id,
)
EventDispatcher.send_event(
new_user,
EventContent(
@ -529,7 +547,9 @@ def transfer_alias(alias, new_user, new_mailboxes: [Mailbox]):
Session.commit()
def change_alias_status(alias: Alias, enabled: bool, commit: bool = False):
def change_alias_status(
alias: Alias, enabled: bool, message: Optional[str] = None, commit: bool = False
):
LOG.i(f"Changing alias {alias} enabled to {enabled}")
alias.enabled = enabled
@ -540,6 +560,12 @@ def change_alias_status(alias: Alias, enabled: bool, commit: bool = False):
created_at=int(alias.created_at.timestamp),
)
EventDispatcher.send_event(alias.user, EventContent(alias_status_change=event))
audit_log_message = f"Set alias status to {enabled}"
if message is not None:
audit_log_message += f". {message}"
emit_alias_audit_log(
alias, AliasAuditLogAction.ChangeAliasStatus, audit_log_message
)
if commit:
Session.commit()

View File

@ -191,15 +191,8 @@ def get_alias_infos_with_pagination_v3(
q = q.order_by(Alias.email.desc())
else:
# default sorting
latest_activity = case(
[
(Alias.created_at > EmailLog.created_at, Alias.created_at),
(Alias.created_at < EmailLog.created_at, EmailLog.created_at),
],
else_=Alias.created_at,
)
q = q.order_by(Alias.pinned.desc())
q = q.order_by(latest_activity.desc())
q = q.order_by(func.greatest(Alias.created_at, EmailLog.created_at).desc())
q = q.limit(page_limit).offset(page_id * page_size)

View File

@ -1,9 +1,13 @@
from typing import Optional
from deprecated import deprecated
from flask import g
from flask import jsonify
from flask import request
from app import alias_utils
from app.alias_audit_log_utils import emit_alias_audit_log, AliasAuditLogAction
from app.alias_mailbox_utils import set_mailboxes_for_alias
from app.api.base import api_bp, require_api_auth
from app.api.serializer import (
AliasInfo,
@ -26,7 +30,7 @@ from app.errors import (
)
from app.extensions import limiter
from app.log import LOG
from app.models import Alias, Contact, Mailbox, AliasMailbox, AliasDeleteReason
from app.models import Alias, Contact, Mailbox, AliasDeleteReason
@deprecated
@ -185,7 +189,11 @@ def toggle_alias(alias_id):
if not alias or alias.user_id != user.id:
return jsonify(error="Forbidden"), 403
alias_utils.change_alias_status(alias, enabled=not alias.enabled)
alias_utils.change_alias_status(
alias,
enabled=not alias.enabled,
message=f"Set enabled={not alias.enabled} via API",
)
LOG.i(f"User {user} changed alias {alias} enabled status to {alias.enabled}")
Session.commit()
@ -272,10 +280,12 @@ def update_alias(alias_id):
if not alias or alias.user_id != user.id:
return jsonify(error="Forbidden"), 403
changed_fields = []
changed = False
if "note" in data:
new_note = data.get("note")
alias.note = new_note
changed_fields.append("note")
changed = True
if "mailbox_id" in data:
@ -285,35 +295,22 @@ def update_alias(alias_id):
return jsonify(error="Forbidden"), 400
alias.mailbox_id = mailbox_id
changed_fields.append(f"mailbox_id ({mailbox_id})")
changed = True
if "mailbox_ids" in data:
mailbox_ids = [int(m_id) for m_id in data.get("mailbox_ids")]
mailboxes: [Mailbox] = []
# check if all mailboxes belong to user
for mailbox_id in mailbox_ids:
mailbox = Mailbox.get(mailbox_id)
if not mailbox or mailbox.user_id != user.id or not mailbox.verified:
return jsonify(error="Forbidden"), 400
mailboxes.append(mailbox)
if not mailboxes:
return jsonify(error="Must choose at least one mailbox"), 400
# <<< update alias mailboxes >>>
# first remove all existing alias-mailboxes links
AliasMailbox.filter_by(alias_id=alias.id).delete()
Session.flush()
# then add all new mailboxes
for i, mailbox in enumerate(mailboxes):
if i == 0:
alias.mailbox_id = mailboxes[0].id
else:
AliasMailbox.create(alias_id=alias.id, mailbox_id=mailbox.id)
# <<< END update alias mailboxes >>>
try:
mailbox_ids = [int(m_id) for m_id in data.get("mailbox_ids")]
except ValueError:
return jsonify(error="Invalid mailbox_id"), 400
err = set_mailboxes_for_alias(
user_id=user.id, alias=alias, mailbox_ids=mailbox_ids
)
if err:
return jsonify(error=err.value), 400
mailbox_ids_string = ",".join(map(str, mailbox_ids))
changed_fields.append(f"mailbox_ids ({mailbox_ids_string})")
changed = True
if "name" in data:
@ -325,17 +322,26 @@ def update_alias(alias_id):
if new_name:
new_name = new_name.replace("\n", "")
alias.name = new_name
changed_fields.append("name")
changed = True
if "disable_pgp" in data:
alias.disable_pgp = data.get("disable_pgp")
changed_fields.append("disable_pgp")
changed = True
if "pinned" in data:
alias.pinned = data.get("pinned")
changed_fields.append("pinned")
changed = True
if changed:
changed_fields_string = ",".join(changed_fields)
emit_alias_audit_log(
alias,
AliasAuditLogAction.UpdateAlias,
f"Alias fields updated ({changed_fields_string})",
)
Session.commit()
return jsonify(ok=True), 200
@ -416,9 +422,8 @@ def create_contact_route(alias_id):
if not data:
return jsonify(error="request body cannot be empty"), 400
alias: Alias = Alias.get(alias_id)
if alias.user_id != g.user.id:
alias: Optional[Alias] = Alias.get_by(id=alias_id, user_id=g.user.id)
if not alias:
return jsonify(error="Forbidden"), 403
contact_address = data.get("contact")
@ -446,11 +451,16 @@ def delete_contact(contact_id):
200
"""
user = g.user
contact = Contact.get(contact_id)
contact: Optional[Contact] = Contact.get(contact_id)
if not contact or contact.alias.user_id != user.id:
return jsonify(error="Forbidden"), 403
emit_alias_audit_log(
alias=contact.alias,
action=AliasAuditLogAction.DeleteContact,
message=f"Deleted contact {contact_id} ({contact.email})",
)
Contact.delete(contact_id)
Session.commit()
@ -468,12 +478,17 @@ def toggle_contact(contact_id):
200
"""
user = g.user
contact = Contact.get(contact_id)
contact: Optional[Contact] = Contact.get(contact_id)
if not contact or contact.alias.user_id != user.id:
return jsonify(error="Forbidden"), 403
contact.block_forward = not contact.block_forward
emit_alias_audit_log(
alias=contact.alias,
action=AliasAuditLogAction.UpdateContact,
message=f"Set contact state {contact.id} {contact.email} -> {contact.website_email} to blocked {contact.block_forward}",
)
Session.commit()
return jsonify(block_forward=contact.block_forward), 200

View File

@ -23,6 +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.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
from app.utils import sanitize_email, canonicalize_email
@ -187,6 +188,11 @@ def auth_activate():
LOG.d("activate user %s", user)
user.activated = True
emit_user_audit_log(
user=user,
action=UserAuditLogAction.ActivateUser,
message=f"User has been activated: {user.email}",
)
AccountActivation.delete(account_activation.id)
Session.commit()

View File

@ -2,8 +2,10 @@ from flask import g, request
from flask import jsonify
from app.api.base import api_bp, require_api_auth
from app.custom_domain_utils import set_custom_domain_mailboxes
from app.db import Session
from app.models import CustomDomain, DomainDeletedAlias, Mailbox, DomainMailbox
from app.log import LOG
from app.models import CustomDomain, DomainDeletedAlias
def custom_domain_to_dict(custom_domain: CustomDomain):
@ -100,23 +102,14 @@ def update_custom_domain(custom_domain_id):
if "mailbox_ids" in data:
mailbox_ids = [int(m_id) for m_id in data.get("mailbox_ids")]
if mailbox_ids:
# check if mailbox is not tempered with
mailboxes = []
for mailbox_id in mailbox_ids:
mailbox = Mailbox.get(mailbox_id)
if not mailbox or mailbox.user_id != user.id or not mailbox.verified:
return jsonify(error="Forbidden"), 400
mailboxes.append(mailbox)
# first remove all existing domain-mailboxes links
DomainMailbox.filter_by(domain_id=custom_domain.id).delete()
Session.flush()
for mailbox in mailboxes:
DomainMailbox.create(domain_id=custom_domain.id, mailbox_id=mailbox.id)
result = set_custom_domain_mailboxes(user.id, custom_domain, mailbox_ids)
if result.success:
changed = True
else:
LOG.info(
f"Prevented from updating mailboxes [custom_domain_id={custom_domain.id}]: {result.reason.value}"
)
return jsonify(error="Forbidden"), 400
if changed:
Session.commit()

View File

@ -6,12 +6,7 @@ from flask import request
from app import mailbox_utils
from app.api.base import api_bp, require_api_auth
from app.dashboard.views.mailbox_detail import verify_mailbox_change
from app.db import Session
from app.email_utils import (
mailbox_already_used,
email_can_be_used_as_mailbox,
)
from app.models import Mailbox
from app.utils import sanitize_email
@ -38,7 +33,11 @@ def create_mailbox():
the new mailbox dict
"""
user = g.user
mailbox_email = sanitize_email(request.get_json().get("email"))
email = request.get_json().get("email")
if not email:
return jsonify(error="Invalid email"), 400
mailbox_email = sanitize_email(email)
try:
new_mailbox = mailbox_utils.create_mailbox(user, mailbox_email).mailbox
@ -118,20 +117,10 @@ def update_mailbox(mailbox_id):
if "email" in data:
new_email = sanitize_email(data.get("email"))
if mailbox_already_used(new_email, user):
return jsonify(error=f"{new_email} already used"), 400
elif not email_can_be_used_as_mailbox(new_email):
return (
jsonify(
error=f"{new_email} cannot be used. Please note a mailbox cannot "
f"be a disposable email address"
),
400,
)
try:
verify_mailbox_change(user, mailbox, new_email)
mailbox_utils.request_mailbox_email_change(user, mailbox, new_email)
except mailbox_utils.MailboxError as e:
return jsonify(error=e.msg), 400
except SMTPRecipientsRefused:
return jsonify(error=f"Incorrect mailbox, please recheck {new_email}"), 400
else:
@ -141,7 +130,7 @@ def update_mailbox(mailbox_id):
if "cancel_email_change" in data:
cancel_email_change = data.get("cancel_email_change")
if cancel_email_change:
mailbox.new_email = None
mailbox_utils.cancel_email_change(mailbox.id, user)
changed = True
if changed:

View File

@ -1,3 +1,4 @@
from email_validator import EmailNotValidError
from flask import g
from flask import jsonify, request
@ -93,12 +94,15 @@ def new_custom_alias_v2():
400,
)
alias = Alias.create(
user_id=user.id,
email=full_alias,
mailbox_id=user.default_mailbox_id,
note=note,
)
try:
alias = Alias.create(
user_id=user.id,
email=full_alias,
mailbox_id=user.default_mailbox_id,
note=note,
)
except EmailNotValidError:
return jsonify(error="Email is not valid"), 400
Session.commit()
@ -153,8 +157,17 @@ def new_custom_alias_v3():
if not isinstance(data, dict):
return jsonify(error="request body does not follow the required format"), 400
alias_prefix = data.get("alias_prefix", "").strip().lower().replace(" ", "")
alias_prefix_data = data.get("alias_prefix", "") or ""
if not isinstance(alias_prefix_data, str):
return jsonify(error="request body does not follow the required format"), 400
alias_prefix = alias_prefix_data.strip().lower().replace(" ", "")
signed_suffix = data.get("signed_suffix", "") or ""
if not isinstance(signed_suffix, str):
return jsonify(error="request body does not follow the required format"), 400
signed_suffix = signed_suffix.strip()
mailbox_ids = data.get("mailbox_ids")

View File

@ -144,5 +144,6 @@ def get_available_domains_for_random_alias_v2():
@require_api_auth
def unlink_proton_account():
user = g.user
perform_proton_account_unlink(user)
if not perform_proton_account_unlink(user):
return jsonify(error="The account cannot be unlinked"), 400
return jsonify({"ok": True})

View File

@ -6,6 +6,7 @@ from app import config
from app.extensions import limiter
from app.log import LOG
from app.models import Job, ApiToCookieToken
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
@api_bp.route("/user", methods=["DELETE"])
@ -16,6 +17,11 @@ def delete_user():
"""
# Schedule delete account job
emit_user_audit_log(
user=g.user,
action=UserAuditLogAction.UserMarkedForDeletion,
message=f"Marked user {g.user.id} ({g.user.email}) for deletion from API",
)
LOG.w("schedule delete account job for %s", g.user)
Job.create(
name=config.JOB_DELETE_ACCOUNT,

View File

@ -7,6 +7,7 @@ from app.db import Session
from app.extensions import limiter
from app.log import LOG
from app.models import ActivationCode
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
from app.utils import sanitize_next_url
@ -47,6 +48,11 @@ def activate():
user = activation_code.user
user.activated = True
emit_user_audit_log(
user=user,
action=UserAuditLogAction.ActivateUser,
message=f"User has been activated: {user.email}",
)
login_user(user)
# activation code is to be used only once

View File

@ -10,6 +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.pw_models import PasswordOracle
from app.utils import sanitize_email, sanitize_next_url, canonicalize_email
@ -43,6 +44,13 @@ def login():
user = User.get_by(email=email) or User.get_by(email=canonical_email)
if not user or not user.check_password(form.password.data):
if not user:
# Do the hash to avoid timing attacks nevertheless
dummy_pw = PasswordOracle()
dummy_pw.password = (
"$2b$12$ZWqpL73h4rGNfLkJohAFAu0isqSw/bX9p/tzpbWRz/To5FAftaW8u"
)
dummy_pw.check_password(form.password.data)
# Trigger rate limiter
g.deduct_limit = True
form.password.data = None

View File

@ -9,6 +9,7 @@ from app.auth.views.login_utils import after_login
from app.db import Session
from app.extensions import limiter
from app.models import ResetPasswordCode
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
class ResetPasswordForm(FlaskForm):
@ -59,6 +60,11 @@ def reset_password():
# this can be served to activate user too
user.activated = True
emit_user_audit_log(
user=user,
action=UserAuditLogAction.ResetPassword,
message="User has reset their password",
)
# remove all reset password codes
ResetPasswordCode.filter_by(user_id=user.id).delete()

View File

@ -309,6 +309,7 @@ JOB_DELETE_DOMAIN = "delete-domain"
JOB_SEND_USER_REPORT = "send-user-report"
JOB_SEND_PROTON_WELCOME_1 = "proton-welcome-1"
JOB_SEND_ALIAS_CREATION_EVENTS = "send-alias-creation-events"
JOB_SEND_EVENT_TO_WEBHOOK = "send-event-to-webhook"
# for pagination
PAGE_LIMIT = 20
@ -601,7 +602,6 @@ 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)
@ -663,3 +663,5 @@ PARTNER_CUSTOM_DOMAIN_VALIDATION_PREFIXES: dict[int, str] = read_partner_dict(
MAILBOX_VERIFICATION_OVERRIDE_CODE: Optional[str] = os.environ.get(
"MAILBOX_VERIFICATION_OVERRIDE_CODE", None
)
AUDIT_LOG_MAX_DAYS = int(os.environ.get("AUDIT_LOG_MAX_DAYS", 30))

View File

@ -4,6 +4,7 @@ from typing import Optional
from sqlalchemy.exc import IntegrityError
from app.alias_audit_log_utils import emit_alias_audit_log, AliasAuditLogAction
from app.db import Session
from app.email_utils import generate_reply_email, parse_full_address
from app.email_validation import is_valid_email
@ -15,6 +16,7 @@ from app.utils import sanitize_email
class ContactCreateError(Enum):
InvalidEmail = "Invalid email"
NotAllowed = "Your plan does not allow to create contacts"
Unknown = "Unknown error when trying to create contact"
@dataclass
@ -86,8 +88,10 @@ def create_contact(
return __update_contact_if_needed(contact, name, mail_from)
# Create the contact
reply_email = generate_reply_email(email, alias)
alias_id = alias.id
try:
flags = Contact.FLAG_PARTNER_CREATED if from_partner else 0
is_invalid_email = email == ""
contact = Contact.create(
user_id=alias.user_id,
alias_id=alias.id,
@ -97,17 +101,38 @@ def create_contact(
mail_from=mail_from,
automatic_created=automatic_created,
flags=flags,
invalid_email=email == "",
invalid_email=is_invalid_email,
commit=True,
)
contact_id = contact.id
if automatic_created:
trail = ". Automatically created"
else:
trail = ". Created by user action"
emit_alias_audit_log(
alias=alias,
action=AliasAuditLogAction.CreateContact,
message=f"Created contact {contact_id} ({email}){trail}",
commit=True,
)
LOG.d(
f"Created contact {contact} for alias {alias} with email {email} invalid_email={contact.invalid_email}"
f"Created contact {contact} for alias {alias} with email {email} invalid_email={is_invalid_email}"
)
return ContactCreateResult(contact, created=True, error=None)
except IntegrityError:
Session.rollback()
LOG.info(
f"Contact with email {email} for alias_id {alias.id} already existed, fetching from DB"
f"Contact with email {email} for alias_id {alias_id} already existed, fetching from DB"
)
contact = Contact.get_by(alias_id=alias.id, website_email=email)
return __update_contact_if_needed(contact, name, mail_from)
return ContactCreateResult(contact, created=True, error=None)
contact: Optional[Contact] = Contact.get_by(
alias_id=alias_id, website_email=email
)
if contact:
return __update_contact_if_needed(contact, name, mail_from)
else:
LOG.warning(
f"Could not find contact with email {email} for alias_id {alias_id} and it should exist"
)
return ContactCreateResult(
None, created=False, error=ContactCreateError.Unknown
)

144
app/app/coupon_utils.py Normal file
View File

@ -0,0 +1,144 @@
from typing import Optional
import arrow
from sqlalchemy import or_, update, and_
from app.config import ADMIN_EMAIL
from app.db import Session
from app.email_utils import send_email
from app.events.event_dispatcher import EventDispatcher
from app.events.generated.event_pb2 import EventContent, UserPlanChanged
from app.log import LOG
from app.models import (
User,
ManualSubscription,
Coupon,
LifetimeCoupon,
PartnerSubscription,
PartnerUser,
)
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
class CouponUserCannotRedeemError(Exception):
pass
def redeem_coupon(coupon_code: str, user: User) -> Optional[Coupon]:
if user.lifetime:
LOG.i(f"User {user} is a lifetime SL user. Cannot redeem coupons")
raise CouponUserCannotRedeemError()
sub = user.get_active_subscription()
if sub and not isinstance(sub, ManualSubscription):
LOG.i(
f"User {user} has an active subscription that is not manual. Cannot redeem coupon {coupon_code}"
)
raise CouponUserCannotRedeemError()
coupon = Coupon.get_by(code=coupon_code)
if not coupon:
LOG.i(f"User is trying to redeem coupon {coupon_code} that does not exist")
return None
now = arrow.utcnow()
stmt = (
update(Coupon)
.where(
and_(
Coupon.code == coupon_code,
Coupon.used == False, # noqa: E712
or_(
Coupon.expires_date == None, # noqa: E711
Coupon.expires_date > now,
),
)
)
.values(used=True, used_by_user_id=user.id, updated_at=now)
)
res = Session.execute(stmt)
if res.rowcount == 0:
LOG.i(f"Coupon {coupon.id} could not be redeemed. It's expired or invalid.")
return None
LOG.i(
f"Redeemed normal coupon {coupon.id} for {coupon.nb_year} years by user {user}"
)
if sub:
# renew existing subscription
if sub.end_at > arrow.now():
sub.end_at = sub.end_at.shift(years=coupon.nb_year)
else:
sub.end_at = arrow.now().shift(years=coupon.nb_year, days=1)
else:
sub = ManualSubscription.create(
user_id=user.id,
end_at=arrow.now().shift(years=coupon.nb_year, days=1),
comment="using coupon code",
is_giveaway=coupon.is_giveaway,
commit=True,
)
emit_user_audit_log(
user=user,
action=UserAuditLogAction.Upgrade,
message=f"User {user} redeemed coupon {coupon.id} for {coupon.nb_year} years",
)
EventDispatcher.send_event(
user=user,
content=EventContent(
user_plan_change=UserPlanChanged(plan_end_time=sub.end_at.timestamp)
),
)
Session.commit()
return coupon
def redeem_lifetime_coupon(coupon_code: str, user: User) -> Optional[Coupon]:
if user.lifetime:
return None
partner_sub = (
Session.query(PartnerSubscription)
.join(PartnerUser, PartnerUser.id == PartnerSubscription.partner_user_id)
.filter(PartnerUser.user_id == user.id, PartnerSubscription.lifetime == True) # noqa: E712
.first()
)
if partner_sub is not None:
return None
coupon: LifetimeCoupon = LifetimeCoupon.get_by(code=coupon_code)
if not coupon:
return None
stmt = (
update(LifetimeCoupon)
.where(
and_(
LifetimeCoupon.code == coupon_code,
LifetimeCoupon.nb_used > 0,
)
)
.values(nb_used=LifetimeCoupon.nb_used - 1)
)
res = Session.execute(stmt)
if res.rowcount == 0:
LOG.i("Coupon could not be redeemed")
return None
user.lifetime = True
user.lifetime_coupon_id = coupon.id
if coupon.paid:
user.paid_lifetime = True
EventDispatcher.send_event(
user=user,
content=EventContent(user_plan_change=UserPlanChanged(lifetime=True)),
)
Session.commit()
# notify admin
send_email(
ADMIN_EMAIL,
subject=f"User {user} used lifetime coupon({coupon.comment}). Coupon nb_used: {coupon.nb_used}",
plaintext="",
html="",
)
return coupon

View File

@ -10,6 +10,7 @@ from app.db import Session
from app.email_utils import get_email_domain_part
from app.log import LOG
from app.models import User, CustomDomain, SLDomain, Mailbox, Job, DomainMailbox
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
_ALLOWED_DOMAIN_REGEX = re.compile(r"^(?!-)[A-Za-z0-9-]{1,63}(?<!-)$")
_MAX_MAILBOXES_PER_DOMAIN = 20
@ -137,6 +138,11 @@ def create_custom_domain(
if partner_id is not None:
new_custom_domain.partner_id = partner_id
emit_user_audit_log(
user=user,
action=UserAuditLogAction.CreateCustomDomain,
message=f"Created custom domain {new_custom_domain.id} ({new_domain})",
)
Session.commit()
return CreateCustomDomainResult(
@ -190,5 +196,11 @@ def set_custom_domain_mailboxes(
for mailbox in mailboxes:
DomainMailbox.create(domain_id=custom_domain.id, mailbox_id=mailbox.id)
mailboxes_as_str = ",".join(map(str, mailbox_ids))
emit_user_audit_log(
user=custom_domain.user,
action=UserAuditLogAction.UpdateCustomDomain,
message=f"Updated custom domain {custom_domain.id} mailboxes (domain={custom_domain.domain}) (mailboxes={mailboxes_as_str})",
)
Session.commit()
return SetCustomDomainMailboxesResult(success=True)

View File

@ -5,12 +5,11 @@ from app import config
from app.constants import DMARC_RECORD
from app.db import Session
from app.dns_utils import (
MxRecord,
DNSClient,
is_mx_equivalent,
get_network_dns_client,
)
from app.models import CustomDomain
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
from app.utils import random_string
@ -20,6 +19,39 @@ class DomainValidationResult:
errors: [str]
@dataclass
class ExpectedValidationRecords:
recommended: str
allowed: list[str]
def is_mx_equivalent(
mx_domains: dict[int, list[str]],
expected_mx_domains: dict[int, ExpectedValidationRecords],
) -> bool:
"""
Compare mx_domains with ref_mx_domains to see if they are equivalent.
mx_domains and ref_mx_domains are list of (priority, domain)
The priority order is taken into account but not the priority number.
For example, [(1, domain1), (2, domain2)] is equivalent to [(10, domain1), (20, domain2)]
"""
expected_prios = []
for prio in expected_mx_domains:
expected_prios.append(prio)
if len(expected_prios) != len(mx_domains):
return False
for prio_position, prio_value in enumerate(sorted(mx_domains.keys())):
for domain in mx_domains[prio_value]:
if domain not in expected_mx_domains[expected_prios[prio_position]].allowed:
return False
return True
class CustomDomainValidation:
def __init__(
self,
@ -36,59 +68,88 @@ class CustomDomainValidation:
or config.PARTNER_CUSTOM_DOMAIN_VALIDATION_PREFIXES
)
def get_ownership_verification_record(self, domain: CustomDomain) -> str:
prefix = "sl"
def get_ownership_verification_record(
self, domain: CustomDomain
) -> ExpectedValidationRecords:
prefixes = ["sl"]
if (
domain.partner_id is not None
and domain.partner_id in self._partner_domain_validation_prefixes
):
prefix = self._partner_domain_validation_prefixes[domain.partner_id]
prefixes.insert(
0, self._partner_domain_validation_prefixes[domain.partner_id]
)
if not domain.ownership_txt_token:
domain.ownership_txt_token = random_string(30)
Session.commit()
return f"{prefix}-verification={domain.ownership_txt_token}"
valid = [
f"{prefix}-verification={domain.ownership_txt_token}" for prefix in prefixes
]
return ExpectedValidationRecords(recommended=valid[0], allowed=valid)
def get_expected_mx_records(self, domain: CustomDomain) -> list[MxRecord]:
records = []
def get_expected_mx_records(
self, domain: CustomDomain
) -> dict[int, ExpectedValidationRecords]:
records = {}
if domain.partner_id is not None and domain.partner_id in self._partner_domains:
domain = self._partner_domains[domain.partner_id]
records.append(MxRecord(10, f"mx1.{domain}."))
records.append(MxRecord(20, f"mx2.{domain}."))
else:
# Default ones
for priority, domain in config.EMAIL_SERVERS_WITH_PRIORITY:
records.append(MxRecord(priority, domain))
records[10] = [f"mx1.{domain}."]
records[20] = [f"mx2.{domain}."]
# Default ones
for priority, domain in config.EMAIL_SERVERS_WITH_PRIORITY:
if priority not in records:
records[priority] = []
records[priority].append(domain)
return records
return {
priority: ExpectedValidationRecords(
recommended=records[priority][0], allowed=records[priority]
)
for priority in records
}
def get_expected_spf_domain(self, domain: CustomDomain) -> str:
def get_expected_spf_domain(
self, domain: CustomDomain
) -> ExpectedValidationRecords:
records = []
if domain.partner_id is not None and domain.partner_id in self._partner_domains:
return self._partner_domains[domain.partner_id]
records.append(self._partner_domains[domain.partner_id])
else:
return config.EMAIL_DOMAIN
records.append(config.EMAIL_DOMAIN)
return ExpectedValidationRecords(recommended=records[0], allowed=records)
def get_expected_spf_record(self, domain: CustomDomain) -> str:
spf_domain = self.get_expected_spf_domain(domain)
return f"v=spf1 include:{spf_domain} ~all"
return f"v=spf1 include:{spf_domain.recommended} ~all"
def get_dkim_records(self, domain: CustomDomain) -> {str: str}:
def get_dkim_records(
self, domain: CustomDomain
) -> {str: ExpectedValidationRecords}:
"""
Get a list of dkim records to set up. Depending on the custom_domain, whether if it's from a partner or not,
it will return the default ones or the partner ones.
"""
# By default use the default domain
dkim_domain = self.dkim_domain
dkim_domains = [self.dkim_domain]
if domain.partner_id is not None:
# Domain is from a partner. Retrieve the partner config and use that domain if exists
dkim_domain = self._partner_domains.get(domain.partner_id, dkim_domain)
# Domain is from a partner. Retrieve the partner config and use that domain as preferred if it exists
partner_domain = self._partner_domains.get(domain.partner_id, None)
if partner_domain is not None:
dkim_domains.insert(0, partner_domain)
return {
f"{key}._domainkey": f"{key}._domainkey.{dkim_domain}"
for key in ("dkim", "dkim02", "dkim03")
}
output = {}
for key in ("dkim", "dkim02", "dkim03"):
records = [
f"{key}._domainkey.{dkim_domain}" for dkim_domain in dkim_domains
]
output[f"{key}._domainkey"] = ExpectedValidationRecords(
recommended=records[0], allowed=records
)
return output
def validate_dkim_records(self, custom_domain: CustomDomain) -> dict[str, str]:
"""
@ -101,7 +162,7 @@ class CustomDomainValidation:
for prefix, expected_record in expected_records.items():
custom_record = f"{prefix}.{custom_domain.domain}"
dkim_record = self._dns_client.get_cname_record(custom_record)
if dkim_record == expected_record:
if dkim_record in expected_record.allowed:
correct_records[prefix] = custom_record
else:
invalid_records[custom_record] = dkim_record or "empty"
@ -121,6 +182,12 @@ class CustomDomainValidation:
# Original DKIM record is not there, which means the DKIM config is not finished. Proceed with the
# rest of the code path, returning the invalid records and clearing the flag
custom_domain.dkim_verified = len(invalid_records) == 0
if custom_domain.dkim_verified:
emit_user_audit_log(
user=custom_domain.user,
action=UserAuditLogAction.VerifyCustomDomain,
message=f"Verified DKIM records for custom domain {custom_domain.id} ({custom_domain.domain})",
)
Session.commit()
return invalid_records
@ -131,12 +198,21 @@ class CustomDomainValidation:
Check if the custom_domain has added the ownership verification records
"""
txt_records = self._dns_client.get_txt_record(custom_domain.domain)
expected_verification_record = self.get_ownership_verification_record(
expected_verification_records = self.get_ownership_verification_record(
custom_domain
)
if expected_verification_record in txt_records:
found = False
for verification_record in expected_verification_records.allowed:
if verification_record in txt_records:
found = True
break
if found:
custom_domain.ownership_verified = True
emit_user_audit_log(
user=custom_domain.user,
action=UserAuditLogAction.VerifyCustomDomain,
message=f"Verified ownership for custom domain {custom_domain.id} ({custom_domain.domain})",
)
Session.commit()
return DomainValidationResult(success=True, errors=[])
else:
@ -149,12 +225,18 @@ class CustomDomainValidation:
expected_mx_records = self.get_expected_mx_records(custom_domain)
if not is_mx_equivalent(mx_domains, expected_mx_records):
return DomainValidationResult(
success=False,
errors=[f"{record.priority} {record.domain}" for record in mx_domains],
)
errors = []
for prio in mx_domains:
for mx_domain in mx_domains[prio]:
errors.append(f"{prio} {mx_domain}")
return DomainValidationResult(success=False, errors=errors)
else:
custom_domain.verified = True
emit_user_audit_log(
user=custom_domain.user,
action=UserAuditLogAction.VerifyCustomDomain,
message=f"Verified MX records for custom domain {custom_domain.id} ({custom_domain.domain})",
)
Session.commit()
return DomainValidationResult(success=True, errors=[])
@ -163,8 +245,13 @@ class CustomDomainValidation:
) -> DomainValidationResult:
spf_domains = self._dns_client.get_spf_domain(custom_domain.domain)
expected_spf_domain = self.get_expected_spf_domain(custom_domain)
if expected_spf_domain in spf_domains:
if len(set(expected_spf_domain.allowed).intersection(set(spf_domains))) > 0:
custom_domain.spf_verified = True
emit_user_audit_log(
user=custom_domain.user,
action=UserAuditLogAction.VerifyCustomDomain,
message=f"Verified SPF records for custom domain {custom_domain.id} ({custom_domain.domain})",
)
Session.commit()
return DomainValidationResult(success=True, errors=[])
else:
@ -183,6 +270,11 @@ class CustomDomainValidation:
txt_records = self._dns_client.get_txt_record("_dmarc." + custom_domain.domain)
if DMARC_RECORD in txt_records:
custom_domain.dmarc_verified = True
emit_user_audit_log(
user=custom_domain.user,
action=UserAuditLogAction.VerifyCustomDomain,
message=f"Verified DMARC records for custom domain {custom_domain.id} ({custom_domain.domain})",
)
Session.commit()
return DomainValidationResult(success=True, errors=[])
else:
@ -194,8 +286,8 @@ class CustomDomainValidation:
self, txt_records: List[str], custom_domain: CustomDomain
) -> List[str]:
final_records = []
verification_record = self.get_ownership_verification_record(custom_domain)
verification_records = self.get_ownership_verification_record(custom_domain)
for record in txt_records:
if record != verification_record:
if record not in verification_records.allowed:
final_records.append(record)
return final_records

View File

@ -1,3 +1,5 @@
import secrets
import arrow
from flask import (
render_template,
@ -163,7 +165,7 @@ def send_reset_password_email(user):
"""
# the activation code is valid for 1h
reset_password_code = ResetPasswordCode.create(
user_id=user.id, code=random_string(60)
user_id=user.id, code=secrets.token_urlsafe(32)
)
Session.commit()
@ -237,6 +239,8 @@ def unlink_proton_account():
flash("Invalid request", "warning")
return redirect(url_for("dashboard.setting"))
perform_proton_account_unlink(current_user)
flash("Your Proton account has been unlinked", "success")
if not perform_proton_account_unlink(current_user):
flash("Account cannot be unlinked", "warning")
else:
flash("Your Proton account has been unlinked", "success")
return redirect(url_for("dashboard.setting"))

View File

@ -1,5 +1,6 @@
from dataclasses import dataclass
from operator import or_
from typing import Optional
from flask import render_template, request, redirect, flash
from flask import url_for
@ -10,6 +11,7 @@ from wtforms import StringField, validators, ValidationError
# Need to import directly from config to allow modification from the tests
from app import config, parallel_limiter, contact_utils
from app.alias_audit_log_utils import emit_alias_audit_log, AliasAuditLogAction
from app.contact_utils import ContactCreateError
from app.dashboard.base import dashboard_bp
from app.db import Session
@ -190,7 +192,7 @@ def get_contact_infos(
def delete_contact(alias: Alias, contact_id: int):
contact = Contact.get(contact_id)
contact: Optional[Contact] = Contact.get(contact_id)
if not contact:
flash("Unknown error. Refresh the page", "warning")
@ -198,6 +200,11 @@ def delete_contact(alias: Alias, contact_id: int):
flash("You cannot delete reverse-alias", "warning")
else:
delete_contact_email = contact.website_email
emit_alias_audit_log(
alias=alias,
action=AliasAuditLogAction.DeleteContact,
message=f"Delete contact {contact_id} ({contact.email})",
)
Contact.delete(contact_id)
Session.commit()
@ -220,7 +227,10 @@ def alias_contact_manager(alias_id):
page = 0
if request.args.get("page"):
page = int(request.args.get("page"))
try:
page = int(request.args.get("page"))
except ValueError:
pass
query = request.args.get("query") or ""

View File

@ -7,6 +7,7 @@ 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_audit_log_utils import emit_alias_audit_log, AliasAuditLogAction
from app.alias_utils import transfer_alias
from app.dashboard.base import dashboard_bp
from app.dashboard.views.enter_sudo import sudo_required
@ -57,6 +58,12 @@ def alias_transfer_send_route(alias_id):
transfer_token = f"{alias.id}.{secrets.token_urlsafe(32)}"
alias.transfer_token = hmac_alias_transfer_token(transfer_token)
alias.transfer_token_expiration = arrow.utcnow().shift(hours=24)
emit_alias_audit_log(
alias,
AliasAuditLogAction.InitiateTransferAlias,
"Initiated alias transfer",
)
Session.commit()
alias_transfer_url = (
config.URL

View File

@ -1,8 +1,11 @@
from typing import Optional
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.alias_audit_log_utils import emit_alias_audit_log, AliasAuditLogAction
from app.dashboard.base import dashboard_bp
from app.db import Session
from app.models import Contact
@ -20,7 +23,7 @@ class PGPContactForm(FlaskForm):
@dashboard_bp.route("/contact/<int:contact_id>/", methods=["GET", "POST"])
@login_required
def contact_detail_route(contact_id):
contact = Contact.get(contact_id)
contact: Optional[Contact] = Contact.get(contact_id)
if not contact or contact.user_id != current_user.id:
flash("You cannot see this page", "warning")
return redirect(url_for("dashboard.index"))
@ -50,6 +53,11 @@ def contact_detail_route(contact_id):
except PGPException:
flash("Cannot add the public key, please verify it", "error")
else:
emit_alias_audit_log(
alias=alias,
action=AliasAuditLogAction.UpdateContact,
message=f"Added PGP key {contact.pgp_public_key} for contact {contact_id} ({contact.email})",
)
Session.commit()
flash(
f"PGP public key for {contact.email} is saved successfully",
@ -62,6 +70,11 @@ def contact_detail_route(contact_id):
)
elif pgp_form.action.data == "remove":
# Free user can decide to remove contact PGP key
emit_alias_audit_log(
alias=alias,
action=AliasAuditLogAction.UpdateContact,
message=f"Removed PGP key {contact.pgp_public_key} for contact {contact_id} ({contact.email})",
)
contact.pgp_public_key = None
contact.pgp_finger_print = None
Session.commit()

View File

@ -1,17 +1,15 @@
import arrow
from flask import render_template, flash, redirect, url_for, request
from flask import render_template, flash, redirect, url_for
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.coupon_utils import redeem_coupon, CouponUserCannotRedeemError
from app.dashboard.base import dashboard_bp
from app.db import Session
from app.log import LOG
from app.models import (
ManualSubscription,
Coupon,
Subscription,
AppleSubscription,
CoinbaseSubscription,
@ -58,56 +56,23 @@ def coupon_route():
if coupon_form.validate_on_submit():
code = coupon_form.code.data
coupon: Coupon = Coupon.get_by(code=code)
if coupon and not coupon.used:
if coupon.expires_date and coupon.expires_date < arrow.now():
flash(
f"The coupon was expired on {coupon.expires_date.humanize()}",
"error",
)
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)
manual_sub: ManualSubscription = ManualSubscription.get_by(
user_id=current_user.id
)
if manual_sub:
# renew existing subscription
if manual_sub.end_at > arrow.now():
manual_sub.end_at = manual_sub.end_at.shift(years=coupon.nb_year)
else:
manual_sub.end_at = arrow.now().shift(years=coupon.nb_year, days=1)
Session.commit()
flash(
f"Your current subscription is extended to {manual_sub.end_at.humanize()}",
"success",
)
else:
ManualSubscription.create(
user_id=current_user.id,
end_at=arrow.now().shift(years=coupon.nb_year, days=1),
comment="using coupon code",
is_giveaway=coupon.is_giveaway,
commit=True,
)
try:
coupon = redeem_coupon(code, current_user)
if coupon:
flash(
"Your account has been upgraded to Premium, thanks for your support!",
"success",
)
return redirect(url_for("dashboard.index"))
else:
flash(f"Code *{code}* expired or invalid", "warning")
else:
flash(
"This coupon cannot be redeemed. It's invalid or has expired",
"warning",
)
except CouponUserCannotRedeemError:
flash(
"You have an active subscription. Please remove it before redeeming a coupon",
"warning",
)
return render_template(
"dashboard/coupon.html",

View File

@ -8,6 +8,7 @@ from app.dashboard.base import dashboard_bp
from app.dashboard.views.enter_sudo import sudo_required
from app.log import LOG
from app.models import Subscription, Job
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
class DeleteDirForm(FlaskForm):
@ -33,6 +34,11 @@ def delete_account():
# Schedule delete account job
LOG.w("schedule delete account job for %s", current_user)
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UserMarkedForDeletion,
message=f"User {current_user.id} ({current_user.email}) marked for deletion via webapp",
)
Job.create(
name=JOB_DELETE_ACCOUNT,
payload={"user_id": current_user.id},

View File

@ -1,3 +1,5 @@
from typing import Optional
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
@ -20,6 +22,7 @@ from app.dashboard.base import dashboard_bp
from app.db import Session
from app.errors import DirectoryInTrashError
from app.models import Directory, Mailbox, DirectoryMailbox
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
class NewDirForm(FlaskForm):
@ -69,7 +72,9 @@ def directory():
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_obj: Optional[Directory] = Directory.get(
delete_dir_form.directory_id.data
)
if not dir_obj:
flash("Unknown error. Refresh the page", "warning")
@ -79,6 +84,11 @@ def directory():
return redirect(url_for("dashboard.directory"))
name = dir_obj.name
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.DeleteDirectory,
message=f"Delete directory {dir_obj.id} ({dir_obj.name})",
)
Directory.delete(dir_obj.id)
Session.commit()
flash(f"Directory {name} has been deleted", "success")
@ -90,7 +100,7 @@ def directory():
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_obj: Optional[Directory] = Directory.get(dir_id)
if not dir_obj or dir_obj.user_id != current_user.id:
flash("Unknown error. Refresh the page", "warning")
@ -103,6 +113,11 @@ def directory():
dir_obj.disabled = True
flash(f"On-the-fly is disabled for {dir_obj.name}", "warning")
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UpdateDirectory,
message=f"Updated directory {dir_obj.id} ({dir_obj.name}) set disabled = {dir_obj.disabled}",
)
Session.commit()
return redirect(url_for("dashboard.directory"))
@ -112,7 +127,7 @@ def directory():
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_obj: Optional[Directory] = Directory.get(dir_id)
if not dir_obj or dir_obj.user_id != current_user.id:
flash("Unknown error. Refresh the page", "warning")
@ -143,6 +158,12 @@ def directory():
for mailbox in mailboxes:
DirectoryMailbox.create(directory_id=dir_obj.id, mailbox_id=mailbox.id)
mailboxes_as_str = ",".join(map(str, mailbox_ids))
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UpdateDirectory,
message=f"Updated directory {dir_obj.id} ({dir_obj.name}) mailboxes ({mailboxes_as_str})",
)
Session.commit()
flash(f"Directory {dir_obj.name} has been updated", "success")
@ -181,6 +202,11 @@ def directory():
new_dir = Directory.create(
name=new_dir_name, user_id=current_user.id
)
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.CreateDirectory,
message=f"New directory {new_dir.name} ({new_dir.name})",
)
except DirectoryInTrashError:
flash(
f"{new_dir_name} has been used before and cannot be reused",

View File

@ -5,8 +5,8 @@ from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import StringField, validators, IntegerField
from app.constants import DMARC_RECORD
from app.config import EMAIL_SERVERS_WITH_PRIORITY, EMAIL_DOMAIN
from app.constants import DMARC_RECORD
from app.custom_domain_utils import delete_custom_domain, set_custom_domain_mailboxes
from app.custom_domain_validation import CustomDomainValidation
from app.dashboard.base import dashboard_bp
@ -20,6 +20,7 @@ from app.models import (
AutoCreateRuleMailbox,
)
from app.regex_utils import regex_match
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
from app.utils import random_string, CSRFValidationForm
@ -136,7 +137,7 @@ def domain_detail_dns(custom_domain_id):
return render_template(
"dashboard/domain_detail/dns.html",
EMAIL_SERVERS_WITH_PRIORITY=EMAIL_SERVERS_WITH_PRIORITY,
ownership_record=domain_validator.get_ownership_verification_record(
ownership_records=domain_validator.get_ownership_verification_record(
custom_domain
),
expected_mx_records=domain_validator.get_expected_mx_records(custom_domain),
@ -164,6 +165,11 @@ def domain_detail(custom_domain_id):
return redirect(request.url)
if request.form.get("form-name") == "switch-catch-all":
custom_domain.catch_all = not custom_domain.catch_all
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UpdateCustomDomain,
message=f"Switched custom domain {custom_domain.id} ({custom_domain.domain}) catch all to {custom_domain.catch_all}",
)
Session.commit()
if custom_domain.catch_all:
@ -182,6 +188,11 @@ def domain_detail(custom_domain_id):
elif request.form.get("form-name") == "set-name":
if request.form.get("action") == "save":
custom_domain.name = request.form.get("alias-name").replace("\n", "")
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UpdateCustomDomain,
message=f"Switched custom domain {custom_domain.id} ({custom_domain.domain}) name",
)
Session.commit()
flash(
f"Default alias name for Domain {custom_domain.domain} has been set",
@ -189,6 +200,11 @@ def domain_detail(custom_domain_id):
)
else:
custom_domain.name = None
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UpdateCustomDomain,
message=f"Cleared custom domain {custom_domain.id} ({custom_domain.domain}) name",
)
Session.commit()
flash(
f"Default alias name for Domain {custom_domain.domain} has been removed",
@ -202,6 +218,11 @@ def domain_detail(custom_domain_id):
custom_domain.random_prefix_generation = (
not custom_domain.random_prefix_generation
)
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UpdateCustomDomain,
message=f"Switched custom domain {custom_domain.id} ({custom_domain.domain}) random prefix generation to {custom_domain.random_prefix_generation}",
)
Session.commit()
if custom_domain.random_prefix_generation:

View File

@ -71,7 +71,10 @@ def index():
page = 0
if request.args.get("page"):
page = int(request.args.get("page"))
try:
page = int(request.args.get("page"))
except ValueError:
pass
highlight_alias_id = None
if request.args.get("highlight_alias_id"):
@ -149,7 +152,9 @@ def index():
)
flash(f"Alias {email} has been deleted", "success")
elif request.form.get("form-name") == "disable-alias":
alias_utils.change_alias_status(alias, enabled=False)
alias_utils.change_alias_status(
alias, enabled=False, message="Set enabled=False from dashboard"
)
Session.commit()
flash(f"Alias {alias.email} has been disabled", "success")

View File

@ -3,11 +3,9 @@ from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from wtforms import StringField, validators
from app.config import ADMIN_EMAIL
from app import parallel_limiter
from app.coupon_utils import redeem_lifetime_coupon
from app.dashboard.base import dashboard_bp
from app.db import Session
from app.email_utils import send_email
from app.models import LifetimeCoupon
class CouponForm(FlaskForm):
@ -16,6 +14,7 @@ class CouponForm(FlaskForm):
@dashboard_bp.route("/lifetime_licence", methods=["GET", "POST"])
@login_required
@parallel_limiter.lock()
def lifetime_licence():
if current_user.lifetime:
flash("You already have a lifetime licence", "warning")
@ -32,28 +31,12 @@ def lifetime_licence():
if coupon_form.validate_on_submit():
code = coupon_form.code.data
coupon: LifetimeCoupon = LifetimeCoupon.get_by(code=code)
if coupon and coupon.nb_used > 0:
coupon.nb_used -= 1
current_user.lifetime = True
current_user.lifetime_coupon_id = coupon.id
if coupon.paid:
current_user.paid_lifetime = True
Session.commit()
# notify admin
send_email(
ADMIN_EMAIL,
subject=f"User {current_user} used lifetime coupon({coupon.comment}). Coupon nb_used: {coupon.nb_used}",
plaintext="",
html="",
)
coupon = redeem_lifetime_coupon(code, current_user)
if coupon:
flash("You are upgraded to lifetime premium!", "success")
return redirect(url_for("dashboard.index"))
else:
flash(f"Code *{code}* expired or invalid", "warning")
flash("Coupon code expired or invalid", "warning")
return render_template("dashboard/lifetime_licence.html", coupon_form=coupon_form)

View File

@ -1,6 +1,7 @@
import base64
import binascii
import json
from typing import Optional
from flask import render_template, request, redirect, url_for, flash
from flask_login import login_required, current_user
@ -15,6 +16,7 @@ from app.dashboard.base import dashboard_bp
from app.db import Session
from app.log import LOG
from app.models import Mailbox
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
from app.utils import CSRFValidationForm
@ -119,10 +121,16 @@ def mailbox_route():
@login_required
def mailbox_verify():
mailbox_id = request.args.get("mailbox_id")
if not mailbox_id:
LOG.i("Missing mailbox_id")
flash("You followed an invalid link", "error")
return redirect(url_for("dashboard.mailbox_route"))
code = request.args.get("code")
if not code:
# Old way
return verify_with_signed_secret(mailbox_id)
try:
mailbox = mailbox_utils.verify_mailbox_code(current_user, mailbox_id, code)
except mailbox_utils.MailboxError as e:
@ -151,7 +159,7 @@ def verify_with_signed_secret(request: str):
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)
mailbox: Optional[Mailbox] = Mailbox.get(mailbox_id)
if not mailbox:
flash("Invalid link", "error")
return redirect(url_for("dashboard.mailbox_route"))
@ -161,6 +169,11 @@ def verify_with_signed_secret(request: str):
return redirect(url_for("dashboard.mailbox_route"))
mailbox.verified = True
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.VerifyMailbox,
message=f"Verified mailbox {mailbox.id} ({mailbox.email})",
)
Session.commit()
LOG.d("Mailbox %s is verified", mailbox)

View File

@ -1,30 +1,31 @@
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 wtforms import validators
from wtforms.fields.html5 import EmailField
from wtforms.fields.simple import StringField
from app import mailbox_utils
from app.config import ENFORCE_SPF, MAILBOX_SECRET
from app.config import URL
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 email_can_be_used_as_mailbox
from app.email_utils import mailbox_already_used, render, send_email
from app.extensions import limiter
from app.log import LOG
from app.models import Alias, AuthorizedAddress
from app.mailbox_utils import (
perform_mailbox_email_change,
MailboxEmailChangeError,
MailboxError,
)
from app.models import AuthorizedAddress
from app.models import Mailbox
from app.pgp_utils import PGPException, load_public_key_and_check
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
from app.utils import sanitize_email, CSRFValidationForm
class ChangeEmailForm(FlaskForm):
email = EmailField(
email = StringField(
"email", validators=[validators.DataRequired(), validators.Email()]
)
@ -55,41 +56,30 @@ def mailbox_detail_route(mailbox_id):
request.form.get("form-name") == "update-email"
and change_email_form.validate_on_submit()
):
new_email = sanitize_email(change_email_form.email.data)
if new_email != mailbox.email and not pending_email:
# check if this email is not already used
if mailbox_already_used(new_email, current_user) or Alias.get_by(
email=new_email
):
flash(f"Email {new_email} already used", "error")
elif not email_can_be_used_as_mailbox(new_email):
flash("You cannot use this email address as your mailbox", "error")
else:
mailbox.new_email = new_email
Session.commit()
try:
verify_mailbox_change(current_user, mailbox, new_email)
except SMTPRecipientsRefused:
flash(
f"Incorrect mailbox, please recheck {mailbox.email}",
"error",
)
else:
flash(
f"You are going to receive an email to confirm {new_email}.",
"success",
)
return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
)
try:
response = mailbox_utils.request_mailbox_email_change(
current_user, mailbox, change_email_form.email.data
)
flash(
f"You are going to receive an email to confirm {mailbox.email}.",
"success",
)
except mailbox_utils.MailboxError as e:
flash(e.msg, "error")
return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
)
elif request.form.get("form-name") == "force-spf":
if not ENFORCE_SPF:
flash("SPF enforcement globally not enabled", "error")
return redirect(url_for("dashboard.index"))
mailbox.force_spf = (
True if request.form.get("spf-status") == "on" else False
force_spf_value = request.form.get("spf-status") == "on"
mailbox.force_spf = force_spf_value
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UpdateMailbox,
message=f"Set force_spf to {force_spf_value} on mailbox {mailbox_id} ({mailbox.email})",
)
Session.commit()
flash(
@ -113,6 +103,11 @@ def mailbox_detail_route(mailbox_id):
if AuthorizedAddress.get_by(mailbox_id=mailbox.id, email=address):
flash(f"{address} already added", "error")
else:
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UpdateMailbox,
message=f"Add authorized address {address} to mailbox {mailbox_id} ({mailbox.email})",
)
AuthorizedAddress.create(
user_id=current_user.id,
mailbox_id=mailbox.id,
@ -133,6 +128,11 @@ def mailbox_detail_route(mailbox_id):
flash("Unknown error. Refresh the page", "warning")
else:
address = authorized_address.email
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UpdateMailbox,
message=f"Remove authorized address {address} from mailbox {mailbox_id} ({mailbox.email})",
)
AuthorizedAddress.delete(authorized_address_id)
Session.commit()
flash(f"{address} has been deleted", "success")
@ -165,6 +165,11 @@ def mailbox_detail_route(mailbox_id):
except PGPException:
flash("Cannot add the public key, please verify it", "error")
else:
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UpdateMailbox,
message=f"Add PGP Key {mailbox.pgp_finger_print} to mailbox {mailbox_id} ({mailbox.email})",
)
Session.commit()
flash("Your PGP public key is saved successfully", "success")
return redirect(
@ -172,6 +177,11 @@ def mailbox_detail_route(mailbox_id):
)
elif request.form.get("action") == "remove":
# Free user can decide to remove their added PGP key
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UpdateMailbox,
message=f"Remove PGP Key {mailbox.pgp_finger_print} from mailbox {mailbox_id} ({mailbox.email})",
)
mailbox.pgp_public_key = None
mailbox.pgp_finger_print = None
mailbox.disable_pgp = False
@ -191,9 +201,19 @@ def mailbox_detail_route(mailbox_id):
)
else:
mailbox.disable_pgp = False
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UpdateMailbox,
message=f"Enabled PGP for mailbox {mailbox_id} ({mailbox.email})",
)
flash(f"PGP is enabled on {mailbox.email}", "info")
else:
mailbox.disable_pgp = True
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UpdateMailbox,
message=f"Disabled PGP for mailbox {mailbox_id} ({mailbox.email})",
)
flash(f"PGP is disabled on {mailbox.email}", "info")
Session.commit()
@ -203,6 +223,11 @@ def mailbox_detail_route(mailbox_id):
elif request.form.get("form-name") == "generic-subject":
if request.form.get("action") == "save":
mailbox.generic_subject = request.form.get("generic-subject")
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UpdateMailbox,
message=f"Set generic subject for mailbox {mailbox_id} ({mailbox.email})",
)
Session.commit()
flash("Generic subject is enabled", "success")
return redirect(
@ -210,6 +235,11 @@ def mailbox_detail_route(mailbox_id):
)
elif request.form.get("action") == "remove":
mailbox.generic_subject = None
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UpdateMailbox,
message=f"Remove generic subject for mailbox {mailbox_id} ({mailbox.email})",
)
Session.commit()
flash("Generic subject is disabled", "success")
return redirect(
@ -220,91 +250,57 @@ def mailbox_detail_route(mailbox_id):
return render_template("dashboard/mailbox_detail.html", **locals())
def verify_mailbox_change(user, mailbox, new_email):
s = TimestampSigner(MAILBOX_SECRET)
mailbox_id_signed = s.sign(str(mailbox.id)).decode()
verification_url = (
f"{URL}/dashboard/mailbox/confirm_change?mailbox_id={mailbox_id_signed}"
)
send_email(
new_email,
"Confirm mailbox change on SimpleLogin",
render(
"transactional/verify-mailbox-change.txt.jinja2",
user=user,
link=verification_url,
mailbox_email=mailbox.email,
mailbox_new_email=new_email,
),
render(
"transactional/verify-mailbox-change.html",
user=user,
link=verification_url,
mailbox_email=mailbox.email,
mailbox_new_email=new_email,
),
)
@dashboard_bp.route(
"/mailbox/<int:mailbox_id>/cancel_email_change", methods=["GET", "POST"]
)
@login_required
def cancel_mailbox_change_route(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"))
if mailbox.new_email:
mailbox.new_email = None
Session.commit()
try:
mailbox_utils.cancel_email_change(mailbox_id, current_user)
flash("Your mailbox change is cancelled", "success")
return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
)
else:
flash("You have no pending mailbox change", "warning")
return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
)
except MailboxError as e:
flash(e.msg, "warning")
return redirect(url_for("dashboard.index"))
@dashboard_bp.route("/mailbox/confirm_change")
def mailbox_confirm_change_route():
s = TimestampSigner(MAILBOX_SECRET)
signed_mailbox_id = request.args.get("mailbox_id")
@login_required
@limiter.limit("3/minute")
def mailbox_confirm_email_change_route():
mailbox_id = request.args.get("mailbox_id")
try:
mailbox_id = int(s.unsign(signed_mailbox_id, max_age=900))
except Exception:
flash("Invalid link", "error")
return redirect(url_for("dashboard.index"))
else:
mailbox = Mailbox.get(mailbox_id)
# new_email can be None if user cancels change in the meantime
if mailbox and mailbox.new_email:
user = mailbox.user
if Mailbox.get_by(email=mailbox.new_email, user_id=user.id):
flash(f"{mailbox.new_email} is already used", "error")
return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox.id)
)
mailbox.email = mailbox.new_email
mailbox.new_email = None
# mark mailbox as verified if the change request is sent from an unverified mailbox
mailbox.verified = True
Session.commit()
LOG.d("Mailbox change %s is verified", mailbox)
flash(f"The {mailbox.email} is updated", "success")
code = request.args.get("code")
if code:
try:
mailbox = mailbox_utils.verify_mailbox_code(current_user, mailbox_id, code)
flash("Successfully changed mailbox email", "success")
return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox.id)
)
else:
except mailbox_utils.MailboxError as e:
flash(f"Cannot verify mailbox: {e.msg}", "error")
return redirect(url_for("dashboard.mailbox_route"))
else:
s = TimestampSigner(MAILBOX_SECRET)
try:
mailbox_id = int(s.unsign(mailbox_id, max_age=900))
res = perform_mailbox_email_change(mailbox_id)
flash(res.message, res.message_category)
if res.error:
if res.error == MailboxEmailChangeError.EmailAlreadyUsed:
return redirect(
url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id)
)
elif res.error == MailboxEmailChangeError.InvalidId:
return redirect(url_for("dashboard.index"))
else:
raise Exception("Unhandled MailboxEmailChangeError")
except Exception:
flash("Invalid link", "error")
return redirect(url_for("dashboard.index"))
flash("Successfully changed mailbox email", "success")
return redirect(url_for("dashboard.mailbox_detail_route", mailbox_id=mailbox_id))

View File

@ -43,7 +43,10 @@ def notification_route(notification_id):
def notifications_route():
page = 0
if request.args.get("page"):
page = int(request.args.get("page"))
try:
page = int(request.args.get("page"))
except ValueError:
pass
notifications = (
Notification.filter_by(user_id=current_user.id)

View File

@ -41,7 +41,7 @@ from app.models import (
PartnerSubscription,
UnsubscribeBehaviourEnum,
)
from app.proton.utils import get_proton_partner
from app.proton.utils import get_proton_partner, can_unlink_proton_account
from app.utils import (
random_string,
CSRFValidationForm,
@ -174,7 +174,12 @@ def setting():
flash("Your preference has been updated", "success")
return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "random-alias-suffix":
scheme = int(request.form.get("random-alias-suffix-generator"))
try:
scheme = int(request.form.get("random-alias-suffix-generator"))
except ValueError:
flash("Invalid value", "error")
return redirect(url_for("dashboard.setting"))
if AliasSuffixEnum.has_value(scheme):
current_user.random_alias_suffix = scheme
Session.commit()
@ -318,4 +323,5 @@ def setting():
ALIAS_RAND_SUFFIX_LENGTH=ALIAS_RANDOM_SUFFIX_LENGTH,
connect_with_proton=CONNECT_WITH_PROTON,
proton_linked_account=proton_linked_account,
can_unlink_proton_account=can_unlink_proton_account(current_user),
)

View File

@ -11,6 +11,7 @@ from app.dashboard.base import dashboard_bp
from app.errors import SubdomainInTrashError
from app.log import LOG
from app.models import CustomDomain, Mailbox, SLDomain
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
# Only lowercase letters, numbers, dashes (-) are currently supported
_SUBDOMAIN_PATTERN = r"[0-9a-z-]{1,}"
@ -102,6 +103,12 @@ def subdomain_route():
ownership_verified=True,
commit=True,
)
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.CreateCustomDomain,
message=f"Create subdomain {new_custom_domain.id} ({full_domain})",
commit=True,
)
except SubdomainInTrashError:
flash(
f"{full_domain} has been used before and cannot be reused",

View File

@ -32,7 +32,9 @@ def unsubscribe(alias_id):
# automatic unsubscribe, according to https://tools.ietf.org/html/rfc8058
if request.method == "POST":
alias_utils.change_alias_status(alias, False)
alias_utils.change_alias_status(
alias, enabled=False, message="Set enabled=False from unsubscribe request"
)
flash(f"Alias {alias.email} has been blocked", "success")
Session.commit()

View File

@ -1,5 +1,4 @@
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import List, Optional
import dns.resolver
@ -9,42 +8,13 @@ from app.config import NAMESERVERS
_include_spf = "include:"
@dataclass
class MxRecord:
priority: int
domain: str
def is_mx_equivalent(
mx_domains: List[MxRecord], ref_mx_domains: List[MxRecord]
) -> bool:
"""
Compare mx_domains with ref_mx_domains to see if they are equivalent.
mx_domains and ref_mx_domains are list of (priority, domain)
The priority order is taken into account but not the priority number.
For example, [(1, domain1), (2, domain2)] is equivalent to [(10, domain1), (20, domain2)]
"""
mx_domains = sorted(mx_domains, key=lambda x: x.priority)
ref_mx_domains = sorted(ref_mx_domains, key=lambda x: x.priority)
if len(mx_domains) < len(ref_mx_domains):
return False
for actual, expected in zip(mx_domains, ref_mx_domains):
if actual.domain != expected.domain:
return False
return True
class DNSClient(ABC):
@abstractmethod
def get_cname_record(self, hostname: str) -> Optional[str]:
pass
@abstractmethod
def get_mx_domains(self, hostname: str) -> List[MxRecord]:
def get_mx_domains(self, hostname: str) -> dict[int, list[str]]:
pass
def get_spf_domain(self, hostname: str) -> List[str]:
@ -88,21 +58,24 @@ class NetworkDNSClient(DNSClient):
except Exception:
return None
def get_mx_domains(self, hostname: str) -> List[MxRecord]:
def get_mx_domains(self, hostname: str) -> dict[int, list[str]]:
"""
return list of (priority, domain name) sorted by priority (lowest priority first)
domain name ends with a "." at the end.
"""
ret = {}
try:
answers = self._resolver.resolve(hostname, "MX", search=True)
ret = []
for a in answers:
record = a.to_text() # for ex '20 alt2.aspmx.l.google.com.'
parts = record.split(" ")
ret.append(MxRecord(priority=int(parts[0]), domain=parts[1]))
return sorted(ret, key=lambda x: x.priority)
prio = int(parts[0])
if prio not in ret:
ret[prio] = []
ret[prio].append(parts[1])
except Exception:
return []
pass
return ret
def get_txt_record(self, hostname: str) -> List[str]:
try:
@ -119,14 +92,14 @@ class NetworkDNSClient(DNSClient):
class InMemoryDNSClient(DNSClient):
def __init__(self):
self.cname_records: dict[str, Optional[str]] = {}
self.mx_records: dict[str, List[MxRecord]] = {}
self.mx_records: dict[int, dict[int, list[str]]] = {}
self.spf_records: dict[str, List[str]] = {}
self.txt_records: dict[str, List[str]] = {}
def set_cname_record(self, hostname: str, cname: str):
self.cname_records[hostname] = cname
def set_mx_records(self, hostname: str, mx_list: List[MxRecord]):
def set_mx_records(self, hostname: str, mx_list: dict[int, list[str]]):
self.mx_records[hostname] = mx_list
def set_txt_record(self, hostname: str, txt_list: List[str]):
@ -135,9 +108,8 @@ class InMemoryDNSClient(DNSClient):
def get_cname_record(self, hostname: str) -> Optional[str]:
return self.cname_records.get(hostname)
def get_mx_domains(self, hostname: str) -> List[MxRecord]:
mx_list = self.mx_records.get(hostname, [])
return sorted(mx_list, key=lambda x: x.priority)
def get_mx_domains(self, hostname: str) -> dict[int, list[str]]:
return self.mx_records.get(hostname, {})
def get_txt_record(self, hostname: str) -> List[str]:
return self.txt_records.get(hostname, [])
@ -147,5 +119,5 @@ def get_network_dns_client() -> NetworkDNSClient:
return NetworkDNSClient(NAMESERVERS)
def get_mx_domains(hostname: str) -> List[MxRecord]:
def get_mx_domains(hostname: str) -> dict[int, list[str]]:
return get_network_dns_client().get_mx_domains(hostname)

View File

@ -657,7 +657,11 @@ def get_mx_domain_list(domain) -> [str]:
"""
priority_domains = get_mx_domains(domain)
return [d.domain[:-1] for d in priority_domains]
mx_domains = []
for prio in priority_domains:
for domain in priority_domains[prio]:
mx_domains.append(domain[:-1])
return mx_domains
def personal_email_already_used(email_address: str) -> bool:
@ -1345,17 +1349,16 @@ def get_queue_id(msg: Message) -> Optional[str]:
received_header = str(msg[headers.RECEIVED])
if not received_header:
return
return None
# received_header looks like 'from mail-wr1-x434.google.com (mail-wr1-x434.google.com [IPv6:2a00:1450:4864:20::434])\r\n\t(using TLSv1.3 with cipher TLS_AES_128_GCM_SHA256 (128/128 bits))\r\n\t(No client certificate requested)\r\n\tby mx1.simplelogin.co (Postfix) with ESMTPS id 4FxQmw1DXdz2vK2\r\n\tfor <jglfdjgld@alias.com>; Fri, 4 Jun 2021 14:55:43 +0000 (UTC)'
search_result = re.search("with ESMTPS id [0-9a-zA-Z]{1,}", received_header)
if not search_result:
return
# the "with ESMTPS id 4FxQmw1DXdz2vK2" part
with_esmtps = received_header[search_result.start() : search_result.end()]
return with_esmtps[len("with ESMTPS id ") :]
search_result = re.search(r"with E?SMTP[AS]? id ([0-9a-zA-Z]{1,})", received_header)
if search_result:
return search_result.group(1)
search_result = re.search("\(Postfix\)\r\n\tid ([a-zA-Z0-9]{1,});", received_header)
if search_result:
return search_result.group(1)
return None
def should_ignore_bounce(mail_from: str) -> bool:

View File

@ -24,7 +24,7 @@ _sym_db = _symbol_database.Default()
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0b\x65vent.proto\x12\x12simplelogin_events\"(\n\x0fUserPlanChanged\x12\x15\n\rplan_end_time\x18\x01 \x01(\r\"\r\n\x0bUserDeleted\"\\\n\x0c\x41liasCreated\x12\n\n\x02id\x18\x01 \x01(\r\x12\r\n\x05\x65mail\x18\x02 \x01(\t\x12\x0c\n\x04note\x18\x03 \x01(\t\x12\x0f\n\x07\x65nabled\x18\x04 \x01(\x08\x12\x12\n\ncreated_at\x18\x05 \x01(\r\"T\n\x12\x41liasStatusChanged\x12\n\n\x02id\x18\x01 \x01(\r\x12\r\n\x05\x65mail\x18\x02 \x01(\t\x12\x0f\n\x07\x65nabled\x18\x03 \x01(\x08\x12\x12\n\ncreated_at\x18\x04 \x01(\r\")\n\x0c\x41liasDeleted\x12\n\n\x02id\x18\x01 \x01(\r\x12\r\n\x05\x65mail\x18\x02 \x01(\t\"D\n\x10\x41liasCreatedList\x12\x30\n\x06\x65vents\x18\x01 \x03(\x0b\x32 .simplelogin_events.AliasCreated\"\x93\x03\n\x0c\x45ventContent\x12?\n\x10user_plan_change\x18\x01 \x01(\x0b\x32#.simplelogin_events.UserPlanChangedH\x00\x12\x37\n\x0cuser_deleted\x18\x02 \x01(\x0b\x32\x1f.simplelogin_events.UserDeletedH\x00\x12\x39\n\ralias_created\x18\x03 \x01(\x0b\x32 .simplelogin_events.AliasCreatedH\x00\x12\x45\n\x13\x61lias_status_change\x18\x04 \x01(\x0b\x32&.simplelogin_events.AliasStatusChangedH\x00\x12\x39\n\ralias_deleted\x18\x05 \x01(\x0b\x32 .simplelogin_events.AliasDeletedH\x00\x12\x41\n\x11\x61lias_create_list\x18\x06 \x01(\x0b\x32$.simplelogin_events.AliasCreatedListH\x00\x42\t\n\x07\x63ontent\"y\n\x05\x45vent\x12\x0f\n\x07user_id\x18\x01 \x01(\r\x12\x18\n\x10\x65xternal_user_id\x18\x02 \x01(\t\x12\x12\n\npartner_id\x18\x03 \x01(\r\x12\x31\n\x07\x63ontent\x18\x04 \x01(\x0b\x32 .simplelogin_events.EventContentb\x06proto3')
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0b\x65vent.proto\x12\x12simplelogin_events\":\n\x0fUserPlanChanged\x12\x15\n\rplan_end_time\x18\x01 \x01(\r\x12\x10\n\x08lifetime\x18\x02 \x01(\x08\"\r\n\x0bUserDeleted\"\\\n\x0c\x41liasCreated\x12\n\n\x02id\x18\x01 \x01(\r\x12\r\n\x05\x65mail\x18\x02 \x01(\t\x12\x0c\n\x04note\x18\x03 \x01(\t\x12\x0f\n\x07\x65nabled\x18\x04 \x01(\x08\x12\x12\n\ncreated_at\x18\x05 \x01(\r\"T\n\x12\x41liasStatusChanged\x12\n\n\x02id\x18\x01 \x01(\r\x12\r\n\x05\x65mail\x18\x02 \x01(\t\x12\x0f\n\x07\x65nabled\x18\x03 \x01(\x08\x12\x12\n\ncreated_at\x18\x04 \x01(\r\")\n\x0c\x41liasDeleted\x12\n\n\x02id\x18\x01 \x01(\r\x12\r\n\x05\x65mail\x18\x02 \x01(\t\"D\n\x10\x41liasCreatedList\x12\x30\n\x06\x65vents\x18\x01 \x03(\x0b\x32 .simplelogin_events.AliasCreated\"\x93\x03\n\x0c\x45ventContent\x12?\n\x10user_plan_change\x18\x01 \x01(\x0b\x32#.simplelogin_events.UserPlanChangedH\x00\x12\x37\n\x0cuser_deleted\x18\x02 \x01(\x0b\x32\x1f.simplelogin_events.UserDeletedH\x00\x12\x39\n\ralias_created\x18\x03 \x01(\x0b\x32 .simplelogin_events.AliasCreatedH\x00\x12\x45\n\x13\x61lias_status_change\x18\x04 \x01(\x0b\x32&.simplelogin_events.AliasStatusChangedH\x00\x12\x39\n\ralias_deleted\x18\x05 \x01(\x0b\x32 .simplelogin_events.AliasDeletedH\x00\x12\x41\n\x11\x61lias_create_list\x18\x06 \x01(\x0b\x32$.simplelogin_events.AliasCreatedListH\x00\x42\t\n\x07\x63ontent\"y\n\x05\x45vent\x12\x0f\n\x07user_id\x18\x01 \x01(\r\x12\x18\n\x10\x65xternal_user_id\x18\x02 \x01(\t\x12\x12\n\npartner_id\x18\x03 \x01(\r\x12\x31\n\x07\x63ontent\x18\x04 \x01(\x0b\x32 .simplelogin_events.EventContentb\x06proto3')
_globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
@ -32,19 +32,19 @@ _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'event_pb2', _globals)
if not _descriptor._USE_C_DESCRIPTORS:
DESCRIPTOR._loaded_options = None
_globals['_USERPLANCHANGED']._serialized_start=35
_globals['_USERPLANCHANGED']._serialized_end=75
_globals['_USERDELETED']._serialized_start=77
_globals['_USERDELETED']._serialized_end=90
_globals['_ALIASCREATED']._serialized_start=92
_globals['_ALIASCREATED']._serialized_end=184
_globals['_ALIASSTATUSCHANGED']._serialized_start=186
_globals['_ALIASSTATUSCHANGED']._serialized_end=270
_globals['_ALIASDELETED']._serialized_start=272
_globals['_ALIASDELETED']._serialized_end=313
_globals['_ALIASCREATEDLIST']._serialized_start=315
_globals['_ALIASCREATEDLIST']._serialized_end=383
_globals['_EVENTCONTENT']._serialized_start=386
_globals['_EVENTCONTENT']._serialized_end=789
_globals['_EVENT']._serialized_start=791
_globals['_EVENT']._serialized_end=912
_globals['_USERPLANCHANGED']._serialized_end=93
_globals['_USERDELETED']._serialized_start=95
_globals['_USERDELETED']._serialized_end=108
_globals['_ALIASCREATED']._serialized_start=110
_globals['_ALIASCREATED']._serialized_end=202
_globals['_ALIASSTATUSCHANGED']._serialized_start=204
_globals['_ALIASSTATUSCHANGED']._serialized_end=288
_globals['_ALIASDELETED']._serialized_start=290
_globals['_ALIASDELETED']._serialized_end=331
_globals['_ALIASCREATEDLIST']._serialized_start=333
_globals['_ALIASCREATEDLIST']._serialized_end=401
_globals['_EVENTCONTENT']._serialized_start=404
_globals['_EVENTCONTENT']._serialized_end=807
_globals['_EVENT']._serialized_start=809
_globals['_EVENT']._serialized_end=930
# @@protoc_insertion_point(module_scope)

View File

@ -6,10 +6,12 @@ from typing import ClassVar as _ClassVar, Iterable as _Iterable, Mapping as _Map
DESCRIPTOR: _descriptor.FileDescriptor
class UserPlanChanged(_message.Message):
__slots__ = ("plan_end_time",)
__slots__ = ("plan_end_time", "lifetime")
PLAN_END_TIME_FIELD_NUMBER: _ClassVar[int]
LIFETIME_FIELD_NUMBER: _ClassVar[int]
plan_end_time: int
def __init__(self, plan_end_time: _Optional[int] = ...) -> None: ...
lifetime: bool
def __init__(self, plan_end_time: _Optional[int] = ..., lifetime: bool = ...) -> None: ...
class UserDeleted(_message.Message):
__slots__ = ()

View File

@ -33,8 +33,11 @@ from app.models import (
SLDomain,
Hibp,
AliasHibp,
PartnerUser,
PartnerSubscription,
)
from app.pgp_utils import load_public_key
from app.proton.utils import get_proton_partner
def fake_data():
@ -269,3 +272,27 @@ def fake_data():
CustomDomain.create(
user_id=user.id, domain="old.com", verified=True, ownership_verified=True
)
# Create a user
proton_partner = get_proton_partner()
user = User.create(
email="test@proton.me",
name="Proton test",
password="password",
activated=True,
is_admin=False,
intro_shown=True,
from_partner=True,
flush=True,
)
pu = PartnerUser.create(
user_id=user.id,
partner_id=proton_partner.id,
partner_email="test@proton.me",
external_user_id="DUMMY",
flush=True,
)
PartnerSubscription.create(
partner_user_id=pu.id, end_at=arrow.now().shift(years=1, days=1)
)
Session.commit()

View File

@ -2,8 +2,8 @@ import urllib
from email.header import Header
from email.message import Message
from app.email import headers
from app import config
from app.email import headers
from app.email_utils import add_or_replace_header, delete_header
from app.handler.unsubscribe_encoder import (
UnsubscribeEncoder,
@ -46,7 +46,11 @@ class UnsubscribeGenerator:
if start == -1 or end == -1 or start >= end:
continue
method = raw_method[start + 1 : end]
url_data = urllib.parse.urlparse(method)
try:
url_data = urllib.parse.urlparse(method)
except ValueError:
LOG.debug(f"Unsub has invalid method {method}. Ignoring.")
continue
if url_data.scheme == "mailto":
if url_data.path == config.UNSUBSCRIBER:
LOG.debug(

View File

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

View File

@ -0,0 +1,70 @@
from __future__ import annotations
import base64
from typing import Optional
import arrow
from app import config
from app.errors import ProtonPartnerNotSetUp
from app.events.generated import event_pb2
from app.events.generated.event_pb2 import EventContent
from app.models import (
User,
Job,
PartnerUser,
)
from app.proton.utils import get_proton_partner
from events.event_sink import EventSink
class SendEventToWebhookJob:
def __init__(self, user: User, event: EventContent):
self._user: User = user
self._event: EventContent = event
def run(self, sink: EventSink) -> bool:
# Check if the current user has a partner_id
try:
proton_partner_id = get_proton_partner().id
except ProtonPartnerNotSetUp:
return False
# It has. Retrieve the information for the PartnerUser
partner_user = PartnerUser.get_by(
user_id=self._user.id, partner_id=proton_partner_id
)
if partner_user is None:
return True
event = event_pb2.Event(
user_id=self._user.id,
external_user_id=partner_user.external_user_id,
partner_id=partner_user.partner_id,
content=self._event,
)
serialized = event.SerializeToString()
return sink.send_data_to_webhook(serialized)
@staticmethod
def create_from_job(job: Job) -> Optional[SendEventToWebhookJob]:
user = User.get(job.payload["user_id"])
if not user:
return None
event_data = base64.b64decode(job.payload["event"])
event = event_pb2.EventContent()
event.ParseFromString(event_data)
return SendEventToWebhookJob(user=user, event=event)
def store_job_in_db(self, run_at: Optional[arrow.Arrow]) -> Job:
stub = self._event.SerializeToString()
return Job.create(
name=config.JOB_SEND_EVENT_TO_WEBHOOK,
payload={
"user_id": self._user.id,
"event": base64.b64encode(stub).decode("utf-8"),
},
run_at=run_at if run_at is not None else arrow.now(),
commit=True,
)

View File

@ -10,7 +10,7 @@ from app.config import (
# this format allows clickable link to code source in PyCharm
_log_format = (
"%(asctime)s - %(name)s - %(levelname)s - %(process)d - "
"%(asctime)s - %(name)s - %(levelname)s - %(process)d - %(request_id)s"
'"%(pathname)s:%(lineno)d" - %(funcName)s() - %(message_id)s - %(message)s'
)
_log_formatter = logging.Formatter(_log_format)
@ -37,6 +37,21 @@ class EmailHandlerFilter(logging.Filter):
return _MESSAGE_ID
class RequestIdFilter(logging.Filter):
"""automatically add request-id to keep track of a request"""
def filter(self, record):
from flask import g, has_request_context
request_id = ""
if has_request_context():
ctx_request_id = getattr(g, "request_id")
if ctx_request_id:
request_id = f"{ctx_request_id} - "
record.request_id = request_id
return True
def _get_console_handler():
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setFormatter(_log_formatter)
@ -54,6 +69,7 @@ def _get_logger(name) -> logging.Logger:
logger.addHandler(_get_console_handler())
logger.addFilter(EmailHandlerFilter())
logger.addFilter(RequestIdFilter())
# no propagation to avoid propagating to root logger
logger.propagate = False

View File

@ -1,6 +1,6 @@
import dataclasses
import secrets
import random
from enum import Enum
from typing import Optional
import arrow
@ -12,10 +12,13 @@ from app.email_utils import (
email_can_be_used_as_mailbox,
send_email,
render,
get_email_domain_part,
)
from app.email_validation import is_valid_email
from app.log import LOG
from app.models import User, Mailbox, Job, MailboxActivation
from app.models import User, Mailbox, Job, MailboxActivation, Alias
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
from app.utils import canonicalize_email, sanitize_email
@dataclasses.dataclass
@ -35,8 +38,9 @@ class OnlyPaidError(MailboxError):
class CannotVerifyError(MailboxError):
def __init__(self, msg: str):
def __init__(self, msg: str, deleted_activation_code: bool = False):
self.msg = msg
self.deleted_activation_code = deleted_activation_code
MAX_ACTIVATION_TRIES = 3
@ -50,29 +54,22 @@ def create_mailbox(
use_digit_codes: bool = False,
send_link: bool = True,
) -> CreateMailboxOutput:
email = sanitize_email(email)
if not user.is_premium():
LOG.i(
f"User {user} has tried to create mailbox with {email} but is not premium"
)
raise OnlyPaidError()
if not is_valid_email(email):
LOG.i(
f"User {user} has tried to create mailbox with {email} but is not valid email"
)
raise MailboxError("Invalid email")
elif mailbox_already_used(email, user):
LOG.i(
f"User {user} has tried to create mailbox with {email} but email is already used"
)
raise MailboxError("Email already used")
elif not email_can_be_used_as_mailbox(email):
LOG.i(
f"User {user} has tried to create mailbox with {email} but email is invalid"
)
raise MailboxError("Invalid email")
new_mailbox = Mailbox.create(
check_email_for_mailbox(email, user)
new_mailbox: Mailbox = Mailbox.create(
email=email, user_id=user.id, verified=verified, commit=True
)
emit_user_audit_log(
user=user,
action=UserAuditLogAction.CreateMailbox,
message=f"Create mailbox {new_mailbox.id} ({new_mailbox.email}). Verified={verified}",
commit=True,
)
if verified:
LOG.i(f"User {user} as created a pre-verified mailbox with {email}")
@ -95,8 +92,29 @@ def create_mailbox(
return output
def check_email_for_mailbox(email, user):
if not is_valid_email(email):
LOG.i(
f"User {user} has tried to create mailbox with {email} but is not valid email"
)
raise MailboxError("Invalid email")
elif mailbox_already_used(email, user):
LOG.i(
f"User {user} has tried to create mailbox with {email} but email is already used"
)
raise MailboxError("Email already used")
elif not email_can_be_used_as_mailbox(email):
LOG.i(
f"User {user} has tried to create mailbox with {email} but email is invalid"
)
raise MailboxError("Invalid email")
def delete_mailbox(
user: User, mailbox_id: int, transfer_mailbox_id: Optional[int]
user: User,
mailbox_id: int,
transfer_mailbox_id: Optional[int],
send_mail: bool = True,
) -> Mailbox:
mailbox = Mailbox.get(mailbox_id)
@ -129,7 +147,7 @@ def delete_mailbox(
if not transfer_mailbox.verified:
LOG.i(f"User {user} has tried to transfer to a non verified mailbox")
MailboxError("Your new mailbox is not verified")
raise MailboxError("Your new mailbox is not verified")
# Schedule delete account job
LOG.i(
@ -142,6 +160,7 @@ def delete_mailbox(
"transfer_mailbox_id": transfer_mailbox_id
if transfer_mailbox_id and transfer_mailbox_id > 0
else None,
"send_mail": send_mail,
},
run_at=arrow.now(),
commit=True,
@ -163,17 +182,17 @@ def verify_mailbox_code(user: User, mailbox_id: int, code: str) -> Mailbox:
f"User {user} failed to verify mailbox {mailbox_id} because it does not exist"
)
raise MailboxError("Invalid mailbox")
if mailbox.verified:
LOG.i(
f"User {user} failed to verify mailbox {mailbox_id} because it's already verified"
)
clear_activation_codes_for_mailbox(mailbox)
return mailbox
if mailbox.user_id != user.id:
LOG.i(
f"User {user} failed to verify mailbox {mailbox_id} because it's owned by another user"
)
raise MailboxError("Invalid mailbox")
if mailbox.verified and not mailbox.new_email:
LOG.i(
f"User {user} failed to verify mailbox {mailbox_id} because it's already verified"
)
clear_activation_codes_for_mailbox(mailbox)
return mailbox
activation = (
MailboxActivation.filter(MailboxActivation.mailbox_id == mailbox_id)
@ -188,7 +207,10 @@ def verify_mailbox_code(user: User, mailbox_id: int, code: str) -> Mailbox:
if activation.tries >= MAX_ACTIVATION_TRIES:
LOG.i(f"User {user} failed to verify mailbox {mailbox_id} more than 3 times")
clear_activation_codes_for_mailbox(mailbox)
raise CannotVerifyError("Invalid activation code. Please request another code.")
raise CannotVerifyError(
"Invalid activation code. Please request another code.",
deleted_activation_code=True,
)
if activation.created_at < arrow.now().shift(minutes=-15):
LOG.i(
f"User {user} failed to verify mailbox {mailbox_id} because code is too old"
@ -202,8 +224,34 @@ def verify_mailbox_code(user: User, mailbox_id: int, code: str) -> Mailbox:
activation.tries = activation.tries + 1
Session.commit()
raise CannotVerifyError("Invalid activation code")
LOG.i(f"User {user} has verified mailbox {mailbox_id}")
mailbox.verified = True
if mailbox.new_email:
LOG.i(
f"User {user} has verified mailbox email change from {mailbox.email} to {mailbox.new_email}"
)
emit_user_audit_log(
user=user,
action=UserAuditLogAction.UpdateMailbox,
message=f"Change mailbox email for mailbox {mailbox_id} (old={mailbox.email} | new={mailbox.new_email})",
)
mailbox.email = mailbox.new_email
mailbox.new_email = None
mailbox.verified = True
elif not mailbox.verified:
LOG.i(f"User {user} has verified mailbox {mailbox_id}")
mailbox.verified = True
emit_user_audit_log(
user=user,
action=UserAuditLogAction.VerifyMailbox,
message=f"Verify mailbox {mailbox_id} ({mailbox.email})",
)
if Mailbox.get_by(email=mailbox.new_email, user_id=user.id):
raise MailboxError("That addres is already in use")
else:
LOG.i(
"User {user} alread has mailbox {mailbox} verified and no pending email change"
)
clear_activation_codes_for_mailbox(mailbox)
return mailbox
@ -216,7 +264,7 @@ def generate_activation_code(
if config.MAILBOX_VERIFICATION_OVERRIDE_CODE:
code = config.MAILBOX_VERIFICATION_OVERRIDE_CODE
else:
code = "{:06d}".format(random.randint(1, 999999))
code = "{:06d}".format(secrets.randbelow(1000000))[:6]
else:
code = secrets.token_urlsafe(16)
return MailboxActivation.create(
@ -228,7 +276,10 @@ def generate_activation_code(
def send_verification_email(
user: User, mailbox: Mailbox, activation: MailboxActivation, send_link: bool = True
user: User,
mailbox: Mailbox,
activation: MailboxActivation,
send_link: bool = True,
):
LOG.i(
f"Sending mailbox verification email to {mailbox.email} with send link={send_link}"
@ -261,3 +312,190 @@ def send_verification_email(
mailbox_email=mailbox.email,
),
)
def send_change_email(user: User, mailbox: Mailbox, activation: MailboxActivation):
verification_url = f"{config.URL}/dashboard/mailbox/confirm_change?mailbox_id={mailbox.id}&code={activation.code}"
send_email(
mailbox.new_email,
"Confirm mailbox change on SimpleLogin",
render(
"transactional/verify-mailbox-change.txt.jinja2",
user=user,
link=verification_url,
mailbox_email=mailbox.email,
mailbox_new_email=mailbox.new_email,
),
render(
"transactional/verify-mailbox-change.html",
user=user,
link=verification_url,
mailbox_email=mailbox.email,
mailbox_new_email=mailbox.new_email,
),
)
def request_mailbox_email_change(
user: User,
mailbox: Mailbox,
new_email: str,
email_ownership_verified: bool = False,
send_email: bool = True,
use_digit_codes: bool = False,
) -> CreateMailboxOutput:
new_email = sanitize_email(new_email)
if new_email == mailbox.email:
raise MailboxError("Same email")
check_email_for_mailbox(new_email, user)
if email_ownership_verified:
mailbox.email = new_email
else:
mailbox.new_email = new_email
emit_user_audit_log(
user=user,
action=UserAuditLogAction.UpdateMailbox,
message=f"Updated mailbox {mailbox.id} email ({new_email}) pre-verified({email_ownership_verified}",
)
Session.commit()
if email_ownership_verified:
LOG.i(f"User {user} as created a pre-verified mailbox with {new_email}")
return CreateMailboxOutput(mailbox=mailbox, activation=None)
LOG.i(f"User {user} has updated mailbox email with {new_email}")
activation = generate_activation_code(mailbox, use_digit_code=use_digit_codes)
output = CreateMailboxOutput(mailbox=mailbox, activation=activation)
if not send_email:
LOG.i(f"Skipping sending validation email for mailbox {mailbox}")
return output
send_change_email(
user,
mailbox,
activation=activation,
)
return output
class MailboxEmailChangeError(Enum):
InvalidId = 1
EmailAlreadyUsed = 2
@dataclasses.dataclass
class MailboxEmailChangeResult:
error: Optional[MailboxEmailChangeError]
message: str
message_category: str
def perform_mailbox_email_change(mailbox_id: int) -> MailboxEmailChangeResult:
mailbox: Optional[Mailbox] = Mailbox.get(mailbox_id)
# new_email can be None if user cancels change in the meantime
if mailbox and mailbox.new_email:
user = mailbox.user
if Mailbox.get_by(email=mailbox.new_email, user_id=user.id):
return MailboxEmailChangeResult(
error=MailboxEmailChangeError.EmailAlreadyUsed,
message=f"{mailbox.new_email} is already used",
message_category="error",
)
emit_user_audit_log(
user=user,
action=UserAuditLogAction.UpdateMailbox,
message=f"Change mailbox email for mailbox {mailbox_id} (old={mailbox.email} | new={mailbox.new_email})",
)
mailbox.email = mailbox.new_email
mailbox.new_email = None
# mark mailbox as verified if the change request is sent from an unverified mailbox
mailbox.verified = True
Session.commit()
LOG.d("Mailbox change %s is verified", mailbox)
return MailboxEmailChangeResult(
error=None,
message=f"The {mailbox.email} is updated",
message_category="success",
)
else:
return MailboxEmailChangeResult(
error=MailboxEmailChangeError.InvalidId,
message="Invalid link",
message_category="error",
)
def cancel_email_change(mailbox_id: int, user: User):
mailbox = Mailbox.get(mailbox_id)
if not mailbox:
LOG.i(
f"User {user} has tried to cancel a mailbox an unknown mailbox {mailbox_id}"
)
raise MailboxError("Invalid mailbox")
if mailbox.user.id != user.id:
LOG.i(
f"User {user} has tried to cancel a mailbox {mailbox} owned by another user"
)
raise MailboxError("Invalid mailbox")
mailbox.new_email = None
LOG.i(f"User {mailbox.user} has cancelled mailbox email change")
clear_activation_codes_for_mailbox(mailbox)
def __get_alias_mailbox_from_email(
email_address: str, alias: Alias
) -> Optional[Mailbox]:
for mailbox in alias.mailboxes:
if mailbox.email == email_address:
return mailbox
for authorized_address in mailbox.authorized_addresses:
if authorized_address.email == email_address:
LOG.d(
"Found an authorized address for %s %s %s",
alias,
mailbox,
authorized_address,
)
return mailbox
return None
def __get_alias_mailbox_from_email_or_canonical_email(
email_address: str, alias: Alias
) -> Optional[Mailbox]:
# We need to first check for the uncanonicalized version because we still have users in the db with the
# email non canonicalized. So if it matches the already existing one use that, otherwise check the canonical one
mbox = __get_alias_mailbox_from_email(email_address, alias)
if mbox is not None:
return mbox
canonical_email = canonicalize_email(email_address)
if canonical_email != email_address:
return __get_alias_mailbox_from_email(canonical_email, alias)
return None
def get_mailbox_for_reply_phase(
envelope_mail_from: str, header_mail_from: str, alias
) -> Optional[Mailbox]:
"""return the corresponding mailbox given the mail_from and alias
Usually the mail_from=mailbox.email but it can also be one of the authorized address
"""
mbox = __get_alias_mailbox_from_email_or_canonical_email(envelope_mail_from, alias)
if mbox is not None:
return mbox
if not header_mail_from:
return None
envelope_from_domain = get_email_domain_part(envelope_mail_from)
header_from_domain = get_email_domain_part(header_mail_from)
if envelope_from_domain != header_from_domain:
return None
# For services that use VERP sending (envelope from has encoded data to account for bounces)
# if the domain is the same in the header from as the envelope from we can use the header from
return __get_alias_mailbox_from_email_or_canonical_email(header_mail_from, alias)

View File

@ -24,6 +24,7 @@ from sqlalchemy import text, desc, CheckConstraint, Index, Column
from sqlalchemy.dialects.postgresql import TSVECTOR
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import deferred
from sqlalchemy.orm.exc import ObjectDeletedError
from sqlalchemy.sql import and_
from sqlalchemy_utils import ArrowType
@ -31,7 +32,6 @@ from app import config, rate_limiter
from app import s3
from app.db import Session
from app.dns_utils import get_mx_domains
from app.errors import (
AliasInTrashError,
DirectoryInTrashError,
@ -157,6 +157,8 @@ class File(Base, ModelMixin):
path = sa.Column(sa.String(128), unique=True, nullable=False)
user_id = sa.Column(sa.ForeignKey("users.id", ondelete="cascade"), nullable=True)
__table_args__ = (sa.Index("ix_file_user_id", "user_id"),)
def get_url(self, expires_in=3600):
return s3.get_url(self.path, expires_in)
@ -318,6 +320,8 @@ class HibpNotifiedAlias(Base, ModelMixin):
notified_at = sa.Column(ArrowType, default=arrow.utcnow, nullable=False)
__table_args__ = (sa.Index("ix_hibp_notified_alias_user_id", "user_id"),)
class Fido(Base, ModelMixin):
__tablename__ = "fido"
@ -332,11 +336,13 @@ class Fido(Base, ModelMixin):
name = sa.Column(sa.String(128), nullable=False, unique=False)
user_id = sa.Column(sa.ForeignKey("users.id", ondelete="cascade"), nullable=True)
__table_args__ = (sa.Index("ix_fido_user_id", "user_id"),)
class User(Base, ModelMixin, UserMixin, PasswordOracle):
__tablename__ = "users"
FLAG_DISABLE_CREATE_CONTACTS = 1 << 0
FLAG_FREE_DISABLE_CREATE_CONTACTS = 1 << 0
FLAG_CREATED_FROM_PARTNER = 1 << 1
FLAG_FREE_OLD_ALIAS_LIMIT = 1 << 2
FLAG_CREATED_ALIAS_FROM_PARTNER = 1 << 3
@ -355,7 +361,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
sa.Boolean, default=True, nullable=False, server_default="1"
)
activated = sa.Column(sa.Boolean, default=False, nullable=False, index=True)
activated = sa.Column(sa.Boolean, default=False, nullable=False)
# an account can be disabled if having harmful behavior
disabled = sa.Column(sa.Boolean, default=False, nullable=False, server_default="0")
@ -543,7 +549,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
# bitwise flags. Allow for future expansion
flags = sa.Column(
sa.BigInteger,
default=FLAG_DISABLE_CREATE_CONTACTS,
default=FLAG_FREE_DISABLE_CREATE_CONTACTS,
server_default="0",
nullable=False,
)
@ -564,6 +570,17 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
"ix_users_activated_trial_end_lifetime", activated, trial_end, lifetime
),
sa.Index("ix_users_delete_on", delete_on),
sa.Index("ix_users_default_mailbox_id", default_mailbox_id),
sa.Index(
"ix_users_default_alias_custom_domain_id", default_alias_custom_domain_id
),
sa.Index("ix_users_profile_picture_id", profile_picture_id),
sa.Index(
"idx_users_email_trgm",
"email",
postgresql_ops={"email": "gin_trgm_ops"},
postgresql_using="gin",
),
)
@property
@ -616,10 +633,19 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
if "alternative_id" not in kwargs:
user.alternative_id = str(uuid.uuid4())
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
trail = ". Created from partner" if from_partner else ""
emit_user_audit_log(
user=user,
action=UserAuditLogAction.CreateUser,
message=f"Created user {email}{trail}",
)
# If the user is created from partner, do not notify
# nor give a trial
if from_partner:
user.flags = User.FLAG_CREATED_FROM_PARTNER
user.flags = user.flags | User.FLAG_CREATED_FROM_PARTNER
user.notification = False
user.trial_end = None
Job.create(
@ -1168,7 +1194,7 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
def can_create_contacts(self) -> bool:
if self.is_premium():
return True
if self.flags & User.FLAG_DISABLE_CREATE_CONTACTS == 0:
if self.flags & User.FLAG_FREE_DISABLE_CREATE_CONTACTS == 0:
return True
return not config.DISABLE_CREATE_CONTACTS_FOR_FREE_USERS
@ -1211,6 +1237,8 @@ class ActivationCode(Base, ModelMixin):
expired = sa.Column(ArrowType, nullable=False, default=_expiration_1h)
__table_args__ = (sa.Index("ix_activation_code_user_id", "user_id"),)
def is_expired(self):
return self.expired < arrow.now()
@ -1227,6 +1255,8 @@ class ResetPasswordCode(Base, ModelMixin):
expired = sa.Column(ArrowType, nullable=False, default=_expiration_1h)
__table_args__ = (sa.Index("ix_reset_password_code_user_id", "user_id"),)
def is_expired(self):
return self.expired < arrow.now()
@ -1269,6 +1299,8 @@ class MfaBrowser(Base, ModelMixin):
user = orm.relationship(User)
__table_args__ = (sa.Index("ix_mfa_browser_user_id", "user_id"),)
@classmethod
def create_new(cls, user, token_length=64) -> "MfaBrowser":
found = False
@ -1327,6 +1359,12 @@ class Client(Base, ModelMixin):
user = orm.relationship(User)
referral = orm.relationship("Referral")
__table_args__ = (
sa.Index("ix_client_user_id", "user_id"),
sa.Index("ix_client_icon_id", "icon_id"),
sa.Index("ix_client_referral_id", "referral_id"),
)
def nb_user(self):
return ClientUser.filter_by(client_id=self.id).count()
@ -1375,6 +1413,8 @@ class RedirectUri(Base, ModelMixin):
client = orm.relationship(Client, backref="redirect_uris")
__table_args__ = (sa.Index("ix_redirect_uri_client_id", "client_id"),)
class AuthorizationCode(Base, ModelMixin):
__tablename__ = "authorization_code"
@ -1396,6 +1436,11 @@ class AuthorizationCode(Base, ModelMixin):
expired = sa.Column(ArrowType, nullable=False, default=_expiration_5m)
__table_args__ = (
sa.Index("ix_authorization_code_client_id", "client_id"),
sa.Index("ix_authorization_code_user_id", "user_id"),
)
def is_expired(self):
return self.expired < arrow.now()
@ -1418,6 +1463,11 @@ class OauthToken(Base, ModelMixin):
expired = sa.Column(ArrowType, nullable=False, default=_expiration_1h)
__table_args__ = (
sa.Index("ix_oauth_token_user_id", "user_id"),
sa.Index("ix_oauth_token_client_id", "client_id"),
)
def is_expired(self):
return self.expired < arrow.now()
@ -1571,6 +1621,7 @@ class Alias(Base, ModelMixin):
postgresql_ops={"note": "gin_trgm_ops"},
postgresql_using="gin",
),
Index("ix_alias_original_owner_id", "original_owner_id"),
)
user = orm.relationship(User, foreign_keys=[user_id])
@ -1613,7 +1664,7 @@ class Alias(Base, ModelMixin):
return False
@staticmethod
def get_custom_domain(alias_address) -> Optional["CustomDomain"]:
def get_custom_domain(alias_address: str) -> Optional["CustomDomain"]:
alias_domain = validate_email(
alias_address, check_deliverability=False, allow_smtputf8=False
).domain
@ -1656,6 +1707,11 @@ class Alias(Base, ModelMixin):
custom_domain = Alias.get_custom_domain(email)
if custom_domain:
new_alias.custom_domain_id = custom_domain.id
else:
custom_domain = CustomDomain.get(kw["custom_domain_id"])
# If it comes from a custom domain created from partner. Mark it as created from partner
if custom_domain is not None and custom_domain.partner_id is not None:
new_alias.flags = (new_alias.flags or 0) | Alias.FLAG_PARTNER_CREATED
Session.add(new_alias)
DailyMetric.get_or_create_today_metric().nb_alias += 1
@ -1673,6 +1729,7 @@ class Alias(Base, ModelMixin):
Session.flush()
# Internal import to avoid global import cycles
from app.alias_audit_log_utils import AliasAuditLogAction, emit_alias_audit_log
from app.events.event_dispatcher import EventDispatcher
from app.events.generated.event_pb2 import AliasCreated, EventContent
@ -1684,6 +1741,9 @@ class Alias(Base, ModelMixin):
created_at=int(new_alias.created_at.timestamp),
)
EventDispatcher.send_event(user, EventContent(alias_created=event))
emit_alias_audit_log(
new_alias, AliasAuditLogAction.CreateAlias, "New alias created"
)
return new_alias
@ -1869,13 +1929,16 @@ class Contact(Base, ModelMixin):
__table_args__ = (
sa.UniqueConstraint("alias_id", "website_email", name="uq_contact"),
sa.Index("ix_contact_user_id_id", "user_id", "id"),
)
user_id = sa.Column(
sa.ForeignKey(User.id, ondelete="cascade"), nullable=False, index=True
sa.ForeignKey(User.id, ondelete="cascade"),
nullable=False,
)
alias_id = sa.Column(
sa.ForeignKey(Alias.id, ondelete="cascade"), nullable=False, index=True
sa.ForeignKey(Alias.id, ondelete="cascade"),
nullable=False,
)
name = sa.Column(
@ -2055,11 +2118,15 @@ class Contact(Base, ModelMixin):
class EmailLog(Base, ModelMixin):
__tablename__ = "email_log"
__table_args__ = (Index("ix_email_log_created_at", "created_at"),)
user_id = sa.Column(
sa.ForeignKey(User.id, ondelete="cascade"), nullable=False, index=True
__table_args__ = (
Index("ix_email_log_created_at", "created_at"),
Index("ix_email_log_mailbox_id", "mailbox_id"),
Index("ix_email_log_bounced_mailbox_id", "bounced_mailbox_id"),
Index("ix_email_log_refused_email_id", "refused_email_id"),
Index("ix_email_log_user_id_email_log_id", "user_id", "id"),
)
user_id = sa.Column(sa.ForeignKey(User.id, ondelete="cascade"), nullable=False)
contact_id = sa.Column(
sa.ForeignKey(Contact.id, ondelete="cascade"), nullable=False, index=True
)
@ -2331,10 +2398,12 @@ class AliasUsedOn(Base, ModelMixin):
__table_args__ = (
sa.UniqueConstraint("alias_id", "hostname", name="uq_alias_used"),
sa.Index("ix_alias_used_on_user_id", "user_id"),
)
alias_id = sa.Column(
sa.ForeignKey(Alias.id, ondelete="cascade"), nullable=False, index=True
sa.ForeignKey(Alias.id, ondelete="cascade"),
nullable=False,
)
user_id = sa.Column(sa.ForeignKey(User.id, ondelete="cascade"), nullable=False)
@ -2357,6 +2426,8 @@ class ApiKey(Base, ModelMixin):
user = orm.relationship(User)
__table_args__ = (sa.Index("ix_api_key_user_id", "user_id"),)
@classmethod
def create(cls, user_id, name=None, **kwargs):
code = random_string(60)
@ -2558,6 +2629,7 @@ class DomainDeletedAlias(Base, ModelMixin):
__table_args__ = (
sa.UniqueConstraint("domain_id", "email", name="uq_domain_trash"),
sa.Index("ix_domain_deleted_alias_user_id", "user_id"),
)
email = sa.Column(sa.String(256), nullable=False)
@ -2618,6 +2690,8 @@ class Coupon(Base, ModelMixin):
# a coupon can have an expiration
expires_date = sa.Column(ArrowType, nullable=True)
__table_args__ = (sa.Index("ix_coupon_used_by_user_id", "used_by_user_id"),)
class Directory(Base, ModelMixin):
__tablename__ = "directory"
@ -2632,6 +2706,8 @@ class Directory(Base, ModelMixin):
"Mailbox", secondary="directory_mailbox", lazy="joined"
)
__table_args__ = (sa.Index("ix_directory_user_id", "user_id"),)
@property
def mailboxes(self):
if self._mailboxes:
@ -2692,7 +2768,6 @@ class Job(Base, ModelMixin):
nullable=False,
server_default=str(JobState.ready.value),
default=JobState.ready.value,
index=True,
)
attempts = sa.Column(sa.Integer, nullable=False, server_default="0", default=0)
taken_at = sa.Column(ArrowType, nullable=True)
@ -2705,9 +2780,7 @@ class Job(Base, ModelMixin):
class Mailbox(Base, ModelMixin):
__tablename__ = "mailbox"
user_id = sa.Column(
sa.ForeignKey(User.id, ondelete="cascade"), nullable=False, index=True
)
user_id = sa.Column(sa.ForeignKey(User.id, ondelete="cascade"), nullable=False)
email = sa.Column(sa.String(256), nullable=False, index=True)
verified = sa.Column(sa.Boolean, default=False, nullable=False)
force_spf = sa.Column(sa.Boolean, default=True, server_default="1", nullable=False)
@ -2733,7 +2806,17 @@ class Mailbox(Base, ModelMixin):
generic_subject = sa.Column(sa.String(78), nullable=True)
__table_args__ = (sa.UniqueConstraint("user_id", "email", name="uq_mailbox_user"),)
__table_args__ = (
sa.UniqueConstraint("user_id", "email", name="uq_mailbox_user"),
sa.Index("ix_mailbox_pgp_finger_print", "pgp_finger_print"),
# index on email column using pg_trgm
Index(
"ix_mailbox_email_trgm_idx",
"email",
postgresql_ops={"email": "gin_trgm_ops"},
postgresql_using="gin",
),
)
user = orm.relationship(User, foreign_keys=[user_id])
@ -2870,6 +2953,8 @@ class RefusedEmail(Base, ModelMixin):
# toggle this when email content (stored at full_report_path & path are deleted)
deleted = sa.Column(sa.Boolean, nullable=False, default=False, server_default="0")
__table_args__ = (sa.Index("ix_refused_email_user_id", "user_id"),)
def get_url(self, expires_in=3600):
if self.path:
return s3.get_url(self.path, expires_in)
@ -2892,6 +2977,8 @@ class Referral(Base, ModelMixin):
user = orm.relationship(User, foreign_keys=[user_id], backref="referrals")
__table_args__ = (sa.Index("ix_referral_user_id", "user_id"),)
@property
def nb_user(self) -> int:
return User.filter_by(referral_id=self.id, activated=True).count()
@ -2931,6 +3018,12 @@ class SentAlert(Base, ModelMixin):
to_email = sa.Column(sa.String(256), nullable=False)
alert_type = sa.Column(sa.String(256), nullable=False)
__table_args__ = (
sa.Index("ix_sent_alert_user_id", "user_id"),
sa.Index("ix_sent_alert_to_email", "to_email"),
sa.Index("ix_sent_alert_alert_type", "alert_type"),
)
class AliasMailbox(Base, ModelMixin):
__tablename__ = "alias_mailbox"
@ -2939,7 +3032,8 @@ class AliasMailbox(Base, ModelMixin):
)
alias_id = sa.Column(
sa.ForeignKey(Alias.id, ondelete="cascade"), nullable=False, index=True
sa.ForeignKey(Alias.id, ondelete="cascade"),
nullable=False,
)
mailbox_id = sa.Column(
sa.ForeignKey(Mailbox.id, ondelete="cascade"), nullable=False, index=True
@ -2954,7 +3048,8 @@ class AliasHibp(Base, ModelMixin):
__table_args__ = (sa.UniqueConstraint("alias_id", "hibp_id", name="uq_alias_hibp"),)
alias_id = sa.Column(
sa.Integer(), sa.ForeignKey("alias.id", ondelete="cascade"), index=True
sa.Integer(),
sa.ForeignKey("alias.id", ondelete="cascade"),
)
hibp_id = sa.Column(
sa.Integer(), sa.ForeignKey("hibp.id", ondelete="cascade"), index=True
@ -3176,6 +3271,11 @@ class BatchImport(Base, ModelMixin):
file = orm.relationship(File)
user = orm.relationship(User)
__table_args__ = (
sa.Index("ix_batch_import_file_id", "file_id"),
sa.Index("ix_batch_import_user_id", "user_id"),
)
def nb_alias(self):
return Alias.filter_by(batch_import_id=self.id).count()
@ -3196,6 +3296,7 @@ class AuthorizedAddress(Base, ModelMixin):
__table_args__ = (
sa.UniqueConstraint("mailbox_id", "email", name="uq_authorize_address"),
sa.Index("ix_authorized_address_user_id", "user_id"),
)
mailbox = orm.relationship(Mailbox, backref="authorized_addresses")
@ -3337,6 +3438,8 @@ class Payout(Base, ModelMixin):
user = orm.relationship(User)
__table_args__ = (sa.Index("ix_payout_user_id", "user_id"),)
class IgnoredEmail(Base, ModelMixin):
"""If an email has mail_from and rcpt_to present in this table, discard it by returning 250 status."""
@ -3438,6 +3541,8 @@ class PhoneReservation(Base, ModelMixin):
start = sa.Column(ArrowType, nullable=False)
end = sa.Column(ArrowType, nullable=False)
__table_args__ = (sa.Index("ix_phone_reservation_user_id", "user_id"),)
class PhoneMessage(Base, ModelMixin):
__tablename__ = "phone_message"
@ -3612,6 +3717,11 @@ class ProviderComplaint(Base, ModelMixin):
user = orm.relationship(User, foreign_keys=[user_id])
refused_email = orm.relationship(RefusedEmail, foreign_keys=[refused_email_id])
__table_args__ = (
sa.Index("ix_provider_complaint_user_id", "user_id"),
sa.Index("ix_provider_complaint_refused_email_id", "refused_email_id"),
)
class PartnerApiToken(Base, ModelMixin):
__tablename__ = "partner_api_token"
@ -3655,7 +3765,8 @@ class PartnerUser(Base, ModelMixin):
index=True,
)
partner_id = sa.Column(
sa.ForeignKey("partner.id", ondelete="cascade"), nullable=False, index=True
sa.ForeignKey("partner.id", ondelete="cascade"),
nullable=False,
)
external_user_id = sa.Column(sa.String(128), unique=False, nullable=False)
partner_email = sa.Column(sa.String(255), unique=False, nullable=True)
@ -3682,7 +3793,8 @@ class PartnerSubscription(Base, ModelMixin):
)
# when the partner subscription ends
end_at = sa.Column(ArrowType, nullable=False, index=True)
end_at = sa.Column(ArrowType, nullable=True, index=True)
lifetime = sa.Column(sa.Boolean, default=False, nullable=False, server_default="0")
partner_user = orm.relationship(PartnerUser)
@ -3704,7 +3816,9 @@ class PartnerSubscription(Base, ModelMixin):
return None
def is_active(self):
return self.end_at > arrow.now().shift(days=-_PARTNER_SUBSCRIPTION_GRACE_DAYS)
return self.lifetime or self.end_at > arrow.now().shift(
days=-_PARTNER_SUBSCRIPTION_GRACE_DAYS
)
# endregion
@ -3735,6 +3849,8 @@ class NewsletterUser(Base, ModelMixin):
user = orm.relationship(User)
newsletter = orm.relationship(Newsletter)
__table_args__ = (sa.Index("ix_newsletter_user_user_id", "user_id"),)
class ApiToCookieToken(Base, ModelMixin):
__tablename__ = "api_cookie_token"
@ -3745,6 +3861,11 @@ class ApiToCookieToken(Base, ModelMixin):
user = orm.relationship(User)
api_key = orm.relationship(ApiKey)
__table_args__ = (
sa.Index("ix_api_to_cookie_token_api_key_id", "api_key_id"),
sa.Index("ix_api_to_cookie_token_user_id", "user_id"),
)
@classmethod
def create(cls, **kwargs):
code = secrets.token_urlsafe(32)
@ -3767,17 +3888,19 @@ class SyncEvent(Base, ModelMixin):
sa.Index("ix_sync_event_taken_time", "taken_time"),
)
def mark_as_taken(self) -> bool:
sql = """
UPDATE sync_event
SET taken_time = :taken_time
WHERE id = :sync_event_id
AND taken_time IS NULL
"""
args = {"taken_time": arrow.now().datetime, "sync_event_id": self.id}
res = Session.execute(sql, args)
Session.commit()
def mark_as_taken(self, allow_taken_older_than: Optional[Arrow] = None) -> bool:
try:
taken_condition = ["taken_time IS NULL"]
args = {"taken_time": arrow.now().datetime, "sync_event_id": self.id}
if allow_taken_older_than:
taken_condition.append("taken_time < :taken_older_than")
args["taken_older_than"] = allow_taken_older_than.datetime
sql_taken_condition = "({})".format(" OR ".join(taken_condition))
sql = f"UPDATE sync_event SET taken_time = :taken_time WHERE id = :sync_event_id AND {sql_taken_condition}"
res = Session.execute(sql, args)
Session.commit()
except ObjectDeletedError:
return False
return res.rowcount > 0
@ -3801,3 +3924,39 @@ class SyncEvent(Base, ModelMixin):
.limit(100)
.all()
)
class AliasAuditLog(Base, ModelMixin):
"""This model holds an audit log for all the actions performed to an alias"""
__tablename__ = "alias_audit_log"
user_id = sa.Column(sa.Integer, nullable=False)
alias_id = sa.Column(sa.Integer, nullable=False)
alias_email = sa.Column(sa.String(255), nullable=False)
action = sa.Column(sa.String(255), nullable=False)
message = sa.Column(sa.Text, default=None, nullable=True)
__table_args__ = (
sa.Index("ix_alias_audit_log_user_id", "user_id"),
sa.Index("ix_alias_audit_log_alias_id", "alias_id"),
sa.Index("ix_alias_audit_log_alias_email", "alias_email"),
sa.Index("ix_alias_audit_log_created_at", "created_at"),
)
class UserAuditLog(Base, ModelMixin):
"""This model holds an audit log for all the actions performed by a user"""
__tablename__ = "user_audit_log"
user_id = sa.Column(sa.Integer, nullable=False)
user_email = sa.Column(sa.String(255), nullable=False)
action = sa.Column(sa.String(255), nullable=False)
message = sa.Column(sa.Text, default=None, nullable=True)
__table_args__ = (
sa.Index("ix_user_audit_log_user_id", "user_id"),
sa.Index("ix_user_audit_log_user_email", "user_email"),
sa.Index("ix_user_audit_log_created_at", "created_at"),
)

View File

@ -0,0 +1,55 @@
from typing import Optional
import arrow
from arrow import Arrow
from app import config
from app.models import PartnerUser, PartnerSubscription, User, Job
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
def create_partner_user(
user: User, partner_id: int, partner_email: str, external_user_id: str
) -> PartnerUser:
instance = PartnerUser.create(
user_id=user.id,
partner_id=partner_id,
partner_email=partner_email,
external_user_id=external_user_id,
)
Job.create(
name=config.JOB_SEND_ALIAS_CREATION_EVENTS,
payload={"user_id": user.id},
run_at=arrow.now(),
)
emit_user_audit_log(
user=user,
action=UserAuditLogAction.LinkAccount,
message=f"Linked account to partner_id={partner_id} | partner_email={partner_email} | external_user_id={external_user_id}",
)
return instance
def create_partner_subscription(
partner_user: PartnerUser,
expiration: Optional[Arrow] = None,
lifetime: bool = False,
msg: Optional[str] = None,
) -> PartnerSubscription:
instance = PartnerSubscription.create(
partner_user_id=partner_user.id,
end_at=expiration,
lifetime=lifetime,
)
message = "User upgraded through partner subscription"
if msg:
message += f" | {msg}"
emit_user_audit_log(
user=partner_user.user,
action=UserAuditLogAction.Upgrade,
message=message,
)
return instance

View File

View File

@ -0,0 +1,121 @@
from typing import Optional
import arrow
from coinbase_commerce.error import WebhookInvalidPayload, SignatureVerificationError
from coinbase_commerce.webhook import Webhook
from flask import Flask, request
from app.config import COINBASE_WEBHOOK_SECRET
from app.db import Session
from app.email_utils import send_email, render
from app.log import LOG
from app.models import CoinbaseSubscription, User
from app.subscription_webhook import execute_subscription_webhook
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
def setup_coinbase_commerce(app: Flask):
@app.route("/coinbase", methods=["POST"])
def coinbase_webhook():
# event payload
request_data = request.data.decode("utf-8")
# webhook signature
request_sig = request.headers.get("X-CC-Webhook-Signature", None)
try:
# signature verification and event object construction
event = Webhook.construct_event(
request_data, request_sig, COINBASE_WEBHOOK_SECRET
)
except (WebhookInvalidPayload, SignatureVerificationError) as e:
LOG.e("Invalid Coinbase webhook")
return str(e), 400
LOG.d("Coinbase event %s", event)
if event["type"] == "charge:confirmed":
if handle_coinbase_event(event):
return "success", 200
else:
return "error", 400
return "success", 200
def handle_coinbase_event(event) -> bool:
server_user_id = event["data"]["metadata"]["user_id"]
try:
user_id = int(server_user_id)
except ValueError:
user_id = int(float(server_user_id))
code = event["data"]["code"]
user: Optional[User] = User.get(user_id)
if not user:
LOG.e("User not found %s", user_id)
return False
coinbase_subscription: CoinbaseSubscription = CoinbaseSubscription.get_by(
user_id=user_id
)
if not coinbase_subscription:
LOG.d("Create a coinbase subscription for %s", user)
coinbase_subscription = CoinbaseSubscription.create(
user_id=user_id, end_at=arrow.now().shift(years=1), code=code, commit=True
)
emit_user_audit_log(
user=user,
action=UserAuditLogAction.Upgrade,
message="Upgraded though Coinbase",
commit=True,
)
send_email(
user.email,
"Your SimpleLogin account has been upgraded",
render(
"transactional/coinbase/new-subscription.txt",
user=user,
coinbase_subscription=coinbase_subscription,
),
render(
"transactional/coinbase/new-subscription.html",
user=user,
coinbase_subscription=coinbase_subscription,
),
)
else:
if coinbase_subscription.code != code:
LOG.d("Update code from %s to %s", coinbase_subscription.code, code)
coinbase_subscription.code = code
if coinbase_subscription.is_active():
coinbase_subscription.end_at = coinbase_subscription.end_at.shift(years=1)
else: # already expired subscription
coinbase_subscription.end_at = arrow.now().shift(years=1)
emit_user_audit_log(
user=user,
action=UserAuditLogAction.SubscriptionExtended,
message="Extended coinbase subscription",
)
Session.commit()
send_email(
user.email,
"Your SimpleLogin account has been extended",
render(
"transactional/coinbase/extend-subscription.txt",
user=user,
coinbase_subscription=coinbase_subscription,
),
render(
"transactional/coinbase/extend-subscription.html",
user=user,
coinbase_subscription=coinbase_subscription,
),
)
execute_subscription_webhook(user)
return True

286
app/app/payments/paddle.py Normal file
View File

@ -0,0 +1,286 @@
import arrow
import json
from dateutil.relativedelta import relativedelta
from flask import Flask, request
from app import paddle_utils, paddle_callback
from app.config import (
PADDLE_MONTHLY_PRODUCT_ID,
PADDLE_MONTHLY_PRODUCT_IDS,
PADDLE_YEARLY_PRODUCT_IDS,
PADDLE_COUPON_ID,
)
from app.db import Session
from app.email_utils import send_email, render
from app.log import LOG
from app.models import Subscription, PlanEnum, User, Coupon
from app.subscription_webhook import execute_subscription_webhook
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
from app.utils import random_string
def setup_paddle_callback(app: Flask):
@app.route("/paddle", methods=["GET", "POST"])
def paddle():
LOG.d(f"paddle callback {request.form.get('alert_name')} {request.form}")
# make sure the request comes from Paddle
if not paddle_utils.verify_incoming_request(dict(request.form)):
LOG.e("request not coming from paddle. Request data:%s", dict(request.form))
return "KO", 400
if (
request.form.get("alert_name") == "subscription_created"
): # new user subscribes
# the passthrough is json encoded, e.g.
# request.form.get("passthrough") = '{"user_id": 88 }'
passthrough = json.loads(request.form.get("passthrough"))
user_id = passthrough.get("user_id")
user = User.get(user_id)
subscription_plan_id = int(request.form.get("subscription_plan_id"))
if subscription_plan_id in PADDLE_MONTHLY_PRODUCT_IDS:
plan = PlanEnum.monthly
elif subscription_plan_id in PADDLE_YEARLY_PRODUCT_IDS:
plan = PlanEnum.yearly
else:
LOG.e(
"Unknown subscription_plan_id %s %s",
subscription_plan_id,
request.form,
)
return "No such subscription", 400
sub = Subscription.get_by(user_id=user.id)
if not sub:
LOG.d(f"create a new Subscription for user {user}")
Subscription.create(
user_id=user.id,
cancel_url=request.form.get("cancel_url"),
update_url=request.form.get("update_url"),
subscription_id=request.form.get("subscription_id"),
event_time=arrow.now(),
next_bill_date=arrow.get(
request.form.get("next_bill_date"), "YYYY-MM-DD"
).date(),
plan=plan,
)
emit_user_audit_log(
user=user,
action=UserAuditLogAction.Upgrade,
message="Upgraded through Paddle",
)
else:
LOG.d(f"Update an existing Subscription for user {user}")
sub.cancel_url = request.form.get("cancel_url")
sub.update_url = request.form.get("update_url")
sub.subscription_id = request.form.get("subscription_id")
sub.event_time = arrow.now()
sub.next_bill_date = arrow.get(
request.form.get("next_bill_date"), "YYYY-MM-DD"
).date()
sub.plan = plan
# make sure to set the new plan as not-cancelled
# in case user cancels a plan and subscribes a new plan
sub.cancelled = False
emit_user_audit_log(
user=user,
action=UserAuditLogAction.SubscriptionExtended,
message="Extended Paddle subscription",
)
execute_subscription_webhook(user)
LOG.d("User %s upgrades!", user)
Session.commit()
elif request.form.get("alert_name") == "subscription_payment_succeeded":
subscription_id = request.form.get("subscription_id")
LOG.d("Update subscription %s", subscription_id)
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
# when user subscribes, the "subscription_payment_succeeded" can arrive BEFORE "subscription_created"
# at that time, subscription object does not exist yet
if sub:
sub.event_time = arrow.now()
sub.next_bill_date = arrow.get(
request.form.get("next_bill_date"), "YYYY-MM-DD"
).date()
Session.commit()
execute_subscription_webhook(sub.user)
elif request.form.get("alert_name") == "subscription_cancelled":
subscription_id = request.form.get("subscription_id")
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
if sub:
# cancellation_effective_date should be the same as next_bill_date
LOG.w(
"Cancel subscription %s %s on %s, next bill date %s",
subscription_id,
sub.user,
request.form.get("cancellation_effective_date"),
sub.next_bill_date,
)
sub.event_time = arrow.now()
sub.cancelled = True
emit_user_audit_log(
user=sub.user,
action=UserAuditLogAction.SubscriptionCancelled,
message="Cancelled Paddle subscription",
)
Session.commit()
user = sub.user
send_email(
user.email,
"SimpleLogin - your subscription is canceled",
render(
"transactional/subscription-cancel.txt",
user=user,
end_date=request.form.get("cancellation_effective_date"),
),
)
execute_subscription_webhook(sub.user)
else:
# user might have deleted their account
LOG.i(f"Cancel non-exist subscription {subscription_id}")
return "OK"
elif request.form.get("alert_name") == "subscription_updated":
subscription_id = request.form.get("subscription_id")
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
if sub:
next_bill_date = request.form.get("next_bill_date")
if not next_bill_date:
paddle_callback.failed_payment(sub, subscription_id)
return "OK"
LOG.d(
"Update subscription %s %s on %s, next bill date %s",
subscription_id,
sub.user,
request.form.get("cancellation_effective_date"),
sub.next_bill_date,
)
if (
int(request.form.get("subscription_plan_id"))
== PADDLE_MONTHLY_PRODUCT_ID
):
plan = PlanEnum.monthly
else:
plan = PlanEnum.yearly
sub.cancel_url = request.form.get("cancel_url")
sub.update_url = request.form.get("update_url")
sub.event_time = arrow.now()
sub.next_bill_date = arrow.get(
request.form.get("next_bill_date"), "YYYY-MM-DD"
).date()
sub.plan = plan
# make sure to set the new plan as not-cancelled
sub.cancelled = False
emit_user_audit_log(
user=sub.user,
action=UserAuditLogAction.SubscriptionExtended,
message="Extended Paddle subscription",
)
Session.commit()
execute_subscription_webhook(sub.user)
else:
LOG.w(
f"update non-exist subscription {subscription_id}. {request.form}"
)
return "No such subscription", 400
elif request.form.get("alert_name") == "payment_refunded":
subscription_id = request.form.get("subscription_id")
LOG.d("Refund request for subscription %s", subscription_id)
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
if sub:
user = sub.user
Subscription.delete(sub.id)
emit_user_audit_log(
user=user,
action=UserAuditLogAction.SubscriptionCancelled,
message="Paddle subscription cancelled as user requested a refund",
)
Session.commit()
LOG.e("%s requests a refund", user)
execute_subscription_webhook(sub.user)
elif request.form.get("alert_name") == "subscription_payment_refunded":
subscription_id = request.form.get("subscription_id")
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
LOG.d(
"Handle subscription_payment_refunded for subscription %s",
subscription_id,
)
if not sub:
LOG.w(
"No such subscription for %s, payload %s",
subscription_id,
request.form,
)
return "No such subscription"
plan_id = int(request.form["subscription_plan_id"])
if request.form["refund_type"] == "full":
if plan_id in PADDLE_MONTHLY_PRODUCT_IDS:
LOG.d("subtract 1 month from next_bill_date %s", sub.next_bill_date)
sub.next_bill_date = sub.next_bill_date - relativedelta(months=1)
LOG.d("next_bill_date is %s", sub.next_bill_date)
Session.commit()
elif plan_id in PADDLE_YEARLY_PRODUCT_IDS:
LOG.d("subtract 1 year from next_bill_date %s", sub.next_bill_date)
sub.next_bill_date = sub.next_bill_date - relativedelta(years=1)
LOG.d("next_bill_date is %s", sub.next_bill_date)
Session.commit()
else:
LOG.e("Unknown plan_id %s", plan_id)
else:
LOG.w("partial subscription_payment_refunded, not handled")
execute_subscription_webhook(sub.user)
return "OK"
@app.route("/paddle_coupon", methods=["GET", "POST"])
def paddle_coupon():
LOG.d("paddle coupon callback %s", request.form)
if not paddle_utils.verify_incoming_request(dict(request.form)):
LOG.e("request not coming from paddle. Request data:%s", dict(request.form))
return "KO", 400
product_id = request.form.get("p_product_id")
if product_id != PADDLE_COUPON_ID:
LOG.e("product_id %s not match with %s", product_id, PADDLE_COUPON_ID)
return "KO", 400
email = request.form.get("email")
LOG.d("Paddle coupon request for %s", email)
coupon = Coupon.create(
code=random_string(30),
comment="For 1-year coupon",
expires_date=arrow.now().shift(years=1, days=-1),
commit=True,
)
return (
f"Your 1-year coupon is <b>{coupon.code}</b> <br> "
f"It's valid until <b>{coupon.expires_date.date().isoformat()}</b>"
)

View File

@ -2,11 +2,9 @@ from dataclasses import dataclass
from enum import Enum
from flask import url_for
from typing import Optional
import arrow
from app import config
from app.errors import LinkException
from app.models import User, Partner, Job
from app.models import User, Partner
from app.proton.proton_client import ProtonClient, ProtonUser
from app.account_linking import (
process_login_case,
@ -43,21 +41,12 @@ class ProtonCallbackHandler:
def __init__(self, proton_client: ProtonClient):
self.proton_client = proton_client
def _initial_alias_sync(self, user: User):
Job.create(
name=config.JOB_SEND_ALIAS_CREATION_EVENTS,
payload={"user_id": user.id},
run_at=arrow.now(),
commit=True,
)
def handle_login(self, partner: Partner) -> ProtonCallbackResult:
try:
user = self.__get_partner_user()
if user is None:
return generate_account_not_allowed_to_log_in()
res = process_login_case(user, partner)
self._initial_alias_sync(res.user)
return ProtonCallbackResult(
redirect_to_login=False,
flash_message=None,
@ -86,7 +75,6 @@ class ProtonCallbackHandler:
if user is None:
return generate_account_not_allowed_to_log_in()
res = process_link_case(user, current_user, partner)
self._initial_alias_sync(res.user)
return ProtonCallbackResult(
redirect_to_login=False,
flash_message="Account successfully linked",

View File

@ -16,6 +16,7 @@ PROTON_ERROR_CODE_HV_NEEDED = 9001
PLAN_FREE = 1
PLAN_PREMIUM = 2
PLAN_PREMIUM_LIFETIME = 3
@dataclass
@ -112,10 +113,13 @@ class HttpProtonClient(ProtonClient):
if plan_value == PLAN_FREE:
plan = SLPlan(type=SLPlanType.Free, expiration=None)
elif plan_value == PLAN_PREMIUM:
expiration = info.get("PlanExpiration", "1")
plan = SLPlan(
type=SLPlanType.Premium,
expiration=Arrow.fromtimestamp(info["PlanExpiration"], tzinfo="utc"),
expiration=Arrow.fromtimestamp(expiration, tzinfo="utc"),
)
elif plan_value == PLAN_PREMIUM_LIFETIME:
plan = SLPlan(SLPlanType.PremiumLifetime, expiration=None)
else:
raise Exception(f"Invalid value for plan: {plan_value}")

View File

@ -1,10 +1,12 @@
from newrelic import agent
from typing import Optional
from newrelic import agent
from app.db import Session
from app.log import LOG
from app.errors import ProtonPartnerNotSetUp
from app.log import LOG
from app.models import Partner, PartnerUser, User
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
PROTON_PARTNER_NAME = "Proton"
_PROTON_PARTNER: Optional[Partner] = None
@ -25,13 +27,25 @@ def is_proton_partner(partner: Partner) -> bool:
return partner.name == PROTON_PARTNER_NAME
def perform_proton_account_unlink(current_user: User):
def can_unlink_proton_account(user: User) -> bool:
return (user.flags & User.FLAG_CREATED_FROM_PARTNER) == 0
def perform_proton_account_unlink(current_user: User) -> bool:
if not can_unlink_proton_account(current_user):
return False
proton_partner = get_proton_partner()
partner_user = PartnerUser.get_by(
user_id=current_user.id, partner_id=proton_partner.id
)
if partner_user is not None:
LOG.info(f"User {current_user} has unlinked the account from {partner_user}")
emit_user_audit_log(
user=current_user,
action=UserAuditLogAction.UnlinkAccount,
message=f"User has unlinked the account (email={partner_user.partner_email} | external_user_id={partner_user.external_user_id})",
)
PartnerUser.delete(partner_user.id)
Session.commit()
agent.record_custom_event("AccountUnlinked", {"partner": proton_partner.name})
return True

6
app/app/request_utils.py Normal file
View File

@ -0,0 +1,6 @@
from random import randbytes
from base64 import b64encode
def generate_request_id() -> str:
return b64encode(randbytes(6)).decode()

View File

@ -1,40 +1,16 @@
import requests
from requests import RequestException
from app import config
from app.db import Session
from app.events.event_dispatcher import EventDispatcher
from app.events.generated.event_pb2 import EventContent, UserPlanChanged
from app.log import LOG
from app.models import User
def execute_subscription_webhook(user: User):
webhook_url = config.SUBSCRIPTION_CHANGE_WEBHOOK
if webhook_url is None:
return
subscription_end = user.get_active_subscription_end(
include_partner_subscription=False
)
sl_subscription_end = None
if subscription_end:
sl_subscription_end = subscription_end.timestamp
payload = {
"user_id": user.id,
"is_premium": user.is_premium(),
"active_subscription_end": sl_subscription_end,
}
try:
response = requests.post(webhook_url, json=payload, timeout=2)
if response.status_code == 200:
LOG.i("Sent request to subscription update webhook successfully")
else:
LOG.i(
f"Request to webhook failed with status {response.status_code}: {response.text}"
)
except RequestException as e:
LOG.error(f"Subscription request exception: {e}")
event = UserPlanChanged(plan_end_time=sl_subscription_end)
EventDispatcher.send_event(user, EventContent(user_plan_change=event))
Session.commit()

View File

@ -0,0 +1,44 @@
from enum import Enum
from app.models import User, UserAuditLog
class UserAuditLogAction(Enum):
CreateUser = "create_user"
ActivateUser = "activate_user"
ResetPassword = "reset_password"
Upgrade = "upgrade"
SubscriptionExtended = "subscription_extended"
SubscriptionCancelled = "subscription_cancelled"
LinkAccount = "link_account"
UnlinkAccount = "unlink_account"
CreateMailbox = "create_mailbox"
VerifyMailbox = "verify_mailbox"
UpdateMailbox = "update_mailbox"
DeleteMailbox = "delete_mailbox"
CreateCustomDomain = "create_custom_domain"
VerifyCustomDomain = "verify_custom_domain"
UpdateCustomDomain = "update_custom_domain"
DeleteCustomDomain = "delete_custom_domain"
CreateDirectory = "create_directory"
UpdateDirectory = "update_directory"
DeleteDirectory = "delete_directory"
UserMarkedForDeletion = "user_marked_for_deletion"
DeleteUser = "delete_user"
def emit_user_audit_log(
user: User, action: UserAuditLogAction, message: str, commit: bool = False
):
UserAuditLog.create(
user_id=user.id,
user_email=user.email,
action=action.value,
message=message,
commit=commit,
)

View File

@ -3,6 +3,7 @@ from typing import Optional
from app.db import Session
from app.log import LOG
from app.models import User, SLDomain, CustomDomain, Mailbox
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
class CannotSetAlias(Exception):
@ -54,7 +55,7 @@ def set_default_alias_domain(user: User, domain_name: Optional[str]):
def set_default_mailbox(user: User, mailbox_id: int) -> Mailbox:
mailbox = Mailbox.get(mailbox_id)
mailbox: Optional[Mailbox] = Mailbox.get(mailbox_id)
if not mailbox or mailbox.user_id != user.id:
raise CannotSetMailbox("Invalid mailbox")
@ -67,5 +68,11 @@ def set_default_mailbox(user: User, mailbox_id: int) -> Mailbox:
LOG.i(f"User {user} has set mailbox {mailbox} as his default one")
user.default_mailbox_id = mailbox.id
emit_user_audit_log(
user=user,
action=UserAuditLogAction.UpdateMailbox,
message=f"Set mailbox {mailbox.id} ({mailbox.email}) as default",
)
Session.commit()
return mailbox

View File

@ -1,4 +1,3 @@
import random
import re
import secrets
import string
@ -32,8 +31,9 @@ def random_words(words: int = 2, numbers: int = 0):
fields = [secrets.choice(_words) for i in range(words)]
if numbers > 0:
digits = "".join([str(random.randint(0, 9)) for i in range(numbers)])
return "_".join(fields) + digits
digits = [n for n in range(10)]
suffix = "".join([str(secrets.choice(digits)) for i in range(numbers)])
return "_".join(fields) + suffix
else:
return "_".join(fields)

View File

@ -14,9 +14,9 @@ from sqlalchemy.sql import Insert, text
from app import s3, config
from app.alias_utils import nb_email_log_for_mailbox
from app.api.views.apple import verify_receipt
from app.custom_domain_validation import CustomDomainValidation
from app.custom_domain_validation import CustomDomainValidation, is_mx_equivalent
from app.db import Session
from app.dns_utils import get_mx_domains, is_mx_equivalent
from app.dns_utils import get_mx_domains
from app.email_utils import (
send_email,
send_trial_end_soon_email,
@ -60,8 +60,11 @@ from app.models import (
)
from app.pgp_utils import load_public_key_and_check, PGPException
from app.proton.utils import get_proton_partner
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
from app.utils import sanitize_email
from server import create_light_app
from tasks.clean_alias_audit_log import cleanup_alias_audit_log
from tasks.clean_user_audit_log import cleanup_user_audit_log
from tasks.cleanup_old_imports import cleanup_old_imports
from tasks.cleanup_old_jobs import cleanup_old_jobs
from tasks.cleanup_old_notifications import cleanup_old_notifications
@ -283,8 +286,16 @@ def notify_manual_sub_end():
def poll_apple_subscription():
"""Poll Apple API to update AppleSubscription"""
# todo: only near the end of the subscription
for apple_sub in AppleSubscription.all():
for apple_sub in (
AppleSubscription.filter(
AppleSubscription.expires_date < arrow.now().shift(days=15)
)
.enable_eagerloads(False)
.yield_per(100)
):
if not apple_sub.is_valid():
# Subscription is not valid anymore and hasn't been renewed
continue
if not apple_sub.product_id:
LOG.d("Ignore %s", apple_sub)
continue
@ -897,6 +908,24 @@ def check_mailbox_valid_pgp_keys():
def check_custom_domain():
# Delete custom domains that haven't been verified in a month
for custom_domain in (
CustomDomain.filter(
CustomDomain.verified == False, # noqa: E712
CustomDomain.created_at < arrow.now().shift(months=-1),
)
.enable_eagerloads(False)
.yield_per(100)
):
alias_count = Alias.filter(Alias.custom_domain_id == custom_domain.id).count()
if alias_count > 0:
LOG.warn(
f"Custom Domain {custom_domain} has {alias_count} aliases. Won't delete"
)
else:
LOG.i(f"Deleting unverified old custom domain {custom_domain}")
CustomDomain.delete(custom_domain.id)
LOG.d("Check verified domain for DNS issues")
for custom_domain in CustomDomain.filter_by(verified=True): # type: CustomDomain
@ -968,7 +997,7 @@ def delete_expired_tokens():
LOG.d("Delete api to cookie tokens older than %s, nb row %s", max_time, nb_row)
async def _hibp_check(api_key, queue):
async def _hibp_check(api_key: str, queue: asyncio.Queue):
"""
Uses a single API key to check the queue as fast as possible.
@ -987,11 +1016,16 @@ async def _hibp_check(api_key, queue):
if not alias:
continue
user = alias.user
if user.disabled or not user.is_paid():
if user.disabled or not user.is_premium():
# Mark it as hibp done to skip it as if it had been checked
alias.hibp_last_check = arrow.utcnow()
Session.commit()
continue
if alias.flags & Alias.FLAG_PARTNER_CREATED > 0:
# Mark as hibp done
alias.hibp_last_check = arrow.utcnow()
Session.commit()
continue
LOG.d("Checking HIBP for %s", alias)
@ -1218,7 +1252,7 @@ def notify_hibp():
def clear_users_scheduled_to_be_deleted(dry_run=False):
users = User.filter(
users: List[User] = User.filter(
and_(
User.delete_on.isnot(None),
User.delete_on <= arrow.now().shift(days=-DELETE_GRACE_DAYS),
@ -1230,6 +1264,11 @@ def clear_users_scheduled_to_be_deleted(dry_run=False):
)
if dry_run:
continue
emit_user_audit_log(
user=user,
action=UserAuditLogAction.DeleteUser,
message=f"Delete user {user.id} ({user.email})",
)
User.delete(user.id)
Session.commit()
@ -1241,6 +1280,16 @@ def delete_old_data():
cleanup_old_notifications(oldest_valid)
def clear_alias_audit_log():
oldest_valid = arrow.now().shift(days=-config.AUDIT_LOG_MAX_DAYS)
cleanup_alias_audit_log(oldest_valid)
def clear_user_audit_log():
oldest_valid = arrow.now().shift(days=-config.AUDIT_LOG_MAX_DAYS)
cleanup_user_audit_log(oldest_valid)
if __name__ == "__main__":
LOG.d("Start running cronjob")
parser = argparse.ArgumentParser()
@ -1249,22 +1298,6 @@ if __name__ == "__main__":
"--job",
help="Choose a cron job to run",
type=str,
choices=[
"stats",
"notify_trial_end",
"notify_manual_subscription_end",
"notify_premium_end",
"delete_logs",
"delete_old_data",
"poll_apple_subscription",
"sanity_check",
"delete_old_monitoring",
"check_custom_domain",
"check_hibp",
"notify_hibp",
"cleanup_tokens",
"send_undelivered_mails",
],
)
args = parser.parse_args()
# wrap in an app context to benefit from app setup like database cleanup, sentry integration, etc
@ -1313,4 +1346,10 @@ if __name__ == "__main__":
load_unsent_mails_from_fs_and_resend()
elif args.job == "delete_scheduled_users":
LOG.d("Deleting users scheduled to be deleted")
clear_users_scheduled_to_be_deleted(dry_run=True)
clear_users_scheduled_to_be_deleted()
elif args.job == "clear_alias_audit_log":
LOG.d("Clearing alias audit log")
clear_alias_audit_log()
elif args.job == "clear_user_audit_log":
LOG.d("Clearing user audit log")
clear_user_audit_log()

View File

@ -14,15 +14,28 @@ jobs:
- name: SimpleLogin Custom Domain check
command: python /code/cron.py -j check_custom_domain
shell: /bin/bash
schedule: "15 2 * * *"
schedule: "15 */4 * * *"
captureStderr: true
concurrencyPolicy: Forbid
onFailure:
retry:
maximumRetries: 10
initialDelay: 1
maximumDelay: 30
backoffMultiplier: 2
- name: SimpleLogin HIBP check
command: python /code/cron.py -j check_hibp
shell: /bin/bash
schedule: "15 3 * * *"
schedule: "13 */4 * * *"
captureStderr: true
concurrencyPolicy: Forbid
onFailure:
retry:
maximumRetries: 10
initialDelay: 1
maximumDelay: 30
backoffMultiplier: 2
- name: SimpleLogin Notify HIBP breaches
command: python /code/cron.py -j notify_hibp
@ -31,6 +44,7 @@ jobs:
captureStderr: true
concurrencyPolicy: Forbid
- name: SimpleLogin Delete Logs
command: python /code/cron.py -j delete_logs
shell: /bin/bash
@ -80,3 +94,17 @@ jobs:
schedule: "*/5 * * * *"
captureStderr: true
concurrencyPolicy: Forbid
- name: SimpleLogin clear alias_audit_log old entries
command: python /code/cron.py -j clear_alias_audit_log
shell: /bin/bash
schedule: "0 * * * *" # Once every hour
captureStderr: true
concurrencyPolicy: Forbid
- name: SimpleLogin clear user_audit_log old entries
command: python /code/cron.py -j clear_user_audit_log
shell: /bin/bash
schedule: "0 * * * *" # Once every hour
captureStderr: true
concurrencyPolicy: Forbid

View File

@ -149,6 +149,7 @@ from app.handler.unsubscribe_generator import UnsubscribeGenerator
from app.handler.unsubscribe_handler import UnsubscribeHandler
from app.log import LOG, set_message_id
from app.mail_sender import sl_sendmail
from app.mailbox_utils import get_mailbox_for_reply_phase
from app.message_utils import message_to_bytes
from app.models import (
Alias,
@ -172,12 +173,14 @@ from app.pgp_utils import (
sign_data,
load_public_key_and_check,
)
from app.utils import sanitize_email, canonicalize_email
from app.utils import sanitize_email
from init_app import load_pgp_public_keys
from server import create_light_app
def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Contact:
def get_or_create_contact(
from_header: str, mail_from: str, alias: Alias
) -> Optional[Contact]:
"""
contact_from_header is the RFC 2047 format FROM header
"""
@ -208,6 +211,8 @@ def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Con
automatic_created=True,
from_partner=False,
)
if contact_result.error:
LOG.w(f"Error creating contact: {contact_result.error.value}")
return contact_result.contact
@ -558,7 +563,7 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str
if not user.is_active():
LOG.w(f"User {user} has been soft deleted")
return False, status.E502
return [(False, status.E502)]
if not user.can_send_or_receive():
LOG.i(f"User {user} cannot receive emails")
@ -579,19 +584,48 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str
from_header = get_header_unicode(msg[headers.FROM])
LOG.d("Create or get contact for from_header:%s", from_header)
contact = get_or_create_contact(from_header, envelope.mail_from, alias)
if not contact:
return [(False, status.E504)]
alias = (
contact.alias
) # In case the Session was closed in the get_or_create we re-fetch the alias
reply_to_contact = None
reply_to_contact = []
if msg[headers.REPLY_TO]:
reply_to = get_header_unicode(msg[headers.REPLY_TO])
LOG.d("Create or get contact for reply_to_header:%s", reply_to)
# ignore when reply-to = alias
if reply_to == alias.email:
LOG.i("Reply-to same as alias %s", alias)
else:
reply_to_contact = get_or_create_reply_to_contact(reply_to, alias, msg)
reply_to_header_contents = get_header_unicode(msg[headers.REPLY_TO])
if reply_to_header_contents:
LOG.d(
"Create or get contact for reply_to_header:%s", reply_to_header_contents
)
for reply_to in [
reply_to.strip()
for reply_to in reply_to_header_contents.split(",")
if reply_to.strip()
]:
try:
reply_to_name, reply_to_email = parse_full_address(reply_to)
except ValueError:
LOG.d(f"Could not parse reply-to address {reply_to}")
continue
if reply_to_email == alias.email:
LOG.i("Reply-to same as alias %s", alias)
else:
reply_contact = get_or_create_reply_to_contact(
reply_to_email, alias, msg
)
if reply_contact:
reply_to_contact.append(reply_contact)
if alias.user.delete_on is not None:
LOG.d(f"user {user} is pending to be deleted. Do not forward")
EmailLog.create(
contact_id=contact.id,
user_id=contact.user_id,
blocked=True,
alias_id=contact.alias_id,
commit=True,
)
return [(True, status.E502)]
if not alias.enabled or contact.block_forward:
LOG.d("%s is disabled, do not forward", alias)
@ -683,7 +717,7 @@ def forward_email_to_mailbox(
envelope,
mailbox,
user,
reply_to_contact: Optional[Contact],
reply_to_contacts: list[Contact],
) -> (bool, str):
LOG.d("Forward %s -> %s -> %s", contact, alias, mailbox)
@ -866,11 +900,13 @@ def forward_email_to_mailbox(
add_or_replace_header(msg, "From", new_from_header)
LOG.d("From header, new:%s, old:%s", new_from_header, old_from_header)
if reply_to_contact:
reply_to_header = msg[headers.REPLY_TO]
new_reply_to_header = reply_to_contact.new_addr()
if len(reply_to_contacts) > 0:
original_reply_to = get_header_unicode(msg[headers.REPLY_TO])
new_reply_to_header = ", ".join(
[reply_to_contact.new_addr() for reply_to_contact in reply_to_contacts][:5]
)
add_or_replace_header(msg, "Reply-To", new_reply_to_header)
LOG.d("Reply-To header, new:%s, old:%s", new_reply_to_header, reply_to_header)
LOG.d("Reply-To header, new:%s, old:%s", new_reply_to_header, original_reply_to)
# replace CC & To emails by reverse-alias for all emails that are not alias
try:
@ -1002,7 +1038,6 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str):
return False, status.E503
user = alias.user
mail_from = envelope.mail_from
if not user.can_send_or_receive():
LOG.i(f"User {user} cannot send emails")
@ -1016,13 +1051,15 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str):
return False, dmarc_delivery_status
# Anti-spoofing
mailbox = get_mailbox_from_mail_from(mail_from, alias)
mailbox = get_mailbox_for_reply_phase(
envelope.mail_from, get_header_unicode(msg[headers.FROM]), alias
)
if not mailbox:
if alias.disable_email_spoofing_check:
# ignore this error, use default alias mailbox
LOG.w(
"ignore unknown sender to reverse-alias %s: %s -> %s",
mail_from,
envelope.mail_from,
alias,
contact,
)
@ -1361,32 +1398,6 @@ def replace_original_message_id(alias: Alias, email_log: EmailLog, msg: Message)
msg[headers.REFERENCES] = " ".join(new_message_ids)
def get_mailbox_from_mail_from(mail_from: str, alias) -> Optional[Mailbox]:
"""return the corresponding mailbox given the mail_from and alias
Usually the mail_from=mailbox.email but it can also be one of the authorized address
"""
def __check(email_address: str, alias: Alias) -> Optional[Mailbox]:
for mailbox in alias.mailboxes:
if mailbox.email == email_address:
return mailbox
for authorized_address in mailbox.authorized_addresses:
if authorized_address.email == email_address:
LOG.d(
"Found an authorized address for %s %s %s",
alias,
mailbox,
authorized_address,
)
return mailbox
return None
# We need to first check for the uncanonicalized version because we still have users in the db with the
# email non canonicalized. So if it matches the already existing one use that, otherwise check the canonical one
return __check(mail_from, alias) or __check(canonicalize_email(mail_from), alias)
def handle_unknown_mailbox(
envelope, msg, reply_email: str, user: User, alias: Alias, contact: Contact
):
@ -1502,7 +1513,9 @@ def handle_bounce_forward_phase(msg: Message, email_log: EmailLog):
LOG.w(
f"Disable alias {alias} because {reason}. {alias.mailboxes} {alias.user}. Last contact {contact}"
)
change_alias_status(alias, enabled=False)
change_alias_status(
alias, enabled=False, message=f"Set enabled=False due to {reason}"
)
Notification.create(
user_id=user.id,

View File

@ -12,6 +12,10 @@ class EventSink(ABC):
def process(self, event: SyncEvent) -> bool:
pass
@abstractmethod
def send_data_to_webhook(self, data: bytes) -> bool:
pass
class HttpEventSink(EventSink):
def process(self, event: SyncEvent) -> bool:
@ -21,9 +25,16 @@ class HttpEventSink(EventSink):
LOG.info(f"Sending event {event.id} to {EVENT_WEBHOOK}")
if self.send_data_to_webhook(event.content):
LOG.info(f"Event {event.id} sent successfully to webhook")
return True
return False
def send_data_to_webhook(self, data: bytes) -> bool:
res = requests.post(
url=EVENT_WEBHOOK,
data=event.content,
data=data,
headers={"Content-Type": "application/x-protobuf"},
verify=not EVENT_WEBHOOK_SKIP_VERIFY_SSL,
)
@ -36,7 +47,6 @@ class HttpEventSink(EventSink):
)
return False
else:
LOG.info(f"Event {event.id} sent successfully to webhook")
return True
@ -44,3 +54,7 @@ class ConsoleEventSink(EventSink):
def process(self, event: SyncEvent) -> bool:
LOG.info(f"Handling event {event.id}")
return True
def send_data_to_webhook(self, data: bytes) -> bool:
LOG.info(f"Sending {len(data)} bytes to webhook")
return True

View File

@ -85,24 +85,28 @@ class DeadLetterEventSource(EventSource):
def __init__(self, max_retries: int):
self.__max_retries = max_retries
def execute_loop(
self, on_event: Callable[[SyncEvent], NoReturn]
) -> list[SyncEvent]:
threshold = arrow.utcnow().shift(minutes=-_DEAD_LETTER_THRESHOLD_MINUTES)
events = SyncEvent.get_dead_letter(
older_than=threshold, max_retries=self.__max_retries
)
if events:
LOG.info(f"Got {len(events)} dead letter events")
newrelic.agent.record_custom_metric(
"Custom/dead_letter_events_to_process", len(events)
)
for event in events:
if event.mark_as_taken(allow_taken_older_than=threshold):
on_event(event)
return events
@newrelic.agent.background_task()
def run(self, on_event: Callable[[SyncEvent], NoReturn]):
while True:
try:
threshold = arrow.utcnow().shift(
minutes=-_DEAD_LETTER_THRESHOLD_MINUTES
)
events = SyncEvent.get_dead_letter(
older_than=threshold, max_retries=self.__max_retries
)
if events:
LOG.info(f"Got {len(events)} dead letter events")
if events:
newrelic.agent.record_custom_metric(
"Custom/dead_letter_events_to_process", len(events)
)
for event in events:
on_event(event)
events = self.execute_loop(on_event)
Session.close() # Ensure that we have a new connection and we don't have a dangling tx with a lock
if not events:
LOG.debug("No dead letter events")

View File

@ -56,14 +56,15 @@ def add_sl_domains():
Session.commit()
def add_proton_partner():
def add_proton_partner() -> Partner:
proton_partner = Partner.get_by(name=PROTON_PARTNER_NAME)
if not proton_partner:
Partner.create(
proton_partner = Partner.create(
name=PROTON_PARTNER_NAME,
contact_email="simplelogin@protonmail.com",
)
Session.commit()
return proton_partner
if __name__ == "__main__":

View File

@ -18,8 +18,10 @@ from app.events.event_dispatcher import PostgresDispatcher
from app.import_utils import handle_batch_import
from app.jobs.event_jobs import send_alias_creation_events_for_user
from app.jobs.export_user_data_job import ExportUserDataJob
from app.jobs.send_event_job import SendEventToWebhookJob
from app.log import LOG
from app.models import User, Job, BatchImport, Mailbox, CustomDomain, JobState
from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
from server import create_light_app
@ -128,7 +130,7 @@ def welcome_proton(user):
def delete_mailbox_job(job: Job):
mailbox_id = job.payload.get("mailbox_id")
mailbox = Mailbox.get(mailbox_id)
mailbox: Optional[Mailbox] = Mailbox.get(mailbox_id)
if not mailbox:
return
@ -152,10 +154,18 @@ def delete_mailbox_job(job: Job):
mailbox_email = mailbox.email
user = mailbox.user
emit_user_audit_log(
user=user,
action=UserAuditLogAction.DeleteMailbox,
message=f"Delete mailbox {mailbox.id} ({mailbox.email})",
)
Mailbox.delete(mailbox_id)
Session.commit()
LOG.d("Mailbox %s %s deleted", mailbox_id, mailbox_email)
if not job.payload.get("send_mail", True):
return
if alias_transferred_to:
send_email(
user.email,
@ -244,6 +254,7 @@ def process_job(job: Job):
if not custom_domain:
return
is_subdomain = custom_domain.is_sl_subdomain
domain_name = custom_domain.domain
user = custom_domain.user
@ -251,6 +262,16 @@ def process_job(job: Job):
CustomDomain.delete(custom_domain.id)
Session.commit()
if is_subdomain:
message = f"Delete subdomain {custom_domain_id} ({domain_name})"
else:
message = f"Delete custom domain {custom_domain_id} ({domain_name})"
emit_user_audit_log(
user=user,
action=UserAuditLogAction.DeleteCustomDomain,
message=message,
)
LOG.d("Domain %s deleted", domain_name)
if custom_domain_partner_id is None:
@ -282,6 +303,10 @@ def process_job(job: Job):
send_alias_creation_events_for_user(
user, dispatcher=PostgresDispatcher.get()
)
elif job.name == config.JOB_SEND_EVENT_TO_WEBHOOK:
send_job = SendEventToWebhookJob.create_from_job(job)
if send_job:
send_job.run()
else:
LOG.e("Unknown job name %s", job.name)

View File

@ -1,6 +1,4 @@
abacus
abdomen
abdominal
abide
abiding
ability
@ -1031,7 +1029,6 @@ chosen
chowder
chowtime
chrome
chubby
chuck
chug
chummy
@ -2041,8 +2038,6 @@ dwindling
dynamic
dynamite
dynasty
dyslexia
dyslexic
each
eagle
earache
@ -2081,7 +2076,6 @@ eatery
eating
eats
ebay
ebony
ebook
ecard
eccentric
@ -2375,8 +2369,6 @@ exclude
excluding
exclusion
exclusive
excretion
excretory
excursion
excusable
excusably
@ -2396,8 +2388,6 @@ existing
exit
exodus
exonerate
exorcism
exorcist
expand
expanse
expansion
@ -2483,7 +2473,6 @@ fanning
fantasize
fantastic
fantasy
fascism
fastball
faster
fasting
@ -3028,7 +3017,6 @@ guiding
guileless
guise
gulf
gullible
gully
gulp
gumball
@ -3040,10 +3028,6 @@ gurgle
gurgling
guru
gush
gusto
gusty
gutless
guts
gutter
guy
guzzler
@ -3242,8 +3226,6 @@ humble
humbling
humbly
humid
humiliate
humility
humming
hummus
humongous
@ -3271,7 +3253,6 @@ hurray
hurricane
hurried
hurry
hurt
husband
hush
husked
@ -3292,8 +3273,6 @@ hypnotic
hypnotism
hypnotist
hypnotize
hypocrisy
hypocrite
ibuprofen
ice
iciness
@ -3323,7 +3302,6 @@ image
imaginary
imagines
imaging
imbecile
imitate
imitation
immerse
@ -3746,7 +3724,6 @@ machine
machinist
magazine
magenta
maggot
magical
magician
magma
@ -3968,8 +3945,6 @@ multitude
mumble
mumbling
mumbo
mummified
mummify
mumps
munchkin
mundane
@ -4022,8 +3997,6 @@ napped
napping
nappy
narrow
nastily
nastiness
national
native
nativity
@ -4446,7 +4419,6 @@ pasta
pasted
pastel
pastime
pastor
pastrami
pasture
pasty
@ -4458,7 +4430,6 @@ path
patience
patient
patio
patriarch
patriot
patrol
patronage
@ -4549,7 +4520,6 @@ pettiness
petty
petunia
phantom
phobia
phoenix
phonebook
phoney
@ -4608,7 +4578,6 @@ plot
plow
ploy
pluck
plug
plunder
plunging
plural
@ -4875,7 +4844,6 @@ pupil
puppet
puppy
purchase
pureblood
purebred
purely
pureness
@ -5047,7 +5015,6 @@ recharger
recipient
recital
recite
reckless
reclaim
recliner
reclining
@ -5440,7 +5407,6 @@ rubdown
ruby
ruckus
rudder
rug
ruined
rule
rumble
@ -5448,7 +5414,6 @@ rumbling
rummage
rumor
runaround
rundown
runner
running
runny
@ -5518,7 +5483,6 @@ sandpaper
sandpit
sandstone
sandstorm
sandworm
sandy
sanitary
sanitizer
@ -5541,7 +5505,6 @@ satisfy
saturate
saturday
sauciness
saucy
sauna
savage
savanna
@ -5552,7 +5515,6 @@ savor
saxophone
say
scabbed
scabby
scalded
scalding
scale
@ -5587,7 +5549,6 @@ science
scientist
scion
scoff
scolding
scone
scoop
scooter
@ -5651,8 +5612,6 @@ sedate
sedation
sedative
sediment
seduce
seducing
segment
seismic
seizing
@ -5899,7 +5858,6 @@ skimpily
skincare
skinless
skinning
skinny
skintight
skipper
skipping
@ -6248,17 +6206,12 @@ stifle
stifling
stillness
stilt
stimulant
stimulate
stimuli
stimulus
stinger
stingily
stinging
stingray
stingy
stinking
stinky
stipend
stipulate
stir
@ -6866,7 +6819,6 @@ unbent
unbiased
unbitten
unblended
unblessed
unblock
unbolted
unbounded
@ -6947,7 +6899,6 @@ undertone
undertook
undertow
underuse
underwear
underwent
underwire
undesired
@ -7000,7 +6951,6 @@ unfunded
unglazed
ungloved
unglue
ungodly
ungraded
ungreased
unguarded
@ -7032,7 +6982,6 @@ uninsured
uninvited
union
uniquely
unisexual
unison
unissued
unit
@ -7493,8 +7442,6 @@ wheat
whenever
whiff
whimsical
whinny
whiny
whisking
whoever
whole
@ -7600,7 +7547,6 @@ wrongness
wrought
xbox
xerox
yahoo
yam
yanking
yapping

View File

@ -0,0 +1,45 @@
"""alias_audit_log
Revision ID: 91ed7f46dc81
Revises: 62afa3a10010
Create Date: 2024-10-11 13:22:11.594054
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '91ed7f46dc81'
down_revision = '62afa3a10010'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('alias_audit_log',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('alias_id', sa.Integer(), nullable=False),
sa.Column('alias_email', sa.String(length=255), nullable=False),
sa.Column('action', sa.String(length=255), nullable=False),
sa.Column('message', sa.Text(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_alias_audit_log_alias_email', 'alias_audit_log', ['alias_email'], unique=False)
op.create_index('ix_alias_audit_log_alias_id', 'alias_audit_log', ['alias_id'], unique=False)
op.create_index('ix_alias_audit_log_user_id', 'alias_audit_log', ['user_id'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('ix_alias_audit_log_user_id', table_name='alias_audit_log')
op.drop_index('ix_alias_audit_log_alias_id', table_name='alias_audit_log')
op.drop_index('ix_alias_audit_log_alias_email', table_name='alias_audit_log')
op.drop_table('alias_audit_log')
# ### end Alembic commands ###

View File

@ -0,0 +1,44 @@
"""user_audit_log
Revision ID: 7d7b84779837
Revises: 91ed7f46dc81
Create Date: 2024-10-16 11:52:49.128644
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '7d7b84779837'
down_revision = '91ed7f46dc81'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('user_audit_log',
sa.Column('id', sa.Integer(), autoincrement=True, nullable=False),
sa.Column('created_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=False),
sa.Column('updated_at', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('user_email', sa.String(length=255), nullable=False),
sa.Column('action', sa.String(length=255), nullable=False),
sa.Column('message', sa.Text(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_user_audit_log_user_email', 'user_audit_log', ['user_email'], unique=False)
op.create_index('ix_user_audit_log_user_id', 'user_audit_log', ['user_id'], unique=False)
op.create_index('ix_user_audit_log_created_at', 'user_audit_log', ['created_at'], unique=False)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('ix_user_audit_log_user_id', table_name='user_audit_log')
op.drop_index('ix_user_audit_log_user_email', table_name='user_audit_log')
op.drop_index('ix_user_audit_log_created_at', table_name='user_audit_log')
op.drop_table('user_audit_log')
# ### end Alembic commands ###

View File

@ -0,0 +1,27 @@
"""alias_audit_log_index_created_at
Revision ID: 32f25cbf12f6
Revises: 7d7b84779837
Create Date: 2024-10-16 16:45:36.827161
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '32f25cbf12f6'
down_revision = '7d7b84779837'
branch_labels = None
depends_on = None
def upgrade():
with op.get_context().autocommit_block():
op.create_index('ix_alias_audit_log_created_at', 'alias_audit_log', ['created_at'], unique=False, postgresql_concurrently=True)
def downgrade():
with op.get_context().autocommit_block():
op.drop_index('ix_alias_audit_log_created_at', table_name='alias_audit_log', postgresql_concurrently=True)

View File

@ -0,0 +1,28 @@
"""Preserve user id on alias delete
Revision ID: 4882cc49dde9
Revises: 32f25cbf12f6
Create Date: 2024-11-06 10:10:40.235991
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '4882cc49dde9'
down_revision = '32f25cbf12f6'
branch_labels = None
depends_on = None
def upgrade():
op.add_column('deleted_alias', sa.Column('user_id', sa.Integer(), server_default=None, nullable=True))
with op.get_context().autocommit_block():
op.create_index('ix_deleted_alias_user_id_created_at', 'deleted_alias', ['user_id', 'created_at'], unique=False, postgresql_concurrently=True)
def downgrade():
with op.get_context().autocommit_block():
op.drop_index('ix_deleted_alias_user_id_created_at', table_name='deleted_alias')
op.drop_column('deleted_alias', 'user_id')

View File

@ -0,0 +1,28 @@
"""Revert user id on deleted alias
Revision ID: bc9aa210efa3
Revises: 4882cc49dde9
Create Date: 2024-11-06 12:44:44.129691
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'bc9aa210efa3'
down_revision = '4882cc49dde9'
branch_labels = None
depends_on = None
def upgrade():
with op.get_context().autocommit_block():
op.drop_index('ix_deleted_alias_user_id_created_at', table_name='deleted_alias')
op.drop_column('deleted_alias', 'user_id')
def downgrade():
op.add_column('deleted_alias', sa.Column('user_id', sa.Integer(), server_default=None, nullable=True))
with op.get_context().autocommit_block():
op.create_index('ix_deleted_alias_user_id_created_at', 'deleted_alias', ['user_id', 'created_at'], unique=False, postgresql_concurrently=True)

View File

@ -0,0 +1,30 @@
"""add missing indices on user and mailbox
Revision ID: 842ac670096e
Revises: bc9aa210efa3
Create Date: 2024-11-13 15:55:28.798506
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '842ac670096e'
down_revision = 'bc9aa210efa3'
branch_labels = None
depends_on = None
def upgrade():
with op.get_context().autocommit_block():
op.create_index('ix_mailbox_pgp_finger_print', 'mailbox', ['pgp_finger_print'], unique=False)
op.create_index('ix_users_default_mailbox_id', 'users', ['default_mailbox_id'], unique=False)
# ### end Alembic commands ###
def downgrade():
with op.get_context().autocommit_block():
op.drop_index('ix_users_default_mailbox_id', table_name='users')
op.drop_index('ix_mailbox_pgp_finger_print', table_name='mailbox')

View File

@ -0,0 +1,29 @@
"""add missing indices on email log
Revision ID: 12274da2299f
Revises: 842ac670096e
Create Date: 2024-11-14 10:27:20.371191
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '12274da2299f'
down_revision = '842ac670096e'
branch_labels = None
depends_on = None
def upgrade():
with op.get_context().autocommit_block():
op.create_index('ix_email_log_bounced_mailbox_id', 'email_log', ['bounced_mailbox_id'], unique=False)
op.create_index('ix_email_log_mailbox_id', 'email_log', ['mailbox_id'], unique=False)
def downgrade():
with op.get_context().autocommit_block():
op.drop_index('ix_email_log_mailbox_id', table_name='email_log')
op.drop_index('ix_email_log_bounced_mailbox_id', table_name='email_log')

View File

@ -0,0 +1,102 @@
"""add missing indices for fk constraints
Revision ID: 0f3ee15b0014
Revises: 12274da2299f
Create Date: 2024-11-15 12:29:10.739938
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '0f3ee15b0014'
down_revision = '12274da2299f'
branch_labels = None
depends_on = None
def upgrade():
with op.get_context().autocommit_block():
op.create_index('ix_activation_code_user_id', 'activation_code', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_alias_original_owner_id', 'alias', ['original_owner_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_alias_used_on_user_id', 'alias_used_on', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_api_to_cookie_token_api_key_id', 'api_cookie_token', ['api_key_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_api_to_cookie_token_user_id', 'api_cookie_token', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_api_key_code', 'api_key', ['code'], unique=False, postgresql_concurrently=True)
op.create_index('ix_api_key_user_id', 'api_key', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_authorization_code_client_id', 'authorization_code', ['client_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_authorization_code_user_id', 'authorization_code', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_authorized_address_user_id', 'authorized_address', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_auto_create_rule_custom_domain_id', 'auto_create_rule', ['custom_domain_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_batch_import_file_id', 'batch_import', ['file_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_batch_import_user_id', 'batch_import', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_client_icon_id', 'client', ['icon_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_client_referral_id', 'client', ['referral_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_client_user_id', 'client', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_coupon_used_by_user_id', 'coupon', ['used_by_user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_directory_user_id', 'directory', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_domain_deleted_alias_user_id', 'domain_deleted_alias', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_email_log_refused_email_id', 'email_log', ['refused_email_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_fido_user_id', 'fido', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_file_user_id', 'file', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_hibp_notified_alias_user_id', 'hibp_notified_alias', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_mfa_browser_user_id', 'mfa_browser', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_newsletter_user_user_id', 'newsletter_user', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_oauth_token_client_id', 'oauth_token', ['client_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_oauth_token_user_id', 'oauth_token', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_payout_user_id', 'payout', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_phone_reservation_user_id', 'phone_reservation', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_provider_complaint_refused_email_id', 'provider_complaint', ['refused_email_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_provider_complaint_user_id', 'provider_complaint', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_redirect_uri_client_id', 'redirect_uri', ['client_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_referral_user_id', 'referral', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_refused_email_user_id', 'refused_email', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_reset_password_code_user_id', 'reset_password_code', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_sent_alert_user_id', 'sent_alert', ['user_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_users_default_alias_custom_domain_id', 'users', ['default_alias_custom_domain_id'], unique=False, postgresql_concurrently=True)
op.create_index('ix_users_profile_picture_id', 'users', ['profile_picture_id'], unique=False, postgresql_concurrently=True)
def downgrade():
with op.get_context().autocommit_block():
op.drop_index('ix_users_profile_picture_id', table_name='users')
op.drop_index('ix_users_default_alias_custom_domain_id', table_name='users')
op.drop_index('ix_sent_alert_user_id', table_name='sent_alert')
op.drop_index('ix_reset_password_code_user_id', table_name='reset_password_code')
op.drop_index('ix_refused_email_user_id', table_name='refused_email')
op.drop_index('ix_referral_user_id', table_name='referral')
op.drop_index('ix_redirect_uri_client_id', table_name='redirect_uri')
op.drop_index('ix_provider_complaint_user_id', table_name='provider_complaint')
op.drop_index('ix_provider_complaint_refused_email_id', table_name='provider_complaint')
op.drop_index('ix_phone_reservation_user_id', table_name='phone_reservation')
op.drop_index('ix_payout_user_id', table_name='payout')
op.drop_index('ix_oauth_token_user_id', table_name='oauth_token')
op.drop_index('ix_oauth_token_client_id', table_name='oauth_token')
op.drop_index('ix_newsletter_user_user_id', table_name='newsletter_user')
op.drop_index('ix_mfa_browser_user_id', table_name='mfa_browser')
op.drop_index('ix_hibp_notified_alias_user_id', table_name='hibp_notified_alias')
op.drop_index('ix_file_user_id', table_name='file')
op.drop_index('ix_fido_user_id', table_name='fido')
op.drop_index('ix_email_log_refused_email_id', table_name='email_log')
op.drop_index('ix_domain_deleted_alias_user_id', table_name='domain_deleted_alias')
op.drop_index('ix_directory_user_id', table_name='directory')
op.drop_index('ix_coupon_used_by_user_id', table_name='coupon')
op.drop_index('ix_client_user_id', table_name='client')
op.drop_index('ix_client_referral_id', table_name='client')
op.drop_index('ix_client_icon_id', table_name='client')
op.drop_index('ix_batch_import_user_id', table_name='batch_import')
op.drop_index('ix_batch_import_file_id', table_name='batch_import')
op.drop_index('ix_auto_create_rule_custom_domain_id', table_name='auto_create_rule')
op.drop_index('ix_authorized_address_user_id', table_name='authorized_address')
op.drop_index('ix_authorization_code_user_id', table_name='authorization_code')
op.drop_index('ix_authorization_code_client_id', table_name='authorization_code')
op.drop_index('ix_api_key_user_id', table_name='api_key')
op.drop_index('ix_api_key_code', table_name='api_key')
op.drop_index('ix_api_to_cookie_token_user_id', table_name='api_cookie_token')
op.drop_index('ix_api_to_cookie_token_api_key_id', table_name='api_cookie_token')
op.drop_index('ix_alias_used_on_user_id', table_name='alias_used_on')
op.drop_index('ix_alias_original_owner_id', table_name='alias')
op.drop_index('ix_activation_code_user_id', table_name='activation_code')

View File

@ -0,0 +1,35 @@
"""empty message
Revision ID: 085f77996ce3
Revises: 0f3ee15b0014
Create Date: 2024-11-26 19:20:32.227899
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '085f77996ce3'
down_revision = '0f3ee15b0014'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('partner_subscription', sa.Column('lifetime', sa.Boolean(), server_default='0', nullable=False))
op.alter_column('partner_subscription', 'end_at',
existing_type=postgresql.TIMESTAMP(),
nullable=True)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.alter_column('partner_subscription', 'end_at',
existing_type=postgresql.TIMESTAMP(),
nullable=False)
op.drop_column('partner_subscription', 'lifetime')
# ### end Alembic commands ###

View File

@ -0,0 +1,91 @@
"""index cleanup
Revision ID: d3ff8848c930
Revises: 085f77996ce3
Create Date: 2025-01-30 15:00:02.995813
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = "d3ff8848c930"
down_revision = "085f77996ce3"
branch_labels = None
depends_on = None
def upgrade():
with op.get_context().autocommit_block():
op.drop_index("ix_alias_hibp_alias_id", table_name="alias_hibp")
op.drop_index("ix_alias_mailbox_alias_id", table_name="alias_mailbox")
op.drop_index("ix_alias_used_on_alias_id", table_name="alias_used_on")
op.drop_index("ix_api_key_code", table_name="api_key")
op.drop_index(
"ix_auto_create_rule_custom_domain_id", table_name="auto_create_rule"
)
op.drop_index("ix_contact_alias_id", table_name="contact")
op.create_index(
"ix_email_log_user_id_email_log_id",
"email_log",
["user_id", "id"],
unique=False,
)
op.drop_index("ix_job_state", table_name="job")
op.create_index(
"ix_mailbox_email_trgm_idx",
"mailbox",
["email"],
unique=False,
postgresql_ops={"email": "gin_trgm_ops"},
postgresql_using="gin",
)
op.drop_index("ix_partner_user_partner_id", table_name="partner_user")
op.create_index(
"ix_sent_alert_alert_type", "sent_alert", ["alert_type"], unique=False
)
op.create_index(
"ix_sent_alert_to_email", "sent_alert", ["to_email"], unique=False
)
op.create_index(
"idx_users_email_trgm",
"users",
["email"],
unique=False,
postgresql_ops={"email": "gin_trgm_ops"},
postgresql_using="gin",
)
op.drop_index("ix_users_activated", table_name="users")
op.drop_index("ix_mailbox_user_id", table_name="users")
def downgrade():
with op.get_context().autocommit_block():
op.create_index("ix_users_activated", "users", ["activated"], unique=False)
op.drop_index("idx_users_email_trgm", table_name="users")
op.drop_index("ix_sent_alert_to_email", table_name="sent_alert")
op.drop_index("ix_sent_alert_alert_type", table_name="sent_alert")
op.create_index(
"ix_partner_user_partner_id", "partner_user", ["partner_id"], unique=False
)
op.drop_index("ix_mailbox_email_trgm_idx", table_name="mailbox")
op.create_index("ix_job_state", "job", ["state"], unique=False)
op.drop_index("ix_email_log_user_id_email_log_id", table_name="email_log")
op.create_index("ix_contact_alias_id", "contact", ["alias_id"], unique=False)
op.create_index(
"ix_auto_create_rule_custom_domain_id",
"auto_create_rule",
["custom_domain_id"],
unique=False,
)
op.create_index("ix_api_key_code", "api_key", ["code"], unique=False)
op.create_index(
"ix_alias_used_on_alias_id", "alias_used_on", ["alias_id"], unique=False
)
op.create_index(
"ix_alias_mailbox_alias_id", "alias_mailbox", ["alias_id"], unique=False
)
op.create_index(
"ix_alias_hibp_alias_id", "alias_hibp", ["alias_id"], unique=False
)
op.create_index("ix_mailbox_user_id", "users", ["user_id"], unique=False)

View File

@ -0,0 +1,23 @@
"""index cleanup
Revision ID: 97edba8794f8
Revises: d3ff8848c930
Create Date: 2025-01-31 14:42:22.590597
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = '97edba8794f8'
down_revision = 'd3ff8848c930'
branch_labels = None
depends_on = None
def upgrade():
op.drop_index('ix_email_log_user_id', table_name='email_log')
def downgrade():
op.create_index('ix_email_log_user_id', 'email_log', ['user_id'], unique=False)

View File

@ -0,0 +1,27 @@
"""contact index
Revision ID: 20e7d3ca289a
Revises: 97edba8794f8
Create Date: 2025-02-03 16:52:06.775032
"""
from alembic import op
# revision identifiers, used by Alembic.
revision = '20e7d3ca289a'
down_revision = '97edba8794f8'
branch_labels = None
depends_on = None
def upgrade():
with op.get_context().autocommit_block():
op.create_index('ix_contact_user_id_id', 'contact', ['user_id', 'id'], unique=False)
op.drop_index('ix_contact_user_id', table_name='contact')
def downgrade():
with op.get_context().autocommit_block():
op.create_index('ix_contact_user_id', 'contact', ['user_id'], unique=False)
op.drop_index('ix_contact_user_id_id', table_name='contact')

View File

@ -21,7 +21,7 @@ if max_alias_id == 0:
max_alias_id = Session.query(func.max(Alias.id)).scalar()
print(f"Checking alias {alias_id_start} to {max_alias_id}")
step = 1000
step = 10000
noteSql = "(note = 'Created through Proton' or note = 'Created through partner Proton')"
alias_query = f"UPDATE alias set note = NULL, flags = flags | :flag where id>=:start AND id<:end and {noteSql}"
updated = 0
@ -38,12 +38,12 @@ for batch_start in range(alias_id_start, max_alias_id, step):
updated += rows_done.rowcount
Session.commit()
elapsed = time.time() - start_time
time_per_alias = elapsed / (updated + 1)
last_batch_id = batch_start + step
time_per_alias = elapsed / (last_batch_id)
remaining = max_alias_id - last_batch_id
time_remaining = (max_alias_id - last_batch_id) * time_per_alias
hours_remaining = time_remaining / 3600.0
time_remaining = remaining / time_per_alias
hours_remaining = time_remaining / 60.0
print(
f"\rAlias {batch_start}/{max_alias_id} {updated} {hours_remaining:.2f}hrs remaining"
f"\rAlias {batch_start}/{max_alias_id} {updated} {hours_remaining:.2f} mins remaining"
)
print("")

View File

@ -0,0 +1,62 @@
#!/usr/bin/env python3
import argparse
import time
import arrow
from sqlalchemy import func
from app.events.event_dispatcher import EventDispatcher
from app.events.generated.event_pb2 import UserPlanChanged, EventContent
from app.models import PartnerUser, User
from app.db import Session
parser = argparse.ArgumentParser(
prog="Backfill alias", description="Send lifetime users to proton"
)
parser.add_argument(
"-s", "--start_pu_id", default=0, type=int, help="Initial partner_user_id"
)
parser.add_argument(
"-e", "--end_pu_id", default=0, type=int, help="Last partner_user_id"
)
args = parser.parse_args()
pu_id_start = args.start_pu_id
max_pu_id = args.end_pu_id
if max_pu_id == 0:
max_pu_id = Session.query(func.max(PartnerUser.id)).scalar()
print(f"Checking partner user {pu_id_start} to {max_pu_id}")
step = 1000
done = 0
start_time = time.time()
with_lifetime = 0
for batch_start in range(pu_id_start, max_pu_id, step):
users = (
Session.query(User)
.join(PartnerUser, PartnerUser.user_id == User.id)
.filter(
PartnerUser.id >= batch_start,
PartnerUser.id < batch_start + step,
User.lifetime == True, # noqa :E712
)
).all()
for user in users:
# Just in case the == True cond is wonky
if not user.lifetime:
continue
with_lifetime += 1
event = UserPlanChanged(plan_end_time=arrow.get("2038-01-01").timestamp)
EventDispatcher.send_event(user, EventContent(user_plan_change=event))
Session.flush()
Session.commit()
elapsed = time.time() - start_time
last_batch_id = batch_start + step
time_per_alias = elapsed / (last_batch_id)
remaining = max_pu_id - last_batch_id
time_remaining = remaining / time_per_alias
hours_remaining = time_remaining / 60.0
print(
f"\PartnerUser {batch_start}/{max_pu_id} {with_lifetime} {hours_remaining:.2f} mins remaining"
)
print(f"With SL lifetime {with_lifetime}")

View File

@ -0,0 +1,57 @@
#!/usr/bin/env python3
import argparse
import time
from sqlalchemy import func
from app.account_linking import send_user_plan_changed_event
from app.models import PartnerUser
from app.db import Session
parser = argparse.ArgumentParser(
prog="Backfill alias", description="Update alias notes and backfill flag"
)
parser.add_argument(
"-s", "--start_pu_id", default=0, type=int, help="Initial partner_user_id"
)
parser.add_argument(
"-e", "--end_pu_id", default=0, type=int, help="Last partner_user_id"
)
args = parser.parse_args()
pu_id_start = args.start_pu_id
max_pu_id = args.end_pu_id
if max_pu_id == 0:
max_pu_id = Session.query(func.max(PartnerUser.id)).scalar()
print(f"Checking partner user {pu_id_start} to {max_pu_id}")
step = 100
updated = 0
start_time = time.time()
with_premium = 0
with_lifetime = 0
for batch_start in range(pu_id_start, max_pu_id, step):
partner_users = (
Session.query(PartnerUser).filter(
PartnerUser.id >= batch_start, PartnerUser.id < batch_start + step
)
).all()
for partner_user in partner_users:
event = send_user_plan_changed_event(partner_user)
if event is not None:
if event.lifetime:
with_lifetime += 1
else:
with_premium += 1
updated += 1
Session.commit()
elapsed = time.time() - start_time
last_batch_id = batch_start + step
time_per_alias = elapsed / (last_batch_id)
remaining = max_pu_id - last_batch_id
time_remaining = remaining / time_per_alias
hours_remaining = time_remaining / 60.0
print(
f"\PartnerUser {batch_start}/{max_pu_id} {updated} {hours_remaining:.2f} mins remaining"
)
print(f"With SL premium {with_premium} lifetime {with_lifetime}")

View File

@ -4,6 +4,7 @@ package simplelogin_events;
message UserPlanChanged {
uint32 plan_end_time = 1;
bool lifetime = 2;
}
message UserDeleted {

View File

@ -1,20 +1,101 @@
[project]
name = "SimpleLogin"
version = "0.1.0"
description = "SimpleLogin partner API"
authors = [ {name="SimpleLogin", email="dev@simplelogin.io"}]
license = "MIT"
repository = "https://github.com/simple-login/app"
keywords = ["email", "alias", "privacy", "oauth2", "openid"]
packages = [
{ include = "app/" },
{ include = "migrations/" },
]
include = ["templates/*", "templates/**/*", "local_data/*.txt"]
requires-python = "~=3.10"
dependencies = [
"flask ~= 1.1.2",
"flask_login ~= 0.5.0",
"wtforms ~= 2.3.3",
"unidecode ~= 1.1.1",
"gunicorn ~= 20.0.4",
"bcrypt ~= 3.2.0",
"python-dotenv ~= 0.14.0",
"ipython ~= 7.31.1",
"sqlalchemy_utils ~= 0.36.8",
"psycopg2-binary ~= 2.9.3",
"sentry_sdk ~= 2.20.0",
"blinker ~= 1.4",
"arrow ~= 0.16.0",
"Flask-WTF ~= 0.14.3",
"boto3 ~= 1.35.37",
"Flask-Migrate ~= 2.5.3",
"flask_admin ~= 1.5.6",
"flask-cors ~= 3.0.9",
"watchtower ~= 0.8.0",
"sqlalchemy-utils == 0.36.8",
"jwcrypto ~= 0.8",
"yacron~=0.11.2",
"flask-debugtoolbar ~= 0.11.0",
"requests_oauthlib ~= 1.3.0",
"pyopenssl ~= 19.1.0",
"aiosmtpd ~= 1.2",
"dnspython==2.0.0",
"coloredlogs ~= 14.0",
"pycryptodome ~= 3.9.8",
"phpserialize ~= 1.3",
"dkimpy ~= 1.0.5",
"pyotp ~= 2.4.0",
"flask_profiler ~= 1.8.1",
"facebook-sdk ~= 3.1.0",
"google-api-python-client ~= 1.12.3",
"google-auth-httplib2 ~= 0.0.4",
"python-gnupg ~= 0.4.6",
"webauthn ~= 0.4.7",
"pyspf ~= 2.0.14",
"Flask-Limiter == 1.4",
"memory_profiler ~= 0.57.0",
"gevent ~= 24.11.1",
"email-validator ~= 1.1.3",
"PGPy == 0.5.4",
"coinbase-commerce ~= 1.0.1",
"requests ~= 2.25.1",
"newrelic ~= 8.8.0",
"flanker ~= 0.9.11",
"pyre2 ~= 0.3.6",
"tldextract ~= 3.1.2",
"flask-debugtoolbar-sqlalchemy ~= 0.2.0",
"twilio ~= 7.3.2",
"Deprecated ~= 1.2.13",
"MarkupSafe~=1.1.1",
"cryptography ~= 37.0.1",
"SQLAlchemy ~= 1.3.24",
"redis==4.6.0",
"newrelic-telemetry-sdk ~= 0.5.0",
"aiospamc == 0.10",
"itsdangerous ~= 1.1.0",
"werkzeug ~= 1.0.1",
"alembic ~= 1.4.3",
]
[tool.black]
target-version = ['py310']
exclude = '''
(
/(
\.eggs # exclude a few common directories in the
| \.git # root of the project
| \.hg
| \.mypy_cache
| \.tox
| \.venv
| _build
| buck-out
| build
| dist
| migrations # migrations/ is generated by alembic
| app/events/generated
/(
\.eggs # exclude a few common directories in the
| \.git # root of the project
| \.hg
| \.mypy_cache
| \.tox
| \.venv
| _build
| buck-out
| build
| dist
| migrations # migrations/ is generated by alembic
| app/events/generated
)/
)
'''
@ -27,7 +108,6 @@ exclude = [".venv", "migrations", "app/events/generated"]
indent = 2
profile = "jinja"
blank_line_after_tag = "if,for,include,load,extends,block,endcall"
# H006: Images should have a height attribute
# H013: Images should have an alt attribute
# H016: Missing title tag in html. | False positive on template
@ -43,92 +123,26 @@ blank_line_after_tag = "if,for,include,load,extends,block,endcall"
# T001: Variables should be wrapped in a single whitespace. | Messes up with comments
ignore = "H006,H013,H016,H017,H019,H021,H025,H030,H031,T003,J004,J018,T001"
[tool.poetry]
name = "SimpleLogin"
version = "0.1.0"
description = "open-source email alias solution"
authors = ["SimpleLogin <dev@simplelogin.io>"]
license = "MIT"
repository = "https://github.com/simple-login/app"
keywords = ["email", "alias", "privacy", "oauth2", "openid"]
packages = [
{ include = "app/" },
{ include = "migrations/" },
[tool.uv]
dev-dependencies = [
"pytest ~= 7.0.0",
"pytest-cov ~= 3.0.0",
"pre-commit ~= 2.17.0",
"black ~= 22.1.0",
"djlint==1.34.1",
"pylint ~= 2.14.4",
"ruff ~= 0.1.5",
]
include = ["templates/*", "templates/**/*", "local_data/*.txt"]
[tool.poetry.dependencies]
python = "^3.10"
flask = "^1.1.2"
flask_login = "^0.5.0"
wtforms = "^2.3.3"
unidecode = "^1.1.1"
gunicorn = "^20.0.4"
bcrypt = "^3.2.0"
python-dotenv = "^0.14.0"
ipython = "^7.31.1"
sqlalchemy_utils = "^0.36.8"
psycopg2-binary = "^2.9.3"
sentry_sdk = "^2.16.0"
blinker = "^1.4"
arrow = "^0.16.0"
Flask-WTF = "^0.14.3"
boto3 = "^1.15.9"
Flask-Migrate = "^2.5.3"
flask_admin = "^1.5.6"
flask-cors = "^3.0.9"
watchtower = "^0.8.0"
sqlalchemy-utils = "^0.36.8"
jwcrypto = "^0.8"
yacron = "^0.11.1"
flask-debugtoolbar = "^0.11.0"
requests_oauthlib = "^1.3.0"
pyopenssl = "^19.1.0"
aiosmtpd = "^1.2"
dnspython = "^2.0.0"
coloredlogs = "^14.0"
pycryptodome = "^3.9.8"
phpserialize = "^1.3"
dkimpy = "^1.0.5"
pyotp = "^2.4.0"
flask_profiler = "^1.8.1"
facebook-sdk = "^3.1.0"
google-api-python-client = "^1.12.3"
google-auth-httplib2 = "^0.0.4"
python-gnupg = "^0.4.6"
webauthn = "^0.4.7"
pyspf = "^2.0.14"
Flask-Limiter = "^1.4"
memory_profiler = "^0.57.0"
gevent = "22.10.2"
email_validator = "^1.1.1"
PGPy = "0.5.4"
coinbase-commerce = "^1.0.1"
requests = "^2.25.1"
newrelic = "8.8.0"
flanker = "^0.9.11"
pyre2 = "^0.3.6"
tldextract = "^3.1.2"
flask-debugtoolbar-sqlalchemy = "^0.2.0"
twilio = "^7.3.2"
Deprecated = "^1.2.13"
cryptography = "37.0.1"
SQLAlchemy = "1.3.24"
redis = "^4.5.3"
newrelic-telemetry-sdk = "^0.5.0"
aiospamc = "0.10"
[tool.poetry.dev-dependencies]
pytest = "^7.0.0"
pytest-cov = "^3.0.0"
black = "^22.1.0"
djlint = "^1.3.0"
pylint = "^2.14.4"
[tool.poetry.group.dev.dependencies]
ruff = "^0.1.5"
pre-commit = "^3.8.0"
[build-system]
requires = ["poetry>=0.12"]
build-backend = "poetry.masonry.api"
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.metadata]
allow-direct-references = true
[tool.hatch.build.targets.sdist]
include = ["app", "local_data", "migrations", "templates"]
[tool.hatch.build.targets.wheel]
packages = ["app", "local_data", "migrations", "templates"]

469
app/requirements-dev.lock Normal file
View File

@ -0,0 +1,469 @@
# generated by rye
# use `rye lock` or `rye sync` to update this lockfile
#
# last locked with the following flags:
# pre: false
# features: []
# all-features: false
# with-sources: false
# generate-hashes: false
# universal: false
-e file:.
aiohappyeyeballs==2.4.4
# via aiohttp
aiohttp==3.11.11
# via yacron
aiosignal==1.3.2
# via aiohttp
aiosmtpd==1.4.6
# via simplelogin
aiosmtplib==3.0.2
# via yacron
aiospamc==0.10.0
# via simplelogin
alembic==1.14.0
# via flask-migrate
appnope==0.1.4
# via ipython
arrow==0.16.0
# via simplelogin
astroid==2.11.7
# via pylint
async-timeout==5.0.1
# via aiohttp
# via redis
atpublic==5.0
# via aiosmtpd
attrs==24.3.0
# via aiohttp
# via aiosmtpd
# via flanker
# via pytest
backcall==0.2.0
# via ipython
bcrypt==3.2.2
# via simplelogin
black==22.1.0
blinker==1.9.0
# via flask-debugtoolbar
# via simplelogin
boto3==1.35.99
# via simplelogin
# via watchtower
botocore==1.35.99
# via boto3
# via s3transfer
cachetools==5.5.0
# via google-auth
cbor2==5.6.5
# via webauthn
certifi==2024.12.14
# via aiospamc
# via requests
# via sentry-sdk
cffi==1.17.1
# via bcrypt
# via cryptography
cfgv==3.4.0
# via pre-commit
chardet==4.0.0
# via flanker
# via requests
click==8.1.8
# via black
# via djlint
# via flask
# via typer
coinbase-commerce==1.0.1
# via simplelogin
colorama==0.4.6
# via djlint
coloredlogs==14.3
# via simplelogin
coverage==7.6.10
# via pytest-cov
crontab==0.22.8
# via yacron
cryptography==37.0.4
# via flanker
# via jwcrypto
# via pgpy
# via pyopenssl
# via simplelogin
# via webauthn
decorator==5.1.1
# via ipython
deprecated==1.2.15
# via jwcrypto
# via limits
# via simplelogin
dill==0.3.9
# via pylint
distlib==0.3.9
# via virtualenv
djlint==1.3.0
dkimpy==1.0.6
# via simplelogin
dnspython==2.6.1
# via dkimpy
# via email-validator
# via simplelogin
email-validator==1.1.3
# via simplelogin
facebook-sdk==3.1.0
# via simplelogin
filelock==3.16.1
# via tldextract
# via virtualenv
flanker==0.9.11
# via simplelogin
flask==1.1.2
# via flask-admin
# via flask-cors
# via flask-debugtoolbar
# via flask-httpauth
# via flask-limiter
# via flask-login
# via flask-migrate
# via flask-profiler
# via flask-sqlalchemy
# via flask-wtf
# via simplelogin
flask-admin==1.5.8
# via simplelogin
flask-cors==3.0.10
# via simplelogin
flask-debugtoolbar==0.11.0
# via flask-debugtoolbar-sqlalchemy
# via simplelogin
flask-debugtoolbar-sqlalchemy==0.2.0
# via simplelogin
flask-httpauth==4.8.0
# via flask-profiler
flask-limiter==1.4
# via simplelogin
flask-login==0.5.0
# via simplelogin
flask-migrate==2.5.3
# via simplelogin
flask-profiler==1.8.1
# via simplelogin
flask-sqlalchemy==2.5.1
# via flask-migrate
flask-wtf==0.14.3
# via simplelogin
frozenlist==1.5.0
# via aiohttp
# via aiosignal
future==1.0.0
# via webauthn
gevent==24.11.1
# via simplelogin
google-api-core==2.24.0
# via google-api-python-client
google-api-python-client==1.12.11
# via simplelogin
google-auth==2.37.0
# via google-api-core
# via google-api-python-client
# via google-auth-httplib2
google-auth-httplib2==0.0.4
# via google-api-python-client
# via simplelogin
googleapis-common-protos==1.66.0
# via google-api-core
greenlet==3.1.1
# via gevent
gunicorn==20.0.4
# via simplelogin
html-tag-names==0.1.2
# via djlint
html-void-elements==0.1.0
# via djlint
httplib2==0.22.0
# via google-api-python-client
# via google-auth-httplib2
humanfriendly==10.0
# via coloredlogs
identify==2.6.5
# via pre-commit
idna==2.10
# via email-validator
# via flanker
# via requests
# via tldextract
# via yarl
importlib-metadata==4.13.0
# via djlint
iniconfig==2.0.0
# via pytest
ipython==7.31.1
# via simplelogin
isort==5.13.2
# via pylint
itsdangerous==1.1.0
# via flask
# via flask-debugtoolbar
# via flask-wtf
# via simplelogin
jedi==0.19.2
# via ipython
jinja2==2.11.3
# via flask
# via yacron
jmespath==1.0.1
# via boto3
# via botocore
jwcrypto==0.9.1
# via simplelogin
lazy-object-proxy==1.10.0
# via astroid
limits==4.0.0
# via flask-limiter
loguru==0.7.3
# via aiospamc
mako==1.3.8
# via alembic
markupsafe==1.1.1
# via jinja2
# via mako
# via simplelogin
# via wtforms
matplotlib-inline==0.1.7
# via ipython
mccabe==0.7.0
# via pylint
memory-profiler==0.57.0
# via simplelogin
multidict==6.1.0
# via aiohttp
# via yarl
mypy-extensions==1.0.0
# via black
newrelic==8.8.1
# via simplelogin
newrelic-telemetry-sdk==0.5.1
# via simplelogin
nodeenv==1.9.1
# via pre-commit
oauthlib==3.2.2
# via requests-oauthlib
packaging==24.2
# via limits
# via pytest
parso==0.8.4
# via jedi
pathspec==0.9.0
# via black
# via djlint
pexpect==4.9.0
# via ipython
pgpy==0.5.4
# via simplelogin
phpserialize==1.3
# via simplelogin
pickleshare==0.7.5
# via ipython
platformdirs==4.3.6
# via black
# via pylint
# via virtualenv
pluggy==1.5.0
# via pytest
ply==3.11
# via flanker
pre-commit==2.17.0
prompt-toolkit==3.0.48
# via ipython
propcache==0.2.1
# via aiohttp
# via yarl
proto-plus==1.25.0
# via google-api-core
protobuf==5.29.3
# via google-api-core
# via googleapis-common-protos
# via proto-plus
psutil==6.1.1
# via memory-profiler
psycopg2-binary==2.9.10
# via simplelogin
ptyprocess==0.7.0
# via pexpect
py==1.11.0
# via pytest
pyasn1==0.6.1
# via pgpy
# via pyasn1-modules
# via rsa
pyasn1-modules==0.4.1
# via google-auth
pycparser==2.22
# via cffi
pycryptodome==3.9.9
# via simplelogin
pygments==2.19.1
# via flask-debugtoolbar-sqlalchemy
# via ipython
pyjwt==2.10.1
# via twilio
pylint==2.14.5
pyopenssl==19.1.0
# via simplelogin
# via webauthn
pyotp==2.4.1
# via simplelogin
pyparsing==3.2.1
# via httplib2
pyre2==0.3.6
# via simplelogin
pyspf==2.0.14
# via simplelogin
pytest==7.0.1
# via pytest-cov
pytest-cov==3.0.0
python-dateutil==2.9.0.post0
# via arrow
# via botocore
# via strictyaml
python-dotenv==0.14.0
# via simplelogin
python-gnupg==0.4.9
# via simplelogin
pytz==2024.2
# via twilio
# via yacron
pyyaml==6.0.2
# via djlint
# via pre-commit
redis==4.5.5
# via simplelogin
regex==2022.10.31
# via djlint
# via flanker
requests==2.25.1
# via coinbase-commerce
# via facebook-sdk
# via google-api-core
# via requests-file
# via requests-oauthlib
# via simplelogin
# via tldextract
# via twilio
requests-file==2.1.0
# via tldextract
requests-oauthlib==1.3.1
# via simplelogin
rsa==4.9
# via google-auth
ruamel-yaml==0.17.4
# via yacron
ruff==0.1.15
s3transfer==0.10.4
# via boto3
sentry-sdk==2.20.0
# via simplelogin
# via yacron
setuptools==75.8.0
# via astroid
# via gunicorn
# via ipython
# via zope-event
# via zope-interface
simplejson==3.19.3
# via flask-profiler
six==1.17.0
# via coinbase-commerce
# via flanker
# via flask-cors
# via flask-limiter
# via google-api-python-client
# via google-auth-httplib2
# via jwcrypto
# via pgpy
# via pyopenssl
# via python-dateutil
# via sqlalchemy-utils
# via webauthn
sqlalchemy==1.3.24
# via alembic
# via flask-debugtoolbar-sqlalchemy
# via flask-sqlalchemy
# via simplelogin
# via sqlalchemy-utils
sqlalchemy-utils==0.36.8
# via simplelogin
sqlparse==0.5.3
# via flask-debugtoolbar-sqlalchemy
strictyaml==1.7.3
# via yacron
tld==0.13
# via flanker
tldextract==3.1.2
# via simplelogin
toml==0.10.2
# via pre-commit
tomli==2.2.1
# via black
# via coverage
# via djlint
# via pylint
# via pytest
tomlkit==0.13.2
# via pylint
tqdm==4.67.1
# via djlint
traitlets==5.14.3
# via ipython
# via matplotlib-inline
twilio==7.3.2
# via simplelogin
typer==0.9.4
# via aiospamc
typing-extensions==4.12.2
# via aiospamc
# via alembic
# via limits
# via multidict
# via typer
unidecode==1.1.2
# via simplelogin
uritemplate==3.0.1
# via google-api-python-client
urllib3==1.26.20
# via botocore
# via newrelic-telemetry-sdk
# via requests
# via sentry-sdk
virtualenv==20.29.0
# via pre-commit
watchtower==0.8.0
# via simplelogin
wcwidth==0.2.13
# via prompt-toolkit
webauthn==0.4.7
# via simplelogin
webob==1.8.9
# via flanker
werkzeug==1.0.1
# via flask
# via flask-debugtoolbar
# via simplelogin
wrapt==1.17.2
# via astroid
# via deprecated
wtforms==2.3.3
# via flask-admin
# via flask-wtf
# via simplelogin
yacron==0.19.0
# via simplelogin
yarl==1.18.3
# via aiohttp
zipp==3.21.0
# via importlib-metadata
zope-event==5.0
# via gevent
zope-interface==7.2
# via gevent

392
app/requirements.lock Normal file
View File

@ -0,0 +1,392 @@
# generated by rye
# use `rye lock` or `rye sync` to update this lockfile
#
# last locked with the following flags:
# pre: false
# features: []
# all-features: false
# with-sources: false
# generate-hashes: false
# universal: false
-e file:.
aiohttp==3.8.4
# via google-auth
# via yacron
aiosignal==1.2.0
# via aiohttp
aiosmtpd==1.4.2
# via simplelogin
aiosmtplib==1.1.4
# via yacron
aiospamc==0.10.0
# via simplelogin
alembic==1.4.3
# via flask-migrate
appnope==0.1.0
# via ipython
arrow==0.16.0
# via simplelogin
async-timeout==4.0.2
# via aiohttp
# via redis
atpublic==2.0
# via aiosmtpd
attrs==20.2.0
# via aiohttp
# via aiosmtpd
# via flanker
backcall==0.2.0
# via ipython
bcrypt==3.2.0
# via simplelogin
blinker==1.4
# via flask-debugtoolbar
# via simplelogin
boto3==1.35.99
# via simplelogin
# via watchtower
botocore==1.35.99
# via boto3
# via s3transfer
cachetools==4.1.1
# via google-auth
cbor2==5.2.0
# via webauthn
certifi==2019.11.28
# via aiospamc
# via requests
# via sentry-sdk
cffi==1.14.4
# via bcrypt
# via cryptography
chardet==3.0.4
# via flanker
# via requests
charset-normalizer==3.4.1
# via aiohttp
click==8.0.3
# via flask
# via typer
coinbase-commerce==1.0.1
# via simplelogin
coloredlogs==14.0
# via simplelogin
crontab==0.22.8
# via yacron
cryptography==37.0.1
# via flanker
# via jwcrypto
# via pgpy
# via pyopenssl
# via simplelogin
# via webauthn
decorator==4.4.2
# via ipython
deprecated==1.2.13
# via simplelogin
dkimpy==1.0.5
# via simplelogin
dnspython==2.6.1
# via dkimpy
# via email-validator
# via simplelogin
email-validator==1.1.3
# via simplelogin
facebook-sdk==3.1.0
# via simplelogin
filelock==3.15.4
# via tldextract
flanker==0.9.11
# via simplelogin
flask==1.1.2
# via flask-admin
# via flask-cors
# via flask-debugtoolbar
# via flask-httpauth
# via flask-limiter
# via flask-login
# via flask-migrate
# via flask-profiler
# via flask-sqlalchemy
# via flask-wtf
# via simplelogin
flask-admin==1.5.7
# via simplelogin
flask-cors==3.0.9
# via simplelogin
flask-debugtoolbar==0.11.0
# via flask-debugtoolbar-sqlalchemy
# via simplelogin
flask-debugtoolbar-sqlalchemy==0.2.0
# via simplelogin
flask-httpauth==4.1.0
# via flask-profiler
flask-limiter==1.4
# via simplelogin
flask-login==0.5.0
# via simplelogin
flask-migrate==2.5.3
# via simplelogin
flask-profiler==1.8.1
# via simplelogin
flask-sqlalchemy==2.5.1
# via flask-migrate
flask-wtf==0.14.3
# via simplelogin
frozenlist==1.3.3
# via aiohttp
# via aiosignal
future==0.18.3
# via webauthn
gevent==24.11.1
# via simplelogin
google-api-core==1.22.2
# via google-api-python-client
google-api-python-client==1.12.3
# via simplelogin
google-auth==1.22.0
# via google-api-core
# via google-api-python-client
# via google-auth-httplib2
google-auth-httplib2==0.0.4
# via google-api-python-client
# via simplelogin
googleapis-common-protos==1.52.0
# via google-api-core
greenlet==3.1.1
# via gevent
gunicorn==20.0.4
# via simplelogin
httplib2==0.22.0
# via google-api-python-client
# via google-auth-httplib2
humanfriendly==8.2
# via coloredlogs
idna==2.10
# via email-validator
# via flanker
# via requests
# via tldextract
# via yarl
ipython==7.31.1
# via simplelogin
ipython-genutils==0.2.0
# via traitlets
itsdangerous==1.1.0
# via flask
# via flask-debugtoolbar
# via flask-wtf
# via simplelogin
jedi==0.17.2
# via ipython
jinja2==2.11.3
# via flask
# via yacron
jmespath==0.10.0
# via boto3
# via botocore
jwcrypto==0.8
# via simplelogin
limits==1.5.1
# via flask-limiter
loguru==0.7.2
# via aiospamc
mako==1.2.4
# via alembic
markupsafe==1.1.1
# via jinja2
# via mako
# via simplelogin
# via wtforms
matplotlib-inline==0.1.3
# via ipython
memory-profiler==0.57.0
# via simplelogin
multidict==4.7.6
# via aiohttp
# via yarl
newrelic==8.8.0
# via simplelogin
newrelic-telemetry-sdk==0.5.0
# via simplelogin
oauthlib==3.1.0
# via requests-oauthlib
parso==0.7.1
# via jedi
pexpect==4.8.0
# via ipython
pgpy==0.5.4
# via simplelogin
phpserialize==1.3
# via simplelogin
pickleshare==0.7.5
# via ipython
ply==3.11
# via flanker
prompt-toolkit==3.0.7
# via ipython
protobuf==5.27.1
# via google-api-core
# via googleapis-common-protos
psutil==5.7.2
# via memory-profiler
psycopg2-binary==2.9.3
# via simplelogin
ptyprocess==0.6.0
# via pexpect
pyasn1==0.4.8
# via pgpy
# via pyasn1-modules
# via rsa
pyasn1-modules==0.2.8
# via google-auth
pycparser==2.20
# via cffi
pycryptodome==3.9.8
# via simplelogin
pygments==2.7.4
# via flask-debugtoolbar-sqlalchemy
# via ipython
pyjwt==2.4.0
# via twilio
pyopenssl==19.1.0
# via simplelogin
# via webauthn
pyotp==2.4.0
# via simplelogin
pyparsing==2.4.7
# via httplib2
pyre2==0.3.6
# via simplelogin
pyspf==2.0.14
# via simplelogin
python-dateutil==2.8.1
# via alembic
# via arrow
# via botocore
# via strictyaml
python-dotenv==0.14.0
# via simplelogin
python-editor==1.0.4
# via alembic
python-gnupg==0.4.6
# via simplelogin
pytz==2020.1
# via google-api-core
# via twilio
# via yacron
redis==4.5.5
# via simplelogin
regex==2023.12.25
# via flanker
requests==2.25.1
# via coinbase-commerce
# via facebook-sdk
# via google-api-core
# via requests-file
# via requests-oauthlib
# via simplelogin
# via tldextract
# via twilio
requests-file==1.5.1
# via tldextract
requests-oauthlib==1.3.0
# via simplelogin
rsa==4.6
# via google-auth
ruamel-yaml==0.17.4
# via strictyaml
# via yacron
s3transfer==0.10.4
# via boto3
sentry-sdk==2.20.0
# via simplelogin
# via yacron
setuptools==67.6.0
# via google-api-core
# via google-auth
# via gunicorn
# via ipython
# via zope-event
# via zope-interface
simplejson==3.17.2
# via flask-profiler
six==1.15.0
# via bcrypt
# via coinbase-commerce
# via flanker
# via flask-cors
# via flask-limiter
# via google-api-core
# via google-api-python-client
# via google-auth
# via google-auth-httplib2
# via limits
# via pgpy
# via pyopenssl
# via python-dateutil
# via requests-file
# via sqlalchemy-utils
# via webauthn
sqlalchemy==1.3.24
# via alembic
# via flask-debugtoolbar-sqlalchemy
# via flask-sqlalchemy
# via simplelogin
# via sqlalchemy-utils
sqlalchemy-utils==0.36.8
# via simplelogin
sqlparse==0.4.4
# via flask-debugtoolbar-sqlalchemy
strictyaml==1.1.0
# via yacron
tld==0.12.6
# via flanker
tldextract==3.1.2
# via simplelogin
traitlets==5.0.4
# via ipython
# via matplotlib-inline
twilio==7.3.2
# via simplelogin
typer==0.9.0
# via aiospamc
typing-extensions==4.8.0
# via aiospamc
# via typer
unidecode==1.1.1
# via simplelogin
uritemplate==3.0.1
# via google-api-python-client
urllib3==1.26.20
# via botocore
# via newrelic-telemetry-sdk
# via requests
# via sentry-sdk
watchtower==0.8.0
# via simplelogin
wcwidth==0.2.5
# via prompt-toolkit
webauthn==0.4.7
# via simplelogin
webob==1.8.7
# via flanker
werkzeug==1.0.1
# via flask
# via flask-debugtoolbar
# via simplelogin
wrapt==1.15.0
# via deprecated
wtforms==2.3.3
# via flask-admin
# via flask-wtf
# via simplelogin
yacron==0.19.0
# via simplelogin
yarl==1.9.2
# via aiohttp
zope-event==5.0
# via gevent
zope-interface==7.2
# via gevent

View File

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

View File

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

View File

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

View File

@ -10,10 +10,10 @@ docker run -d --name sl-test-db -e POSTGRES_PASSWORD=test -e POSTGRES_USER=test
sleep 3
# migrate the DB to the latest version
CONFIG=tests/test.env poetry run alembic upgrade head
CONFIG=tests/test.env uv run alembic upgrade head
# run test
poetry run pytest -c pytest.ci.ini
uv run pytest -c pytest.ci.ini
# Delete the test DB
docker rm -f sl-test-db

View File

@ -1,4 +1,3 @@
import json
import os
import time
from datetime import timedelta
@ -9,9 +8,6 @@ import flask_limiter
import flask_profiler
import newrelic.agent
import sentry_sdk
from coinbase_commerce.error import WebhookInvalidPayload, SignatureVerificationError
from coinbase_commerce.webhook import Webhook
from dateutil.relativedelta import relativedelta
from flask import (
Flask,
redirect,
@ -30,7 +26,7 @@ from sentry_sdk.integrations.flask import FlaskIntegration
from sentry_sdk.integrations.sqlalchemy import SqlalchemyIntegration
from werkzeug.middleware.proxy_fix import ProxyFix
from app import paddle_utils, config, paddle_callback, constants
from app import config, constants
from app.admin_model import (
SLAdminIndexView,
UserAdmin,
@ -47,6 +43,7 @@ from app.admin_model import (
MetricAdmin,
InvalidMailboxDomainAdmin,
EmailSearchAdmin,
CustomDomainSearchAdmin,
)
from app.api.base import api_bp
from app.auth.base import auth_bp
@ -56,7 +53,6 @@ from app.config import (
FLASK_SECRET,
SENTRY_DSN,
URL,
PADDLE_MONTHLY_PRODUCT_ID,
FLASK_PROFILER_PATH,
FLASK_PROFILER_PASSWORD,
SENTRY_FRONT_END_DSN,
@ -70,22 +66,16 @@ from app.config import (
LANDING_PAGE_URL,
STATUS_PAGE_URL,
SUPPORT_EMAIL,
PADDLE_MONTHLY_PRODUCT_IDS,
PADDLE_YEARLY_PRODUCT_IDS,
PGP_SIGNER,
COINBASE_WEBHOOK_SECRET,
PAGE_LIMIT,
PADDLE_COUPON_ID,
ZENDESK_ENABLED,
MAX_NB_EMAIL_FREE_PLAN,
MEM_STORE_URI,
)
from app.dashboard.base import dashboard_bp
from app.subscription_webhook import execute_subscription_webhook
from app.db import Session
from app.developer.base import developer_bp
from app.discover.base import discover_bp
from app.email_utils import send_email, render
from app.extensions import login_manager, limiter
from app.fake_data import fake_data
from app.internal.base import internal_bp
@ -94,11 +84,8 @@ from app.log import LOG
from app.models import (
User,
Alias,
Subscription,
PlanEnum,
CustomDomain,
Mailbox,
CoinbaseSubscription,
EmailLog,
Contact,
ManualSubscription,
@ -115,10 +102,12 @@ from app.monitor.base import monitor_bp
from app.newsletter_utils import send_newsletter_to_user
from app.oauth.base import oauth_bp
from app.onboarding.base import onboarding_bp
from app.payments.coinbase import setup_coinbase_commerce
from app.payments.paddle import setup_paddle_callback
from app.phone.base import phone_bp
from app.redis_services import initialize_redis_services
from app.request_utils import generate_request_id
from app.sentry_utils import sentry_before_send
from app.utils import random_string
if SENTRY_DSN:
LOG.d("enable sentry")
@ -275,6 +264,7 @@ def set_index_page(app):
and not request.path.startswith("/_debug_toolbar")
):
g.start_time = time.time()
g.request_id = generate_request_id()
# to handle the referral url that has ?slref=code part
ref_code = request.args.get("slref")
@ -446,341 +436,6 @@ def jinja2_filter(app):
)
def setup_paddle_callback(app: Flask):
@app.route("/paddle", methods=["GET", "POST"])
def paddle():
LOG.d(f"paddle callback {request.form.get('alert_name')} {request.form}")
# make sure the request comes from Paddle
if not paddle_utils.verify_incoming_request(dict(request.form)):
LOG.e("request not coming from paddle. Request data:%s", dict(request.form))
return "KO", 400
if (
request.form.get("alert_name") == "subscription_created"
): # new user subscribes
# the passthrough is json encoded, e.g.
# request.form.get("passthrough") = '{"user_id": 88 }'
passthrough = json.loads(request.form.get("passthrough"))
user_id = passthrough.get("user_id")
user = User.get(user_id)
subscription_plan_id = int(request.form.get("subscription_plan_id"))
if subscription_plan_id in PADDLE_MONTHLY_PRODUCT_IDS:
plan = PlanEnum.monthly
elif subscription_plan_id in PADDLE_YEARLY_PRODUCT_IDS:
plan = PlanEnum.yearly
else:
LOG.e(
"Unknown subscription_plan_id %s %s",
subscription_plan_id,
request.form,
)
return "No such subscription", 400
sub = Subscription.get_by(user_id=user.id)
if not sub:
LOG.d(f"create a new Subscription for user {user}")
Subscription.create(
user_id=user.id,
cancel_url=request.form.get("cancel_url"),
update_url=request.form.get("update_url"),
subscription_id=request.form.get("subscription_id"),
event_time=arrow.now(),
next_bill_date=arrow.get(
request.form.get("next_bill_date"), "YYYY-MM-DD"
).date(),
plan=plan,
)
else:
LOG.d(f"Update an existing Subscription for user {user}")
sub.cancel_url = request.form.get("cancel_url")
sub.update_url = request.form.get("update_url")
sub.subscription_id = request.form.get("subscription_id")
sub.event_time = arrow.now()
sub.next_bill_date = arrow.get(
request.form.get("next_bill_date"), "YYYY-MM-DD"
).date()
sub.plan = plan
# make sure to set the new plan as not-cancelled
# in case user cancels a plan and subscribes a new plan
sub.cancelled = False
execute_subscription_webhook(user)
LOG.d("User %s upgrades!", user)
Session.commit()
elif request.form.get("alert_name") == "subscription_payment_succeeded":
subscription_id = request.form.get("subscription_id")
LOG.d("Update subscription %s", subscription_id)
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
# when user subscribes, the "subscription_payment_succeeded" can arrive BEFORE "subscription_created"
# at that time, subscription object does not exist yet
if sub:
sub.event_time = arrow.now()
sub.next_bill_date = arrow.get(
request.form.get("next_bill_date"), "YYYY-MM-DD"
).date()
Session.commit()
execute_subscription_webhook(sub.user)
elif request.form.get("alert_name") == "subscription_cancelled":
subscription_id = request.form.get("subscription_id")
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
if sub:
# cancellation_effective_date should be the same as next_bill_date
LOG.w(
"Cancel subscription %s %s on %s, next bill date %s",
subscription_id,
sub.user,
request.form.get("cancellation_effective_date"),
sub.next_bill_date,
)
sub.event_time = arrow.now()
sub.cancelled = True
Session.commit()
user = sub.user
send_email(
user.email,
"SimpleLogin - your subscription is canceled",
render(
"transactional/subscription-cancel.txt",
user=user,
end_date=request.form.get("cancellation_effective_date"),
),
)
execute_subscription_webhook(sub.user)
else:
# user might have deleted their account
LOG.i(f"Cancel non-exist subscription {subscription_id}")
return "OK"
elif request.form.get("alert_name") == "subscription_updated":
subscription_id = request.form.get("subscription_id")
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
if sub:
next_bill_date = request.form.get("next_bill_date")
if not next_bill_date:
paddle_callback.failed_payment(sub, subscription_id)
return "OK"
LOG.d(
"Update subscription %s %s on %s, next bill date %s",
subscription_id,
sub.user,
request.form.get("cancellation_effective_date"),
sub.next_bill_date,
)
if (
int(request.form.get("subscription_plan_id"))
== PADDLE_MONTHLY_PRODUCT_ID
):
plan = PlanEnum.monthly
else:
plan = PlanEnum.yearly
sub.cancel_url = request.form.get("cancel_url")
sub.update_url = request.form.get("update_url")
sub.event_time = arrow.now()
sub.next_bill_date = arrow.get(
request.form.get("next_bill_date"), "YYYY-MM-DD"
).date()
sub.plan = plan
# make sure to set the new plan as not-cancelled
sub.cancelled = False
Session.commit()
execute_subscription_webhook(sub.user)
else:
LOG.w(
f"update non-exist subscription {subscription_id}. {request.form}"
)
return "No such subscription", 400
elif request.form.get("alert_name") == "payment_refunded":
subscription_id = request.form.get("subscription_id")
LOG.d("Refund request for subscription %s", subscription_id)
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
if sub:
user = sub.user
Subscription.delete(sub.id)
Session.commit()
LOG.e("%s requests a refund", user)
execute_subscription_webhook(sub.user)
elif request.form.get("alert_name") == "subscription_payment_refunded":
subscription_id = request.form.get("subscription_id")
sub: Subscription = Subscription.get_by(subscription_id=subscription_id)
LOG.d(
"Handle subscription_payment_refunded for subscription %s",
subscription_id,
)
if not sub:
LOG.w(
"No such subscription for %s, payload %s",
subscription_id,
request.form,
)
return "No such subscription"
plan_id = int(request.form["subscription_plan_id"])
if request.form["refund_type"] == "full":
if plan_id in PADDLE_MONTHLY_PRODUCT_IDS:
LOG.d("subtract 1 month from next_bill_date %s", sub.next_bill_date)
sub.next_bill_date = sub.next_bill_date - relativedelta(months=1)
LOG.d("next_bill_date is %s", sub.next_bill_date)
Session.commit()
elif plan_id in PADDLE_YEARLY_PRODUCT_IDS:
LOG.d("subtract 1 year from next_bill_date %s", sub.next_bill_date)
sub.next_bill_date = sub.next_bill_date - relativedelta(years=1)
LOG.d("next_bill_date is %s", sub.next_bill_date)
Session.commit()
else:
LOG.e("Unknown plan_id %s", plan_id)
else:
LOG.w("partial subscription_payment_refunded, not handled")
execute_subscription_webhook(sub.user)
return "OK"
@app.route("/paddle_coupon", methods=["GET", "POST"])
def paddle_coupon():
LOG.d("paddle coupon callback %s", request.form)
if not paddle_utils.verify_incoming_request(dict(request.form)):
LOG.e("request not coming from paddle. Request data:%s", dict(request.form))
return "KO", 400
product_id = request.form.get("p_product_id")
if product_id != PADDLE_COUPON_ID:
LOG.e("product_id %s not match with %s", product_id, PADDLE_COUPON_ID)
return "KO", 400
email = request.form.get("email")
LOG.d("Paddle coupon request for %s", email)
coupon = Coupon.create(
code=random_string(30),
comment="For 1-year coupon",
expires_date=arrow.now().shift(years=1, days=-1),
commit=True,
)
return (
f"Your 1-year coupon is <b>{coupon.code}</b> <br> "
f"It's valid until <b>{coupon.expires_date.date().isoformat()}</b>"
)
def setup_coinbase_commerce(app):
@app.route("/coinbase", methods=["POST"])
def coinbase_webhook():
# event payload
request_data = request.data.decode("utf-8")
# webhook signature
request_sig = request.headers.get("X-CC-Webhook-Signature", None)
try:
# signature verification and event object construction
event = Webhook.construct_event(
request_data, request_sig, COINBASE_WEBHOOK_SECRET
)
except (WebhookInvalidPayload, SignatureVerificationError) as e:
LOG.e("Invalid Coinbase webhook")
return str(e), 400
LOG.d("Coinbase event %s", event)
if event["type"] == "charge:confirmed":
if handle_coinbase_event(event):
return "success", 200
else:
return "error", 400
return "success", 200
def handle_coinbase_event(event) -> bool:
server_user_id = event["data"]["metadata"]["user_id"]
try:
user_id = int(server_user_id)
except ValueError:
user_id = int(float(server_user_id))
code = event["data"]["code"]
user = User.get(user_id)
if not user:
LOG.e("User not found %s", user_id)
return False
coinbase_subscription: CoinbaseSubscription = CoinbaseSubscription.get_by(
user_id=user_id
)
if not coinbase_subscription:
LOG.d("Create a coinbase subscription for %s", user)
coinbase_subscription = CoinbaseSubscription.create(
user_id=user_id, end_at=arrow.now().shift(years=1), code=code, commit=True
)
send_email(
user.email,
"Your SimpleLogin account has been upgraded",
render(
"transactional/coinbase/new-subscription.txt",
user=user,
coinbase_subscription=coinbase_subscription,
),
render(
"transactional/coinbase/new-subscription.html",
user=user,
coinbase_subscription=coinbase_subscription,
),
)
else:
if coinbase_subscription.code != code:
LOG.d("Update code from %s to %s", coinbase_subscription.code, code)
coinbase_subscription.code = code
if coinbase_subscription.is_active():
coinbase_subscription.end_at = coinbase_subscription.end_at.shift(years=1)
else: # already expired subscription
coinbase_subscription.end_at = arrow.now().shift(years=1)
Session.commit()
send_email(
user.email,
"Your SimpleLogin account has been extended",
render(
"transactional/coinbase/extend-subscription.txt",
user=user,
coinbase_subscription=coinbase_subscription,
),
render(
"transactional/coinbase/extend-subscription.html",
user=user,
coinbase_subscription=coinbase_subscription,
),
)
execute_subscription_webhook(user)
return True
def init_extensions(app: Flask):
login_manager.init_app(app)
@ -789,10 +444,15 @@ def init_admin(app):
admin = Admin(name="SimpleLogin", template_mode="bootstrap4")
admin.init_app(app, index_view=SLAdminIndexView())
admin.add_view(EmailSearchAdmin(name="Email Search", endpoint="email_search"))
admin.add_view(
CustomDomainSearchAdmin(
name="Custom domain search", endpoint="custom_domain_search"
)
)
admin.add_view(UserAdmin(User, Session))
admin.add_view(AliasAdmin(Alias, Session))
admin.add_view(MailboxAdmin(Mailbox, Session))
admin.add_view(EmailSearchAdmin(name="Email Search", endpoint="email_search"))
admin.add_view(CouponAdmin(Coupon, Session))
admin.add_view(ManualSubscriptionAdmin(ManualSubscription, Session))
admin.add_view(CustomDomainAdmin(CustomDomain, Session))
@ -839,9 +499,9 @@ def register_custom_commands(app):
from init_app import add_sl_domains, add_proton_partner
LOG.w("reset db, add fake data")
add_proton_partner()
fake_data()
add_sl_domains()
add_proton_partner()
@app.cli.command("send-newsletter")
@click.option("-n", "--newsletter_id", type=int, help="Newsletter ID to be sent")

View File

@ -0,0 +1,12 @@
import arrow
from app.db import Session
from app.log import LOG
from app.models import AliasAuditLog
def cleanup_alias_audit_log(oldest_allowed: arrow.Arrow):
LOG.i(f"Deleting alias_audit_log older than {oldest_allowed}")
count = AliasAuditLog.filter(AliasAuditLog.created_at < oldest_allowed).delete()
Session.commit()
LOG.i(f"Deleted {count} alias_audit_log entries")

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