Tech

Self-Hosting Postfix + OpenDKIM + Brevo for AI Agent Email

Akshay Sarode
Direct answer

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

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

ItemMonthly
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.