Backend architecture

Transactional email from Django with Resend, and the domain's mailbox on Zoho Mail

One domain, two mail services: Resend sends the system mail, Zoho Mail holds the support mailbox. The DNS records and why the two SPF records don't clash, a Celery queue in Django, and SMTP settings kept encrypted in the database, not settings.py.

Resend and Zoho Mail

Two kinds of mail move through my site's domain: what the system sends by itself, such as sign-up and sign-in codes and password-reset links, and people writing to support. Resend sends the first kind; Zoho Mail holds the domain's mailbox. Below, domain.site stands in for the real domain.

Why sending and the mailbox are separate

System mail has to reach the inbox fast; a sign-in code that arrives late or lands in spam keeps a user stuck at the door. Resend is built for transactional sending, over an API or SMTP (Django uses SMTP), but it is not a mailbox. Support needs a real mailbox with webmail, and Zoho Mail's Business Email plan (the Forever Free tier) provides it.

  • noreply@domain.site: sent through Resend; verification codes, password resets, automatic notices.
  • support@domain.site and info@domain.site: Zoho Mail mailboxes for support.

The only place the two have to get along is DNS.

The DNS records

These go into the domain's DNS panel:

TypeNameValuePriorityPurpose
MX@mx.zoho.com10Incoming mail to Zoho
MX@mx2.zoho.com20Incoming mail to Zoho (fallback)
MX@mx3.zoho.com50Incoming mail to Zoho (fallback)
TXT@v=spf1 include:zohomail.com ~all-Zoho's SPF, on the root
TXTsendv=spf1 include:amazonses.com ~all-Resend's SPF, on the send subdomain
TXTzoho._domainkeyv=DKIM1; k=rsa; p=PUBLIC_KEY_FROM_ZOHO-Zoho's DKIM signature (2048-bit)
TXT_dmarcv=DMARC1; p=none; rua=mailto:support@domain.site; ruf=mailto:support@domain.site; sp=quarantine; adkim=r; aspf=r-DMARC policy, relaxed alignment

The three MX records route incoming mail to Zoho; mx.zoho.com is the main one, the other two are fallbacks. Resend takes no part in receiving.

Why the two SPF records don't clash

A DNS name can carry only one SPF record; two v=spf1 records on the same name make the SPF check end in an error. There is no need to merge both services' includes into one record here: Zoho's SPF sits on the root (@) and Resend's on the send subdomain. Resend puts the return path (the envelope sender) of its messages on send.domain.site, so a receiving server checks SPF for Resend's mail there, and for Zoho's at domain.site itself. The include:amazonses.com is simply what Resend asks for.

DKIM works the same way: Zoho's key lives at zoho._domainkey, and Resend hands out its own DKIM record under its own name.

DMARC

DMARC starts at p=none: reports only, no reject or quarantine on the root domain yet. Both kinds of report (rua and ruf) go to support@domain.site, the Zoho mailbox itself; subdomains get sp=quarantine. Relaxed alignment (adkim=r; aspf=r) matters here: Resend's SPF passes on send.domain.site while the From a reader sees is noreply@domain.site, and relaxed alignment counts a subdomain as aligned with its parent.

Verifying the domain in Resend

  1. In Resend, open Domains, click Add Domain and enter domain.site.
  2. Resend lists the records it needs, the SPF on send and DKIM; add them in the DNS panel and click Verify.
  3. Under API Keys, create a key with Sending access. That key, something like re_xxxxxxxx, is the SMTP password on the Django side.

Django: settings in the database, not in settings.py

EMAIL_HOST and friends used to live in .env, read once at startup, so switching mail on or changing the password meant editing a file and restarting the container. The rule now: a value read by code that runs inside a request belongs on the SystemSetting model; only what is read before there is a database to ask stays in .env.

So the SMTP settings are SystemSetting columns, edited in the admin panel under System settings, Email and SMS, Edit, by a superuser only, and applied without a restart. For Resend:

ColumnValue
use_email_serviceYes
email_backenddjango.core.mail.backends.smtp.EmailBackend
email_hostsmtp.resend.com
email_port587
email_use_tlsYes
email_use_sslNo
email_host_userresend
email_host_password_encryptedThe Resend API key (re_xxxxxxxx), stored encrypted
email_send_fromnoreply@domain.site

The same keys written into .env do nothing; no code reads them.

The SMTP password, encrypted

An API key in a plain column makes every database backup a list of live credentials. email_host_password_encrypted holds Fernet ciphertext, keyed from the environment: FIELD_ENCRYPTION_KEY, or failing that, a key derived from SECRET_KEY with PBKDF2. The form field is write-only: it renders empty, and an empty save keeps the stored value. One catch: rotate SECRET_KEY without a FIELD_ENCRYPTION_KEY and the stored password no longer decrypts, so it has to be entered again.

A connection built for every send

With the settings in the database, mail_settings_service.py builds the connection from the row on each send:

def _smtp_options(backend, timeout=None):
    if not _speaks_smtp(backend):
        return {}
    row = _setting()
    return {
        "host": system_option("email_host"),
        "port": int(system_option("email_port")),
        "username": system_option("email_host_user"),
        "password": row.get_email_host_password() if row is not None else "",
        "use_tls": system_flag("email_use_tls"),
        "use_ssl": system_flag("email_use_ssl"),
        "timeout": timeout,
    }
def build_mail_connection(timeout=None):
    backend = import_string(_backend_path())
    return backend(alias=MAILER_ALIAS, **_smtp_options(backend, timeout))

get_mail_connection() wraps this and returns None when mail is off or the form is half filled in: a half-filled form should stop mail, not break the page that sent it.

The one mail entry left in settings.py

MAILERS = {
    "default": {
        "BACKEND": "_0_utils.service.email.backends.SystemSettingEmailBackend",
    },
}

Since Django 6.1, MAILERS takes the place of EMAIL_BACKEND. The project's own code asks get_mail_connection() directly, but allauth, contrib.auth's password reset and mail_admins() use Django's default mailer, and without this entry they would silently fall back to localhost:25 as webmaster@localhost. SystemSettingEmailBackend builds the connection from the same row at send time, fills in the panel's sender when a message has none, and returns 0 when mail is off.

A Celery queue: no request waits on SMTP

Inside the sign-up request, an SMTP send would keep the user waiting on round trips to Resend, and its slowness would become ours. So send_mail in email_with_queue_service.py only queues the work:

def send_mail(subject, email, context, template):
    if not email_is_enabled():
        ...  # print the code: on screen under DEBUG, behind SHOW_LOG_DEBUG otherwise
    else:
        _send_mail.delay(
            subject=subject, to=email, context=context, template_name=template
        )
@shared_task()
def _send_mail(subject, to, context, template_name):
    connection = get_mail_connection()
    if connection is None:
        return False  # switched off between queueing and running
    html_message = render_to_string(template_name, _mail_context(context))
    message = EmailMultiAlternatives(
        subject=subject,
        body=strip_tags(html_message),
        from_email=mail_from_address(),
        to=[to],
    )
    message.attach_alternative(html_message, "text/html")
    connection.send_messages([message])

Only plain data goes to .delay(), since the arguments are JSON-encoded onto the broker; passing the site's settings, which are model instances, once raised an EncodeError and a 500 on the sign-up POST. The site context is now built in the worker, so the mail renders with the settings in force when it is sent. The worker also asks get_mail_connection() again and steps aside if mail was switched off in between.

With mail off, the code is printed on screen only under DEBUG: a sign-in code in a production log is a way in for whoever reads it.

The test mail

On the same Email and SMS page, a Test Email button sends one of the site's own mails (sign-up code, sign-in code, password reset) with sample values (the code 123456), or a typed message. It differs from the real sender on purpose:

  • It is not queued: the result has to reach the page, and every SMTP call is bounded by TEST_MAIL_TIMEOUT (15 seconds).
  • It ignores use_email_service, so a connection can be tried before mail is switched on.
  • It raises, through build_mail_connection(), so a bad password shows on the page instead of vanishing into None.

It uses the saved values: save first, then test.

Checking delivery

That leaves three places to look: the domain's status in Resend, the test mail arriving, and the DMARC reports in the support@ mailbox. At p=none nothing is rejected yet; the reports show who is sending as the domain.