Mailer
The library's main class. Hosts call Mailer.init(config) to construct an instance.
import { Mailer } from 'mailery'Static methods
Mailer.init(config)
static async init(config: MailerConfig): Promise<Mailer>Connects to Mongo, ensures indexes on all mailer collections, instantiates the queue driver. Does not start workers — call .startWorkers() for that.
const mailer = await Mailer.init({
db,
adapter: new MongoContactAdapter({ db, collection: 'users' }),
queue: { driver: 'bull', redis: { url: process.env.REDIS_URL! } },
providers: { sendgrid: new SendGridProvider({ apiKey: '...' }) },
defaultProvider: 'sendgrid',
publicUrl: 'https://yourdomain.com',
unsubscribeSecret: process.env.MAILER_UNSUB_SECRET!,
})A host on a different mongodb driver major (every Mongoose 8 app) gives a URI instead of a Db, and an adapter factory that mailery calls with its own database:
const mailer = await Mailer.init({
mongo: { uri: process.env.MONGODB_URI!, dbName: 'app' },
adapter: (db) => new MongoContactAdapter({ db, collection: 'users' }),
// ...the rest as above
})
// ...
await mailer.stop() // closes the connection mailery opened; a host's `db` is never closedSee the configuration guide for the full MailerConfig.
Mailer.fromEnv()
static async fromEnv(): Promise<Mailer>Constructs a Mailer from MAILER_* env vars. See the guide for the variable list.
Event firing
registerEvent(input)
registerEvent(input: { name: string; dedupePolicy: 'once-per-contact' | 'once-per-day' | 'every-time' }): voidDeclares an event name + dedupe policy so subsequent fire() calls can omit the key.
fire(name, externalId, properties?, dedupeKey?)
fire(
eventName: string,
externalId: string,
properties?: Record<string, unknown>,
dedupeKey?: string,
): Promise<void>Records a behavioral event. If no dedupeKey is passed, one is derived from the registered policy. Without either, throws.
Duplicate-keyed calls are silent no-ops at the unique index.
fireFromSession(session, name, externalId, properties?, dedupeKey?)
fireFromSession(
session: ClientSession,
eventName: string,
externalId: string,
properties?: Record<string, unknown>,
dedupeKey?: string,
): Promise<void>Use inside a Mongo transaction. Writes to mailer_outbox within the session; the tick drains pending rows into mailer_events after the transaction commits.
Subscriptions
upsertSubscription(input)
upsertSubscription(input: {
externalId: string
source: string
consentTimestamp?: Date
consentIp?: string
consentUserAgent?: string
}): Promise<void>Creates / updates the mailer_subscriptions row for a contact. If requireDoubleOptIn: true in config, the new row is status: 'pending_doi'.
resubscribe(input)
resubscribe(input: {
externalId: string
source: string
scope?: 'marketing' | 'all' | `category:${string}` // default 'marketing'
consentTimestamp?: Date
consentIp?: string
consentUserAgent?: string
}): Promise<{ removedSuppressions: number }>An explicit opt-in from a contact who unsubscribed before. Deletes the reason: 'unsubscribed' suppression rows for the contact's address (marketing clears the marketing and all scopes; all clears every scope; category:<id> clears that category only), then calls upsertSubscription. Bounce, complaint, manual, list-cleaning and GDPR rows are left alone — a re-subscribed address that hard-bounced still gets nothing. Audit-logged as contact.resubscribe when it removed something.
Use this, not upsertSubscription, wherever a person clicks "subscribe" again: upsertSubscription flips the status but the suppression check runs at enqueue time regardless, so on its own it produces a contact that reads subscribed while every send comes back suppressed. It is a separate method on purpose — a backfill that re-upserts every account must not resurrect addresses that opted out.
unsubscribe(email, opts)
unsubscribe(email: string, opts: {
scope: 'all' | 'marketing' | 'transactional' | `category:${string}`
reason?: 'user_request' | 'hard_bounce' | 'complaint' | 'manual' | 'gdpr_forget' | 'list_cleaning'
source?: string
notes?: string
}): Promise<void>Records an unsubscribe + adds a suppression row. Same path as the public /m/unsub/:token endpoint. A category:<id> scope writes the suppression row only and leaves the subscription status alone; it is accepted even for an id no longer declared, because an opt-out is never refused.
getPreferences(email)
getPreferences(email: string): Promise<{ marketing: boolean; categories: Record<string, boolean>; pausedUntil: Date | null }>The address's current opt-in state across the categories declared in config (case-insensitive, hashed GDPR rows count). marketing is false when an all or marketing suppression is live, and then every category reads false. Transactional is never listed. pausedUntil is the expiry of a live marketing_pause row, else null; a pause never makes marketing false.
pauseMarketing(email, opts) · resumeMarketing(email, opts?)
pauseMarketing(email: string, opts: { days: number; source?: string }): Promise<{ pausedUntil: Date }>
resumeMarketing(email: string, opts?: { source?: string }): Promise<{ resumed: boolean }>Pause all marketing email to an address for days days (an integer 1–365, else it throws), or lift the pause early. A pause is one marketing_pause suppression row (reason paused, expiresAt set); pausing again replaces it, and an unsubscribe row is never touched. Transactional mail is not paused. Source defaults to 'api'. Audit-logged as contact.pause and contact.resume. The preference page's lengths come from MailerConfig.preferences.pauseDays (default [7, 14, 30]; [] hides the control).
setPreferences(email, update, opts?)
setPreferences(
email: string,
update: { marketing?: boolean; categories?: Record<string, boolean> },
opts?: { source?: string }, // default 'api'
): Promise<{ optedOut: string[]; optedIn: string[] }>The preference page's save, for a host settings screen. marketing: false writes a marketing opt-out and ignores categories. marketing: true clears marketing/all opt-outs written by unsubscribes. Per category, false writes category:<id> and true deletes it. Only reason: 'unsubscribed' rows are ever deleted. Undeclared ids throw before any write. Audit-logged as contact.preferences.
Suppression
suppress(email, opts)
suppress(email: string, opts: {
scope: 'all' | 'marketing' | 'transactional'
reason: 'unsubscribed' | 'hard_bounce' | 'complaint' | 'manual' | 'list_cleaning' | 'gdpr_forget'
source?: string
notes?: string
expiresAt?: Date
}): Promise<void>Adds a suppression row directly. Idempotent (upsert on (email, scope)).
Tags
tag(externalId, tag) / untag(externalId, tag)
tag(externalId: string, tag: string): Promise<void>
untag(externalId: string, tag: string): Promise<void>Adds / removes a tag. Routes through adapter.addTags / removeTags if defined; otherwise writes to mailer_contact_tags.
Flow abort
abortFlow(flowSlug, externalId, opts?)
abortFlow(
flowSlug: string,
externalId: string,
opts?: { reason?: string },
): Promise<{ abortedRuns: number; cancelledSends: number }>Aborts every active run of the flow for that contact, immediately. Exits the runs (exitReason: 'aborted_by_host:<reason>') — including runs parked in a wait, whose delayed wake-up then no-ops — and cancels any of the flow's emails still sitting undispatched in the send queue (queued, failed awaiting retry, or deferred by the contact policy → cancelled; a deferred send is cancelled even when its run has already completed and is counted in cancelledSends). Writes an audit row (flow.abort).
Call it from the same handler that processes the business event:
// user upgraded — stop the trial sequences
await mailer.abortFlow('trial-onboarding', userId, { reason: 'upgraded' })
await mailer.abortFlow('trial-winback', userId, { reason: 'upgraded' })No-op (zero counts) when nothing is active. Throws on an unknown flowSlug. Note: a flow with trigger: { once: true } will not re-enter for a contact with any prior run, aborted included.
abortAllFlows(externalId, opts?)
abortAllFlows(
externalId: string,
opts?: { reason?: string },
): Promise<{ abortedRuns: number; cancelledSends: number }>Same semantics with no flow filter — exits every active run for the contact across all flows (audit action flow.abort_all). For "stop everything" events: account deleted, churned.
Programs
See the Programs guide for the model. These need factsAdapter in the config.
saveProgramDraft(definition, opts)
saveProgramDraft(
definition: ProgramDefinition,
opts: { actor: string; notes?: string },
): Promise<ProgramDoc>Saves definition as the draft, creating the program (disabled, version: 0, never ticks) when the slug is new. Throws a ZodError on a structurally invalid definition; semantic checks run at publish. Audit action program.save_draft.
publishProgram(slug, opts)
publishProgram(
slug: string,
opts: { actor: string },
): Promise<{ ok: true; version: number } | { ok: false; issues: Array<{ path: string; message: string }> }>Validates the draft (categories, declared facts, templates, requires cycles) and publishes it: version + 1, snapshot in mailer_program_versions, draft cleared. Returns every issue instead of throwing. Throws on an unknown slug. Audit action program.publish.
setProgramEnabled(slug, enabled, opts)
setProgramEnabled(slug: string, enabled: boolean, opts: { actor: string }): Promise<void>Throws for an unknown or never-published program, and when enabling without a factsAdapter. Enabling moves the entry and Facts Changed watermarks to now. Audit actions program.enable / program.disable.
enterProgram(slug, subjectId)
enterProgram(slug: string, subjectId: string): Promise<{ created: boolean }>Creates the subject's run now, without an entry event (backfills, previews). created: false when the subject already has a run, whatever its status.
tickProgram(slug, subjectId)
tickProgram(slug: string, subjectId: string): Promise<ProgramTickResult>Ticks the subject's run now with trigger: 'forced'. Every rule a scheduled tick has still applies. Throws when the subject has no run. Returns { status: 'skipped', skipped } or { status: 'ticked', decisionId, reason, chosen, attempt, sendIds }.
abortProgram(slug, subjectId, opts?)
abortProgram(
slug: string,
subjectId: string,
opts?: { reason?: string; actor?: string },
): Promise<{ aborted: boolean; cancelledSends: number }>actor is recorded on the audit row (default host; the admin and agent routes pass the request's actor).
Like abortFlow: ends an active run immediately (exitReason: 'aborted_by_host: <reason>') and cancels its queued, deferred and held sends. No-op when there is no active run. Audit action program.abort when it aborted something.
getProgramState(slug, subjectId)
getProgramState(slug: string, subjectId: string): Promise<ProgramChecklistItem[] | null>The checklist for your in-app UI: [{ actionId, title, cta, status, isNext, attempts, completedAt }] in priority order, or null when the subject has no run.
GDPR
forget(externalId)
forget(externalId: string): Promise<void>Hard-deletes all PII for the contact across mailer collections, then inserts a mailer_suppressions row with email: null + emailHash retained. Future sends to the same email are blocked at the hash level — INVARIANT 9.
forgetSubject(subjectId)
forgetSubject(subjectId: string): Promise<{ runs: number; decisions: number }>Deletes a Program subject's runs and decision rows across all programs (decisions hold the facts snapshot). Audited as gdpr.forget_subject. Call it when you erase an account, together with forget(externalId) for each of the account's contacts.
exportContactData(externalId)
exportContactData(externalId: string): Promise<Record<string, unknown>>Returns a JSON-serializable object with all mailer-side data for the contact (subscription, events, flowRuns, sends, suppressions, tags). Pipe this into your host's GDPR export.
One-off sends
sendOneOff(input)
sendOneOff(input: {
templateSlug: string
externalId: string
vars?: Record<string, unknown>
providerOverride?: string
dedupeKey: string // required
}): Promise<{ sendId: string }>Ad-hoc transactional send (receipts, password resets). Same pipeline as flow sends — suppression check, tracking, idempotency via dedupeKey.
await mailer.sendOneOff({
templateSlug: 'password-reset',
externalId: user._id.toString(),
vars: { resetUrl: `https://yourdomain.com/reset?token=${tokenHash}` },
dedupeKey: `password-reset:${user._id}:${tokenHash}`,
})The dedupeKey prevents accidental double-sends if your caller retries.
Audit
audit(entry)
audit(entry: {
actor: string
action: string
resource: { collection: string; id?: string | ObjectId; slug?: string }
before?: Record<string, unknown> | null
after?: Record<string, unknown> | null
diffSummary?: string
ip?: string
userAgent?: string
requestId?: string
}): Promise<void>Writes an entry to mailer_audit_log. Useful when you mutate mailer collections directly from a deploy script:
await db.collection('mailer_flows').updateOne({ slug }, { $set: { 'draft.steps': newSteps } })
await mailer.audit({
actor: 'script:deploy',
action: 'flow.draft.update',
resource: { collection: 'mailer_flows', slug },
diffSummary: 'Added day-21 check-in step',
})Lifecycle
startWorkers()
startWorkers(): Promise<void>Spins up queue workers (Bull or Agenda, per your queue.driver) for tick / advance / send / webhook queues. Call from your worker process only.
stop()
stop(): Promise<void>Closes workers, queues, and underlying connections (Redis for the Bull driver). Call on SIGTERM for graceful shutdown.
getRunnerContext()
getRunnerContext(): RunnerContextReturns the internal context object passed to runner functions. Useful for tests that drive runTick, processOneRunStep, dispatchSend directly. Not part of the public API for production code.
Properties
| Property | Type | Notes |
|---|---|---|
mailer.db | Db | The Mongo database mailery owns its collections in. |
mailer.collections | Collections | Typed accessors for every mailer_* collection. |
mailer.adapter | ContactAdapter | Whatever you passed at init. |
mailer.providers | Record<string, MailProvider> | Configured providers map. |
mailer.queues | Queues | Driver-agnostic queue facade (add, getWaitingCount, close). |
mailer.config | ResolvedConfig | Resolved configuration with defaults applied. |
mailer.events | EventRegistry | Internal registry tracking event policies. |
Deliverability config (optional)
The MailerConfig interface accepts these optional sub-objects in addition to the core fields. Each is fully opt-in — leave it out and the feature stays dormant.
interface MailerConfig {
// ... core fields ...
circuitBreaker?: Partial<{
hardBounceRatePctTrip: number // default 2
complaintRatePctTrip: number // default 0.3
combinedBounceRatePctTrip: number // default 5
failedToSendRatePctDegrade: number // default 10
windowMinutes: number // default 60
minSendsBeforeEval: number // default 100
}>
dnsbl?: {
domainLists?: Array<{ host: string; label: string }>
ipLists?: Array<{ host: string; label: string }>
dedicatedIps?: string[]
intervalHours?: number // default 24, 0 disables
}
postmaster?: {
clientId: string
clientSecret: string
refreshToken: string // scope: postmaster.readonly
domains?: string[] // defaults to union of senderDomains keys + fromDefaults.email domain + transactionalFromDefaults.email domain
intervalHours?: number // default 24
}
snds?: {
accessKey: string
ips?: string[] // optional filter
intervalHours?: number // default 24
}
dmarc?: {
knownSources?: Array<{ ip: string; label: string; ignored?: boolean }>
retentionDays?: number // default 90 — old failures pruned by an hourly housekeeping job
}
mailTester?: {
apiKey: string
minScore?: number // default 8.0
requireScore?: boolean // default false — true also blocks never-checked content
cacheHours?: number // default 24
baseUrl?: string // override for staging
}
}See the configuration guide for behavior + the deliverability guide for the surrounding features.
Collections
mailer.collections exposes typed handles to every mailer-owned Mongo collection. New collections introduced with the deliverability features:
| Collection | Document type | Purpose |
|---|---|---|
health | HealthDoc | One doc per (sender domain × kind) bucket plus an _id: 'agg' aggregate. Replaces the prior singleton. |
dnsblChecks | DnsblCheckDoc | Latest scan result per (target, list). |
postmasterSnapshots | PostmasterSnapshotDoc | One doc per (domain, day) from Google Postmaster Tools. |
sndsSnapshots | SndsSnapshotDoc | One doc per (IP, activityStart) from Microsoft SNDS. |
dmarcReports | DmarcReportDoc | One doc per ingested DMARC aggregate report. |
dmarcFailures | DmarcFailureDoc | One doc per (report × non-aligned source IP). |
dmarcSourceTags | DmarcSourceTagDoc | Operator-set IP labels written from the admin UI. |
mailTesterScores | MailTesterScoreDoc | Cached Mail-Tester scores keyed on a content fingerprint. |
Each document type is exported from mailery for hosts that need to query mailer-owned collections directly.
Deliverability helpers
These functions live in mailery/server/runner/* and are exported for hosts that want to bypass the admin endpoints (e.g., scheduled jobs in your own process):
import {
recordHealthCounter,
evaluateHealth,
effectiveOverallStatus,
getBucketStatus,
} from 'mailery' // health.ts
import { runDnsblChecks } from 'mailery' // dnsbl.ts
import { runPostmasterPull } from 'mailery' // postmaster.ts
import { runSndsPull } from 'mailery' // snds.ts
import {
ingestDmarcAttachment, // bytes → parse → persist
parseDmarcReport, // pure XML → parsed report
extractDmarcXml, // pure decompress
pruneDmarcFailures,
resolveSourceTags,
suggestPolicyProgression,
} from 'mailery' // dmarc.ts
import { computeListHygiene } from 'mailery' // hygiene.ts
import {
createMailTesterClient,
evaluateMailTesterGate,
mailTesterContentKey,
} from 'mailery' // mail-tester.ts
import { lintTemplate } from 'mailery' // templates/linter.tsThe tick.ts runner already calls evaluateHealth, runDnsblChecks, runPostmasterPull, runSndsPull, and pruneDmarcFailures on its interval. Reach for these helpers directly only when you need on-demand execution outside the tick.