Skip to content

Repository files navigation

bb-email-agents

One email interface over SendGrid, AWS SES and Resend.

Two rules hold across every provider:

  1. 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.
  2. sendEmail never 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.

Install

npm install bb-email-agents

The 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' provider

Reaching 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.

Credentials

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.

Sending

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.

Attachments

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.

Helpers

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.

Provider differences

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.

Errors

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.

Development

npm install
npm test
npm run typecheck
npm run build

Tests 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.

License

MIT

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages