diff --git a/app/admin/__init__.py b/app/admin/__init__.py
index 7d434aa5a..2ae406103 100644
--- a/app/admin/__init__.py
+++ b/app/admin/__init__.py
@@ -28,6 +28,7 @@
CustomDomainSearchAdmin,
)
from app.admin.abuser_lookup import AbuserLookupResult, AbuserLookupAdmin
+from app.admin.domain_check import DomainCheckAdmin
__all__ = [
# Initialization
@@ -62,4 +63,5 @@
"CustomDomainSearchAdmin",
"AbuserLookupResult",
"AbuserLookupAdmin",
+ "DomainCheckAdmin",
]
diff --git a/app/admin/domain_check.py b/app/admin/domain_check.py
new file mode 100644
index 000000000..5d342eeae
--- /dev/null
+++ b/app/admin/domain_check.py
@@ -0,0 +1,23 @@
+from __future__ import annotations
+
+from flask import request
+from flask_admin import expose
+
+from app.admin.base import BaseAdminView
+from app.email_utils import check_domain_for_mailbox, MailboxDomainCheckResult
+
+
+class DomainCheckAdmin(BaseAdminView):
+ @expose("/", methods=["GET"])
+ def index(self):
+ domain = request.args.get("domain", "").strip()
+ result: MailboxDomainCheckResult | None = None
+
+ if domain:
+ result = check_domain_for_mailbox(domain)
+
+ return self.render(
+ "admin/domain_check.html",
+ domain=domain,
+ result=result,
+ )
diff --git a/app/admin/index.py b/app/admin/index.py
index 46cb6780f..255bdcb9c 100644
--- a/app/admin/index.py
+++ b/app/admin/index.py
@@ -34,6 +34,7 @@
from app.admin.email_search import EmailSearchAdmin
from app.admin.custom_domain_search import CustomDomainSearchAdmin
from app.admin.abuser_lookup import AbuserLookupAdmin
+from app.admin.domain_check import DomainCheckAdmin
def init_admin(app: Flask):
@@ -49,6 +50,7 @@ def init_admin(app: Flask):
admin.add_view(
AbuserLookupAdmin(name="Abuser Lookup", endpoint="admin.abuser_lookup")
)
+ admin.add_view(DomainCheckAdmin(name="Domain Check", endpoint="admin.domain_check"))
admin.add_view(UserAdmin(User, Session))
admin.add_view(AliasAdmin(Alias, Session))
admin.add_view(MailboxAdmin(Mailbox, Session))
diff --git a/app/email_utils.py b/app/email_utils.py
index 3a2e62b82..c4f4fbcf3 100644
--- a/app/email_utils.py
+++ b/app/email_utils.py
@@ -603,60 +603,118 @@ class EmailCannotBeUsedReason(enum.Enum):
MailboxOfDisabledUser = "This email address is not allowed"
-def email_can_be_used_as_mailbox_with_reason(
- email_address: str,
-) -> Optional[EmailCannotBeUsedReason]:
- try:
- domain = validate_email(
- email_address, check_deliverability=False, allow_smtputf8=False
- ).domain
- except EmailNotValidError:
- LOG.d("%s is invalid email address", email_address)
- return EmailCannotBeUsedReason.InvalidEmailAddress
+class MailboxDomainCheckResult:
+ """Detailed result of a domain-level mailbox check, for admin use."""
+
+ def __init__(
+ self,
+ can_be_used: bool,
+ reason: Optional[EmailCannotBeUsedReason] = None,
+ detail: Optional[str] = None,
+ ):
+ self.can_be_used = can_be_used
+ self.reason = reason
+ self.detail = detail
- if not domain:
- LOG.d("no valid domain associated to %s", email_address)
- return EmailCannotBeUsedReason.InvalidEmailDomain
- if SLDomain.get_by(domain=domain):
- LOG.d("%s is a SL domain", email_address)
- return EmailCannotBeUsedReason.IsSimpleLoginDomain
+def check_domain_for_mailbox(domain: str) -> MailboxDomainCheckResult:
+ """Check whether a domain can be used for mailboxes.
- from app.models import CustomDomain
+ This is the single source of truth for domain-level mailbox checks.
+ Returns a MailboxDomainCheckResult with a human-readable detail string.
+ """
+ if not domain or "." not in domain:
+ return MailboxDomainCheckResult(
+ can_be_used=False,
+ reason=EmailCannotBeUsedReason.InvalidEmailDomain,
+ detail=f"'{domain}' is not a valid domain name.",
+ )
+
+ if SLDomain.get_by(domain=domain):
+ LOG.d("domain %s is a SL domain", domain)
+ return MailboxDomainCheckResult(
+ can_be_used=False,
+ reason=EmailCannotBeUsedReason.IsSimpleLoginDomain,
+ detail=f"'{domain}' is a SimpleLogin alias domain and cannot be used for mailboxes.",
+ )
custom_domain = CustomDomain.get_by(domain=domain, verified=True)
if custom_domain is not None:
LOG.d("domain %s is custom domain %s", domain, custom_domain)
- return EmailCannotBeUsedReason.IsCustomDomain
+ return MailboxDomainCheckResult(
+ can_be_used=False,
+ reason=EmailCannotBeUsedReason.IsCustomDomain,
+ detail=f"'{domain}' is a verified custom domain (ID: {custom_domain.id}, owned by user ID: {custom_domain.user_id}).",
+ )
if is_invalid_mailbox_domain(domain):
- LOG.d("Domain %s is invalid mailbox domain", domain)
- return EmailCannotBeUsedReason.InvalidMailboxDomain
+ LOG.d("domain %s is an invalid mailbox domain", domain)
+ return MailboxDomainCheckResult(
+ can_be_used=False,
+ reason=EmailCannotBeUsedReason.InvalidMailboxDomain,
+ detail=f"'{domain}' is listed as an invalid mailbox domain.",
+ )
- # check if email MX domain is disposable
mx_domains = get_mx_domain_list(domain)
- # if no MX record, email is not valid
if not config.SKIP_MX_LOOKUP_ON_CHECK and not mx_domains:
- LOG.d("No MX record for domain %s", domain)
- return EmailCannotBeUsedReason.NoMxRecordFound
+ LOG.d("no MX record for domain %s", domain)
+ return MailboxDomainCheckResult(
+ can_be_used=False,
+ reason=EmailCannotBeUsedReason.NoMxRecordFound,
+ detail=f"No MX records found for '{domain}'.",
+ )
mx_ips = set()
for mx_domain in mx_domains:
if is_invalid_mailbox_domain(mx_domain):
- LOG.d("MX Domain %s %s is invalid mailbox domain", mx_domain, domain)
- return EmailCannotBeUsedReason.InvalidMailboxDomain
+ LOG.d("MX domain %s for %s is an invalid mailbox domain", mx_domain, domain)
+ return MailboxDomainCheckResult(
+ can_be_used=False,
+ reason=EmailCannotBeUsedReason.InvalidMailboxDomain,
+ detail=f"MX domain '{mx_domain}' (used by '{domain}') is listed as an invalid mailbox domain.",
+ )
a_record = get_a_record(mx_domain)
- LOG.i(
- f"Found MX Domain {mx_domain} for mailbox {email_address} with a record {a_record}"
- )
+ LOG.i("Found MX domain %s for %s with A record %s", mx_domain, domain, a_record)
if a_record is not None:
mx_ips.add(a_record)
- if len(mx_ips) > 0:
- forbidden_ip = ForbiddenMxIp.filter(ForbiddenMxIp.ip.in_(list(mx_ips))).all()
- if forbidden_ip:
- LOG.i("Found forbidden MX ip %s", forbidden_ip)
- return EmailCannotBeUsedReason.ForbiddenMxRecordFound
+
+ if mx_ips:
+ forbidden = ForbiddenMxIp.filter(ForbiddenMxIp.ip.in_(list(mx_ips))).all()
+ if forbidden:
+ forbidden_str = ", ".join(fi.ip for fi in forbidden)
+ LOG.i("Found forbidden MX IPs for domain %s: %s", domain, forbidden_str)
+ return MailboxDomainCheckResult(
+ can_be_used=False,
+ reason=EmailCannotBeUsedReason.ForbiddenMxRecordFound,
+ detail=f"Forbidden MX IP(s) detected for '{domain}': {forbidden_str}.",
+ )
+
+ mx_list = ", ".join(mx_domains) if mx_domains else "none found (MX check skipped)"
+ return MailboxDomainCheckResult(
+ can_be_used=True,
+ detail=f"'{domain}' can be used as a mailbox domain. MX record(s): {mx_list}.",
+ )
+
+
+def email_can_be_used_as_mailbox_with_reason(
+ email_address: str,
+) -> Optional[EmailCannotBeUsedReason]:
+ try:
+ domain = validate_email(
+ email_address, check_deliverability=False, allow_smtputf8=False
+ ).domain
+ except EmailNotValidError:
+ LOG.d("%s is invalid email address", email_address)
+ return EmailCannotBeUsedReason.InvalidEmailAddress
+
+ if not domain:
+ LOG.d("no valid domain associated to %s", email_address)
+ return EmailCannotBeUsedReason.InvalidEmailDomain
+
+ domain_result = check_domain_for_mailbox(domain)
+ if not domain_result.can_be_used:
+ return domain_result.reason
existing_user = User.get_by(email=email_address)
if existing_user and existing_user.disabled:
@@ -707,7 +765,7 @@ def is_invalid_mailbox_domain(domain):
return False
-def get_mx_domain_list(domain) -> [str]:
+def get_mx_domain_list(domain) -> List[str]:
"""return list of MX domains for a given email.
domain name ends *without* a dot (".") at the end.
"""
diff --git a/templates/admin/domain_check.html b/templates/admin/domain_check.html
new file mode 100644
index 000000000..e1a2adc8c
--- /dev/null
+++ b/templates/admin/domain_check.html
@@ -0,0 +1,225 @@
+{% extends 'admin/master.html' %}
+
+{% block head_css %}
+ {{ super() }}
+
+{% endblock %}
+
+{% block body %}
+
+
+ {# Search Form #}
+
+
+ {# Result #}
+ {% if result is not none and domain %}
+ {% if result.can_be_used %}
+
+
+
+
+
+ | Domain: |
+ {{ domain }} |
+
+
+ | Status: |
+ Allowed |
+
+ {% if result.detail %}
+
+ | Detail: |
+ {{ result.detail }} |
+
+ {% endif %}
+
+
+
+ {% else %}
+
+
+
+
+
+ | Domain: |
+ {{ domain }} |
+
+
+ | Status: |
+ Blocked |
+
+ {% if result.reason %}
+
+ | Reason: |
+ {{ result.reason.name }}{{ result.reason.value }} |
+
+ {% endif %}
+ {% if result.detail %}
+
+ | Detail: |
+ {{ result.detail }} |
+
+ {% endif %}
+
+
+
+ {% endif %}
+ {% endif %}
+
+
+{% endblock %}