Compare commits

...

16 Commits

Author SHA1 Message Date
050cef0e4e 4.40.2 2024-03-07 12:00:08 +00:00
0d557ef875 4.40.1 2024-03-05 12:00:09 +00:00
6e56ea4489 Merge pull request 'Replace Drone with Gitea Actions' (#1) from gitea-actions into main
Reviewed-on: #1
2024-03-04 13:42:58 +00:00
def0de643b Remove Drone 2024-03-04 13:38:57 +00:00
9e7cb2c7dd Add Gitea Actions 2024-03-04 13:38:52 +00:00
f1110506c0 4.39.3 2024-02-27 12:00:07 +00:00
f5bce7d7ff 4.39.2 2024-02-23 12:00:07 +00:00
75f45d9365 4.39.1 2024-02-20 12:00:07 +00:00
ead425e0c2 4.38.3 2024-02-14 12:00:07 +00:00
6c910d62c5 4.38.2 2024-02-06 12:00:07 +00:00
99ffd1ec0c 4.38.0 2024-02-03 16:55:23 +00:00
eda940f8b2 4.37.2 2024-01-27 12:00:07 +00:00
1dad582523 4.37.1 2024-01-25 12:00:08 +00:00
e516266a27 4.37.0 2024-01-18 12:00:07 +00:00
850fc95477 4.36.8 2023-12-28 12:00:07 +00:00
d172825900 4.36.7 2023-12-21 12:00:09 +00:00
74 changed files with 1411 additions and 370913 deletions

View File

@ -1,52 +0,0 @@
kind: pipeline
type: docker
name: build-multiarch-images
platform:
os: linux
arch: amd64
steps:
- name: make-tags
image: node
commands:
- echo -n "${DRONE_TAG}, latest" > .tags
- name: build
image: thegeeklab/drone-docker-buildx
privileged: true
settings:
provenance: false
dockerfile: app/Dockerfile
context: app
registry: git.mrmeeb.stream
username:
from_secret: docker_username
password:
from_secret: docker_password
repo: git.mrmeeb.stream/mrmeeb/simple-login
platforms:
- linux/arm64
- linux/amd64
- name: notify
image: plugins/slack
when:
status:
- success
- failure
- killed
settings:
webhook:
from_secret: slack_webhook
icon_url:
from_secret: slack_avatar
trigger:
event:
include:
- tag
ref:
include:
- refs/tags/**

View File

@ -0,0 +1,195 @@
name: Build-Release-Image
on:
push:
tags:
- '*'
env:
CONTAINER_NAME: git.mrmeeb.stream/mrmeeb/simple-login-dev
TEA_VERSION: 0.9.2
jobs:
Build-Image:
runs-on: [ubuntu-docker-latest, "${{ matrix.platform }}"]
strategy:
fail-fast: false
matrix:
platform:
- linux/amd64
- linux/arm64
steps:
- name: Prepare
run: |
platform=${{ matrix.platform }}
echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV
echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Checkout
uses: actions/checkout@v2
# Not needed currently due to https://github.com/go-gitea/gitea/issues/29563
#- name: Prepare tags
# id: meta
# uses: docker/metadata-action@v5
# with:
# images: ${{ env.CONTAINER_NAME }}
# tags: |
# type=pep440,pattern={{version}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: git.mrmeeb.stream
username: ${{ env.GITHUB_ACTOR }}
password: ${{ secrets.GTCR_TOKEN }}
- name: Build and push by digest
uses: docker/build-push-action@v5
id: build
with:
context: ./app
platforms: ${{ matrix.platform }}
provenance: false
outputs: type=image,name=${{ env.CONTAINER_NAME }},push-by-digest=true,name-canonical=true,push=true
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest
uses: actions/upload-artifact@v3
with:
name: digests-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
- name: Notify
uses: rjstone/discord-webhook-notify@v1
if: failure()
with:
severity: ${{ job.status == 'success' && 'info' || (job.status == 'cancelled' && 'warn' || 'error') }}
details: Build ${{ job.status == 'success' && 'succeeded' || (job.status == 'cancelled' && 'cancelled' || 'failed') }}!
webhookUrl: ${{ secrets.DISCORD_WEBHOOK }}
username: Gitea
avatarUrl: ${{ vars.RUNNER_ICON_URL }}
Merge-Images:
runs-on: ubuntu-docker-latest
needs: [Build-Image]
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Get tag
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Download digests
uses: actions/download-artifact@v3
with:
path: /tmp/digests
pattern: digests-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Not needed currently due to https://github.com/go-gitea/gitea/issues/29563
#- name: Prepare Docker metadata
# id: meta
# uses: docker/metadata-action@v5
# with:
# images: ${{ env.CONTAINER_NAME }}
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: git.mrmeeb.stream
username: ${{ env.GITHUB_ACTOR }}
password: ${{ secrets.GTCR_TOKEN }}
- name: Create manifest latest
working-directory: /tmp/digests
run: |
docker manifest create ${{ env.CONTAINER_NAME }}:latest \
--amend ${{ env.CONTAINER_NAME }}@sha256:$(ls -p digests-linux-amd64/* | cut -d / -f 2) \
--amend ${{ env.CONTAINER_NAME }}@sha256:$(ls -p digests-linux-arm64/* | cut -d / -f 2)
#docker manifest annotate --arch amd64 --os linux ${{ env.CONTAINER_NAME }}:latest ${{ env.CONTAINER_NAME }}@sha256:$(ls -p digests-linux-amd64/* | cut -d / -f 2)
#docker manifest annotate --arch arm64 --os linux ${{ env.CONTAINER_NAME }}:latest ${{ env.CONTAINER_NAME }}@sha256:$(ls -p digests-linux-arm64/* | cut -d / -f 2)
docker manifest inspect ${{ env.CONTAINER_NAME }}:latest
docker manifest push ${{ env.CONTAINER_NAME }}:latest
- name: Create manifest tagged
working-directory: /tmp/digests
run: |
docker manifest create ${{ env.CONTAINER_NAME }}:${{ env.RELEASE_VERSION }} \
--amend ${{ env.CONTAINER_NAME }}@sha256:$(ls -p digests-linux-amd64/* | cut -d / -f 2) \
--amend ${{ env.CONTAINER_NAME }}@sha256:$(ls -p digests-linux-arm64/* | cut -d / -f 2)
#docker manifest annotate --arch amd64 --os linux ${{ env.CONTAINER_NAME }}:${{ env.RELEASE_VERSION }} ${{ env.CONTAINER_NAME }}@sha256:$(ls -p digests-linux-amd64/* | cut -d / -f 2)
#docker manifest annotate --arch arm64 --os linux ${{ env.CONTAINER_NAME }}:${{ env.RELEASE_VERSION }} ${{ env.CONTAINER_NAME }}@sha256:$(ls -p digests-linux-arm64/* | cut -d / -f 2)
docker manifest inspect ${{ env.CONTAINER_NAME }}:${{ env.RELEASE_VERSION }}
docker manifest push ${{ env.CONTAINER_NAME }}:${{ env.RELEASE_VERSION }}
# Disabled due to https://github.com/go-gitea/gitea/issues/29563
#- name: Create manifest list and push
# working-directory: /tmp/digests
# run: |
# echo $DOCKER_METADATA_OUTPUT_JSON
# echo $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
# $(printf '${{ env.CONTAINER_NAME }}@sha256:%s ' $(ls -p */* | cut -d / -f 2))
# docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
# $(printf '${{ env.CONTAINER_NAME }}@sha256:%s ' $(ls -p */* | cut -d / -f 2))
#- name: Inspect image
# run: |
# docker buildx imagetools inspect ${{ env.CONTAINER_NAME }}:${{ steps.meta.outputs.version }}
- name: Notify
uses: rjstone/discord-webhook-notify@v1
if: failure()
with:
severity: ${{ job.status == 'success' && 'info' || (job.status == 'cancelled' && 'warn' || 'error') }}
details: Build ${{ job.status == 'success' && 'succeeded' || (job.status == 'cancelled' && 'cancelled' || 'failed') }}!
webhookUrl: ${{ secrets.DISCORD_WEBHOOK }}
username: Gitea
avatarUrl: ${{ vars.RUNNER_ICON_URL }}
Create-Release:
runs-on: [ubuntu-latest, linux/amd64]
needs: [Merge-Images]
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Get tag
run: echo "RELEASE_VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Prepare tea
run: |
# Download tea from Gitea release page
echo "Downloading Tea v${{ env.TEA_VERSION }}" && \
wget -q -O tea https://gitea.com/gitea/tea/releases/download/v${{ env.TEA_VERSION }}/tea-${{ env.TEA_VERSION }}-linux-amd64 && \
echo "Downloaded Tea" && \
chmod +x tea && \
# Login to Gitea
echo "Logging in to Gitea using Tea" && \
./tea login add --name SimpleLogin --url https://git.mrmeeb.stream --token ${{ secrets.GITHUB_TOKEN }} && \
echo "Done"
- name: Make release
run: |
echo "Creating release" && \
./tea release create --login "SimpleLogin" --repo ${{ env.GITHUB_REPOSITORY }} --tag ${{ env.RELEASE_VERSION }} -t ${{ env.RELEASE_VERSION }} -n "Triggered by release of v${{ env.RELEASE_VERSION }} by the SimpleLogin team. <a href=\"https://github.com/simple-login/app/releases/tag/v${{ env.RELEASE_VERSION }}\" target=\"_blank\">View the changelog</a>" && \
echo "Done"
- name: Notify
uses: rjstone/discord-webhook-notify@v1
if: failure()
with:
severity: ${{ job.status == 'success' && 'info' || (job.status == 'cancelled' && 'warn' || 'error') }}
details: Release ${{ job.status == 'success' && 'succeeded' || (job.status == 'cancelled' && 'cancelled' || 'failed') }}!
webhookUrl: ${{ secrets.DISCORD_WEBHOOK }}
username: Gitea
avatarUrl: ${{ vars.RUNNER_ICON_URL }}
Notify:
runs-on: ubuntu-latest
needs: [Build-Image, Merge-Images, Create-Release]
steps:
- name: Notify
uses: rjstone/discord-webhook-notify@v1
if: always()
with:
severity: ${{ job.status == 'success' && 'info' || (job.status == 'cancelled' && 'warn' || 'error') }}
details: Release ${{ job.status == 'success' && 'succeeded' || (job.status == 'cancelled' && 'cancelled' || 'failed') }}!
webhookUrl: ${{ secrets.DISCORD_WEBHOOK }}
username: Gitea
avatarUrl: ${{ vars.RUNNER_ICON_URL }}

View File

@ -74,7 +74,7 @@ Setting up DKIM is highly recommended to reduce the chance your emails ending up
First you need to generate a private and public key for DKIM:
```bash
openssl genrsa -out dkim.key 1024
openssl genrsa -out dkim.key -traditional 1024
openssl rsa -in dkim.key -pubout -out dkim.pub.key
```
@ -510,11 +510,14 @@ server {
server_name app.mydomain.com;
location / {
proxy_pass http://localhost:7777;
proxy_pass http://localhost:7777;
proxy_set_header Host $host;
}
}
```
Note: If `/etc/nginx/sites-enabled/default` exists, delete it or certbot will fail due to the conflict. The `simplelogin` file should be the only file in `sites-enabled`.
Reload Nginx with the command below
```bash

View File

@ -168,6 +168,8 @@ class NewUserStrategy(ClientMergeStrategy):
class ExistingUnlinkedUserStrategy(ClientMergeStrategy):
def process(self) -> LinkResult:
# IF it was scheduled to be deleted. Unschedule it.
self.user.delete_on = None
partner_user = ensure_partner_user_exists_for_user(
self.link_request, self.user, self.partner
)
@ -246,6 +248,8 @@ def link_user(
) -> LinkResult:
# Sanitize email just in case
link_request.email = sanitize_email(link_request.email)
# If it was scheduled to be deleted. Unschedule it.
current_user.delete_on = None
partner_user = ensure_partner_user_exists_for_user(
link_request, current_user, partner
)

View File

@ -214,6 +214,20 @@ class UserAdmin(SLModelView):
Session.commit()
@action(
"remove trial",
"Stop trial period",
"Remove trial for this user?",
)
def stop_trial(self, ids):
for user in User.filter(User.id.in_(ids)):
user.trial_end = None
flash(f"Stopped trial for {user}", "success")
AdminAuditLog.stop_trial(current_user.id, user.id)
Session.commit()
@action(
"disable_otp_fido",
"Disable OTP & FIDO",

View File

@ -33,6 +33,9 @@ def authorize_request() -> Optional[Tuple[str, int]]:
if g.user.disabled:
return jsonify(error="Disabled account"), 403
if not g.user.is_active():
return jsonify(error="Account does not exist"), 401
g.api_key = api_key
return None

View File

@ -201,10 +201,10 @@ def get_alias_infos_with_pagination_v3(
q = q.order_by(Alias.pinned.desc())
q = q.order_by(latest_activity.desc())
q = list(q.limit(page_limit).offset(page_id * page_size))
q = q.limit(page_limit).offset(page_id * page_size)
ret = []
for alias, contact, email_log, nb_reply, nb_blocked, nb_forward in q:
for alias, contact, email_log, nb_reply, nb_blocked, nb_forward in list(q):
ret.append(
AliasInfo(
alias=alias,
@ -358,7 +358,6 @@ def construct_alias_query(user: User):
else_=0,
)
).label("nb_forward"),
func.max(EmailLog.created_at).label("latest_email_log_created_at"),
)
.join(EmailLog, Alias.id == EmailLog.alias_id, isouter=True)
.filter(Alias.user_id == user.id)
@ -366,14 +365,6 @@ def construct_alias_query(user: User):
.subquery()
)
alias_contact_subquery = (
Session.query(Alias.id, func.max(Contact.id).label("max_contact_id"))
.join(Contact, Alias.id == Contact.alias_id, isouter=True)
.filter(Alias.user_id == user.id)
.group_by(Alias.id)
.subquery()
)
return (
Session.query(
Alias,
@ -385,23 +376,7 @@ def construct_alias_query(user: User):
)
.options(joinedload(Alias.hibp_breaches))
.options(joinedload(Alias.custom_domain))
.join(Contact, Alias.id == Contact.alias_id, isouter=True)
.join(EmailLog, Contact.id == EmailLog.contact_id, isouter=True)
.join(EmailLog, Alias.last_email_log_id == EmailLog.id, isouter=True)
.join(Contact, EmailLog.contact_id == Contact.id, isouter=True)
.filter(Alias.id == alias_activity_subquery.c.id)
.filter(Alias.id == alias_contact_subquery.c.id)
.filter(
or_(
EmailLog.created_at
== alias_activity_subquery.c.latest_email_log_created_at,
and_(
# no email log yet for this alias
alias_activity_subquery.c.latest_email_log_created_at.is_(None),
# to make sure only 1 contact is returned in this case
or_(
Contact.id == alias_contact_subquery.c.max_contact_id,
alias_contact_subquery.c.max_contact_id.is_(None),
),
),
)
)
)

View File

@ -31,6 +31,7 @@ from app.models import Alias, Contact, Mailbox, AliasMailbox
@deprecated
@api_bp.route("/aliases", methods=["GET", "POST"])
@require_api_auth
@limiter.limit("10/minute", key_func=lambda: g.user.id)
def get_aliases():
"""
Get aliases
@ -72,10 +73,8 @@ def get_aliases():
@api_bp.route("/v2/aliases", methods=["GET", "POST"])
@limiter.limit(
"5/minute",
)
@require_api_auth
@limiter.limit("50/minute", key_func=lambda: g.user.id)
def get_aliases_v2():
"""
Get aliases

View File

@ -17,9 +17,14 @@ from app.models import PlanEnum, AppleSubscription
_MONTHLY_PRODUCT_ID = "io.simplelogin.ios_app.subscription.premium.monthly"
_YEARLY_PRODUCT_ID = "io.simplelogin.ios_app.subscription.premium.yearly"
# SL Mac app used to be in SL account
_MACAPP_MONTHLY_PRODUCT_ID = "io.simplelogin.macapp.subscription.premium.monthly"
_MACAPP_YEARLY_PRODUCT_ID = "io.simplelogin.macapp.subscription.premium.yearly"
# SL Mac app is moved to Proton account
_MACAPP_MONTHLY_PRODUCT_ID_NEW = "me.proton.simplelogin.macos.premium.monthly"
_MACAPP_YEARLY_PRODUCT_ID_NEW = "me.proton.simplelogin.macos.premium.yearly"
# Apple API URL
_SANDBOX_URL = "https://sandbox.itunes.apple.com/verifyReceipt"
_PROD_URL = "https://buy.itunes.apple.com/verifyReceipt"
@ -263,7 +268,11 @@ def apple_update_notification():
plan = (
PlanEnum.monthly
if transaction["product_id"]
in (_MONTHLY_PRODUCT_ID, _MACAPP_MONTHLY_PRODUCT_ID)
in (
_MONTHLY_PRODUCT_ID,
_MACAPP_MONTHLY_PRODUCT_ID,
_MACAPP_MONTHLY_PRODUCT_ID_NEW,
)
else PlanEnum.yearly
)
@ -517,7 +526,11 @@ def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]:
plan = (
PlanEnum.monthly
if latest_transaction["product_id"]
in (_MONTHLY_PRODUCT_ID, _MACAPP_MONTHLY_PRODUCT_ID)
in (
_MONTHLY_PRODUCT_ID,
_MACAPP_MONTHLY_PRODUCT_ID,
_MACAPP_MONTHLY_PRODUCT_ID_NEW,
)
else PlanEnum.yearly
)

View File

@ -11,7 +11,7 @@ from itsdangerous import Signer
from app import email_utils
from app.api.base import api_bp
from app.config import FLASK_SECRET, DISABLE_REGISTRATION
from app.dashboard.views.setting import send_reset_password_email
from app.dashboard.views.account_setting import send_reset_password_email
from app.db import Session
from app.email_utils import (
email_can_be_used_as_mailbox,

View File

@ -32,6 +32,7 @@ def user_to_dict(user: User) -> dict:
"in_trial": user.in_trial(),
"max_alias_free_plan": user.max_alias_for_free_account(),
"connected_proton_address": None,
"can_create_reverse_alias": user.can_create_contacts(),
}
if config.CONNECT_WITH_PROTON:
@ -58,6 +59,7 @@ def user_info():
- in_trial
- max_alias_free
- is_connected_with_proton
- can_create_reverse_alias
"""
user = g.user

View File

@ -3,6 +3,7 @@ from flask_login import login_user
from app.auth.base import auth_bp
from app.db import Session
from app.log import LOG
from app.models import EmailChange, ResetPasswordCode
@ -22,12 +23,14 @@ def change_email():
return render_template("auth/change_email.html")
user = email_change.user
old_email = user.email
user.email = email_change.new_email
EmailChange.delete(email_change.id)
ResetPasswordCode.filter_by(user_id=user.id).delete()
Session.commit()
LOG.i(f"User {user} has changed their email from {old_email} to {user.email}")
flash("Your new email has been updated", "success")
login_user(user)

View File

@ -3,7 +3,7 @@ from flask_wtf import FlaskForm
from wtforms import StringField, validators
from app.auth.base import auth_bp
from app.dashboard.views.setting import send_reset_password_email
from app.dashboard.views.account_setting import send_reset_password_email
from app.extensions import limiter
from app.log import LOG
from app.models import User

View File

@ -7,7 +7,7 @@ from app.config import URL, GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET
from app.db import Session
from app.log import LOG
from app.models import User, File, SocialAuth
from app.utils import random_string, sanitize_email
from app.utils import random_string, sanitize_email, sanitize_next_url
from .login_utils import after_login
_authorization_base_url = "https://accounts.google.com/o/oauth2/v2/auth"
@ -29,7 +29,7 @@ def google_login():
# to avoid flask-login displaying the login error message
session.pop("_flashes", None)
next_url = request.args.get("next")
next_url = sanitize_next_url(request.args.get("next"))
# Google does not allow to append param to redirect_url
# we need to pass the next url by session

View File

@ -421,6 +421,8 @@ try:
except Exception:
HIBP_SCAN_INTERVAL_DAYS = 7
HIBP_API_KEYS = sl_getenv("HIBP_API_KEYS", list) or []
HIBP_MAX_ALIAS_CHECK = 10_000
HIBP_RPM = 100
POSTMASTER = os.environ.get("POSTMASTER")
@ -489,7 +491,34 @@ def setup_nameservers():
NAMESERVERS = setup_nameservers()
DISABLE_CREATE_CONTACTS_FOR_FREE_USERS = False
DISABLE_CREATE_CONTACTS_FOR_FREE_USERS = os.environ.get(
"DISABLE_CREATE_CONTACTS_FOR_FREE_USERS", False
)
# Expect format hits,seconds:hits,seconds...
# Example 1,10:4,60 means 1 in the last 10 secs or 4 in the last 60 secs
def getRateLimitFromConfig(
env_var: string, default: string = ""
) -> list[tuple[int, int]]:
value = os.environ.get(env_var, default)
if not value:
return []
entries = [entry for entry in value.split(":")]
limits = []
for entry in entries:
fields = entry.split(",")
limit = (int(fields[0]), int(fields[1]))
limits.append(limit)
return limits
ALIAS_CREATE_RATE_LIMIT_FREE = getRateLimitFromConfig(
"ALIAS_CREATE_RATE_LIMIT_FREE", "10,900:50,3600"
)
ALIAS_CREATE_RATE_LIMIT_PAID = getRateLimitFromConfig(
"ALIAS_CREATE_RATE_LIMIT_PAID", "50,900:200,3600"
)
PARTNER_API_TOKEN_SECRET = os.environ.get("PARTNER_API_TOKEN_SECRET") or (
FLASK_SECRET + "partnerapitoken"
)
@ -540,3 +569,5 @@ MAX_API_KEYS = int(os.environ.get("MAX_API_KEYS", 30))
UPCLOUD_USERNAME = os.environ.get("UPCLOUD_USERNAME", None)
UPCLOUD_PASSWORD = os.environ.get("UPCLOUD_PASSWORD", None)
UPCLOUD_DB_ID = os.environ.get("UPCLOUD_DB_ID", None)
STORE_TRANSACTIONAL_EMAILS = "STORE_TRANSACTIONAL_EMAILS" in os.environ

View File

@ -32,6 +32,7 @@ from .views import (
delete_account,
notification,
support,
account_setting,
)
__all__ = [
@ -68,4 +69,5 @@ __all__ = [
"delete_account",
"notification",
"support",
"account_setting",
]

View File

@ -0,0 +1,242 @@
import arrow
from flask import (
render_template,
request,
redirect,
url_for,
flash,
)
from flask_login import login_required, current_user
from app import email_utils
from app.config import (
URL,
FIRST_ALIAS_DOMAIN,
ALIAS_RANDOM_SUFFIX_LENGTH,
CONNECT_WITH_PROTON,
)
from app.dashboard.base import dashboard_bp
from app.dashboard.views.enter_sudo import sudo_required
from app.dashboard.views.mailbox_detail import ChangeEmailForm
from app.db import Session
from app.email_utils import (
email_can_be_used_as_mailbox,
personal_email_already_used,
)
from app.extensions import limiter
from app.jobs.export_user_data_job import ExportUserDataJob
from app.log import LOG
from app.models import (
BlockBehaviourEnum,
PlanEnum,
ResetPasswordCode,
EmailChange,
User,
Alias,
AliasGeneratorEnum,
SenderFormatEnum,
UnsubscribeBehaviourEnum,
)
from app.proton.utils import perform_proton_account_unlink
from app.utils import (
random_string,
CSRFValidationForm,
canonicalize_email,
)
@dashboard_bp.route("/account_setting", methods=["GET", "POST"])
@login_required
@sudo_required
@limiter.limit("5/minute", methods=["POST"])
def account_setting():
change_email_form = ChangeEmailForm()
csrf_form = CSRFValidationForm()
email_change = EmailChange.get_by(user_id=current_user.id)
if email_change:
pending_email = email_change.new_email
else:
pending_email = None
if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(url_for("dashboard.setting"))
if request.form.get("form-name") == "update-email":
if change_email_form.validate():
# whether user can proceed with the email update
new_email_valid = True
new_email = canonicalize_email(change_email_form.email.data)
if new_email != current_user.email and not pending_email:
# check if this email is not already used
if personal_email_already_used(new_email) or Alias.get_by(
email=new_email
):
flash(f"Email {new_email} already used", "error")
new_email_valid = False
elif not email_can_be_used_as_mailbox(new_email):
flash(
"You cannot use this email address as your personal inbox.",
"error",
)
new_email_valid = False
# a pending email change with the same email exists from another user
elif EmailChange.get_by(new_email=new_email):
other_email_change: EmailChange = EmailChange.get_by(
new_email=new_email
)
LOG.w(
"Another user has a pending %s with the same email address. Current user:%s",
other_email_change,
current_user,
)
if other_email_change.is_expired():
LOG.d(
"delete the expired email change %s", other_email_change
)
EmailChange.delete(other_email_change.id)
Session.commit()
else:
flash(
"You cannot use this email address as your personal inbox.",
"error",
)
new_email_valid = False
if new_email_valid:
email_change = EmailChange.create(
user_id=current_user.id,
code=random_string(
60
), # todo: make sure the code is unique
new_email=new_email,
)
Session.commit()
send_change_email_confirmation(current_user, email_change)
flash(
"A confirmation email is on the way, please check your inbox",
"success",
)
return redirect(url_for("dashboard.account_setting"))
elif request.form.get("form-name") == "change-password":
flash(
"You are going to receive an email containing instructions to change your password",
"success",
)
send_reset_password_email(current_user)
return redirect(url_for("dashboard.account_setting"))
elif request.form.get("form-name") == "send-full-user-report":
if ExportUserDataJob(current_user).store_job_in_db():
flash(
"You will receive your SimpleLogin data via email shortly",
"success",
)
else:
flash("An export of your data is currently in progress", "error")
partner_sub = None
partner_name = None
return render_template(
"dashboard/account_setting.html",
csrf_form=csrf_form,
PlanEnum=PlanEnum,
SenderFormatEnum=SenderFormatEnum,
BlockBehaviourEnum=BlockBehaviourEnum,
change_email_form=change_email_form,
pending_email=pending_email,
AliasGeneratorEnum=AliasGeneratorEnum,
UnsubscribeBehaviourEnum=UnsubscribeBehaviourEnum,
partner_sub=partner_sub,
partner_name=partner_name,
FIRST_ALIAS_DOMAIN=FIRST_ALIAS_DOMAIN,
ALIAS_RAND_SUFFIX_LENGTH=ALIAS_RANDOM_SUFFIX_LENGTH,
connect_with_proton=CONNECT_WITH_PROTON,
)
def send_reset_password_email(user):
"""
generate a new ResetPasswordCode and send it over email to user
"""
# the activation code is valid for 1h
reset_password_code = ResetPasswordCode.create(
user_id=user.id, code=random_string(60)
)
Session.commit()
reset_password_link = f"{URL}/auth/reset_password?code={reset_password_code.code}"
email_utils.send_reset_password_email(user.email, reset_password_link)
def send_change_email_confirmation(user: User, email_change: EmailChange):
"""
send confirmation email to the new email address
"""
link = f"{URL}/auth/change_email?code={email_change.code}"
email_utils.send_change_email(email_change.new_email, user.email, link)
@dashboard_bp.route("/resend_email_change", methods=["GET", "POST"])
@limiter.limit("5/hour")
@login_required
@sudo_required
def resend_email_change():
form = CSRFValidationForm()
if not form.validate():
flash("Invalid request. Please try again", "warning")
return redirect(url_for("dashboard.setting"))
email_change = EmailChange.get_by(user_id=current_user.id)
if email_change:
# extend email change expiration
email_change.expired = arrow.now().shift(hours=12)
Session.commit()
send_change_email_confirmation(current_user, email_change)
flash("A confirmation email is on the way, please check your inbox", "success")
return redirect(url_for("dashboard.setting"))
else:
flash(
"You have no pending email change. Redirect back to Setting page", "warning"
)
return redirect(url_for("dashboard.setting"))
@dashboard_bp.route("/cancel_email_change", methods=["GET", "POST"])
@login_required
@sudo_required
def cancel_email_change():
form = CSRFValidationForm()
if not form.validate():
flash("Invalid request. Please try again", "warning")
return redirect(url_for("dashboard.setting"))
email_change = EmailChange.get_by(user_id=current_user.id)
if email_change:
EmailChange.delete(email_change.id)
Session.commit()
flash("Your email change is cancelled", "success")
return redirect(url_for("dashboard.setting"))
else:
flash(
"You have no pending email change. Redirect back to Setting page", "warning"
)
return redirect(url_for("dashboard.setting"))
@dashboard_bp.route("/unlink_proton_account", methods=["POST"])
@login_required
@sudo_required
def unlink_proton_account():
csrf_form = CSRFValidationForm()
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(url_for("dashboard.setting"))
perform_proton_account_unlink(current_user)
flash("Your Proton account has been unlinked", "success")
return redirect(url_for("dashboard.setting"))

View File

@ -51,14 +51,6 @@ def email_validator():
return _check
def user_can_create_contacts(user: User) -> bool:
if user.is_premium():
return True
if user.flags & User.FLAG_FREE_DISABLE_CREATE_ALIAS == 0:
return True
return not config.DISABLE_CREATE_CONTACTS_FOR_FREE_USERS
def create_contact(user: User, alias: Alias, contact_address: str) -> Contact:
"""
Create a contact for a user. Can be restricted for new free users by enabling DISABLE_CREATE_CONTACTS_FOR_FREE_USERS.
@ -82,7 +74,7 @@ def create_contact(user: User, alias: Alias, contact_address: str) -> Contact:
if contact:
raise ErrContactAlreadyExists(contact)
if not user_can_create_contacts(user):
if not user.can_create_contacts():
raise ErrContactErrorUpgradeNeeded()
contact = Contact.create(
@ -327,6 +319,6 @@ def alias_contact_manager(alias_id):
last_page=last_page,
query=query,
nb_contact=nb_contact,
can_create_contacts=user_can_create_contacts(current_user),
can_create_contacts=current_user.can_create_contacts(),
csrf_form=csrf_form,
)

View File

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

View File

@ -5,6 +5,7 @@ from flask_login import login_required, current_user
from app import s3
from app.config import JOB_BATCH_IMPORT
from app.dashboard.base import dashboard_bp
from app.dashboard.views.enter_sudo import sudo_required
from app.db import Session
from app.log import LOG
from app.models import File, BatchImport, Job
@ -13,6 +14,7 @@ from app.utils import random_string, CSRFValidationForm
@dashboard_bp.route("/batch_import", methods=["GET", "POST"])
@login_required
@sudo_required
def batch_import_route():
# only for users who have custom domains
if not current_user.verified_custom_domains():

View File

@ -24,6 +24,7 @@ from app.models import (
AliasMailbox,
DomainDeletedAlias,
)
from app.utils import CSRFValidationForm
@dashboard_bp.route("/custom_alias", methods=["GET", "POST"])
@ -48,9 +49,13 @@ def custom_alias():
at_least_a_premium_domain = True
break
csrf_form = CSRFValidationForm()
mailboxes = current_user.mailboxes()
if request.method == "POST":
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(request.url)
alias_prefix = request.form.get("prefix").strip().lower().replace(" ", "")
signed_alias_suffix = request.form.get("signed-alias-suffix")
mailbox_ids = request.form.getlist("mailboxes")
@ -164,4 +169,5 @@ def custom_alias():
alias_suffixes=alias_suffixes,
at_least_a_premium_domain=at_least_a_premium_domain,
mailboxes=mailboxes,
csrf_form=csrf_form,
)

View File

@ -52,16 +52,13 @@ def get_stats(user: User) -> Stats:
@dashboard_bp.route("/", methods=["GET", "POST"])
@login_required
@limiter.limit(
ALIAS_LIMIT,
methods=["POST"],
exempt_when=lambda: request.form.get("form-name") != "create-random-email",
)
@limiter.limit(
"5/minute",
methods=["GET"],
)
@login_required
@limiter.limit("10/minute", methods=["GET"], key_func=lambda: current_user.id)
@parallel_limiter.lock(
name="alias_creation",
only_when=lambda: request.form.get("form-name") == "create-random-email",

View File

@ -13,34 +13,24 @@ from flask_login import login_required, current_user
from flask_wtf import FlaskForm
from flask_wtf.file import FileField
from wtforms import StringField, validators
from wtforms.fields.html5 import EmailField
from app import s3, email_utils
from app import s3
from app.config import (
URL,
FIRST_ALIAS_DOMAIN,
ALIAS_RANDOM_SUFFIX_LENGTH,
CONNECT_WITH_PROTON,
)
from app.dashboard.base import dashboard_bp
from app.db import Session
from app.email_utils import (
email_can_be_used_as_mailbox,
personal_email_already_used,
)
from app.errors import ProtonPartnerNotSetUp
from app.extensions import limiter
from app.image_validation import detect_image_format, ImageFormat
from app.jobs.export_user_data_job import ExportUserDataJob
from app.log import LOG
from app.models import (
BlockBehaviourEnum,
PlanEnum,
File,
ResetPasswordCode,
EmailChange,
User,
Alias,
CustomDomain,
AliasGeneratorEnum,
AliasSuffixEnum,
@ -53,11 +43,10 @@ from app.models import (
PartnerSubscription,
UnsubscribeBehaviourEnum,
)
from app.proton.utils import get_proton_partner, perform_proton_account_unlink
from app.proton.utils import get_proton_partner
from app.utils import (
random_string,
CSRFValidationForm,
canonicalize_email,
)
@ -66,12 +55,6 @@ class SettingForm(FlaskForm):
profile_picture = FileField("Profile Picture")
class ChangeEmailForm(FlaskForm):
email = EmailField(
"email", validators=[validators.DataRequired(), validators.Email()]
)
class PromoCodeForm(FlaskForm):
code = StringField("Name", validators=[validators.DataRequired()])
@ -109,7 +92,6 @@ def get_partner_subscription_and_name(
def setting():
form = SettingForm()
promo_form = PromoCodeForm()
change_email_form = ChangeEmailForm()
csrf_form = CSRFValidationForm()
email_change = EmailChange.get_by(user_id=current_user.id)
@ -122,63 +104,7 @@ def setting():
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(url_for("dashboard.setting"))
if request.form.get("form-name") == "update-email":
if change_email_form.validate():
# whether user can proceed with the email update
new_email_valid = True
new_email = canonicalize_email(change_email_form.email.data)
if new_email != current_user.email and not pending_email:
# check if this email is not already used
if personal_email_already_used(new_email) or Alias.get_by(
email=new_email
):
flash(f"Email {new_email} already used", "error")
new_email_valid = False
elif not email_can_be_used_as_mailbox(new_email):
flash(
"You cannot use this email address as your personal inbox.",
"error",
)
new_email_valid = False
# a pending email change with the same email exists from another user
elif EmailChange.get_by(new_email=new_email):
other_email_change: EmailChange = EmailChange.get_by(
new_email=new_email
)
LOG.w(
"Another user has a pending %s with the same email address. Current user:%s",
other_email_change,
current_user,
)
if other_email_change.is_expired():
LOG.d(
"delete the expired email change %s", other_email_change
)
EmailChange.delete(other_email_change.id)
Session.commit()
else:
flash(
"You cannot use this email address as your personal inbox.",
"error",
)
new_email_valid = False
if new_email_valid:
email_change = EmailChange.create(
user_id=current_user.id,
code=random_string(
60
), # todo: make sure the code is unique
new_email=new_email,
)
Session.commit()
send_change_email_confirmation(current_user, email_change)
flash(
"A confirmation email is on the way, please check your inbox",
"success",
)
return redirect(url_for("dashboard.setting"))
if request.form.get("form-name") == "update-profile":
if form.validate():
profile_updated = False
@ -222,15 +148,6 @@ def setting():
if profile_updated:
flash("Your profile has been updated", "success")
return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "change-password":
flash(
"You are going to receive an email containing instructions to change your password",
"success",
)
send_reset_password_email(current_user)
return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "notification-preference":
choose = request.form.get("notification")
if choose == "on":
@ -240,7 +157,6 @@ def setting():
Session.commit()
flash("Your notification preference has been updated", "success")
return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "change-alias-generator":
scheme = int(request.form.get("alias-generator-scheme"))
if AliasGeneratorEnum.has_value(scheme):
@ -248,7 +164,6 @@ def setting():
Session.commit()
flash("Your preference has been updated", "success")
return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "change-random-alias-default-domain":
default_domain = request.form.get("random-alias-default-domain")
@ -287,7 +202,6 @@ def setting():
Session.commit()
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"))
if AliasSuffixEnum.has_value(scheme):
@ -295,7 +209,6 @@ def setting():
Session.commit()
flash("Your preference has been updated", "success")
return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "change-sender-format":
sender_format = int(request.form.get("sender-format"))
if SenderFormatEnum.has_value(sender_format):
@ -305,7 +218,6 @@ def setting():
flash("Your sender format preference has been updated", "success")
Session.commit()
return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "replace-ra":
choose = request.form.get("replace-ra")
if choose == "on":
@ -315,7 +227,6 @@ def setting():
Session.commit()
flash("Your preference has been updated", "success")
return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "sender-in-ra":
choose = request.form.get("enable")
if choose == "on":
@ -325,7 +236,6 @@ def setting():
Session.commit()
flash("Your preference has been updated", "success")
return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "expand-alias-info":
choose = request.form.get("enable")
if choose == "on":
@ -387,14 +297,6 @@ def setting():
Session.commit()
flash("Your preference has been updated", "success")
return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "send-full-user-report":
if ExportUserDataJob(current_user).store_job_in_db():
flash(
"You will receive your SimpleLogin data via email shortly",
"success",
)
else:
flash("An export of your data is currently in progress", "error")
manual_sub = ManualSubscription.get_by(user_id=current_user.id)
apple_sub = AppleSubscription.get_by(user_id=current_user.id)
@ -417,7 +319,6 @@ def setting():
SenderFormatEnum=SenderFormatEnum,
BlockBehaviourEnum=BlockBehaviourEnum,
promo_form=promo_form,
change_email_form=change_email_form,
pending_email=pending_email,
AliasGeneratorEnum=AliasGeneratorEnum,
UnsubscribeBehaviourEnum=UnsubscribeBehaviourEnum,
@ -432,85 +333,3 @@ def setting():
connect_with_proton=CONNECT_WITH_PROTON,
proton_linked_account=proton_linked_account,
)
def send_reset_password_email(user):
"""
generate a new ResetPasswordCode and send it over email to user
"""
# the activation code is valid for 1h
reset_password_code = ResetPasswordCode.create(
user_id=user.id, code=random_string(60)
)
Session.commit()
reset_password_link = f"{URL}/auth/reset_password?code={reset_password_code.code}"
email_utils.send_reset_password_email(user.email, reset_password_link)
def send_change_email_confirmation(user: User, email_change: EmailChange):
"""
send confirmation email to the new email address
"""
link = f"{URL}/auth/change_email?code={email_change.code}"
email_utils.send_change_email(email_change.new_email, user.email, link)
@dashboard_bp.route("/resend_email_change", methods=["GET", "POST"])
@limiter.limit("5/hour")
@login_required
def resend_email_change():
form = CSRFValidationForm()
if not form.validate():
flash("Invalid request. Please try again", "warning")
return redirect(url_for("dashboard.setting"))
email_change = EmailChange.get_by(user_id=current_user.id)
if email_change:
# extend email change expiration
email_change.expired = arrow.now().shift(hours=12)
Session.commit()
send_change_email_confirmation(current_user, email_change)
flash("A confirmation email is on the way, please check your inbox", "success")
return redirect(url_for("dashboard.setting"))
else:
flash(
"You have no pending email change. Redirect back to Setting page", "warning"
)
return redirect(url_for("dashboard.setting"))
@dashboard_bp.route("/cancel_email_change", methods=["GET", "POST"])
@login_required
def cancel_email_change():
form = CSRFValidationForm()
if not form.validate():
flash("Invalid request. Please try again", "warning")
return redirect(url_for("dashboard.setting"))
email_change = EmailChange.get_by(user_id=current_user.id)
if email_change:
EmailChange.delete(email_change.id)
Session.commit()
flash("Your email change is cancelled", "success")
return redirect(url_for("dashboard.setting"))
else:
flash(
"You have no pending email change. Redirect back to Setting page", "warning"
)
return redirect(url_for("dashboard.setting"))
@dashboard_bp.route("/unlink_proton_account", methods=["POST"])
@login_required
def unlink_proton_account():
csrf_form = CSRFValidationForm()
if not csrf_form.validate():
flash("Invalid request", "warning")
return redirect(url_for("dashboard.setting"))
perform_proton_account_unlink(current_user)
flash("Your Proton account has been unlinked", "success")
return redirect(url_for("dashboard.setting"))

View File

@ -583,6 +583,26 @@ def email_can_be_used_as_mailbox(email_address: str) -> bool:
LOG.d("MX Domain %s %s is invalid mailbox domain", mx_domain, domain)
return False
existing_user = User.get_by(email=email_address)
if existing_user and existing_user.disabled:
LOG.d(
f"User {existing_user} is disabled. {email_address} cannot be used for other mailbox"
)
return False
for existing_user in (
User.query()
.join(Mailbox, User.id == Mailbox.user_id)
.filter(Mailbox.email == email_address)
.group_by(User.id)
.all()
):
if existing_user.disabled:
LOG.d(
f"User {existing_user} is disabled and has a mailbox with {email_address}. Id cannot be used for other mailbox"
)
return False
return True
@ -1383,7 +1403,7 @@ def generate_verp_email(
# Time is in minutes granularity and start counting on 2022-01-01 to reduce bytes to represent time
data = [
verp_type.value,
object_id,
object_id or 0,
int((time.time() - VERP_TIME_START) / 60),
]
json_payload = json.dumps(data).encode("utf-8")

View File

@ -131,7 +131,7 @@ def quarantine_dmarc_failed_forward_email(alias, contact, envelope, msg) -> Emai
refused_email = RefusedEmail.create(
full_report_path=s3_report_path, user_id=alias.user_id, flush=True
)
return EmailLog.create(
email_log = EmailLog.create(
user_id=alias.user_id,
mailbox_id=alias.mailbox_id,
contact_id=contact.id,
@ -142,6 +142,7 @@ def quarantine_dmarc_failed_forward_email(alias, contact, envelope, msg) -> Emai
blocked=True,
commit=True,
)
return email_log
def apply_dmarc_policy_for_reply_phase(

View File

@ -3,6 +3,7 @@ from email.header import Header
from email.message import Message
from app.email import headers
from app import config
from app.email_utils import add_or_replace_header, delete_header
from app.handler.unsubscribe_encoder import (
UnsubscribeEncoder,
@ -47,6 +48,11 @@ class UnsubscribeGenerator:
method = raw_method[start + 1 : end]
url_data = urllib.parse.urlparse(method)
if url_data.scheme == "mailto":
if url_data.path == config.UNSUBSCRIBER:
LOG.debug(
f"Skipping replacing unsubscribe since the original email already points to {config.UNSUBSCRIBER}"
)
return message
query_data = urllib.parse.parse_qs(url_data.query)
mailto_unsubs = (url_data.path, query_data.get("subject", [""])[0])
LOG.debug(f"Unsub is mailto to {mailto_unsubs}")

View File

@ -27,7 +27,7 @@ from sqlalchemy.orm import deferred
from sqlalchemy.sql import and_
from sqlalchemy_utils import ArrowType
from app import config
from app import config, rate_limiter
from app import s3
from app.db import Session
from app.dns_utils import get_mx_domains
@ -235,6 +235,7 @@ class AuditLogActionEnum(EnumE):
download_provider_complaint = 8
disable_user = 9
enable_user = 10
stop_trial = 11
class Phase(EnumE):
@ -726,6 +727,11 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
return True
def is_active(self) -> bool:
if self.delete_on is None:
return True
return self.delete_on < arrow.now()
def in_trial(self):
"""return True if user does not have lifetime licence or an active subscription AND is in trial period"""
if self.lifetime_or_active_subscription():
@ -827,6 +833,9 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
Whether user can create a new alias. User can't create a new alias if
- has more than 15 aliases in the free plan, *even in the free trial*
"""
if not self.is_active():
return False
if self.disabled:
return False
@ -907,7 +916,11 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
return sub
def verified_custom_domains(self) -> List["CustomDomain"]:
return CustomDomain.filter_by(user_id=self.id, ownership_verified=True).all()
return (
CustomDomain.filter_by(user_id=self.id, ownership_verified=True)
.order_by(CustomDomain.domain.asc())
.all()
)
def mailboxes(self) -> List["Mailbox"]:
"""list of mailbox that user own"""
@ -1113,6 +1126,13 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
return random_words(1)
def can_create_contacts(self) -> bool:
if self.is_premium():
return True
if self.flags & User.FLAG_FREE_DISABLE_CREATE_ALIAS == 0:
return True
return not config.DISABLE_CREATE_CONTACTS_FOR_FREE_USERS
def __repr__(self):
return f"<User {self.id} {self.name} {self.email}>"
@ -1488,6 +1508,8 @@ class Alias(Base, ModelMixin):
TSVector(), sa.Computed("to_tsvector('english', note)", persisted=True)
)
last_email_log_id = sa.Column(sa.Integer, default=None, nullable=True)
__table_args__ = (
Index("ix_video___ts_vector__", ts_vector, postgresql_using="gin"),
# index on note column using pg_trgm
@ -1506,7 +1528,8 @@ class Alias(Base, ModelMixin):
def mailboxes(self):
ret = [self.mailbox]
for m in self._mailboxes:
ret.append(m)
if m.id is not self.mailbox.id:
ret.append(m)
ret = [mb for mb in ret if mb.verified]
ret = sorted(ret, key=lambda mb: mb.email)
@ -1555,6 +1578,15 @@ class Alias(Base, ModelMixin):
flush = kw.pop("flush", False)
new_alias = cls(**kw)
user = User.get(new_alias.user_id)
if user.is_premium():
limits = config.ALIAS_CREATE_RATE_LIMIT_PAID
else:
limits = config.ALIAS_CREATE_RATE_LIMIT_FREE
# limits is array of (hits,days)
for limit in limits:
key = f"alias_create_{limit[1]}d:{user.id}"
rate_limiter.check_bucket_limit(key, limit[0], limit[1])
email = kw["email"]
# make sure email is lowercase and doesn't have any whitespace
@ -2037,6 +2069,20 @@ class EmailLog(Base, ModelMixin):
def get_dashboard_url(self):
return f"{config.URL}/dashboard/refused_email?highlight_id={self.id}"
@classmethod
def create(cls, *args, **kwargs):
commit = kwargs.pop("commit", False)
email_log = super().create(*args, **kwargs)
Session.flush()
if "alias_id" in kwargs:
sql = "UPDATE alias SET last_email_log_id = :el_id WHERE id = :alias_id"
Session.execute(
sql, {"el_id": email_log.id, "alias_id": kwargs["alias_id"]}
)
if commit:
Session.commit()
return email_log
def __repr__(self):
return f"<EmailLog {self.id}>"
@ -3132,6 +3178,20 @@ class TransactionalEmail(Base, ModelMixin):
__table_args__ = (sa.Index("ix_transactional_email_created_at", "created_at"),)
@classmethod
def create(cls, **kw):
# whether to call Session.commit
commit = kw.pop("commit", False)
r = cls(**kw)
if not config.STORE_TRANSACTIONAL_EMAILS:
return r
Session.add(r)
if commit:
Session.commit()
return r
class Payout(Base, ModelMixin):
"""Referral payouts"""
@ -3322,6 +3382,15 @@ class AdminAuditLog(Base):
},
)
@classmethod
def stop_trial(cls, admin_user_id: int, user_id: int):
cls.create(
admin_user_id=admin_user_id,
action=AuditLogActionEnum.stop_trial.value,
model="User",
model_id=user_id,
)
@classmethod
def disable_otp_fido(
cls, admin_user_id: int, user_id: int, had_otp: bool, had_fido: bool

View File

@ -140,7 +140,7 @@ def authorize():
Scope=Scope,
)
else: # POST - user allows or denies
if not current_user.is_authenticated or not current_user.is_active:
if not current_user.is_authenticated or not current_user.is_active():
LOG.i(
"Attempt to validate a OAUth allow request by an unauthenticated user"
)

40
app/app/rate_limiter.py Normal file
View File

@ -0,0 +1,40 @@
from datetime import datetime
from typing import Optional
import newrelic.agent
import redis.exceptions
import werkzeug.exceptions
from limits.storage import RedisStorage
from app.log import LOG
lock_redis: Optional[RedisStorage] = None
def set_redis_concurrent_lock(redis: RedisStorage):
global lock_redis
lock_redis = redis
def check_bucket_limit(
lock_name: Optional[str] = None,
max_hits: int = 5,
bucket_seconds: int = 3600,
):
# Calculate current bucket time
int_time = int(datetime.utcnow().timestamp())
bucket_id = int_time - (int_time % bucket_seconds)
bucket_lock_name = f"bl:{lock_name}:{bucket_id}"
if not lock_redis:
return
try:
value = lock_redis.incr(bucket_lock_name, bucket_seconds)
if value > max_hits:
LOG.i(f"Rate limit hit for {bucket_lock_name} -> {value}/{max_hits}")
newrelic.agent.record_custom_event(
"BucketRateLimit",
{"lock_name": lock_name, "bucket_seconds": bucket_seconds},
)
raise werkzeug.exceptions.TooManyRequests()
except (redis.exceptions.RedisError, AttributeError):
LOG.e("Cannot connect to redis")

View File

@ -2,6 +2,7 @@ import flask
import limits.storage
from app.parallel_limiter import set_redis_concurrent_lock
from app.rate_limiter import set_redis_concurrent_lock as rate_limit_set_redis
from app.session import RedisSessionStore
@ -10,12 +11,14 @@ def initialize_redis_services(app: flask.Flask, redis_url: str):
storage = limits.storage.RedisStorage(redis_url)
app.session_interface = RedisSessionStore(storage.storage, storage.storage, app)
set_redis_concurrent_lock(storage)
rate_limit_set_redis(storage)
elif redis_url.startswith("redis+sentinel://"):
storage = limits.storage.RedisSentinelStorage(redis_url)
app.session_interface = RedisSessionStore(
storage.storage, storage.storage_slave, app
)
set_redis_concurrent_lock(storage)
rate_limit_set_redis(storage)
else:
raise RuntimeError(
f"Tried to set_redis_session with an invalid redis url: ${redis_url}"

View File

@ -49,11 +49,11 @@ def random_string(length=10, include_digits=False):
def convert_to_id(s: str):
"""convert a string to id-like: remove space, remove special accent"""
s = s.replace(" ", "")
s = s.lower()
s = unidecode(s)
s = s.replace(" ", "")
return s
return s[:256]
_ALLOWED_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-."

View File

@ -5,7 +5,7 @@ from typing import List, Tuple
import arrow
import requests
from sqlalchemy import func, desc, or_, and_
from sqlalchemy import func, desc, or_, and_, nullsfirst
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import joinedload
from sqlalchemy.orm.exc import ObjectDeletedError
@ -62,6 +62,8 @@ from app.proton.utils import get_proton_partner
from app.utils import sanitize_email
from server import create_light_app
DELETE_GRACE_DAYS = 30
def notify_trial_end():
for user in User.filter(
@ -960,6 +962,9 @@ async def _hibp_check(api_key, queue):
This function to be ran simultaneously (multiple _hibp_check functions with different keys on the same queue) to make maximum use of multiple API keys.
"""
default_rate_sleep = (60.0 / config.HIBP_RPM) + 0.1
rate_sleep = default_rate_sleep
rate_hit_counter = 0
while True:
try:
alias_id = queue.get_nowait()
@ -967,9 +972,11 @@ async def _hibp_check(api_key, queue):
return
alias = Alias.get(alias_id)
# an alias can be deleted in the meantime
if not alias:
return
continue
user = alias.user
if user.disabled or not user.is_paid():
continue
LOG.d("Checking HIBP for %s", alias)
@ -981,7 +988,6 @@ async def _hibp_check(api_key, queue):
f"https://haveibeenpwned.com/api/v3/breachedaccount/{urllib.parse.quote(alias.email)}",
headers=request_headers,
)
if r.status_code == 200:
# Breaches found
alias.hibp_breaches = [
@ -989,20 +995,27 @@ async def _hibp_check(api_key, queue):
]
if len(alias.hibp_breaches) > 0:
LOG.w("%s appears in HIBP breaches %s", alias, alias.hibp_breaches)
if rate_hit_counter > 0:
rate_hit_counter -= 1
elif r.status_code == 404:
# No breaches found
alias.hibp_breaches = []
elif r.status_code == 429:
# rate limited
LOG.w("HIBP rate limited, check alias %s in the next run", alias)
await asyncio.sleep(1.6)
return
rate_hit_counter += 1
rate_sleep = default_rate_sleep + (0.2 * rate_hit_counter)
if rate_hit_counter > 10:
LOG.w(f"HIBP rate limited too many times stopping with alias {alias}")
return
# Just sleep for a while
asyncio.sleep(5)
elif r.status_code > 500:
LOG.w("HIBP server 5** error %s", r.status_code)
return
else:
LOG.error(
"An error occured while checking alias %s: %s - %s",
"An error occurred while checking alias %s: %s - %s",
alias,
r.status_code,
r.text,
@ -1013,9 +1026,8 @@ async def _hibp_check(api_key, queue):
Session.add(alias)
Session.commit()
LOG.d("Updated breaches info for %s", alias)
await asyncio.sleep(1.6)
LOG.d("Updated breach info for %s", alias)
await asyncio.sleep(rate_sleep)
async def check_hibp():
@ -1038,15 +1050,22 @@ async def check_hibp():
Session.commit()
LOG.d("Updated list of known breaches")
LOG.d("Getting the list of users to skip")
query = "select u.id, count(a.id) from users u, alias a where a.user_id=u.id group by u.id having count(a.id) > :max_alias"
rows = Session.execute(query, {"max_alias": config.HIBP_MAX_ALIAS_CHECK})
user_ids = [row[0] for row in rows]
LOG.d("Got %d users to skip" % len(user_ids))
LOG.d("Preparing list of aliases to check")
queue = asyncio.Queue()
max_date = arrow.now().shift(days=-config.HIBP_SCAN_INTERVAL_DAYS)
for alias in (
Alias.filter(
or_(Alias.hibp_last_check.is_(None), Alias.hibp_last_check < max_date)
or_(Alias.hibp_last_check.is_(None), Alias.hibp_last_check < max_date),
Alias.user_id.notin_(user_ids),
)
.filter(Alias.enabled)
.order_by(Alias.hibp_last_check.asc())
.order_by(nullsfirst(Alias.hibp_last_check.asc()), Alias.id.asc())
.yield_per(500)
.enable_eagerloads(False)
):
@ -1126,14 +1145,19 @@ def notify_hibp():
Session.commit()
def clear_users_scheduled_to_be_deleted():
def clear_users_scheduled_to_be_deleted(dry_run=False):
users = User.filter(
and_(User.delete_on.isnot(None), User.delete_on < arrow.now())
and_(
User.delete_on.isnot(None),
User.delete_on <= arrow.now().shift(days=-DELETE_GRACE_DAYS),
)
).all()
for user in users:
LOG.i(
f"Scheduled deletion of user {user} with scheduled delete on {user.delete_on}"
)
if dry_run:
continue
User.delete(user.id)
Session.commit()
@ -1206,4 +1230,4 @@ 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()
clear_users_scheduled_to_be_deleted(dry_run=True)

View File

@ -62,7 +62,7 @@ jobs:
captureStderr: true
- name: SimpleLogin delete users scheduled to be deleted
command: echo disabled_user_deletion #python /code/cron.py -j delete_scheduled_users
command: python /code/cron.py -j delete_scheduled_users
shell: /bin/bash
schedule: "15 11 * * *"
captureStderr: true

View File

@ -388,7 +388,7 @@ Input:
- (Optional but recommended) `hostname` passed in query string
- Request Message Body in json (`Content-Type` is `application/json`)
- alias_prefix: string. The first part of the alias that user can choose.
- signed_suffix: should be one of the suffixes returned in the `GET /api/v4/alias/options` endpoint.
- signed_suffix: should be one of the suffixes returned in the `GET /api/v5/alias/options` endpoint.
- mailbox_ids: list of mailbox_id that "owns" this alias
- (Optional) note: alias note
- (Optional) name: alias name

View File

@ -236,15 +236,16 @@ def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Con
Session.commit()
else:
try:
contact_email_for_reply = (
contact_email if is_valid_email(contact_email) else ""
)
contact = Contact.create(
user_id=alias.user_id,
alias_id=alias.id,
website_email=contact_email,
name=contact_name,
mail_from=mail_from,
reply_email=generate_reply_email(contact_email, alias)
if is_valid_email(contact_email)
else NOREPLY,
reply_email=generate_reply_email(contact_email_for_reply, alias),
automatic_created=True,
)
if not contact_email:
@ -636,6 +637,10 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str
user = alias.user
if not user.is_active():
LOG.w(f"User {user} has been soft deleted")
return False, status.E502
if not user.can_send_or_receive():
LOG.i(f"User {user} cannot receive emails")
if should_ignore_bounce(envelope.mail_from):
@ -1055,6 +1060,9 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str):
if not contact:
LOG.w(f"No contact with {reply_email} as reverse alias")
return False, status.E502
if not contact.user.is_active():
LOG.w(f"User {contact.user} has been soft deleted")
return False, status.E502
alias = contact.alias
alias_address: str = contact.alias.email
@ -1883,24 +1891,30 @@ def handle_transactional_bounce(
envelope: Envelope, msg, rcpt_to, transactional_id=None
):
LOG.d("handle transactional bounce sent to %s", rcpt_to)
if transactional_id is None:
LOG.i(
f"No transactional record for {envelope.mail_from} -> {envelope.rcpt_tos}"
)
return
# parse the TransactionalEmail
transactional_id = transactional_id or parse_id_from_bounce(rcpt_to)
transactional = TransactionalEmail.get(transactional_id)
# a transaction might have been deleted in delete_logs()
if transactional:
LOG.i("Create bounce for %s", transactional.email)
bounce_info = get_mailbox_bounce_info(msg)
if bounce_info:
Bounce.create(
email=transactional.email,
info=bounce_info.as_bytes().decode(),
commit=True,
)
else:
LOG.w("cannot get bounce info, debug at %s", save_email_for_debugging(msg))
Bounce.create(email=transactional.email, commit=True)
if not transactional:
LOG.i(
f"No transactional record for {envelope.mail_from} -> {envelope.rcpt_tos}"
)
return
LOG.i("Create bounce for %s", transactional.email)
bounce_info = get_mailbox_bounce_info(msg)
if bounce_info:
Bounce.create(
email=transactional.email,
info=bounce_info.as_bytes().decode(),
commit=True,
)
else:
LOG.w("cannot get bounce info, debug at %s", save_email_for_debugging(msg))
Bounce.create(email=transactional.email, commit=True)
def handle_bounce(envelope, email_log: EmailLog, msg: Message) -> str:
@ -1921,6 +1935,9 @@ def handle_bounce(envelope, email_log: EmailLog, msg: Message) -> str:
contact,
alias,
)
if not email_log.user.is_active():
LOG.d(f"User {email_log.user} is not active")
return status.E510
if email_log.is_reply:
content_type = msg.get_content_type().lower()
@ -1982,6 +1999,9 @@ def send_no_reply_response(mail_from: str, msg: Message):
if not mailbox:
LOG.d("Unknown sender. Skipping reply from {}".format(NOREPLY))
return
if not mailbox.user.is_active():
LOG.d(f"User {mailbox.user} is soft-deleted. Skipping sending reply response")
return
send_email_at_most_times(
mailbox.user,
ALERT_TO_NOREPLY,

View File

@ -192,7 +192,6 @@ amigos
amines
amnion
amoeba
amoral
amount
amours
ampere
@ -215,7 +214,6 @@ animus
anions
ankles
anklet
annals
anneal
annoys
annual
@ -364,7 +362,6 @@ auntie
aureus
aurora
author
autism
autumn
avails
avatar
@ -638,14 +635,12 @@ bigwig
bijoux
bikers
biking
bikini
bilges
bilked
bilker
billed
billet
billow
bimbos
binary
binder
binged
@ -710,8 +705,6 @@ blocks
blokes
blonde
blonds
bloods
bloody
blooms
bloops
blotch
@ -817,8 +810,6 @@ bounds
bounty
bovine
bovver
bowels
bowers
bowing
bowled
bowleg
@ -827,10 +818,8 @@ bowman
bowmen
bowwow
boxcar
boxers
boxier
boxing
boyish
braced
bracer
braces
@ -861,7 +850,6 @@ breach
breads
breaks
breams
breast
breath
breech
breeds
@ -872,9 +860,6 @@ brevet
brewed
brewer
briars
bribed
briber
bribes
bricks
bridal
brides
@ -926,13 +911,7 @@ buffed
buffer
buffet
bugged
bugger
bugled
bugler
bugles
builds
bulged
bulges
bulked
bulled
bullet
@ -1340,8 +1319,6 @@ clingy
clinic
clinks
clique
cloaca
cloaks
cloche
clocks
clomps
@ -1448,7 +1425,6 @@ comply
compos
conchs
concur
condom
condor
condos
coneys
@ -1568,8 +1544,6 @@ cranes
cranks
cranky
cranny
crapes
crappy
crated
crater
crates
@ -1585,7 +1559,6 @@ crazes
creaks
creaky
creams
creamy
crease
create
creche
@ -1594,8 +1567,6 @@ credos
creeds
creeks
creels
creeps
creepy
cremes
creole
crepes
@ -1728,9 +1699,6 @@ dainty
daises
damage
damask
dammed
dammit
damned
damped
dampen
damper
@ -1754,7 +1722,6 @@ darers
daring
darken
darker
darkie
darkly
darned
darner
@ -1763,8 +1730,6 @@ darter
dashed
dasher
dashes
daters
dating
dative
daubed
dauber
@ -1921,7 +1886,6 @@ dharma
dhotis
diadem
dialog
diaper
diatom
dibble
dicier
@ -1943,7 +1907,6 @@ digits
diking
diktat
dilate
dildos
dilute
dimity
dimmed
@ -2058,7 +2021,6 @@ dotted
double
doubly
doubts
douche
doughy
dourer
dourly
@ -2139,15 +2101,6 @@ duenna
duffed
duffer
dugout
dulcet
dulled
duller
dumber
dumbly
dumbos
dumdum
dumped
dumper
dunces
dunged
dunked
@ -2285,7 +2238,6 @@ endows
endued
endues
endure
enemas
energy
enfold
engage
@ -2333,7 +2285,6 @@ erects
ermine
eroded
erodes
erotic
errand
errant
errata
@ -2344,7 +2295,6 @@ eructs
erupts
escape
eschew
escort
escrow
escudo
espied
@ -2363,7 +2313,6 @@ ethnic
etudes
euchre
eulogy
eunuch
eureka
evaded
evader
@ -2392,7 +2341,6 @@ exempt
exerts
exeunt
exhale
exhort
exhume
exiled
exiles
@ -2415,7 +2363,6 @@ extant
extend
extent
extols
extort
extras
exuded
exudes
@ -2440,7 +2387,6 @@ faeces
faerie
faffed
fagged
faggot
failed
faille
fainer
@ -2473,18 +2419,10 @@ faring
farmed
farmer
farrow
farted
fascia
fasted
fasten
faster
father
fathom
fating
fatsos
fatten
fatter
fatwas
faucet
faults
faulty
@ -2532,7 +2470,6 @@ fesses
festal
fester
feting
fetish
fetter
fettle
feudal
@ -2617,9 +2554,7 @@ flaked
flakes
flambe
flamed
flamer
flames
flange
flanks
flared
flares
@ -2754,8 +2689,6 @@ franks
frappe
frauds
frayed
freaks
freaky
freely
freest
freeze
@ -2795,8 +2728,6 @@ fryers
frying
ftpers
ftping
fucked
fucker
fuddle
fudged
fudges
@ -2891,10 +2822,7 @@ gasbag
gashed
gashes
gasket
gasman
gasmen
gasped
gassed
gasses
gateau
gather
@ -3104,7 +3032,6 @@ grimed
grimes
grimly
grinds
gringo
griped
griper
gripes
@ -3186,8 +3113,6 @@ gypsum
gyrate
gyving
habits
hacked
hacker
hackle
hadith
haggis
@ -3195,8 +3120,6 @@ haggle
hailed
hairdo
haired
hajjes
hajjis
halest
haling
halite
@ -3223,11 +3146,8 @@ happen
haptic
harass
harden
harder
hardly
harems
haring
harked
harlot
harmed
harped
@ -3407,7 +3327,6 @@ hoofed
hoofer
hookah
hooked
hooker
hookup
hooped
hoopla
@ -3459,8 +3378,6 @@ huffed
hugely
hugest
hugged
hulled
huller
humane
humans
humble
@ -3667,8 +3584,6 @@ jacket
jading
jagged
jaguar
jailed
jailer
jalopy
jammed
jangle
@ -3689,8 +3604,6 @@ jejune
jelled
jellos
jennet
jerked
jerkin
jersey
jested
jester
@ -3814,11 +3727,7 @@ kidded
kidder
kiddie
kiddos
kidnap
kidney
killed
killer
kilned
kilted
kilter
kimono
@ -3827,15 +3736,11 @@ kinder
kindle
kindly
kingly
kinked
kiosks
kipped
kipper
kirsch
kismet
kissed
kisser
kisses
kiting
kitsch
kitted
@ -3847,10 +3752,6 @@ kluges
klutzy
knacks
knaves
kneads
kneels
knells
knifed
knifes
knight
knives
@ -4210,8 +4111,6 @@ lunges
lupine
lupins
luring
lurked
lurker
lusher
lushes
lushly
@ -4608,7 +4507,6 @@ muggle
mukluk
mulcts
mulish
mullah
mulled
mullet
mumble
@ -4721,9 +4619,6 @@ nickel
nicker
nickle
nieces
niggas
niggaz
nigger
niggle
nigher
nights
@ -4736,7 +4631,6 @@ ninjas
ninths
nipped
nipper
nipple
nitric
nitwit
nixing
@ -4781,15 +4675,6 @@ nozzle
nuance
nubbin
nubile
nuclei
nudest
nudged
nudges
nudism
nudist
nudity
nugget
nuking
numbed
number
numbly
@ -4804,7 +4689,6 @@ nutter
nuzzle
nybble
nylons
nympho
nymphs
oafish
oaring
@ -4885,7 +4769,6 @@ opting
option
opuses
oracle
orally
orange
orated
orates
@ -4897,7 +4780,6 @@ ordeal
orders
ordure
organs
orgasm
orgies
oriels
orient
@ -4993,10 +4875,6 @@ pander
panels
panics
panned
panted
pantie
pantos
pantry
papacy
papaya
papers
@ -5078,7 +4956,6 @@ pebble
pebbly
pecans
pecked
pecker
pectic
pectin
pedalo
@ -5151,9 +5028,6 @@ phenom
phials
phlegm
phloem
phobia
phobic
phoebe
phoned
phones
phoney
@ -5228,9 +5102,6 @@ piques
piracy
pirate
pirogi
pissed
pisser
pisses
pistes
pistil
pistol
@ -5311,8 +5182,6 @@ pogrom
points
pointy
poised
poises
poison
pokers
pokeys
pokier
@ -5422,7 +5291,6 @@ preyed
priced
prices
pricey
pricks
prided
prides
priers
@ -5602,14 +5470,9 @@ rabbit
rabble
rabies
raceme
racers
racial
racier
racily
racing
racism
racist
racked
racket
radars
radial
@ -5661,8 +5524,6 @@ rapers
rapids
rapier
rapine
raping
rapist
rapped
rappel
rapper
@ -5747,7 +5608,6 @@ recoup
rectal
rector
rectos
rectum
recurs
recuse
redact
@ -5891,7 +5751,6 @@ resume
retail
retain
retake
retard
retell
retest
retied
@ -6125,8 +5984,6 @@ sadden
sadder
saddle
sadhus
sadism
sadist
safari
safely
safest
@ -6364,16 +6221,6 @@ severs
sewage
sewers
sewing
sexier
sexily
sexing
sexism
sexist
sexpot
sextet
sexton
sexual
shabby
shacks
shaded
shades
@ -6383,10 +6230,7 @@ shaggy
shaken
shaker
shakes
shalom
shaman
shamed
shames
shandy
shanks
shanty
@ -6432,7 +6276,6 @@ shirks
shirrs
shirts
shirty
shitty
shiver
shoals
shoats
@ -6575,9 +6418,6 @@ slangy
slants
slated
slates
slaved
slaver
slaves
slayed
slayer
sleaze
@ -6672,7 +6512,6 @@ snarks
snarky
snarls
snarly
snatch
snazzy
sneaks
sneaky
@ -6716,7 +6555,6 @@ socket
sodded
sodden
sodium
sodomy
soever
soften
softer
@ -7468,7 +7306,6 @@ torrid
torsos
tortes
tossed
tosser
tosses
tossup
totals
@ -7686,7 +7523,6 @@ unhook
unhurt
unions
unique
unisex
unison
united
unites
@ -7793,7 +7629,6 @@ vacant
vacate
vacuum
vagary
vagina
vaguer
vainer
vainly
@ -7930,9 +7765,6 @@ votive
vowels
vowing
voyage
voyeur
vulgar
vulvae
wabbit
wacker
wackos
@ -7975,7 +7807,6 @@ wander
wangle
waning
wanked
wanker
wanner
wanted
wanton

View File

@ -1944,7 +1944,6 @@ dosage
dose
dotted
doubling
douche
dove
down
dowry
@ -3015,7 +3014,6 @@ groom
groove
grooving
groovy
grope
ground
grouped
grout
@ -3135,7 +3133,6 @@ happiness
happy
harbor
hardcopy
hardcore
hardcover
harddisk
hardened
@ -6553,7 +6550,6 @@ swimmer
swimming
swimsuit
swimwear
swinger
swinging
swipe
swirl
@ -7464,9 +7460,7 @@ villain
vindicate
vineyard
vintage
violate
violation
violator
violet
violin
viper

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,29 @@
"""empty message
Revision ID: 818b0a956205
Revises: 4bc54632d9aa
Create Date: 2024-02-01 10:43:46.253184
"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '818b0a956205'
down_revision = '4bc54632d9aa'
branch_labels = None
depends_on = None
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.add_column('alias', sa.Column('last_email_log_id', sa.Integer(), nullable=True))
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_column('alias', 'last_email_log_id')
# ### end Alembic commands ###

View File

@ -0,0 +1,44 @@
#!/usr/bin/env python3
import argparse
import time
from sqlalchemy import func
from app.models import Alias
from app.db import Session
parser = argparse.ArgumentParser(
prog="Backfill alias", description="Backfill alias las use"
)
parser.add_argument(
"-s", "--start_alias_id", default=0, type=int, help="Initial alias_id"
)
parser.add_argument("-e", "--end_alias_id", default=0, type=int, help="Last alias_id")
args = parser.parse_args()
alias_id_start = args.start_alias_id
max_alias_id = args.end_alias_id
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
el_query = "SELECT alias_id, MAX(id) from email_log where alias_id>=:start AND alias_id < :end GROUP BY alias_id"
alias_query = "UPDATE alias set last_email_log_id = :el_id where id = :alias_id"
updated = 0
start_time = time.time()
for batch_start in range(alias_id_start, max_alias_id, step):
rows = Session.execute(el_query, {"start": batch_start, "end": batch_start + step})
for row in rows:
Session.execute(alias_query, {"alias_id": row[0], "el_id": row[1]})
Session.commit()
updated += 1
elapsed = time.time() - start_time
time_per_alias = elapsed / (updated + 1)
last_batch_id = batch_start + step
remaining = max_alias_id - last_batch_id
time_remaining = (max_alias_id - last_batch_id) * time_per_alias
hours_remaining = time_remaining / 3600.0
print(
f"\rAlias {batch_start}/{max_alias_id} {updated} {hours_remaining:.2f}hrs remaining"
)
print("")

View File

@ -0,0 +1,53 @@
#!/usr/bin/env python3
import argparse
import time
from app import config
from app.email_utils import generate_reply_email
from app.email_validation import is_valid_email
from app.models import Alias
from app.db import Session
parser = argparse.ArgumentParser(
prog=f"Replace {config.NOREPLY}",
description=f"Replace {config.NOREPLY} from contacts reply email",
)
args = parser.parse_args()
el_query = "SELECT id, alias_id, website_email from contact where id>=:last_id AND reply_email=:reply_email ORDER BY id ASC LIMIT :step"
update_query = "UPDATE contact SET reply_email=:reply_email WHERE id=:contact_id "
updated = 0
start_time = time.time()
step = 100
last_id = 0
print(f"Replacing contacts with reply_email={config.NOREPLY}")
while True:
rows = Session.execute(
el_query, {"last_id": last_id, "reply_email": config.NOREPLY, "step": step}
)
loop_updated = 0
for row in rows:
contact_id = row[0]
alias_id = row[1]
last_id = contact_id
website_email = row[2]
contact_email_for_reply = website_email if is_valid_email(website_email) else ""
alias = Alias.get(alias_id)
if alias is None:
print(f"CANNOT find alias {alias_id} in database for contact {contact_id}")
reply_email = generate_reply_email(contact_email_for_reply, alias)
print(
f"Replacing contact {contact_id} with {website_email} reply_email for {reply_email}"
)
Session.execute(
update_query, {"contact_id": row[0], "reply_email": reply_email}
)
Session.commit()
updated += 1
loop_updated += 1
elapsed = time.time() - start_time
print(f"\rContact {last_id} done")
if loop_updated == 0:
break
print("")

View File

@ -20,6 +20,7 @@ exclude = '''
[tool.ruff]
ignore-init-module-imports = true
exclude = [".venv", "migrations"]
[tool.djlint]
indent = 2

View File

@ -228,6 +228,8 @@ def load_user(alternative_id):
sentry_sdk.set_user({"email": user.email, "id": user.id})
if user.disabled:
return None
if not user.is_active():
return None
return user

View File

@ -15,7 +15,7 @@
{{ otp_token_form.csrf_token }}
<input type="hidden" name="form-name" value="create" />
<div class="font-weight-bold mt-5">Token</div>
<div class="small-text mb-3">Please enter the 2FA code from your 2FA authenticator</div>
<div class="small-text mb-3">Please enter the 2FA code from your authenticator app</div>
{{ otp_token_form.token(class="form-control", autofocus="true") }}
{{ render_field_errors(otp_token_form.token) }}
<div class="form-check">

View File

@ -9,7 +9,7 @@
<h1 class="card-title">Create new account</h1>
<div class="form-group">
<label class="form-label">Email address</label>
{{ form.email(class="form-control", type="email", placeholder="YourName@protonmail.com") }}
{{ form.email(class="form-control", type="email", placeholder="username@proton.me") }}
<div class="small-text alert alert-info" style="margin-top: 1px">
Emails sent to your alias will be forwarded to this email address.
<br>

View File

@ -7,6 +7,7 @@
<div class="card-body p-6 text-center">
<h1 class="h4">An email to validate your email is on its way.</h1>
<p>Please check your inbox/spam folder.</p>
<p>Make sure to mark the message as not spam so that future messages come to your normal inbox</p>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,179 @@
{% extends "default.html" %}
{% set active_page = "setting" %}
{% block title %}Settings{% endblock %}
{% block head %}
<style>
.card-title {
font-size: 22px;
font-weight: 600;
margin-bottom: 3px;
}
.highlighted{
border: solid 2px #5675E2;
}
li {
margin-top: 8px;
}
</style>
{% endblock %}
{% block default_content %}
<div class="col pb-3">
<!-- Change email -->
<div class="card">
<div class="card-body">
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="form-name" value="update-email">
{{ change_email_form.csrf_token }}
<div class="card-title">Account Email</div>
<div class="mb-3">
This email address is used to log in to SimpleLogin.
<br />
If you want to change the mailbox that emails are forwarded to, use the
<a href="{{ url_for('dashboard.mailbox_route') }}">
<i class="fe fe-inbox"></i> Mailboxes page
</a>
instead.
</div>
<div class="form-group mt-2">
<!-- Not allow user to change email if there's a pending change -->
{{ change_email_form.email(class="form-control", value=current_user.email, readonly=pending_email != None) }}
{{ render_field_errors(change_email_form.email) }}
</div>
<button class="btn btn-outline-primary">Change Email</button>
</form>
{% if pending_email %}
<div class="mt-2">
<span class="text-danger float-left">Pending email change: {{ pending_email }}</span>
<form method="POST"
action="{{ url_for('dashboard.resend_email_change') }}"
class="float-left ml-2">
{{ change_email_form.csrf_token }}
<a onclick="this.closest('form').submit()"
class="btn btn-secondary btn-sm">Resend confirmation email</a>
</form>
<form method="POST"
action="{{ url_for('dashboard.cancel_email_change') }}"
class="float-left ml-2">
{{ change_email_form.csrf_token }}
<a onclick="this.closest('form').submit()"
class="btn btn-secondary btn-sm">Cancel email change</a>
</form>
</div>
{% endif %}
</div>
</div>
<!-- END Change email -->
<!-- Change password -->
<div class="card" id="change_password">
<div class="card-body">
<div class="card-title">Password</div>
<div class="mb-3">You will receive an email containing instructions on how to change your password.</div>
<form method="post">
{{ csrf_form.csrf_token }}
<input type="hidden" name="form-name" value="change-password">
<button class="btn btn-outline-primary">Change password</button>
</form>
</div>
</div>
<!-- END Change password -->
<!-- TOTP -->
<div class="card" id="totp">
<div class="card-body">
<div class="card-title">Two Factor Authentication</div>
<div class="mb-3">
Secure your account with 2FA, you'll be asked for a code generated through an app when you login.
<br />
</div>
{% if not current_user.enable_otp %}
<a href="{{ url_for('dashboard.mfa_setup') }}"
class="btn btn-outline-primary">Setup TOTP</a>
{% else %}
<a href="{{ url_for('dashboard.mfa_cancel') }}"
class="btn btn-outline-danger">Disable TOTP</a>
{% endif %}
</div>
</div>
<!-- END TOTP -->
<!-- WebAuthn -->
<div class="card">
<div class="card-body">
<div class="card-title">Security Key (WebAuthn)</div>
<div class="mb-3">
You can secure your account by linking either your FIDO-supported physical key such as Yubikey, Google
Titan,
or a device with appropriate hardware to your account.
</div>
{% if current_user.fido_uuid is none %}
<a href="{{ url_for('dashboard.fido_setup') }}"
class="btn btn-outline-primary">Setup WebAuthn</a>
{% else %}
<a href="{{ url_for('dashboard.fido_manage') }}"
class="btn btn-outline-info">Manage WebAuthn</a>
{% endif %}
</div>
</div>
<!-- END WebAuthn -->
<!-- Alias import/export -->
<div class="card">
<div class="card-body">
<div class="card-title">Alias import/export</div>
<div class="mb-3">
You can import your aliases created on other platforms into SimpleLogin.
You can also export your aliases to a readable csv format for a future batch import.
</div>
<a href="{{ url_for('dashboard.batch_import_route') }}"
class="btn btn-outline-primary">Batch Import</a>
<a href="{{ url_for('dashboard.alias_export_route') }}"
class="btn btn-outline-secondary">Export Aliases</a>
</div>
</div>
<!-- END Alias import/export -->
<!-- data export -->
<div class="card">
<div class="card-body">
<div class="card-title">SimpleLogin data export</div>
<div class="mb-3">
As per GDPR (General Data Protection Regulation) law, you can request a copy of your data which are stored on
SimpleLogin.
A zip file that contains all information will be sent to your SimpleLogin account address.
</div>
<div class="d-flex">
<div>
<form method="post">
{{ csrf_form.csrf_token }}
<input type="hidden" name="form-name" value="send-full-user-report">
<button class="btn btn-outline-info">Request your data</button>
</form>
</div>
</div>
</div>
</div>
<!-- END data export -->
<!-- Delete account -->
<div class="card">
<div class="card-body">
<div class="card-title">Account Deletion</div>
<div class="mb-3">If SimpleLogin isn't the right fit for you, you can simply delete your account.</div>
<a href="{{ url_for('dashboard.delete_account') }}"
class="btn btn-outline-danger">Delete account</a>
</div>
</div>
<!-- END Delete account -->
</div>
{% endblock %}
{% block script %}
<script>
let anchor = window.location.hash;
$(anchor).addClass("highlighted")
</script>
{% endblock %}

View File

@ -59,26 +59,29 @@
</div>
</div>
</div>
<div class="row mb-5">
<div class="col-12 col-lg-6 pt-1">
<form method="post">
<input type="hidden" name="form-name" value="create" />
{{ new_contact_form.csrf_token }}
{{ new_contact_form.email(class="form-control", placeholder="First Last <email@example.com>", autofocus=True) }}
{{ render_field_errors(new_contact_form.email) }}
<div class="small-text">Where do you want to send the email?</div>
{% if can_create_contacts %}
{% if can_create_contacts %}
<button class="btn btn-primary mt-2">Create reverse-alias</button>
{% else %}
<button disabled
title="Upgrade to premium to create reverse-aliases"
class="btn btn-primary mt-2">
Create reverse-alias
</button>
{% endif %}
</form>
</div>
<div class="row mb-5">
<div class="col-12 col-lg-6 pt-1">
<form method="post">
<input type="hidden" name="form-name" value="create" />
{{ new_contact_form.csrf_token }}
{{ new_contact_form.email(class="form-control", placeholder="First Last <email@example.com>", autofocus=True) }}
{{ render_field_errors(new_contact_form.email) }}
<div class="small-text">Where do you want to send the email?</div>
{% if can_create_contacts %}
<button class="btn btn-primary mt-2">Create reverse-alias</button>
{% else %}
<button disabled
title="Upgrade to premium to create reverse-aliases"
class="btn btn-primary mt-2">
Create reverse-alias
</button>
{% endif %}
</form>
</div>
{% endif %}
<div class="col-12 col-lg-6 pt-1">
<div class="float-right d-flex">
<form method="post">

View File

@ -17,7 +17,7 @@
<b>hello@{{ FIRST_ALIAS_DOMAIN }}</b>,
<b>me@{{ FIRST_ALIAS_DOMAIN }}</b>, etc.
<br />
If you add your own domain, this restriction is removed, and you can fully customize the alias.
If you add your own domain (or subdomain), this restriction is removed, and you can fully customize the alias.
<br />
</div>
</div>
@ -93,6 +93,7 @@
</div>
<div class="row">
<div class="col p-1">
{{ csrf_form.csrf_token }}
<button type="submit" id="create" class="btn btn-primary mt-1">Create</button>
</div>
</div>

View File

@ -12,7 +12,7 @@
<div class="card-body">
<h1 class="h3">Two Factor Authentication - TOTP</h1>
<p>
You will need to use a 2FA application like Google Authenticator or Authy on your phone or PC and scan the following QR Code:
You will need to use a 2FA application like Proton Pass or Aegis on your phone or PC and scan the following QR Code:
</p>
<canvas id="qr"></canvas>
<script>

View File

@ -10,7 +10,7 @@
<div>{{ notification.message | safe }}</div>
<form method="post"
class="float-right mt-3"
onsubmit="return confirm('This operation is not reversible, please confirm');">
onsubmit="return confirm('This operation is irreversible, please confirm');">
<button class="btn btn-outline-danger">Delete</button>
</form>
</div>

View File

@ -88,45 +88,6 @@
</div>
</div>
<!-- END Current plan -->
<!-- TOTP -->
<div class="card" id="totp">
<div class="card-body">
<div class="card-title">Two Factor Authentication</div>
<div class="mb-3">
Secure your account with 2FA, you'll be asked for a code generated through an app when you login.
<br />
</div>
{% if not current_user.enable_otp %}
<a href="{{ url_for('dashboard.mfa_setup') }}"
class="btn btn-outline-primary">Setup TOTP</a>
{% else %}
<a href="{{ url_for('dashboard.mfa_cancel') }}"
class="btn btn-outline-danger">Disable TOTP</a>
{% endif %}
</div>
</div>
<!-- END TOTP -->
<!-- WebAuthn -->
<div class="card">
<div class="card-body">
<div class="card-title">Security Key (WebAuthn)</div>
<div class="mb-3">
You can secure your account by linking either your FIDO-supported physical key such as Yubikey, Google
Titan,
or a device with appropriate hardware to your account.
</div>
{% if current_user.fido_uuid is none %}
<a href="{{ url_for('dashboard.fido_setup') }}"
class="btn btn-outline-primary">Setup WebAuthn</a>
{% else %}
<a href="{{ url_for('dashboard.fido_manage') }}"
class="btn btn-outline-info">Manage WebAuthn</a>
{% endif %}
</div>
</div>
<!-- END WebAuthn -->
<!-- Newsletter -->
<div class="card" id="notification">
<div class="card-body">
@ -179,52 +140,6 @@
</form>
</div>
<!-- END change name & profile picture -->
<!-- Change email -->
<div class="card">
<div class="card-body">
<form method="post" enctype="multipart/form-data">
<input type="hidden" name="form-name" value="update-email">
{{ change_email_form.csrf_token }}
<div class="card-title">Account Email</div>
<div class="mb-3">
This email address is used to log in to SimpleLogin.
<br />
If you want to change the mailbox that emails are forwarded to, use the
<a href="{{ url_for('dashboard.mailbox_route') }}">
<i class="fe fe-inbox"></i> Mailboxes page
</a>
instead.
</div>
<div class="form-group mt-2">
<!-- Not allow user to change email if there's a pending change -->
{{ change_email_form.email(class="form-control", value=current_user.email, readonly=pending_email != None) }}
{{ render_field_errors(change_email_form.email) }}
</div>
<button class="btn btn-outline-primary">Change Email</button>
</form>
{% if pending_email %}
<div class="mt-2">
<span class="text-danger float-left">Pending email change: {{ pending_email }}</span>
<form method="POST"
action="{{ url_for('dashboard.resend_email_change') }}"
class="float-left ml-2">
{{ change_email_form.csrf_token }}
<a onclick="this.closest('form').submit()"
class="btn btn-secondary btn-sm">Resend confirmation email</a>
</form>
<form method="POST"
action="{{ url_for('dashboard.cancel_email_change') }}"
class="float-left ml-2">
{{ change_email_form.csrf_token }}
<a onclick="this.closest('form').submit()"
class="btn btn-secondary btn-sm">Cancel email change</a>
</form>
</div>
{% endif %}
</div>
</div>
<!-- END Change email -->
<!-- Connect with Proton -->
{% if connect_with_proton %}
@ -265,32 +180,11 @@
</div>
{% endif %}
<!-- END Connect with Proton -->
<!-- Change password -->
<div class="card" id="change_password">
<div class="card-body">
<div class="card-title">Password</div>
<div class="mb-3">
You will receive an email containing instructions on how to change your password.
</div>
<form method="post">
{{ csrf_form.csrf_token }}
<input type="hidden" name="form-name" value="change-password">
<button class="btn btn-outline-primary">
Change password
</button>
</form>
</div>
</div>
<!-- END Change password -->
<!-- Random alias -->
<div id="random-alias" class="card">
<div class="card-body">
<div class="card-title">
Aliases
</div>
<div class="mt-3 mb-1">
Change the way random aliases are generated by default.
</div>
<div class="card-title">Aliases</div>
<div class="mt-3 mb-1">Change the way random aliases are generated by default.</div>
<form method="post" action="#random-alias" class="form-inline">
{{ csrf_form.csrf_token }}
<input type="hidden" name="form-name" value="change-alias-generator">
@ -306,13 +200,9 @@
on {{ AliasGeneratorEnum.uuid.name.upper() }}
</option>
</select>
<button class="btn btn-outline-primary">
Update
</button>
<button class="btn btn-outline-primary">Update</button>
</form>
<div class="mt-3 mb-1">
Select the default domain for aliases.
</div>
<div class="mt-3 mb-1">Select the default domain for aliases.</div>
<form method="post" action="#random-alias" class="form-inline">
{{ csrf_form.csrf_token }}
<input type="hidden"
@ -338,13 +228,9 @@
</option>
{% endfor %}
</select>
<button class="btn btn-outline-primary">
Update
</button>
<button class="btn btn-outline-primary">Update</button>
</form>
<div id="random-alias-suffix" class="mt-3 mb-1">
Select the default suffix generator for aliases.
</div>
<div id="random-alias-suffix" class="mt-3 mb-1">Select the default suffix generator for aliases.</div>
<form method="post" action="#random-alias-suffix" class="form-inline">
{{ csrf_form.csrf_token }}
<input type="hidden" name="form-name" value="random-alias-suffix">
@ -358,9 +244,7 @@
Random combination of {{ ALIAS_RAND_SUFFIX_LENGTH }} letter and digits
</option>
</select>
<button class="btn btn-outline-primary">
Update
</button>
<button class="btn btn-outline-primary">Update</button>
</form>
</div>
</div>
@ -368,9 +252,7 @@
<!-- Sender Format -->
<div class="card" id="sender-format">
<div class="card-body">
<div class="card-title">
Sender Address Format
</div>
<div class="card-title">Sender Address Format</div>
<div class="mt-1 mb-3">
When your alias receives an email, say from: <b>John Wick &lt;john@wick.com&gt;</b>,
SimpleLogin forwards it to your mailbox.
@ -403,9 +285,7 @@
No Name (i.e. only reverse-alias)
</option>
</select>
<button class="btn btn-outline-primary mt-3">
Update
</button>
<button class="btn btn-outline-primary mt-3">Update</button>
</form>
</div>
</div>
@ -415,9 +295,7 @@
<div class="card-body">
<div class="card-title">
Reverse Alias Replacement
<div class="badge badge-warning">
Experimental
</div>
<div class="badge badge-warning">Experimental</div>
</div>
<div class="mb-3">
When replying to a forwarded email, the <b>reverse-alias</b> can be automatically included
@ -434,13 +312,9 @@
name="replace-ra"
{% if current_user.replace_reverse_alias %} checked{% endif %}
class="form-check-input">
<label for="replace-ra">
Enable replacing reverse alias
</label>
<label for="replace-ra">Enable replacing reverse alias</label>
</div>
<button type="submit" class="btn btn-outline-primary">
Update
</button>
<button type="submit" class="btn btn-outline-primary">Update</button>
</form>
</div>
</div>
@ -709,62 +583,6 @@
</form>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="card-title">
Alias import/export
</div>
<div class="mb-3">
You can import your aliases created on other platforms into SimpleLogin.
You can also export your aliases to a readable csv format for a future batch import.
</div>
<a href="{{ url_for('dashboard.batch_import_route') }}"
class="btn btn-outline-primary">
Batch Import
</a>
<a href="{{ url_for('dashboard.alias_export_route') }}"
class="btn btn-outline-secondary">
Export Aliases
</a>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="card-title">
SimpleLogin data export
</div>
<div class="mb-3">
As per GDPR (General Data Protection Regulation) law, you can request a copy of your data which are stored on
SimpleLogin.
A zip file that contains all information will be sent to your SimpleLogin account address.
</div>
<div class="d-flex">
<div>
<form method="post">
{{ csrf_form.csrf_token }}
<input type="hidden" name="form-name" value="send-full-user-report">
<button class="btn btn-outline-info">
Request your data
</button>
</form>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-body">
<div class="card-title">
Account Deletion
</div>
<div class="mb-3">
If SimpleLogin isn't the right fit for you, you can simply delete your account.
</div>
<a href="{{ url_for('dashboard.delete_account') }}"
class="btn btn-outline-danger">
Delete account
</a>
</div>
</div>
</div>
{% endblock %}
{% block script %}

View File

@ -18,7 +18,7 @@
<br />
For generic questions, i.e. not related to your account, we recommend to post the question on
our
<a href="https://www.reddit.com/r/Simplelogin/">Reddit</a>
<a href="https://www.reddit.com/r/Simplelogin/">Reddit</a> or <a href="https://forum.simplelogin.io/">our official forum</a>
where our community can help answer the question
and other people with the same question can find the answer there.
</div>

View File

@ -1,17 +1,19 @@
{% extends "default.html" %}
{% set active_page = "dashboard" %}
{% block title %}Block an alias{% endblock %}
{% block title %}Deactivate an alias{% endblock %}
{% block default_content %}
<div class="card">
<div class="card-body">
<h1 class="h3">Block alias</h1>
<h1 class="h3">Deactivate alias</h1>
<p>
You are about to block the alias
You are about to deactivate the alias
<a href="mailto:{{ alias }}" target="_blank">{{ alias }}</a>
</p>
<p>After this, you will stop receiving all emails sent to this alias, please confirm.</p>
<p>
After this, you will stop receiving all emails sent to this alias, please confirm. You will always be able to re-activate it untill you will decide to delete it.
</p>
<form method="post">
<button class="btn btn-warning">Confirm</button>
</form>

View File

@ -43,9 +43,8 @@ Note, if you are a paying Proton Mail user, you automatically receive the premiu
{% endcall %}
{% call text() %}
For any question, feedback or feature request, please join our
<a href="https://github.com/simple-login/app/discussions">GitHub forum</a>
.
For any question or feedback, please join our <a href="https://forum.simplelogin.io/">official forum</a>.
If you want to request a feature, please submit it on our <a href="https://github.com/simple-login/app/discussions">GitHub repo</a>.
You can also join our
<a href="https://www.reddit.com/r/Simplelogin/">Reddit</a>
or follow our

View File

@ -13,7 +13,8 @@ SimpleLogin is also available on Android and iOS so you can manage your aliases
Note, if you are a paying Proton Mail user, you automatically receive the premium version of SimpleLogin.
For any question, feedback or feature request, please join our GitHub forum.
For any question or feedback, please join our official forum.
If you want to request a feature, please submit it on our GitHub repo.
You can also join our Reddit or follow our Twitter.
Best,
@ -26,7 +27,8 @@ Firefox: https://addons.mozilla.org/firefox/addon/simplelogin/
Edge: https://microsoftedge.microsoft.com/addons/detail/simpleloginreceive-sen/diacfpipniklenphgljfkmhinphjlfff
Android: https://play.google.com/store/apps/details?id=io.simplelogin.android
iOS: https://apps.apple.com/app/id1494359858
Github forum: https://github.com/simple-login/app/discussions
Github repo: https://github.com/simple-login/app/discussions
Official forum: https://forum.simplelogin.io/
Reddit: https://www.reddit.com/r/Simplelogin/
Twitter: https://twitter.com/simple_login

View File

@ -71,9 +71,10 @@ Please note that you can't create more than {{ MAX_NB_EMAIL_FREE_PLAN }} aliases
{% endif %}
{% call text() %}
For any question, feedback or feature request, please join our
<a href="https://github.com/simple-login/app/discussions">GitHub forum</a>
.
For any question or feedback,
please join our <a href="https://forum.simplelogin.io/">official forum</a>.
If you want to request a feature,
please submit it on our <a href="https://github.com/simple-login/app/discussions">GitHub repo</a>.
You can also join our
<a href="https://www.reddit.com/r/Simplelogin/">Reddit</a>
or follow our

View File

@ -26,6 +26,8 @@ No worries: all aliases you create during this period will continue to work norm
At any time, you can reach out to us by simply replying to this email.
For any question, feedback or feature request, please join our GitHub forum at https://github.com/simple-login/app/discussions
For any question or feedback, please join our official forum at https://forum.simplelogin.io/
If you want to request a feature, please submit it on our GitHub repo at https://github.com/simple-login/app/discussions
You can also join our Reddit at https://www.reddit.com/r/Simplelogin/ follow our Twitter at https://twitter.com/simplelogin

View File

@ -4,6 +4,7 @@
{{ render_text("Thank you for choosing SimpleLogin.") }}
{{ render_text("To get started, please confirm that <b>" + email + "</b> is your email address by clicking on the button below within 1 hour.") }}
{{ render_text("If it wasn't you, maybe someone entered your email by mistake. In this case you can ignore this mail.") }}
{{ render_button("Verify email", activation_link) }}
{{ render_text('Thanks,
<br />

View File

@ -4,4 +4,6 @@
Thank you for choosing SimpleLogin.
To get started, please confirm that {{email}} is your email address using this link {{activation_link}} within 1 hour.
If it wasn't you, maybe someone entered your email by mistake. In this case you can ignore this mail.
{% endblock %}

View File

@ -7,7 +7,7 @@
{% endcall %}
{% call text() %}
Your have tried to register multiple times to {{ service }}, and this is against the terms of service of SimpleLogin. Please don't do that anymore.
You have tried to register multiple times to {{ service }}, and this is against the terms of service of SimpleLogin. Please don't do that anymore.
{% endcall %}
{% call text() %}

View File

@ -9,7 +9,7 @@
<a href='https://simplelogin.io/' aria-label="SimpleLogin">
<img src="/static/logo-white.svg"
height="30px"
class="mb-3"
class="mt-3 mb-3"
alt="SimpleLogin logo">
</a>
<!-- End Logo -->
@ -17,8 +17,7 @@
SimpleLogin is an <a href="https://github.com/simple-login">open source</a> email alias solution to protect your email address.
</p>
<p class="small text-white">
SimpleLogin is the product of SimpleLogin SAS, registered in France under the SIREN number 884302134.
SimpleLogin SAS is part of <a href="https://proton.me">Proton AG</a>.
SimpleLogin is the product of <a href="https://proton.me">Proton AG</a>, registered in Switzerland under number CHE-354.686.492.
</p>
</div>
</div>
@ -38,12 +37,6 @@
alt="GitHub">
</a>
</li>
<li>
<a class="list-group-item text-white footer-item "
href="https://github.com/simple-login/app/blob/master/docs/api.md">
API Docs
</a>
</li>
<li>
<a class="list-group-item text-white footer-item "
href="https://status.simplelogin.io/">Status</a>
@ -61,18 +54,10 @@
<a class="list-group-item text-white footer-item"
href="https://simplelogin.io/blog/">Blog</a>
</li>
<li>
<a class="list-group-item text-white footer-item"
href="https://simplelogin.io/job/">Join Us</a>
</li>
<li>
<a class="list-group-item text-white footer-item"
href="https://simplelogin.io/about/">About Us</a>
</li>
<li>
<a class="list-group-item text-white footer-item"
href="https://github.com/simple-login/app/projects/1">Roadmap</a>
</li>
<li>
<a class="list-group-item text-white footer-item"
href="https://simplelogin.io/contact/">Contact Us</a>
@ -106,37 +91,9 @@
<a class="list-group-item text-white footer-item "
href="https://simplelogin.io/docs/">Documentation</a>
</li>
</ul>
</div>
<div class="col-sm-4 col-lg-2 mb-4">
<h3 class="h4 text-white">Comparisons</h3>
<ul class="list-group list-group-transparent list-group-white list-group-flush list-group-borderless mb-0 footer-list-group">
<li>
<a class="list-group-item text-white footer-item"
href="https://simplelogin.io/blog/email-alias-vs-plus-sign/">
vs Plus Sign (+) Trick
</a>
</li>
<li>
<a class="list-group-item text-white footer-item"
href="https://simplelogin.io/blog/vs-firefox-relay/">
vs
Firefox Relay
</a>
</li>
<li>
<a class="list-group-item text-white footer-item"
href="https://simplelogin.io/blog/vs-burner-mail/">
vs
Burner Mail
</a>
</li>
<li>
<a class="list-group-item text-white footer-item"
href="https://simplelogin.io/blog/alternative-33mail/">
vs
33mail
</a>
<a class="list-group-item text-white footer-item "
href="https://forum.simplelogin.io">Forum</a>
</li>
</ul>
</div>

View File

@ -83,7 +83,15 @@
</a>
</div>
<div class="dropdown-item">
<a href="https://github.com/simple-login/app/discussions"
<a href="https://github.com/simple-login/app/"
target="_blank"
rel="noopener noreferrer">
Github repo
<i class="fa fa-external-link" aria-hidden="true"></i>
</a>
</div>
<div class="dropdown-item">
<a href="https://forum.simplelogin.io"
target="_blank"
rel="noopener noreferrer">
Forum
@ -140,6 +148,10 @@
<a class="dropdown-item mb-3" href="{{ url_for('dashboard.api_key') }}">
<i class="dropdown-icon fa fa-key"></i> API Keys
</a>
<a class="dropdown-item mb-3"
href="{{ url_for('dashboard.account_setting') }}">
<i class="dropdown-icon fa fa-user"></i> Account settings
</a>
<a class="dropdown-item" href="{{ url_for('auth.logout') }}">
<i class="dropdown-icon fe fe-log-out"></i> Sign out
</a>

View File

@ -106,7 +106,7 @@
</a>
</div>
<div class="dropdown-item">
<a href="https://github.com/simple-login/app/discussions"
<a href="https://forum.simplelogin.io/"
target="_blank"
rel="noopener noreferrer">
Forum

View File

@ -40,14 +40,16 @@ def test_get_notifications(flask_client):
def test_mark_notification_as_read(flask_client):
user, api_key = get_new_user_and_api_key()
Notification.create(id=1, user_id=user.id, message="Test message 1")
notif_id = Notification.create(
user_id=user.id, message="Test message 1", flush=True
).id
Session.commit()
r = flask_client.post(
url_for("api.mark_as_read", notification_id=1),
url_for("api.mark_as_read", notification_id=notif_id),
headers={"Authentication": api_key.code},
)
assert r.status_code == 200
notification = Notification.first()
notification = Notification.filter_by(id=notif_id).first()
assert notification.read

View File

@ -1,8 +1,8 @@
from app.api.serializer import get_alias_infos_with_pagination_v3
from app.config import PAGE_LIMIT
from app.db import Session
from app.models import Alias, Mailbox, Contact
from tests.utils import create_new_user
from app.models import Alias, Mailbox, Contact, EmailLog
from tests.utils import create_new_user, random_email
def test_get_alias_infos_with_pagination_v3(flask_client):
@ -155,3 +155,46 @@ def test_get_alias_infos_pinned_alias(flask_client):
# pinned alias isn't included in the search
alias_infos = get_alias_infos_with_pagination_v3(user, query="no match")
assert len(alias_infos) == 0
def test_get_alias_infos_with_no_last_email_log(flask_client):
user = create_new_user()
alias_infos = get_alias_infos_with_pagination_v3(user)
assert len(alias_infos) == 1
row = alias_infos[0]
assert row.alias.id == user.newsletter_alias_id
assert row.latest_contact is None
assert row.latest_email_log is None
def test_get_alias_infos_with_email_log_no_contact():
user = create_new_user()
contact = Contact.create(
user_id=user.id,
alias_id=user.newsletter_alias_id,
website_email="a@a.com",
reply_email=random_email(),
flush=True,
)
Contact.create(
user_id=user.id,
alias_id=user.newsletter_alias_id,
website_email="unused@a.com",
reply_email=random_email(),
flush=True,
)
EmailLog.create(
user_id=user.id,
alias_id=user.newsletter_alias_id,
contact_id=contact.id,
commit=True,
)
alias_infos = get_alias_infos_with_pagination_v3(user)
assert len(alias_infos) == 1
row = alias_infos[0]
assert row.alias.id == user.newsletter_alias_id
assert row.latest_contact is not None
assert row.latest_contact.id == contact.id
assert row.latest_email_log is not None
alias = Alias.get(id=user.newsletter_alias_id)
assert row.latest_email_log.id == alias.last_email_log_id

View File

@ -1,6 +1,7 @@
from flask import url_for
from app import config
from app.db import Session
from app.models import User, PartnerUser
from app.proton.utils import get_proton_partner
from tests.api.utils import get_new_user_and_api_key
@ -23,6 +24,7 @@ def test_user_in_trial(flask_client):
"profile_picture_url": None,
"max_alias_free_plan": config.MAX_NB_EMAIL_FREE_PLAN,
"connected_proton_address": None,
"can_create_reverse_alias": True,
}
@ -52,9 +54,24 @@ def test_user_linked_to_proton(flask_client):
"profile_picture_url": None,
"max_alias_free_plan": config.MAX_NB_EMAIL_FREE_PLAN,
"connected_proton_address": partner_email,
"can_create_reverse_alias": user.can_create_contacts(),
}
def test_cannot_create_reverse_alias(flask_client):
user, api_key = get_new_user_and_api_key()
user.trial_end = None
Session.flush()
config.DISABLE_CREATE_CONTACTS_FOR_FREE_USERS = True
r = flask_client.get(
url_for("api.user_info"), headers={"Authentication": api_key.code}
)
assert r.status_code == 200
assert not r.json["can_create_reverse_alias"]
def test_wrong_api_key(flask_client):
r = flask_client.get(
url_for("api.user_info"), headers={"Authentication": "Invalid code"}

View File

@ -6,16 +6,27 @@ from tests.utils import create_new_user
def test_unactivated_user_login(flask_client):
user = create_new_user()
user.activated = False
Session.commit()
"""
Test function for logging in with an unactivated user.
Steps:
1. Creates a new user.
2. Sets the user's activated status to False.
3. Sends a POST request to the login route with user credentials.
4. Checks the response status code and content for expected messages.
"""
user = create_new_user() # Creating a new user
user.activated = False # Setting the user's activated status to False
Session.commit() # Committing the session changes
# Sending a POST request to the login route with user credentials and following redirects
r = flask_client.post(
url_for("auth.login"),
data={"email": user.email, "password": "password"},
follow_redirects=True,
)
# Asserting the response status code and content for expected messages
assert r.status_code == 200
assert (
b"Please check your inbox for the activation email. You can also have this email re-sent"
@ -24,59 +35,98 @@ def test_unactivated_user_login(flask_client):
def test_non_canonical_login(flask_client):
email = f"pre.{random_string(10)}@gmail.com"
name = f"NAME-{random_string(10)}"
user = create_new_user(email, name)
Session.commit()
"""
Test function for logging in with a non-canonical email.
Steps:
1. Creates a new user with a non-canonical email.
2. Sends a POST request to the login route with user credentials.
3. Checks the response status code and content for expected messages.
4. Checks the canonicalization of the email.
5. Logs out the user.
6. Sends a POST request to the login route with the canonicalized email.
7. Checks the response status code and content for expected messages.
"""
email = f"pre.{random_string(10)}@gmail.com" # Generating a non-canonical email
name = f"NAME-{random_string(10)}" # Generating a random name
user = create_new_user(
email, name
) # Creating a new user with the generated email and name
Session.commit() # Committing the session changes
# Sending a POST request to the login route with user credentials and following redirects
r = flask_client.post(
url_for("auth.login"),
data={"email": user.email, "password": "password"},
follow_redirects=True,
)
# Asserting the response status code and content for expected messages
assert r.status_code == 200
assert name.encode("utf-8") in r.data
# Canonicalizing the email
canonical_email = canonicalize_email(email)
assert canonical_email != email
assert (
canonical_email != email
) # Checking if the canonical email is different from the original email
flask_client.get(url_for("auth.logout"))
flask_client.get(url_for("auth.logout")) # Logging out the user
# Sending a POST request to the login route with the canonicalized email and following redirects
r = flask_client.post(
url_for("auth.login"),
data={"email": canonical_email, "password": "password"},
follow_redirects=True,
)
# Asserting the response status code and content for expected messages
assert r.status_code == 200
assert name.encode("utf-8") not in r.data
def test_canonical_login_with_non_canonical_email(flask_client):
suffix = f"{random_string(10)}@gmail.com"
canonical_email = f"pre{suffix}"
non_canonical_email = f"pre.{suffix}"
name = f"NAME-{random_string(10)}"
create_new_user(canonical_email, name)
Session.commit()
"""
Test function for logging in with a canonical email and a non-canonical email.
Steps:
1. Generates canonical and non-canonical email addresses.
2. Creates a new user with the canonical email.
3. Sends a POST request to the login route with the non-canonical email.
4. Checks the response status code and content for expected messages.
5. Logs out the user.
6. Sends a POST request to the login route with the canonical email.
7. Checks the response status code and content for expected messages.
"""
suffix = f"{random_string(10)}@gmail.com" # Generating a random suffix for emails
canonical_email = f"pre{suffix}" # Generating a canonical email
non_canonical_email = f"pre.{suffix}" # Generating a non-canonical email
name = f"NAME-{random_string(10)}" # Generating a random name
create_new_user(
canonical_email, name
) # Creating a new user with the canonical email
Session.commit() # Committing the session changes
# Sending a POST request to the login route with the non-canonical email and following redirects
r = flask_client.post(
url_for("auth.login"),
data={"email": non_canonical_email, "password": "password"},
follow_redirects=True,
)
# Asserting the response status code and content for expected messages
assert r.status_code == 200
assert name.encode("utf-8") in r.data
flask_client.get(url_for("auth.logout"))
flask_client.get(url_for("auth.logout")) # Logging out the user
# Sending a POST request to the login route with the canonical email and following redirects
r = flask_client.post(
url_for("auth.login"),
data={"email": canonical_email, "password": "password"},
follow_redirects=True,
)
# Asserting the response status code and content for expected messages
assert r.status_code == 200
assert name.encode("utf-8") in r.data

View File

@ -13,7 +13,7 @@ def test_setup_done(flask_client):
noncanonical_email = f"nonca.{random_email()}"
r = flask_client.post(
url_for("dashboard.setting"),
url_for("dashboard.account_setting"),
data={
"form-name": "update-email",
"email": noncanonical_email,

View File

@ -0,0 +1,28 @@
from flask import url_for
from app import config
from app.models import EmailChange
from app.utils import canonicalize_email
from tests.utils import login, random_email, create_new_user
def test_setup_done(flask_client):
config.SKIP_MX_LOOKUP_ON_CHECK = True
user = create_new_user()
login(flask_client, user)
noncanonical_email = f"nonca.{random_email()}"
r = flask_client.post(
url_for("dashboard.account_setting"),
data={
"form-name": "update-email",
"email": noncanonical_email,
},
follow_redirects=True,
)
assert r.status_code == 200
email_change = EmailChange.get_by(user_id=user.id)
assert email_change is not None
assert email_change.new_email == canonicalize_email(noncanonical_email)
config.SKIP_MX_LOOKUP_ON_CHECK = False

View File

@ -13,8 +13,7 @@ from app.handler.unsubscribe_encoder import (
)
from app.handler.unsubscribe_generator import UnsubscribeGenerator
from app.models import Alias, Contact, UnsubscribeBehaviourEnum
from tests.utils import create_new_user
from tests.utils import create_new_user, random_email
TEST_UNSUB_EMAIL = "unsub@sl.com"
@ -204,3 +203,23 @@ def test_unsub_preserve_original(
assert message[headers.LIST_UNSUBSCRIBE_POST] is None
else:
assert "List-Unsubscribe=One-Click" == message[headers.LIST_UNSUBSCRIBE_POST]
def test_unsub_preserves_sl_unsubscriber():
user = create_new_user()
user.unsub_behaviour = UnsubscribeBehaviourEnum.PreserveOriginal
alias = Alias.create_new_random(user)
Session.commit()
config.UNSUBSCRIBER = random_email()
contact = Contact.create(
user_id=user.id,
alias_id=alias.id,
website_email="contact@example.com",
reply_email="rep@sl.local",
commit=True,
)
message = Message()
original_header = f"<mailto:{config.UNSUBSCRIBER}?subject=dummysubject>"
message[headers.LIST_UNSUBSCRIBE] = original_header
message = UnsubscribeGenerator().add_header_to_message(alias, contact, message)
assert original_header == message[headers.LIST_UNSUBSCRIBE]

View File

@ -0,0 +1,17 @@
from app.db import Session
from app.models import Alias, Mailbox, AliasMailbox
from tests.utils import create_new_user, random_email
def test_duplicated_mailbox_is_returned_only_once():
user = create_new_user()
other_mailbox = Mailbox.create(user_id=user.id, email=random_email(), verified=True)
alias = Alias.create_new_random(user)
AliasMailbox.create(mailbox_id=other_mailbox.id, alias_id=alias.id)
AliasMailbox.create(mailbox_id=user.default_mailbox_id, alias_id=alias.id)
Session.flush()
alias_mailboxes = alias.mailboxes
assert len(alias_mailboxes) == 2
alias_mailbox_id = [mailbox.id for mailbox in alias_mailboxes]
assert user.default_mailbox_id in alias_mailbox_id
assert other_mailbox.id in alias_mailbox_id

View File

@ -39,15 +39,17 @@ def test_cleanup_tokens(flask_client):
def test_cleanup_users():
u_delete_none_id = create_new_user().id
u_delete_after = create_new_user()
u_delete_after_id = u_delete_after.id
u_delete_before = create_new_user()
u_delete_before_id = u_delete_before.id
u_delete_grace_has_expired = create_new_user()
u_delete_grace_has_expired_id = u_delete_grace_has_expired.id
u_delete_grace_has_not_expired = create_new_user()
u_delete_grace_has_not_expired_id = u_delete_grace_has_not_expired.id
now = arrow.now()
u_delete_after.delete_on = now.shift(minutes=1)
u_delete_before.delete_on = now.shift(minutes=-1)
u_delete_grace_has_expired.delete_on = now.shift(days=-(cron.DELETE_GRACE_DAYS + 1))
u_delete_grace_has_not_expired.delete_on = now.shift(
days=-(cron.DELETE_GRACE_DAYS - 1)
)
Session.flush()
cron.clear_users_scheduled_to_be_deleted()
assert User.get(u_delete_none_id) is not None
assert User.get(u_delete_after_id) is not None
assert User.get(u_delete_before_id) is None
assert User.get(u_delete_grace_has_not_expired_id) is not None
assert User.get(u_delete_grace_has_expired_id) is None

View File

@ -49,10 +49,26 @@ from app.models import (
VerpType,
AliasGeneratorEnum,
SLDomain,
Mailbox,
)
# flake8: noqa: E101, W191
from tests.utils import login, load_eml_file, create_new_user, random_domain
from tests.utils import (
login,
load_eml_file,
create_new_user,
random_email,
random_domain,
random_token,
)
def setup_module(module):
config.SKIP_MX_LOOKUP_ON_CHECK = True
def teardown_module(module):
config.SKIP_MX_LOOKUP_ON_CHECK = False
def test_get_email_domain_part():
@ -68,10 +84,6 @@ def test_email_belongs_to_alias_domains():
assert not can_create_directory_for_address("hey@d3.test")
@pytest.mark.skipif(
"GITHUB_ACTIONS_TEST" in os.environ,
reason="this test requires DNS lookup that does not work on Github CI",
)
def test_can_be_used_as_personal_email(flask_client):
# default alias domain
assert not email_can_be_used_as_mailbox("ab@sl.local")
@ -94,6 +106,27 @@ def test_can_be_used_as_personal_email(flask_client):
assert email_can_be_used_as_mailbox("abcd@gmail.com")
def test_disabled_user_prevents_email_from_being_used_as_mailbox():
email = f"user_{random_token(10)}@mailbox.test"
assert email_can_be_used_as_mailbox(email)
user = create_new_user(email)
user.disabled = True
Session.flush()
assert not email_can_be_used_as_mailbox(email)
def test_disabled_user_with_secondary_mailbox_prevents_email_from_being_used_as_mailbox():
email = f"user_{random_token(10)}@mailbox.test"
assert email_can_be_used_as_mailbox(email)
user = create_new_user()
Mailbox.create(user_id=user.id, email=email)
Session.flush()
assert email_can_be_used_as_mailbox(email)
user.disabled = True
Session.flush()
assert not email_can_be_used_as_mailbox(email)
def test_delete_header():
msg = EmailMessage()
assert msg._headers == []
@ -154,13 +187,14 @@ def test_parse_full_address():
def test_send_email_with_rate_control(flask_client):
user = create_new_user()
email = random_email()
for _ in range(MAX_ALERT_24H):
assert send_email_with_rate_control(
user, "test alert type", "abcd@gmail.com", "subject", "plaintext"
user, "test alert type", email, "subject", "plaintext"
)
assert not send_email_with_rate_control(
user, "test alert type", "abcd@gmail.com", "subject", "plaintext"
user, "test alert type", email, "subject", "plaintext"
)