Compare commits

...

13 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
46 changed files with 1352 additions and 619 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

@ -510,7 +510,8 @@ server {
server_name app.mydomain.com; server_name app.mydomain.com;
location / { location / {
proxy_pass http://localhost:7777; proxy_pass http://localhost:7777;
proxy_set_header Host $host;
} }
} }
``` ```

View File

@ -168,6 +168,8 @@ class NewUserStrategy(ClientMergeStrategy):
class ExistingUnlinkedUserStrategy(ClientMergeStrategy): class ExistingUnlinkedUserStrategy(ClientMergeStrategy):
def process(self) -> LinkResult: 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( partner_user = ensure_partner_user_exists_for_user(
self.link_request, self.user, self.partner self.link_request, self.user, self.partner
) )
@ -246,6 +248,8 @@ def link_user(
) -> LinkResult: ) -> LinkResult:
# Sanitize email just in case # Sanitize email just in case
link_request.email = sanitize_email(link_request.email) 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( partner_user = ensure_partner_user_exists_for_user(
link_request, current_user, partner link_request, current_user, partner
) )

View File

@ -214,6 +214,20 @@ class UserAdmin(SLModelView):
Session.commit() 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( @action(
"disable_otp_fido", "disable_otp_fido",
"Disable OTP & FIDO", "Disable OTP & FIDO",

View File

@ -33,6 +33,9 @@ def authorize_request() -> Optional[Tuple[str, int]]:
if g.user.disabled: if g.user.disabled:
return jsonify(error="Disabled account"), 403 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 g.api_key = api_key
return None 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(Alias.pinned.desc())
q = q.order_by(latest_activity.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 = [] 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( ret.append(
AliasInfo( AliasInfo(
alias=alias, alias=alias,
@ -358,7 +358,6 @@ def construct_alias_query(user: User):
else_=0, else_=0,
) )
).label("nb_forward"), ).label("nb_forward"),
func.max(EmailLog.created_at).label("latest_email_log_created_at"),
) )
.join(EmailLog, Alias.id == EmailLog.alias_id, isouter=True) .join(EmailLog, Alias.id == EmailLog.alias_id, isouter=True)
.filter(Alias.user_id == user.id) .filter(Alias.user_id == user.id)
@ -366,14 +365,6 @@ def construct_alias_query(user: User):
.subquery() .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 ( return (
Session.query( Session.query(
Alias, Alias,
@ -385,23 +376,7 @@ def construct_alias_query(user: User):
) )
.options(joinedload(Alias.hibp_breaches)) .options(joinedload(Alias.hibp_breaches))
.options(joinedload(Alias.custom_domain)) .options(joinedload(Alias.custom_domain))
.join(Contact, Alias.id == Contact.alias_id, isouter=True) .join(EmailLog, Alias.last_email_log_id == EmailLog.id, isouter=True)
.join(EmailLog, Contact.id == EmailLog.contact_id, isouter=True) .join(Contact, EmailLog.contact_id == Contact.id, isouter=True)
.filter(Alias.id == alias_activity_subquery.c.id) .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

@ -17,9 +17,14 @@ from app.models import PlanEnum, AppleSubscription
_MONTHLY_PRODUCT_ID = "io.simplelogin.ios_app.subscription.premium.monthly" _MONTHLY_PRODUCT_ID = "io.simplelogin.ios_app.subscription.premium.monthly"
_YEARLY_PRODUCT_ID = "io.simplelogin.ios_app.subscription.premium.yearly" _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_MONTHLY_PRODUCT_ID = "io.simplelogin.macapp.subscription.premium.monthly"
_MACAPP_YEARLY_PRODUCT_ID = "io.simplelogin.macapp.subscription.premium.yearly" _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 # Apple API URL
_SANDBOX_URL = "https://sandbox.itunes.apple.com/verifyReceipt" _SANDBOX_URL = "https://sandbox.itunes.apple.com/verifyReceipt"
_PROD_URL = "https://buy.itunes.apple.com/verifyReceipt" _PROD_URL = "https://buy.itunes.apple.com/verifyReceipt"
@ -263,7 +268,11 @@ def apple_update_notification():
plan = ( plan = (
PlanEnum.monthly PlanEnum.monthly
if transaction["product_id"] 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 else PlanEnum.yearly
) )
@ -517,7 +526,11 @@ def verify_receipt(receipt_data, user, password) -> Optional[AppleSubscription]:
plan = ( plan = (
PlanEnum.monthly PlanEnum.monthly
if latest_transaction["product_id"] 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 else PlanEnum.yearly
) )

View File

@ -11,7 +11,7 @@ from itsdangerous import Signer
from app import email_utils from app import email_utils
from app.api.base import api_bp from app.api.base import api_bp
from app.config import FLASK_SECRET, DISABLE_REGISTRATION 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.db import Session
from app.email_utils import ( from app.email_utils import (
email_can_be_used_as_mailbox, email_can_be_used_as_mailbox,

View File

@ -3,6 +3,7 @@ from flask_login import login_user
from app.auth.base import auth_bp from app.auth.base import auth_bp
from app.db import Session from app.db import Session
from app.log import LOG
from app.models import EmailChange, ResetPasswordCode from app.models import EmailChange, ResetPasswordCode
@ -22,12 +23,14 @@ def change_email():
return render_template("auth/change_email.html") return render_template("auth/change_email.html")
user = email_change.user user = email_change.user
old_email = user.email
user.email = email_change.new_email user.email = email_change.new_email
EmailChange.delete(email_change.id) EmailChange.delete(email_change.id)
ResetPasswordCode.filter_by(user_id=user.id).delete() ResetPasswordCode.filter_by(user_id=user.id).delete()
Session.commit() 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") flash("Your new email has been updated", "success")
login_user(user) login_user(user)

View File

@ -3,7 +3,7 @@ from flask_wtf import FlaskForm
from wtforms import StringField, validators from wtforms import StringField, validators
from app.auth.base import auth_bp 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.extensions import limiter
from app.log import LOG from app.log import LOG
from app.models import User 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.db import Session
from app.log import LOG from app.log import LOG
from app.models import User, File, SocialAuth 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 from .login_utils import after_login
_authorization_base_url = "https://accounts.google.com/o/oauth2/v2/auth" _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 # to avoid flask-login displaying the login error message
session.pop("_flashes", None) 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 # Google does not allow to append param to redirect_url
# we need to pass the next url by session # we need to pass the next url by session

View File

@ -421,6 +421,8 @@ try:
except Exception: except Exception:
HIBP_SCAN_INTERVAL_DAYS = 7 HIBP_SCAN_INTERVAL_DAYS = 7
HIBP_API_KEYS = sl_getenv("HIBP_API_KEYS", list) or [] HIBP_API_KEYS = sl_getenv("HIBP_API_KEYS", list) or []
HIBP_MAX_ALIAS_CHECK = 10_000
HIBP_RPM = 100
POSTMASTER = os.environ.get("POSTMASTER") POSTMASTER = os.environ.get("POSTMASTER")
@ -492,6 +494,31 @@ NAMESERVERS = setup_nameservers()
DISABLE_CREATE_CONTACTS_FOR_FREE_USERS = os.environ.get( DISABLE_CREATE_CONTACTS_FOR_FREE_USERS = os.environ.get(
"DISABLE_CREATE_CONTACTS_FOR_FREE_USERS", False "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 ( PARTNER_API_TOKEN_SECRET = os.environ.get("PARTNER_API_TOKEN_SECRET") or (
FLASK_SECRET + "partnerapitoken" FLASK_SECRET + "partnerapitoken"
) )
@ -542,3 +569,5 @@ MAX_API_KEYS = int(os.environ.get("MAX_API_KEYS", 30))
UPCLOUD_USERNAME = os.environ.get("UPCLOUD_USERNAME", None) UPCLOUD_USERNAME = os.environ.get("UPCLOUD_USERNAME", None)
UPCLOUD_PASSWORD = os.environ.get("UPCLOUD_PASSWORD", None) UPCLOUD_PASSWORD = os.environ.get("UPCLOUD_PASSWORD", None)
UPCLOUD_DB_ID = os.environ.get("UPCLOUD_DB_ID", 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, delete_account,
notification, notification,
support, support,
account_setting,
) )
__all__ = [ __all__ = [
@ -68,4 +69,5 @@ __all__ = [
"delete_account", "delete_account",
"notification", "notification",
"support", "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

@ -1,9 +1,11 @@
from app.dashboard.base import dashboard_bp from app.dashboard.base import dashboard_bp
from flask_login import login_required, current_user from flask_login import login_required, current_user
from app.alias_utils import alias_export_csv from app.alias_utils import alias_export_csv
from app.dashboard.views.enter_sudo import sudo_required
@dashboard_bp.route("/alias_export", methods=["GET"]) @dashboard_bp.route("/alias_export", methods=["GET"])
@login_required @login_required
@sudo_required
def alias_export_route(): def alias_export_route():
return alias_export_csv(current_user) 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 import s3
from app.config import JOB_BATCH_IMPORT from app.config import JOB_BATCH_IMPORT
from app.dashboard.base import dashboard_bp from app.dashboard.base import dashboard_bp
from app.dashboard.views.enter_sudo import sudo_required
from app.db import Session from app.db import Session
from app.log import LOG from app.log import LOG
from app.models import File, BatchImport, Job 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"]) @dashboard_bp.route("/batch_import", methods=["GET", "POST"])
@login_required @login_required
@sudo_required
def batch_import_route(): def batch_import_route():
# only for users who have custom domains # only for users who have custom domains
if not current_user.verified_custom_domains(): if not current_user.verified_custom_domains():

View File

@ -13,34 +13,24 @@ from flask_login import login_required, current_user
from flask_wtf import FlaskForm from flask_wtf import FlaskForm
from flask_wtf.file import FileField from flask_wtf.file import FileField
from wtforms import StringField, validators 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 ( from app.config import (
URL,
FIRST_ALIAS_DOMAIN, FIRST_ALIAS_DOMAIN,
ALIAS_RANDOM_SUFFIX_LENGTH, ALIAS_RANDOM_SUFFIX_LENGTH,
CONNECT_WITH_PROTON, CONNECT_WITH_PROTON,
) )
from app.dashboard.base import dashboard_bp from app.dashboard.base import dashboard_bp
from app.db import Session from app.db import Session
from app.email_utils import (
email_can_be_used_as_mailbox,
personal_email_already_used,
)
from app.errors import ProtonPartnerNotSetUp from app.errors import ProtonPartnerNotSetUp
from app.extensions import limiter from app.extensions import limiter
from app.image_validation import detect_image_format, ImageFormat 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.log import LOG
from app.models import ( from app.models import (
BlockBehaviourEnum, BlockBehaviourEnum,
PlanEnum, PlanEnum,
File, File,
ResetPasswordCode,
EmailChange, EmailChange,
User,
Alias,
CustomDomain, CustomDomain,
AliasGeneratorEnum, AliasGeneratorEnum,
AliasSuffixEnum, AliasSuffixEnum,
@ -53,11 +43,10 @@ from app.models import (
PartnerSubscription, PartnerSubscription,
UnsubscribeBehaviourEnum, 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 ( from app.utils import (
random_string, random_string,
CSRFValidationForm, CSRFValidationForm,
canonicalize_email,
) )
@ -66,12 +55,6 @@ class SettingForm(FlaskForm):
profile_picture = FileField("Profile Picture") profile_picture = FileField("Profile Picture")
class ChangeEmailForm(FlaskForm):
email = EmailField(
"email", validators=[validators.DataRequired(), validators.Email()]
)
class PromoCodeForm(FlaskForm): class PromoCodeForm(FlaskForm):
code = StringField("Name", validators=[validators.DataRequired()]) code = StringField("Name", validators=[validators.DataRequired()])
@ -109,7 +92,6 @@ def get_partner_subscription_and_name(
def setting(): def setting():
form = SettingForm() form = SettingForm()
promo_form = PromoCodeForm() promo_form = PromoCodeForm()
change_email_form = ChangeEmailForm()
csrf_form = CSRFValidationForm() csrf_form = CSRFValidationForm()
email_change = EmailChange.get_by(user_id=current_user.id) email_change = EmailChange.get_by(user_id=current_user.id)
@ -122,63 +104,7 @@ def setting():
if not csrf_form.validate(): if not csrf_form.validate():
flash("Invalid request", "warning") flash("Invalid request", "warning")
return redirect(url_for("dashboard.setting")) 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 request.form.get("form-name") == "update-profile":
if form.validate(): if form.validate():
profile_updated = False profile_updated = False
@ -222,15 +148,6 @@ def setting():
if profile_updated: if profile_updated:
flash("Your profile has been updated", "success") flash("Your profile has been updated", "success")
return redirect(url_for("dashboard.setting")) 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": elif request.form.get("form-name") == "notification-preference":
choose = request.form.get("notification") choose = request.form.get("notification")
if choose == "on": if choose == "on":
@ -240,7 +157,6 @@ def setting():
Session.commit() Session.commit()
flash("Your notification preference has been updated", "success") flash("Your notification preference has been updated", "success")
return redirect(url_for("dashboard.setting")) return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "change-alias-generator": elif request.form.get("form-name") == "change-alias-generator":
scheme = int(request.form.get("alias-generator-scheme")) scheme = int(request.form.get("alias-generator-scheme"))
if AliasGeneratorEnum.has_value(scheme): if AliasGeneratorEnum.has_value(scheme):
@ -248,7 +164,6 @@ def setting():
Session.commit() Session.commit()
flash("Your preference has been updated", "success") flash("Your preference has been updated", "success")
return redirect(url_for("dashboard.setting")) return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "change-random-alias-default-domain": elif request.form.get("form-name") == "change-random-alias-default-domain":
default_domain = request.form.get("random-alias-default-domain") default_domain = request.form.get("random-alias-default-domain")
@ -287,7 +202,6 @@ def setting():
Session.commit() Session.commit()
flash("Your preference has been updated", "success") flash("Your preference has been updated", "success")
return redirect(url_for("dashboard.setting")) return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "random-alias-suffix": elif request.form.get("form-name") == "random-alias-suffix":
scheme = int(request.form.get("random-alias-suffix-generator")) scheme = int(request.form.get("random-alias-suffix-generator"))
if AliasSuffixEnum.has_value(scheme): if AliasSuffixEnum.has_value(scheme):
@ -295,7 +209,6 @@ def setting():
Session.commit() Session.commit()
flash("Your preference has been updated", "success") flash("Your preference has been updated", "success")
return redirect(url_for("dashboard.setting")) return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "change-sender-format": elif request.form.get("form-name") == "change-sender-format":
sender_format = int(request.form.get("sender-format")) sender_format = int(request.form.get("sender-format"))
if SenderFormatEnum.has_value(sender_format): if SenderFormatEnum.has_value(sender_format):
@ -305,7 +218,6 @@ def setting():
flash("Your sender format preference has been updated", "success") flash("Your sender format preference has been updated", "success")
Session.commit() Session.commit()
return redirect(url_for("dashboard.setting")) return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "replace-ra": elif request.form.get("form-name") == "replace-ra":
choose = request.form.get("replace-ra") choose = request.form.get("replace-ra")
if choose == "on": if choose == "on":
@ -315,7 +227,6 @@ def setting():
Session.commit() Session.commit()
flash("Your preference has been updated", "success") flash("Your preference has been updated", "success")
return redirect(url_for("dashboard.setting")) return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "sender-in-ra": elif request.form.get("form-name") == "sender-in-ra":
choose = request.form.get("enable") choose = request.form.get("enable")
if choose == "on": if choose == "on":
@ -325,7 +236,6 @@ def setting():
Session.commit() Session.commit()
flash("Your preference has been updated", "success") flash("Your preference has been updated", "success")
return redirect(url_for("dashboard.setting")) return redirect(url_for("dashboard.setting"))
elif request.form.get("form-name") == "expand-alias-info": elif request.form.get("form-name") == "expand-alias-info":
choose = request.form.get("enable") choose = request.form.get("enable")
if choose == "on": if choose == "on":
@ -387,14 +297,6 @@ def setting():
Session.commit() Session.commit()
flash("Your preference has been updated", "success") flash("Your preference has been updated", "success")
return redirect(url_for("dashboard.setting")) 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) manual_sub = ManualSubscription.get_by(user_id=current_user.id)
apple_sub = AppleSubscription.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, SenderFormatEnum=SenderFormatEnum,
BlockBehaviourEnum=BlockBehaviourEnum, BlockBehaviourEnum=BlockBehaviourEnum,
promo_form=promo_form, promo_form=promo_form,
change_email_form=change_email_form,
pending_email=pending_email, pending_email=pending_email,
AliasGeneratorEnum=AliasGeneratorEnum, AliasGeneratorEnum=AliasGeneratorEnum,
UnsubscribeBehaviourEnum=UnsubscribeBehaviourEnum, UnsubscribeBehaviourEnum=UnsubscribeBehaviourEnum,
@ -432,85 +333,3 @@ def setting():
connect_with_proton=CONNECT_WITH_PROTON, connect_with_proton=CONNECT_WITH_PROTON,
proton_linked_account=proton_linked_account, 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) LOG.d("MX Domain %s %s is invalid mailbox domain", mx_domain, domain)
return False 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 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 # Time is in minutes granularity and start counting on 2022-01-01 to reduce bytes to represent time
data = [ data = [
verp_type.value, verp_type.value,
object_id, object_id or 0,
int((time.time() - VERP_TIME_START) / 60), int((time.time() - VERP_TIME_START) / 60),
] ]
json_payload = json.dumps(data).encode("utf-8") 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( refused_email = RefusedEmail.create(
full_report_path=s3_report_path, user_id=alias.user_id, flush=True 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, user_id=alias.user_id,
mailbox_id=alias.mailbox_id, mailbox_id=alias.mailbox_id,
contact_id=contact.id, contact_id=contact.id,
@ -142,6 +142,7 @@ def quarantine_dmarc_failed_forward_email(alias, contact, envelope, msg) -> Emai
blocked=True, blocked=True,
commit=True, commit=True,
) )
return email_log
def apply_dmarc_policy_for_reply_phase( def apply_dmarc_policy_for_reply_phase(

View File

@ -3,6 +3,7 @@ from email.header import Header
from email.message import Message from email.message import Message
from app.email import headers from app.email import headers
from app import config
from app.email_utils import add_or_replace_header, delete_header from app.email_utils import add_or_replace_header, delete_header
from app.handler.unsubscribe_encoder import ( from app.handler.unsubscribe_encoder import (
UnsubscribeEncoder, UnsubscribeEncoder,
@ -47,6 +48,11 @@ class UnsubscribeGenerator:
method = raw_method[start + 1 : end] method = raw_method[start + 1 : end]
url_data = urllib.parse.urlparse(method) url_data = urllib.parse.urlparse(method)
if url_data.scheme == "mailto": 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) query_data = urllib.parse.parse_qs(url_data.query)
mailto_unsubs = (url_data.path, query_data.get("subject", [""])[0]) mailto_unsubs = (url_data.path, query_data.get("subject", [""])[0])
LOG.debug(f"Unsub is mailto to {mailto_unsubs}") 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.sql import and_
from sqlalchemy_utils import ArrowType from sqlalchemy_utils import ArrowType
from app import config from app import config, rate_limiter
from app import s3 from app import s3
from app.db import Session from app.db import Session
from app.dns_utils import get_mx_domains from app.dns_utils import get_mx_domains
@ -235,6 +235,7 @@ class AuditLogActionEnum(EnumE):
download_provider_complaint = 8 download_provider_complaint = 8
disable_user = 9 disable_user = 9
enable_user = 10 enable_user = 10
stop_trial = 11
class Phase(EnumE): class Phase(EnumE):
@ -726,6 +727,11 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
return True 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): def in_trial(self):
"""return True if user does not have lifetime licence or an active subscription AND is in trial period""" """return True if user does not have lifetime licence or an active subscription AND is in trial period"""
if self.lifetime_or_active_subscription(): 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 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* - has more than 15 aliases in the free plan, *even in the free trial*
""" """
if not self.is_active():
return False
if self.disabled: if self.disabled:
return False return False
@ -907,7 +916,11 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
return sub return sub
def verified_custom_domains(self) -> List["CustomDomain"]: 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"]: def mailboxes(self) -> List["Mailbox"]:
"""list of mailbox that user own""" """list of mailbox that user own"""
@ -1495,6 +1508,8 @@ class Alias(Base, ModelMixin):
TSVector(), sa.Computed("to_tsvector('english', note)", persisted=True) TSVector(), sa.Computed("to_tsvector('english', note)", persisted=True)
) )
last_email_log_id = sa.Column(sa.Integer, default=None, nullable=True)
__table_args__ = ( __table_args__ = (
Index("ix_video___ts_vector__", ts_vector, postgresql_using="gin"), Index("ix_video___ts_vector__", ts_vector, postgresql_using="gin"),
# index on note column using pg_trgm # index on note column using pg_trgm
@ -1563,6 +1578,15 @@ class Alias(Base, ModelMixin):
flush = kw.pop("flush", False) flush = kw.pop("flush", False)
new_alias = cls(**kw) 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"] email = kw["email"]
# make sure email is lowercase and doesn't have any whitespace # make sure email is lowercase and doesn't have any whitespace
@ -2045,6 +2069,20 @@ class EmailLog(Base, ModelMixin):
def get_dashboard_url(self): def get_dashboard_url(self):
return f"{config.URL}/dashboard/refused_email?highlight_id={self.id}" 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): def __repr__(self):
return f"<EmailLog {self.id}>" return f"<EmailLog {self.id}>"
@ -3140,6 +3178,20 @@ class TransactionalEmail(Base, ModelMixin):
__table_args__ = (sa.Index("ix_transactional_email_created_at", "created_at"),) __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): class Payout(Base, ModelMixin):
"""Referral payouts""" """Referral payouts"""
@ -3330,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 @classmethod
def disable_otp_fido( def disable_otp_fido(
cls, admin_user_id: int, user_id: int, had_otp: bool, had_fido: bool cls, admin_user_id: int, user_id: int, had_otp: bool, had_fido: bool

View File

@ -140,7 +140,7 @@ def authorize():
Scope=Scope, Scope=Scope,
) )
else: # POST - user allows or denies 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( LOG.i(
"Attempt to validate a OAUth allow request by an unauthenticated user" "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 import limits.storage
from app.parallel_limiter import set_redis_concurrent_lock 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 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) storage = limits.storage.RedisStorage(redis_url)
app.session_interface = RedisSessionStore(storage.storage, storage.storage, app) app.session_interface = RedisSessionStore(storage.storage, storage.storage, app)
set_redis_concurrent_lock(storage) set_redis_concurrent_lock(storage)
rate_limit_set_redis(storage)
elif redis_url.startswith("redis+sentinel://"): elif redis_url.startswith("redis+sentinel://"):
storage = limits.storage.RedisSentinelStorage(redis_url) storage = limits.storage.RedisSentinelStorage(redis_url)
app.session_interface = RedisSessionStore( app.session_interface = RedisSessionStore(
storage.storage, storage.storage_slave, app storage.storage, storage.storage_slave, app
) )
set_redis_concurrent_lock(storage) set_redis_concurrent_lock(storage)
rate_limit_set_redis(storage)
else: else:
raise RuntimeError( raise RuntimeError(
f"Tried to set_redis_session with an invalid redis url: ${redis_url}" 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): def convert_to_id(s: str):
"""convert a string to id-like: remove space, remove special accent""" """convert a string to id-like: remove space, remove special accent"""
s = s.replace(" ", "")
s = s.lower() s = s.lower()
s = unidecode(s) s = unidecode(s)
s = s.replace(" ", "")
return s return s[:256]
_ALLOWED_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-." _ALLOWED_CHARS = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-."

View File

@ -5,7 +5,7 @@ from typing import List, Tuple
import arrow import arrow
import requests 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.ext.compiler import compiles
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from sqlalchemy.orm.exc import ObjectDeletedError from sqlalchemy.orm.exc import ObjectDeletedError
@ -62,6 +62,8 @@ from app.proton.utils import get_proton_partner
from app.utils import sanitize_email from app.utils import sanitize_email
from server import create_light_app from server import create_light_app
DELETE_GRACE_DAYS = 30
def notify_trial_end(): def notify_trial_end():
for user in User.filter( 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. 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: while True:
try: try:
alias_id = queue.get_nowait() alias_id = queue.get_nowait()
@ -967,9 +972,11 @@ async def _hibp_check(api_key, queue):
return return
alias = Alias.get(alias_id) alias = Alias.get(alias_id)
# an alias can be deleted in the meantime
if not alias: if not alias:
return continue
user = alias.user
if user.disabled or not user.is_paid():
continue
LOG.d("Checking HIBP for %s", alias) 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)}", f"https://haveibeenpwned.com/api/v3/breachedaccount/{urllib.parse.quote(alias.email)}",
headers=request_headers, headers=request_headers,
) )
if r.status_code == 200: if r.status_code == 200:
# Breaches found # Breaches found
alias.hibp_breaches = [ alias.hibp_breaches = [
@ -989,20 +995,27 @@ async def _hibp_check(api_key, queue):
] ]
if len(alias.hibp_breaches) > 0: if len(alias.hibp_breaches) > 0:
LOG.w("%s appears in HIBP breaches %s", alias, alias.hibp_breaches) 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: elif r.status_code == 404:
# No breaches found # No breaches found
alias.hibp_breaches = [] alias.hibp_breaches = []
elif r.status_code == 429: elif r.status_code == 429:
# rate limited # rate limited
LOG.w("HIBP rate limited, check alias %s in the next run", alias) LOG.w("HIBP rate limited, check alias %s in the next run", alias)
await asyncio.sleep(1.6) rate_hit_counter += 1
return 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: elif r.status_code > 500:
LOG.w("HIBP server 5** error %s", r.status_code) LOG.w("HIBP server 5** error %s", r.status_code)
return return
else: else:
LOG.error( LOG.error(
"An error occured while checking alias %s: %s - %s", "An error occurred while checking alias %s: %s - %s",
alias, alias,
r.status_code, r.status_code,
r.text, r.text,
@ -1013,9 +1026,8 @@ async def _hibp_check(api_key, queue):
Session.add(alias) Session.add(alias)
Session.commit() Session.commit()
LOG.d("Updated breaches info for %s", alias) LOG.d("Updated breach info for %s", alias)
await asyncio.sleep(rate_sleep)
await asyncio.sleep(1.6)
async def check_hibp(): async def check_hibp():
@ -1038,15 +1050,22 @@ async def check_hibp():
Session.commit() Session.commit()
LOG.d("Updated list of known breaches") 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") LOG.d("Preparing list of aliases to check")
queue = asyncio.Queue() queue = asyncio.Queue()
max_date = arrow.now().shift(days=-config.HIBP_SCAN_INTERVAL_DAYS) max_date = arrow.now().shift(days=-config.HIBP_SCAN_INTERVAL_DAYS)
for alias in ( for alias in (
Alias.filter( 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) .filter(Alias.enabled)
.order_by(Alias.hibp_last_check.asc()) .order_by(nullsfirst(Alias.hibp_last_check.asc()), Alias.id.asc())
.yield_per(500) .yield_per(500)
.enable_eagerloads(False) .enable_eagerloads(False)
): ):
@ -1126,14 +1145,19 @@ def notify_hibp():
Session.commit() Session.commit()
def clear_users_scheduled_to_be_deleted(): def clear_users_scheduled_to_be_deleted(dry_run=False):
users = User.filter( 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() ).all()
for user in users: for user in users:
LOG.i( LOG.i(
f"Scheduled deletion of user {user} with scheduled delete on {user.delete_on}" f"Scheduled deletion of user {user} with scheduled delete on {user.delete_on}"
) )
if dry_run:
continue
User.delete(user.id) User.delete(user.id)
Session.commit() Session.commit()
@ -1206,4 +1230,4 @@ if __name__ == "__main__":
load_unsent_mails_from_fs_and_resend() load_unsent_mails_from_fs_and_resend()
elif args.job == "delete_scheduled_users": elif args.job == "delete_scheduled_users":
LOG.d("Deleting users scheduled to be deleted") 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 captureStderr: true
- name: SimpleLogin delete users scheduled to be deleted - 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 shell: /bin/bash
schedule: "15 11 * * *" schedule: "15 11 * * *"
captureStderr: true captureStderr: true

View File

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

View File

@ -7460,9 +7460,7 @@ villain
vindicate vindicate
vineyard vineyard
vintage vintage
violate
violation violation
violator
violet violet
violin violin
viper viper

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

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

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

@ -88,45 +88,6 @@
</div> </div>
</div> </div>
<!-- END Current plan --> <!-- 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 --> <!-- Newsletter -->
<div class="card" id="notification"> <div class="card" id="notification">
<div class="card-body"> <div class="card-body">
@ -179,52 +140,6 @@
</form> </form>
</div> </div>
<!-- END change name & profile picture --> <!-- 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 --> <!-- Connect with Proton -->
{% if connect_with_proton %} {% if connect_with_proton %}
@ -265,32 +180,11 @@
</div> </div>
{% endif %} {% endif %}
<!-- END Connect with Proton --> <!-- 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 --> <!-- Random alias -->
<div id="random-alias" class="card"> <div id="random-alias" class="card">
<div class="card-body"> <div class="card-body">
<div class="card-title"> <div class="card-title">Aliases</div>
Aliases <div class="mt-3 mb-1">Change the way random aliases are generated by default.</div>
</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"> <form method="post" action="#random-alias" class="form-inline">
{{ csrf_form.csrf_token }} {{ csrf_form.csrf_token }}
<input type="hidden" name="form-name" value="change-alias-generator"> <input type="hidden" name="form-name" value="change-alias-generator">
@ -306,13 +200,9 @@
on {{ AliasGeneratorEnum.uuid.name.upper() }} on {{ AliasGeneratorEnum.uuid.name.upper() }}
</option> </option>
</select> </select>
<button class="btn btn-outline-primary"> <button class="btn btn-outline-primary">Update</button>
Update
</button>
</form> </form>
<div class="mt-3 mb-1"> <div class="mt-3 mb-1">Select the default domain for aliases.</div>
Select the default domain for aliases.
</div>
<form method="post" action="#random-alias" class="form-inline"> <form method="post" action="#random-alias" class="form-inline">
{{ csrf_form.csrf_token }} {{ csrf_form.csrf_token }}
<input type="hidden" <input type="hidden"
@ -338,13 +228,9 @@
</option> </option>
{% endfor %} {% endfor %}
</select> </select>
<button class="btn btn-outline-primary"> <button class="btn btn-outline-primary">Update</button>
Update
</button>
</form> </form>
<div id="random-alias-suffix" class="mt-3 mb-1"> <div id="random-alias-suffix" class="mt-3 mb-1">Select the default suffix generator for aliases.</div>
Select the default suffix generator for aliases.
</div>
<form method="post" action="#random-alias-suffix" class="form-inline"> <form method="post" action="#random-alias-suffix" class="form-inline">
{{ csrf_form.csrf_token }} {{ csrf_form.csrf_token }}
<input type="hidden" name="form-name" value="random-alias-suffix"> <input type="hidden" name="form-name" value="random-alias-suffix">
@ -358,9 +244,7 @@
Random combination of {{ ALIAS_RAND_SUFFIX_LENGTH }} letter and digits Random combination of {{ ALIAS_RAND_SUFFIX_LENGTH }} letter and digits
</option> </option>
</select> </select>
<button class="btn btn-outline-primary"> <button class="btn btn-outline-primary">Update</button>
Update
</button>
</form> </form>
</div> </div>
</div> </div>
@ -368,9 +252,7 @@
<!-- Sender Format --> <!-- Sender Format -->
<div class="card" id="sender-format"> <div class="card" id="sender-format">
<div class="card-body"> <div class="card-body">
<div class="card-title"> <div class="card-title">Sender Address Format</div>
Sender Address Format
</div>
<div class="mt-1 mb-3"> <div class="mt-1 mb-3">
When your alias receives an email, say from: <b>John Wick &lt;john@wick.com&gt;</b>, When your alias receives an email, say from: <b>John Wick &lt;john@wick.com&gt;</b>,
SimpleLogin forwards it to your mailbox. SimpleLogin forwards it to your mailbox.
@ -403,9 +285,7 @@
No Name (i.e. only reverse-alias) No Name (i.e. only reverse-alias)
</option> </option>
</select> </select>
<button class="btn btn-outline-primary mt-3"> <button class="btn btn-outline-primary mt-3">Update</button>
Update
</button>
</form> </form>
</div> </div>
</div> </div>
@ -415,9 +295,7 @@
<div class="card-body"> <div class="card-body">
<div class="card-title"> <div class="card-title">
Reverse Alias Replacement Reverse Alias Replacement
<div class="badge badge-warning"> <div class="badge badge-warning">Experimental</div>
Experimental
</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
When replying to a forwarded email, the <b>reverse-alias</b> can be automatically included When replying to a forwarded email, the <b>reverse-alias</b> can be automatically included
@ -434,13 +312,9 @@
name="replace-ra" name="replace-ra"
{% if current_user.replace_reverse_alias %} checked{% endif %} {% if current_user.replace_reverse_alias %} checked{% endif %}
class="form-check-input"> class="form-check-input">
<label for="replace-ra"> <label for="replace-ra">Enable replacing reverse alias</label>
Enable replacing reverse alias
</label>
</div> </div>
<button type="submit" class="btn btn-outline-primary"> <button type="submit" class="btn btn-outline-primary">Update</button>
Update
</button>
</form> </form>
</div> </div>
</div> </div>
@ -709,62 +583,6 @@
</form> </form>
</div> </div>
</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> </div>
{% endblock %} {% endblock %}
{% block script %} {% block script %}

View File

@ -9,7 +9,7 @@
<a href='https://simplelogin.io/' aria-label="SimpleLogin"> <a href='https://simplelogin.io/' aria-label="SimpleLogin">
<img src="/static/logo-white.svg" <img src="/static/logo-white.svg"
height="30px" height="30px"
class="mb-3" class="mt-3 mb-3"
alt="SimpleLogin logo"> alt="SimpleLogin logo">
</a> </a>
<!-- End Logo --> <!-- End Logo -->

View File

@ -89,86 +89,91 @@
Github repo Github repo
<i class="fa fa-external-link" aria-hidden="true"></i> <i class="fa fa-external-link" aria-hidden="true"></i>
</a> </a>
<div class="dropdown-item"> </div>
<a href="https://forum.simplelogin.io" <div class="dropdown-item">
target="_blank" <a href="https://forum.simplelogin.io"
rel="noopener noreferrer"> target="_blank"
Forum rel="noopener noreferrer">
<i class="fa fa-external-link" aria-hidden="true"></i> Forum
</a> <i class="fa fa-external-link" aria-hidden="true"></i>
</div> </a>
<div class="dropdown-item"> </div>
<a href="/dashboard/support">Support</a> <div class="dropdown-item">
</div> <a href="/dashboard/support">Support</a>
</div> </div>
</div> </div>
{% else %} </div>
<div class="nav-item"> {% else %}
<a href="https://simplelogin.io/docs/" <div class="nav-item">
target="_blank" <a href="https://simplelogin.io/docs/"
rel="noopener noreferrer"> target="_blank"
Docs rel="noopener noreferrer">
<i class="fa fa-external-link" aria-hidden="true"></i> Docs
</a> <i class="fa fa-external-link" aria-hidden="true"></i>
</div> </a>
{% endif %} </div>
{% if current_user.should_show_upgrade_button() %} {% endif %}
{% if current_user.should_show_upgrade_button() %}
<div class="nav-item">
<a href="{{ url_for('dashboard.pricing') }}" <div class="nav-item">
class="btn btn-sm btn-outline-primary">Upgrade</a> <a href="{{ url_for('dashboard.pricing') }}"
</div> class="btn btn-sm btn-outline-primary">Upgrade</a>
{% endif %} </div>
<div class="dropdown"> {% endif %}
<a href="#" class="nav-link pr-0 leading-none" data-toggle="dropdown"> <div class="dropdown">
{% if current_user.profile_picture_id %} <a href="#" class="nav-link pr-0 leading-none" data-toggle="dropdown">
{% if current_user.profile_picture_id %}
<span class="avatar"
style="background-image: url('{{ current_user.profile_picture_url() }}')"></span> <span class="avatar"
{% else %} style="background-image: url('{{ current_user.profile_picture_url() }}')"></span>
<span class="avatar avatar-blue">{{ current_user.get_name_initial() or "👻" }}</span> {% else %}
{% endif %} <span class="avatar avatar-blue">{{ current_user.get_name_initial() or "👻" }}</span>
<span class="ml-2 d-none d-lg-block"> {% endif %}
<span class="text-default text-break">{{ current_user.name or current_user.email }}</span> <span class="ml-2 d-none d-lg-block">
{% if current_user.in_trial() %} <span class="text-default text-break">{{ current_user.name or current_user.email }}</span>
{% if current_user.in_trial() %}
<small class="text-success d-block mt-1"
data-toggle="tooltip" <small class="text-success d-block mt-1"
title="When you signed up, you have a free 7-day Premium trial. After that your account will automatically be downgraded to the Free plan. During the trial, the only limit is you can't create more than {{ MAX_NB_EMAIL_FREE_PLAN }} aliases."> data-toggle="tooltip"
Premium expires {{ current_user.trial_end|dt }} title="When you signed up, you have a free 7-day Premium trial. After that your account will automatically be downgraded to the Free plan. During the trial, the only limit is you can't create more than {{ MAX_NB_EMAIL_FREE_PLAN }} aliases.">
<i class="fe fe-info"></i> Premium expires {{ current_user.trial_end|dt }}
</small> <i class="fe fe-info"></i>
{% elif current_user.is_premium() %} </small>
<small class="text-success d-block mt-1">Premium</small> {% elif current_user.is_premium() %}
{% endif %} <small class="text-success d-block mt-1">Premium</small>
</span> {% endif %}
</span>
</a>
<div class="dropdown-menu dropdown-menu-right dropdown-menu-arrow">
<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> </a>
<div class="dropdown-menu dropdown-menu-right dropdown-menu-arrow">
<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" href="{{ url_for('auth.logout') }}">
<i class="dropdown-icon fe fe-log-out"></i> Sign out
</a>
</div>
</div> </div>
</div> </div>
<a href="#"
class="header-toggler d-lg-none ml-3 ml-lg-0"
data-toggle="collapse"
data-target="#headerMenuCollapse">
<span class="header-toggler-icon"></span>
</a>
</div> </div>
<a href="#"
class="header-toggler d-lg-none ml-3 ml-lg-0"
data-toggle="collapse"
data-target="#headerMenuCollapse">
<span class="header-toggler-icon"></span>
</a>
</div> </div>
</div> </div>
<div class="header collapse d-lg-flex p-0" id="headerMenuCollapse"> </div>
<div class="container"> <div class="header collapse d-lg-flex p-0" id="headerMenuCollapse">
<div class="row align-items-center"> <div class="container">
<div class="col-lg order-lg-first"> <div class="row align-items-center">
{% include "menu.html" %} <div class="col-lg order-lg-first">
{% include "menu.html" %}
</div>
</div> </div>
</div> </div>
</div> </div>
</div>

View File

@ -40,14 +40,16 @@ def test_get_notifications(flask_client):
def test_mark_notification_as_read(flask_client): def test_mark_notification_as_read(flask_client):
user, api_key = get_new_user_and_api_key() 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() Session.commit()
r = flask_client.post( 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}, headers={"Authentication": api_key.code},
) )
assert r.status_code == 200 assert r.status_code == 200
notification = Notification.first() notification = Notification.filter_by(id=notif_id).first()
assert notification.read assert notification.read

View File

@ -1,8 +1,8 @@
from app.api.serializer import get_alias_infos_with_pagination_v3 from app.api.serializer import get_alias_infos_with_pagination_v3
from app.config import PAGE_LIMIT from app.config import PAGE_LIMIT
from app.db import Session from app.db import Session
from app.models import Alias, Mailbox, Contact from app.models import Alias, Mailbox, Contact, EmailLog
from tests.utils import create_new_user from tests.utils import create_new_user, random_email
def test_get_alias_infos_with_pagination_v3(flask_client): 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 # pinned alias isn't included in the search
alias_infos = get_alias_infos_with_pagination_v3(user, query="no match") alias_infos = get_alias_infos_with_pagination_v3(user, query="no match")
assert len(alias_infos) == 0 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

@ -6,16 +6,27 @@ from tests.utils import create_new_user
def test_unactivated_user_login(flask_client): def test_unactivated_user_login(flask_client):
user = create_new_user() """
user.activated = False Test function for logging in with an unactivated user.
Session.commit()
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( r = flask_client.post(
url_for("auth.login"), url_for("auth.login"),
data={"email": user.email, "password": "password"}, data={"email": user.email, "password": "password"},
follow_redirects=True, follow_redirects=True,
) )
# Asserting the response status code and content for expected messages
assert r.status_code == 200 assert r.status_code == 200
assert ( assert (
b"Please check your inbox for the activation email. You can also have this email re-sent" 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): def test_non_canonical_login(flask_client):
email = f"pre.{random_string(10)}@gmail.com" """
name = f"NAME-{random_string(10)}" Test function for logging in with a non-canonical email.
user = create_new_user(email, name)
Session.commit()
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( r = flask_client.post(
url_for("auth.login"), url_for("auth.login"),
data={"email": user.email, "password": "password"}, data={"email": user.email, "password": "password"},
follow_redirects=True, follow_redirects=True,
) )
# Asserting the response status code and content for expected messages
assert r.status_code == 200 assert r.status_code == 200
assert name.encode("utf-8") in r.data assert name.encode("utf-8") in r.data
# Canonicalizing the email
canonical_email = canonicalize_email(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( r = flask_client.post(
url_for("auth.login"), url_for("auth.login"),
data={"email": canonical_email, "password": "password"}, data={"email": canonical_email, "password": "password"},
follow_redirects=True, follow_redirects=True,
) )
# Asserting the response status code and content for expected messages
assert r.status_code == 200 assert r.status_code == 200
assert name.encode("utf-8") not in r.data assert name.encode("utf-8") not in r.data
def test_canonical_login_with_non_canonical_email(flask_client): def test_canonical_login_with_non_canonical_email(flask_client):
suffix = f"{random_string(10)}@gmail.com" """
canonical_email = f"pre{suffix}" Test function for logging in with a canonical email and a non-canonical email.
non_canonical_email = f"pre.{suffix}"
name = f"NAME-{random_string(10)}"
create_new_user(canonical_email, name)
Session.commit()
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( r = flask_client.post(
url_for("auth.login"), url_for("auth.login"),
data={"email": non_canonical_email, "password": "password"}, data={"email": non_canonical_email, "password": "password"},
follow_redirects=True, follow_redirects=True,
) )
# Asserting the response status code and content for expected messages
assert r.status_code == 200 assert r.status_code == 200
assert name.encode("utf-8") in r.data 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( r = flask_client.post(
url_for("auth.login"), url_for("auth.login"),
data={"email": canonical_email, "password": "password"}, data={"email": canonical_email, "password": "password"},
follow_redirects=True, follow_redirects=True,
) )
# Asserting the response status code and content for expected messages
assert r.status_code == 200 assert r.status_code == 200
assert name.encode("utf-8") in r.data 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()}" noncanonical_email = f"nonca.{random_email()}"
r = flask_client.post( r = flask_client.post(
url_for("dashboard.setting"), url_for("dashboard.account_setting"),
data={ data={
"form-name": "update-email", "form-name": "update-email",
"email": noncanonical_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.handler.unsubscribe_generator import UnsubscribeGenerator
from app.models import Alias, Contact, UnsubscribeBehaviourEnum 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" TEST_UNSUB_EMAIL = "unsub@sl.com"
@ -204,3 +203,23 @@ def test_unsub_preserve_original(
assert message[headers.LIST_UNSUBSCRIBE_POST] is None assert message[headers.LIST_UNSUBSCRIBE_POST] is None
else: else:
assert "List-Unsubscribe=One-Click" == message[headers.LIST_UNSUBSCRIBE_POST] 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

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

View File

@ -49,10 +49,26 @@ from app.models import (
VerpType, VerpType,
AliasGeneratorEnum, AliasGeneratorEnum,
SLDomain, SLDomain,
Mailbox,
) )
# flake8: noqa: E101, W191 # 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(): 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") 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): def test_can_be_used_as_personal_email(flask_client):
# default alias domain # default alias domain
assert not email_can_be_used_as_mailbox("ab@sl.local") 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") 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(): def test_delete_header():
msg = EmailMessage() msg = EmailMessage()
assert msg._headers == [] assert msg._headers == []
@ -154,13 +187,14 @@ def test_parse_full_address():
def test_send_email_with_rate_control(flask_client): def test_send_email_with_rate_control(flask_client):
user = create_new_user() user = create_new_user()
email = random_email()
for _ in range(MAX_ALERT_24H): for _ in range(MAX_ALERT_24H):
assert send_email_with_rate_control( 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( assert not send_email_with_rate_control(
user, "test alert type", "abcd@gmail.com", "subject", "plaintext" user, "test alert type", email, "subject", "plaintext"
) )