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.siteandinfo@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:
| Type | Name | Value | Priority | Purpose |
|---|---|---|---|---|
| MX | @ | mx.zoho.com | 10 | Incoming mail to Zoho |
| MX | @ | mx2.zoho.com | 20 | Incoming mail to Zoho (fallback) |
| MX | @ | mx3.zoho.com | 50 | Incoming mail to Zoho (fallback) |
| TXT | @ | v=spf1 include:zohomail.com ~all | - | Zoho's SPF, on the root |
| TXT | send | v=spf1 include:amazonses.com ~all | - | Resend's SPF, on the send subdomain |
| TXT | zoho._domainkey | v=DKIM1; k=rsa; p=PUBLIC_KEY_FROM_ZOHO | - | Zoho's DKIM signature (2048-bit) |
| TXT | _dmarc | v=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
- In Resend, open Domains, click Add Domain and enter
domain.site. - Resend lists the records it needs, the SPF on
sendand DKIM; add them in the DNS panel and click Verify. - Under API Keys, create a key with
Sending access. That key, something likere_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:
| Column | Value |
|---|---|
use_email_service | Yes |
email_backend | django.core.mail.backends.smtp.EmailBackend |
email_host | smtp.resend.com |
email_port | 587 |
email_use_tls | Yes |
email_use_ssl | No |
email_host_user | resend |
email_host_password_encrypted | The Resend API key (re_xxxxxxxx), stored encrypted |
email_send_from | noreply@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 intoNone.
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.