One email interface over SendGrid, AWS SES and Resend.
Two rules hold across every provider:
- Credentials are passed in per call. Nothing is read from the environment, so a single process can send on behalf of many tenants, each with its own provider and sender identity.
sendEmailnever throws. Failures come back as{ success: false, error }, so a caller that records delivery outcomes always has a result to record.
import { getEmailService } from 'bb-email-agents';
const mail = getEmailService(provider, credentials); // provider comes from your own config
const result = await mail.sendEmail({
to: 'client@example.com',
from: { email: 'billing@example.com', name: 'Acme Billing' },
subject: 'Invoice INV-1',
html: '<p>See attached.</p>',
attachments: [mail.createAttachment(pdfBuffer, 'INV-1.pdf', 'application/pdf')],
});
if (!result.success) {
console.error(result.error);
}Switching a tenant from SendGrid to SES is a change to that tenant's stored config. No call site changes.
npm install bb-email-agentsThe provider SDKs are optional peer dependencies, loaded with a dynamic import() at send time. Install only the ones you use:
npm install @sendgrid/mail # for the 'sendgrid' provider
npm install @aws-sdk/client-sesv2 # for the 'ses' provider
npm install resend # for the 'resend' providerReaching for a provider whose SDK is missing gives you an instruction, not a bare ERR_MODULE_NOT_FOUND.
Requires Node 18+. ESM and CommonJS builds are both published.
getEmailService(provider, credentials) takes the same credential shape for every provider, so you can store one row per tenant and never branch on the provider name:
| Field | SendGrid | AWS SES | Resend |
|---|---|---|---|
key |
API key | AWS Access Key ID | API key |
secret |
— | AWS Secret Access Key | — |
region |
— | AWS region, e.g. ap-southeast-2 |
— |
getEmailService('sendgrid', { key: sendgridApiKey });
getEmailService('sendgrid', sendgridApiKey); // bare string, equivalent
getEmailService('ses', { key: accessKeyId, secret: secretAccessKey, region });
getEmailService('resend', { key: resendApiKey });Credentials are validated at construction, so a misconfigured tenant fails loudly and immediately rather than silently returning a failed send later. Each credential set gets its own client instance, so two tenants sending concurrently cannot overwrite each other's configuration.
interface EmailOptions {
to: string | string[] | EmailAddress | EmailAddress[];
from: string | EmailAddress;
subject: string;
text?: string;
html?: string;
attachments?: EmailAttachment[];
replyTo?: string;
cc?: string | string[] | EmailAddress | EmailAddress[];
bcc?: string | string[] | EmailAddress | EmailAddress[];
templateId?: string; // SendGrid only
dynamicTemplateData?: Record<string, unknown>; // SendGrid only
}
interface EmailResponse {
success: boolean;
messageId?: string;
error?: string;
}EmailAddress is { email, name? }. A display name is rendered correctly for each provider, including non-ASCII names, which SES receives as RFC 2047 encoded-words.
Every service also exposes sendMultipleEmails(options[]), which sends sequentially and returns one result per input, in order. A failure does not abort the rest of the batch.
mail.createAttachment(buffer, 'INV-1.pdf', 'application/pdf');
mail.createInlineAttachment(buffer, 'logo.png', 'logo', 'image/png'); // <img src="cid:logo">An attachment either arrives as a file the recipient downloads or as an image the HTML body displays. Which one is the disposition field, and the two helpers are how you set it:
disposition |
contentId |
|
|---|---|---|
createAttachment(buffer, filename, mimeType?) |
always 'attachment' |
none |
createInlineAttachment(buffer, filename, contentId, mimeType?) |
'inline' |
required |
createAttachment takes no disposition argument because it is the attachment case: it sets disposition: 'attachment' outright, so a report or an invoice PDF built with it is a download and nothing else needs saying. Reach for createInlineAttachment only for an image the body references through cid:.
Building the EmailAttachment by hand is fine too. An attachment is treated as inline only when it carries both disposition: 'inline' and a contentId — 'inline' without one would be a part the HTML has no way to reference, so it is delivered as an ordinary attachment instead. Every other combination, including omitting disposition altogether, is an attachment. That is decided here rather than left to each provider's own default, so the three behave alike.
For SES the library assembles the raw MIME message itself, so attachments, non-ASCII subjects and long HTML lines survive encoding intact. bcc travels in the SES envelope only and never appears in the recipients' copy of the message.
isValidEmail(email) and validateEmails(emails) are available both as standalone exports and on the service object. They are the same functions the library uses internally to reject malformed addresses before a send.
The helpers and types are also published on their own, free of any provider SDK:
import { createAttachment, type EmailAttachment } from 'bb-email-agents/common';Import the package root only from code that actually sends. The root reaches all three SDKs, so a bundler following an import of it from shared or browser-bound code will try to pull them in — bb-email-agents/common is the entry point for building an attachment or validating an address somewhere that will never send.
The interface is identical, but the providers are not. Where a provider cannot do something, the library says so rather than sending a subtly different message:
| SendGrid | AWS SES | Resend | |
|---|---|---|---|
| HTML + text, cc, bcc, replyTo | yes | yes | yes |
| File attachments | yes | yes | yes |
Inline attachments (cid:) |
yes | yes | yes, on resend v4+ — older SDKs deliver them as regular attachments |
Dynamic templates (templateId) |
yes | rejected with an error | rejected with an error |
Anything rejected comes back as { success: false, error } before the provider is called, so a template-based message is never silently delivered with an empty body.
sendEmail resolves rather than throwing, whether the failure is a malformed address, a missing body, a rejected credential or a transport error. getEmailService is the one place that throws: on an unknown provider name or incomplete credentials.
Resend answers a rejected send with HTTP 200 and an error object in the body rather than throwing; this library checks for it, so a rejected send is never reported as a success.
npm install
npm test
npm run typecheck
npm run buildTests mock each provider SDK and assert what actually goes on the wire — the SendGrid message object, the SES SendEmailCommand envelope and its raw MIME body, and the Resend JSON payload. They do not call the live APIs.
MIT