- Aug 19, 2026
- 5 min read
One Sender Adapter, Two Providers: Switching Mailtrap and Mailjet Without Code Changes
My email pipeline sends from two places: a Cloudflare Worker sends the double opt-in confirmations (how those work), and a Python service on my homelab sends the drip sequences (the drip engine). Both started on Mailtrap. Then my production sending domain ended up on Mailjet.
Because both senders were already behind one function, switching was a configuration change. Getting the second provider right still had a few traps worth writing down.
One switch per side
| Side | Setting | Values | Secrets |
|---|---|---|---|
| Worker (confirmations) | EMAIL_PROVIDER | mailtrap · mailjet | MAILTRAP_TOKEN or MAILJET_API_KEY + MAILJET_SECRET_KEY |
| Hub (drips) | SENDER | smtp · mailtrap · mailjet | the same, in a chmod 600 .env |
In the Worker, the entry point only dispatches:
export async function sendEmail(env: EmailEnv, email: OutgoingEmail, fetcher = fetch): Promise<SendResult> {
const provider = env.EMAIL_PROVIDER ?? "mailtrap";
if (provider === "mailjet") return sendMailjet(env, email, fetcher);
if (provider !== "mailtrap") {
console.error(`unknown EMAIL_PROVIDER ${provider}`);
return { ok: false, status: 0 };
}
return sendMailtrap(env, email, fetcher);
}
The hub does the same with a Sender protocol: SmtpSender (Mailpit in
development), MailtrapSender and MailjetSender all implement
send(msg) -> provider_id, and the CLI picks one from SENDER. The engine
never knows which one it is talking to.
Auth: a token vs a key pair
Mailtrap uses one bearer token:
Authorization: Bearer <token>
Mailjet’s Send API uses HTTP Basic auth with two values, the API key as the user name and the secret key as the password:
Authorization: `Basic ${btoa(`${env.MAILJET_API_KEY}:${env.MAILJET_SECRET_KEY}`)}`
That bit me during setup: Mailjet shows the API key prominently, and one key alone can’t send anything. The Worker checks for both and logs “not sent, no MAILJET keys” instead of calling the API half-configured.
The Mailjet v3.1 payload
Mailjet wraps messages in an array and capitalises everything:
message = {
"From": {"Email": msg.from_email, "Name": msg.from_name},
"To": [{"Email": msg.to}],
"Subject": msg.subject,
"TextPart": msg.text,
"HTMLPart": msg.html,
"CustomID": msg.category,
"TrackOpens": "disabled",
"TrackClicks": "disabled",
}
headers = {**msg.list_headers(), **msg.headers}
if headers:
message["Headers"] = headers
if msg.reply_to:
message["ReplyTo"] = {"Email": msg.reply_to}
body = {"Messages": [message]}
CustomID carries the sequence id (or double-opt-in for confirmations), so
the provider’s message history shows which flow sent what.
HTTP 200 is not success
Mailjet reports the result per message in the response body:
{ "Messages": [ { "Status": "success", "To": [ { "MessageUUID": "…" } ] } ] }
A request can come back with HTTP 200 and "Status": "error" for the message
inside it. Both sides treat only the combination as success:
const ok = response.ok && status === "success";
and the hub raises with Mailjet’s Errors text so the drip ledger records a
failed send that gets retried, not a silent sent. A test feeds
{"Messages":[{"Status":"error"}]} with HTTP 200 and expects a failure.
Turn off click and open tracking
Mailjet’s default can include open and click tracking. Click tracking rewrites every link through the provider’s redirector. My confirm and unsubscribe links carry HMAC signatures; wrapping them in someone else’s redirect adds a third party to every click and changes what the recipient sees. Open tracking adds a pixel I don’t want on a privacy-first newsletter.
So every message sets:
"TrackOpens": "disabled",
"TrackClicks": "disabled"
The tests assert both, with a comment explaining why, so nobody “cleans up” those two lines later.
One-click unsubscribe as custom headers
Drip emails need the RFC 8058 pair:
List-Unsubscribe: <https://www.jalcocertech.com/api/forms/unsubscribe?t=…>
List-Unsubscribe-Post: List-Unsubscribe=One-Click
With SMTP I build them into the message myself (and had to stop Python from
folding them — that bug has its own post).
With Mailtrap and Mailjet they go into the JSON as custom headers: Mailtrap’s
headers object, Mailjet’s Headers object. Same Message.list_headers()
helper feeds all three senders.
Confirmation emails don’t get these headers: they’re transactional, sent once because someone asked, not marketing.
Rolling out a new provider safely
The order that kept the live site working the whole time:
- Ship the code with the old provider still selected. The Worker went out
with
EMAIL_PROVIDER = "mailtrap"and the new Mailjet path in place. No behaviour change, and the tests covered the new path. - Add the secrets with prompts, so they never pass through chat logs or
shell history:
npx wrangler secret put MAILJET_API_KEY npx wrangler secret put MAILJET_SECRET_KEY - Test the provider apart from the app. On the laptop, the same keys in a
local
.env(chmod 600, git-ignored), then one direct send:
This isolated problems: when the API accepted the request but nothing arrived, I knew the keys were fine and the issue was the sending domain (Mailjet held my emails).set -a; . ./.env; set +a leadshub mail-test [email protected] - Flip the config —
EMAIL_PROVIDER = "mailjet"and the newSENDER_EMAIL— deploy, and run one real subscription with an allow-listed test address.
Rolling back is the same flip in reverse.
What stays provider-independent
| Concern | Lives in |
|---|---|
| Who may receive email (confirmed, not suppressed) | lead store + suppression list |
| Signed confirm / unsubscribe links | Worker + hub, shared secret |
| Unsubscribe footer, placeholders | hub renderer |
| Never sending twice | hub send ledger |
| From address, provider, tracking | configuration + adapter |
The provider only moves bytes. That’s why choosing one was a low-stakes decision (Mailpit vs Mailtrap vs listmonk).
Takeaways
- Put every provider behind one
send()from day one; switching becomes a config flip. - Know each provider’s auth: Mailtrap is one bearer token, Mailjet is a key pair.
- Check per-message status, not just HTTP 200.
- Disable click and open tracking when links are signed or privacy matters.
- Pass RFC 8058 headers explicitly as provider custom headers.
- Deploy the new path with the old provider selected, test the provider on its own, then flip.
Next: from form to first drip — testing an email pipeline on production.