Self-Hosting Postfix + OpenDKIM + Brevo for AI Agent Email
One Hetzner VM. Postfix accepts inbound on :25 (after Hetzner unblocks port 25 for you). OpenDKIM signs outbound with your DKIM keys. Brevo's free tier (300/day) relays outbound until you don't need it. A small Node bridge POSTs inbound to your Cloud Function. Inbox-placement is good after the first 7 days.
This is the runbook we use for Ujex Postbox's outbound + inbound. Same shape works for any AI agent email setup. Approximate cost: $5/month (Hetzner) + $0/month (Brevo free tier).
Why this stack
- Postfix — battle-tested MTA. Handles SMTP. Knows about queues, bounces, DSN.
- OpenDKIM — signs outbound with your DKIM keys. Postfix runs it as a milter.
- Brevo — relay through their SMTP because Hetzner blocks port 25 by default. Brevo's free tier (300/day) is enough for low volume; their paid plans are reasonable.
- Node bridge — converts inbound MIME to JSON; HMACs and POSTs to your webhook (Cloud Function).
Day 1: VM setup
hetzner-cloud-cli server create --type=cax11 --image=ubuntu-22.04 --name=mail
ssh root@
apt update && apt install -y postfix opendkim opendkim-tools certbot
Day 1: DKIM keys
mkdir -p /etc/opendkim/keys/yourdomain.dev
cd /etc/opendkim/keys/yourdomain.dev
opendkim-genkey -s ap1 -d yourdomain.dev
chown opendkim:opendkim ap1.private
Copy ap1.txt contents into a DNS TXT record at ap1._domainkey.yourdomain.dev. Add an SPF record v=spf1 mx a ~all. Add DMARC v=DMARC1; p=none; rua=mailto:dmarc@yourdomain.dev (start with p=none to monitor).
Day 1: Postfix config
Edit /etc/postfix/main.cf:
myhostname = mail.yourdomain.dev
mydomain = yourdomain.dev
myorigin = $mydomain
inet_interfaces = all
mydestination = localhost.$mydomain, localhost
# OpenDKIM milter
smtpd_milters = inet:localhost:8891
non_smtpd_milters = inet:localhost:8891
# Inbound: deliver to bridge
virtual_transport = lmtp:127.0.0.1:2424
virtual_mailbox_domains = $mydomain
# Outbound relay via Brevo (initially)
relayhost = [smtp-relay.brevo.com]:587
smtp_use_tls = yes
smtp_sasl_auth_enable = yes
smtp_sasl_password_maps = hash:/etc/postfix/sasl_passwd
smtp_sasl_security_options = noanonymous
smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt
Day 1: Hetzner port-25 ticket
Hetzner blocks outbound :25 by default. Submit a ticket: "I'm running an MTA, please unblock outbound port 25 for IP X.X.X.X." Response in 24–48h. While you're waiting, outbound goes through Brevo (port 587) — that works immediately.
Inbound :25 is allowed by default (other people sending you mail). When :25 is unblocked, you can drop Brevo and send directly.
Day 2: Node bridge for inbound
Postfix's virtual_transport = lmtp:127.0.0.1:2424 means inbound mail gets LMTP-delivered to a local listener. Node bridge:
const { SMTPServer } = require('smtp-server');
const simpleParser = require('mailparser').simpleParser;
const server = new SMTPServer({
authOptional: true,
onData(stream, session, callback) {
simpleParser(stream).then(async parsed => {
const payload = {
from: parsed.from.value[0].address,
to: parsed.to.value[0].address,
subject: parsed.subject,
text: parsed.text,
html: parsed.html,
};
const sig = hmac(POSTBOX_INGEST_SECRET, JSON.stringify(payload));
await fetch('https://your-fn.cloudfunctions.net/postboxIngest', {
method: 'POST',
headers: { 'X-Signature': sig, 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
callback();
});
},
});
server.listen(2424, '127.0.0.1');
Day 2: Cloud Function side
Verify HMAC. Look up recipient via collectionGroup('mailbox').where(localPart, ==, ...). Score for prompt injection. Write to agents/{a}/messages/{msgId}. FCM push if PI is high.
Day 3: outbound worker
A separate process polls collectionGroup('outbound').where(status, ==, pending), claims rows, builds MIME via nodemailer, hands to local Postfix on :25, writes status: sent.
Day 4: warm up
For the first week, send small volumes (10–50/day) only to addresses that will engage (not bulk). DKIM "neutral" in Gmail is normal in the first week — Brevo modifies bodies and our signature doesn't survive their rewrite. After Hetzner unblocks :25, send directly and DKIM ap1 shows "pass."
Cost
| Item | Monthly |
|---|---|
| Hetzner CAX11 VM | ~$5 |
| Brevo free tier (300/day) | $0 |
| Cloudflare DNS | $0 |
| Domain | ~$1 |
| Total | ~$6 |
What this gives you
A real MTA you can SSH into. Real DKIM/SPF/DMARC. Per-agent inboxes routed via the Cloud Function. Audit log of every message. Prompt-injection scoring. Mobile approval on outbound. About 4 hours of setup.
FAQ
What if Hetzner refuses to unblock :25?
Stay on Brevo — their paid tier is reasonable for production volume. Or switch to a host that doesn't block by default (Vultr, OVH, AWS Lightsail, smaller VPS providers).
Why not just use SES / Mailgun?
You can. The cost is similar at low volume. The argument for self-host is full control over the MTA and end-to-end DKIM (no vendor's body rewrite breaking your signature).
Is this safe to run as a single VM?
For low-medium volume, yes. For HA you'd want two VMs with active-passive failover and a load balancer for inbound.