mail-sdk
v0.0.2
Published
Unified, agent-friendly email SDK. One API for send, inbox, contacts, and domains. Web-standard I/O. Signed webhook parsing. Tool factories for the Vercel AI SDK, OpenAI Agents, Claude Agent SDK, and MCP.
Keywords
Readme
mail-sdk
Unified, agent-friendly SDK for email. One small API for send, inbox, contacts, and domains. Web-standard I/O. Signed webhook parsing. First-class tool factories for the Vercel AI SDK, OpenAI Agents, Claude Agent SDK, and MCP.
Mail is a capability registry. Mount any combination of providers; the call shape stays the same. Send through Resend / SendGrid / Postmark / Mailgun / SES / SMTP / Loops / Plunk / Mailtrap / Brevo / Mandrill / SparkPost / MailerSend / Mailjet / AgentMail / Cloudflare. Read through Gmail / Microsoft Graph / IMAP / AgentMail.
Install
npm install mail-sdkEach adapter either depends on the provider's official Node SDK or speaks HTTP directly via a bundled fetch client. A default install pulls everything the bundled adapters need; prune anything you don't import via your bundler's tree-shaking.
Quick start
Pick the axes you need. Each slot on Mail is optional.
Send-only
import { Mail } from 'mail-sdk';
import { resend } from 'mail-sdk/resend';
const mail = new Mail({
transport: resend({ apiKey: process.env.RESEND_API_KEY! }),
});
await mail.send({
from: { email: '[email protected]', name: 'Acme' },
to: '[email protected]',
subject: 'Welcome',
html: '<p>Glad you are here.</p>',
});Read-only
import { Mail } from 'mail-sdk';
import { gmail } from 'mail-sdk/gmail';
const mail = new Mail({
inbox: gmail({ accessToken: token }),
});
const { items } = await mail.inbox!.list({ limit: 20 });Composed
import { Mail } from 'mail-sdk';
import { resend } from 'mail-sdk/resend';
import { gmail } from 'mail-sdk/gmail';
import { resendContacts } from 'mail-sdk/resend/contacts';
import { resendDomains } from 'mail-sdk/resend/domains';
const mail = new Mail({
transport: resend(),
inbox: gmail({ accessToken }),
contacts: resendContacts({ audienceId: 'aud_XXXX' }),
domains: resendDomains(),
});Calling a method whose capability is not mounted throws MailError(code: 'Unsupported') with a clear message.
What you get
- Capability registry. One
Mailclass, four optional slots. Compose any subset. - Unified call shape.
EmailMessage,InboxMessage,Contact,Domain— same types across every provider. - Web-standard I/O. Attachment bodies accept
Blob,File,ReadableStream,Uint8Array,ArrayBuffer, orstring. No provider-specific types leak. - Capability introspection. Every provider declares optional features (
cancel,scheduled,idempotency,template, ...). Agent bundles use this to expose only the tools the underlying transport supports. - Escape hatch. Each provider exposes its native client at
provider.raw. Provider quirks remain one property access away. - Signed webhooks. Per-provider
parseWebhookhelpers verify the signature and return typed events. Inbound mail webhooks share a unifiedInboundEmailshape. - Agent-ready. Tool factories for the Vercel AI SDK, OpenAI Agents, Claude Agent SDK, and MCP. Tools are emitted only for capabilities the mounted transport supports.
- Retry + idempotency.
withRetryhelper plus per-requestidempotencyKey(honored natively by Resend). - Tree-shakeable. Each provider is a separate entry point.
Send providers
| Provider | Batch | Cancel | Scheduled | Template | Idempotency | Webhook |
|---|---|---|---|---|---|---|
| mail-sdk/resend | native | yes | yes | yes | yes | Svix |
| mail-sdk/sendgrid | parallel | yes (batchId) | yes | yes | — | ECDSA |
| mail-sdk/postmark | native | — | — | yes | — | parse-only |
| mail-sdk/mailgun | parallel | — | yes | yes | — | body-HMAC |
| mail-sdk/ses | parallel | — | — | yes | — | SNS (parse-only) |
| mail-sdk/smtp | serial | — | — | — | — | — |
| mail-sdk/loops | parallel | — | — | required | — | Svix |
| mail-sdk/plunk | parallel | — | — | yes | — | — |
| mail-sdk/mailtrap | native | — | — | yes | — | hex HMAC |
| mail-sdk/brevo | parallel | yes | yes | yes | — | HMAC |
| mail-sdk/mandrill | parallel | yes | yes | yes | — | SHA-1 (URL + params) |
| mail-sdk/sparkpost | parallel | yes | yes | yes | — | unsigned (use Basic Auth) |
| mail-sdk/mailersend | parallel | yes | yes | yes | — | HMAC |
| mail-sdk/mailjet | native | — | — | yes | — | unsigned (use Basic Auth) |
| mail-sdk/agentmail | parallel | yes | yes (via drafts) | — | — | Svix |
| mail-sdk/cloudflare | parallel | — | — | — | — | Worker email() handler |
Inbox providers
| Provider | Auth | Search | Threads |
|---|---|---|---|
| mail-sdk/gmail | OAuth bearer | yes | yes (id only) |
| mail-sdk/msgraph | OAuth bearer | yes | yes (id only) |
| mail-sdk/imap | password / XOAUTH2 | yes | — |
| mail-sdk/agentmail | API key | yes (labels) | yes (id only) |
IMAP requires both imapflow and postal-mime as peer dependencies (postal-mime parses message bodies with correct charset and Content-Transfer-Encoding handling). The Cloudflare inbound parser also requires postal-mime.
Sending mail
await mail.send({
from: '[email protected]',
to: ['[email protected]', { email: '[email protected]', name: 'Other' }],
cc: '[email protected]',
bcc: '[email protected]',
replyTo: '[email protected]',
subject: 'Hello',
html: '<p>Hi</p>',
text: 'Hi',
attachments: [
{ filename: 'doc.pdf', content: pdfBuffer, contentType: 'application/pdf' },
],
headers: { 'X-Campaign': 'spring-2026' },
tags: { env: 'prod', category: 'newsletter' },
scheduledAt: new Date(Date.now() + 60_000),
idempotencyKey: '7a1c5e2d-...',
});Template-driven send:
await mail.send({
from: '[email protected]',
to: '[email protected]',
subject: 'Welcome',
template: { id: 'welcome', data: { name: 'Alice' } },
});Reading mail
const { items } = await mail.inbox!.list({ folder: 'INBOX', limit: 20 });
const msg = await mail.inbox!.get(items[0].id);
await mail.inbox!.markRead(msg.id, true);
await mail.inbox!.archive(msg.id);Contacts
import { resendContacts } from 'mail-sdk/resend/contacts';
const contacts = resendContacts({ audienceId: 'aud_XXXX' });
await contacts.create({ email: '[email protected]', firstName: 'Jane' });Also available: brevo/contacts, loops/contacts, mailjet/contacts, plunk/contacts.
Domains
import { resendDomains } from 'mail-sdk/resend/domains';
const domains = resendDomains();
const created = await domains.create({ name: 'send.acme.com' });
await domains.verify(created.id);Also available: agentmail/domains, brevo/domains, mailersend/domains, mailgun/domains, mailjet/domains, postmark/domains, sendgrid/domains, sparkpost/domains.
Webhooks
Per-provider parseWebhook helpers verify signatures and return typed events. Inbound mail (where the provider supports it) shares a unified InboundEmail shape.
import { parseWebhook } from 'mail-sdk/resend/webhook';
app.post('/webhooks/resend', (req, res) => {
try {
const event = parseWebhook(req.rawBody, req.headers, {
secret: process.env.RESEND_WEBHOOK_SECRET!,
});
} catch (err) {
return res.status(401).send('invalid signature');
}
});Verification details per provider:
mail-sdk/resend/webhook,mail-sdk/loops/webhook,mail-sdk/agentmail/webhook— Standard Webhooks (Svix). HMAC-SHA256 over${id}.${timestamp}.${body}.mail-sdk/sendgrid/webhook— ECDSA P-256 overtimestamp + raw_body. Pass the dashboard public key (PEM or raw base64 DER, both accepted).mail-sdk/postmark/webhook— no signature (Postmark uses Basic Auth at the receiver).mail-sdk/mailgun/webhook— body-embedded HMAC-SHA256 overtimestamp + token.mail-sdk/mailtrap/webhook— hex HMAC-SHA256 over raw body,Mailtrap-Signatureheader.mail-sdk/ses/webhook— SNS envelope unwrap. Production receivers should verify the SNS X.509 signature externally.mail-sdk/brevo/webhook— optional hex HMAC-SHA256 over raw body,X-Brevo-Signature(or legacyX-Mailin-Signature). Signing is a paid-plan feature; parser verifies only when a secret is supplied.mail-sdk/mandrill/webhook— HMAC-SHA1 (base64) overurl + sorted(key+value)form params,X-Mandrill-Signature.mail-sdk/sparkpost/webhook— unsigned. Gate the receiver with HTTP Basic Auth from the SparkPost dashboard.mail-sdk/mailersend/webhook— hex HMAC-SHA256 over raw body,Signatureheader (also acceptsx-mailersend-signature).mail-sdk/mailjet/webhook— unsigned (Mailjet v3.1 has no signature scheme). Gate with Basic Auth.
Inbound parsers exist for Resend, Postmark, SendGrid, Mailgun, AgentMail, and Cloudflare. See /docs/inbound.
Retry + idempotency
import { withRetry } from 'mail-sdk/retry';
await withRetry(() => mail.send(message), {
attempts: 5,
initialDelayMs: 500,
});withRetry respects MailError.retryable. Combine with idempotencyKey for safe retries that won't double-send.
Agent tools
Tool bundles for the agent runtimes below. Each only emits tools whose capability is mounted on the Mail.
Vercel AI SDK
import { createAiSdkMailTools } from 'mail-sdk/agents/ai-sdk';
const tools = createAiSdkMailTools(mail);
await generateText({ model, prompt, tools });OpenAI Agents
import { createOpenAIMailTools } from 'mail-sdk/agents/openai';
const { sendEmail, cancelEmail } = createOpenAIMailTools(mail);
new Agent({ name: 'mailer', tools: [sendEmail, cancelEmail].filter(Boolean) });Claude Agent SDK
import { createClaudeMailTools } from 'mail-sdk/agents/claude';
const { mcpServers, allowedTools, canUseTool } = createClaudeMailTools(mail);
for await (const msg of query({ prompt, options: { mcpServers, allowedTools, canUseTool } })) { /* ... */ }MCP (Model Context Protocol)
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { registerMailTools } from 'mail-sdk/agents/mcp';
const server = new McpServer({ name: 'mail', version: '1.0.0' });
registerMailTools(server, mail);Works with Cloudflare Agents (agents/mcp, createMcpHandler), Cursor, Continue, and any self-hosted MCP server.
needsApproval: true is the default for sendEmail and cancelEmail in the AI SDK / OpenAI / Claude bundles. Override with approvalRequired: [] to opt out. MCP has no tool-level approval primitive; gate inside your host's request lifecycle.
Errors
Every provider normalizes provider errors into a single MailError:
import { MailError } from 'mail-sdk';
try {
await mail.send({ /* ... */ });
} catch (err) {
if (err instanceof MailError && err.retryable) {
// back off and retry
}
}Codes: InvalidMessage, Unauthorized, RateLimited, NotFound, Unsupported, Provider.
Agent-friendly docs
Every documentation page is available as raw markdown at /docs/<slug>.md. The full corpus lives at /llms.txt and /llms-full.txt (also as .md).
Development
The published mail-sdk package supports Node >=18 at runtime. The monorepo itself (this repo) requires Node >=20 because apps/www uses Next.js 16, which dropped Node 18 support. .npmrc has engine-strict=true, so cloning the repo and running pnpm install on Node 18 will be rejected. End-user installs of the published package are unaffected.
pnpm install
pnpm dev # turbo run dev across packages
pnpm test # vitest run in packages/mail-sdk
pnpm types # tsc --noEmit across the workspace
pnpm lint # biome check (use lint:fix to auto-fix)
pnpm build # turbo run buildLicense
MIT
