Migrating from Mailgun
Keep the Mailgun client and its form-encoded send, and change the one line that says where it points. Bring the suppression lists first, from both regions.
emails.sh serves a Mailgun-compatible API at https://api.emails.sh/mailgun. It takes their form-encoded send at /v3/<domain>/messages with HTTP basic auth, and answers {"id":"<...@domain>","message":"Queued. Thank you."} with a 200, which is what their schema declares required and what their own SDK fixtures assert. Errors come back as {"message":"..."}, the single field their error schema documents. Their key becomes an esh_ key and goes in the same basic-auth password, with the same api username.
// Node, mailgun.js
const mg = new Mailgun(FormData).client({
username: 'api',
key: 'esh_live_yourkey',
url: 'https://api.emails.sh/mailgun'
});
# PHP
Mailgun::create($key, 'https://api.emails.sh/mailgun');
# Go v5, which rejects a base URL carrying a version
mg.SetAPIBase("https://api.emails.sh/mailgun")
# Go v4, which requires one, and is the only client of the twelve
# in this chapter that reads an environment variable at all
MG_URL=https://api.emails.sh/mailgun/v3Every language, and how to point it here
These were read from each SDK's source, not from its documentation. All seven are overridable, and only the Go client reads an environment variable, through the explicit NewMailgunFromEnv() entry point rather than ambiently. So this is a code change like Postmark and SendGrid rather than a config change like Resend.
| Language | Package | How to point it at emails.sh | Change |
|---|---|---|---|
Node / TypeScript | mailgun.js | client({ username: 'api', key, url: 'https://api.emails.sh/mailgun' }) | client option |
PHP | mailgun/mailgun-php | Mailgun::create($key, 'https://api.emails.sh/mailgun') | constructor |
Ruby | mailgun-ruby | Mailgun::Client.new(key, 'api.emails.sh/mailgun') | host, no scheme |
Python | mailgun | Client(auth=("api", key), api_url="https://api.emails.sh/mailgun") | constructor |
Java | com.mailgun:mailgun-java | MailgunClient.config("https://api.emails.sh/mailgun", key) | builder |
Go v5 | mailgun-go/v5 | mg.SetAPIBase("https://api.emails.sh/mailgun") | one line, no /v3 |
Go v4 | mailgun-go/v4 | MG_URL=https://api.emails.sh/mailgun/v3 | env var, with /v3 |
The two Go rows differ for a real reason: v4 requires the version on the end of the base URL and v5 returns an error if you include it, so a v4 to v5 upgrade during a migration needs both changed at once. Ruby takes a bare host with no scheme, because it builds the URL from a separate secure flag. The Python client refuses a plain http:// host outright unless it is localhost, which is a good rule and not one you will hit here.
What the compatibility endpoint covers
/mailgun/v3/:domain/messagesSend, form-encoded. Answers {"id":"<...>","message":"Queued. Thank you."} as theirs does.
/mailgun/v3/:domain/bouncesYour suppression list, in their {items, paging} envelope.
/mailgun/v3/:domain/complaintsSpam complaints.
/mailgun/v3/:domain/unsubscribesOpt-outs.
/mailgun/v4/domainsSending domains, in their {total_count, items} envelope.
/mailgun/v4/domainsAdd one, and get back the DNS records to publish.
/mailgun/v3/domainsThe same list, for clients pinned to the version before they moved it to v4.
curl -s --user 'api:esh_live_yourkey' https://api.emails.sh/mailgun/v3/yourdomain.com/messages -F from='Acme <receipts@yourdomain.com>' -F to=someone@example.com -F subject='Your receipt' -F text='Thanks for your order.' -F o:tag=receipts -F v:order-id=1234These are a translation over the same /v1 handlers everything else uses, not a second implementation. A send through the compatibility endpoint passes the same suppression check, the same quota, the same spend cap, and the same idempotency table as a send through /v1/emails.
What differs
- The domain in the URL must match the from address
- At Mailgun the path names your sending domain. Here the path domain and the domain in from must agree, and a mismatch is refused rather than resolved silently in favour of one of them. A request whose URL and body disagree has a bug either way, and a silent success hides it.
- recipient-variables
- Refused, and this is the field a Mailgun migration most often trips on. Batch sending with per-recipient substitution has a different shape here: POST
/v1/emails/batchtakes up to a hundred fully rendered messages in one call, each with its own subject and body. Render the per-recipient values in your own code and post the array. That also means each recipient gets their own message rather than seeing the others in the To header. - o:testmode
- Refused. There is no accept-and-discard mode, because a message that vanishes cannot be inspected. Send to a reserved test recipient instead: delivered@emails.sh, bounced@emails.sh, complained@emails.sh, or suppressed@emails.sh. Nothing reaches the internet, and you still get a real id, real delivery events and real webhooks, against no allowance. See
/docs/sending. - Click tracking and the optimisation options
- o:tracking-clicks is refused because we do not rewrite links. o:deliverytime-optimize-period and o:time-zone-localize are refused because we do not model a recipient timezone: compute the moment yourself and pass o:deliverytime, which is honoured up to thirty days out. Simple o:deliverytime works and is parsed from RFC 2822 as theirs is.
- Tags and variables
- Both translate. o:tag becomes a tag with an empty value, since theirs is a bare label and ours is a pair. v:name=value becomes a tag with its value, since their custom variables are arbitrary key/value metadata echoed on events, which is what our tags are. Both are readable on GET
/v1/emails/:id. - Reply-To
- Mailgun has no
reply_tofield: it is set as h:Reply-To, and that is exactly how it arrives here. It is lifted out of the headers and validated as a reply-to rather than passed through as a raw header. - Routes
- A Route matches an inbound message and fires an action, and the message is gone unless you stored it. Inbound here is a mailbox: mail to a verified domain is stored, parsed, and threaded against what you sent, and you read and reply through the API. Routes that forward to a human address have no equivalent and should stay at Mailgun. The migrate command does not read them.
- Regions
- Mailgun runs api.mailgun.net and api.eu.mailgun.net, and messages, logs, suppressions, routes, and mailing lists are region-bound between them. We run one API host, so there is nothing to choose on your side, but a migration has to read both of theirs. Where our own mail leaves from is a separate question:
/docs/regions. - Message ids
- Theirs is an RFC 2392 message id in angle brackets and ours is a UUID, so we return the UUID as the local part: strip the brackets and the domain and you have the id GET
/v1/emails/:id takes.
Bringing the account across
The code is one line, but an account is also domains, webhooks, stored templates, and three suppression lists per domain per region. One command reads them and imports what it can. It prints a plan and waits for a yes before writing anything, and it is safe to run again.
npx @emails.sh/cli migrate mailgun
# reads MAILGUN_API_KEY, or pass --from-key
# add --region eu if any of your domains live in Mailgun's EU region:
# suppressions are region-bound there and would otherwise be left behind
# imports bounces, complaints, and unsubscribes for every domain firstTheir bounce, complaint, and unsubscribe endpoints page on an opaque paging.next URL rather than on an offset, and skip does not work on them, so the command follows the cursor verbatim until a page comes back empty. That matters on a long-lived account: an importer that paged with skip would silently stop after the first hundred.
Suppressions are written straight to your list here before any domain is added, so an interrupted run is one where the half that finished is the half that mattered. A copy is saved as mailgun-suppressions.csv either way.
Cutting over
When you are ready to stop being compatible, move to /v1. It is a JSON body rather than a form, to is an array rather than a repeated field, h: headers become a headers object, v: variables become tags, and o:deliverytime becomes send_at as an RFC 3339 string. There is no deadline: the compatibility endpoint is a supported surface, not a temporary bridge.