DNS & Email Setup
Configure DNS records, DKIM signing, SPF, DMARC, and bounce handling for reliable email delivery.
For production email delivery, your self-hosted Owlat instance needs proper DNS configuration. Without it, most email providers will reject or spam-folder your messages.
This page applies when you send via Owlat's built-in MTA. If you send through Resend or SES, follow their domain-authentication guides instead; if you run IMAP-only (reading an external mailbox, replying via your own SMTP), you can skip DNS setup entirely.
DNS setup is only needed for production. For local testing, the MTA will attempt delivery with the default settings — most emails will be rejected by receiving servers, but you can verify the pipeline works end-to-end.
Required DNS Records
A Record
Point your application domain to your server's IP address:
owlat.example.com. A 203.0.113.10
PTR / Reverse DNS
The EHLO_HOSTNAME in your .env must have a PTR record matching your server's IP address. This is set through your hosting provider's control panel (not your DNS provider).
# Your .env
EHLO_HOSTNAME=mail.example.com
# PTR record (set in hosting provider)
203.0.113.10 PTR mail.example.com
Most receiving mail servers reject connections where the EHLO hostname doesn't match the PTR record.
MX Record for Bounce Processing
The RETURN_PATH_DOMAIN needs an MX record pointing to your server so bounce emails are routed back to the MTA on port 25:
bounces.example.com. MX 10 mail.example.com.
SPF
When you click Add domain, the built-in MTA generates your DKIM and DMARC records but only emits an SPF record when the MTA_SPF_INCLUDE environment variable is set. If MTA_SPF_INCLUDE is unset, no SPF record is shown and you must add one manually as described below. DKIM + DMARC alignment still works without SPF, but a missing or wrong SPF record hurts deliverability.
You have two ways to publish SPF, depending on how you run the MTA:
Option A — add an ip4 record manually (default). If you have not set MTA_SPF_INCLUDE, add a TXT record on your sending domain that authorizes your server's IP directly:
example.com. TXT "v=spf1 ip4:203.0.113.10 -all"
If you use multiple IPs (separate transactional and campaign pools), include all of them:
example.com. TXT "v=spf1 ip4:203.0.113.10 ip4:203.0.113.11 -all"
Option B — let the MTA generate an include: record. Set MTA_SPF_INCLUDE in the Convex backend to a domain whose own SPF record lists your sending IPs (for example a shared _spf subdomain you maintain). The Add-domain flow then emits this record for you:
example.com. TXT "v=spf1 include:<MTA_SPF_INCLUDE value> ~all"
Use the ip4 form (Option A) unless you operate a maintained include: domain — it keeps your authorized IPs explicit and within SPF's 10-lookup limit (RFC 7208 §4.6.4).
While you are still adding IPs or relays, publish ~all (soft-fail) so a message sent from an IP you haven't listed yet is marked, not rejected. Once your authorized IP set is stable, switch to -all (hard-fail). The Owlat domain-verification flow generates ~all by default; set SPF_QUALIFIER=-all once you're confident in your IP list.
SPF for the bounce return-path domain
This is the step most self-hosters miss. The MTA sends with a VERP bounce envelope (MAIL FROM: bounce+…@RETURN_PATH_DOMAIN), so receivers evaluate SPF against your RETURN_PATH_DOMAIN, not your From-domain apex. If RETURN_PATH_DOMAIN has no SPF record, the bounce envelope fails SPF — and because the return-path domain (bounces.example.com) does not align with your From-domain (example.com), SPF can never contribute to DMARC for your From-domain on its own. DKIM still aligns and carries DMARC, but you should still authorize the return-path.
Publish a TXT record on RETURN_PATH_DOMAIN authorizing each pool IP:
bounces.example.com. TXT "v=spf1 ip4:203.0.113.10 -all"
With multiple pool IPs, include all of them:
bounces.example.com. TXT "v=spf1 ip4:203.0.113.10 ip4:203.0.113.11 -all"
To make SPF align (so SPF can satisfy DMARC by itself), use a return-path subdomain of your sending domain instead of a shared bounce domain — e.g. set RETURN_PATH_DOMAIN=bounce.example.com so the return-path shares the organizational domain with your From-domain and aligns under DMARC's relaxed mode. Then publish the SPF record on that subdomain:
bounce.example.com. TXT "v=spf1 ip4:203.0.113.10 -all"
Set MTA_RETURN_PATH_DOMAIN and MTA_IP_POOLS (comma-separated sending IPs) in the Convex backend env and the in-app domain-verification flow will generate the return-path SPF record for you, with the qualifier from SPF_QUALIFIER.
DKIM
DKIM signs outgoing emails with a cryptographic key, allowing receivers to verify the message hasn't been tampered with.
Use one DKIM path: the in-app Add domain flow. When you add a domain, the built-in MTA generates the RSA 2048-bit key pair for you, stores the private key in Redis, and shows you the exact DNS TXT record to publish. Do not also generate a key with openssl and set it via DKIM_KEYS for the same domain — the Add-domain flow assigns its own selector (s{timestamp}) and overwrites the Redis key, so a hand-generated s1 key would no longer match what the MTA signs with, breaking DKIM.
1. Generate the Key (in-app)
- Open Settings > Technical > Sending Domains.
- Click Add domain and enter your domain.
- The MTA generates a key pair and displays a DKIM TXT record at
<selector>._domainkey.example.com, where<selector>is the auto-assigned selector (for examples1718000000000).
2. Add the DNS TXT Record
Publish the TXT record exactly as the UI shows it, at the selector host the UI displays:
s1718000000000._domainkey.example.com. TXT "v=DKIM1; k=rsa; p=MIIBIjANBgkq..."
If your public key is longer than 255 characters, you may need to split it across multiple quoted strings in the TXT record. Most DNS providers handle this automatically.
Then click Verify in the UI to confirm the record is live.
Pre-seeding keys with the DKIM_KEYS environment variable (optional)
DKIM_KEYS is only for migrating an existing DKIM key into a fresh self-host (for example when moving providers and you want to keep your already-published s1 record). The MTA seeds these keys into Redis on startup, and only if no key already exists for that domain — it never overwrites a key the Add-domain flow created.
If you use DKIM_KEYS for a domain, do not also click Add domain for it: the in-app flow is the canonical path, and registering a domain that has no key yet would generate a new selector. Pick one path per domain — the in-app flow for new domains, DKIM_KEYS only to carry over a pre-existing key.
DKIM_KEYS={"example.com":{"selector":"s1","privateKey":"-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAK...\n-----END RSA PRIVATE KEY-----\n"}}
The JSON format supports multiple domains:
{
"example.com": {
"selector": "s1",
"privateKey": "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----\n"
},
"anotherdomain.com": {
"selector": "s1",
"privateKey": "-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----\n"
}
}
DMARC
DMARC tells receiving servers what to do when SPF or DKIM checks fail. Add a TXT record at _dmarc.example.com:
_dmarc.example.com. TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc-reports@example.com"
| Policy | Behavior |
|---|---|
p=none | Monitor only — no action on failures (good for initial setup) |
p=quarantine | Send failing messages to spam folder |
p=reject | Reject failing messages entirely (strictest) |
Start with p=none while verifying your setup, then move to p=quarantine or p=reject once everything is working.
IP Pools
The MTA supports separate IP pools for transactional and campaign emails. This protects your transactional email reputation from being affected by marketing campaigns.
# In .env
IP_POOLS_TRANSACTIONAL=203.0.113.10
IP_POOLS_CAMPAIGN=203.0.113.11,203.0.113.12
Each IP in a pool needs its own PTR record and must be included in your SPF record.
If you only have one IP address, use it for both pools. Reputation separation won't apply, but everything will work correctly.
IP Warming
Fresh IP addresses have no sending reputation. Sending large volumes immediately will trigger spam filters and potentially get your IP blocklisted. Gradually increase volume over 2-4 weeks.
A typical warming schedule for a new IP:
| Week | Daily Volume |
|---|---|
| 1 | 50-100 emails |
| 2 | 500-1,000 emails |
| 3 | 5,000-10,000 emails |
| 4+ | Full volume |
Focus on sending to engaged recipients first (recent openers/clickers) to build positive reputation signals.
Verification
Once your DNS records are configured:
- In-app verification — Owlat's domain verification flow checks SPF, DKIM, and DMARC records automatically. See Deliverability for details.
- External testing — send a test email to mail-tester.com and aim for a score of 9+/10.
- Manual checks:
# Verify SPF dig TXT example.com +short # Verify DKIM (use the selector the Add-domain flow displayed) dig TXT s1718000000000._domainkey.example.com +short # Verify DMARC dig TXT _dmarc.example.com +short # Verify PTR dig -x 203.0.113.10 +short
For details on the MTA architecture, rate limiting, and bounce processing, see MTA System.