mailery 0.0.0 → 0.1.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.
- package/README.md +103 -11
- package/dist/admin/spa/index-B6riKdpB.css +1 -0
- package/dist/admin/spa/index-C_FJL_E_.js +41 -0
- package/dist/admin/spa/index-C_FJL_E_.js.map +1 -0
- package/dist/admin/spa/index.html +16 -0
- package/dist/admin/spa/template-editor-Cx1ky7rr.js +502 -0
- package/dist/admin/spa/template-editor-Cx1ky7rr.js.map +1 -0
- package/dist/index.cjs +3691 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +242 -2
- package/dist/index.d.ts +242 -2
- package/dist/index.js +3667 -3
- package/dist/index.js.map +1 -1
- package/dist/null-7gnz1V98.d.cts +959 -0
- package/dist/null-7gnz1V98.d.ts +959 -0
- package/dist/testing.cjs +16565 -2
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +63 -2
- package/dist/testing.d.ts +63 -2
- package/dist/testing.js +16555 -2
- package/dist/testing.js.map +1 -1
- package/package.json +38 -5
- package/dist/admin/static/.gitkeep +0 -0
- package/dist/admin/views/.gitkeep +0 -0
|
@@ -0,0 +1,959 @@
|
|
|
1
|
+
import { Db, ObjectId, Collection, ClientSession } from 'mongodb';
|
|
2
|
+
import IORedis from 'ioredis';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import Handlebars from 'handlebars';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Shared types — used by both server and client. Stub placeholders for Phase 0.
|
|
8
|
+
* Full shapes live in plans/02-data-model.md.
|
|
9
|
+
*/
|
|
10
|
+
interface Contact {
|
|
11
|
+
externalId: string;
|
|
12
|
+
email: string;
|
|
13
|
+
tags: string[];
|
|
14
|
+
fields: Record<string, unknown>;
|
|
15
|
+
timezone?: string;
|
|
16
|
+
locale?: string;
|
|
17
|
+
}
|
|
18
|
+
interface AdapterFilter {
|
|
19
|
+
emailIn?: string[];
|
|
20
|
+
externalIdIn?: string[];
|
|
21
|
+
fieldEquals?: {
|
|
22
|
+
field: string;
|
|
23
|
+
value: unknown;
|
|
24
|
+
};
|
|
25
|
+
fieldIn?: {
|
|
26
|
+
field: string;
|
|
27
|
+
values: unknown[];
|
|
28
|
+
};
|
|
29
|
+
fieldExists?: string;
|
|
30
|
+
hasTag?: string;
|
|
31
|
+
hasTagIn?: string[];
|
|
32
|
+
createdAfter?: Date;
|
|
33
|
+
createdBefore?: Date;
|
|
34
|
+
}
|
|
35
|
+
interface ContactAdapter {
|
|
36
|
+
getById(externalId: string): Promise<Contact | null>;
|
|
37
|
+
getByEmail(email: string): Promise<Contact | null>;
|
|
38
|
+
getBatch(externalIds: string[]): Promise<Map<string, Contact>>;
|
|
39
|
+
query(filter: AdapterFilter, opts: {
|
|
40
|
+
limit: number;
|
|
41
|
+
cursor?: string;
|
|
42
|
+
}): Promise<{
|
|
43
|
+
contacts: Contact[];
|
|
44
|
+
nextCursor?: string;
|
|
45
|
+
}>;
|
|
46
|
+
count(filter: AdapterFilter): Promise<number>;
|
|
47
|
+
addTags?(externalId: string, tags: string[]): Promise<void>;
|
|
48
|
+
removeTags?(externalId: string, tags: string[]): Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
type FlowStep = {
|
|
51
|
+
type: 'wait';
|
|
52
|
+
value: number;
|
|
53
|
+
unit: 'minutes' | 'hours' | 'days' | 'weeks';
|
|
54
|
+
} | {
|
|
55
|
+
type: 'condition';
|
|
56
|
+
test: Predicate;
|
|
57
|
+
ifFalse: 'continue' | 'exit';
|
|
58
|
+
} | {
|
|
59
|
+
type: 'branch';
|
|
60
|
+
test: Predicate;
|
|
61
|
+
ifTrueSteps: FlowStep[];
|
|
62
|
+
ifFalseSteps: FlowStep[];
|
|
63
|
+
} | {
|
|
64
|
+
type: 'send';
|
|
65
|
+
templateSlug: string;
|
|
66
|
+
providerOverride?: string;
|
|
67
|
+
vars?: Record<string, unknown>;
|
|
68
|
+
} | {
|
|
69
|
+
type: 'tag';
|
|
70
|
+
addTags?: string[];
|
|
71
|
+
removeTags?: string[];
|
|
72
|
+
} | {
|
|
73
|
+
type: 'fire_event';
|
|
74
|
+
eventName: string;
|
|
75
|
+
properties?: Record<string, unknown>;
|
|
76
|
+
} | {
|
|
77
|
+
type: 'webhook';
|
|
78
|
+
url: string;
|
|
79
|
+
method?: 'POST' | 'PUT';
|
|
80
|
+
payload?: Record<string, unknown>;
|
|
81
|
+
failureMode?: 'soft' | 'fail_run';
|
|
82
|
+
} | {
|
|
83
|
+
type: 'exit';
|
|
84
|
+
reason?: string;
|
|
85
|
+
};
|
|
86
|
+
type Predicate = {
|
|
87
|
+
hasTag: string;
|
|
88
|
+
} | {
|
|
89
|
+
notHasTag: string;
|
|
90
|
+
} | {
|
|
91
|
+
fieldEquals: {
|
|
92
|
+
field: string;
|
|
93
|
+
value: unknown;
|
|
94
|
+
};
|
|
95
|
+
} | {
|
|
96
|
+
fieldExists: string;
|
|
97
|
+
} | {
|
|
98
|
+
hasFiredEvent: string;
|
|
99
|
+
sinceFlowStart?: boolean;
|
|
100
|
+
withinDays?: number;
|
|
101
|
+
} | {
|
|
102
|
+
notHasFiredEvent: string;
|
|
103
|
+
withinDays?: number;
|
|
104
|
+
} | {
|
|
105
|
+
subscriptionStatus: 'subscribed' | 'unsubscribed' | 'pending_doi' | 'bounced' | 'complained';
|
|
106
|
+
} | {
|
|
107
|
+
hasOpened: {
|
|
108
|
+
templateSlug?: string;
|
|
109
|
+
sinceFlowStart?: boolean;
|
|
110
|
+
withinDays?: number;
|
|
111
|
+
};
|
|
112
|
+
} | {
|
|
113
|
+
hasClicked: {
|
|
114
|
+
templateSlug?: string;
|
|
115
|
+
sinceFlowStart?: boolean;
|
|
116
|
+
withinDays?: number;
|
|
117
|
+
};
|
|
118
|
+
} | {
|
|
119
|
+
hasOpenedExcludingBots: {
|
|
120
|
+
templateSlug?: string;
|
|
121
|
+
sinceFlowStart?: boolean;
|
|
122
|
+
withinDays?: number;
|
|
123
|
+
};
|
|
124
|
+
} | {
|
|
125
|
+
hasClickedExcludingBots: {
|
|
126
|
+
templateSlug?: string;
|
|
127
|
+
sinceFlowStart?: boolean;
|
|
128
|
+
withinDays?: number;
|
|
129
|
+
};
|
|
130
|
+
} | {
|
|
131
|
+
openedAtLeastN: {
|
|
132
|
+
count: number;
|
|
133
|
+
withinDays: number;
|
|
134
|
+
};
|
|
135
|
+
} | {
|
|
136
|
+
clickedAtLeastN: {
|
|
137
|
+
count: number;
|
|
138
|
+
withinDays: number;
|
|
139
|
+
};
|
|
140
|
+
} | {
|
|
141
|
+
all: Predicate[];
|
|
142
|
+
} | {
|
|
143
|
+
any: Predicate[];
|
|
144
|
+
} | {
|
|
145
|
+
not: Predicate;
|
|
146
|
+
};
|
|
147
|
+
interface SegmentDefinition {
|
|
148
|
+
filters: SegmentFilter[];
|
|
149
|
+
}
|
|
150
|
+
type SegmentFilter = {
|
|
151
|
+
kind: 'fieldEquals';
|
|
152
|
+
field: string;
|
|
153
|
+
value: unknown;
|
|
154
|
+
} | {
|
|
155
|
+
kind: 'fieldIn';
|
|
156
|
+
field: string;
|
|
157
|
+
values: unknown[];
|
|
158
|
+
} | {
|
|
159
|
+
kind: 'fieldExists';
|
|
160
|
+
field: string;
|
|
161
|
+
} | {
|
|
162
|
+
kind: 'hasTag';
|
|
163
|
+
tag: string;
|
|
164
|
+
} | {
|
|
165
|
+
kind: 'notHasTag';
|
|
166
|
+
tag: string;
|
|
167
|
+
} | {
|
|
168
|
+
kind: 'subscriptionStatus';
|
|
169
|
+
equals: 'subscribed' | 'unsubscribed' | 'pending_doi' | 'bounced' | 'complained';
|
|
170
|
+
} | {
|
|
171
|
+
kind: 'firedEvent';
|
|
172
|
+
eventName: string;
|
|
173
|
+
withinDays?: number;
|
|
174
|
+
} | {
|
|
175
|
+
kind: 'notFiredEvent';
|
|
176
|
+
eventName: string;
|
|
177
|
+
withinDays?: number;
|
|
178
|
+
} | {
|
|
179
|
+
kind: 'subscribedAfter';
|
|
180
|
+
date: Date;
|
|
181
|
+
} | {
|
|
182
|
+
kind: 'subscribedBefore';
|
|
183
|
+
date: Date;
|
|
184
|
+
} | {
|
|
185
|
+
kind: 'opened';
|
|
186
|
+
templateSlug?: string;
|
|
187
|
+
withinDays?: number;
|
|
188
|
+
} | {
|
|
189
|
+
kind: 'notOpened';
|
|
190
|
+
templateSlug?: string;
|
|
191
|
+
withinDays?: number;
|
|
192
|
+
} | {
|
|
193
|
+
kind: 'any';
|
|
194
|
+
filters: SegmentFilter[];
|
|
195
|
+
} | {
|
|
196
|
+
kind: 'not';
|
|
197
|
+
filter: SegmentFilter;
|
|
198
|
+
};
|
|
199
|
+
interface SendArgs {
|
|
200
|
+
to: string;
|
|
201
|
+
fromName: string;
|
|
202
|
+
fromEmail: string;
|
|
203
|
+
replyTo?: string;
|
|
204
|
+
subject: string;
|
|
205
|
+
html: string;
|
|
206
|
+
text: string;
|
|
207
|
+
headers?: Record<string, string>;
|
|
208
|
+
messageMeta?: Record<string, string>;
|
|
209
|
+
}
|
|
210
|
+
interface SendResult {
|
|
211
|
+
providerId: string;
|
|
212
|
+
status: 'accepted' | 'rejected';
|
|
213
|
+
raw?: unknown;
|
|
214
|
+
}
|
|
215
|
+
interface NormalizedEvent {
|
|
216
|
+
type: 'delivered' | 'open' | 'click' | 'bounce' | 'complaint' | 'unsubscribe' | 'spam_report';
|
|
217
|
+
providerEventId: string;
|
|
218
|
+
providerMessageId: string;
|
|
219
|
+
email: string;
|
|
220
|
+
occurredAt: Date;
|
|
221
|
+
details: {
|
|
222
|
+
bounceType?: 'hard' | 'soft';
|
|
223
|
+
bounceReason?: string;
|
|
224
|
+
clickedUrl?: string;
|
|
225
|
+
userAgent?: string;
|
|
226
|
+
ipAddress?: string;
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
interface MailProvider {
|
|
230
|
+
readonly name: string;
|
|
231
|
+
/** Per-provider send rate cap (per second). Used by the send-queue rate limiter. */
|
|
232
|
+
readonly sendRatePerSecond?: number;
|
|
233
|
+
send(args: SendArgs): Promise<SendResult>;
|
|
234
|
+
verifyWebhook(rawBody: Buffer, headers: Record<string, string>): Promise<boolean>;
|
|
235
|
+
parseWebhookEvents(payload: unknown, headers: Record<string, string>): NormalizedEvent[];
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Zod schemas for runtime validation at write boundaries — public API entry
|
|
240
|
+
* points (fire, upsertSubscription, suppress, sendOneOff, scheduleBroadcast)
|
|
241
|
+
* and REST handler bodies.
|
|
242
|
+
*
|
|
243
|
+
* Domain TypeScript types live in `./types.ts`; these schemas overlap with
|
|
244
|
+
* those types deliberately so we get both compile-time and runtime checking
|
|
245
|
+
* at the surfaces that need it.
|
|
246
|
+
*/
|
|
247
|
+
|
|
248
|
+
declare const registerEventSchema: z.ZodObject<{
|
|
249
|
+
name: z.ZodString;
|
|
250
|
+
dedupePolicy: z.ZodEnum<{
|
|
251
|
+
"once-per-contact": "once-per-contact";
|
|
252
|
+
"once-per-day": "once-per-day";
|
|
253
|
+
"every-time": "every-time";
|
|
254
|
+
}>;
|
|
255
|
+
}, z.core.$strip>;
|
|
256
|
+
type RegisterEventInput = z.infer<typeof registerEventSchema>;
|
|
257
|
+
declare const upsertSubscriptionSchema: z.ZodObject<{
|
|
258
|
+
externalId: z.ZodString;
|
|
259
|
+
source: z.ZodString;
|
|
260
|
+
consentTimestamp: z.ZodOptional<z.ZodDate>;
|
|
261
|
+
consentIp: z.ZodOptional<z.ZodString>;
|
|
262
|
+
consentUserAgent: z.ZodOptional<z.ZodString>;
|
|
263
|
+
}, z.core.$strip>;
|
|
264
|
+
type UpsertSubscriptionInput = z.infer<typeof upsertSubscriptionSchema>;
|
|
265
|
+
declare const unsubscribeInputSchema: z.ZodObject<{
|
|
266
|
+
email: z.ZodString;
|
|
267
|
+
scope: z.ZodEnum<{
|
|
268
|
+
all: "all";
|
|
269
|
+
marketing: "marketing";
|
|
270
|
+
transactional: "transactional";
|
|
271
|
+
}>;
|
|
272
|
+
reason: z.ZodDefault<z.ZodEnum<{
|
|
273
|
+
complaint: "complaint";
|
|
274
|
+
user_request: "user_request";
|
|
275
|
+
hard_bounce: "hard_bounce";
|
|
276
|
+
manual: "manual";
|
|
277
|
+
gdpr_forget: "gdpr_forget";
|
|
278
|
+
list_cleaning: "list_cleaning";
|
|
279
|
+
}>>;
|
|
280
|
+
source: z.ZodDefault<z.ZodString>;
|
|
281
|
+
notes: z.ZodOptional<z.ZodString>;
|
|
282
|
+
}, z.core.$strip>;
|
|
283
|
+
type UnsubscribeInput = z.infer<typeof unsubscribeInputSchema>;
|
|
284
|
+
declare const suppressInputSchema: z.ZodObject<{
|
|
285
|
+
email: z.ZodString;
|
|
286
|
+
scope: z.ZodEnum<{
|
|
287
|
+
all: "all";
|
|
288
|
+
marketing: "marketing";
|
|
289
|
+
transactional: "transactional";
|
|
290
|
+
}>;
|
|
291
|
+
reason: z.ZodEnum<{
|
|
292
|
+
unsubscribed: "unsubscribed";
|
|
293
|
+
complaint: "complaint";
|
|
294
|
+
hard_bounce: "hard_bounce";
|
|
295
|
+
manual: "manual";
|
|
296
|
+
gdpr_forget: "gdpr_forget";
|
|
297
|
+
list_cleaning: "list_cleaning";
|
|
298
|
+
}>;
|
|
299
|
+
source: z.ZodDefault<z.ZodString>;
|
|
300
|
+
notes: z.ZodOptional<z.ZodString>;
|
|
301
|
+
expiresAt: z.ZodOptional<z.ZodDate>;
|
|
302
|
+
}, z.core.$strip>;
|
|
303
|
+
type SuppressInput = z.infer<typeof suppressInputSchema>;
|
|
304
|
+
declare const sendOneOffInputSchema: z.ZodObject<{
|
|
305
|
+
templateSlug: z.ZodString;
|
|
306
|
+
externalId: z.ZodString;
|
|
307
|
+
vars: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
308
|
+
providerOverride: z.ZodOptional<z.ZodString>;
|
|
309
|
+
dedupeKey: z.ZodString;
|
|
310
|
+
}, z.core.$strip>;
|
|
311
|
+
type SendOneOffInput = z.infer<typeof sendOneOffInputSchema>;
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Mailer configuration shape. Required + optional surfaces with sane defaults.
|
|
315
|
+
*/
|
|
316
|
+
|
|
317
|
+
interface RedisOptions {
|
|
318
|
+
host?: string;
|
|
319
|
+
port?: number;
|
|
320
|
+
password?: string;
|
|
321
|
+
url?: string;
|
|
322
|
+
db?: number;
|
|
323
|
+
username?: string;
|
|
324
|
+
tls?: boolean;
|
|
325
|
+
}
|
|
326
|
+
interface CircuitBreakerThresholds {
|
|
327
|
+
/** Trip when last-hour hard-bounce rate >= this percent (e.g. 2 = 2%). */
|
|
328
|
+
hardBounceRatePctTrip: number;
|
|
329
|
+
complaintRatePctTrip: number;
|
|
330
|
+
combinedBounceRatePctTrip: number;
|
|
331
|
+
failedToSendRatePctDegrade: number;
|
|
332
|
+
windowMinutes: number;
|
|
333
|
+
minSendsBeforeEval: number;
|
|
334
|
+
}
|
|
335
|
+
interface MailerConfig {
|
|
336
|
+
db: Db;
|
|
337
|
+
collectionPrefix?: string;
|
|
338
|
+
adapter: ContactAdapter;
|
|
339
|
+
/**
|
|
340
|
+
* Connection options, a pre-built ioredis instance, or `null` to opt out of
|
|
341
|
+
* BullMQ entirely (synchronous-only mode — used by tests).
|
|
342
|
+
*/
|
|
343
|
+
redis: RedisOptions | IORedis | null;
|
|
344
|
+
providers: Record<string, MailProvider>;
|
|
345
|
+
defaultProvider: string;
|
|
346
|
+
defaultTransactionalProvider?: string;
|
|
347
|
+
publicUrl: string;
|
|
348
|
+
unsubscribeSecret: string;
|
|
349
|
+
senderAddress?: string;
|
|
350
|
+
fromDefaults?: {
|
|
351
|
+
name: string;
|
|
352
|
+
email: string;
|
|
353
|
+
};
|
|
354
|
+
transactionalFromDefaults?: {
|
|
355
|
+
name: string;
|
|
356
|
+
email: string;
|
|
357
|
+
};
|
|
358
|
+
requireDoubleOptIn?: boolean;
|
|
359
|
+
unsubscribeTokenLifetimeDays?: number;
|
|
360
|
+
transactionalRespectUnsubscribe?: boolean;
|
|
361
|
+
/** Slug of the transactional template sent for DOI confirmation. Defaults to 'doi-confirmation'. */
|
|
362
|
+
doiTemplateSlug?: string;
|
|
363
|
+
/** How long the DOI token stays valid. Default 7 days. */
|
|
364
|
+
doiTokenLifetimeDays?: number;
|
|
365
|
+
circuitBreaker?: Partial<CircuitBreakerThresholds>;
|
|
366
|
+
broadcastConfirmationThreshold?: number;
|
|
367
|
+
broadcastEnqueueBatchSize?: number;
|
|
368
|
+
broadcastEnqueueMaxWaiting?: number;
|
|
369
|
+
workerless?: boolean;
|
|
370
|
+
tickIntervalSeconds?: number;
|
|
371
|
+
sendConcurrency?: number;
|
|
372
|
+
sendRatePerSecond?: number;
|
|
373
|
+
softBouncePromotionThreshold?: number;
|
|
374
|
+
softBouncePromotionWindowDays?: number;
|
|
375
|
+
webhookRetryAttempts?: number;
|
|
376
|
+
sendRetryAttempts?: number;
|
|
377
|
+
trackOpens?: boolean;
|
|
378
|
+
trackClicks?: boolean;
|
|
379
|
+
storeTrackingIp?: boolean;
|
|
380
|
+
storeRenderedBody?: boolean;
|
|
381
|
+
getAdminActor?: (req: any) => string;
|
|
382
|
+
onCircuitBreakerTrip?: (info: {
|
|
383
|
+
reason: string;
|
|
384
|
+
rates: Record<string, number>;
|
|
385
|
+
}) => Promise<void> | void;
|
|
386
|
+
onSendFailure?: (info: {
|
|
387
|
+
send: any;
|
|
388
|
+
error: Error;
|
|
389
|
+
}) => Promise<void> | void;
|
|
390
|
+
handlebarsHelpers?: Record<string, Handlebars.HelperDelegate>;
|
|
391
|
+
}
|
|
392
|
+
type ResolvedConfig = Required<Pick<MailerConfig, 'collectionPrefix' | 'requireDoubleOptIn' | 'unsubscribeTokenLifetimeDays' | 'transactionalRespectUnsubscribe' | 'doiTemplateSlug' | 'doiTokenLifetimeDays' | 'broadcastConfirmationThreshold' | 'broadcastEnqueueBatchSize' | 'broadcastEnqueueMaxWaiting' | 'workerless' | 'tickIntervalSeconds' | 'sendConcurrency' | 'sendRatePerSecond' | 'softBouncePromotionThreshold' | 'softBouncePromotionWindowDays' | 'webhookRetryAttempts' | 'sendRetryAttempts' | 'trackOpens' | 'trackClicks' | 'storeTrackingIp' | 'storeRenderedBody'>> & {
|
|
393
|
+
circuitBreaker: CircuitBreakerThresholds;
|
|
394
|
+
} & MailerConfig;
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Shared enums — string-literal unions for status fields and other discriminators.
|
|
398
|
+
* Kept separate from types.ts so the client can import these without pulling in
|
|
399
|
+
* server-shaped interfaces.
|
|
400
|
+
*/
|
|
401
|
+
type SubscriptionStatus = 'subscribed' | 'pending_doi' | 'unsubscribed' | 'bounced' | 'complained';
|
|
402
|
+
type SendStatus = 'queued' | 'sending' | 'sent' | 'delivered' | 'bounced' | 'complained' | 'failed' | 'suppressed';
|
|
403
|
+
type TemplateKind = 'transactional' | 'marketing';
|
|
404
|
+
type SuppressionScope = 'all' | 'marketing' | 'transactional';
|
|
405
|
+
type SuppressionReason = 'unsubscribed' | 'hard_bounce' | 'complaint' | 'manual' | 'list_cleaning' | 'gdpr_forget';
|
|
406
|
+
type FlowRunStatus = 'active' | 'completed' | 'exited' | 'failed';
|
|
407
|
+
type BroadcastStatus = 'draft' | 'scheduled' | 'sending' | 'sent' | 'cancelled' | 'failed';
|
|
408
|
+
type HealthStatus = 'healthy' | 'degraded' | 'tripped';
|
|
409
|
+
type FlowGoal = 'activation' | 'conversion' | 'retention' | 'reactivation' | 'transactional' | 'broadcast';
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Mongo collection helpers + indexes for every mailer-owned collection.
|
|
413
|
+
*
|
|
414
|
+
* The single source of truth for `mailer_*` schemas lives in
|
|
415
|
+
* `plans/02-data-model.md`. The TS interfaces here track that doc.
|
|
416
|
+
*
|
|
417
|
+
* const collections = getCollections(db)
|
|
418
|
+
* await ensureIndexes(db)
|
|
419
|
+
* await collections.events.insertOne({ ... })
|
|
420
|
+
*/
|
|
421
|
+
|
|
422
|
+
interface SubscriptionDoc {
|
|
423
|
+
_id?: ObjectId;
|
|
424
|
+
externalId: string;
|
|
425
|
+
status: SubscriptionStatus;
|
|
426
|
+
subscribedAt: Date | null;
|
|
427
|
+
unsubscribedAt: Date | null;
|
|
428
|
+
unsubscribeReason: string | null;
|
|
429
|
+
doiTokenHash: string | null;
|
|
430
|
+
doiRequestedAt: Date | null;
|
|
431
|
+
doiConfirmedAt: Date | null;
|
|
432
|
+
doiIp: string | null;
|
|
433
|
+
doiUserAgent: string | null;
|
|
434
|
+
source: string;
|
|
435
|
+
emailAtSubscribe: string;
|
|
436
|
+
createdAt: Date;
|
|
437
|
+
updatedAt: Date;
|
|
438
|
+
}
|
|
439
|
+
interface LeadDoc {
|
|
440
|
+
_id?: ObjectId;
|
|
441
|
+
email: string;
|
|
442
|
+
source: string;
|
|
443
|
+
capturedFields: Record<string, unknown>;
|
|
444
|
+
status: 'lead' | 'promoted' | 'rejected';
|
|
445
|
+
promotedToExternalId: string | null;
|
|
446
|
+
promotedAt: Date | null;
|
|
447
|
+
consentedAt: Date | null;
|
|
448
|
+
consentIp: string | null;
|
|
449
|
+
unsubscribedAt: Date | null;
|
|
450
|
+
createdAt: Date;
|
|
451
|
+
updatedAt: Date;
|
|
452
|
+
}
|
|
453
|
+
interface EventDoc {
|
|
454
|
+
_id?: ObjectId;
|
|
455
|
+
externalId: string;
|
|
456
|
+
name: string;
|
|
457
|
+
properties: Record<string, unknown>;
|
|
458
|
+
dedupeKey: string;
|
|
459
|
+
occurredAt: Date;
|
|
460
|
+
createdAt: Date;
|
|
461
|
+
}
|
|
462
|
+
interface FlowTrigger {
|
|
463
|
+
type: 'event' | 'segment_enter' | 'cron';
|
|
464
|
+
eventName?: string;
|
|
465
|
+
segmentDefinition?: SegmentDefinition;
|
|
466
|
+
cronExpression?: string;
|
|
467
|
+
once: boolean;
|
|
468
|
+
}
|
|
469
|
+
interface FlowDraft {
|
|
470
|
+
steps: FlowStep[];
|
|
471
|
+
notes: string;
|
|
472
|
+
lastModifiedBy: string;
|
|
473
|
+
lastModifiedAt: Date;
|
|
474
|
+
}
|
|
475
|
+
interface FlowDoc {
|
|
476
|
+
_id?: ObjectId;
|
|
477
|
+
slug: string;
|
|
478
|
+
name: string;
|
|
479
|
+
description: string;
|
|
480
|
+
trigger: FlowTrigger;
|
|
481
|
+
enabled: boolean;
|
|
482
|
+
steps: FlowStep[];
|
|
483
|
+
version: number;
|
|
484
|
+
draft: FlowDraft | null;
|
|
485
|
+
goal: FlowGoal;
|
|
486
|
+
audience: string;
|
|
487
|
+
expectedVolumePerWeek: number | null;
|
|
488
|
+
stats: {
|
|
489
|
+
activeRuns: number;
|
|
490
|
+
completedRuns: number;
|
|
491
|
+
sendsTotal: number;
|
|
492
|
+
sendsLast7Days: number;
|
|
493
|
+
};
|
|
494
|
+
lastTriggerScanAt: Date | null;
|
|
495
|
+
publishedAt: Date | null;
|
|
496
|
+
publishedBy: string | null;
|
|
497
|
+
createdAt: Date;
|
|
498
|
+
updatedAt: Date;
|
|
499
|
+
}
|
|
500
|
+
interface FlowVersionDoc {
|
|
501
|
+
_id?: ObjectId;
|
|
502
|
+
flowId: ObjectId;
|
|
503
|
+
version: number;
|
|
504
|
+
steps: FlowStep[];
|
|
505
|
+
trigger: FlowTrigger;
|
|
506
|
+
publishedAt: Date;
|
|
507
|
+
publishedBy: string;
|
|
508
|
+
}
|
|
509
|
+
interface FlowRunHistoryEntry {
|
|
510
|
+
stepIndex: number;
|
|
511
|
+
action: 'entered' | 'wait_started' | 'wait_completed' | 'condition_evaluated' | 'branch_taken' | 'sent' | 'send_skipped' | 'tagged' | 'event_fired' | 'webhook_called' | 'exited' | 'failed';
|
|
512
|
+
at: Date;
|
|
513
|
+
details?: Record<string, unknown>;
|
|
514
|
+
}
|
|
515
|
+
interface FlowRunDoc {
|
|
516
|
+
_id?: ObjectId;
|
|
517
|
+
externalId: string;
|
|
518
|
+
flowId: ObjectId;
|
|
519
|
+
flowSlug: string;
|
|
520
|
+
flowVersion: number;
|
|
521
|
+
emailAtEntry: string;
|
|
522
|
+
enteredAt: Date;
|
|
523
|
+
status: FlowRunStatus;
|
|
524
|
+
currentStepIndex: number;
|
|
525
|
+
currentBranchPath: Array<number | 'true' | 'false'>;
|
|
526
|
+
nextActionAt: Date;
|
|
527
|
+
attemptsForCurrentStep: number;
|
|
528
|
+
history: FlowRunHistoryEntry[];
|
|
529
|
+
exitedAt: Date | null;
|
|
530
|
+
exitReason: string | null;
|
|
531
|
+
createdAt: Date;
|
|
532
|
+
updatedAt: Date;
|
|
533
|
+
}
|
|
534
|
+
interface TemplateDraft {
|
|
535
|
+
subject: string;
|
|
536
|
+
preheader: string;
|
|
537
|
+
/** MJML source (when template is authored as MJML). Empty string if Maily-authored. */
|
|
538
|
+
mjml: string;
|
|
539
|
+
/** Maily editor JSON (when template is authored via the WYSIWYG editor). null otherwise. */
|
|
540
|
+
editorJson: Record<string, unknown> | null;
|
|
541
|
+
notes: string;
|
|
542
|
+
lastModifiedBy: string;
|
|
543
|
+
lastModifiedAt: Date;
|
|
544
|
+
}
|
|
545
|
+
interface TemplateDoc {
|
|
546
|
+
_id?: ObjectId;
|
|
547
|
+
slug: string;
|
|
548
|
+
name: string;
|
|
549
|
+
description: string;
|
|
550
|
+
kind: TemplateKind;
|
|
551
|
+
fromName: string;
|
|
552
|
+
fromEmail: string;
|
|
553
|
+
replyTo: string | null;
|
|
554
|
+
providerOverride: string | null;
|
|
555
|
+
subject: string;
|
|
556
|
+
preheader: string;
|
|
557
|
+
body: {
|
|
558
|
+
/** MJML source (set when published from MJML). */
|
|
559
|
+
mjml: string;
|
|
560
|
+
/** Maily editor JSON (set when published from the WYSIWYG editor). null otherwise. */
|
|
561
|
+
editorJson: Record<string, unknown> | null;
|
|
562
|
+
html: string;
|
|
563
|
+
plainText: string;
|
|
564
|
+
compiledAt: Date | null;
|
|
565
|
+
};
|
|
566
|
+
variablesSchema: Record<string, {
|
|
567
|
+
type: 'string' | 'number' | 'boolean' | 'date' | 'url';
|
|
568
|
+
required: boolean;
|
|
569
|
+
description?: string;
|
|
570
|
+
defaultValue?: unknown;
|
|
571
|
+
}>;
|
|
572
|
+
draft: TemplateDraft | null;
|
|
573
|
+
tags: string[];
|
|
574
|
+
trackOpens: boolean;
|
|
575
|
+
trackClicks: boolean;
|
|
576
|
+
stats: {
|
|
577
|
+
sent: number;
|
|
578
|
+
delivered: number;
|
|
579
|
+
opened: number;
|
|
580
|
+
clicked: number;
|
|
581
|
+
bounced: number;
|
|
582
|
+
complained: number;
|
|
583
|
+
unsubscribed: number;
|
|
584
|
+
lastSentAt: Date | null;
|
|
585
|
+
};
|
|
586
|
+
publishedAt: Date | null;
|
|
587
|
+
publishedBy: string | null;
|
|
588
|
+
createdAt: Date;
|
|
589
|
+
updatedAt: Date;
|
|
590
|
+
}
|
|
591
|
+
interface TemplateVersionDoc {
|
|
592
|
+
_id?: ObjectId;
|
|
593
|
+
templateId: ObjectId;
|
|
594
|
+
version: number;
|
|
595
|
+
mjml: string;
|
|
596
|
+
html: string;
|
|
597
|
+
plainText: string;
|
|
598
|
+
subject: string;
|
|
599
|
+
preheader: string;
|
|
600
|
+
publishedAt: Date;
|
|
601
|
+
publishedBy: string;
|
|
602
|
+
}
|
|
603
|
+
interface SendDoc {
|
|
604
|
+
_id?: ObjectId;
|
|
605
|
+
dedupeKey: string;
|
|
606
|
+
externalId: string;
|
|
607
|
+
emailAtSend: string;
|
|
608
|
+
templateId: ObjectId;
|
|
609
|
+
templateSlug: string;
|
|
610
|
+
flowRunId: ObjectId | null;
|
|
611
|
+
broadcastId: ObjectId | null;
|
|
612
|
+
manualSendBy: string | null;
|
|
613
|
+
kind: TemplateKind;
|
|
614
|
+
provider: string;
|
|
615
|
+
providerMessageId: string | null;
|
|
616
|
+
fromName: string;
|
|
617
|
+
fromEmail: string;
|
|
618
|
+
subject: string;
|
|
619
|
+
bodyHash: string;
|
|
620
|
+
status: SendStatus;
|
|
621
|
+
errorMessage: string | null;
|
|
622
|
+
bounceType: 'hard' | 'soft' | null;
|
|
623
|
+
bounceReason: string | null;
|
|
624
|
+
/** Pre-send map: every rewritten link's linkId → original URL. Lookup target on click. */
|
|
625
|
+
links: Array<{
|
|
626
|
+
linkId: string;
|
|
627
|
+
url: string;
|
|
628
|
+
}>;
|
|
629
|
+
/** Render-time vars from the flow step or one-off send. Re-applied at dispatch. */
|
|
630
|
+
vars: Record<string, unknown>;
|
|
631
|
+
openedAt: Date | null;
|
|
632
|
+
openCount: number;
|
|
633
|
+
firstClickAt: Date | null;
|
|
634
|
+
clickCount: number;
|
|
635
|
+
/** History of actual clicks (a linkId may appear multiple times). */
|
|
636
|
+
clickedLinks: Array<{
|
|
637
|
+
url: string;
|
|
638
|
+
linkId: string;
|
|
639
|
+
clickedAt: Date;
|
|
640
|
+
}>;
|
|
641
|
+
unsubscribedAt: Date | null;
|
|
642
|
+
complainedAt: Date | null;
|
|
643
|
+
queuedAt: Date;
|
|
644
|
+
sentAt: Date | null;
|
|
645
|
+
deliveredAt: Date | null;
|
|
646
|
+
}
|
|
647
|
+
interface SuppressionDoc {
|
|
648
|
+
_id?: ObjectId;
|
|
649
|
+
email: string | null;
|
|
650
|
+
emailHash: string;
|
|
651
|
+
scope: SuppressionScope;
|
|
652
|
+
reason: SuppressionReason;
|
|
653
|
+
source: string;
|
|
654
|
+
notes: string | null;
|
|
655
|
+
addedAt: Date;
|
|
656
|
+
expiresAt: Date | null;
|
|
657
|
+
}
|
|
658
|
+
interface BroadcastDoc {
|
|
659
|
+
_id?: ObjectId;
|
|
660
|
+
slug: string;
|
|
661
|
+
name: string;
|
|
662
|
+
templateSlug: string;
|
|
663
|
+
segmentDefinition: SegmentDefinition;
|
|
664
|
+
status: BroadcastStatus;
|
|
665
|
+
scheduledAt: Date | null;
|
|
666
|
+
startedAt: Date | null;
|
|
667
|
+
completedAt: Date | null;
|
|
668
|
+
confirmationRequired: boolean;
|
|
669
|
+
confirmedCount: number | null;
|
|
670
|
+
confirmedAt: Date | null;
|
|
671
|
+
confirmedBy: string | null;
|
|
672
|
+
recipientCount: number | null;
|
|
673
|
+
/** When true, dispatch fires each recipient at their local-timezone equivalent of `scheduledAt`. */
|
|
674
|
+
respectRecipientTimezone?: boolean;
|
|
675
|
+
stats: {
|
|
676
|
+
sent: number;
|
|
677
|
+
delivered: number;
|
|
678
|
+
opened: number;
|
|
679
|
+
clicked: number;
|
|
680
|
+
bounced: number;
|
|
681
|
+
complained: number;
|
|
682
|
+
unsubscribed: number;
|
|
683
|
+
};
|
|
684
|
+
createdAt: Date;
|
|
685
|
+
createdBy: string;
|
|
686
|
+
updatedAt: Date;
|
|
687
|
+
}
|
|
688
|
+
interface OutboxDoc {
|
|
689
|
+
_id?: ObjectId;
|
|
690
|
+
payload: {
|
|
691
|
+
type: 'event' | 'upsert_subscription' | 'unsubscribe';
|
|
692
|
+
data: Record<string, unknown>;
|
|
693
|
+
dedupeKey: string;
|
|
694
|
+
};
|
|
695
|
+
status: 'pending' | 'processed' | 'failed' | 'duplicate';
|
|
696
|
+
attempts: number;
|
|
697
|
+
lastAttemptAt: Date | null;
|
|
698
|
+
lastError: string | null;
|
|
699
|
+
enqueuedAt: Date;
|
|
700
|
+
processedAt: Date | null;
|
|
701
|
+
}
|
|
702
|
+
interface AuditLogDoc {
|
|
703
|
+
_id?: ObjectId;
|
|
704
|
+
actor: string;
|
|
705
|
+
action: string;
|
|
706
|
+
resource: {
|
|
707
|
+
collection: string;
|
|
708
|
+
id?: ObjectId | string;
|
|
709
|
+
slug?: string;
|
|
710
|
+
};
|
|
711
|
+
before: Record<string, unknown> | null;
|
|
712
|
+
after: Record<string, unknown> | null;
|
|
713
|
+
diffSummary: string | null;
|
|
714
|
+
ip: string | null;
|
|
715
|
+
userAgent: string | null;
|
|
716
|
+
requestId: string | null;
|
|
717
|
+
occurredAt: Date;
|
|
718
|
+
}
|
|
719
|
+
interface WebhookEventDoc {
|
|
720
|
+
_id?: ObjectId;
|
|
721
|
+
provider: string;
|
|
722
|
+
providerEventId: string;
|
|
723
|
+
eventType: string;
|
|
724
|
+
normalizedType: 'delivered' | 'open' | 'click' | 'bounce' | 'complaint' | 'unsubscribe' | 'spam_report';
|
|
725
|
+
providerMessageId: string;
|
|
726
|
+
email: string;
|
|
727
|
+
occurredAt: Date;
|
|
728
|
+
receivedAt: Date;
|
|
729
|
+
processed: boolean;
|
|
730
|
+
raw: unknown;
|
|
731
|
+
}
|
|
732
|
+
interface HealthDoc {
|
|
733
|
+
_id: 'singleton';
|
|
734
|
+
windowStartedAt: Date;
|
|
735
|
+
windowDurationMs: number;
|
|
736
|
+
counters: {
|
|
737
|
+
sent: number;
|
|
738
|
+
delivered: number;
|
|
739
|
+
bounced: number;
|
|
740
|
+
hardBounced: number;
|
|
741
|
+
softBounced: number;
|
|
742
|
+
complained: number;
|
|
743
|
+
failedToSend: number;
|
|
744
|
+
};
|
|
745
|
+
rates: {
|
|
746
|
+
bounceRate: number;
|
|
747
|
+
hardBounceRate: number;
|
|
748
|
+
complaintRate: number;
|
|
749
|
+
failureRate: number;
|
|
750
|
+
};
|
|
751
|
+
status: HealthStatus;
|
|
752
|
+
trippedAt: Date | null;
|
|
753
|
+
trippedReason: string | null;
|
|
754
|
+
manuallyResumedAt: Date | null;
|
|
755
|
+
updatedAt: Date;
|
|
756
|
+
}
|
|
757
|
+
interface ContactTagDoc {
|
|
758
|
+
_id?: ObjectId;
|
|
759
|
+
externalId: string;
|
|
760
|
+
tag: string;
|
|
761
|
+
appliedBy: 'flow' | 'admin' | 'script' | 'import';
|
|
762
|
+
appliedAt: Date;
|
|
763
|
+
}
|
|
764
|
+
interface Collections {
|
|
765
|
+
subscriptions: Collection<SubscriptionDoc>;
|
|
766
|
+
leads: Collection<LeadDoc>;
|
|
767
|
+
events: Collection<EventDoc>;
|
|
768
|
+
flows: Collection<FlowDoc>;
|
|
769
|
+
flowVersions: Collection<FlowVersionDoc>;
|
|
770
|
+
flowRuns: Collection<FlowRunDoc>;
|
|
771
|
+
templates: Collection<TemplateDoc>;
|
|
772
|
+
templateVersions: Collection<TemplateVersionDoc>;
|
|
773
|
+
sends: Collection<SendDoc>;
|
|
774
|
+
suppressions: Collection<SuppressionDoc>;
|
|
775
|
+
broadcasts: Collection<BroadcastDoc>;
|
|
776
|
+
outbox: Collection<OutboxDoc>;
|
|
777
|
+
auditLog: Collection<AuditLogDoc>;
|
|
778
|
+
webhookEvents: Collection<WebhookEventDoc>;
|
|
779
|
+
health: Collection<HealthDoc>;
|
|
780
|
+
contactTags: Collection<ContactTagDoc>;
|
|
781
|
+
}
|
|
782
|
+
declare function getCollections(db: Db, prefix?: string): Collections;
|
|
783
|
+
declare function ensureIndexes(db: Db, prefix?: string): Promise<void>;
|
|
784
|
+
|
|
785
|
+
/**
|
|
786
|
+
* Event registry + dedupe-key policy. INVARIANT 1: every `mailer_events` row
|
|
787
|
+
* has a unique dedupeKey. Callers either pass one or `mailer.registerEvent`
|
|
788
|
+
* declares a policy and we derive it.
|
|
789
|
+
*/
|
|
790
|
+
type DedupePolicy = 'once-per-contact' | 'once-per-day' | 'every-time';
|
|
791
|
+
interface EventRegistration {
|
|
792
|
+
name: string;
|
|
793
|
+
dedupePolicy: DedupePolicy;
|
|
794
|
+
}
|
|
795
|
+
declare class EventRegistry {
|
|
796
|
+
private readonly policies;
|
|
797
|
+
register(reg: EventRegistration): void;
|
|
798
|
+
has(name: string): boolean;
|
|
799
|
+
policy(name: string): DedupePolicy | undefined;
|
|
800
|
+
/**
|
|
801
|
+
* Derive a dedupeKey for an event call. Returns null when no policy is
|
|
802
|
+
* registered AND no key was passed — caller should throw.
|
|
803
|
+
*/
|
|
804
|
+
deriveKey(name: string, externalId: string, passedKey: string | undefined, now: Date): string | null;
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* BullMQ wiring: four queues + the corresponding worker factories.
|
|
809
|
+
*
|
|
810
|
+
* mailer:tick → recovery sweep + event-trigger scan + scheduled broadcasts + outbox drain
|
|
811
|
+
* mailer:advance → per-flow_run wakeup at nextActionAt (delayed jobs)
|
|
812
|
+
* mailer:send → provider dispatch for a single send row
|
|
813
|
+
* mailer:webhook → async normalization + apply of inbound provider events
|
|
814
|
+
*
|
|
815
|
+
* The Mailer class instantiates queues at init() and workers at startWorkers().
|
|
816
|
+
* Job handlers themselves live in `runner/` and `api/webhook-processor.ts`.
|
|
817
|
+
*/
|
|
818
|
+
|
|
819
|
+
/**
|
|
820
|
+
* Minimal queue surface the runner depends on. Production wraps BullMQ; tests
|
|
821
|
+
* can supply a no-op implementation.
|
|
822
|
+
*/
|
|
823
|
+
interface QueueAPI {
|
|
824
|
+
add(name: string, data: unknown, opts?: {
|
|
825
|
+
delay?: number;
|
|
826
|
+
attempts?: number;
|
|
827
|
+
backoff?: {
|
|
828
|
+
type: 'exponential';
|
|
829
|
+
delay: number;
|
|
830
|
+
};
|
|
831
|
+
jobId?: string;
|
|
832
|
+
}): Promise<unknown>;
|
|
833
|
+
getWaitingCount(): Promise<number>;
|
|
834
|
+
close(): Promise<void>;
|
|
835
|
+
}
|
|
836
|
+
interface Queues {
|
|
837
|
+
tick: QueueAPI;
|
|
838
|
+
advance: QueueAPI;
|
|
839
|
+
send: QueueAPI;
|
|
840
|
+
webhook: QueueAPI;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* Runner context + public entry points. The shared context object is passed
|
|
845
|
+
* to every handler so the runner stays a pure function over (state, action).
|
|
846
|
+
*/
|
|
847
|
+
|
|
848
|
+
interface RunnerContext {
|
|
849
|
+
db: Db;
|
|
850
|
+
collections: Collections;
|
|
851
|
+
adapter: ContactAdapter;
|
|
852
|
+
providers: Record<string, MailProvider>;
|
|
853
|
+
queues: Queues;
|
|
854
|
+
config: ResolvedConfig;
|
|
855
|
+
handlebarsHelpers?: Record<string, Handlebars.HelperDelegate>;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Mailer — public facade. Hosts call `Mailer.init(config)` and get an object
|
|
860
|
+
* exposing the public API (fire, upsertSubscription, unsubscribe, ...) plus
|
|
861
|
+
* mountable Express routers.
|
|
862
|
+
*
|
|
863
|
+
* See plans/10-public-api.md for the surface.
|
|
864
|
+
*/
|
|
865
|
+
|
|
866
|
+
declare class Mailer {
|
|
867
|
+
readonly db: Db;
|
|
868
|
+
readonly collections: Collections;
|
|
869
|
+
readonly adapter: ContactAdapter;
|
|
870
|
+
readonly providers: Record<string, MailProvider>;
|
|
871
|
+
readonly redis: IORedis | null;
|
|
872
|
+
readonly queues: Queues;
|
|
873
|
+
readonly config: ResolvedConfig;
|
|
874
|
+
readonly events: EventRegistry;
|
|
875
|
+
private workers;
|
|
876
|
+
private bullQueues;
|
|
877
|
+
private runnerContext;
|
|
878
|
+
private constructor();
|
|
879
|
+
/**
|
|
880
|
+
* Construct a Mailer from environment variables. Reads:
|
|
881
|
+
*
|
|
882
|
+
* MAILER_MONGODB_URI — Mongo connection string (required)
|
|
883
|
+
* MAILER_MONGODB_DB — database name (optional; defaults to the URI default)
|
|
884
|
+
* MAILER_REDIS_URL — Redis connection URL (required)
|
|
885
|
+
* MAILER_PUBLIC_URL — base for tracking/unsub URLs (required)
|
|
886
|
+
* MAILER_UNSUBSCRIBE_SECRET — HMAC key (required)
|
|
887
|
+
* MAILER_SENDER_ADDRESS — postal address for CAN-SPAM (optional)
|
|
888
|
+
* MAILER_FROM_NAME / MAILER_FROM_EMAIL
|
|
889
|
+
* MAILER_DEFAULT_PROVIDER — defaults to 'sendgrid' if SENDGRID_API_KEY is set
|
|
890
|
+
* MAILER_SENDGRID_API_KEY / MAILER_SENDGRID_WEBHOOK_KEY
|
|
891
|
+
* MAILER_HOST_USERS_COLLECTION (default 'users')
|
|
892
|
+
* MAILER_HOST_USERS_EMAIL_FIELD (default 'email')
|
|
893
|
+
* MAILER_HOST_USERS_ID_FIELD (default '_id')
|
|
894
|
+
* MAILER_HOST_USERS_TAGS_FIELD
|
|
895
|
+
* MAILER_HOST_USERS_TAGS_WRITABLE — '1' or 'true' to enable
|
|
896
|
+
*
|
|
897
|
+
* For anything beyond this (custom toContact, custom providers, hooks),
|
|
898
|
+
* use the programmatic init.
|
|
899
|
+
*/
|
|
900
|
+
static fromEnv(): Promise<Mailer>;
|
|
901
|
+
static init(input: MailerConfig): Promise<Mailer>;
|
|
902
|
+
registerEvent(reg: RegisterEventInput): void;
|
|
903
|
+
fire(eventName: string, externalId: string, properties?: Record<string, unknown>, dedupeKey?: string): Promise<void>;
|
|
904
|
+
fireFromSession(session: ClientSession, eventName: string, externalId: string, properties?: Record<string, unknown>, dedupeKey?: string): Promise<void>;
|
|
905
|
+
upsertSubscription(input: UpsertSubscriptionInput): Promise<void>;
|
|
906
|
+
unsubscribe(email: string, opts: Omit<UnsubscribeInput, 'email'>): Promise<void>;
|
|
907
|
+
suppress(email: string, opts: Omit<SuppressInput, 'email'>): Promise<void>;
|
|
908
|
+
tag(externalId: string, tag: string): Promise<void>;
|
|
909
|
+
untag(externalId: string, tag: string): Promise<void>;
|
|
910
|
+
/**
|
|
911
|
+
* GDPR right-to-erasure. Hard-deletes the contact's PII and leaves a hashed
|
|
912
|
+
* suppression row to block re-import. INVARIANT 9.
|
|
913
|
+
*/
|
|
914
|
+
forget(externalId: string): Promise<void>;
|
|
915
|
+
/** GDPR data export. JSON-serializable. */
|
|
916
|
+
exportContactData(externalId: string): Promise<Record<string, unknown>>;
|
|
917
|
+
sendOneOff(input: SendOneOffInput): Promise<{
|
|
918
|
+
sendId: string;
|
|
919
|
+
}>;
|
|
920
|
+
audit(entry: {
|
|
921
|
+
actor: string;
|
|
922
|
+
action: string;
|
|
923
|
+
resource: {
|
|
924
|
+
collection: string;
|
|
925
|
+
id?: string | ObjectId;
|
|
926
|
+
slug?: string;
|
|
927
|
+
};
|
|
928
|
+
before?: Record<string, unknown> | null;
|
|
929
|
+
after?: Record<string, unknown> | null;
|
|
930
|
+
diffSummary?: string;
|
|
931
|
+
ip?: string;
|
|
932
|
+
userAgent?: string;
|
|
933
|
+
requestId?: string;
|
|
934
|
+
}): Promise<void>;
|
|
935
|
+
startWorkers(): Promise<void>;
|
|
936
|
+
/** Process unprocessed webhook events in mailer_webhook_events. */
|
|
937
|
+
private processWebhookBacklog;
|
|
938
|
+
stop(): Promise<void>;
|
|
939
|
+
/** Used internally by the admin router and tests; not part of the public API. */
|
|
940
|
+
getRunnerContext(): RunnerContext;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* NullProvider — in-memory provider for tests and dev. Records every send
|
|
945
|
+
* without dispatching anything. Inspect `.sent` to assert what would have
|
|
946
|
+
* been delivered.
|
|
947
|
+
*/
|
|
948
|
+
|
|
949
|
+
declare class NullProvider implements MailProvider {
|
|
950
|
+
readonly name = "null";
|
|
951
|
+
readonly sendRatePerSecond = 1000;
|
|
952
|
+
readonly sent: SendArgs[];
|
|
953
|
+
send(args: SendArgs): Promise<SendResult>;
|
|
954
|
+
verifyWebhook(): Promise<boolean>;
|
|
955
|
+
parseWebhookEvents(): NormalizedEvent[];
|
|
956
|
+
reset(): void;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
export { type AdapterFilter as A, type BroadcastDoc as B, type ContactAdapter as C, type TemplateVersionDoc as D, type EventDoc as E, type FlowDoc as F, ensureIndexes as G, type HealthDoc as H, getCollections as I, type LeadDoc as L, type MailProvider as M, type NormalizedEvent as N, type OutboxDoc as O, type Predicate as P, type RedisOptions as R, type SendArgs as S, type TemplateDoc as T, type WebhookEventDoc as W, type Contact as a, type SendResult as b, Mailer as c, type SuppressionScope as d, type AuditLogDoc as e, type BroadcastStatus as f, type CircuitBreakerThresholds as g, type Collections as h, type ContactTagDoc as i, type FlowGoal as j, type FlowRunDoc as k, type FlowRunStatus as l, type FlowStep as m, type FlowVersionDoc as n, type HealthStatus as o, type MailerConfig as p, NullProvider as q, type SegmentDefinition as r, type SegmentFilter as s, type SendDoc as t, type SendStatus as u, type SubscriptionDoc as v, type SubscriptionStatus as w, type SuppressionDoc as x, type SuppressionReason as y, type TemplateKind as z };
|