diff --git a/app/app/admin_model.py b/app/app/admin_model.py
index 0394fbe..4f7e6d0 100644
--- a/app/app/admin_model.py
+++ b/app/app/admin_model.py
@@ -256,6 +256,17 @@ class UserAdmin(SLModelView):
Session.commit()
+ @action(
+ "clear_delete_on",
+ "Remove scheduled deletion of user",
+ "This will remove the scheduled deletion for this users",
+ )
+ def clean_delete_on(self, ids):
+ for user in User.filter(User.id.in_(ids)):
+ user.delete_on = None
+
+ Session.commit()
+
# @action(
# "login_as",
# "Login as this user",
diff --git a/app/app/alias_utils.py b/app/app/alias_utils.py
index 3034ee8..b02cb1f 100644
--- a/app/app/alias_utils.py
+++ b/app/app/alias_utils.py
@@ -21,6 +21,8 @@ from app.email_utils import (
send_cannot_create_directory_alias_disabled,
get_email_local_part,
send_cannot_create_domain_alias,
+ send_email,
+ render,
)
from app.errors import AliasInTrashError
from app.log import LOG
@@ -36,6 +38,8 @@ from app.models import (
EmailLog,
Contact,
AutoCreateRule,
+ AliasUsedOn,
+ ClientUser,
)
from app.regex_utils import regex_match
@@ -399,3 +403,58 @@ def alias_export_csv(user, csv_direct_export=False):
output.headers["Content-Disposition"] = "attachment; filename=aliases.csv"
output.headers["Content-type"] = "text/csv"
return output
+
+
+def transfer_alias(alias, new_user, new_mailboxes: [Mailbox]):
+ # cannot transfer alias which is used for receiving newsletter
+ if User.get_by(newsletter_alias_id=alias.id):
+ raise Exception("Cannot transfer alias that's used to receive newsletter")
+
+ # update user_id
+ Session.query(Contact).filter(Contact.alias_id == alias.id).update(
+ {"user_id": new_user.id}
+ )
+
+ Session.query(AliasUsedOn).filter(AliasUsedOn.alias_id == alias.id).update(
+ {"user_id": new_user.id}
+ )
+
+ Session.query(ClientUser).filter(ClientUser.alias_id == alias.id).update(
+ {"user_id": new_user.id}
+ )
+
+ # remove existing mailboxes from the alias
+ Session.query(AliasMailbox).filter(AliasMailbox.alias_id == alias.id).delete()
+
+ # set mailboxes
+ alias.mailbox_id = new_mailboxes.pop().id
+ for mb in new_mailboxes:
+ AliasMailbox.create(alias_id=alias.id, mailbox_id=mb.id)
+
+ # alias has never been transferred before
+ if not alias.original_owner_id:
+ alias.original_owner_id = alias.user_id
+
+ # inform previous owner
+ old_user = alias.user
+ send_email(
+ old_user.email,
+ f"Alias {alias.email} has been received",
+ render(
+ "transactional/alias-transferred.txt",
+ alias=alias,
+ ),
+ render(
+ "transactional/alias-transferred.html",
+ alias=alias,
+ ),
+ )
+
+ # now the alias belongs to the new user
+ alias.user_id = new_user.id
+
+ # set some fields back to default
+ alias.disable_pgp = False
+ alias.pinned = False
+
+ Session.commit()
diff --git a/app/app/api/views/auth.py b/app/app/api/views/auth.py
index 79e019b..b77036a 100644
--- a/app/app/api/views/auth.py
+++ b/app/app/api/views/auth.py
@@ -63,6 +63,11 @@ def auth_login():
elif user.disabled:
LoginEvent(LoginEvent.ActionType.disabled_login, LoginEvent.Source.api).send()
return jsonify(error="Account disabled"), 400
+ elif user.delete_on is not None:
+ LoginEvent(
+ LoginEvent.ActionType.scheduled_to_be_deleted, LoginEvent.Source.api
+ ).send()
+ return jsonify(error="Account scheduled for deletion"), 400
elif not user.activated:
LoginEvent(LoginEvent.ActionType.not_activated, LoginEvent.Source.api).send()
return jsonify(error="Account not activated"), 422
diff --git a/app/app/auth/views/login.py b/app/app/auth/views/login.py
index 55cb0c6..56b2ac3 100644
--- a/app/app/auth/views/login.py
+++ b/app/app/auth/views/login.py
@@ -54,6 +54,12 @@ def login():
"error",
)
LoginEvent(LoginEvent.ActionType.disabled_login).send()
+ elif user.delete_on is not None:
+ flash(
+ f"Your account is scheduled to be deleted on {user.delete_on}",
+ "error",
+ )
+ LoginEvent(LoginEvent.ActionType.scheduled_to_be_deleted).send()
elif not user.activated:
show_resend_activation = True
flash(
diff --git a/app/app/dashboard/views/alias_transfer.py b/app/app/dashboard/views/alias_transfer.py
index af8fab5..8b2e767 100644
--- a/app/app/dashboard/views/alias_transfer.py
+++ b/app/app/dashboard/views/alias_transfer.py
@@ -7,79 +7,19 @@ from flask import render_template, redirect, url_for, flash, request
from flask_login import login_required, current_user
from app import config
+from app.alias_utils import transfer_alias
from app.dashboard.base import dashboard_bp
from app.dashboard.views.enter_sudo import sudo_required
from app.db import Session
-from app.email_utils import send_email, render
from app.extensions import limiter
from app.log import LOG
from app.models import (
Alias,
- Contact,
- AliasUsedOn,
- AliasMailbox,
- User,
- ClientUser,
)
from app.models import Mailbox
from app.utils import CSRFValidationForm
-def transfer(alias, new_user, new_mailboxes: [Mailbox]):
- # cannot transfer alias which is used for receiving newsletter
- if User.get_by(newsletter_alias_id=alias.id):
- raise Exception("Cannot transfer alias that's used to receive newsletter")
-
- # update user_id
- Session.query(Contact).filter(Contact.alias_id == alias.id).update(
- {"user_id": new_user.id}
- )
-
- Session.query(AliasUsedOn).filter(AliasUsedOn.alias_id == alias.id).update(
- {"user_id": new_user.id}
- )
-
- Session.query(ClientUser).filter(ClientUser.alias_id == alias.id).update(
- {"user_id": new_user.id}
- )
-
- # remove existing mailboxes from the alias
- Session.query(AliasMailbox).filter(AliasMailbox.alias_id == alias.id).delete()
-
- # set mailboxes
- alias.mailbox_id = new_mailboxes.pop().id
- for mb in new_mailboxes:
- AliasMailbox.create(alias_id=alias.id, mailbox_id=mb.id)
-
- # alias has never been transferred before
- if not alias.original_owner_id:
- alias.original_owner_id = alias.user_id
-
- # inform previous owner
- old_user = alias.user
- send_email(
- old_user.email,
- f"Alias {alias.email} has been received",
- render(
- "transactional/alias-transferred.txt",
- alias=alias,
- ),
- render(
- "transactional/alias-transferred.html",
- alias=alias,
- ),
- )
-
- # now the alias belongs to the new user
- alias.user_id = new_user.id
-
- # set some fields back to default
- alias.disable_pgp = False
- alias.pinned = False
-
- Session.commit()
-
-
def hmac_alias_transfer_token(transfer_token: str) -> str:
alias_hmac = hmac.new(
config.ALIAS_TRANSFER_TOKEN_SECRET.encode("utf-8"),
@@ -214,7 +154,7 @@ def alias_transfer_receive_route():
mailboxes,
token,
)
- transfer(alias, current_user, mailboxes)
+ transfer_alias(alias, current_user, mailboxes)
# reset transfer token
alias.transfer_token = None
diff --git a/app/app/events/auth_event.py b/app/app/events/auth_event.py
index f675218..60b7b8c 100644
--- a/app/app/events/auth_event.py
+++ b/app/app/events/auth_event.py
@@ -9,6 +9,7 @@ class LoginEvent:
failed = 1
disabled_login = 2
not_activated = 3
+ scheduled_to_be_deleted = 4
class Source(EnumE):
web = 0
diff --git a/app/app/models.py b/app/app/models.py
index 036733b..8807dc0 100644
--- a/app/app/models.py
+++ b/app/app/models.py
@@ -280,6 +280,7 @@ class IntEnumType(sa.types.TypeDecorator):
class AliasOptions:
show_sl_domains: bool = True
show_partner_domains: Optional[Partner] = None
+ show_partner_premium: Optional[bool] = None
class Hibp(Base, ModelMixin):
@@ -539,10 +540,14 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
nullable=False,
)
+ # Trigger hard deletion of the account at this time
+ delete_on = sa.Column(ArrowType, default=None)
+
__table_args__ = (
sa.Index(
"ix_users_activated_trial_end_lifetime", activated, trial_end, lifetime
),
+ sa.Index("ix_users_delete_on", delete_on),
)
@property
@@ -833,6 +838,17 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
< self.max_alias_for_free_account()
)
+ def can_send_or_receive(self) -> bool:
+ if self.disabled:
+ LOG.i(f"User {self} is disabled. Cannot receive or send emails")
+ return False
+ if self.delete_on is not None:
+ LOG.i(
+ f"User {self} is scheduled to be deleted. Cannot receive or send emails"
+ )
+ return False
+ return True
+
def profile_picture_url(self):
if self.profile_picture_id:
return self.profile_picture.get_url()
@@ -1023,29 +1039,35 @@ class User(Base, ModelMixin, UserMixin, PasswordOracle):
) -> list["SLDomain"]:
if alias_options is None:
alias_options = AliasOptions()
- conditions = [SLDomain.hidden == False] # noqa: E712
- if not self.is_premium():
- conditions.append(SLDomain.premium_only == False) # noqa: E712
- partner_domain_cond = [] # noqa:E711
+ top_conds = [SLDomain.hidden == False] # noqa: E712
+ or_conds = [] # noqa:E711
if self.default_alias_public_domain_id is not None:
- partner_domain_cond.append(
- SLDomain.id == self.default_alias_public_domain_id
- )
+ default_domain_conds = [SLDomain.id == self.default_alias_public_domain_id]
+ if not self.is_premium():
+ default_domain_conds.append(
+ SLDomain.premium_only == False # noqa: E712
+ )
+ or_conds.append(and_(*default_domain_conds).self_group())
if alias_options.show_partner_domains is not None:
partner_user = PartnerUser.filter_by(
user_id=self.id, partner_id=alias_options.show_partner_domains.id
).first()
if partner_user is not None:
- partner_domain_cond.append(
- SLDomain.partner_id == partner_user.partner_id
- )
+ partner_domain_cond = [SLDomain.partner_id == partner_user.partner_id]
+ if alias_options.show_partner_premium is None:
+ alias_options.show_partner_premium = self.is_premium()
+ if not alias_options.show_partner_premium:
+ partner_domain_cond.append(
+ SLDomain.premium_only == False # noqa: E712
+ )
+ or_conds.append(and_(*partner_domain_cond).self_group())
if alias_options.show_sl_domains:
- partner_domain_cond.append(SLDomain.partner_id == None) # noqa:E711
- if len(partner_domain_cond) == 1:
- conditions.append(partner_domain_cond[0])
- else:
- conditions.append(or_(*partner_domain_cond))
- query = Session.query(SLDomain).filter(*conditions).order_by(SLDomain.order)
+ sl_conds = [SLDomain.partner_id == None] # noqa: E711
+ if not self.is_premium():
+ sl_conds.append(SLDomain.premium_only == False) # noqa: E712
+ or_conds.append(and_(*sl_conds).self_group())
+ top_conds.append(or_(*or_conds))
+ query = Session.query(SLDomain).filter(*top_conds).order_by(SLDomain.order)
return query.all()
def available_alias_domains(
@@ -1925,6 +1947,7 @@ class Contact(Base, ModelMixin):
class EmailLog(Base, ModelMixin):
__tablename__ = "email_log"
+ __table_args__ = (Index("ix_email_log_created_at", "created_at"),)
user_id = sa.Column(
sa.ForeignKey(User.id, ondelete="cascade"), nullable=False, index=True
@@ -2576,6 +2599,7 @@ class Mailbox(Base, ModelMixin):
self.email.endswith("@proton.me")
or self.email.endswith("@protonmail.com")
or self.email.endswith("@protonmail.ch")
+ or self.email.endswith("@proton.ch")
or self.email.endswith("@pm.me")
):
return True
diff --git a/app/cron.py b/app/cron.py
index 7c3cd2c..6c59343 100644
--- a/app/cron.py
+++ b/app/cron.py
@@ -5,7 +5,7 @@ from typing import List, Tuple
import arrow
import requests
-from sqlalchemy import func, desc, or_
+from sqlalchemy import func, desc, or_, and_
from sqlalchemy.ext.compiler import compiles
from sqlalchemy.orm import joinedload
from sqlalchemy.orm.exc import ObjectDeletedError
@@ -1106,6 +1106,18 @@ def notify_hibp():
Session.commit()
+def clear_users_scheduled_to_be_deleted():
+ users = User.filter(
+ and_(User.delete_on.isnot(None), User.delete_on < arrow.now())
+ ).all()
+ for user in users:
+ LOG.i(
+ f"Scheduled deletion of user {user} with scheduled delete on {user.delete_on}"
+ )
+ User.delete(user.id)
+ Session.commit()
+
+
if __name__ == "__main__":
LOG.d("Start running cronjob")
parser = argparse.ArgumentParser()
@@ -1172,3 +1184,6 @@ if __name__ == "__main__":
elif args.job == "send_undelivered_mails":
LOG.d("Sending undelivered emails")
load_unsent_mails_from_fs_and_resend()
+ elif args.job == "delete_scheduled_users":
+ LOG.d("Deleting users scheduled to be deleted")
+ clear_users_scheduled_to_be_deleted()
diff --git a/app/crontab.yml b/app/crontab.yml
index ece8ac1..86877a5 100644
--- a/app/crontab.yml
+++ b/app/crontab.yml
@@ -61,7 +61,12 @@ jobs:
schedule: "15 10 * * *"
captureStderr: true
-
+ - name: SimpleLogin delete users scheduled to be deleted
+ command: echo disabled_user_deletion #python /code/cron.py -j delete_scheduled_users
+ shell: /bin/bash
+ schedule: "15 11 * * *"
+ captureStderr: true
+ concurrencyPolicy: Forbid
- name: SimpleLogin send unsent emails
command: python /code/cron.py -j send_undelivered_mails
diff --git a/app/email_handler.py b/app/email_handler.py
index 4ccba49..c5be199 100644
--- a/app/email_handler.py
+++ b/app/email_handler.py
@@ -637,8 +637,8 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str
user = alias.user
- if user.disabled:
- LOG.w("User %s disabled, disable forwarding emails for %s", user, alias)
+ if not user.can_send_or_receive():
+ LOG.i(f"User {user} cannot receive emails")
if should_ignore_bounce(envelope.mail_from):
return [(True, status.E207)]
else:
@@ -1070,13 +1070,8 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str):
user = alias.user
mail_from = envelope.mail_from
- if user.disabled:
- LOG.e(
- "User %s disabled, disable sending emails from %s to %s",
- user,
- alias,
- contact,
- )
+ if not user.can_send_or_receive():
+ LOG.i(f"User {user} cannot send emails")
return False, status.E504
# Check if we need to reject or quarantine based on dmarc
@@ -1257,7 +1252,6 @@ def handle_reply(envelope, msg: Message, rcpt_to: str) -> (bool, str):
if str(msg[headers.TO]).lower() == "undisclosed-recipients:;":
# no need to replace TO header
LOG.d("email is sent in BCC mode")
- del msg[headers.TO]
else:
replace_header_when_reply(msg, alias, headers.TO)
diff --git a/app/migrations/versions/2023_090715_0a5701a4f5e4_.py b/app/migrations/versions/2023_090715_0a5701a4f5e4_.py
new file mode 100644
index 0000000..08f15ae
--- /dev/null
+++ b/app/migrations/versions/2023_090715_0a5701a4f5e4_.py
@@ -0,0 +1,33 @@
+"""empty message
+
+Revision ID: 0a5701a4f5e4
+Revises: 01827104004b
+Create Date: 2023-09-07 15:28:10.122756
+
+"""
+import sqlalchemy_utils
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = '0a5701a4f5e4'
+down_revision = '01827104004b'
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.add_column('users', sa.Column('delete_on', sqlalchemy_utils.types.arrow.ArrowType(), nullable=True))
+ with op.get_context().autocommit_block():
+ op.create_index('ix_users_delete_on', 'users', ['delete_on'], unique=False, postgresql_concurrently=True)
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.get_context().autocommit_block():
+ op.drop_index('ix_users_delete_on', table_name='users', postgresql_concurrently=True)
+ op.drop_column('users', 'delete_on')
+ # ### end Alembic commands ###
diff --git a/app/migrations/versions/2023_092818_ec7fdde8da9f_.py b/app/migrations/versions/2023_092818_ec7fdde8da9f_.py
new file mode 100644
index 0000000..f1fae82
--- /dev/null
+++ b/app/migrations/versions/2023_092818_ec7fdde8da9f_.py
@@ -0,0 +1,34 @@
+"""empty message
+
+Revision ID: ec7fdde8da9f
+Revises: 0a5701a4f5e4
+Create Date: 2023-09-28 18:09:48.016620
+
+"""
+import sqlalchemy_utils
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = "ec7fdde8da9f"
+down_revision = "0a5701a4f5e4"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.get_context().autocommit_block():
+ op.create_index(
+ "ix_email_log_created_at", "email_log", ["created_at"], unique=False
+ )
+
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.get_context().autocommit_block():
+ op.drop_index("ix_email_log_created_at", table_name="email_log")
+ # ### end Alembic commands ###
diff --git a/app/templates/dashboard/domain_detail/dns.html b/app/templates/dashboard/domain_detail/dns.html
index 75c2672..3b055e7 100644
--- a/app/templates/dashboard/domain_detail/dns.html
+++ b/app/templates/dashboard/domain_detail/dns.html
@@ -268,7 +268,7 @@
If you are using a subdomain, e.g. subdomain.domain.com,
you need to use dkim._domainkey.subdomain as the domain instead.
- That means, if your domain is mail.domain.com you should enter dkim._domainkey.mail.domain.com as the Domain.
+ That means, if your domain is mail.domain.com you should enter dkim._domainkey.mail as the Domain.