Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,8 @@ def facebook_enabled():
ALERT_MAILBOX_IS_ALIAS = "mailbox_is_alias"

AlERT_WRONG_MX_RECORD_CUSTOM_DOMAIN = "custom_domain_mx_record_issue"
ALERT_WRONG_DKIM_RECORD_CUSTOM_DOMAIN = "custom_domain_dkim_record_issue"
ALERT_WRONG_DMARC_RECORD_CUSTOM_DOMAIN = "custom_domain_dmarc_record_issue"

# alert when a new alias is about to be created on a disabled directory
ALERT_DIRECTORY_DISABLED_ALIAS_CREATION = "alert_directory_disabled_alias_creation"
Expand Down
13 changes: 12 additions & 1 deletion app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -2618,13 +2618,24 @@ class CustomDomain(Base, ModelMixin):
sa.Boolean, nullable=False, default=False, server_default="0"
)

# incremented when a check is failed on the domain
# incremented when the MX check is failed on the domain
# alert when the number exceeds a threshold
# used in check_custom_domain()
nb_failed_checks = sa.Column(
sa.Integer, default=0, server_default="0", nullable=False
)

dkim_nb_failed_checks = sa.Column(
sa.Integer, default=0, server_default="0", nullable=False
)
dkim_nb_failed_checks_updated_at = sa.Column(ArrowType, default=None, nullable=True)
dmarc_nb_failed_checks = sa.Column(
sa.Integer, default=0, server_default="0", nullable=False
)
dmarc_nb_failed_checks_updated_at = sa.Column(
ArrowType, default=None, nullable=True
)

# only domain has the ownership verified can go the next DNS step
# MX verified domains before this change don't have to do the TXT check
# and therefore have ownership_verified=True
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Add independent DKIM/DMARC debounce columns to custom_domain

Revision ID: b7c1a9d3e2f4
Revises: 4a9f8c2e1b3d
Create Date: 2026-09-04 12:00:00.000000

"""
import sqlalchemy_utils
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = "b7c1a9d3e2f4"
down_revision = "4a9f8c2e1b3d"
branch_labels = None
depends_on = None


def upgrade():
op.add_column(
"custom_domain",
sa.Column(
"dkim_nb_failed_checks", sa.Integer(), server_default="0", nullable=False
),
)
op.add_column(
"custom_domain",
sa.Column(
"dkim_nb_failed_checks_updated_at",
sqlalchemy_utils.types.arrow.ArrowType(),
nullable=True,
),
)
op.add_column(
"custom_domain",
sa.Column(
"dmarc_nb_failed_checks", sa.Integer(), server_default="0", nullable=False
),
)
op.add_column(
"custom_domain",
sa.Column(
"dmarc_nb_failed_checks_updated_at",
sqlalchemy_utils.types.arrow.ArrowType(),
nullable=True,
),
)


def downgrade():
op.drop_column("custom_domain", "dmarc_nb_failed_checks_updated_at")
op.drop_column("custom_domain", "dmarc_nb_failed_checks")
op.drop_column("custom_domain", "dkim_nb_failed_checks_updated_at")
op.drop_column("custom_domain", "dkim_nb_failed_checks")
163 changes: 131 additions & 32 deletions tasks/check_custom_domains.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from dataclasses import dataclass

import arrow
from sqlalchemy.orm.exc import ObjectDeletedError

Expand All @@ -6,8 +8,38 @@
from app.db import Session
from app.dns_utils import get_mx_domains
from app.email_utils import send_email_with_rate_control, render
from app.errors import ProtonPartnerNotSetUp
from app.log import LOG
from app.models import CustomDomain, Alias
from app.proton.proton_partner import get_proton_partner


@dataclass
class RecordAlertConfig:
record_name: str
alert_type: str
template: str
subject_infix: str


MX_ALERT = RecordAlertConfig(
record_name="MX",
alert_type=config.AlERT_WRONG_MX_RECORD_CUSTOM_DOMAIN,
template="transactional/custom-domain-dns-issue.txt.jinja2",
subject_infix="",
)
DKIM_ALERT = RecordAlertConfig(
record_name="DKIM",
alert_type=config.ALERT_WRONG_DKIM_RECORD_CUSTOM_DOMAIN,
template="transactional/custom-domain-dkim-issue.txt.jinja2",
subject_infix="DKIM ",
)
DMARC_ALERT = RecordAlertConfig(
record_name="DMARC",
alert_type=config.ALERT_WRONG_DMARC_RECORD_CUSTOM_DOMAIN,
template="transactional/custom-domain-dmarc-issue.txt.jinja2",
subject_infix="DMARC ",
)


def check_all_custom_domains():
Expand Down Expand Up @@ -54,60 +86,127 @@ def check_all_custom_domains():
Session.close()


def _send_alert(
custom_domain: CustomDomain,
user,
domain_dns_url: str,
provider: str,
cfg: RecordAlertConfig,
):
LOG.w(
"Alert domain %s check fails %s about %s", cfg.record_name, user, custom_domain
)
send_email_with_rate_control(
user,
cfg.alert_type,
user.email,
f"Please update {custom_domain.domain} {cfg.subject_infix}DNS on {provider}",
render(
cfg.template,
user=user,
custom_domain=custom_domain,
domain_dns_url=domain_dns_url,
),
max_nb_alert=1,
nb_day=30,
retries=3,
)


def check_single_custom_domain(custom_domain: CustomDomain):
if custom_domain.is_sl_subdomain:
return
if custom_domain.user.disabled:
return
user = custom_domain.user
# snapshot before validate_dkim_records()/validate_dmarc_records() below can commit
# and bump these, which would otherwise throw off the once-a-day throttles
mx_last_updated_at = custom_domain.updated_at
dkim_last_updated_at = custom_domain.dkim_nb_failed_checks_updated_at
dmarc_last_updated_at = custom_domain.dmarc_nb_failed_checks_updated_at

mx_domains = get_mx_domains(custom_domain.domain)
validator = CustomDomainValidation(
dkim_domain=config.EMAIL_DOMAIN,
partner_domains=config.PARTNER_DNS_CUSTOM_DOMAINS,
partner_domains_validation_prefixes=config.PARTNER_CUSTOM_DOMAIN_VALIDATION_PREFIXES,
)
expected_custom_domains = validator.get_expected_mx_records(custom_domain)
if not is_mx_equivalent(mx_domains, expected_custom_domains):
user = custom_domain.user
mx_ok = is_mx_equivalent(mx_domains, expected_custom_domains)

dkim_errors = validator.validate_dkim_records(custom_domain)
dkim_ok = len(dkim_errors) == 0
dmarc_ok = validator.validate_dmarc_records(custom_domain).success

domain_dns_url = f"{config.URL}/dashboard/domains/{custom_domain.id}/dns"
try:
is_proton_domain = custom_domain.partner_id == get_proton_partner().id
except ProtonPartnerNotSetUp:
is_proton_domain = False
provider = "Proton" if is_proton_domain else "SimpleLogin"
now = arrow.now()

if mx_ok:
custom_domain.nb_failed_checks = 0
else:
LOG.w(
f"The MX record is not correctly set for domain {custom_domain} of user {user}. Got {mx_domains}. Retried {custom_domain.nb_failed_checks} days",
f"MX check failed for domain {custom_domain} of user {user}. "
f"Retried {custom_domain.nb_failed_checks} days",
)

if (
not custom_domain.updated_at
or custom_domain.updated_at <= arrow.now().shift(days=-1)
):
# Only update it once a day
if not mx_last_updated_at or mx_last_updated_at <= now.shift(days=-1):
custom_domain.nb_failed_checks += 1

# send alert if fail for MAX_DOMAIN_CHECKS consecutive days
if custom_domain.nb_failed_checks > config.MAX_DOMAIN_CHECKS:
domain_dns_url = f"{config.URL}/dashboard/domains/{custom_domain.id}/dns"
LOG.w("Alert domain MX check fails %s about %s", user, custom_domain)
send_email_with_rate_control(
user,
config.AlERT_WRONG_MX_RECORD_CUSTOM_DOMAIN,
user.email,
f"Please update {custom_domain.domain} DNS on SimpleLogin",
render(
"transactional/custom-domain-dns-issue.txt.jinja2",
user=user,
custom_domain=custom_domain,
domain_dns_url=domain_dns_url,
),
max_nb_alert=1,
nb_day=30,
retries=3,
)
_send_alert(custom_domain, user, domain_dns_url, provider, MX_ALERT)
LOG.w(
"De-verifying domain %s after %d failed MX checks",
custom_domain,
custom_domain.nb_failed_checks,
)
# reset domain
custom_domain.verified = False
custom_domain.dkim_verified = False
custom_domain.dmarc_verified = False
custom_domain.spf_verified = False
custom_domain.nb_failed_checks = 0

if dkim_ok:
custom_domain.dkim_nb_failed_checks = 0
else:
# reset checks
custom_domain.nb_failed_checks = 0
LOG.w(
f"DKIM check failed for domain {custom_domain} of user {user}. "
f"Retried {custom_domain.dkim_nb_failed_checks} days",
)
if not dkim_last_updated_at or dkim_last_updated_at <= now.shift(days=-1):
custom_domain.dkim_nb_failed_checks += 1
custom_domain.dkim_nb_failed_checks_updated_at = now

if custom_domain.dkim_nb_failed_checks > config.MAX_DOMAIN_CHECKS:
_send_alert(custom_domain, user, domain_dns_url, provider, DKIM_ALERT)
LOG.w(
"Un-verifying DKIM for domain %s after %d failed checks",
custom_domain,
custom_domain.dkim_nb_failed_checks,
)
custom_domain.dkim_verified = False
custom_domain.dkim_nb_failed_checks = 0

if dmarc_ok:
custom_domain.dmarc_nb_failed_checks = 0
else:
LOG.w(
f"DMARC check failed for domain {custom_domain} of user {user}. "
f"Retried {custom_domain.dmarc_nb_failed_checks} days",
)
if not dmarc_last_updated_at or dmarc_last_updated_at <= now.shift(days=-1):
custom_domain.dmarc_nb_failed_checks += 1
custom_domain.dmarc_nb_failed_checks_updated_at = now

if custom_domain.dmarc_nb_failed_checks > config.MAX_DOMAIN_CHECKS:
_send_alert(custom_domain, user, domain_dns_url, provider, DMARC_ALERT)
LOG.w(
"Un-verifying DMARC for domain %s after %d failed checks",
custom_domain,
custom_domain.dmarc_nb_failed_checks,
)
custom_domain.dmarc_verified = False
custom_domain.dmarc_nb_failed_checks = 0

Session.commit()
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{% extends "base.txt.jinja2" %}

{% block content %}
We have detected that the DKIM records for your domain {{ custom_domain.domain }} are no longer correctly set up.

To fix this, please update the DKIM CNAME records and re-run the DNS check at {{ domain_dns_url }}.

Until this is fixed, emails sent from {{ custom_domain.domain }} may not be signed correctly and could be marked as spam.
{% endblock %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{% extends "base.txt.jinja2" %}

{% block content %}
We have detected that the DMARC record for your domain {{ custom_domain.domain }} is no longer correctly set up.

To fix this, please update the DMARC TXT record and re-run the DNS check at {{ domain_dns_url }}.
{% endblock %}
Loading
Loading