Providers
mailery never talks to SMTP directly — sends always route through a transactional email provider. Provider-specific quirks (auth, payload shape, webhook format, signature verification) are hidden behind a uniform interface.
SendGrid (built in)
import { SendGridProvider } from 'mailery'
new SendGridProvider({
apiKey: process.env.SENDGRID_API_KEY!,
webhookVerificationKey: process.env.SENDGRID_WEBHOOK_KEY!,
webhookToleranceSeconds: 300, // replay window for signed webhooks; 0 or false disables
sendRatePerSecond: 10, // shared IP default; raise on dedicated IP
sandbox: process.env.NODE_ENV !== 'production',
})One-time setup on SendGrid side
Automated path (Cloudflare DNS)
The whole setup below collapses into one command if your DNS is on Cloudflare: npx mailery setup-sendgrid --domain X --webhook-url Y --cloudflare. It's idempotent and safe to re-run. See Deliverability → Automated setup for the full walkthrough.
1. Authenticate your sender domain (SPF / DKIM / DMARC)
Without DKIM, expect <10% inbox placement at Gmail. Full step-by-step is in Deliverability → SendGrid setup — covers the dashboard path, the DNS records to add, and how to verify everything is passing.
Short version:
- SendGrid dashboard → Settings → Sender Authentication → Authenticate Your Domain
- Add the 3-5 CNAMEs SendGrid generates to your DNS provider
- Add an SPF TXT record (
v=spf1 include:sendgrid.net ~allif you don't have one yet) - Add a DMARC TXT record at
_dmarc.yourdomain.com(start withp=nonefor monitoring)
If you're running separate marketing and transactional domains, authenticate each domain separately — each gets its own DKIM key and reputation.
2. Configure the Event Webhook (mailery's inbound signal)
The event webhook is how SendGrid tells mailery what happened to each send (delivered, bounced, opened, clicked, complained, unsubscribed). Without it, mailery can't update send records or maintain suppression.
SendGrid dashboard → Settings → Mail Settings → Event Webhook:
- HTTP Post URL:
https://yourdomain.com/m/webhooks/sendgrid(must be reachable from the public internet, not behind your admin auth) - Actions to be POSTed: check delivered, open, click, bounce, dropped, spamreport, unsubscribe. Skip deferred (mailery treats it as a soft bounce; SendGrid retries internally) and processed (noise — every send produces one).
- Toggle Event Webhook Status to Enabled.
- Save.
One SendGrid account can hold several event webhooks. If the account already serves another app (or a staging install), create a new webhook rather than editing the existing one — editing repoints it, and the old consumer stops receiving events without any error. Give yours a friendly name so the dashboard shows who owns it. mailery setup-sendgrid does exactly this, matching on the URL you pass.
3. Generate the Signed Event Webhook key
Same settings page, on your webhook (keys are per-webhook — another webhook's key will fail verification), scroll to Signed Event Webhook:
- Click Enable Signing if not already on.
- SendGrid generates an ECDSA public verification key.
- Click Test Your Integration to confirm SendGrid can reach the URL.
- Copy the Verification Key exactly as shown — a single-line base64 string starting with
MFkw…(the same valuemailery setup-sendgridprints, and the API'spublic_key). A PEM block starting with-----BEGIN PUBLIC KEY-----works too, including one whose newlines were escaped to\nfor an env file. - Paste it as the
webhookVerificationKeyoption (or setSENDGRID_WEBHOOK_KEYenv var if usingMailer.fromEnv()). A value that is not a public key makesMailer.initthrow — before 0.16.1 it silently failed every signature check instead.
mailery verifies every inbound webhook signature against this key. Unsigned or invalid-signature requests are rejected with 401. Don't skip this — without verification, anyone who finds your webhook URL can fake bounce events and poison your suppression list.
4. (Optional) Tune the replay window
A signature proves a payload came from SendGrid. It doesn't prove it's current — anyone who captures one valid request could otherwise resend it forever. SendGrid signs timestamp + body, so mailery also checks that the signed timestamp is recent:
new SendGridProvider({
apiKey: SG_KEY,
webhookVerificationKey: SG_WEBHOOK_KEY,
webhookToleranceSeconds: 300, // the default — 5 minutes either side of now
})- Default
300(5 minutes), matching the conventional tolerance used by other signed-webhook SDKs. Comfortably wider than NTP-grade clock drift or provider retry jitter. - Rejected in both directions — too old and implausibly far in the future, since clock skew cuts both ways.
- Widen it if your host's ingress genuinely queues webhooks (
webhookToleranceSeconds: 900). - Disable it with
webhookToleranceSeconds: 0orfalseif delivery delays are unbounded. The signature check still applies; you're only giving up replay protection.
Rejections look identical to a bad signature: a bare 401, no detail about why.
5. (Optional) IP allowlist
If your edge / CDN supports it, allowlist SendGrid's webhook source IPs (SendGrid publishes them at https://api.sendgrid.com/v3/access_settings/whitelist). Defense-in-depth over the signature check.
Sandbox mode
When sandbox: true, SendGrid validates the request but doesn't deliver. Useful for CI / staging environments where you want the full send pipeline to run but never email real users.
NullProvider (tests + local dev)
import { NullProvider } from 'mailery'
const provider = new NullProvider()
// ...
const mailer = await Mailer.init({
providers: { null: provider },
defaultProvider: 'null',
// ...
})
// After a send:
expect(provider.sent[0]?.subject).toContain('Welcome')
provider.reset() // clear between testsStores every send() call in provider.sent so tests can assert on what would have gone out. No network calls.
Routing per template kind
If you want marketing through SendGrid (priced for volume) and transactional through Postmark (priced for inbox placement):
await Mailer.init({
providers: {
sendgrid: new SendGridProvider({ apiKey: SG_KEY }),
postmark: new PostmarkProvider({ apiKey: PM_KEY }), // when Postmark ships
},
defaultProvider: 'sendgrid',
defaultTransactionalProvider: 'postmark',
})The send pipeline picks a provider in this order:
- Step override (
{ type: 'send', providerOverride: 'postmark' }) - Template
providerOverridefield - Kind-specific default (
defaultTransactionalProviderfor kind=transactional) - Global
defaultProvider
Adding your own provider
Implement the MailProvider interface — see Custom providers reference for the full signature.
import type { MailProvider, SendArgs, SendResult, NormalizedEvent } from 'mailery'
class MailerSendProvider implements MailProvider {
readonly name = 'mailersend'
readonly sendRatePerSecond = 25
constructor(private apiKey: string) {}
async send(args: SendArgs): Promise<SendResult> {
const res = await fetch('https://api.mailersend.com/v1/email', {
method: 'POST',
headers: { Authorization: `Bearer ${this.apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({
from: { name: args.fromName, email: args.fromEmail },
to: [{ email: args.to }],
subject: args.subject,
html: args.html,
text: args.text,
headers: Object.entries(args.headers ?? {}).map(([k, v]) => ({ name: k, value: v })),
}),
})
if (!res.ok) throw new Error(`mailersend ${res.status}`)
const body = await res.json()
return { providerId: body.message_id, status: 'accepted' }
}
async verifyWebhook(rawBody: Buffer, headers: Record<string, string>): Promise<boolean> {
// HMAC verification specific to MailerSend
// ...
return true
}
parseWebhookEvents(payload: unknown): NormalizedEvent[] {
// Normalize MailerSend's webhook payload into our shape
return []
}
}Pass it to Mailer.init({ providers: { mailersend: new MailerSendProvider(KEY) }, defaultProvider: 'mailersend' }) and you're done — the runner doesn't know or care which provider it's calling.
Status of other providers
| Provider | Status |
|---|---|
| SendGrid | ✅ Shipped in V1 |
| Postmark | Designed, implementation in Phase 3 |
| AWS SES | Designed, implementation deferred |
| Resend | Designed, implementation deferred |
| MailerSend | Community-implementable today via the interface above |
The provider interface is intentionally tiny (send, verifyWebhook, parseWebhookEvents). Most implementations are 80-150 lines.