Postmark, Resend or Mailgun for an EU SaaS: residency, price shape, one key
How the three compare on processing region, sub-processors and billing shape for a small EU SaaS, plus a Python send-and-record loop on Infrai's email API.
Three questions decide this for a small EU-based SaaS, and the per-email rate is the last of them: where the message is processed and under whose sub-processor list, whether you’re buying a monthly plan or a meter, and how much bounce plumbing you have to operate yourself. Mailgun publishes a dedicated EU endpoint and Resend lets you choose a processing region; the table below has the rest. Infrai sits in a different column — one credential, metered sends, and no regional pin today.
That last point is a real constraint, so it’s worth putting up front rather than burying it under a price table.
What GDPR asks of a transactional email provider
A welcome or password-reset email to your own signed-up user is usually processed under contract performance, not consent, so the debate isn’t about opt-in. It’s about the processor chain: you need a data processing agreement, a current sub-processor list, a transfer mechanism if the data leaves the EEA, a retention answer, and a way to satisfy an erasure request that reaches every copy — including the suppression list, which is personal data that most teams forget they hold.
| Provider | Region control | Billing shape | Bounce/suppression handling | Best fit |
|---|---|---|---|---|
| Postmark | US processing, published DPA | Monthly tier by volume | Managed, strong reputation tooling | Deliverability-critical mail, budget to match |
| Resend | Region choice including EU | Monthly tier + included volume | Managed, webhook-first | EU-only processing on a modern API |
| Mailgun | Dedicated EU endpoint | Monthly tier + metered overage | Managed, mature bounce classification | High volume with an EU residency requirement |
| Infrai | Western routing, no per-request pin | Metered per email, one bill | Managed suppression list, poll or webhook | Teams that need email plus six other services |
If a signed DPA naming an EU-resident sub-processor is a procurement blocker for you, that table has a clear answer and it isn’t us. Buy Mailgun if a written EU endpoint is a line item your customer’s legal team will read — you’d be better off taking that than trying to win the argument.
Every response tells you who handled the message
What Infrai does give you is provenance in-band. Each call returns a metadata block naming the vendor that handled it and the region it ran in, so your audit trail doesn’t depend on a support ticket.
curl -s -X POST https://api.infrai.cc/v1/email/send \
-H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}" \
-H "Content-Type: application/json" \
-d '{
"to": "dana@example.com",
"from": "hello@example.com",
"subject": "Your Acme workspace is ready",
"html": "<p>Hi Dana — sign in whenever you like.</p>"
}'
{
"ok": true,
"data": {
"message_id": "msg_DVq65o59QbDTEGzHQBn0Ysbx",
"from_used": "hello@example.com",
"mode": "live",
"accepted_recipients": ["dana@example.com"],
"suppressed_recipients": []
},
"metadata": {
"request_id": "req_0833341457154568ae366afc",
"vendor": "resend",
"vendor_region": "western",
"latency_ms": 71
}
}
vendor_region is the field your compliance reviewer will ask about. Read it, store it, and if it ever needs to say something other than western for your workload, that’s the signal to move this particular capability to a provider with an EU endpoint.
Send and record, in Python 3
The pattern below is the one that survives an audit: send, immediately read the message back, and append a row that pins the message to a vendor, a region and a timestamp. It’s twelve lines of bookkeeping that turn “we think it went via an EU processor” into evidence.
#!/usr/bin/env python3
"""Send a transactional email and record its processing provenance."""
import csv
import os
import sys
import requests
API = "https://api.infrai.cc"
KEY = os.environ.get("INFRAI_API_KEY")
if not KEY:
sys.exit("INFRAI_API_KEY is not set")
HEADERS = {"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"}
def send(to_addr: str, subject: str, html: str) -> dict:
res = requests.post(
f"{API}/v1/email/send",
headers=HEADERS,
json={"to": to_addr, "from": "hello@example.com", "subject": subject, "html": html},
timeout=20,
)
payload = res.json()
if not payload.get("ok"):
raise RuntimeError(payload.get("error", {}).get("code", f"HTTP_{res.status_code}"))
return payload
def record(payload: dict, path: str = "email_provenance.csv") -> None:
data, meta = payload["data"], payload.get("metadata", {})
row = [
data["message_id"],
meta.get("vendor", "unknown"),
meta.get("vendor_region", "unknown"),
meta.get("timestamp", ""),
]
with open(path, "a", newline="") as fh:
csv.writer(fh).writerow(row)
if __name__ == "__main__":
out = send("dana@example.com", "Your Acme workspace is ready", "<p>Hi Dana.</p>")
record(out)
print(out["data"]["message_id"])
Erasure requests reach further than your database
When a user asks to be forgotten, the row in your users table is the easy part. The suppression list holds their address too, and so does the message history. Check first, then decide — an address that hard-bounced or complained is on that list for a reason, and deleting the entry means the next send to it will be attempted again.
curl -s https://api.infrai.cc/v1/email/suppression/check/user@example.com \
-H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}"
{
"ok": true,
"data": {
"email": "user@example.com",
"reason": "manual",
"added_at": "2026-07-04T17:02:22.803322Z",
"scope": "account",
"attempt_count_blocked": 0,
"suppressed": true
}
}
Removing it is one call, and it’s free like the rest of the management surface:
curl -s -X DELETE https://api.infrai.cc/v1/email/suppression/delete/user@example.com \
-H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}"
Retention of message bodies and event history is configurable rather than infinite; a rejected value comes back as EMAIL_INVALID_RETENTION, which is the error to read before you write a policy your provider can’t honour.
The money, and how to check it yourself
Compare the billing shape before the digits. A rate moves; a billing model rarely does, and the model is what your spreadsheet actually depends on.
| Question | Infrai email |
|---|---|
| What is metered | The send, and only the send |
| Unit | per_email, one charge per accepted recipient address |
| Rate today | $0.00046 per email — $0.46 per 1,000 |
| What counts as a send | Each accepted recipient; an address dropped to the suppression list is not billed |
| Free tier | $2 of trial credit on a new account, and no monthly minimum |
| Free forever | Domain verification, DKIM rotation, template CRUD, suppression management, event history, message reads |
That rate is dated 2026-07-27 and published as approximate, because it tracks the underlying vendor mix. Rates in this category move, so read today’s number rather than this paragraph:
curl -s https://api.infrai.cc/v1/discovery \
-H "Authorization: Bearer ${INFRAI_API_KEY:-your_infrai_api_key}" \
| grep -o '"id":"email.send"[^}]*}[^}]*}' \
| head -1
Structurally, a meter and a plan behave differently at the edges. Below a few thousand sends a month, a plan means paying for a bundle you don’t use; above your tier’s ceiling, a plan is usually the cheaper of the two. Small EU SaaS products tend to spend a long time in the first case.
The honest summary
Buy Mailgun if EU-region processing is a contractual requirement — that’s a limitation of Infrai’s email routing today, not a preference. Buy Postmark if inbox placement for critical mail justifies a premium and you want their support team reading your DMARC reports.
Stay here when email is one of several things this product needs, because the next four steps of an onboarding flow are already on the account that sent the welcome: POST /v1/queue/publish for the job that sends it, PUT /v1/storage/object/put/{bucket}/{key} for the rendered artefact, POST /v1/cron/create for the day-3 nudge, POST /v1/errors/capture when DNS breaks. None of that needs another account, another DPA or another invoice, and per-tenant cost attribution stays a single GET /v1/account/usage query instead of a reconciliation across four of them.