Compare commits

...

5 Commits

Author SHA1 Message Date
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
23 changed files with 868 additions and 481 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

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

View File

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

View File

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

View File

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

View File

@ -421,6 +421,8 @@ try:
except Exception:
HIBP_SCAN_INTERVAL_DAYS = 7
HIBP_API_KEYS = sl_getenv("HIBP_API_KEYS", list) or []
HIBP_MAX_ALIAS_CHECK = 10_000
HIBP_RPM = 100
POSTMASTER = os.environ.get("POSTMASTER")
@ -567,3 +569,5 @@ MAX_API_KEYS = int(os.environ.get("MAX_API_KEYS", 30))
UPCLOUD_USERNAME = os.environ.get("UPCLOUD_USERNAME", None)
UPCLOUD_PASSWORD = os.environ.get("UPCLOUD_PASSWORD", None)
UPCLOUD_DB_ID = os.environ.get("UPCLOUD_DB_ID", None)
STORE_TRANSACTIONAL_EMAILS = "STORE_TRANSACTIONAL_EMAILS" in os.environ

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1403,7 +1403,7 @@ def generate_verp_email(
# Time is in minutes granularity and start counting on 2022-01-01 to reduce bytes to represent time
data = [
verp_type.value,
object_id,
object_id or 0,
int((time.time() - VERP_TIME_START) / 60),
]
json_payload = json.dumps(data).encode("utf-8")

View File

@ -3178,6 +3178,20 @@ class TransactionalEmail(Base, ModelMixin):
__table_args__ = (sa.Index("ix_transactional_email_created_at", "created_at"),)
@classmethod
def create(cls, **kw):
# whether to call Session.commit
commit = kw.pop("commit", False)
r = cls(**kw)
if not config.STORE_TRANSACTIONAL_EMAILS:
return r
Session.add(r)
if commit:
Session.commit()
return r
class Payout(Base, ModelMixin):
"""Referral payouts"""

View File

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

View File

@ -236,15 +236,16 @@ def get_or_create_contact(from_header: str, mail_from: str, alias: Alias) -> Con
Session.commit()
else:
try:
contact_email_for_reply = (
contact_email if is_valid_email(contact_email) else ""
)
contact = Contact.create(
user_id=alias.user_id,
alias_id=alias.id,
website_email=contact_email,
name=contact_name,
mail_from=mail_from,
reply_email=generate_reply_email(contact_email, alias)
if is_valid_email(contact_email)
else NOREPLY,
reply_email=generate_reply_email(contact_email_for_reply, alias),
automatic_created=True,
)
if not contact_email:
@ -1890,24 +1891,30 @@ def handle_transactional_bounce(
envelope: Envelope, msg, rcpt_to, transactional_id=None
):
LOG.d("handle transactional bounce sent to %s", rcpt_to)
if transactional_id is None:
LOG.i(
f"No transactional record for {envelope.mail_from} -> {envelope.rcpt_tos}"
)
return
# parse the TransactionalEmail
transactional_id = transactional_id or parse_id_from_bounce(rcpt_to)
transactional = TransactionalEmail.get(transactional_id)
# a transaction might have been deleted in delete_logs()
if transactional:
LOG.i("Create bounce for %s", transactional.email)
bounce_info = get_mailbox_bounce_info(msg)
if bounce_info:
Bounce.create(
email=transactional.email,
info=bounce_info.as_bytes().decode(),
commit=True,
)
else:
LOG.w("cannot get bounce info, debug at %s", save_email_for_debugging(msg))
Bounce.create(email=transactional.email, commit=True)
if not transactional:
LOG.i(
f"No transactional record for {envelope.mail_from} -> {envelope.rcpt_tos}"
)
return
LOG.i("Create bounce for %s", transactional.email)
bounce_info = get_mailbox_bounce_info(msg)
if bounce_info:
Bounce.create(
email=transactional.email,
info=bounce_info.as_bytes().decode(),
commit=True,
)
else:
LOG.w("cannot get bounce info, debug at %s", save_email_for_debugging(msg))
Bounce.create(email=transactional.email, commit=True)
def handle_bounce(envelope, email_log: EmailLog, msg: Message) -> str:

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

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

View File

@ -148,6 +148,10 @@
<a class="dropdown-item mb-3" href="{{ url_for('dashboard.api_key') }}">
<i class="dropdown-icon fa fa-key"></i> API Keys
</a>
<a class="dropdown-item mb-3"
href="{{ url_for('dashboard.account_setting') }}">
<i class="dropdown-icon fa fa-user"></i> Account settings
</a>
<a class="dropdown-item" href="{{ url_for('auth.logout') }}">
<i class="dropdown-icon fe fe-log-out"></i> Sign out
</a>

View File

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

View File

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

View File

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