diff --git a/app/admin_model.py b/app/admin_model.py
index e59f2b089..4809bf337 100644
--- a/app/admin_model.py
+++ b/app/admin_model.py
@@ -969,7 +969,7 @@ def delete_partner_link(self):
return redirect(url_for("admin.email_search.index", query=user_id))
AdminAuditLog.create(
- admin_user_id=user.id,
+ admin_user_id=current_user.id,
model=User.__class__.__name__,
model_id=user.id,
action=AuditLogActionEnum.unlink_user.value,
@@ -979,6 +979,67 @@ def delete_partner_link(self):
return redirect(url_for("admin.email_search.index", query=user_id))
+ @expose("/stop_soft_delete_user", methods=["POST"])
+ def stop_soft_delete_user(self):
+ user_id = request.form.get("user_id")
+ if not user_id:
+ flash("Missing user_id", "error")
+ return redirect(url_for("admin.email_search.index"))
+ try:
+ user_id = int(user_id)
+ except ValueError:
+ flash("Missing user_id", "error")
+ return redirect(url_for("admin.email_search.index", query=user_id))
+ user = User.get(user_id)
+ if user is None:
+ flash("User not found", "error")
+ return redirect(url_for("admin.email_search.index", query=user_id))
+ if user.delete_on is None:
+ flash("User is not pending deletion", "error")
+ return redirect(url_for("admin.email_search.index", query=user_id))
+
+ user.delete_on = None
+ AdminAuditLog.create(
+ admin_user_id=current_user.id,
+ model=User.__class__.__name__,
+ model_id=user.id,
+ action=AuditLogActionEnum.stop_soft_delete_user.value,
+ data={"user_id": user_id},
+ )
+ Session.commit()
+
+ return redirect(url_for("admin.email_search.index", query=user_id))
+
+ @expose("/force_soft_delete_user", methods=["POST"])
+ def force_soft_delete_user(self):
+ user_id = request.form.get("user_id")
+ if not user_id:
+ flash("Missing user_id", "error")
+ return redirect(url_for("admin.email_search.index"))
+ try:
+ user_id = int(user_id)
+ except ValueError:
+ flash("Missing user_id", "error")
+ return redirect(url_for("admin.email_search.index", query=user_id))
+ user = User.get(user_id)
+ if user is None:
+ flash("User not found", "error")
+ return redirect(url_for("admin.email_search.index", query=user_id))
+ if user.delete_on is None:
+ flash("User is not pending deletion", "error")
+ return redirect(url_for("admin.email_search.index", query=user_id))
+ User.delete(user.id)
+ AdminAuditLog.create(
+ admin_user_id=current_user.id,
+ model=User.__class__.__name__,
+ model_id=user.id,
+ action=AuditLogActionEnum.delete_object.value,
+ data={"user_id": user_id},
+ )
+ Session.commit()
+
+ return redirect(url_for("admin.email_search.index", query=user_id))
+
class CustomDomainWithValidationData:
def __init__(self, domain: CustomDomain):
diff --git a/app/api/views/user.py b/app/api/views/user.py
index 700792319..af6cb17c9 100644
--- a/app/api/views/user.py
+++ b/app/api/views/user.py
@@ -1,12 +1,9 @@
from flask import jsonify, g
-from sqlalchemy_utils.types.arrow import arrow
from app.api.base import api_bp, require_api_sudo, require_api_auth
-from app.constants import JobType
from app.extensions import limiter
-from app.log import LOG
-from app.models import Job, ApiToCookieToken
-from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
+from app.models import ApiToCookieToken
+from app.user_utils import soft_delete_user
@api_bp.route("/user", methods=["DELETE"])
@@ -14,21 +11,8 @@
def delete_user():
"""
Delete the user. Requires sudo mode.
-
"""
- # Schedule delete account job
- emit_user_audit_log(
- user=g.user,
- action=UserAuditLogAction.UserMarkedForDeletion,
- message=f"Marked user {g.user.id} ({g.user.email}) for deletion from API",
- )
- LOG.w("schedule delete account job for %s", g.user)
- Job.create(
- name=JobType.DELETE_ACCOUNT.value,
- payload={"user_id": g.user.id},
- run_at=arrow.now(),
- commit=True,
- )
+ soft_delete_user(g.user, "API")
return jsonify(ok=True)
diff --git a/app/auth/views/login.py b/app/auth/views/login.py
index ffa09a6a6..624c92f86 100644
--- a/app/auth/views/login.py
+++ b/app/auth/views/login.py
@@ -64,7 +64,7 @@ def login():
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}",
+ "Email or password incorrect",
"error",
)
LoginEvent(LoginEvent.ActionType.scheduled_to_be_deleted).send()
diff --git a/app/dashboard/views/delete_account.py b/app/dashboard/views/delete_account.py
index b3ef1b4d6..c80f75a96 100644
--- a/app/dashboard/views/delete_account.py
+++ b/app/dashboard/views/delete_account.py
@@ -1,14 +1,11 @@
-import arrow
from flask import flash, redirect, url_for, request, render_template
from flask_login import login_required, current_user
from flask_wtf import FlaskForm
-from app.constants import JobType
from app.dashboard.base import dashboard_bp
from app.dashboard.views.enter_sudo import sudo_required
-from app.log import LOG
-from app.models import Subscription, Job
-from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
+from app.models import Subscription
+from app.user_utils import soft_delete_user
class DeleteDirForm(FlaskForm):
@@ -32,25 +29,8 @@ def delete_account():
flash("Please cancel your current subscription first", "warning")
return redirect(url_for("dashboard.setting"))
- # Schedule delete account job
- LOG.w("schedule delete account job for %s", current_user)
- emit_user_audit_log(
- user=current_user,
- action=UserAuditLogAction.UserMarkedForDeletion,
- message=f"User {current_user.id} ({current_user.email}) marked for deletion via webapp",
- )
- Job.create(
- name=JobType.DELETE_ACCOUNT.value,
- payload={"user_id": current_user.id},
- run_at=arrow.now(),
- commit=True,
- )
-
- flash(
- "Your account deletion has been scheduled. "
- "You'll receive an email when the deletion is finished",
- "info",
- )
+ soft_delete_user(current_user, "webapp")
+ flash("Your account deletion has been scheduled", "info")
return redirect(url_for("dashboard.setting"))
return render_template("dashboard/delete_account.html", delete_form=delete_form)
diff --git a/app/models.py b/app/models.py
index b0526d83a..1435cea2a 100644
--- a/app/models.py
+++ b/app/models.py
@@ -242,6 +242,7 @@ class AuditLogActionEnum(EnumE):
stop_trial = 11
unlink_user = 12
delete_custom_domain = 13
+ stop_soft_delete_user = 14
class Phase(EnumE):
diff --git a/app/user_utils.py b/app/user_utils.py
new file mode 100644
index 000000000..9be3adeec
--- /dev/null
+++ b/app/user_utils.py
@@ -0,0 +1,20 @@
+import arrow
+
+from app.db import Session
+from app.log import LOG
+from app.models import User, ApiKey
+from app.session import logout_session
+from app.user_audit_log_utils import emit_user_audit_log, UserAuditLogAction
+
+
+def soft_delete_user(user: User, source: str):
+ LOG.i(f"Marked user {user} for soft-deletion from {source}")
+ emit_user_audit_log(
+ user=user,
+ action=UserAuditLogAction.UserMarkedForDeletion,
+ message=f"Marked user {user} ({user.email}) for deletion from {source}",
+ )
+ user.delete_on = arrow.utcnow()
+ ApiKey.filter_by(user_id=user.id).delete()
+ Session.commit()
+ logout_session()
diff --git a/email_handler.py b/email_handler.py
index 5f0f6805b..a85888d69 100644
--- a/email_handler.py
+++ b/email_handler.py
@@ -632,7 +632,7 @@ def handle_forward(envelope, msg: Message, rcpt_to: str) -> List[Tuple[bool, str
if reply_contact:
reply_to_contact.append(reply_contact)
- if alias.user.delete_on is not None:
+ if not alias.user.is_active():
LOG.d(f"user {user} is pending to be deleted. Do not forward")
EmailLog.create(
contact_id=contact.id,
diff --git a/templates/admin/abuser_lookup.html b/templates/admin/abuser_lookup.html
index f58c794e6..0e53e2b17 100644
--- a/templates/admin/abuser_lookup.html
+++ b/templates/admin/abuser_lookup.html
@@ -1,152 +1,163 @@
{% extends 'admin/master.html' %}
{% macro show_user_overview(bundle) -%}
-
Overview
-
-
-
- User ID
- Primary email address
- User created
-
-
-
-
-
- {% if bundle.get('user', None) %}
- {{ bundle.get('user').id }}
- {% else %}
- {{ bundle.get('account_id') }}
- {% endif %}
-
-
- {% if bundle.get('user', None) %}
- {{ bundle.get('user').email }}
- {% else %}
- {{ bundle.get('email', '') }}
- {% endif %}
-
- {{ bundle.get('user_created_at', '').strftime('%B %d, %Y %I:%M %p') }}
-
-
-
+ Overview
+
+
+
+ User ID
+ Primary email address
+ User created
+
+
+
+
+
+ {% if bundle.get('user', None) %}
+
+ {{ bundle.get("user").id }}
+ {% else %}
+ {{ bundle.get("account_id") }}
+ {% endif %}
+
+
+ {% if bundle.get('user', None) %}
+
+ {{ bundle.get("user").email }}
+ {% else %}
+ {{ bundle.get('email', '') }}
+ {% endif %}
+
+ {{ bundle.get('user_created_at', '').strftime('%B %d, %Y %I:%M %p') }}
+
+
+
{%- endmacro %}
{% macro show_emails_table(emails) -%}
-
-
+
+
+
+ #
+ Email address
+ Date created
+
+
+
+ {% for idx, email in emails|enumerate %}
+
- #
- Email address
- Date created
+ {{ idx + 1 }}
+ {{ email.get('address', '') }}
+ {{ email.get('created_at', '').strftime('%B %d, %Y %I:%M %p') }}
-
-
- {% for idx, email in emails|enumerate %}
-
- {{ idx + 1 }}
- {{ email.get('address', '') }}
- {{ email.get('created_at', '').strftime('%B %d, %Y %I:%M %p') }}
-
- {% endfor %}
-
-
+ {% endfor %}
+
+
{%- endmacro %}
{% macro show_bundle(no, bundle) -%}
- Bundle #{{ no + 1 }}
- {{ show_user_overview(bundle) }}
- {% if bundle.get('aliases', []) %}
- List of aliases
- {{ show_emails_table(bundle.get('aliases', [])) }}
- {% endif %}
- {% if bundle.get('mailboxes', []) %}
- List of mailboxes
- {{ show_emails_table(bundle.get('mailboxes', [])) }}
- {% endif %}
-
-
- Copy bundle
-
-
-
+ Bundle #{{ no + 1 }}
+ {{ show_user_overview(bundle) }}
+ {% if bundle.get('aliases', []) %}
+
+ List of aliases
+ {{ show_emails_table(bundle.get('aliases', []) ) }}
+ {% endif %}
+ {% if bundle.get('mailboxes', []) %}
+
+ List of mailboxes
+ {{ show_emails_table(bundle.get('mailboxes', []) ) }}
+ {% endif %}
+
+
+ Copy bundle
+
+
+
{%- endmacro %}
{% macro show_bundles(bundles) -%}
-
- {% for idx, bundle in bundles|enumerate %}
-
- {{ show_bundle(idx, bundle) }}
-
- {% endfor %}
-
+
+ {% for idx, bundle in bundles|enumerate %}
{{ show_bundle(idx, bundle) }}
{% endfor %}
+
{%- endmacro %}
{% macro show_audit_log(audit_log) -%}
- {% if audit_log and audit_log|length > 0 %}
-
- Toggle audit log
-
-
-
-
-
-
-
-
- #
- Action
- Message
- Date created
- Admin User ID
-
-
-
- {% for idx, log in audit_log|enumerate %}
-
- {{ idx + 1 }}
-
- {{ log.get('action', '') }}
-
- {{ log.get('message', '') }}
- {{ log.get('created_at', '').strftime('%B %d, %Y %I:%M %p') }}
-
- {{ log.admin_id }}
-
-
- {% endfor %}
-
-
-
-
-
-
+ {% if audit_log and audit_log|length > 0 %}
+
+
+ Toggle audit log
+
+
+
+
+
+
+
+
+ #
+ Action
+ Message
+ Date created
+ Admin User ID
+
+
+
+ {% for idx, log in audit_log|enumerate %}
+
+
+ {{ idx + 1 }}
+
+ {{ log.get('action', '') }}
+
+ {{ log.get('message', '') }}
+ {{ log.get('created_at', '').strftime('%B %d, %Y %I:%M %p') }}
+
+ {{ log.admin_id }}
+
+
+ {% endfor %}
+
+
+
+
- {% endif %}
+
+
+ {% endif %}
{%- endmacro %}
{% block body %}
+
+ {% if data.no_match and query %}
+
+
No abuser data was found for the provided email address.
+ {% endif %}
+ {% if data.bundles %}
+
- {% if data.no_match and query %}
-
No abuser data was found for the provided email address.
-
- {% endif %}
- {% if data.bundles %}
-
-
Found abuser data for {{ data.query }}
- {{ show_audit_log(data.audit_log) }}
- {{ show_bundles(data.bundles) }}
-
- {% endif %}
+
+ Found abuser data for {{ data.query }}
+
+ {{ show_audit_log(data.audit_log) }}
+ {{ show_bundles(data.bundles) }}
+ {% endif %}
+
{% endblock %}
diff --git a/templates/admin/custom_domain_search.html b/templates/admin/custom_domain_search.html
index e3fefb2fa..19d9fdcbf 100644
--- a/templates/admin/custom_domain_search.html
+++ b/templates/admin/custom_domain_search.html
@@ -135,18 +135,17 @@ {{ title }}
{% macro show_domain(domain_with_data) -%}
-
{% if domain_with_data.domain.pending_deletion == True %}
-
-
Domain {{ domain_with_data.domain.domain }}
- Scheduled for deletion
-
+
+
+
Domain {{ domain_with_data.domain.domain }}
+ Scheduled for deletion
+
{% else %}
-
-
Domain {{ domain_with_data.domain.domain }}
-
+
+
Domain {{ domain_with_data.domain.domain }}
+
{% endif %}
-
{% set domain = domain_with_data.domain %}
@@ -158,17 +157,16 @@ Domain {{ domain_with_data.domain.domain }}
{{ show_verification("DKIM {}.{}".format(dkim_domain, domain.domain) , domain_with_data.dkim_expected[dkim_domain], [domain_with_data.dkim_validation.get(dkim_domain+"."+domain.domain,'')]) }}
{% endfor %}
-
diff --git a/templates/admin/email_search.html b/templates/admin/email_search.html
index fe52d6cc1..6f24706d9 100644
--- a/templates/admin/email_search.html
+++ b/templates/admin/email_search.html
@@ -39,15 +39,35 @@
User {{ user.email }} with ID {{ user.id }}.
{% endif %}
{% if user.delete_on %}
-
{{ user.delete_on }}
+
+ {{ user.delete_on.format("YYYY-MM-DD HH:mm:ss") }}
+
+
+
+
+
{% else %}
None
{% endif %}
{{ "yes" if user.is_paid() else "No" }}
{{ "yes" if user.is_premium() else "No" }}
{{ user.get_active_subscription() }}
-
{{ user.created_at }}
-
{{ user.updated_at }}
+
{{ user.created_at.format("YYYY-MM-DD HH:mm:ss") }}
+
{{ user.updated_at.format("YYYY-MM-DD HH:mm:ss") }}
{% if pu %}
@@ -91,7 +111,7 @@
{{ "Yes" if mailbox.verified else "No" }}
-
{{ mailbox.created_at }}
+
{{ mailbox.created_at.format("YYYY-MM-DD HH:mm:ss") }}
{% endfor %}
@@ -120,7 +140,7 @@
{{ alias.email }}
{{ "Yes" if alias.enabled else "No" }}
- {{ alias.created_at }}
+ {{ alias.created_at.format("YYYY-MM-DD HH:mm:ss") }}
{% endfor %}
@@ -141,7 +161,7 @@ Deleted Alias {{ deleted_alias.email }} with ID {{ deleted_alias.id }}.
{{ deleted_alias.id }}
{{ deleted_alias.email }}
- {{ deleted_alias.created_at }}
+ {{ deleted_alias.created_at.format("YYYY-MM-DD HH:mm:ss") }}
{{ deleted_alias.reason }}
@@ -171,7 +191,7 @@
{{ dom_deleted_alias.domain.domain }}
{{ dom_deleted_alias.domain.id }}
{{ dom_deleted_alias.domain.user_id }}
- {{ dom_deleted_alias.created_at }}
+ {{ dom_deleted_alias.created_at.format("YYYY-MM-DD HH:mm:ss") }}
@@ -201,7 +221,7 @@ Alias Audit Log
{{ entry.action }}
{{ entry.message }}
- {{ entry.created_at }}
+ {{ entry.created_at.format("YYYY-MM-DD HH:mm:ss") }}
{% endfor %}
@@ -227,7 +247,7 @@ User Audit Log
{{ entry.action }}
{{ entry.message }}
- {{ entry.created_at }}
+ {{ entry.created_at.format("YYYY-MM-DD HH:mm:ss") }}
{% endfor %}
diff --git a/templates/dashboard/delete_account.html b/templates/dashboard/delete_account.html
index d708bec0a..e4e16e643 100644
--- a/templates/dashboard/delete_account.html
+++ b/templates/dashboard/delete_account.html
@@ -7,14 +7,16 @@
Account Deletion
-
- Once an account is deleted, it can't be restored.
- All its records (aliases, domains, settings, etc.) are immediately deleted.
+
+ Once an account is deleted, it can't be restored. You will lose access immediately to all the data stored in this account (aliases, domains, settings, etc.)
+
+
+ You will not be able to create another account with the same email for some time.
diff --git a/templates/dashboard/setting.html b/templates/dashboard/setting.html
index 60a68796f..f8d332b64 100644
--- a/templates/dashboard/setting.html
+++ b/templates/dashboard/setting.html
@@ -488,9 +488,9 @@
Move to Trash
+ selected="selected"{% endif %}>Move to Trash
Delete immediately
+ selected="selected"{% endif %}>Delete immediately
Update
diff --git a/tests/api/test_user.py b/tests/api/test_user.py
index 76bc3b851..3ea82cf7c 100644
--- a/tests/api/test_user.py
+++ b/tests/api/test_user.py
@@ -2,9 +2,8 @@
from flask import url_for
-from app.constants import JobType
from app.db import Session
-from app.models import Job, ApiToCookieToken
+from app.models import Job, ApiToCookieToken, User, ApiKey
from tests.api.utils import get_new_user_and_api_key
@@ -45,11 +44,9 @@ def test_delete_with_sudo(flask_client):
)
assert r.status_code == 200
- jobs = Job.all()
- assert len(jobs) == 1
- job = jobs[0]
- assert job.name == JobType.DELETE_ACCOUNT.value
- assert job.payload == {"user_id": user.id}
+ db_user = User.get(user.id)
+ assert db_user.delete_on is not None
+ assert ApiKey.filter_by(user_id=db_user.id).count() == 0
def test_get_cookie_token(flask_client):