tva
← Insights

Setting Up a Mailbox for Your Project-Specific AI Agent

Sooner or later, the project-specific AI agent you built needs an email address. The Telegram bot pattern we described in Building a Project-Specific AI Assistant via Telegram handles real-time chat, but mail is a different channel — service notifications, vendor confirmations, OAuth password resets, backoffice correspondence with humans who aren't on Telegram. You can route that mail to a personal address and live with the noise, or you can give the project its own mailbox on a dedicated subdomain.

This guide covers the second path: a dedicated mailbox on a project subdomain, with DKIM, SPF, and DMARC configured cleanly. The pattern uses one provider for DNS (Cloudflare in our case) and a separate provider for the mail system (Opalstack), which is the configuration most small operations end up with — DNS where the apex domain already lives, mail at whichever provider has decent IMAP, sane API access, and a reputation that survives Gmail's filters.

The whole setup is four API calls plus three DNS records. With the right pre-flight, it takes about ten minutes end-to-end. We'll show the calls, the verification, and the failure modes that tripped us up the first time.

What This Guide Fixes

  • A project subdomain (system.example.com) with no mail infrastructure attached
  • Inbound mail to a project-specific address bouncing because no MX record exists
  • Outbound mail from the project failing DKIM checks at Gmail and landing in spam
  • SPF and DMARC records that exist but have nothing to authenticate against
  • An AI agent that needs an email channel without routing through your personal account

What You'll Need

  • An apex domain you control, with DNS at a provider that exposes an API (Cloudflare, Route53, DigitalOcean DNS, etc.)
  • A mail provider that supports adding domains via API, generates DKIM keys on domain creation, and exposes IMAP/SMTP endpoints (Opalstack, Mailcow, Migadu, Fastmail, etc.)
  • API tokens for both providers, stored somewhere you can shell out
  • dig, openssl s_client, and a Python interpreter for verification
  • Roughly fifteen minutes

Why the Provider-Mix Pattern

The default mental model is "one provider for everything" — host the website, the DNS, and the mailbox in the same place. That works until any one of the three needs to evolve independently. We covered the decision framework for picking between bundled services and dedicated specialists in The Self-Hosting Decision: When SaaS Costs More Than Your Own Infrastructure, and the same logic applies here at a smaller scale.

In our case, the apex domain's DNS is at Cloudflare because we need the AnyCast resolution, the proxying for the public-facing sites, and the API for automated record management. The mail system is at Opalstack because their per-mailbox cost is negligible, their IMAP server reputation is healthy, and their API is one of the cleaner ones in the EU mail-hosting space. We are not moving DNS to chase mail, and we are not moving mail to chase DNS. The cost of running them separately is exactly three DNS records and one mental model: the DNS provider tells the world where mail goes; the mail provider receives, stores, and signs it.

This split matters operationally because it constrains your blast radius. A DNS provider outage stops new mail routing decisions, but mail already in flight to the MX hosts continues to land. A mail provider outage stops delivery, but DNS lookups still resolve. If we had stacked everything at one provider, both layers would have failed together. The same logic shows up in our production architecture for stacking reverse proxies — separation of concerns, each layer replaceable in isolation.

Dedicated Mailbox, Forwarding Alias, or SaaS Inbox

Before you create anything, pick the right level of dedication. The three options aren't equivalent.

A dedicated mailbox means a real inbox that holds mail, has IMAP credentials, and can both send and receive. Cost: roughly one to three euros per month at a self-hosted mail provider, plus the API setup work. This is what the AI agent uses when it needs to actually read mail — to parse incoming vendor confirmations, to handle service notifications, to maintain a thread with a human counterparty over multiple turns.

A forwarding alias means a virtual address that just redirects to an existing inbox. Cost: zero, configuration-only. This is the right choice when the AI agent only needs to receive at a project-branded address but the actual reading and processing happens in a human inbox that already exists. The trade-off is no IMAP access for the agent, no per-project filtering on the destination side, and no clean rollback path when the project ends — alias-cleanup gets forgotten.

A SaaS inbox means a service like Postmark, Mailgun Inbound, or AWS SES with receive rules. Cost: per-message billing plus the engineering work to wire your AI agent into the webhook receivers. This is the right call when the agent needs to handle high volume, route mail by header rules, or trigger automated workflows on each message. For a project-specific agent that handles dozens to hundreds of messages a month, this is overkill.

For the use case in the Telegram AI assistant post — one human operator, two project collaborators, a Claude-backed bot — the dedicated mailbox is the right fit. The agent gets a real address it can read with IMAP, send from with SMTP, and which lives or dies cleanly with the project.

Step 1: Register the Subdomain in Your Mail System

The mail provider has to know that your project subdomain is one of its domains before it will accept mail for it. This is the call that also generates the DKIM key pair.

POST https://my.mailhost.example/api/v1/domain/create/
Authorization: Token <YOUR_MAIL_API_TOKEN>
Content-Type: application/json

[{"name": "system.example.com"}]

The response contains three fields you care about: the domain UUID (which you need for cleanup and for the address mapping later), the state (usually PENDING_CREATE for a few seconds, then READY), and a dkim_record field with a fully formatted TXT value ready to paste into DNS:

{
  "id": "<DOMAIN_UUID>",
  "state": "PENDING_CREATE",
  "name": "system.example.com",
  "dkim_record": "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4G..."
}

Poll until state flips to READY, then capture the dkim_record value. You'll paste it verbatim into the DNS provider in the next step. If the create call fails with an invalid hostname error, the most common cause is that your mail provider only supports second-level domains and balks at three-level subdomains. Open a support ticket; we have not yet encountered a provider that refused this in practice.

Check the DKIM key size before moving on. A dkim_record value under about 250 bytes is a 1024-bit RSA key; 2048-bit keys run roughly 380–420 bytes. NIST deprecated 1024-bit RSA in 2013, and Gmail, Microsoft, Yahoo, and AWS SES treat 2048-bit as the 2026 baseline — 1024-bit keys still authenticate but score as a weak signal in enterprise spam filters. If your provider lets you pick a size, choose 2048; a 1024-only default is a provider-selection signal.

Step 2: Configure the DNS Records

Three records, all created at your DNS provider for the project subdomain. None of them can be proxied through Cloudflare's edge — MX and TXT records must be DNS-only, and the Cloudflare API will silently fall back to DNS-only if you set proxied: true, but it's cleaner to set it explicitly.

The two MX records tell senders where to deliver mail. Most mail providers run two or more MX hosts with equal priority for round-robin failover:

POST https://api.dnsprovider.example/zones/<ZONE_ID>/dns_records

{"type":"MX","name":"system.example.com",
 "content":"mx1.de.mailhost.example","priority":10,
 "ttl":3600,"proxied":false}

{"type":"MX","name":"system.example.com",
 "content":"mx2.de.mailhost.example","priority":10,
 "ttl":3600,"proxied":false}

The DKIM TXT record uses the dkim_record value from Step 1, placed at dkim._domainkey.<subdomain>:

{"type":"TXT",
 "name":"dkim._domainkey.system.example.com",
 "content":"v=DKIM1; k=rsa; p=MIGfMA0G...",
 "ttl":3600,"proxied":false}

If the DKIM public key is longer than 255 bytes (which is the case for 2048-bit RSA keys), you'll need to split it into multiple quoted chunks: "chunk1" "chunk2". Most DNS providers handle the splitting automatically in their API; if yours doesn't, the chunking has to happen on your end.

SPF and DMARC records you set once, on the subdomain itself and on _dmarc.<subdomain>. SPF tells receivers which IPs are allowed to send for this domain; DMARC tells them what to do when SPF or DKIM fails:

{"type":"TXT","name":"system.example.com",
 "content":"v=spf1 include:spf.mailhost.example ~all",
 "ttl":3600,"proxied":false}

{"type":"TXT","name":"_dmarc.system.example.com",
 "content":"v=DMARC1; p=none; rua=mailto:[email protected]; aspf=r; adkim=r",
 "ttl":3600,"proxied":false}

Start DMARC with p=none. This is monitoring mode — receivers send aggregate reports to the rua address, but no mail is rejected based on policy. The standard rollout is two-to-six weeks at p=none until aggregate reports show at least 98% legitimate-mail compliance, then a gradual ramp via the pct= tag — for example p=quarantine; pct=25 for two weeks, then pct=75, then pct=100, then finally p=reject. Skipping the ramp and going straight to p=reject on a brand-new domain is a reliable way to drop your own legitimate mail before you've validated the DKIM-signing path.

Step 3: Create the Mailbox and Address Mapping

The mail provider's data model usually has two separate resources: a mailuser (the IMAP account with credentials and a quota) and an address (the local@domain string that routes mail to a mailuser). They're separate because the same mailuser can be the destination for several different addresses on the same or different domains.

Generate a strong password and create the mailuser. The naming convention varies by provider; the common pattern is <local>_<domain-with-underscores>. Watch the length limit — most providers cap mailuser names at 32 characters, which is easy to overshoot on a longer subdomain:

import secrets, string
alphabet = string.ascii_letters + string.digits + "!@#%^&*-_=+"
password = ''.join(secrets.choice(alphabet) for _ in range(32))

# Then:
POST /api/v1/mailuser/create/
[{"name": "bot_system_example_com",
  "imap_server": "<IMAP_SERVER_UUID>",
  "password": password}]

Once the mailuser is READY, create the address mapping that ties local@subdomain to it:

POST /api/v1/address/create/
[{"source": "[email protected]",
  "destinations": ["<MAILUSER_UUID>"],
  "forwards": []}]

The forwards array is for additional external addresses to receive a copy. Leave it empty unless you actually want the mailbox forwarding to a human inbox in parallel — which is a useful pattern during the project handover phase but messy long-term.

Step 4: Verify the Setup End-to-End

Four checks, in order. Don't skip the self-loopback one — it's the only test that confirms inbound routing actually reaches the mailbox.

DNS propagation. Cloudflare-class AnyCast providers usually propagate in well under a minute. Loop until both records appear:

for i in $(seq 1 36); do
  mx=$(dig +short system.example.com MX)
  dkim=$(dig +short dkim._domainkey.system.example.com TXT)
  if [ -n "$mx" ] && [ -n "$dkim" ]; then break; fi
  sleep 5
done
echo "MX: $mx"
echo "DKIM: $dkim"

IMAP login. Confirm credentials work and the mailbox is actually backed by an inbox:

openssl s_client -crlf -connect imap.mailhost.example:993 -quiet
a1 LOGIN bot_system_example_com <password>
a2 SELECT INBOX

Expected response: a1 OK Logged in followed by the SELECT confirming EXISTS 0 on a fresh mailbox.

Self-loopback. The best end-to-end test: send a mail from the new address back to itself and check that it arrives in IMAP. This validates the full path — SMTP authentication, DKIM signing, MX resolution, mail acceptance, mailbox delivery, IMAP visibility — without depending on any external mail provider's spam filter cooperating:

import smtplib, ssl, imaplib, time
from email.message import EmailMessage

msg = EmailMessage()
msg["From"] = "[email protected]"
msg["To"]   = "[email protected]"
msg["Subject"] = "loopback test"
msg.set_content("hello self")

with smtplib.SMTP("smtp.mailhost.example", 587) as s:
    s.starttls(context=ssl.create_default_context())
    s.login("bot_system_example_com", "<password>")
    s.send_message(msg)

time.sleep(10)
imap = imaplib.IMAP4_SSL("imap.mailhost.example", 993)
imap.login("bot_system_example_com", "<password>")
imap.select("INBOX")
typ, search = imap.search(None, "ALL")
print("inbox count:", len(search[0].split()))

Inside the delivered mail's headers, look for the Authentication-Results line. A correctly configured setup shows spf=pass with the right smtp.mailfrom and a DKIM-Signature header with d=<your subdomain> and s=dkim. If those are present, every part of the chain works; the only remaining variable is how external receivers feel about you, which DMARC reports will tell you over the following days.

External outbound. Send one mail to an external address that you can verify — a personal Gmail, a separate work inbox, something out-of-system. Confirm it lands in inbox, not spam. If it lands in spam on the very first send from a new domain, the most common cause is a sender-domain reputation that doesn't yet exist; warming up is gradual, but a single test mail should pass cleanly because the receiver evaluates DKIM and SPF on their own merits, not historical sending volume.

Common Pitfalls

The 32-character mailuser-name limit bites whenever your subdomain plus local part is longer than expected. backoffice_dashboard_system_example_com runs to thirty-nine characters and gets rejected; bo_dashboard_system_example_com fits at thirty-one. Pick abbreviations consciously rather than letting the API force them on you mid-deploy.

The Cloudflare proxy trap. Cloudflare's UI lets you toggle the orange-cloud proxy for any record. For A/AAAA on a web origin you almost always want it on. For MX records, it's always off — Cloudflare doesn't proxy mail. The trap is when you mass-enable proxying via a script and accidentally include the MX records. Cloudflare silently drops the proxy flag for unsupported types, so it looks like it worked, but you lose minutes debugging why nothing's wrong.

Greylisting on the first inbound. Mail systems sometimes greylist the first message from an unknown sender — temporary 4xx response, asks the sender to retry in a few minutes. Gmail retries automatically. If your first inbound smoke test seems to fail, wait fifteen minutes before declaring it broken.

The SPF DNS-lookup ceiling. SPF has a hard limit of ten DNS lookups during evaluation (RFC 7208 §4.6.4). A single include:spf.mailhost.example counts as one lookup even if that include resolves to dozens of IPs internally. You only hit the ceiling if you start chaining multiple senders — for example, adding include:_spf.resend.com later for a marketing pipeline. Plan the SPF record for the long-term sender list, not just the first one.

Empty dkim_privkey on the mail provider's side. Some providers expose a domain-create endpoint that succeeds but doesn't actually generate the DKIM key, requiring a separate "enable DKIM" call. Verify that the dkim_record field in your create response is non-empty before moving on. If it's empty, check the provider's documentation for the secondary call.

Naive monitoring of the deployment. If you operate dozens of domains, an unhealthy DKIM record or expired DMARC report destination won't show up in routine monitoring unless you actively check. Our monthly health check routine for self-hosted services includes a mail-record audit step for exactly this reason.

Skipping MTA-STS where it's available. MTA-STS publishes a policy that forces senders to use TLS to your MX hosts and validate the certificate. Gmail, Microsoft 365, and Yahoo check for it; internet-wide adoption is still under one percent, so a valid policy is a small reputation signal. The trade-off is operational: a policy pointing at MX hosts that fail TLS verification blocks legitimate inbound. If your provider exposes MTA-STS, enable it after DKIM and DMARC are stable.

Rollback Path

If verification fails or you decide the project doesn't need its own mailbox after all, the cleanup is idempotent and takes five minutes:

  1. POST /api/v1/address/delete/ with the address UUID
  2. POST /api/v1/mailuser/delete/ with the mailuser UUID
  3. POST /api/v1/domain/delete/ with the mail-system domain UUID (this also wipes the DKIM keys)
  4. DELETE /zones/<zone>/dns_records/<id> for each of the three DNS records

The order matters because some providers refuse to delete the domain while it still has addresses attached. Reverse-order also gives you a sane point to stop and inspect — if Step 1 fails because there's mail in the mailbox you forgot about, you've not yet broken the DNS-level routing.

Leaving the SPF and DMARC records on the subdomain after a rollback is harmless. They authenticate against nothing, but they also tell future receivers that this subdomain explicitly does not send legitimate mail — which is a small anti-spoofing benefit. We treat them as default-on once they exist. This kind of "safe residue" thinking is the same logic we apply to disaster recovery for self-hosted services: the cleanup-after pattern matters as much as the install pattern.

Wrapping Up

A project-specific mailbox isn't infrastructure; it's a small operational primitive that lets an AI agent — or any other automated process — become a first-class participant in email-based workflows. The four-API-call pattern above ports cleanly between mail providers; the DNS records port cleanly between DNS providers. What's worth getting right is the boundary between the two systems: which record lives where, what the failure modes look like, and how to verify end-to-end without depending on any external counterparty's spam filter to behave.

If you'd like help architecting a project-specific stack — Telegram agent, mailbox, document intake, the works — tva can help.


Related Insights

Further Reading