info@altius-group.ch
Froideville, Vaud
IT

The MX you must not publish yet

/ 12 min di lettura / aggiornato 04.09.2026

Every tenant on names.legal gets a domain. Some of them want mailboxes on it — contact@cabinet-martin.names.legal, a real inbox with IMAP and a webmail.

Provisioning that looks like an API call. It is two systems that have to agree, in an order, and getting the order wrong destroys mail that will never be retried.

The rule the whole feature is built around

def provision_domain_dns(tenant_domain, api=None):
    """Chemin nominal après enregistrement : zone + enregistrements site + push.

    Volontairement SANS le bloc mail : celui-ci n'est posé qu'à la création
    effective d'une boîte (cf. `seed_mail_records`), pour ne pas publier un MX
    vers un serveur qui ne connaît pas encore le domaine — ce qui ferait
    rebondir les messages entrants.
    """

Deliberately WITHOUT the mail block. When a domain is registered, the platform publishes the records that make the website work — an apex A, a www A — and nothing else. The mail records are published later, at the moment a mailbox actually exists.

The reason is not tidiness. An MX record is an instruction to the entire internet: send this domain’s mail here. A Mailcow that has never heard of cabinet-martin.names.legal will answer a delivery attempt for it with a permanent rejection — 550, relay access denied, no such domain — because that is exactly what a correctly configured mail server does with a domain it does not host.

And a permanent rejection is permanent. The sending server does not retry. It generates a bounce telling the sender the address does not exist. Publishing an MX too early therefore does not create a temporary outage that fixes itself when the mailbox is created twenty minutes later — it tells everyone who wrote during that window that the recipient is not real.

The same ordering is asserted at the call site, so it survives a refactor of either half:

# DNS mail posé automatiquement si le domaine est géré par
# nous (zone OpenSRS). Volontairement APRÈS la création de la
# boîte : publier un MX vers un serveur qui ne connaît pas
# encore le domaine ferait rebondir les messages entrants.
dns_note = _sync_mail_dns(req, dkim_txt)

Mail server first, DNS second. Written twice, in both files, because it is the kind of constraint that looks like an arbitrary sequencing choice to anybody who does not know what a 550 does.

Six records, and one that must not be guessed

def seed_mail_records(tenant_domain, *, dkim_txt=None):
    """Pose le bloc messagerie du domaine vers le Mailcow.

    C'est ce que la plateforme faisait à la main jusqu'ici (un message
    d'admin rappelait à l'opérateur de poser MX/SPF/DKIM) : désormais
    automatique dès qu'une boîte est créée.
    """

The block is MX, SPF, DMARC, two SRV records and — conditionally — DKIM.

The condition on DKIM is the second ordering rule, and it is sharper than the first:

    """
    `dkim_txt` vient de `mailcow.ensure_dkim()`. S'il est absent, le DKIM
    n'est PAS posé (on ne publie pas un sélecteur vide, qui ferait échouer la
    signature de tous les messages).
    """
if dkim_txt:
    records.append(_upsert_managed(
        tenant_domain, DnsRecord.Managed.DKIM,
        record_type=DnsRecord.Type.TXT,
        subdomain=f"{settings.MAIL_DKIM_SELECTOR}._domainkey",
        value=dkim_txt,
    ))
else:
    logger.warning(
        "DKIM non posé pour %s : aucune clé fournie par Mailcow. "
        "La messagerie fonctionnera mais sans signature.",
        tenant_domain.domain_name,
    )

A published-but-wrong DKIM record is worse than no DKIM record at all. With no _domainkey TXT, a verifier finds no key, treats the message as unsigned, and falls back to SPF — which passes. With a TXT containing an empty or wrong public key, every signature fails verification, and a DKIM fail is a much stronger negative signal to a spam filter than an absent signature.

So the fallback is: publish nothing, log a warning, and let mail flow unsigned. The warning says exactly that — mail will work, but unsigned — which tells the operator both that something is wrong and that it is not an emergency.

SPF and DMARC, tuned to be survivable

The two records that people get wrong by being strict:

# SPF : le mécanisme "mx" autorise les hôtes listés en MX (donc MAIL_HOST_FQDN)
# à émettre pour le domaine. On reste en "~all" (softfail) et non "-all" :
# durcir le rejet avant d'être certain de TOUTES les sources d'envoi d'un
# tenant (outil de newsletter, ERP, formulaire tiers) ferait silencieusement
# classer en spam des messages légitimes. À passer en "-all" domaine par
# domaine une fois les sources connues.
MAIL_SPF_RECORD = os.environ.get("MAIL_SPF_RECORD", "v=spf1 mx ~all")
# DMARC en p=none : phase d'observation. Publier p=reject d'emblée ferait
# rejeter les messages de toute source non encore alignée. {domain} est
# substitué par le domaine du tenant.
MAIL_DMARC_RECORD = os.environ.get(
    "MAIL_DMARC_RECORD",
    "v=DMARC1; p=none; rua=mailto:postmaster@{domain}",
)

This is the correct posture for a platform provisioning mail on behalf of someone else, and it is the opposite of what a hardening checklist recommends.

The reason is that you do not know the tenant’s other senders. A law firm on this platform has a newsletter tool, an invoicing product, maybe a booking form on a third-party site — all of them sending as @cabinet-martin.names.legal, none of them known to the platform. -all and p=reject on day one would make every one of those messages disappear, and the tenant would experience it as “the mail you set up broke my invoices”.

~all plus p=none publishes the same information without acting on it. The rua= address collects the reports, which is how you learn the sender list you did not have. Then you tighten, per domain, once you know. The comment says this explicitly — à passer en -all domaine par domaine une fois les sources connues — which turns a permissive default into a documented stage rather than an oversight.

Using the mx mechanism rather than an ip4: literal is the other quiet good choice: SPF stays correct automatically when the mail host moves, because it authorises whatever the MX points at.

SRV instead of autodiscover, for a certificate reason

# Autoconfiguration des clients de messagerie par SRV (et non par
# CNAME autodiscover) : le client se connecte à l'hôte cible et valide
# le certificat contre CE nom, donc pas de dépendance à un certificat
# couvrant le domaine du tenant.
_upsert_managed(
    tenant_domain, DnsRecord.Managed.IMAPS,
    record_type=DnsRecord.Type.SRV, subdomain="_imaps._tcp",
    value=host, port=993, priority=0, weight=1,
),
_upsert_managed(
    tenant_domain, DnsRecord.Managed.SUBMISSION,
    record_type=DnsRecord.Type.SRV, subdomain="_submission._tcp",
    value=host, port=587, priority=0, weight=1,
),

The common way to autoconfigure mail clients is autodiscover.<domain> or autoconfig.<domain> pointing at your server. It works, and it drags a certificate problem behind it: the client connects to a hostname in the tenant’s domain, so your mail server needs a valid certificate for autodiscover.cabinet-martin.names.legal — and for every other tenant, forever, issued and renewed.

_imaps._tcp SRV records avoid it entirely. The record names the target host, the client connects to mail.altiusmail.ch, and TLS is validated against that name. One certificate, on one host, regardless of how many tenant domains exist.

That is a genuinely elegant trade and it is the sort of thing you only find by having been bitten by the certificate side first.

Replaying must not stack records

def _upsert_managed(tenant_domain, managed, **fields):
    """Pose ou met à jour l'enregistrement géré d'un rôle donné.

    Clé sur (domaine, rôle) — cf. la contrainte d'unicité du modèle : rejouer
    une synchronisation ne doit jamais empiler deux SPF ou deux apex.
    """
    record, _ = DnsRecord.objects.update_or_create(
        tenant_domain=tenant_domain, managed=managed, defaults=fields,
    )
    return record

The key is (domain, role) — not (domain, type, name). That choice matters, because type and name do not identify these records: SPF and DMARC are both TXT, apex and www are both A, and the two SRV records differ only by subdomain. The role — “this is the SPF record” — is what stays stable when the value changes.

Two SPF TXT records at the apex is not a redundant configuration, it is a broken one: RFC 7208 says a domain with multiple SPF records produces a permerror, and evaluators treat it as no SPF at all. So an operation that can be replayed — and this one is replayed, by manage.py domain_dns_sync — has to be an upsert keyed on identity, and the identity has to be the role.

The same reasoning explains why user records and managed records are kept apart:

if replace:
    with transaction.atomic():
        tenant_domain.dns_records.filter(managed="").delete()
        DnsRecord.objects.bulk_create(parsed)

Importing a zone deletes only the records with an empty managed — the tenant’s own. The platform’s records survive an import, because a tenant pasting their old zone file must not be able to delete the MX that makes their new mailbox work.

Talking to Mailcow, which returns 200 on failure

def _post(path, payload):
    try:
        r = requests.post(f"{_base_url()}{path}", headers=_headers(),
                          json=payload, timeout=TIMEOUT)
        r.raise_for_status()
        data = r.json()
    except requests.RequestException as exc:
        raise MailcowError(f"Mailcow POST {path}: {exc}") from exc
    # Mailcow renvoie une liste d'items {type: success|danger, msg: [...]}.
    items = data if isinstance(data, list) else [data]
    for item in items:
        if isinstance(item, dict) and item.get("type") == "danger":
            raise MailcowError(f"Mailcow {path}: {item.get('msg')}")
    return data

raise_for_status() is not enough. Mailcow answers 200 OK and reports the actual outcome in the body, as a list of items each carrying type: success or type: danger. An integration that trusts the status code records a mailbox as created when Mailcow refused it.

Two details make this robust rather than merely working. data if isinstance(data, list) else [data] handles both shapes, because the API returns a bare object in some places and a list in others. And the whole thing is wrapped so that every failure — network, HTTP, application-level — arrives at the caller as one exception type:

"""
Toutes les fonctions lèvent MailcowError avec un message actionnable ;
l'appelant (action d'approbation plateforme) décide quoi afficher.
"""

One exception type at a service boundary is worth more than precision here. The caller has exactly one thing to do with any of these failures — show the operator what happened and mark the request failed — and a taxonomy of error classes would be five except branches doing the same thing.

Idempotent, because the retry cannot see the password

The best-reasoned function in the module:

def add_mailbox(local_part, domain, name="", quota_mib=1024, password=None):
    """Crée la boîte (mot de passe temporaire à changer au premier login)
    et renvoie le mot de passe en clair — à remettre UNE fois puis effacer.

    Idempotent (#10) : si la boîte existe déjà (retry après un échec partiel),
    on réinitialise le mot de passe au lieu de re-POSTer /add/mailbox (qui
    échouerait et bloquerait définitivement la demande, en laissant une boîte
    orpheline dont le mot de passe est perdu)."""
    address = f"{local_part}@{domain}"
    if _mailbox_exists(local_part, domain):
        logger.info("mailcow: boîte %s déjà existante — reset du mot de passe "
                    "(idempotence)", address)
        return set_mailbox_password(address, password)

Follow the failure it prevents. The approval action creates the mailbox, Mailcow succeeds, and then the network drops before the response is read. The request row still says PENDING. An operator clicks approve again.

Without the branch, /add/mailbox fails — the address exists — the request goes to FAILED, and there is now a real mailbox on the mail server whose temporary password nobody has and nobody can recover, because it was generated in a process that has since exited. The only fix is manual intervention on Mailcow.

With the branch, the retry resets the password and returns the new one. The key insight is in the docstring: a retry cannot re-read the old temporary password. Idempotency here cannot mean “do nothing if it exists” — the caller needs a password back — so it means “make the state match, and produce a usable credential”.

The creation parameters carry their own reasoning:

_post("/api/v1/add/mailbox", {
    ...
    "quota": str(quota_mib),
    "active": "1",
    "force_pw_update": "1",   # SOGo exige un nouveau mdp au 1er login
    "tls_enforce_in": "1",
    "tls_enforce_out": "1",
})

force_pw_update is what makes handing over a generated password acceptable at all: it is valid for exactly one login. TLS enforced in both directions is a default that should not be a default anywhere in 2026, and is.

And the domain gets reputation guardrails from the start:

"defquota": "1024",     # quota boîte par défaut (MiB)
"maxquota": "3072",
"quota": "10240",       # quota total du domaine
"mailboxes": "10",
"aliases": "20",

Caps on a shared mail host are not about storage. They are about the day a tenant’s credentials leak and someone starts sending: ten mailboxes and twenty aliases bound the blast radius of an account takeover on infrastructure whose IP reputation every other tenant depends on.

The unmanaged case, answered rather than refused

if td is None:
    return (
        f"⚠️ {req.domain} n'est pas un domaine que nous hébergeons : "
        f"à saisir chez son fournisseur DNS — MX 10 "
        f"{settings.MAIL_HOST_FQDN}, TXT « {settings.MAIL_SPF_RECORD} », "
        f"et DKIM {req.dkim_record[:90]}…"
    )

A tenant whose domain is hosted elsewhere cannot have records published for them. Rather than failing, the action returns the exact values to enter at their provider. The mailbox is created either way; only the DNS half is manual.

And the DNS half never fails the request:

"""
Ne lève jamais : la boîte existe déjà côté Mailcow, un échec DNS ne doit
pas faire basculer la demande en FAILED. Le rattrapage se fait par
`domain_dns_sync`.
"""
except Exception as exc:  # noqa: BLE001 — voir docstring
    logger.error("DNS mail %s non posé : %s", req.domain, exc)
    return (f"⚠️ boîte créée mais DNS mail NON posé sur {req.domain} "
            f"({exc}). Relancer : manage.py domain_dns_sync {req.domain}")

The mailbox is the irreversible half; the DNS is the replayable half. Marking the whole request FAILED because the second one failed would hide a mailbox that exists — and the message names the command that fixes it, which is the difference between a warning an operator can act on and one they escalate.

The bare except Exception carries a noqa pointing at the docstring that justifies it. That is the right way to keep a broad catch: not by arguing with the linter, but by leaving the argument where the next reader will find it.

What carries over

  • Publish the MX after the mail server knows the domain. A permanent rejection is not an outage; it is a bounce telling senders the address does not exist.
  • No DKIM beats wrong DKIM. A failing signature is a stronger negative signal than an absent one. Warn and run unsigned.
  • Start at ~all and p=none when you provision for someone else. You do not know their other senders; the rua= reports are how you find out.
  • Prefer SRV autoconfiguration to autodiscover.<tenant> and you never need a certificate in the tenant’s namespace.
  • Key managed records on their role, not their type and name. Two SPF records at an apex is a permerror, not redundancy.
  • Check the body, not the status code, on any API that reports outcomes in its payload.
  • Idempotency must return what the caller needs. “Do nothing if it exists” strands a mailbox nobody can log into.
Pronto a cominciare?

Parliamo del suo progetto

Ci racconti le sue esigenze in IoT, GIS o sviluppo su misura — le rispondiamo entro 24 ore.