mailery 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,5 +1,6 @@
1
1
  import { Db } from 'mongodb';
2
- import { C as ContactAdapter, a as Contact, A as AdapterFilter, d as Mailer, s as NullProvider } from './null-B0rPgE5_.cjs';
2
+ import { C as ContactAdapter, a as Contact, A as AdapterFilter, M as MailProvider, S as SendArgs, b as SendResult, N as NormalizedEvent, F as FlowStep, a2 as FlowTrigger, m as FlowGoal, U as TemplateKind, T as TemplateDoc, l as FlowDoc, D as DeliveryWindow, R as RunnerContext, d as Mailer, a3 as QueueDriverConfig } from './null-BCxlHRJ0.cjs';
3
+ export { s as NullProvider } from './null-BCxlHRJ0.cjs';
3
4
  import 'zod';
4
5
  import 'handlebars';
5
6
  import 'ioredis';
@@ -29,36 +30,255 @@ declare class MemoryContactAdapter implements ContactAdapter {
29
30
  removeTags(externalId: string, tags: string[]): Promise<void>;
30
31
  }
31
32
 
33
+ /**
34
+ * RecordingProvider — a `MailProvider` decorator that records every send and
35
+ * then delegates to whatever it wraps.
36
+ *
37
+ * This is what lets one test suite run against two very different backends.
38
+ * Wrapping `NullProvider` gives fast, offline, deterministic assertions;
39
+ * wrapping `SendGridProvider` makes the *same* assertions run against the real
40
+ * API, with real auth, real payload validation and (outside sandbox mode) real
41
+ * delivery. Tests assert on the recording either way, so nothing is duplicated
42
+ * between the two tiers.
43
+ *
44
+ * const provider = new RecordingProvider(new NullProvider())
45
+ * ...
46
+ * expect(provider.sent[0]?.subject).toContain('Alice') // SendArgs
47
+ * expect(provider.records[0]?.result?.status).toBe('accepted')
48
+ *
49
+ * `sent` is deliberately a bare `SendArgs[]`, matching `NullProvider.sent`, so
50
+ * this drops in wherever the old harness provider was used. `records` carries
51
+ * the richer per-call detail (result, error, duration) that the live tier needs.
52
+ */
53
+
54
+ interface SendRecord {
55
+ args: SendArgs;
56
+ /** Provider result, or null when the call threw. */
57
+ result: SendResult | null;
58
+ /** Error thrown by the wrapped provider, or null on success. */
59
+ error: Error | null;
60
+ /** Wall-clock ms the wrapped `send` took. Meaningful for the live tier. */
61
+ durationMs: number;
62
+ at: Date;
63
+ }
64
+ declare class RecordingProvider implements MailProvider {
65
+ readonly inner: MailProvider;
66
+ /** Every `SendArgs` handed to the provider, in call order. */
67
+ readonly sent: SendArgs[];
68
+ /** Same calls, with outcome attached. */
69
+ readonly records: SendRecord[];
70
+ constructor(inner: MailProvider);
71
+ get name(): string;
72
+ get sendRatePerSecond(): number | undefined;
73
+ send(args: SendArgs): Promise<SendResult>;
74
+ verifyWebhook(rawBody: Buffer, headers: Record<string, string>): Promise<boolean>;
75
+ parseWebhookEvents(payload: unknown, headers: Record<string, string>): NormalizedEvent[];
76
+ /** Last recorded send, or undefined. Sugar for the common single-send assertion. */
77
+ get last(): SendArgs | undefined;
78
+ /** All sends addressed to `email` (case-insensitive). */
79
+ toRecipient(email: string): SendArgs[];
80
+ reset(): void;
81
+ }
82
+
83
+ /**
84
+ * Document builders for tests. Hand-rolling a full `TemplateDoc` / `FlowDoc`
85
+ * literal is ~40 lines of noise per fixture; these fill every required field
86
+ * with a sane default so a test only states what it is actually asserting on.
87
+ *
88
+ * await H.seedTemplate({ slug: 'welcome', subject: 'Hi {{contact.fields.firstName}}' })
89
+ * await H.seedFlow({ slug: 'onboarding', eventName: 'Created', steps: [...] })
90
+ *
91
+ * `createdAt` defaults to a minute in the past: the event-trigger scan uses
92
+ * `lastTriggerScanAt ?? createdAt` as its watermark and only picks up events
93
+ * with `occurredAt > watermark`, so a flow created at the same instant as the
94
+ * event it should catch would never fire.
95
+ */
96
+
97
+ interface TemplateSpec {
98
+ slug: string;
99
+ name?: string;
100
+ description?: string;
101
+ kind?: TemplateKind;
102
+ fromName?: string;
103
+ fromEmail?: string;
104
+ replyTo?: string | null;
105
+ providerOverride?: string | null;
106
+ subject?: string;
107
+ preheader?: string;
108
+ /** MJML source — compiled to html + derived plain text. */
109
+ mjml?: string;
110
+ /** Pre-compiled HTML. Wins over `mjml`. */
111
+ html?: string;
112
+ /**
113
+ * Body copy wrapped in a minimal MJML document. The lazy path — use when the
114
+ * test cares about the rendered *content*, not the markup around it.
115
+ */
116
+ text?: string;
117
+ /** Explicit plain-text part. Omit to auto-derive from the HTML. */
118
+ plainText?: string;
119
+ variablesSchema?: TemplateDoc['variablesSchema'];
120
+ tags?: string[];
121
+ trackOpens?: boolean;
122
+ trackClicks?: boolean;
123
+ published?: boolean;
124
+ createdAt?: Date;
125
+ }
126
+ declare function buildTemplate(spec: TemplateSpec): Promise<TemplateDoc>;
127
+ /** Minimal valid MJML document around a body string. */
128
+ declare function wrapMjml(body: string): string;
129
+ interface FlowSpec {
130
+ slug: string;
131
+ name?: string;
132
+ description?: string;
133
+ steps: FlowStep[];
134
+ /** Shorthand for an `event` trigger. Ignored when `trigger` is given. */
135
+ eventName?: string;
136
+ /** Re-entry policy for the shorthand trigger. Defaults to true (enter once). */
137
+ once?: boolean;
138
+ trigger?: FlowTrigger;
139
+ enabled?: boolean;
140
+ goal?: FlowGoal;
141
+ audience?: string;
142
+ version?: number;
143
+ createdAt?: Date;
144
+ }
145
+ declare function buildFlow(spec: FlowSpec): FlowDoc;
146
+ declare const step: {
147
+ readonly send: (templateSlug: string, opts?: Omit<Extract<FlowStep, {
148
+ type: "send";
149
+ }>, "type" | "templateSlug">) => FlowStep;
150
+ readonly sendAt: (templateSlug: string, delivery: DeliveryWindow) => FlowStep;
151
+ readonly wait: (value: number, unit?: "minutes" | "hours" | "days" | "weeks") => FlowStep;
152
+ readonly tag: (addTags?: string[], removeTags?: string[]) => FlowStep;
153
+ readonly exit: (reason?: string) => FlowStep;
154
+ readonly fireEvent: (eventName: string, properties?: Record<string, unknown>) => FlowStep;
155
+ };
156
+
157
+ /**
158
+ * Deterministic runner driver.
159
+ *
160
+ * The test harness uses the `noop` queue driver: `queue.add()` throws nothing
161
+ * away quietly but nothing ever fires it either, so the runner only moves when
162
+ * a test tells it to. `drain` is that push — it alternates the trigger scan,
163
+ * the due-run sweep and send dispatch until the system is quiescent, which is
164
+ * what a real deployment converges to between ticks.
165
+ *
166
+ * await H.mailer.fire('Created', 'u1')
167
+ * await drain(H.mailer.getRunnerContext())
168
+ * expect(H.provider.sent).toHaveLength(1)
169
+ *
170
+ * "Quiescent" means: no active flow_run whose `nextActionAt` has passed, and
171
+ * no queued send left to dispatch. Runs parked in a `wait` step or deferred by
172
+ * a delivery window are *expected* to remain — move the clock forward and
173
+ * drain again.
174
+ */
175
+
176
+ interface DrainOptions {
177
+ /**
178
+ * Safety valve. Each round advances every due run by exactly one step, so a
179
+ * flow needs one round per step. Hitting the cap means the system did not
180
+ * converge — `settled` comes back false rather than throwing, so a test can
181
+ * assert on non-convergence deliberately.
182
+ */
183
+ maxRounds?: number;
184
+ /** Skip send dispatch to inspect `queued` send rows before they go out. */
185
+ dispatch?: boolean;
186
+ /**
187
+ * Let provider/render errors propagate. Off by default: `dispatchSend`
188
+ * rethrows so the real queue can retry, and a drain that unwound on the
189
+ * first failure could not assert on the failed send row it just wrote.
190
+ */
191
+ throwOnSendError?: boolean;
192
+ }
193
+ interface DrainResult {
194
+ rounds: number;
195
+ /** Sends passed to `dispatchSend`. Not all of them reached the provider. */
196
+ dispatched: number;
197
+ /** Errors thrown by `dispatchSend`, swallowed unless `throwOnSendError`. */
198
+ errors: Error[];
199
+ settled: boolean;
200
+ }
201
+ declare function drain(ctx: RunnerContext, opts?: DrainOptions): Promise<DrainResult>;
202
+ /**
203
+ * Dispatch every currently-queued send, once. Use when a test drove the flow
204
+ * with `drain({ dispatch: false })` and wants to inspect the queued rows first.
205
+ */
206
+ declare function dispatchQueued(ctx: RunnerContext): Promise<number>;
207
+
32
208
  /**
33
209
  * Test helpers — `import { ... } from 'mailery/testing'`.
34
210
  *
35
- * const harness = await createTestMailer({ seedContacts: [...] })
36
- * await harness.mailer.upsertSubscription({ externalId: 'u1', source: 'test' })
37
- * await harness.mailer.fire('Created', 'u1')
38
- * // drive the runner directly:
39
- * const { runTick, processOneRunStep, dispatchSend } = await import('mailery')
40
- * await runTick(harness.mailer.getRunnerContext())
41
- * expect(harness.provider.sent.length).toBe(1)
42
- * await harness.stop()
211
+ * const H = await createTestMailer({
212
+ * seedContacts: [{ externalId: 'u1', email: 'alice@example.com', tags: [], fields: {} }],
213
+ * })
214
+ * await H.seedContact({ externalId: 'u1', ... }) // adapter + subscription
215
+ * await H.seedTemplate({ slug: 'welcome', subject: 'Hi {{contact.fields.firstName}}' })
216
+ * await H.seedFlow({ slug: 'onboarding', eventName: 'Created', steps: [step.send('welcome')] })
217
+ *
218
+ * H.mailer.registerEvent({ name: 'Created', dedupePolicy: 'once-per-contact' })
219
+ * await H.mailer.fire('Created', 'u1')
220
+ * await H.drain() // run to quiescence
43
221
  *
44
- * Backed by mongodb-memory-server + ioredis-mock + NullProvider.
222
+ * expect(H.provider.sent).toHaveLength(1)
223
+ * await H.stop()
224
+ *
225
+ * Backed by mongodb-memory-server + the `noop` queue driver, so nothing runs in
226
+ * the background and every transition is something the test asked for. The
227
+ * provider is always wrapped in a `RecordingProvider`, which is what allows the
228
+ * same suite to run against `NullProvider` offline or against real SendGrid
229
+ * (`provider: 'sendgrid'`) without changing a single assertion.
45
230
  */
46
231
 
232
+ /**
233
+ * Which provider the harness sends through.
234
+ * - `'null'` — in-memory, offline, the default.
235
+ * - `'sendgrid'` — the real API. Needs `SENDGRID_API_KEY`. Sandbox mode
236
+ * unless `MAILERY_LIVE_E2E=deliver`, so the default costs
237
+ * nothing and delivers nothing while still proving that
238
+ * SendGrid accepts the payload.
239
+ * - a `MailProvider` instance — anything else you want to record around.
240
+ */
241
+ type ProviderSpec = 'null' | 'sendgrid' | MailProvider;
47
242
  interface TestMailerOptions {
48
243
  adapter?: ContactAdapter;
49
244
  seedContacts?: Contact[];
50
- provider?: NullProvider;
245
+ provider?: ProviderSpec;
246
+ /**
247
+ * Queue driver. Defaults to `noop`, which is what makes the fast matrix
248
+ * deterministic — nothing runs until a test calls `drain()`. The live tier
249
+ * passes a real `bull` config to exercise job delays, retries and the send
250
+ * rate limiter, and must then also set `startWorkers`.
251
+ */
252
+ queue?: QueueDriverConfig;
253
+ /** Call `mailer.startWorkers()` after init. Requires a non-noop `queue`. */
254
+ startWorkers?: boolean;
51
255
  /** Override Mailer config (excluding required fields the harness fills in). */
52
256
  config?: Partial<Omit<Parameters<typeof Mailer.init>[0], 'db' | 'adapter' | 'queue' | 'providers' | 'defaultProvider'>>;
53
257
  }
258
+ interface SeedContactOptions {
259
+ /** Also create a `subscribed` subscription row. Default true. */
260
+ subscribe?: boolean;
261
+ source?: string;
262
+ }
54
263
  interface TestMailerHarness {
55
264
  mailer: Mailer;
56
265
  db: Db;
57
- provider: NullProvider;
266
+ /** Recording wrapper around the configured provider. */
267
+ provider: RecordingProvider;
58
268
  adapter: ContactAdapter;
59
269
  memoryAdapter: MemoryContactAdapter | null;
270
+ /** The runner context, for passing to `runTick` / `processOneRunStep` / `drain`. */
271
+ ctx: RunnerContext;
272
+ /** Insert a contact into the adapter and (by default) subscribe it. */
273
+ seedContact: (contact: Contact, opts?: SeedContactOptions) => Promise<Contact>;
274
+ /** Build + insert a published template. Returns the doc, `_id` included. */
275
+ seedTemplate: (spec: TemplateSpec) => Promise<TemplateDoc>;
276
+ /** Build + insert a published flow. Returns the doc, `_id` included. */
277
+ seedFlow: (spec: FlowSpec) => Promise<FlowDoc>;
278
+ /** Run the runner to quiescence. See `drive.ts`. */
279
+ drain: (opts?: DrainOptions) => Promise<DrainResult>;
60
280
  stop: () => Promise<void>;
61
281
  }
62
282
  declare function createTestMailer(opts?: TestMailerOptions): Promise<TestMailerHarness>;
63
283
 
64
- export { MemoryContactAdapter, NullProvider, type TestMailerHarness, type TestMailerOptions, createTestMailer };
284
+ export { type DrainOptions, type DrainResult, type FlowSpec, MemoryContactAdapter, type ProviderSpec, RecordingProvider, type SeedContactOptions, type SendRecord, type TemplateSpec, type TestMailerHarness, type TestMailerOptions, buildFlow, buildTemplate, createTestMailer, dispatchQueued, drain, step, wrapMjml };
package/dist/testing.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { Db } from 'mongodb';
2
- import { C as ContactAdapter, a as Contact, A as AdapterFilter, d as Mailer, s as NullProvider } from './null-B0rPgE5_.js';
2
+ import { C as ContactAdapter, a as Contact, A as AdapterFilter, M as MailProvider, S as SendArgs, b as SendResult, N as NormalizedEvent, F as FlowStep, a2 as FlowTrigger, m as FlowGoal, U as TemplateKind, T as TemplateDoc, l as FlowDoc, D as DeliveryWindow, R as RunnerContext, d as Mailer, a3 as QueueDriverConfig } from './null-BCxlHRJ0.js';
3
+ export { s as NullProvider } from './null-BCxlHRJ0.js';
3
4
  import 'zod';
4
5
  import 'handlebars';
5
6
  import 'ioredis';
@@ -29,36 +30,255 @@ declare class MemoryContactAdapter implements ContactAdapter {
29
30
  removeTags(externalId: string, tags: string[]): Promise<void>;
30
31
  }
31
32
 
33
+ /**
34
+ * RecordingProvider — a `MailProvider` decorator that records every send and
35
+ * then delegates to whatever it wraps.
36
+ *
37
+ * This is what lets one test suite run against two very different backends.
38
+ * Wrapping `NullProvider` gives fast, offline, deterministic assertions;
39
+ * wrapping `SendGridProvider` makes the *same* assertions run against the real
40
+ * API, with real auth, real payload validation and (outside sandbox mode) real
41
+ * delivery. Tests assert on the recording either way, so nothing is duplicated
42
+ * between the two tiers.
43
+ *
44
+ * const provider = new RecordingProvider(new NullProvider())
45
+ * ...
46
+ * expect(provider.sent[0]?.subject).toContain('Alice') // SendArgs
47
+ * expect(provider.records[0]?.result?.status).toBe('accepted')
48
+ *
49
+ * `sent` is deliberately a bare `SendArgs[]`, matching `NullProvider.sent`, so
50
+ * this drops in wherever the old harness provider was used. `records` carries
51
+ * the richer per-call detail (result, error, duration) that the live tier needs.
52
+ */
53
+
54
+ interface SendRecord {
55
+ args: SendArgs;
56
+ /** Provider result, or null when the call threw. */
57
+ result: SendResult | null;
58
+ /** Error thrown by the wrapped provider, or null on success. */
59
+ error: Error | null;
60
+ /** Wall-clock ms the wrapped `send` took. Meaningful for the live tier. */
61
+ durationMs: number;
62
+ at: Date;
63
+ }
64
+ declare class RecordingProvider implements MailProvider {
65
+ readonly inner: MailProvider;
66
+ /** Every `SendArgs` handed to the provider, in call order. */
67
+ readonly sent: SendArgs[];
68
+ /** Same calls, with outcome attached. */
69
+ readonly records: SendRecord[];
70
+ constructor(inner: MailProvider);
71
+ get name(): string;
72
+ get sendRatePerSecond(): number | undefined;
73
+ send(args: SendArgs): Promise<SendResult>;
74
+ verifyWebhook(rawBody: Buffer, headers: Record<string, string>): Promise<boolean>;
75
+ parseWebhookEvents(payload: unknown, headers: Record<string, string>): NormalizedEvent[];
76
+ /** Last recorded send, or undefined. Sugar for the common single-send assertion. */
77
+ get last(): SendArgs | undefined;
78
+ /** All sends addressed to `email` (case-insensitive). */
79
+ toRecipient(email: string): SendArgs[];
80
+ reset(): void;
81
+ }
82
+
83
+ /**
84
+ * Document builders for tests. Hand-rolling a full `TemplateDoc` / `FlowDoc`
85
+ * literal is ~40 lines of noise per fixture; these fill every required field
86
+ * with a sane default so a test only states what it is actually asserting on.
87
+ *
88
+ * await H.seedTemplate({ slug: 'welcome', subject: 'Hi {{contact.fields.firstName}}' })
89
+ * await H.seedFlow({ slug: 'onboarding', eventName: 'Created', steps: [...] })
90
+ *
91
+ * `createdAt` defaults to a minute in the past: the event-trigger scan uses
92
+ * `lastTriggerScanAt ?? createdAt` as its watermark and only picks up events
93
+ * with `occurredAt > watermark`, so a flow created at the same instant as the
94
+ * event it should catch would never fire.
95
+ */
96
+
97
+ interface TemplateSpec {
98
+ slug: string;
99
+ name?: string;
100
+ description?: string;
101
+ kind?: TemplateKind;
102
+ fromName?: string;
103
+ fromEmail?: string;
104
+ replyTo?: string | null;
105
+ providerOverride?: string | null;
106
+ subject?: string;
107
+ preheader?: string;
108
+ /** MJML source — compiled to html + derived plain text. */
109
+ mjml?: string;
110
+ /** Pre-compiled HTML. Wins over `mjml`. */
111
+ html?: string;
112
+ /**
113
+ * Body copy wrapped in a minimal MJML document. The lazy path — use when the
114
+ * test cares about the rendered *content*, not the markup around it.
115
+ */
116
+ text?: string;
117
+ /** Explicit plain-text part. Omit to auto-derive from the HTML. */
118
+ plainText?: string;
119
+ variablesSchema?: TemplateDoc['variablesSchema'];
120
+ tags?: string[];
121
+ trackOpens?: boolean;
122
+ trackClicks?: boolean;
123
+ published?: boolean;
124
+ createdAt?: Date;
125
+ }
126
+ declare function buildTemplate(spec: TemplateSpec): Promise<TemplateDoc>;
127
+ /** Minimal valid MJML document around a body string. */
128
+ declare function wrapMjml(body: string): string;
129
+ interface FlowSpec {
130
+ slug: string;
131
+ name?: string;
132
+ description?: string;
133
+ steps: FlowStep[];
134
+ /** Shorthand for an `event` trigger. Ignored when `trigger` is given. */
135
+ eventName?: string;
136
+ /** Re-entry policy for the shorthand trigger. Defaults to true (enter once). */
137
+ once?: boolean;
138
+ trigger?: FlowTrigger;
139
+ enabled?: boolean;
140
+ goal?: FlowGoal;
141
+ audience?: string;
142
+ version?: number;
143
+ createdAt?: Date;
144
+ }
145
+ declare function buildFlow(spec: FlowSpec): FlowDoc;
146
+ declare const step: {
147
+ readonly send: (templateSlug: string, opts?: Omit<Extract<FlowStep, {
148
+ type: "send";
149
+ }>, "type" | "templateSlug">) => FlowStep;
150
+ readonly sendAt: (templateSlug: string, delivery: DeliveryWindow) => FlowStep;
151
+ readonly wait: (value: number, unit?: "minutes" | "hours" | "days" | "weeks") => FlowStep;
152
+ readonly tag: (addTags?: string[], removeTags?: string[]) => FlowStep;
153
+ readonly exit: (reason?: string) => FlowStep;
154
+ readonly fireEvent: (eventName: string, properties?: Record<string, unknown>) => FlowStep;
155
+ };
156
+
157
+ /**
158
+ * Deterministic runner driver.
159
+ *
160
+ * The test harness uses the `noop` queue driver: `queue.add()` throws nothing
161
+ * away quietly but nothing ever fires it either, so the runner only moves when
162
+ * a test tells it to. `drain` is that push — it alternates the trigger scan,
163
+ * the due-run sweep and send dispatch until the system is quiescent, which is
164
+ * what a real deployment converges to between ticks.
165
+ *
166
+ * await H.mailer.fire('Created', 'u1')
167
+ * await drain(H.mailer.getRunnerContext())
168
+ * expect(H.provider.sent).toHaveLength(1)
169
+ *
170
+ * "Quiescent" means: no active flow_run whose `nextActionAt` has passed, and
171
+ * no queued send left to dispatch. Runs parked in a `wait` step or deferred by
172
+ * a delivery window are *expected* to remain — move the clock forward and
173
+ * drain again.
174
+ */
175
+
176
+ interface DrainOptions {
177
+ /**
178
+ * Safety valve. Each round advances every due run by exactly one step, so a
179
+ * flow needs one round per step. Hitting the cap means the system did not
180
+ * converge — `settled` comes back false rather than throwing, so a test can
181
+ * assert on non-convergence deliberately.
182
+ */
183
+ maxRounds?: number;
184
+ /** Skip send dispatch to inspect `queued` send rows before they go out. */
185
+ dispatch?: boolean;
186
+ /**
187
+ * Let provider/render errors propagate. Off by default: `dispatchSend`
188
+ * rethrows so the real queue can retry, and a drain that unwound on the
189
+ * first failure could not assert on the failed send row it just wrote.
190
+ */
191
+ throwOnSendError?: boolean;
192
+ }
193
+ interface DrainResult {
194
+ rounds: number;
195
+ /** Sends passed to `dispatchSend`. Not all of them reached the provider. */
196
+ dispatched: number;
197
+ /** Errors thrown by `dispatchSend`, swallowed unless `throwOnSendError`. */
198
+ errors: Error[];
199
+ settled: boolean;
200
+ }
201
+ declare function drain(ctx: RunnerContext, opts?: DrainOptions): Promise<DrainResult>;
202
+ /**
203
+ * Dispatch every currently-queued send, once. Use when a test drove the flow
204
+ * with `drain({ dispatch: false })` and wants to inspect the queued rows first.
205
+ */
206
+ declare function dispatchQueued(ctx: RunnerContext): Promise<number>;
207
+
32
208
  /**
33
209
  * Test helpers — `import { ... } from 'mailery/testing'`.
34
210
  *
35
- * const harness = await createTestMailer({ seedContacts: [...] })
36
- * await harness.mailer.upsertSubscription({ externalId: 'u1', source: 'test' })
37
- * await harness.mailer.fire('Created', 'u1')
38
- * // drive the runner directly:
39
- * const { runTick, processOneRunStep, dispatchSend } = await import('mailery')
40
- * await runTick(harness.mailer.getRunnerContext())
41
- * expect(harness.provider.sent.length).toBe(1)
42
- * await harness.stop()
211
+ * const H = await createTestMailer({
212
+ * seedContacts: [{ externalId: 'u1', email: 'alice@example.com', tags: [], fields: {} }],
213
+ * })
214
+ * await H.seedContact({ externalId: 'u1', ... }) // adapter + subscription
215
+ * await H.seedTemplate({ slug: 'welcome', subject: 'Hi {{contact.fields.firstName}}' })
216
+ * await H.seedFlow({ slug: 'onboarding', eventName: 'Created', steps: [step.send('welcome')] })
217
+ *
218
+ * H.mailer.registerEvent({ name: 'Created', dedupePolicy: 'once-per-contact' })
219
+ * await H.mailer.fire('Created', 'u1')
220
+ * await H.drain() // run to quiescence
43
221
  *
44
- * Backed by mongodb-memory-server + ioredis-mock + NullProvider.
222
+ * expect(H.provider.sent).toHaveLength(1)
223
+ * await H.stop()
224
+ *
225
+ * Backed by mongodb-memory-server + the `noop` queue driver, so nothing runs in
226
+ * the background and every transition is something the test asked for. The
227
+ * provider is always wrapped in a `RecordingProvider`, which is what allows the
228
+ * same suite to run against `NullProvider` offline or against real SendGrid
229
+ * (`provider: 'sendgrid'`) without changing a single assertion.
45
230
  */
46
231
 
232
+ /**
233
+ * Which provider the harness sends through.
234
+ * - `'null'` — in-memory, offline, the default.
235
+ * - `'sendgrid'` — the real API. Needs `SENDGRID_API_KEY`. Sandbox mode
236
+ * unless `MAILERY_LIVE_E2E=deliver`, so the default costs
237
+ * nothing and delivers nothing while still proving that
238
+ * SendGrid accepts the payload.
239
+ * - a `MailProvider` instance — anything else you want to record around.
240
+ */
241
+ type ProviderSpec = 'null' | 'sendgrid' | MailProvider;
47
242
  interface TestMailerOptions {
48
243
  adapter?: ContactAdapter;
49
244
  seedContacts?: Contact[];
50
- provider?: NullProvider;
245
+ provider?: ProviderSpec;
246
+ /**
247
+ * Queue driver. Defaults to `noop`, which is what makes the fast matrix
248
+ * deterministic — nothing runs until a test calls `drain()`. The live tier
249
+ * passes a real `bull` config to exercise job delays, retries and the send
250
+ * rate limiter, and must then also set `startWorkers`.
251
+ */
252
+ queue?: QueueDriverConfig;
253
+ /** Call `mailer.startWorkers()` after init. Requires a non-noop `queue`. */
254
+ startWorkers?: boolean;
51
255
  /** Override Mailer config (excluding required fields the harness fills in). */
52
256
  config?: Partial<Omit<Parameters<typeof Mailer.init>[0], 'db' | 'adapter' | 'queue' | 'providers' | 'defaultProvider'>>;
53
257
  }
258
+ interface SeedContactOptions {
259
+ /** Also create a `subscribed` subscription row. Default true. */
260
+ subscribe?: boolean;
261
+ source?: string;
262
+ }
54
263
  interface TestMailerHarness {
55
264
  mailer: Mailer;
56
265
  db: Db;
57
- provider: NullProvider;
266
+ /** Recording wrapper around the configured provider. */
267
+ provider: RecordingProvider;
58
268
  adapter: ContactAdapter;
59
269
  memoryAdapter: MemoryContactAdapter | null;
270
+ /** The runner context, for passing to `runTick` / `processOneRunStep` / `drain`. */
271
+ ctx: RunnerContext;
272
+ /** Insert a contact into the adapter and (by default) subscribe it. */
273
+ seedContact: (contact: Contact, opts?: SeedContactOptions) => Promise<Contact>;
274
+ /** Build + insert a published template. Returns the doc, `_id` included. */
275
+ seedTemplate: (spec: TemplateSpec) => Promise<TemplateDoc>;
276
+ /** Build + insert a published flow. Returns the doc, `_id` included. */
277
+ seedFlow: (spec: FlowSpec) => Promise<FlowDoc>;
278
+ /** Run the runner to quiescence. See `drive.ts`. */
279
+ drain: (opts?: DrainOptions) => Promise<DrainResult>;
60
280
  stop: () => Promise<void>;
61
281
  }
62
282
  declare function createTestMailer(opts?: TestMailerOptions): Promise<TestMailerHarness>;
63
283
 
64
- export { MemoryContactAdapter, NullProvider, type TestMailerHarness, type TestMailerOptions, createTestMailer };
284
+ export { type DrainOptions, type DrainResult, type FlowSpec, MemoryContactAdapter, type ProviderSpec, RecordingProvider, type SeedContactOptions, type SendRecord, type TemplateSpec, type TestMailerHarness, type TestMailerOptions, buildFlow, buildTemplate, createTestMailer, dispatchQueued, drain, step, wrapMjml };