Rafters Mail

Newsletters and Broadcasts

Sending email to many recipients via mailing lists, subscribers, and campaigns.


Vocabulary

@rafters/mail uses generic platform terms in its interface surface:

Interface nameVendor synonymsMeaning
MailingListAudience (Resend), List (Mailchimp)A named collection of subscribers
SubscriberContact, Audience MemberSomeone on a mailing list
CampaignBroadcastA message sent to a mailing list

Vendor terms (audience, broadcast) appear inside adapter implementations and in the platform-side mirror tables, but they do not leak into the interface surface consumers call.


The EmailProvider surface

All mailing list, subscriber, and campaign operations go through the EmailProvider interface. The provider is the authoritative source of truth for list membership and delivery. @rafters/mail does not duplicate that data locally unless the consumer opts into platform-side tracking.

interface EmailProvider {
  // Mailing lists
  createMailingList(name: string): Promise<MailingList>;
  getMailingList(id: string): Promise<MailingList>;
  deleteMailingList(id: string): Promise<void>;

  // Subscribers
  addSubscriber(listId: string, email: string, data?: SubscriberData): Promise<Subscriber>;
  removeSubscriber(listId: string, subscriberId: string): Promise<void>;
  updateSubscriber(subscriberId: string, updates: SubscriberUpdates): Promise<Subscriber>;
  listSubscribers(listId: string): Promise<Subscriber[]>;

  // Campaigns
  sendCampaign(params: CampaignParams): Promise<{ id: string }>;
  getCampaign(id: string): Promise<{ id: string; subject: string; sentAt: Date }>;
  createCampaignDraft(params: CampaignParams): Promise<{ id: string }>;
  sendCampaignDraft(campaignId: string): Promise<{ id: string }>;
  getCampaignStatus(campaignId: string): Promise<CampaignStatus>;

  // Audiences (read-only list of available mailing lists)
  listAudiences(): Promise<Audience[]>;
}

MailingList, Subscriber, Campaign shapes

interface MailingList {
  id: string;
  name: string;
  createdAt: Date;
}

interface Subscriber {
  id: string;
  email: string;
  firstName?: string;
  lastName?: string;
  unsubscribed: boolean;
}

interface CampaignParams {
  listId: string;
  subject: string; // 1..200 chars
  html: string; // min 1 char
  text?: string;
  from: string; // sender email
  replyTo?: string;
  name?: string;
}

interface CampaignStatus {
  id: string;
  status: "draft" | "queued" | "sending" | "sent" | "cancelled";
  subject: string;
  sentAt: Date | null;
}

sendCampaign publishes a campaign immediately. createCampaignDraft + sendCampaignDraft is the two-step flow for drafts that might be edited before send. Polling getCampaignStatus is how you observe the send through queued -> sending -> sent.


Platform-side mirror tables (optional)

The @rafters/mail schema ships three newsletter-related tables in packages/core/src/schema/newsletter.ts:

TablePurpose
platform_audienceLocal mirror of a mailing list, keyed by providerListId. Stores name, description, and a URL-safe slug. Use when you want to show audiences in your own UI without re-fetching from the provider.
platform_subscriberAssociation between one of your app users (userId) and one audience (audienceId), plus the providerSubscriberId for provider correlation. Use when you need to answer “which of my users is on which list” without round-tripping the provider.
broadcast_auditAudit log of campaign sends: providerCampaignId, subject, contentHash, sentBy (user id), audienceName, recipientCount, sentAt. Use for compliance, “who sent what to whom” queries, and content-hash deduplication across sends.

These tables are not in the exported migrationSQL string. They exist as Zod row schemas in @rafters/mail (platformAudienceRowSchema, platformSubscriberRowSchema, broadcastAuditRowSchema) and as Drizzle table definitions in @rafters/mail-drizzle (platformAudience, platformSubscriber, broadcastAudit) that you can include in your own migrations if you want platform-side tracking. If you only use the EmailProvider interface and trust the provider’s own audience/subscriber storage, you do not need these tables at all.


Templates

Campaign HTML is usually rendered from a React Email template via the TemplateRenderer interface. See the @rafters/mail-react-email package for the shipped renderer and baseline templates, and its docs/templates.md for writing your own.

Every campaign email should include an unsubscribe link as required by CAN-SPAM and similar regulations. The provider handles the actual unsubscribe flow when a recipient clicks the link; the template is responsible for emitting a valid URL that the provider routes back to the subscriber record.


Delivery and analytics

Delivery events (delivered, bounced, complained, opened, clicked) come from the provider’s webhook. @rafters/mail-resend ships a webhook handler at @rafters/mail-resend/webhooks that validates the signature and invokes user-provided callbacks for each event type. Correlate events back to campaigns via the providerCampaignId stored in broadcast_audit, or query the provider directly with getCampaignStatus.