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/dist/index.cjs CHANGED
@@ -1,8 +1,3697 @@
1
1
  'use strict';
2
2
 
3
- // src/index.ts
4
- var VERSION = "0.0.0";
3
+ var mongodb = require('mongodb');
4
+ var crypto2 = require('crypto');
5
+ var sgMail = require('@sendgrid/mail');
6
+ var zod = require('zod');
7
+ var bullmq = require('bullmq');
8
+ var IORedis = require('ioredis');
9
+ var Handlebars = require('handlebars');
10
+ var htmlToText = require('html-to-text');
11
+ var mjml2html = require('mjml');
12
+ var express = require('express');
13
+ var path = require('path');
14
+ var url = require('url');
15
+ var fs = require('fs');
5
16
 
17
+ var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
18
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
19
+
20
+ var crypto2__default = /*#__PURE__*/_interopDefault(crypto2);
21
+ var sgMail__default = /*#__PURE__*/_interopDefault(sgMail);
22
+ var IORedis__default = /*#__PURE__*/_interopDefault(IORedis);
23
+ var Handlebars__default = /*#__PURE__*/_interopDefault(Handlebars);
24
+ var mjml2html__default = /*#__PURE__*/_interopDefault(mjml2html);
25
+ var express__default = /*#__PURE__*/_interopDefault(express);
26
+ var path__default = /*#__PURE__*/_interopDefault(path);
27
+ var fs__default = /*#__PURE__*/_interopDefault(fs);
28
+
29
+ var __defProp = Object.defineProperty;
30
+ var __getOwnPropNames = Object.getOwnPropertyNames;
31
+ var __esm = (fn, res) => function __init() {
32
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
33
+ };
34
+ var __export = (target, all) => {
35
+ for (var name in all)
36
+ __defProp(target, name, { get: all[name], enumerable: true });
37
+ };
38
+
39
+ // src/server/adapters/mongo.ts
40
+ var mongo_exports = {};
41
+ __export(mongo_exports, {
42
+ MongoContactAdapter: () => exports.MongoContactAdapter
43
+ });
44
+ function canBeObjectId(s) {
45
+ return typeof s === "string" && /^[a-f0-9]{24}$/i.test(s);
46
+ }
47
+ exports.MongoContactAdapter = void 0;
48
+ var init_mongo = __esm({
49
+ "src/server/adapters/mongo.ts"() {
50
+ exports.MongoContactAdapter = class {
51
+ col;
52
+ emailField;
53
+ idField;
54
+ tagsField;
55
+ tagsWritable;
56
+ tagsArrayShape;
57
+ toContactFn;
58
+ translateFilterFn;
59
+ batchSize;
60
+ constructor(opts) {
61
+ this.col = opts.db.collection(opts.collection);
62
+ this.emailField = opts.emailField ?? "email";
63
+ this.idField = opts.idField ?? "_id";
64
+ this.tagsField = opts.tagsField ?? null;
65
+ this.tagsWritable = !!opts.tagsWritable;
66
+ this.tagsArrayShape = opts.tagsArrayShape ?? "strings";
67
+ this.toContactFn = opts.toContact ?? this.defaultToContact.bind(this);
68
+ this.translateFilterFn = opts.translateFilter ?? this.defaultTranslateFilter.bind(this);
69
+ this.batchSize = opts.batchSize ?? 500;
70
+ if (this.tagsWritable && this.tagsField) {
71
+ this.addTags = this.addTagsImpl.bind(this);
72
+ this.removeTags = this.removeTagsImpl.bind(this);
73
+ }
74
+ }
75
+ async getById(externalId) {
76
+ const filter = this.idFilter(externalId);
77
+ const doc = await this.col.findOne(filter);
78
+ return doc ? this.toContactFn(doc) : null;
79
+ }
80
+ async getByEmail(email) {
81
+ const doc = await this.col.findOne({ [this.emailField]: email.toLowerCase() });
82
+ return doc ? this.toContactFn(doc) : null;
83
+ }
84
+ async getBatch(externalIds) {
85
+ if (externalIds.length === 0) return /* @__PURE__ */ new Map();
86
+ const tryObjectId = externalIds.every(canBeObjectId);
87
+ const ids = tryObjectId ? externalIds.map((s) => new mongodb.ObjectId(s)) : externalIds;
88
+ const docs = await this.col.find({ [this.idField]: { $in: ids } }).toArray();
89
+ const out = /* @__PURE__ */ new Map();
90
+ for (const doc of docs) {
91
+ const c = this.toContactFn(doc);
92
+ out.set(c.externalId, c);
93
+ }
94
+ return out;
95
+ }
96
+ async query(filter, opts) {
97
+ const query = this.translateFilterFn(filter);
98
+ const limit = Math.min(opts.limit, this.batchSize);
99
+ if (opts.cursor) {
100
+ const cursorVal = canBeObjectId(opts.cursor) ? new mongodb.ObjectId(opts.cursor) : opts.cursor;
101
+ query[this.idField] = { ...query[this.idField] ?? {}, $gt: cursorVal };
102
+ }
103
+ const docs = await this.col.find(query).sort({ [this.idField]: 1 }).limit(limit + 1).toArray();
104
+ const hasMore = docs.length > limit;
105
+ const slice = hasMore ? docs.slice(0, limit) : docs;
106
+ const contacts = slice.map((d) => this.toContactFn(d));
107
+ const last = slice[slice.length - 1];
108
+ const nextCursor = hasMore && last ? String(last[this.idField]) : void 0;
109
+ return { contacts, nextCursor };
110
+ }
111
+ async count(filter) {
112
+ const query = this.translateFilterFn(filter);
113
+ return await this.col.countDocuments(query);
114
+ }
115
+ addTags;
116
+ removeTags;
117
+ // -------------------------------------------------------------------------
118
+ // Private helpers
119
+ // -------------------------------------------------------------------------
120
+ idFilter(externalId) {
121
+ if (this.idField === "_id" && canBeObjectId(externalId)) {
122
+ return { _id: new mongodb.ObjectId(externalId) };
123
+ }
124
+ return { [this.idField]: externalId };
125
+ }
126
+ defaultToContact(doc) {
127
+ const tagsRaw = this.tagsField ? doc[this.tagsField] ?? [] : [];
128
+ const tags = this.tagsArrayShape === "objects" ? (Array.isArray(tagsRaw) ? tagsRaw : []).map((t) => String(t.name ?? t)) : (Array.isArray(tagsRaw) ? tagsRaw : []).map((t) => String(t));
129
+ const externalId = String(doc[this.idField] ?? "");
130
+ const email = String(doc[this.emailField] ?? "").toLowerCase();
131
+ const fields = {};
132
+ for (const [k, v] of Object.entries(doc)) {
133
+ if (k === this.idField || k === this.emailField || k === this.tagsField) continue;
134
+ fields[k] = v;
135
+ }
136
+ return {
137
+ externalId,
138
+ email,
139
+ tags,
140
+ fields,
141
+ timezone: typeof doc.timezone === "string" ? doc.timezone : void 0,
142
+ locale: typeof doc.locale === "string" ? doc.locale : void 0
143
+ };
144
+ }
145
+ defaultTranslateFilter(filter) {
146
+ const q = {};
147
+ if (filter.emailIn && filter.emailIn.length > 0) {
148
+ q[this.emailField] = { $in: filter.emailIn.map((e) => e.toLowerCase()) };
149
+ }
150
+ if (filter.externalIdIn && filter.externalIdIn.length > 0) {
151
+ const tryObjectId = filter.externalIdIn.every(canBeObjectId);
152
+ q[this.idField] = {
153
+ $in: tryObjectId ? filter.externalIdIn.map((s) => new mongodb.ObjectId(s)) : filter.externalIdIn
154
+ };
155
+ }
156
+ if (filter.fieldEquals) {
157
+ q[filter.fieldEquals.field] = filter.fieldEquals.value;
158
+ }
159
+ if (filter.fieldIn) {
160
+ q[filter.fieldIn.field] = { $in: filter.fieldIn.values };
161
+ }
162
+ if (filter.fieldExists) {
163
+ q[filter.fieldExists] = { $exists: true };
164
+ }
165
+ if (filter.createdAfter || filter.createdBefore) {
166
+ const r = {};
167
+ if (filter.createdAfter) r.$gte = filter.createdAfter;
168
+ if (filter.createdBefore) r.$lte = filter.createdBefore;
169
+ q.createdAt = r;
170
+ }
171
+ if (this.tagsField) {
172
+ if (filter.hasTag) {
173
+ q[this.tagsField] = filter.hasTag;
174
+ }
175
+ if (filter.hasTagIn && filter.hasTagIn.length > 0) {
176
+ q[this.tagsField] = { $in: filter.hasTagIn };
177
+ }
178
+ }
179
+ return q;
180
+ }
181
+ async addTagsImpl(externalId, tags) {
182
+ if (!this.tagsField || tags.length === 0) return;
183
+ const value = this.tagsArrayShape === "objects" ? tags.map((name) => ({ name })) : tags;
184
+ await this.col.updateOne(this.idFilter(externalId), {
185
+ $addToSet: { [this.tagsField]: { $each: value } }
186
+ });
187
+ }
188
+ async removeTagsImpl(externalId, tags) {
189
+ if (!this.tagsField || tags.length === 0) return;
190
+ const value = this.tagsArrayShape === "objects" ? { $in: tags.map((name) => ({ name })) } : { $in: tags };
191
+ await this.col.updateOne(this.idFilter(externalId), {
192
+ $pull: { [this.tagsField]: value }
193
+ });
194
+ }
195
+ };
196
+ }
197
+ });
198
+
199
+ // src/server/providers/sendgrid.ts
200
+ var sendgrid_exports = {};
201
+ __export(sendgrid_exports, {
202
+ SendGridProvider: () => exports.SendGridProvider
203
+ });
204
+ function normalizeSendGridEvent(e) {
205
+ const type = mapEventType(e.event);
206
+ if (!type) return null;
207
+ return {
208
+ type,
209
+ providerEventId: String(e.sg_event_id ?? e["smtp-id"] ?? `${e.event}-${e.timestamp}-${e.email}`),
210
+ providerMessageId: String(e.sg_message_id ?? e["smtp-id"] ?? ""),
211
+ email: String(e.email ?? "").toLowerCase(),
212
+ occurredAt: new Date(Number(e.timestamp) * 1e3),
213
+ details: {
214
+ bounceType: e.event === "bounce" ? e.type === "bounce" ? "hard" : "soft" : void 0,
215
+ bounceReason: e.reason,
216
+ clickedUrl: e.url,
217
+ userAgent: e.useragent,
218
+ ipAddress: e.ip
219
+ }
220
+ };
221
+ }
222
+ function mapEventType(sgEvent) {
223
+ switch (sgEvent) {
224
+ case "delivered":
225
+ return "delivered";
226
+ case "open":
227
+ return "open";
228
+ case "click":
229
+ return "click";
230
+ case "bounce":
231
+ case "dropped":
232
+ return "bounce";
233
+ case "spamreport":
234
+ return "spam_report";
235
+ case "unsubscribe":
236
+ case "group_unsubscribe":
237
+ return "unsubscribe";
238
+ default:
239
+ return null;
240
+ }
241
+ }
242
+ var SG_SIG_HEADER, SG_TS_HEADER; exports.SendGridProvider = void 0;
243
+ var init_sendgrid = __esm({
244
+ "src/server/providers/sendgrid.ts"() {
245
+ SG_SIG_HEADER = "x-twilio-email-event-webhook-signature";
246
+ SG_TS_HEADER = "x-twilio-email-event-webhook-timestamp";
247
+ exports.SendGridProvider = class {
248
+ constructor(opts) {
249
+ this.opts = opts;
250
+ sgMail__default.default.setApiKey(opts.apiKey);
251
+ this.sendRatePerSecond = opts.sendRatePerSecond ?? 10;
252
+ }
253
+ opts;
254
+ name = "sendgrid";
255
+ sendRatePerSecond;
256
+ async send(args) {
257
+ const msg = {
258
+ to: args.to,
259
+ from: { name: args.fromName, email: args.fromEmail },
260
+ replyTo: args.replyTo,
261
+ subject: args.subject,
262
+ text: args.text,
263
+ html: args.html,
264
+ headers: args.headers,
265
+ customArgs: args.messageMeta,
266
+ trackingSettings: {
267
+ // We do our own click/open tracking — let provider stay out of it.
268
+ clickTracking: { enable: false, enableText: false },
269
+ openTracking: { enable: false }
270
+ },
271
+ mailSettings: {
272
+ sandboxMode: { enable: this.opts.sandbox ?? false }
273
+ }
274
+ };
275
+ const [response] = await sgMail__default.default.send(msg);
276
+ const providerId = response.headers["x-message-id"] ?? `sg-${Date.now()}`;
277
+ return {
278
+ providerId,
279
+ status: response.statusCode < 300 ? "accepted" : "rejected",
280
+ raw: response
281
+ };
282
+ }
283
+ async verifyWebhook(rawBody, headers) {
284
+ if (!this.opts.webhookVerificationKey) return false;
285
+ const sig = headers[SG_SIG_HEADER];
286
+ const ts = headers[SG_TS_HEADER];
287
+ if (!sig || !ts) return false;
288
+ const payload = Buffer.concat([Buffer.from(ts, "utf8"), rawBody]);
289
+ try {
290
+ const verifier = crypto2__default.default.createVerify("sha256");
291
+ verifier.update(payload);
292
+ return verifier.verify(this.opts.webhookVerificationKey, sig, "base64");
293
+ } catch {
294
+ return false;
295
+ }
296
+ }
297
+ parseWebhookEvents(payload) {
298
+ if (!Array.isArray(payload)) return [];
299
+ return payload.map((raw) => normalizeSendGridEvent(raw)).filter((e) => e !== null);
300
+ }
301
+ };
302
+ }
303
+ });
304
+ var externalIdSchema = zod.z.string().min(1).max(256);
305
+ var emailSchema = zod.z.string().email().toLowerCase().trim();
306
+ var slugSchema = zod.z.string().min(1).max(128).regex(/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/, "must be kebab-case slug");
307
+ var fireInputSchema = zod.z.object({
308
+ eventName: zod.z.string().min(1).max(128),
309
+ externalId: externalIdSchema,
310
+ properties: zod.z.record(zod.z.string(), zod.z.unknown()).optional(),
311
+ dedupeKey: zod.z.string().min(1).max(512).optional()
312
+ });
313
+ var registerEventSchema = zod.z.object({
314
+ name: zod.z.string().min(1).max(128),
315
+ dedupePolicy: zod.z.enum(["once-per-contact", "once-per-day", "every-time"])
316
+ });
317
+ var upsertSubscriptionSchema = zod.z.object({
318
+ externalId: externalIdSchema,
319
+ source: zod.z.string().min(1).max(256),
320
+ consentTimestamp: zod.z.date().optional(),
321
+ consentIp: zod.z.string().optional(),
322
+ consentUserAgent: zod.z.string().optional()
323
+ });
324
+ var unsubscribeScopeSchema = zod.z.enum(["all", "marketing", "transactional"]);
325
+ var unsubscribeReasonSchema = zod.z.enum([
326
+ "user_request",
327
+ "hard_bounce",
328
+ "complaint",
329
+ "manual",
330
+ "gdpr_forget",
331
+ "list_cleaning"
332
+ ]);
333
+ var unsubscribeInputSchema = zod.z.object({
334
+ email: emailSchema,
335
+ scope: unsubscribeScopeSchema,
336
+ reason: unsubscribeReasonSchema.default("user_request"),
337
+ source: zod.z.string().max(256).default("manual"),
338
+ notes: zod.z.string().max(1024).optional()
339
+ });
340
+ var suppressInputSchema = zod.z.object({
341
+ email: emailSchema,
342
+ scope: unsubscribeScopeSchema,
343
+ reason: zod.z.enum(["unsubscribed", "hard_bounce", "complaint", "manual", "list_cleaning", "gdpr_forget"]),
344
+ source: zod.z.string().max(256).default("manual"),
345
+ notes: zod.z.string().max(1024).optional(),
346
+ expiresAt: zod.z.date().optional()
347
+ });
348
+ var tagInputSchema = zod.z.object({
349
+ externalId: externalIdSchema,
350
+ tag: zod.z.string().min(1).max(128)
351
+ });
352
+ var sendOneOffInputSchema = zod.z.object({
353
+ templateSlug: slugSchema,
354
+ externalId: externalIdSchema,
355
+ vars: zod.z.record(zod.z.string(), zod.z.unknown()).optional(),
356
+ providerOverride: zod.z.string().optional(),
357
+ dedupeKey: zod.z.string().min(1).max(512)
358
+ });
359
+ var flowStepSchema = zod.z.lazy(
360
+ () => zod.z.discriminatedUnion("type", [
361
+ zod.z.object({
362
+ type: zod.z.literal("wait"),
363
+ value: zod.z.number().int().positive(),
364
+ unit: zod.z.enum(["minutes", "hours", "days", "weeks"])
365
+ }),
366
+ zod.z.object({
367
+ type: zod.z.literal("condition"),
368
+ test: predicateSchema,
369
+ ifFalse: zod.z.enum(["continue", "exit"])
370
+ }),
371
+ zod.z.object({
372
+ type: zod.z.literal("branch"),
373
+ test: predicateSchema,
374
+ ifTrueSteps: zod.z.array(flowStepSchema),
375
+ ifFalseSteps: zod.z.array(flowStepSchema)
376
+ }),
377
+ zod.z.object({
378
+ type: zod.z.literal("send"),
379
+ templateSlug: slugSchema,
380
+ providerOverride: zod.z.string().optional(),
381
+ vars: zod.z.record(zod.z.string(), zod.z.unknown()).optional()
382
+ }),
383
+ zod.z.object({
384
+ type: zod.z.literal("tag"),
385
+ addTags: zod.z.array(zod.z.string()).optional(),
386
+ removeTags: zod.z.array(zod.z.string()).optional()
387
+ }),
388
+ zod.z.object({
389
+ type: zod.z.literal("fire_event"),
390
+ eventName: zod.z.string().min(1).max(128),
391
+ properties: zod.z.record(zod.z.string(), zod.z.unknown()).optional()
392
+ }),
393
+ zod.z.object({
394
+ type: zod.z.literal("webhook"),
395
+ url: zod.z.string().url(),
396
+ method: zod.z.enum(["POST", "PUT"]).optional(),
397
+ payload: zod.z.record(zod.z.string(), zod.z.unknown()).optional(),
398
+ failureMode: zod.z.enum(["soft", "fail_run"]).optional()
399
+ }),
400
+ zod.z.object({
401
+ type: zod.z.literal("exit"),
402
+ reason: zod.z.string().optional()
403
+ })
404
+ ])
405
+ );
406
+ var predicateSchema = zod.z.lazy(
407
+ () => zod.z.union([
408
+ zod.z.object({ hasTag: zod.z.string() }),
409
+ zod.z.object({ notHasTag: zod.z.string() }),
410
+ zod.z.object({ fieldEquals: zod.z.object({ field: zod.z.string(), value: zod.z.unknown() }) }),
411
+ zod.z.object({ fieldExists: zod.z.string() }),
412
+ zod.z.object({
413
+ hasFiredEvent: zod.z.string(),
414
+ sinceFlowStart: zod.z.boolean().optional(),
415
+ withinDays: zod.z.number().int().positive().optional()
416
+ }),
417
+ zod.z.object({
418
+ notHasFiredEvent: zod.z.string(),
419
+ withinDays: zod.z.number().int().positive().optional()
420
+ }),
421
+ zod.z.object({
422
+ subscriptionStatus: zod.z.enum(["subscribed", "unsubscribed", "pending_doi", "bounced", "complained"])
423
+ }),
424
+ zod.z.object({
425
+ hasOpened: zod.z.object({
426
+ templateSlug: slugSchema.optional(),
427
+ sinceFlowStart: zod.z.boolean().optional(),
428
+ withinDays: zod.z.number().int().positive().optional()
429
+ })
430
+ }),
431
+ zod.z.object({
432
+ hasClicked: zod.z.object({
433
+ templateSlug: slugSchema.optional(),
434
+ sinceFlowStart: zod.z.boolean().optional(),
435
+ withinDays: zod.z.number().int().positive().optional()
436
+ })
437
+ }),
438
+ zod.z.object({
439
+ hasOpenedExcludingBots: zod.z.object({
440
+ templateSlug: slugSchema.optional(),
441
+ sinceFlowStart: zod.z.boolean().optional(),
442
+ withinDays: zod.z.number().int().positive().optional()
443
+ })
444
+ }),
445
+ zod.z.object({
446
+ hasClickedExcludingBots: zod.z.object({
447
+ templateSlug: slugSchema.optional(),
448
+ sinceFlowStart: zod.z.boolean().optional(),
449
+ withinDays: zod.z.number().int().positive().optional()
450
+ })
451
+ }),
452
+ zod.z.object({
453
+ openedAtLeastN: zod.z.object({ count: zod.z.number().int().positive(), withinDays: zod.z.number().int().positive() })
454
+ }),
455
+ zod.z.object({
456
+ clickedAtLeastN: zod.z.object({ count: zod.z.number().int().positive(), withinDays: zod.z.number().int().positive() })
457
+ }),
458
+ zod.z.object({ all: zod.z.array(predicateSchema) }),
459
+ zod.z.object({ any: zod.z.array(predicateSchema) }),
460
+ zod.z.object({ not: predicateSchema })
461
+ ])
462
+ );
463
+
464
+ // src/server/config.ts
465
+ var DEFAULTS = {
466
+ collectionPrefix: "mailer_",
467
+ requireDoubleOptIn: false,
468
+ unsubscribeTokenLifetimeDays: 90,
469
+ transactionalRespectUnsubscribe: false,
470
+ doiTemplateSlug: "doi-confirmation",
471
+ doiTokenLifetimeDays: 7,
472
+ broadcastConfirmationThreshold: 1e3,
473
+ broadcastEnqueueBatchSize: 1e3,
474
+ broadcastEnqueueMaxWaiting: 5e3,
475
+ workerless: false,
476
+ tickIntervalSeconds: 60,
477
+ sendConcurrency: 5,
478
+ sendRatePerSecond: 10,
479
+ softBouncePromotionThreshold: 3,
480
+ softBouncePromotionWindowDays: 30,
481
+ webhookRetryAttempts: 3,
482
+ sendRetryAttempts: 4,
483
+ trackOpens: true,
484
+ trackClicks: true,
485
+ storeTrackingIp: false,
486
+ storeRenderedBody: false
487
+ };
488
+ var CIRCUIT_BREAKER_DEFAULTS = {
489
+ hardBounceRatePctTrip: 2,
490
+ complaintRatePctTrip: 0.3,
491
+ combinedBounceRatePctTrip: 5,
492
+ failedToSendRatePctDegrade: 10,
493
+ windowMinutes: 60,
494
+ minSendsBeforeEval: 100
495
+ };
496
+ function resolveConfig(c) {
497
+ return {
498
+ ...c,
499
+ collectionPrefix: c.collectionPrefix ?? DEFAULTS.collectionPrefix,
500
+ requireDoubleOptIn: c.requireDoubleOptIn ?? DEFAULTS.requireDoubleOptIn,
501
+ unsubscribeTokenLifetimeDays: c.unsubscribeTokenLifetimeDays ?? DEFAULTS.unsubscribeTokenLifetimeDays,
502
+ transactionalRespectUnsubscribe: c.transactionalRespectUnsubscribe ?? DEFAULTS.transactionalRespectUnsubscribe,
503
+ doiTemplateSlug: c.doiTemplateSlug ?? DEFAULTS.doiTemplateSlug,
504
+ doiTokenLifetimeDays: c.doiTokenLifetimeDays ?? DEFAULTS.doiTokenLifetimeDays,
505
+ broadcastConfirmationThreshold: c.broadcastConfirmationThreshold ?? DEFAULTS.broadcastConfirmationThreshold,
506
+ broadcastEnqueueBatchSize: c.broadcastEnqueueBatchSize ?? DEFAULTS.broadcastEnqueueBatchSize,
507
+ broadcastEnqueueMaxWaiting: c.broadcastEnqueueMaxWaiting ?? DEFAULTS.broadcastEnqueueMaxWaiting,
508
+ workerless: c.workerless ?? DEFAULTS.workerless,
509
+ tickIntervalSeconds: c.tickIntervalSeconds ?? DEFAULTS.tickIntervalSeconds,
510
+ sendConcurrency: c.sendConcurrency ?? DEFAULTS.sendConcurrency,
511
+ sendRatePerSecond: c.sendRatePerSecond ?? DEFAULTS.sendRatePerSecond,
512
+ softBouncePromotionThreshold: c.softBouncePromotionThreshold ?? DEFAULTS.softBouncePromotionThreshold,
513
+ softBouncePromotionWindowDays: c.softBouncePromotionWindowDays ?? DEFAULTS.softBouncePromotionWindowDays,
514
+ webhookRetryAttempts: c.webhookRetryAttempts ?? DEFAULTS.webhookRetryAttempts,
515
+ sendRetryAttempts: c.sendRetryAttempts ?? DEFAULTS.sendRetryAttempts,
516
+ trackOpens: c.trackOpens ?? DEFAULTS.trackOpens,
517
+ trackClicks: c.trackClicks ?? DEFAULTS.trackClicks,
518
+ storeTrackingIp: c.storeTrackingIp ?? DEFAULTS.storeTrackingIp,
519
+ storeRenderedBody: c.storeRenderedBody ?? DEFAULTS.storeRenderedBody,
520
+ circuitBreaker: { ...CIRCUIT_BREAKER_DEFAULTS, ...c.circuitBreaker ?? {} }
521
+ };
522
+ }
523
+
524
+ // src/server/models/index.ts
525
+ function getCollections(db, prefix = "mailer_") {
526
+ return {
527
+ subscriptions: db.collection(`${prefix}subscriptions`),
528
+ leads: db.collection(`${prefix}leads`),
529
+ events: db.collection(`${prefix}events`),
530
+ flows: db.collection(`${prefix}flows`),
531
+ flowVersions: db.collection(`${prefix}flow_versions`),
532
+ flowRuns: db.collection(`${prefix}flow_runs`),
533
+ templates: db.collection(`${prefix}templates`),
534
+ templateVersions: db.collection(`${prefix}template_versions`),
535
+ sends: db.collection(`${prefix}sends`),
536
+ suppressions: db.collection(`${prefix}suppressions`),
537
+ broadcasts: db.collection(`${prefix}broadcasts`),
538
+ outbox: db.collection(`${prefix}outbox`),
539
+ auditLog: db.collection(`${prefix}audit_log`),
540
+ webhookEvents: db.collection(`${prefix}webhook_events`),
541
+ health: db.collection(`${prefix}health`),
542
+ contactTags: db.collection(`${prefix}contact_tags`)
543
+ };
544
+ }
545
+ async function ensureIndexes(db, prefix = "mailer_") {
546
+ const c = getCollections(db, prefix);
547
+ await Promise.all([
548
+ c.subscriptions.createIndexes([
549
+ { key: { externalId: 1 }, unique: true },
550
+ { key: { status: 1, unsubscribedAt: -1 } }
551
+ ]),
552
+ c.leads.createIndexes([
553
+ { key: { email: 1 }, unique: true },
554
+ { key: { status: 1, createdAt: -1 } }
555
+ ]),
556
+ c.events.createIndexes([
557
+ { key: { dedupeKey: 1 }, unique: true },
558
+ { key: { externalId: 1, occurredAt: -1 } },
559
+ { key: { name: 1, occurredAt: -1 } },
560
+ { key: { externalId: 1, name: 1 } }
561
+ ]),
562
+ c.flows.createIndexes([
563
+ { key: { slug: 1 }, unique: true },
564
+ { key: { enabled: 1, "trigger.type": 1, "trigger.eventName": 1 } }
565
+ ]),
566
+ c.flowVersions.createIndexes([{ key: { flowId: 1, version: 1 }, unique: true }]),
567
+ c.flowRuns.createIndexes([
568
+ { key: { status: 1, nextActionAt: 1 } },
569
+ { key: { externalId: 1, flowId: 1 } },
570
+ { key: { flowId: 1, status: 1 } }
571
+ ]),
572
+ c.templates.createIndexes([
573
+ { key: { slug: 1 }, unique: true },
574
+ { key: { tags: 1 } },
575
+ { key: { kind: 1 } }
576
+ ]),
577
+ c.templateVersions.createIndexes([{ key: { templateId: 1, version: 1 }, unique: true }]),
578
+ c.sends.createIndexes([
579
+ { key: { dedupeKey: 1 }, unique: true },
580
+ { key: { externalId: 1, sentAt: -1 } },
581
+ { key: { templateId: 1, sentAt: -1 } },
582
+ { key: { flowRunId: 1 }, sparse: true },
583
+ { key: { broadcastId: 1 }, sparse: true },
584
+ { key: { providerMessageId: 1 }, sparse: true },
585
+ { key: { status: 1, queuedAt: 1 } }
586
+ ]),
587
+ c.suppressions.createIndexes([
588
+ { key: { email: 1, scope: 1 }, unique: true, partialFilterExpression: { email: { $type: "string" } } },
589
+ { key: { emailHash: 1 } },
590
+ { key: { addedAt: -1 } }
591
+ ]),
592
+ c.broadcasts.createIndexes([
593
+ { key: { slug: 1 }, unique: true },
594
+ { key: { status: 1, scheduledAt: 1 } }
595
+ ]),
596
+ c.outbox.createIndexes([
597
+ { key: { status: 1, enqueuedAt: 1 } },
598
+ { key: { "payload.dedupeKey": 1 }, unique: true }
599
+ ]),
600
+ c.auditLog.createIndexes([
601
+ { key: { occurredAt: -1 } },
602
+ { key: { "resource.collection": 1, "resource.id": 1, occurredAt: -1 } },
603
+ { key: { actor: 1, occurredAt: -1 } }
604
+ ]),
605
+ c.webhookEvents.createIndexes([
606
+ { key: { provider: 1, providerEventId: 1 }, unique: true },
607
+ { key: { providerMessageId: 1 } },
608
+ { key: { processed: 1, receivedAt: 1 } }
609
+ ]),
610
+ c.contactTags.createIndexes([
611
+ { key: { externalId: 1, tag: 1 }, unique: true },
612
+ { key: { tag: 1 } }
613
+ ])
614
+ ]);
615
+ }
616
+ var EventRegistry = class {
617
+ policies = /* @__PURE__ */ new Map();
618
+ register(reg) {
619
+ this.policies.set(reg.name, reg.dedupePolicy);
620
+ }
621
+ has(name) {
622
+ return this.policies.has(name);
623
+ }
624
+ policy(name) {
625
+ return this.policies.get(name);
626
+ }
627
+ /**
628
+ * Derive a dedupeKey for an event call. Returns null when no policy is
629
+ * registered AND no key was passed — caller should throw.
630
+ */
631
+ deriveKey(name, externalId, passedKey, now) {
632
+ if (passedKey) return passedKey;
633
+ const policy = this.policies.get(name);
634
+ if (!policy) return null;
635
+ switch (policy) {
636
+ case "once-per-contact":
637
+ return `${externalId}:${name}`;
638
+ case "once-per-day":
639
+ return `${externalId}:${name}:${isoDay(now)}`;
640
+ case "every-time":
641
+ return `${externalId}:${name}:${randomId()}`;
642
+ }
643
+ }
644
+ };
645
+ function isoDay(d) {
646
+ return d.toISOString().slice(0, 10);
647
+ }
648
+ function randomId() {
649
+ return crypto2__default.default.randomBytes(8).toString("hex");
650
+ }
651
+ function signUnsubscribeToken(payload, secret) {
652
+ const body = JSON.stringify({
653
+ e: payload.email.toLowerCase(),
654
+ s: payload.scope,
655
+ x: payload.expiresAt.getTime()
656
+ });
657
+ const bodyB64 = b64url(Buffer.from(body, "utf8"));
658
+ const hmac = crypto2__default.default.createHmac("sha256", secret).update(bodyB64).digest();
659
+ return `${bodyB64}.${b64url(hmac)}`;
660
+ }
661
+ function verifyUnsubscribeToken(token, secret, now = /* @__PURE__ */ new Date()) {
662
+ const parts = token.split(".");
663
+ if (parts.length !== 2) return null;
664
+ const [bodyB64, sigB64] = parts;
665
+ const expected = crypto2__default.default.createHmac("sha256", secret).update(bodyB64).digest();
666
+ let actual;
667
+ try {
668
+ actual = b64urlDecode(sigB64);
669
+ } catch {
670
+ return null;
671
+ }
672
+ if (expected.length !== actual.length) return null;
673
+ if (!crypto2__default.default.timingSafeEqual(expected, actual)) return null;
674
+ let body;
675
+ try {
676
+ body = JSON.parse(b64urlDecode(bodyB64).toString("utf8"));
677
+ } catch {
678
+ return null;
679
+ }
680
+ if (!body.e || !body.s || typeof body.x !== "number") return null;
681
+ if (body.x < now.getTime()) return null;
682
+ if (body.s !== "all" && body.s !== "marketing" && body.s !== "transactional") return null;
683
+ return {
684
+ email: body.e,
685
+ scope: body.s,
686
+ expiresAt: new Date(body.x)
687
+ };
688
+ }
689
+ function b64url(buf) {
690
+ return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
691
+ }
692
+ function b64urlDecode(s) {
693
+ const padded = s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4);
694
+ return Buffer.from(padded, "base64");
695
+ }
696
+ function sha256Hex(input) {
697
+ return crypto2__default.default.createHash("sha256").update(input).digest("hex");
698
+ }
699
+ function signDoiToken(payload, secret) {
700
+ const body = JSON.stringify({
701
+ i: payload.externalId,
702
+ x: payload.expiresAt.getTime(),
703
+ k: "doi"
704
+ });
705
+ const bodyB64 = b64url(Buffer.from(body, "utf8"));
706
+ const hmac = crypto2__default.default.createHmac("sha256", secret).update(bodyB64).digest();
707
+ return `${bodyB64}.${b64url(hmac)}`;
708
+ }
709
+ function verifyDoiToken(token, secret, now = /* @__PURE__ */ new Date()) {
710
+ const parts = token.split(".");
711
+ if (parts.length !== 2) return null;
712
+ const [bodyB64, sigB64] = parts;
713
+ const expected = crypto2__default.default.createHmac("sha256", secret).update(bodyB64).digest();
714
+ let actual;
715
+ try {
716
+ actual = b64urlDecode(sigB64);
717
+ } catch {
718
+ return null;
719
+ }
720
+ if (expected.length !== actual.length) return null;
721
+ if (!crypto2__default.default.timingSafeEqual(expected, actual)) return null;
722
+ let body;
723
+ try {
724
+ body = JSON.parse(b64urlDecode(bodyB64).toString("utf8"));
725
+ } catch {
726
+ return null;
727
+ }
728
+ if (!body.i || typeof body.x !== "number" || body.k !== "doi") return null;
729
+ if (body.x < now.getTime()) return null;
730
+ return { externalId: body.i, expiresAt: new Date(body.x) };
731
+ }
732
+ function adaptBullQueue(q) {
733
+ return {
734
+ add: (name, data, opts) => q.add(name, data, opts),
735
+ getWaitingCount: () => q.getWaitingCount(),
736
+ close: () => q.close()
737
+ };
738
+ }
739
+ function noopQueueAPI() {
740
+ return {
741
+ add: async () => void 0,
742
+ getWaitingCount: async () => 0,
743
+ close: async () => void 0
744
+ };
745
+ }
746
+ function noopQueues() {
747
+ return {
748
+ tick: noopQueueAPI(),
749
+ advance: noopQueueAPI(),
750
+ send: noopQueueAPI(),
751
+ webhook: noopQueueAPI()
752
+ };
753
+ }
754
+ function namespacedQueueNames(prefix) {
755
+ return {
756
+ tick: "mailer-tick",
757
+ advance: "mailer-advance",
758
+ send: "mailer-send",
759
+ webhook: "mailer-webhook"
760
+ };
761
+ }
762
+ function makeRedis(opts) {
763
+ if (isRedisLike(opts)) return opts;
764
+ const config = {
765
+ maxRetriesPerRequest: null,
766
+ // BullMQ requirement
767
+ enableReadyCheck: false
768
+ };
769
+ if (opts.url) {
770
+ return new IORedis__default.default(opts.url, config);
771
+ }
772
+ return new IORedis__default.default({
773
+ ...config,
774
+ host: opts.host ?? "127.0.0.1",
775
+ port: opts.port ?? 6379,
776
+ password: opts.password,
777
+ db: opts.db,
778
+ username: opts.username,
779
+ tls: opts.tls ? {} : void 0
780
+ });
781
+ }
782
+ function isRedisLike(x) {
783
+ return !!x && typeof x === "object" && typeof x.get === "function" && typeof x.set === "function";
784
+ }
785
+ function createQueues(redis) {
786
+ const names = namespacedQueueNames();
787
+ const qOpts = { connection: redis };
788
+ const bullQueues = {
789
+ tick: new bullmq.Queue(names.tick, qOpts),
790
+ advance: new bullmq.Queue(names.advance, qOpts),
791
+ send: new bullmq.Queue(names.send, qOpts),
792
+ webhook: new bullmq.Queue(names.webhook, qOpts)
793
+ };
794
+ return {
795
+ queues: {
796
+ tick: adaptBullQueue(bullQueues.tick),
797
+ advance: adaptBullQueue(bullQueues.advance),
798
+ send: adaptBullQueue(bullQueues.send),
799
+ webhook: adaptBullQueue(bullQueues.webhook)
800
+ },
801
+ bullQueues
802
+ };
803
+ }
804
+ async function scheduleTick(bullQueues, intervalSeconds) {
805
+ await bullQueues.tick.upsertJobScheduler(
806
+ "mailer-tick-repeat",
807
+ { every: intervalSeconds * 1e3 },
808
+ { name: "tick", data: {} }
809
+ );
810
+ }
811
+ function createWorkers(input) {
812
+ const names = namespacedQueueNames();
813
+ const base = { connection: input.redis };
814
+ const tick = new bullmq.Worker(names.tick, async (job) => input.handlers.tick(job.data), {
815
+ ...base,
816
+ concurrency: 1
817
+ // single tick driver per worker process
818
+ });
819
+ const advance = new bullmq.Worker(
820
+ names.advance,
821
+ async (job) => input.handlers.advance(job.data),
822
+ { ...base, concurrency: 10 }
823
+ );
824
+ const sendOpts = {
825
+ ...base,
826
+ concurrency: input.concurrency.send,
827
+ limiter: input.sendRateLimit ? { max: input.sendRateLimit.max, duration: input.sendRateLimit.durationMs } : void 0
828
+ };
829
+ const send = new bullmq.Worker(names.send, async (job) => input.handlers.send(job.data), sendOpts);
830
+ const webhook = new bullmq.Worker(
831
+ names.webhook,
832
+ async (job) => input.handlers.webhook(job.data),
833
+ { ...base, concurrency: 4 }
834
+ );
835
+ return { tick, advance, send, webhook };
836
+ }
837
+ async function closeQueues(queues) {
838
+ await Promise.all([queues.tick.close(), queues.advance.close(), queues.send.close(), queues.webhook.close()]);
839
+ }
840
+ async function closeBullQueues(b) {
841
+ await Promise.all([b.tick.close(), b.advance.close(), b.send.close(), b.webhook.close()]);
842
+ }
843
+ async function closeWorkers(workers) {
844
+ await Promise.all([workers.tick.close(), workers.advance.close(), workers.send.close(), workers.webhook.close()]);
845
+ }
846
+
847
+ // src/server/runner/triggers.ts
848
+ var BATCH_SIZE = 1e3;
849
+ async function processNewlyFiredEventTriggers(ctx) {
850
+ const flows = await ctx.collections.flows.find({ enabled: true, "trigger.type": "event" }).toArray();
851
+ for (const flow of flows) {
852
+ await processFlowTriggers(flow, ctx);
853
+ }
854
+ }
855
+ async function processFlowTriggers(flow, ctx) {
856
+ const eventName = flow.trigger.eventName;
857
+ if (!eventName) return;
858
+ const since = flow.lastTriggerScanAt ?? flow.createdAt;
859
+ const events = await ctx.collections.events.find({ name: eventName, occurredAt: { $gt: since } }).sort({ occurredAt: 1 }).limit(BATCH_SIZE).toArray();
860
+ if (events.length === 0) return;
861
+ for (const event of events) {
862
+ await tryEnterFlow(flow, event, ctx);
863
+ }
864
+ const newestOccurredAt = events[events.length - 1].occurredAt;
865
+ await ctx.collections.flows.updateOne(
866
+ { _id: flow._id },
867
+ { $set: { lastTriggerScanAt: newestOccurredAt, updatedAt: /* @__PURE__ */ new Date() } }
868
+ );
869
+ }
870
+ async function tryEnterFlow(flow, event, ctx) {
871
+ if (flow.trigger.once) {
872
+ const existing = await ctx.collections.flowRuns.findOne(
873
+ { externalId: event.externalId, flowId: flow._id },
874
+ { projection: { _id: 1 } }
875
+ );
876
+ if (existing) return;
877
+ }
878
+ const sub = await ctx.collections.subscriptions.findOne({ externalId: event.externalId });
879
+ if (!sub || sub.status !== "subscribed") return;
880
+ const result = await ctx.collections.flowRuns.insertOne({
881
+ externalId: event.externalId,
882
+ flowId: flow._id,
883
+ flowSlug: flow.slug,
884
+ flowVersion: flow.version,
885
+ emailAtEntry: sub.emailAtSubscribe,
886
+ enteredAt: /* @__PURE__ */ new Date(),
887
+ status: "active",
888
+ currentStepIndex: 0,
889
+ currentBranchPath: [],
890
+ nextActionAt: /* @__PURE__ */ new Date(),
891
+ attemptsForCurrentStep: 0,
892
+ history: [{ stepIndex: -1, action: "entered", at: /* @__PURE__ */ new Date(), details: { eventDedupeKey: event.dedupeKey } }],
893
+ exitedAt: null,
894
+ exitReason: null,
895
+ createdAt: /* @__PURE__ */ new Date(),
896
+ updatedAt: /* @__PURE__ */ new Date()
897
+ });
898
+ await ctx.queues.advance.add("advance", { flowRunId: String(result.insertedId) });
899
+ }
900
+
901
+ // src/server/runner/predicate.ts
902
+ var BOT_UA_RE = /Mimecast|SafeLinks|proofpoint|HeadlessChrome|Googlebot|bingbot/i;
903
+ async function evaluatePredicate(predicate, ctx) {
904
+ const p = predicate;
905
+ if ("hasTag" in p) return ctx.contact.tags.includes(p.hasTag);
906
+ if ("notHasTag" in p) return !ctx.contact.tags.includes(p.notHasTag);
907
+ if ("fieldEquals" in p) {
908
+ return ctx.contact.fields[p.fieldEquals.field] === p.fieldEquals.value;
909
+ }
910
+ if ("fieldExists" in p) {
911
+ return ctx.contact.fields[p.fieldExists] !== void 0;
912
+ }
913
+ if ("subscriptionStatus" in p) {
914
+ const sub = await ctx.collections.subscriptions.findOne({ externalId: ctx.contact.externalId });
915
+ return sub?.status === p.subscriptionStatus;
916
+ }
917
+ if ("hasFiredEvent" in p) {
918
+ return hasEvent(ctx, p.hasFiredEvent, { sinceFlowStart: p.sinceFlowStart, withinDays: p.withinDays });
919
+ }
920
+ if ("notHasFiredEvent" in p) {
921
+ return !await hasEvent(ctx, p.notHasFiredEvent, { withinDays: p.withinDays });
922
+ }
923
+ if ("hasOpened" in p) return await openOrClickCount(ctx, "opened", p.hasOpened, false) > 0;
924
+ if ("hasClicked" in p) return await openOrClickCount(ctx, "clicked", p.hasClicked, false) > 0;
925
+ if ("hasOpenedExcludingBots" in p) return await openOrClickCount(ctx, "opened", p.hasOpenedExcludingBots, true) > 0;
926
+ if ("hasClickedExcludingBots" in p) return await openOrClickCount(ctx, "clicked", p.hasClickedExcludingBots, true) > 0;
927
+ if ("openedAtLeastN" in p) return await openOrClickCount(ctx, "opened", p.openedAtLeastN, true) >= p.openedAtLeastN.count;
928
+ if ("clickedAtLeastN" in p) return await openOrClickCount(ctx, "clicked", p.clickedAtLeastN, true) >= p.clickedAtLeastN.count;
929
+ if ("all" in p) {
930
+ for (const sub of p.all) {
931
+ if (!await evaluatePredicate(sub, ctx)) return false;
932
+ }
933
+ return true;
934
+ }
935
+ if ("any" in p) {
936
+ for (const sub of p.any) {
937
+ if (await evaluatePredicate(sub, ctx)) return true;
938
+ }
939
+ return false;
940
+ }
941
+ if ("not" in p) {
942
+ return !await evaluatePredicate(p.not, ctx);
943
+ }
944
+ return false;
945
+ }
946
+ async function hasEvent(ctx, name, opts) {
947
+ const filter = { externalId: ctx.contact.externalId, name };
948
+ const lower = effectiveLowerBound(ctx, opts);
949
+ if (lower) filter.occurredAt = { $gt: lower };
950
+ const found = await ctx.collections.events.findOne(filter, { projection: { _id: 1 } });
951
+ return !!found;
952
+ }
953
+ async function openOrClickCount(ctx, kind, opts, excludeBots) {
954
+ const filter = { externalId: ctx.contact.externalId };
955
+ if (opts.templateSlug) filter.templateSlug = opts.templateSlug;
956
+ if (kind === "opened") filter.openedAt = { $ne: null };
957
+ if (kind === "clicked") filter.firstClickAt = { $ne: null };
958
+ const lower = effectiveLowerBound(ctx, opts);
959
+ if (lower) filter[kind === "opened" ? "openedAt" : "firstClickAt"] = { $gt: lower };
960
+ if (!excludeBots) {
961
+ return await ctx.collections.sends.countDocuments(filter);
962
+ }
963
+ const docs = await ctx.collections.sends.find(filter).limit(1e3).toArray();
964
+ let n = 0;
965
+ for (const s of docs) {
966
+ if (kind === "opened") {
967
+ n++;
968
+ continue;
969
+ }
970
+ const hasHumanClick = s.clickedLinks?.some((c) => !c.userAgent || !BOT_UA_RE.test(c.userAgent));
971
+ if (hasHumanClick) n++;
972
+ }
973
+ return n;
974
+ }
975
+ function effectiveLowerBound(ctx, opts) {
976
+ const now = ctx.now ?? /* @__PURE__ */ new Date();
977
+ if (opts.sinceFlowStart) return ctx.run.enteredAt;
978
+ if (opts.withinDays && opts.withinDays > 0) {
979
+ return new Date(now.getTime() - opts.withinDays * 24 * 60 * 60 * 1e3);
980
+ }
981
+ return null;
982
+ }
983
+ async function compileTemplate(mjml) {
984
+ const out = await mjml2html__default.default(mjml, { validationLevel: "soft", minify: false });
985
+ const plainText = derivePlaintext(out.html);
986
+ return { html: out.html, plainText, errors: out.errors ?? [] };
987
+ }
988
+ async function compileMailyTemplate(content) {
989
+ const { render } = await import('@maily-to/render');
990
+ const html = await render(content);
991
+ const plainText = derivePlaintext(html);
992
+ return { html, plainText, errors: [] };
993
+ }
994
+ function derivePlaintext(html) {
995
+ return htmlToText.convert(html, {
996
+ wordwrap: 80,
997
+ selectors: [
998
+ { selector: "img", format: "skip" },
999
+ { selector: "a", options: { hideLinkHrefIfSameAsText: true } }
1000
+ ]
1001
+ }).trim();
1002
+ }
1003
+ async function renderTemplate(template, ctx, opts = {}) {
1004
+ const hb = makeHandlebars(opts.helpers);
1005
+ const subject = hb.compile(template.subject)(ctx);
1006
+ const preheader = hb.compile(template.preheader)(ctx);
1007
+ let html;
1008
+ if (template.body.html) {
1009
+ html = hb.compile(template.body.html)(ctx);
1010
+ } else {
1011
+ const compiled = await compileTemplate(template.body.mjml);
1012
+ html = hb.compile(compiled.html)(ctx);
1013
+ }
1014
+ const plainText = template.body.plainText ? hb.compile(template.body.plainText)(ctx) : derivePlaintext(html);
1015
+ return {
1016
+ subject,
1017
+ preheader,
1018
+ html,
1019
+ plainText,
1020
+ fromName: template.fromName,
1021
+ fromEmail: template.fromEmail,
1022
+ replyTo: template.replyTo
1023
+ };
1024
+ }
1025
+ function applyTracking(html, opts) {
1026
+ const preserve = new Set((opts.preserveUrls ?? []).map((u) => u.trim()));
1027
+ const links = [];
1028
+ const seen = /* @__PURE__ */ new Map();
1029
+ let out = html;
1030
+ if (opts.trackClicks) {
1031
+ out = out.replace(/<a\b([^>]*?)\bhref=(["'])([^"']+)\2([^>]*)>/gi, (full, pre, _q, url, post) => {
1032
+ if (shouldSkipClickRewrite(url, preserve, full)) return full;
1033
+ let linkId = seen.get(url);
1034
+ if (!linkId) {
1035
+ linkId = shortHash(`${opts.sendId}:${url}:${links.length}`);
1036
+ seen.set(url, linkId);
1037
+ links.push({ linkId, url });
1038
+ }
1039
+ const newHref = `${opts.publicUrl}/m/click/${opts.sendId}/${linkId}`;
1040
+ return `<a${pre}href="${newHref}"${post}>`;
1041
+ });
1042
+ }
1043
+ if (opts.trackOpens) {
1044
+ const pixel = `<img src="${opts.publicUrl}/m/open/${opts.sendId}.png" width="1" height="1" alt="" style="display:block;border:0" />`;
1045
+ if (/<\/body>/i.test(out)) {
1046
+ out = out.replace(/<\/body>/i, `${pixel}</body>`);
1047
+ } else {
1048
+ out = out + pixel;
1049
+ }
1050
+ }
1051
+ return { html: out, links };
1052
+ }
1053
+ function shouldSkipClickRewrite(url, preserve, fullTag) {
1054
+ if (!url) return true;
1055
+ if (url.startsWith("mailto:")) return true;
1056
+ if (url.startsWith("tel:")) return true;
1057
+ if (url.startsWith("#")) return true;
1058
+ if (url.startsWith("{{") || url.startsWith("{")) return true;
1059
+ if (preserve.has(url.trim())) return true;
1060
+ if (/data-mailer-notrack=["']true["']/.test(fullTag)) return true;
1061
+ return false;
1062
+ }
1063
+ function shortHash(input) {
1064
+ return crypto2__default.default.createHash("sha256").update(input).digest("hex").slice(0, 12);
1065
+ }
1066
+ function makeHandlebars(extra) {
1067
+ const hb = Handlebars__default.default.create();
1068
+ hb.registerHelper("eq", (a, b) => a === b);
1069
+ hb.registerHelper("ne", (a, b) => a !== b);
1070
+ hb.registerHelper("gt", (a, b) => a > b);
1071
+ hb.registerHelper("lt", (a, b) => a < b);
1072
+ hb.registerHelper("gte", (a, b) => a >= b);
1073
+ hb.registerHelper("lte", (a, b) => a <= b);
1074
+ hb.registerHelper("and", (...args) => args.slice(0, -1).every(Boolean));
1075
+ hb.registerHelper("or", (...args) => args.slice(0, -1).some(Boolean));
1076
+ hb.registerHelper("not", (a) => !a);
1077
+ hb.registerHelper("formatDate", (value, fmt) => {
1078
+ if (!value) return "";
1079
+ const d = value instanceof Date ? value : new Date(String(value));
1080
+ if (Number.isNaN(d.getTime())) return "";
1081
+ if (fmt === "long") return d.toLocaleDateString("en-US", { dateStyle: "long" });
1082
+ if (fmt === "short") return d.toLocaleDateString("en-US", { dateStyle: "short" });
1083
+ return d.toISOString().slice(0, 10);
1084
+ });
1085
+ hb.registerHelper("formatNumber", (n) => {
1086
+ const v = Number(n);
1087
+ return Number.isFinite(v) ? v.toLocaleString("en-US") : "";
1088
+ });
1089
+ hb.registerHelper("formatCurrency", (cents, currency = "usd") => {
1090
+ const v = Number(cents);
1091
+ if (!Number.isFinite(v)) return "";
1092
+ return new Intl.NumberFormat("en-US", { style: "currency", currency }).format(v / 100);
1093
+ });
1094
+ hb.registerHelper(
1095
+ "pluralize",
1096
+ (n, one, many) => Number(n) === 1 ? one : many
1097
+ );
1098
+ if (extra) {
1099
+ for (const [name, fn] of Object.entries(extra)) {
1100
+ hb.registerHelper(name, fn);
1101
+ }
1102
+ }
1103
+ return hb;
1104
+ }
1105
+
1106
+ // src/server/runner/suppression.ts
1107
+ var SCOPES_BY_KIND = {
1108
+ marketing: ["all", "marketing"],
1109
+ transactional: ["all", "transactional"]
1110
+ };
1111
+ async function isSuppressed(collections, email, kind) {
1112
+ const normalized = email.toLowerCase();
1113
+ const allowed = SCOPES_BY_KIND[kind];
1114
+ const byEmail = await collections.suppressions.findOne({
1115
+ email: normalized,
1116
+ scope: { $in: allowed },
1117
+ $or: [{ expiresAt: null }, { expiresAt: { $gt: /* @__PURE__ */ new Date() } }]
1118
+ });
1119
+ if (byEmail) return { suppressed: true, scope: byEmail.scope, reason: byEmail.reason };
1120
+ const hashed = await collections.suppressions.findOne({
1121
+ emailHash: sha256Hex(normalized),
1122
+ scope: { $in: allowed }
1123
+ });
1124
+ if (hashed) return { suppressed: true, scope: hashed.scope, reason: hashed.reason };
1125
+ return { suppressed: false };
1126
+ }
1127
+
1128
+ // src/server/runner/health.ts
1129
+ async function recordHealthCounter(ctx, counter2, by = 1) {
1130
+ await ctx.collections.health.updateOne(
1131
+ { _id: "singleton" },
1132
+ {
1133
+ $inc: { [`counters.${counter2}`]: by },
1134
+ $setOnInsert: {
1135
+ _id: "singleton",
1136
+ windowStartedAt: /* @__PURE__ */ new Date(),
1137
+ windowDurationMs: ctx.config.circuitBreaker.windowMinutes * 60 * 1e3,
1138
+ status: "healthy",
1139
+ trippedAt: null,
1140
+ trippedReason: null,
1141
+ manuallyResumedAt: null,
1142
+ rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
1143
+ },
1144
+ $set: { updatedAt: /* @__PURE__ */ new Date() }
1145
+ },
1146
+ { upsert: true }
1147
+ );
1148
+ }
1149
+ async function evaluateHealth(ctx) {
1150
+ const cb = ctx.config.circuitBreaker;
1151
+ const windowMs = cb.windowMinutes * 60 * 1e3;
1152
+ const doc = await ctx.collections.health.findOne({ _id: "singleton" });
1153
+ if (!doc) return;
1154
+ const windowAge = Date.now() - new Date(doc.windowStartedAt).getTime();
1155
+ if (windowAge > windowMs && doc.status !== "tripped") {
1156
+ await ctx.collections.health.updateOne(
1157
+ { _id: "singleton" },
1158
+ {
1159
+ $set: {
1160
+ windowStartedAt: /* @__PURE__ */ new Date(),
1161
+ windowDurationMs: windowMs,
1162
+ counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
1163
+ rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 },
1164
+ updatedAt: /* @__PURE__ */ new Date()
1165
+ }
1166
+ }
1167
+ );
1168
+ return;
1169
+ }
1170
+ const c = doc.counters;
1171
+ const total = c.sent || 1;
1172
+ const rates = {
1173
+ bounceRate: c.bounced / total,
1174
+ hardBounceRate: c.hardBounced / total,
1175
+ complaintRate: c.complained / total,
1176
+ failureRate: c.failedToSend / total
1177
+ };
1178
+ await ctx.collections.health.updateOne(
1179
+ { _id: "singleton" },
1180
+ { $set: { rates, updatedAt: /* @__PURE__ */ new Date() } }
1181
+ );
1182
+ if (c.sent < cb.minSendsBeforeEval) return;
1183
+ if (doc.status === "tripped") return;
1184
+ let trippedReason = null;
1185
+ if (rates.hardBounceRate * 100 >= cb.hardBounceRatePctTrip) {
1186
+ trippedReason = `hard bounce rate ${(rates.hardBounceRate * 100).toFixed(2)}% >= ${cb.hardBounceRatePctTrip}%`;
1187
+ } else if (rates.complaintRate * 100 >= cb.complaintRatePctTrip) {
1188
+ trippedReason = `complaint rate ${(rates.complaintRate * 100).toFixed(2)}% >= ${cb.complaintRatePctTrip}%`;
1189
+ } else if (rates.bounceRate * 100 >= cb.combinedBounceRatePctTrip) {
1190
+ trippedReason = `combined bounce rate ${(rates.bounceRate * 100).toFixed(2)}% >= ${cb.combinedBounceRatePctTrip}%`;
1191
+ }
1192
+ if (trippedReason) {
1193
+ await ctx.collections.health.updateOne(
1194
+ { _id: "singleton" },
1195
+ { $set: { status: "tripped", trippedAt: /* @__PURE__ */ new Date(), trippedReason, updatedAt: /* @__PURE__ */ new Date() } }
1196
+ );
1197
+ if (ctx.config.onCircuitBreakerTrip) {
1198
+ try {
1199
+ await ctx.config.onCircuitBreakerTrip({ reason: trippedReason, rates });
1200
+ } catch {
1201
+ }
1202
+ }
1203
+ return;
1204
+ }
1205
+ if (rates.failureRate * 100 >= cb.failedToSendRatePctDegrade) {
1206
+ if (doc.status !== "degraded") {
1207
+ await ctx.collections.health.updateOne(
1208
+ { _id: "singleton" },
1209
+ { $set: { status: "degraded", updatedAt: /* @__PURE__ */ new Date() } }
1210
+ );
1211
+ }
1212
+ } else if (doc.status === "degraded") {
1213
+ await ctx.collections.health.updateOne(
1214
+ { _id: "singleton" },
1215
+ { $set: { status: "healthy", updatedAt: /* @__PURE__ */ new Date() } }
1216
+ );
1217
+ }
1218
+ }
1219
+
1220
+ // src/server/runner/send.ts
1221
+ async function handleSend(run, step, contact, flow, ctx) {
1222
+ const template = await ctx.collections.templates.findOne({ slug: step.templateSlug });
1223
+ if (!template) {
1224
+ await failFlowRun(run, `template not found: ${step.templateSlug}`, ctx);
1225
+ return;
1226
+ }
1227
+ const dedupeKey = `flowrun:${run._id}:step${run.currentStepIndex}`;
1228
+ const existing = await ctx.collections.sends.findOne({ dedupeKey });
1229
+ if (existing) {
1230
+ await advanceStep(run, ctx, {
1231
+ action: "sent",
1232
+ details: { dedupeKey, alreadySent: true, sendId: String(existing._id) }
1233
+ });
1234
+ return;
1235
+ }
1236
+ const providerName = pickProviderName(step.providerOverride, template, ctx);
1237
+ const renderCtx = buildRenderContext(contact, run, step.vars ?? {}, ctx);
1238
+ let rendered;
1239
+ try {
1240
+ rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
1241
+ } catch (err) {
1242
+ await ctx.collections.sends.insertOne(
1243
+ newSendDoc({
1244
+ dedupeKey,
1245
+ run,
1246
+ contact,
1247
+ template,
1248
+ providerName,
1249
+ renderedSubject: template.subject,
1250
+ bodyHash: "",
1251
+ status: "failed",
1252
+ errorMessage: `render error: ${err?.message ?? err}`
1253
+ })
1254
+ );
1255
+ await advanceStep(run, ctx, {
1256
+ action: "send_skipped",
1257
+ details: { reason: "render_error", message: String(err?.message ?? err) }
1258
+ });
1259
+ return;
1260
+ }
1261
+ const sendId = new mongodb.ObjectId();
1262
+ await ctx.collections.sends.insertOne(
1263
+ newSendDoc({
1264
+ _id: sendId,
1265
+ dedupeKey,
1266
+ run,
1267
+ contact,
1268
+ template,
1269
+ providerName,
1270
+ renderedSubject: rendered.subject,
1271
+ bodyHash: sha256(rendered.html),
1272
+ status: "queued",
1273
+ renderedFrom: rendered.fromName ? { name: rendered.fromName, email: rendered.fromEmail } : void 0,
1274
+ vars: step.vars ?? {}
1275
+ })
1276
+ );
1277
+ await ctx.queues.send.add(
1278
+ "send",
1279
+ { sendId: String(sendId) },
1280
+ { attempts: ctx.config.sendRetryAttempts, backoff: { type: "exponential", delay: 6e4 } }
1281
+ );
1282
+ await advanceStep(run, ctx, { action: "sent", details: { dedupeKey, sendId: String(sendId) } });
1283
+ }
1284
+ async function dispatchSend(sendId, ctx) {
1285
+ const send = await ctx.collections.sends.findOne({ _id: sendId });
1286
+ if (!send) return;
1287
+ if (send.status !== "queued" && send.status !== "failed") return;
1288
+ const template = await ctx.collections.templates.findOne({ _id: send.templateId });
1289
+ if (!template) {
1290
+ await markFailed(send._id, "template_missing", ctx);
1291
+ return;
1292
+ }
1293
+ const supp = await isSuppressed(ctx.collections, send.emailAtSend, send.kind);
1294
+ if (supp.suppressed) {
1295
+ await ctx.collections.sends.updateOne(
1296
+ { _id: send._id },
1297
+ { $set: { status: "suppressed", errorMessage: `suppressed: ${supp.reason}` } }
1298
+ );
1299
+ return;
1300
+ }
1301
+ if (send.kind === "marketing") {
1302
+ const health = await ctx.collections.health.findOne({ _id: "singleton" });
1303
+ if (health?.status === "tripped") {
1304
+ await ctx.queues.send.add("send", { sendId: String(send._id) }, { delay: 6e4 });
1305
+ return;
1306
+ }
1307
+ }
1308
+ const contact = await ctx.adapter.getById(send.externalId);
1309
+ if (!contact) {
1310
+ await markFailed(send._id, "contact_missing", ctx);
1311
+ return;
1312
+ }
1313
+ const run = send.flowRunId ? await ctx.collections.flowRuns.findOne({ _id: send.flowRunId }) : null;
1314
+ const renderCtx = buildRenderContext(
1315
+ contact,
1316
+ run,
1317
+ send.vars ?? {},
1318
+ ctx
1319
+ );
1320
+ const rendered = await renderTemplate(template, renderCtx, { helpers: ctx.handlebarsHelpers });
1321
+ const tracking = applyTracking(rendered.html, {
1322
+ sendId: String(send._id),
1323
+ publicUrl: ctx.config.publicUrl,
1324
+ trackOpens: template.trackOpens ?? ctx.config.trackOpens,
1325
+ trackClicks: template.trackClicks ?? ctx.config.trackClicks,
1326
+ preserveUrls: [renderCtx.unsubscribeUrl]
1327
+ });
1328
+ await ctx.collections.sends.updateOne(
1329
+ { _id: send._id },
1330
+ {
1331
+ $set: {
1332
+ links: tracking.links,
1333
+ bodyHash: sha256(tracking.html),
1334
+ status: "sending",
1335
+ fromName: rendered.fromName,
1336
+ fromEmail: rendered.fromEmail,
1337
+ subject: rendered.subject
1338
+ }
1339
+ }
1340
+ );
1341
+ const provider = ctx.providers[send.provider] ?? ctx.providers[ctx.config.defaultProvider];
1342
+ if (!provider) {
1343
+ await markFailed(send._id, `provider_unknown: ${send.provider}`, ctx);
1344
+ return;
1345
+ }
1346
+ try {
1347
+ const result = await provider.send({
1348
+ to: send.emailAtSend,
1349
+ fromName: rendered.fromName,
1350
+ fromEmail: rendered.fromEmail,
1351
+ replyTo: rendered.replyTo ?? void 0,
1352
+ subject: rendered.subject,
1353
+ html: tracking.html,
1354
+ text: rendered.plainText,
1355
+ headers: {
1356
+ "List-Unsubscribe": `<${renderCtx.unsubscribeUrl}>`,
1357
+ "List-Unsubscribe-Post": "List-Unsubscribe=One-Click"
1358
+ },
1359
+ messageMeta: { sendId: String(send._id) }
1360
+ });
1361
+ await ctx.collections.sends.updateOne(
1362
+ { _id: send._id },
1363
+ {
1364
+ $set: {
1365
+ status: "sent",
1366
+ sentAt: /* @__PURE__ */ new Date(),
1367
+ providerMessageId: result.providerId
1368
+ }
1369
+ }
1370
+ );
1371
+ await recordHealthCounter(ctx, "sent");
1372
+ } catch (err) {
1373
+ await ctx.collections.sends.updateOne(
1374
+ { _id: send._id },
1375
+ { $set: { status: "failed", errorMessage: String(err?.message ?? err) } }
1376
+ );
1377
+ await recordHealthCounter(ctx, "failedToSend");
1378
+ if (ctx.config.onSendFailure) {
1379
+ try {
1380
+ await ctx.config.onSendFailure({ send, error: err });
1381
+ } catch {
1382
+ }
1383
+ }
1384
+ throw err;
1385
+ }
1386
+ }
1387
+ function pickProviderName(stepOverride, tpl, ctx) {
1388
+ if (stepOverride) return stepOverride;
1389
+ if (tpl.providerOverride) return tpl.providerOverride;
1390
+ if (tpl.kind === "transactional" && ctx.config.defaultTransactionalProvider) {
1391
+ return ctx.config.defaultTransactionalProvider;
1392
+ }
1393
+ return ctx.config.defaultProvider;
1394
+ }
1395
+ function buildRenderContext(contact, run, vars, ctx) {
1396
+ const scope = "marketing";
1397
+ const expiresAt = new Date(Date.now() + ctx.config.unsubscribeTokenLifetimeDays * 24 * 60 * 60 * 1e3);
1398
+ const token = signUnsubscribeToken(
1399
+ { email: contact.email, scope, expiresAt },
1400
+ ctx.config.unsubscribeSecret
1401
+ );
1402
+ const unsubscribeUrl = `${ctx.config.publicUrl}/m/unsub/${token}`;
1403
+ return {
1404
+ contact,
1405
+ vars,
1406
+ unsubscribeUrl,
1407
+ senderAddress: ctx.config.senderAddress
1408
+ };
1409
+ }
1410
+ function newSendDoc(input) {
1411
+ return {
1412
+ _id: input._id,
1413
+ dedupeKey: input.dedupeKey,
1414
+ externalId: input.run.externalId,
1415
+ emailAtSend: input.contact.email,
1416
+ templateId: input.template._id,
1417
+ templateSlug: input.template.slug,
1418
+ flowRunId: input.run._id,
1419
+ broadcastId: null,
1420
+ manualSendBy: null,
1421
+ kind: input.template.kind,
1422
+ provider: input.providerName,
1423
+ providerMessageId: null,
1424
+ fromName: input.renderedFrom?.name ?? input.template.fromName,
1425
+ fromEmail: input.renderedFrom?.email ?? input.template.fromEmail,
1426
+ subject: input.renderedSubject,
1427
+ bodyHash: input.bodyHash,
1428
+ status: input.status,
1429
+ errorMessage: input.errorMessage ?? null,
1430
+ bounceType: null,
1431
+ bounceReason: null,
1432
+ links: [],
1433
+ vars: input.vars ?? {},
1434
+ openedAt: null,
1435
+ openCount: 0,
1436
+ firstClickAt: null,
1437
+ clickCount: 0,
1438
+ clickedLinks: [],
1439
+ unsubscribedAt: null,
1440
+ complainedAt: null,
1441
+ queuedAt: /* @__PURE__ */ new Date(),
1442
+ sentAt: null,
1443
+ deliveredAt: null
1444
+ };
1445
+ }
1446
+ async function markFailed(sendId, reason, ctx) {
1447
+ await ctx.collections.sends.updateOne(
1448
+ { _id: sendId },
1449
+ { $set: { status: "failed", errorMessage: reason } }
1450
+ );
1451
+ }
1452
+ function sha256(s) {
1453
+ return crypto2__default.default.createHash("sha256").update(s).digest("hex");
1454
+ }
1455
+
1456
+ // src/server/runner/step.ts
1457
+ async function processOneRunStep(runId, ctx) {
1458
+ const run = await ctx.collections.flowRuns.findOne({ _id: runId });
1459
+ if (!run || run.status !== "active") return;
1460
+ const flow = await ctx.collections.flows.findOne({ _id: run.flowId });
1461
+ if (!flow) {
1462
+ await failFlowRun(run, "flow_missing", ctx);
1463
+ return;
1464
+ }
1465
+ const steps = await loadStepsForRun(run, flow, ctx);
1466
+ const step = locateStep(steps, run.currentStepIndex, run.currentBranchPath);
1467
+ if (!step) {
1468
+ await completeFlowRun(run, "completed", ctx);
1469
+ return;
1470
+ }
1471
+ const contact = await ctx.adapter.getById(run.externalId);
1472
+ if (!contact) {
1473
+ await exitFlowRun(run, "contact_missing", ctx);
1474
+ return;
1475
+ }
1476
+ const sub = await ctx.collections.subscriptions.findOne({ externalId: run.externalId });
1477
+ if (sub && sub.status !== "subscribed") {
1478
+ await exitFlowRun(run, sub.status, ctx);
1479
+ return;
1480
+ }
1481
+ switch (step.type) {
1482
+ case "wait":
1483
+ return handleWait(run, step, ctx);
1484
+ case "condition":
1485
+ return handleCondition(run, step, contact, ctx);
1486
+ case "branch":
1487
+ return handleBranch(run, step, contact, ctx);
1488
+ case "send":
1489
+ return handleSend(run, step, contact, flow, ctx);
1490
+ case "tag":
1491
+ return handleTag(run, step, ctx);
1492
+ case "fire_event":
1493
+ return handleFireEvent(run, step, ctx);
1494
+ case "webhook":
1495
+ return handleWebhookStep(run, step, ctx);
1496
+ case "exit":
1497
+ return exitFlowRun(run, step.reason ?? "exit_step", ctx);
1498
+ }
1499
+ }
1500
+ async function handleWait(run, step, ctx) {
1501
+ const ms = unitToMs(step.value, step.unit);
1502
+ const nextAt = new Date(Date.now() + ms);
1503
+ const updated = await ctx.collections.flowRuns.findOneAndUpdate(
1504
+ { _id: run._id, currentStepIndex: run.currentStepIndex },
1505
+ {
1506
+ $set: { nextActionAt: nextAt, attemptsForCurrentStep: 0, updatedAt: /* @__PURE__ */ new Date() },
1507
+ $inc: { currentStepIndex: 1 },
1508
+ $push: {
1509
+ history: {
1510
+ stepIndex: run.currentStepIndex,
1511
+ action: "wait_started",
1512
+ at: /* @__PURE__ */ new Date(),
1513
+ details: { until: nextAt }
1514
+ }
1515
+ }
1516
+ },
1517
+ { returnDocument: "after" }
1518
+ );
1519
+ if (!updated) return;
1520
+ await ctx.queues.advance.add(
1521
+ "advance",
1522
+ { flowRunId: String(run._id) },
1523
+ { delay: ms, jobId: `advance:${run._id}:${run.currentStepIndex + 1}` }
1524
+ );
1525
+ }
1526
+ async function handleCondition(run, step, contact, ctx) {
1527
+ if (!contact) return;
1528
+ const result = await evaluatePredicate(step.test, { contact, run, collections: ctx.collections });
1529
+ if (result) {
1530
+ await advanceStep(run, ctx, { action: "condition_evaluated", details: { result: true } });
1531
+ return;
1532
+ }
1533
+ if (step.ifFalse === "continue") {
1534
+ await advanceStep(run, ctx, { action: "condition_evaluated", details: { result: false, skipped: 1 } }, { stepInc: 2 });
1535
+ return;
1536
+ }
1537
+ await exitFlowRun(run, "condition_false", ctx);
1538
+ }
1539
+ async function handleBranch(run, step, contact, ctx) {
1540
+ if (!contact) return;
1541
+ const result = await evaluatePredicate(step.test, { contact, run, collections: ctx.collections });
1542
+ const newPath = [...run.currentBranchPath, run.currentStepIndex, result ? "true" : "false", 0];
1543
+ const updated = await ctx.collections.flowRuns.findOneAndUpdate(
1544
+ { _id: run._id, currentStepIndex: run.currentStepIndex },
1545
+ {
1546
+ $set: {
1547
+ currentBranchPath: newPath,
1548
+ currentStepIndex: 0,
1549
+ nextActionAt: /* @__PURE__ */ new Date(),
1550
+ attemptsForCurrentStep: 0,
1551
+ updatedAt: /* @__PURE__ */ new Date()
1552
+ },
1553
+ $push: {
1554
+ history: {
1555
+ stepIndex: run.currentStepIndex,
1556
+ action: "branch_taken",
1557
+ at: /* @__PURE__ */ new Date(),
1558
+ details: { result }
1559
+ }
1560
+ }
1561
+ },
1562
+ { returnDocument: "after" }
1563
+ );
1564
+ if (!updated) return;
1565
+ await ctx.queues.advance.add("advance", { flowRunId: String(run._id) });
1566
+ }
1567
+ async function handleTag(run, step, ctx) {
1568
+ const adds = step.addTags ?? [];
1569
+ const removes = step.removeTags ?? [];
1570
+ if (ctx.adapter.addTags && adds.length > 0) {
1571
+ await ctx.adapter.addTags(run.externalId, adds);
1572
+ } else if (adds.length > 0) {
1573
+ await ctx.collections.contactTags.bulkWrite(
1574
+ adds.map((tag) => ({
1575
+ updateOne: {
1576
+ filter: { externalId: run.externalId, tag },
1577
+ update: { $setOnInsert: { externalId: run.externalId, tag, appliedBy: "flow", appliedAt: /* @__PURE__ */ new Date() } },
1578
+ upsert: true
1579
+ }
1580
+ }))
1581
+ );
1582
+ }
1583
+ if (ctx.adapter.removeTags && removes.length > 0) {
1584
+ await ctx.adapter.removeTags(run.externalId, removes);
1585
+ } else if (removes.length > 0) {
1586
+ await ctx.collections.contactTags.deleteMany({ externalId: run.externalId, tag: { $in: removes } });
1587
+ }
1588
+ await advanceStep(run, ctx, { action: "tagged", details: { addTags: adds, removeTags: removes } });
1589
+ }
1590
+ async function handleFireEvent(run, step, ctx) {
1591
+ const dedupeKey = `flowrun:${run._id}:step${run.currentStepIndex}:${step.eventName}`;
1592
+ try {
1593
+ await ctx.collections.events.insertOne({
1594
+ externalId: run.externalId,
1595
+ name: step.eventName,
1596
+ properties: step.properties ?? {},
1597
+ dedupeKey,
1598
+ occurredAt: /* @__PURE__ */ new Date(),
1599
+ createdAt: /* @__PURE__ */ new Date()
1600
+ });
1601
+ } catch (err) {
1602
+ if (err?.code !== 11e3) throw err;
1603
+ }
1604
+ await advanceStep(run, ctx, { action: "event_fired", details: { name: step.eventName } });
1605
+ }
1606
+ async function handleWebhookStep(run, step, ctx) {
1607
+ try {
1608
+ const res = await fetch(step.url, {
1609
+ method: step.method ?? "POST",
1610
+ headers: { "Content-Type": "application/json" },
1611
+ body: JSON.stringify(step.payload ?? { externalId: run.externalId, flowRunId: String(run._id) }),
1612
+ signal: AbortSignal.timeout(1e4)
1613
+ });
1614
+ if (!res.ok) throw new Error(`webhook step returned ${res.status}`);
1615
+ await advanceStep(run, ctx, { action: "webhook_called", details: { url: step.url, status: res.status } });
1616
+ } catch (err) {
1617
+ const attempts = (run.attemptsForCurrentStep ?? 0) + 1;
1618
+ if (attempts >= ctx.config.webhookRetryAttempts) {
1619
+ if (step.failureMode === "fail_run") {
1620
+ await failFlowRun(run, `webhook failed: ${err?.message}`, ctx);
1621
+ } else {
1622
+ await advanceStep(run, ctx, {
1623
+ action: "webhook_called",
1624
+ details: { url: step.url, error: err?.message, exhausted: true }
1625
+ });
1626
+ }
1627
+ } else {
1628
+ await ctx.collections.flowRuns.updateOne(
1629
+ { _id: run._id, currentStepIndex: run.currentStepIndex },
1630
+ { $inc: { attemptsForCurrentStep: 1 }, $set: { nextActionAt: new Date(Date.now() + 6e4) } }
1631
+ );
1632
+ await ctx.queues.advance.add(
1633
+ "advance",
1634
+ { flowRunId: String(run._id) },
1635
+ { delay: 6e4, jobId: `advance:${run._id}:${run.currentStepIndex}:retry-${attempts}` }
1636
+ );
1637
+ }
1638
+ }
1639
+ }
1640
+ async function advanceStep(run, ctx, log, opts = {}) {
1641
+ const stepInc = opts.stepInc ?? 1;
1642
+ const updated = await ctx.collections.flowRuns.findOneAndUpdate(
1643
+ { _id: run._id, currentStepIndex: run.currentStepIndex },
1644
+ {
1645
+ $set: { nextActionAt: /* @__PURE__ */ new Date(), attemptsForCurrentStep: 0, updatedAt: /* @__PURE__ */ new Date() },
1646
+ $inc: { currentStepIndex: stepInc },
1647
+ $push: {
1648
+ history: { stepIndex: run.currentStepIndex, action: log.action, at: /* @__PURE__ */ new Date(), details: log.details }
1649
+ }
1650
+ },
1651
+ { returnDocument: "after" }
1652
+ );
1653
+ if (!updated) return;
1654
+ await ctx.queues.advance.add("advance", { flowRunId: String(run._id) });
1655
+ }
1656
+ async function exitFlowRun(run, reason, ctx) {
1657
+ await ctx.collections.flowRuns.updateOne(
1658
+ { _id: run._id, status: "active" },
1659
+ {
1660
+ $set: { status: "exited", exitedAt: /* @__PURE__ */ new Date(), exitReason: reason, updatedAt: /* @__PURE__ */ new Date() },
1661
+ $push: { history: { stepIndex: run.currentStepIndex, action: "exited", at: /* @__PURE__ */ new Date(), details: { reason } } }
1662
+ }
1663
+ );
1664
+ }
1665
+ async function completeFlowRun(run, reason, ctx) {
1666
+ await ctx.collections.flowRuns.updateOne(
1667
+ { _id: run._id, status: "active" },
1668
+ {
1669
+ $set: { status: "completed", exitedAt: /* @__PURE__ */ new Date(), exitReason: reason, updatedAt: /* @__PURE__ */ new Date() }
1670
+ }
1671
+ );
1672
+ }
1673
+ async function failFlowRun(run, reason, ctx) {
1674
+ await ctx.collections.flowRuns.updateOne(
1675
+ { _id: run._id, status: "active" },
1676
+ {
1677
+ $set: { status: "failed", exitedAt: /* @__PURE__ */ new Date(), exitReason: reason, updatedAt: /* @__PURE__ */ new Date() },
1678
+ $push: { history: { stepIndex: run.currentStepIndex, action: "failed", at: /* @__PURE__ */ new Date(), details: { reason } } }
1679
+ }
1680
+ );
1681
+ }
1682
+ async function loadStepsForRun(run, flow, ctx) {
1683
+ if (run.flowVersion === flow.version) return flow.steps;
1684
+ const snapshot = await ctx.collections.flowVersions.findOne({ flowId: run.flowId, version: run.flowVersion });
1685
+ return snapshot?.steps ?? flow.steps;
1686
+ }
1687
+ function locateStep(steps, currentStepIndex, branchPath) {
1688
+ let arr = steps;
1689
+ for (let i = 0; i < branchPath.length; i += 3) {
1690
+ const parentIndex = branchPath[i];
1691
+ const branchKey = branchPath[i + 1];
1692
+ const parent = arr[parentIndex];
1693
+ if (!parent || parent.type !== "branch") return null;
1694
+ arr = branchKey === "true" ? parent.ifTrueSteps : parent.ifFalseSteps;
1695
+ }
1696
+ return arr[currentStepIndex] ?? null;
1697
+ }
1698
+ function unitToMs(value, unit) {
1699
+ const m = 6e4;
1700
+ switch (unit) {
1701
+ case "minutes":
1702
+ return value * m;
1703
+ case "hours":
1704
+ return value * 60 * m;
1705
+ case "days":
1706
+ return value * 24 * 60 * m;
1707
+ case "weeks":
1708
+ return value * 7 * 24 * 60 * m;
1709
+ }
1710
+ }
1711
+
1712
+ // src/server/runner/sweep.ts
1713
+ var SWEEP_LIMIT = 500;
1714
+ async function sweepStrandedFlowRuns(ctx) {
1715
+ const runs = await ctx.collections.flowRuns.find({ status: "active", nextActionAt: { $lte: /* @__PURE__ */ new Date() } }).sort({ nextActionAt: 1 }).limit(SWEEP_LIMIT).toArray();
1716
+ for (const run of runs) {
1717
+ try {
1718
+ await processOneRunStep(run._id, ctx);
1719
+ } catch (err) {
1720
+ console.error("mailery: sweep advance failed", { runId: String(run._id), err });
1721
+ }
1722
+ }
1723
+ }
1724
+ async function processScheduledBroadcasts(ctx) {
1725
+ const now = /* @__PURE__ */ new Date();
1726
+ const due = await ctx.collections.broadcasts.find({ status: "scheduled", scheduledAt: { $lte: now } }).toArray();
1727
+ for (const b of due) {
1728
+ const claimed = await ctx.collections.broadcasts.findOneAndUpdate(
1729
+ { _id: b._id, status: "scheduled" },
1730
+ { $set: { status: "sending", startedAt: now, updatedAt: now } },
1731
+ { returnDocument: "after" }
1732
+ );
1733
+ if (!claimed) continue;
1734
+ try {
1735
+ await dispatchBroadcast(claimed, ctx);
1736
+ } catch (err) {
1737
+ console.error("mailery: broadcast dispatch failed", { id: String(b._id), err });
1738
+ await ctx.collections.broadcasts.updateOne(
1739
+ { _id: b._id },
1740
+ { $set: { status: "failed", updatedAt: /* @__PURE__ */ new Date() } }
1741
+ );
1742
+ }
1743
+ }
1744
+ }
1745
+ async function dispatchBroadcast(broadcast, ctx) {
1746
+ const template = await ctx.collections.templates.findOne({ slug: broadcast.templateSlug });
1747
+ if (!template) {
1748
+ await ctx.collections.broadcasts.updateOne(
1749
+ { _id: broadcast._id },
1750
+ { $set: { status: "failed", updatedAt: /* @__PURE__ */ new Date() } }
1751
+ );
1752
+ return;
1753
+ }
1754
+ const hostFilter = toAdapterFilter(broadcast.segmentDefinition);
1755
+ const postFilters = broadcast.segmentDefinition.filters.filter((f) => isMailerSide(f));
1756
+ let cursor = void 0;
1757
+ let total = 0;
1758
+ const batchSize = ctx.config.broadcastEnqueueBatchSize;
1759
+ const maxWaiting = ctx.config.broadcastEnqueueMaxWaiting;
1760
+ const respectTimezone = broadcast.respectRecipientTimezone === true;
1761
+ const scheduledMs = broadcast.scheduledAt?.getTime() ?? Date.now();
1762
+ for (; ; ) {
1763
+ const page = await ctx.adapter.query(hostFilter, { limit: batchSize, cursor });
1764
+ if (page.contacts.length === 0) break;
1765
+ const eligible = await applyPostFilters(page.contacts, postFilters, ctx);
1766
+ if (eligible.length > 0) {
1767
+ while (await ctx.queues.send.getWaitingCount() > maxWaiting) {
1768
+ await sleep(2e3);
1769
+ }
1770
+ const sendDocs = await Promise.all(
1771
+ eligible.map(async (contact) => buildSendDoc(broadcast, template, contact, ctx, scheduledMs, respectTimezone))
1772
+ );
1773
+ const inserted = [];
1774
+ for (const { doc, delayMs } of sendDocs) {
1775
+ if (!doc) continue;
1776
+ try {
1777
+ await ctx.collections.sends.insertOne(doc);
1778
+ inserted.push({ sendId: doc._id, delayMs });
1779
+ } catch (err) {
1780
+ if (err?.code !== 11e3) throw err;
1781
+ }
1782
+ }
1783
+ if (inserted.length > 0) {
1784
+ await Promise.all(
1785
+ inserted.map(
1786
+ ({ sendId, delayMs }) => ctx.queues.send.add(
1787
+ "send",
1788
+ { sendId: String(sendId) },
1789
+ {
1790
+ attempts: ctx.config.sendRetryAttempts,
1791
+ backoff: { type: "exponential", delay: 6e4 },
1792
+ ...delayMs > 0 ? { delay: delayMs } : {}
1793
+ }
1794
+ )
1795
+ )
1796
+ );
1797
+ }
1798
+ total += inserted.length;
1799
+ }
1800
+ if (!page.nextCursor) break;
1801
+ cursor = page.nextCursor;
1802
+ }
1803
+ await ctx.collections.broadcasts.updateOne(
1804
+ { _id: broadcast._id },
1805
+ {
1806
+ $set: {
1807
+ status: "sent",
1808
+ completedAt: /* @__PURE__ */ new Date(),
1809
+ recipientCount: total,
1810
+ updatedAt: /* @__PURE__ */ new Date()
1811
+ }
1812
+ }
1813
+ );
1814
+ }
1815
+ function toAdapterFilter(seg) {
1816
+ const out = {};
1817
+ for (const f of seg.filters) {
1818
+ switch (f.kind) {
1819
+ case "hasTag":
1820
+ out.hasTag = f.tag;
1821
+ break;
1822
+ case "fieldEquals":
1823
+ out.fieldEquals = { field: f.field, value: f.value };
1824
+ break;
1825
+ case "fieldIn":
1826
+ out.fieldIn = { field: f.field, values: f.values };
1827
+ break;
1828
+ case "fieldExists":
1829
+ out.fieldExists = f.field;
1830
+ break;
1831
+ }
1832
+ }
1833
+ return out;
1834
+ }
1835
+ function isMailerSide(f) {
1836
+ return f.kind === "subscriptionStatus" || f.kind === "firedEvent" || f.kind === "notFiredEvent" || f.kind === "subscribedAfter" || f.kind === "subscribedBefore" || f.kind === "opened" || f.kind === "notOpened" || f.kind === "notHasTag" || f.kind === "any" || f.kind === "not";
1837
+ }
1838
+ async function applyPostFilters(contacts, filters, ctx) {
1839
+ if (filters.length === 0) return contacts;
1840
+ const externalIds = contacts.map((c) => c.externalId);
1841
+ const cache = {};
1842
+ for (const f of filters) {
1843
+ if (f.kind === "subscriptionStatus") {
1844
+ const docs = await ctx.collections.subscriptions.find({ externalId: { $in: externalIds }, status: f.equals }).project({ externalId: 1 }).toArray();
1845
+ cache[`sub:${f.equals}`] = new Set(docs.map((d) => d.externalId));
1846
+ }
1847
+ if (f.kind === "firedEvent" || f.kind === "notFiredEvent") {
1848
+ const query = { externalId: { $in: externalIds }, name: f.eventName };
1849
+ if (f.withinDays) {
1850
+ query.occurredAt = { $gt: new Date(Date.now() - f.withinDays * 864e5) };
1851
+ }
1852
+ const docs = await ctx.collections.events.find(query).project({ externalId: 1 }).toArray();
1853
+ cache[`evt:${f.eventName}`] = new Set(docs.map((d) => d.externalId));
1854
+ }
1855
+ }
1856
+ return contacts.filter((c) => filters.every((f) => filterMatches(c, f, cache)));
1857
+ }
1858
+ function filterMatches(c, f, cache) {
1859
+ switch (f.kind) {
1860
+ case "subscriptionStatus":
1861
+ return cache[`sub:${f.equals}`]?.has(c.externalId) ?? false;
1862
+ case "firedEvent":
1863
+ return cache[`evt:${f.eventName}`]?.has(c.externalId) ?? false;
1864
+ case "notFiredEvent":
1865
+ return !cache[`evt:${f.eventName}`]?.has(c.externalId);
1866
+ case "notHasTag":
1867
+ return !c.tags.includes(f.tag);
1868
+ case "opened":
1869
+ case "notOpened":
1870
+ return true;
1871
+ case "subscribedAfter":
1872
+ case "subscribedBefore":
1873
+ return true;
1874
+ // V2
1875
+ case "any":
1876
+ return f.filters.some((sub) => filterMatches(c, sub, cache));
1877
+ case "not":
1878
+ return !filterMatches(c, f.filter, cache);
1879
+ default:
1880
+ return true;
1881
+ }
1882
+ }
1883
+ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, respectTimezone) {
1884
+ const supp = await isSuppressed(ctx.collections, contact.email, template.kind);
1885
+ if (supp.suppressed) return { doc: null, delayMs: 0 };
1886
+ const sendId = new mongodb.ObjectId();
1887
+ const dedupeKey = `broadcast:${broadcast._id}:${contact.externalId}`;
1888
+ let delayMs = Math.max(0, scheduledMs - Date.now());
1889
+ if (respectTimezone && contact.timezone) {
1890
+ delayMs = Math.max(0, perRecipientDelayMs(scheduledMs, contact.timezone));
1891
+ }
1892
+ const doc = {
1893
+ _id: sendId,
1894
+ dedupeKey,
1895
+ externalId: contact.externalId,
1896
+ emailAtSend: contact.email,
1897
+ templateId: template._id,
1898
+ templateSlug: template.slug,
1899
+ flowRunId: null,
1900
+ broadcastId: broadcast._id,
1901
+ manualSendBy: null,
1902
+ kind: template.kind,
1903
+ provider: template.providerOverride ?? ctx.config.defaultProvider,
1904
+ providerMessageId: null,
1905
+ fromName: template.fromName,
1906
+ fromEmail: template.fromEmail,
1907
+ subject: template.subject,
1908
+ bodyHash: "",
1909
+ status: "queued",
1910
+ errorMessage: null,
1911
+ bounceType: null,
1912
+ bounceReason: null,
1913
+ links: [],
1914
+ vars: {},
1915
+ openedAt: null,
1916
+ openCount: 0,
1917
+ firstClickAt: null,
1918
+ clickCount: 0,
1919
+ clickedLinks: [],
1920
+ unsubscribedAt: null,
1921
+ complainedAt: null,
1922
+ queuedAt: /* @__PURE__ */ new Date(),
1923
+ sentAt: null,
1924
+ deliveredAt: null
1925
+ };
1926
+ return { doc, delayMs };
1927
+ }
1928
+ function perRecipientDelayMs(scheduledMs, timezone) {
1929
+ try {
1930
+ const scheduled = new Date(scheduledMs);
1931
+ const utc = scheduled.toLocaleString("en-US", { timeZone: "UTC", hour12: false });
1932
+ const local = scheduled.toLocaleString("en-US", { timeZone: timezone, hour12: false });
1933
+ const parse = (s) => {
1934
+ const m = s.match(/(\d+)\/(\d+)\/(\d+),\s*(\d+):(\d+):(\d+)/);
1935
+ if (!m) return 0;
1936
+ return Date.UTC(+m[3], +m[1] - 1, +m[2], +m[4], +m[5], +m[6]);
1937
+ };
1938
+ const utcMs = parse(utc);
1939
+ const localMs = parse(local);
1940
+ const offsetMs = utcMs - localMs;
1941
+ return offsetMs;
1942
+ } catch {
1943
+ return 0;
1944
+ }
1945
+ }
1946
+ function sleep(ms) {
1947
+ return new Promise((resolve) => setTimeout(resolve, ms));
1948
+ }
1949
+
1950
+ // src/server/runner/bounce-promotion.ts
1951
+ async function promoteSoftBounces(ctx) {
1952
+ const threshold = ctx.config.softBouncePromotionThreshold;
1953
+ const windowDays = ctx.config.softBouncePromotionWindowDays;
1954
+ if (threshold <= 0 || windowDays <= 0) return;
1955
+ const cutoff = new Date(Date.now() - windowDays * 864e5);
1956
+ const offenders = await ctx.collections.sends.aggregate([
1957
+ { $match: { status: "bounced", bounceType: "soft", queuedAt: { $gt: cutoff } } },
1958
+ { $group: { _id: "$emailAtSend", count: { $sum: 1 } } },
1959
+ { $match: { count: { $gte: threshold } } },
1960
+ { $limit: 200 }
1961
+ ]).toArray();
1962
+ for (const o of offenders) {
1963
+ const email = String(o._id ?? "").toLowerCase();
1964
+ if (!email) continue;
1965
+ const existing = await ctx.collections.suppressions.findOne({
1966
+ email,
1967
+ scope: "all"
1968
+ });
1969
+ if (existing) continue;
1970
+ await ctx.collections.suppressions.updateOne(
1971
+ { email, scope: "all" },
1972
+ {
1973
+ $setOnInsert: {
1974
+ email,
1975
+ emailHash: sha256Hex(email),
1976
+ scope: "all",
1977
+ reason: "hard_bounce",
1978
+ source: "soft_promotion",
1979
+ notes: `${o.count} soft bounces in last ${windowDays} days`,
1980
+ addedAt: /* @__PURE__ */ new Date(),
1981
+ expiresAt: null
1982
+ }
1983
+ },
1984
+ { upsert: true }
1985
+ );
1986
+ await ctx.collections.subscriptions.updateOne(
1987
+ { emailAtSubscribe: email },
1988
+ { $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
1989
+ );
1990
+ await recordHealthCounter(ctx, "hardBounced");
1991
+ }
1992
+ }
1993
+
1994
+ // src/server/runner/tick.ts
1995
+ async function runTick(ctx) {
1996
+ await processNewlyFiredEventTriggers(ctx).catch((err) => {
1997
+ console.error("mailery: triggers scan failed", err);
1998
+ });
1999
+ await sweepStrandedFlowRuns(ctx).catch((err) => {
2000
+ console.error("mailery: sweep failed", err);
2001
+ });
2002
+ await drainOutbox(ctx).catch((err) => {
2003
+ console.error("mailery: outbox drain failed", err);
2004
+ });
2005
+ await processScheduledBroadcasts2(ctx).catch((err) => {
2006
+ console.error("mailery: broadcast dispatch failed", err);
2007
+ });
2008
+ await evaluateHealth(ctx).catch((err) => {
2009
+ console.error("mailery: health evaluation failed", err);
2010
+ });
2011
+ await promoteSoftBounces(ctx).catch((err) => {
2012
+ console.error("mailery: soft-bounce promotion failed", err);
2013
+ });
2014
+ }
2015
+ async function drainOutbox(ctx) {
2016
+ const batch = await ctx.collections.outbox.find({ status: "pending" }).sort({ enqueuedAt: 1 }).limit(200).toArray();
2017
+ for (const row of batch) {
2018
+ try {
2019
+ if (row.payload.type === "event") {
2020
+ try {
2021
+ await ctx.collections.events.insertOne({
2022
+ externalId: row.payload.data.externalId,
2023
+ name: row.payload.data.name,
2024
+ properties: row.payload.data.properties ?? {},
2025
+ dedupeKey: row.payload.dedupeKey,
2026
+ occurredAt: row.payload.data.occurredAt ?? /* @__PURE__ */ new Date(),
2027
+ createdAt: /* @__PURE__ */ new Date()
2028
+ });
2029
+ } catch (err) {
2030
+ if (err?.code !== 11e3) throw err;
2031
+ }
2032
+ }
2033
+ await ctx.collections.outbox.updateOne(
2034
+ { _id: row._id },
2035
+ { $set: { status: "processed", processedAt: /* @__PURE__ */ new Date() } }
2036
+ );
2037
+ } catch (err) {
2038
+ await ctx.collections.outbox.updateOne(
2039
+ { _id: row._id },
2040
+ {
2041
+ $set: { lastAttemptAt: /* @__PURE__ */ new Date(), lastError: String(err?.message ?? err) },
2042
+ $inc: { attempts: 1 }
2043
+ }
2044
+ );
2045
+ }
2046
+ }
2047
+ }
2048
+ async function processScheduledBroadcasts2(ctx) {
2049
+ await processScheduledBroadcasts(ctx);
2050
+ }
2051
+
2052
+ // src/server/runner/webhook.ts
2053
+ async function applyWebhookEvent(event, ctx) {
2054
+ const send = await ctx.collections.sends.findOne(
2055
+ event.providerMessageId ? { $or: [{ providerMessageId: event.providerMessageId }, { emailAtSend: event.email }] } : { emailAtSend: event.email },
2056
+ { sort: { queuedAt: -1 } }
2057
+ );
2058
+ switch (event.type) {
2059
+ case "delivered":
2060
+ if (send) {
2061
+ await ctx.collections.sends.updateOne(
2062
+ { _id: send._id },
2063
+ { $set: { status: "delivered", deliveredAt: event.occurredAt } }
2064
+ );
2065
+ }
2066
+ await recordHealthCounter(ctx, "delivered");
2067
+ break;
2068
+ case "open":
2069
+ if (send) {
2070
+ await ctx.collections.sends.updateOne(
2071
+ { _id: send._id },
2072
+ {
2073
+ $set: {
2074
+ openedAt: send.openedAt ?? event.occurredAt,
2075
+ status: send.status === "sent" ? "delivered" : send.status
2076
+ },
2077
+ $inc: { openCount: 1 }
2078
+ }
2079
+ );
2080
+ }
2081
+ break;
2082
+ case "click":
2083
+ if (send) {
2084
+ await ctx.collections.sends.updateOne(
2085
+ { _id: send._id },
2086
+ {
2087
+ $set: { firstClickAt: send.firstClickAt ?? event.occurredAt },
2088
+ $inc: { clickCount: 1 },
2089
+ $push: {
2090
+ clickedLinks: {
2091
+ url: event.details.clickedUrl ?? "",
2092
+ linkId: "",
2093
+ clickedAt: event.occurredAt
2094
+ }
2095
+ }
2096
+ }
2097
+ );
2098
+ }
2099
+ break;
2100
+ case "bounce": {
2101
+ const bounceType = event.details.bounceType ?? "hard";
2102
+ if (send) {
2103
+ await ctx.collections.sends.updateOne(
2104
+ { _id: send._id },
2105
+ {
2106
+ $set: {
2107
+ status: "bounced",
2108
+ bounceType,
2109
+ bounceReason: event.details.bounceReason ?? null
2110
+ }
2111
+ }
2112
+ );
2113
+ }
2114
+ if (bounceType === "hard") {
2115
+ await suppressOnce(ctx, event.email, "hard_bounce", "all");
2116
+ await ctx.collections.subscriptions.updateOne(
2117
+ { emailAtSubscribe: event.email },
2118
+ { $set: { status: "bounced", updatedAt: /* @__PURE__ */ new Date() } }
2119
+ );
2120
+ }
2121
+ await recordHealthCounter(ctx, "bounced");
2122
+ await recordHealthCounter(ctx, bounceType === "hard" ? "hardBounced" : "softBounced");
2123
+ break;
2124
+ }
2125
+ case "complaint":
2126
+ case "spam_report":
2127
+ if (send) {
2128
+ await ctx.collections.sends.updateOne(
2129
+ { _id: send._id },
2130
+ { $set: { complainedAt: event.occurredAt, status: "complained" } }
2131
+ );
2132
+ }
2133
+ await suppressOnce(ctx, event.email, "complaint", "all");
2134
+ await ctx.collections.subscriptions.updateOne(
2135
+ { emailAtSubscribe: event.email },
2136
+ { $set: { status: "complained", updatedAt: /* @__PURE__ */ new Date() } }
2137
+ );
2138
+ await recordHealthCounter(ctx, "complained");
2139
+ break;
2140
+ case "unsubscribe":
2141
+ if (send) {
2142
+ await ctx.collections.sends.updateOne({ _id: send._id }, { $set: { unsubscribedAt: event.occurredAt } });
2143
+ }
2144
+ await suppressOnce(ctx, event.email, "unsubscribed", "marketing");
2145
+ await ctx.collections.subscriptions.updateOne(
2146
+ { emailAtSubscribe: event.email },
2147
+ {
2148
+ $set: {
2149
+ status: "unsubscribed",
2150
+ unsubscribedAt: event.occurredAt,
2151
+ unsubscribeReason: "user_request",
2152
+ updatedAt: /* @__PURE__ */ new Date()
2153
+ }
2154
+ }
2155
+ );
2156
+ break;
2157
+ }
2158
+ }
2159
+ async function suppressOnce(ctx, email, reason, scope) {
2160
+ const normalized = email.toLowerCase();
2161
+ await ctx.collections.suppressions.updateOne(
2162
+ { email: normalized, scope },
2163
+ {
2164
+ $setOnInsert: {
2165
+ email: normalized,
2166
+ emailHash: sha256Hex(normalized),
2167
+ scope,
2168
+ reason,
2169
+ source: "provider_webhook",
2170
+ notes: null,
2171
+ addedAt: /* @__PURE__ */ new Date(),
2172
+ expiresAt: null
2173
+ }
2174
+ },
2175
+ { upsert: true }
2176
+ );
2177
+ }
2178
+
2179
+ // src/server/mailer.ts
2180
+ var Mailer = class _Mailer {
2181
+ db;
2182
+ collections;
2183
+ adapter;
2184
+ providers;
2185
+ redis;
2186
+ queues;
2187
+ config;
2188
+ events;
2189
+ workers = null;
2190
+ bullQueues;
2191
+ runnerContext;
2192
+ constructor(args) {
2193
+ this.config = args.config;
2194
+ this.db = args.db;
2195
+ this.collections = args.collections;
2196
+ this.adapter = args.adapter;
2197
+ this.providers = args.providers;
2198
+ this.redis = args.redis;
2199
+ this.queues = args.queues;
2200
+ this.bullQueues = args.bullQueues;
2201
+ this.events = args.events;
2202
+ this.runnerContext = {
2203
+ db: this.db,
2204
+ collections: this.collections,
2205
+ adapter: this.adapter,
2206
+ providers: this.providers,
2207
+ queues: this.queues,
2208
+ config: this.config,
2209
+ handlebarsHelpers: this.config.handlebarsHelpers
2210
+ };
2211
+ }
2212
+ /**
2213
+ * Construct a Mailer from environment variables. Reads:
2214
+ *
2215
+ * MAILER_MONGODB_URI — Mongo connection string (required)
2216
+ * MAILER_MONGODB_DB — database name (optional; defaults to the URI default)
2217
+ * MAILER_REDIS_URL — Redis connection URL (required)
2218
+ * MAILER_PUBLIC_URL — base for tracking/unsub URLs (required)
2219
+ * MAILER_UNSUBSCRIBE_SECRET — HMAC key (required)
2220
+ * MAILER_SENDER_ADDRESS — postal address for CAN-SPAM (optional)
2221
+ * MAILER_FROM_NAME / MAILER_FROM_EMAIL
2222
+ * MAILER_DEFAULT_PROVIDER — defaults to 'sendgrid' if SENDGRID_API_KEY is set
2223
+ * MAILER_SENDGRID_API_KEY / MAILER_SENDGRID_WEBHOOK_KEY
2224
+ * MAILER_HOST_USERS_COLLECTION (default 'users')
2225
+ * MAILER_HOST_USERS_EMAIL_FIELD (default 'email')
2226
+ * MAILER_HOST_USERS_ID_FIELD (default '_id')
2227
+ * MAILER_HOST_USERS_TAGS_FIELD
2228
+ * MAILER_HOST_USERS_TAGS_WRITABLE — '1' or 'true' to enable
2229
+ *
2230
+ * For anything beyond this (custom toContact, custom providers, hooks),
2231
+ * use the programmatic init.
2232
+ */
2233
+ static async fromEnv() {
2234
+ const env = process.env;
2235
+ const required = (k) => {
2236
+ const v = env[k];
2237
+ if (!v) throw new Error(`Mailer.fromEnv: missing env var ${k}`);
2238
+ return v;
2239
+ };
2240
+ const { MongoClient } = await import('mongodb');
2241
+ const mongoClient = await MongoClient.connect(required("MAILER_MONGODB_URI"));
2242
+ const db = env.MAILER_MONGODB_DB ? mongoClient.db(env.MAILER_MONGODB_DB) : mongoClient.db();
2243
+ const { MongoContactAdapter: MongoContactAdapter2 } = await Promise.resolve().then(() => (init_mongo(), mongo_exports));
2244
+ const adapter = new MongoContactAdapter2({
2245
+ db,
2246
+ collection: env.MAILER_HOST_USERS_COLLECTION ?? "users",
2247
+ emailField: env.MAILER_HOST_USERS_EMAIL_FIELD ?? "email",
2248
+ idField: env.MAILER_HOST_USERS_ID_FIELD ?? "_id",
2249
+ tagsField: env.MAILER_HOST_USERS_TAGS_FIELD,
2250
+ tagsWritable: env.MAILER_HOST_USERS_TAGS_WRITABLE === "1" || env.MAILER_HOST_USERS_TAGS_WRITABLE === "true"
2251
+ });
2252
+ const providers = {};
2253
+ if (env.MAILER_SENDGRID_API_KEY) {
2254
+ const { SendGridProvider: SendGridProvider2 } = await Promise.resolve().then(() => (init_sendgrid(), sendgrid_exports));
2255
+ providers.sendgrid = new SendGridProvider2({
2256
+ apiKey: env.MAILER_SENDGRID_API_KEY,
2257
+ webhookVerificationKey: env.MAILER_SENDGRID_WEBHOOK_KEY,
2258
+ sandbox: env.NODE_ENV !== "production"
2259
+ });
2260
+ }
2261
+ if (Object.keys(providers).length === 0) {
2262
+ throw new Error("Mailer.fromEnv: no provider configured (set MAILER_SENDGRID_API_KEY, ...)");
2263
+ }
2264
+ const defaultProvider = env.MAILER_DEFAULT_PROVIDER ?? Object.keys(providers)[0];
2265
+ return _Mailer.init({
2266
+ db,
2267
+ adapter,
2268
+ redis: { url: required("MAILER_REDIS_URL") },
2269
+ providers,
2270
+ defaultProvider,
2271
+ publicUrl: required("MAILER_PUBLIC_URL"),
2272
+ unsubscribeSecret: required("MAILER_UNSUBSCRIBE_SECRET"),
2273
+ senderAddress: env.MAILER_SENDER_ADDRESS,
2274
+ fromDefaults: env.MAILER_FROM_NAME && env.MAILER_FROM_EMAIL ? { name: env.MAILER_FROM_NAME, email: env.MAILER_FROM_EMAIL } : void 0
2275
+ });
2276
+ }
2277
+ static async init(input) {
2278
+ const config = resolveConfig(input);
2279
+ if (!config.providers[config.defaultProvider]) {
2280
+ throw new Error(`defaultProvider "${config.defaultProvider}" not in providers map`);
2281
+ }
2282
+ const collections = getCollections(config.db, config.collectionPrefix);
2283
+ await ensureIndexes(config.db, config.collectionPrefix);
2284
+ let redis = null;
2285
+ let queues;
2286
+ let bullQueues = null;
2287
+ if (config.redis === null) {
2288
+ queues = noopQueues();
2289
+ } else {
2290
+ redis = makeRedis(config.redis);
2291
+ const created = createQueues(redis);
2292
+ queues = created.queues;
2293
+ bullQueues = created.bullQueues;
2294
+ if (!config.workerless) {
2295
+ await scheduleTick(bullQueues, config.tickIntervalSeconds);
2296
+ }
2297
+ }
2298
+ return new _Mailer({
2299
+ config,
2300
+ db: config.db,
2301
+ collections,
2302
+ adapter: config.adapter,
2303
+ providers: config.providers,
2304
+ redis,
2305
+ queues,
2306
+ bullQueues,
2307
+ events: new EventRegistry()
2308
+ });
2309
+ }
2310
+ // -------------------------------------------------------------------------
2311
+ // Event registration + firing
2312
+ // -------------------------------------------------------------------------
2313
+ registerEvent(reg) {
2314
+ const parsed = registerEventSchema.parse(reg);
2315
+ this.events.register(parsed);
2316
+ }
2317
+ async fire(eventName, externalId, properties = {}, dedupeKey) {
2318
+ const input = fireInputSchema.parse({ eventName, externalId, properties, dedupeKey });
2319
+ const key = this.events.deriveKey(input.eventName, input.externalId, input.dedupeKey, /* @__PURE__ */ new Date());
2320
+ if (!key) {
2321
+ throw new Error(
2322
+ `fire("${input.eventName}") missing dedupeKey and no policy registered. Call mailer.registerEvent("${input.eventName}", { dedupePolicy }) or pass a key explicitly.`
2323
+ );
2324
+ }
2325
+ try {
2326
+ await this.collections.events.insertOne({
2327
+ externalId: input.externalId,
2328
+ name: input.eventName,
2329
+ properties: input.properties ?? {},
2330
+ dedupeKey: key,
2331
+ occurredAt: /* @__PURE__ */ new Date(),
2332
+ createdAt: /* @__PURE__ */ new Date()
2333
+ });
2334
+ } catch (err) {
2335
+ if (err?.code !== 11e3) throw err;
2336
+ }
2337
+ }
2338
+ async fireFromSession(session, eventName, externalId, properties = {}, dedupeKey) {
2339
+ const input = fireInputSchema.parse({ eventName, externalId, properties, dedupeKey });
2340
+ const key = this.events.deriveKey(input.eventName, input.externalId, input.dedupeKey, /* @__PURE__ */ new Date());
2341
+ if (!key) {
2342
+ throw new Error(`fireFromSession("${input.eventName}") missing dedupeKey and no policy registered.`);
2343
+ }
2344
+ try {
2345
+ await this.collections.outbox.insertOne(
2346
+ {
2347
+ payload: {
2348
+ type: "event",
2349
+ data: {
2350
+ externalId: input.externalId,
2351
+ name: input.eventName,
2352
+ properties: input.properties ?? {},
2353
+ occurredAt: /* @__PURE__ */ new Date()
2354
+ },
2355
+ dedupeKey: key
2356
+ },
2357
+ status: "pending",
2358
+ attempts: 0,
2359
+ lastAttemptAt: null,
2360
+ lastError: null,
2361
+ enqueuedAt: /* @__PURE__ */ new Date(),
2362
+ processedAt: null
2363
+ },
2364
+ { session }
2365
+ );
2366
+ } catch (err) {
2367
+ if (err?.code !== 11e3) throw err;
2368
+ }
2369
+ }
2370
+ // -------------------------------------------------------------------------
2371
+ // Subscription / unsubscribe / tags / suppression
2372
+ // -------------------------------------------------------------------------
2373
+ async upsertSubscription(input) {
2374
+ const parsed = upsertSubscriptionSchema.parse(input);
2375
+ const contact = await this.adapter.getById(parsed.externalId);
2376
+ if (!contact) throw new Error(`adapter has no contact for externalId ${parsed.externalId}`);
2377
+ const status = this.config.requireDoubleOptIn ? "pending_doi" : "subscribed";
2378
+ const now = /* @__PURE__ */ new Date();
2379
+ let doiToken = null;
2380
+ let doiTokenHash = null;
2381
+ if (status === "pending_doi") {
2382
+ const expiresAt = new Date(now.getTime() + this.config.doiTokenLifetimeDays * 864e5);
2383
+ doiToken = signDoiToken({ externalId: parsed.externalId, expiresAt }, this.config.unsubscribeSecret);
2384
+ doiTokenHash = sha256Hex(doiToken);
2385
+ }
2386
+ await this.collections.subscriptions.updateOne(
2387
+ { externalId: parsed.externalId },
2388
+ {
2389
+ $set: {
2390
+ status,
2391
+ source: parsed.source,
2392
+ emailAtSubscribe: contact.email,
2393
+ subscribedAt: status === "subscribed" ? parsed.consentTimestamp ?? now : null,
2394
+ updatedAt: now,
2395
+ ...doiTokenHash ? { doiTokenHash, doiRequestedAt: now } : {}
2396
+ },
2397
+ $setOnInsert: {
2398
+ externalId: parsed.externalId,
2399
+ createdAt: now,
2400
+ unsubscribedAt: null,
2401
+ unsubscribeReason: null,
2402
+ doiTokenHash: null,
2403
+ doiRequestedAt: null,
2404
+ doiConfirmedAt: null,
2405
+ doiIp: parsed.consentIp ?? null,
2406
+ doiUserAgent: parsed.consentUserAgent ?? null
2407
+ }
2408
+ },
2409
+ { upsert: true }
2410
+ );
2411
+ if (status === "pending_doi" && doiToken) {
2412
+ const tpl = await this.collections.templates.findOne({ slug: this.config.doiTemplateSlug });
2413
+ if (!tpl) {
2414
+ console.warn(`mailery: DOI required but template "${this.config.doiTemplateSlug}" not found \u2014 skipping confirmation email`);
2415
+ return;
2416
+ }
2417
+ const dedupeKey = `doi:${parsed.externalId}:${now.toISOString().slice(0, 10)}`;
2418
+ try {
2419
+ await this.sendOneOff({
2420
+ templateSlug: tpl.slug,
2421
+ externalId: parsed.externalId,
2422
+ vars: { confirmDoiUrl: `${this.config.publicUrl}/m/confirm-doi/${doiToken}` },
2423
+ dedupeKey
2424
+ });
2425
+ } catch (err) {
2426
+ console.error("mailery: DOI confirmation send failed", err);
2427
+ }
2428
+ }
2429
+ }
2430
+ async unsubscribe(email, opts) {
2431
+ const parsed = unsubscribeInputSchema.parse({ email, ...opts });
2432
+ const normalized = parsed.email;
2433
+ const now = /* @__PURE__ */ new Date();
2434
+ await this.collections.suppressions.updateOne(
2435
+ { email: normalized, scope: parsed.scope },
2436
+ {
2437
+ $setOnInsert: {
2438
+ email: normalized,
2439
+ emailHash: sha256Hex(normalized),
2440
+ scope: parsed.scope,
2441
+ // mailer_suppressions canonical reason — see plans/02-data-model.md.
2442
+ reason: "unsubscribed",
2443
+ source: parsed.source,
2444
+ notes: parsed.notes ?? null,
2445
+ addedAt: now,
2446
+ expiresAt: null
2447
+ }
2448
+ },
2449
+ { upsert: true }
2450
+ );
2451
+ await this.collections.subscriptions.updateOne(
2452
+ { emailAtSubscribe: normalized },
2453
+ {
2454
+ $set: {
2455
+ status: "unsubscribed",
2456
+ unsubscribedAt: now,
2457
+ unsubscribeReason: parsed.reason,
2458
+ updatedAt: now
2459
+ }
2460
+ }
2461
+ );
2462
+ }
2463
+ async suppress(email, opts) {
2464
+ const parsed = suppressInputSchema.parse({ email, ...opts });
2465
+ await this.collections.suppressions.updateOne(
2466
+ { email: parsed.email, scope: parsed.scope },
2467
+ {
2468
+ $setOnInsert: {
2469
+ email: parsed.email,
2470
+ emailHash: sha256Hex(parsed.email),
2471
+ scope: parsed.scope,
2472
+ reason: parsed.reason,
2473
+ source: parsed.source,
2474
+ notes: parsed.notes ?? null,
2475
+ addedAt: /* @__PURE__ */ new Date(),
2476
+ expiresAt: parsed.expiresAt ?? null
2477
+ }
2478
+ },
2479
+ { upsert: true }
2480
+ );
2481
+ }
2482
+ async tag(externalId, tag) {
2483
+ const parsed = tagInputSchema.parse({ externalId, tag });
2484
+ if (this.adapter.addTags) {
2485
+ await this.adapter.addTags(parsed.externalId, [parsed.tag]);
2486
+ } else {
2487
+ await this.collections.contactTags.updateOne(
2488
+ { externalId: parsed.externalId, tag: parsed.tag },
2489
+ {
2490
+ $setOnInsert: {
2491
+ externalId: parsed.externalId,
2492
+ tag: parsed.tag,
2493
+ appliedBy: "admin",
2494
+ appliedAt: /* @__PURE__ */ new Date()
2495
+ }
2496
+ },
2497
+ { upsert: true }
2498
+ );
2499
+ }
2500
+ }
2501
+ async untag(externalId, tag) {
2502
+ const parsed = tagInputSchema.parse({ externalId, tag });
2503
+ if (this.adapter.removeTags) {
2504
+ await this.adapter.removeTags(parsed.externalId, [parsed.tag]);
2505
+ } else {
2506
+ await this.collections.contactTags.deleteOne({ externalId: parsed.externalId, tag: parsed.tag });
2507
+ }
2508
+ }
2509
+ /**
2510
+ * GDPR right-to-erasure. Hard-deletes the contact's PII and leaves a hashed
2511
+ * suppression row to block re-import. INVARIANT 9.
2512
+ */
2513
+ async forget(externalId) {
2514
+ const sub = await this.collections.subscriptions.findOne({ externalId });
2515
+ const email = sub?.emailAtSubscribe?.toLowerCase();
2516
+ const collected = await Promise.all([
2517
+ this.collections.sends.find({ externalId }).map((s) => s.emailAtSend).toArray()
2518
+ ]);
2519
+ const emails = /* @__PURE__ */ new Set();
2520
+ if (email) emails.add(email);
2521
+ for (const e of collected[0]) if (e) emails.add(e.toLowerCase());
2522
+ await Promise.all([
2523
+ this.collections.events.deleteMany({ externalId }),
2524
+ this.collections.flowRuns.deleteMany({ externalId }),
2525
+ this.collections.sends.deleteMany({ externalId }),
2526
+ this.collections.subscriptions.deleteMany({ externalId }),
2527
+ this.collections.contactTags.deleteMany({ externalId })
2528
+ ]);
2529
+ if (email) {
2530
+ await this.collections.leads.deleteMany({ email });
2531
+ }
2532
+ for (const e of emails) {
2533
+ await this.collections.suppressions.updateOne(
2534
+ { emailHash: sha256Hex(e), scope: "all" },
2535
+ {
2536
+ $setOnInsert: {
2537
+ email: null,
2538
+ emailHash: sha256Hex(e),
2539
+ scope: "all",
2540
+ reason: "gdpr_forget",
2541
+ source: "gdpr_request",
2542
+ notes: null,
2543
+ addedAt: /* @__PURE__ */ new Date(),
2544
+ expiresAt: null
2545
+ }
2546
+ },
2547
+ { upsert: true }
2548
+ );
2549
+ }
2550
+ await this.audit({
2551
+ actor: "system:gdpr",
2552
+ action: "gdpr.forget",
2553
+ resource: { collection: "mailer_subscriptions", id: sub?._id },
2554
+ diffSummary: `forget externalId=${externalId}`
2555
+ });
2556
+ }
2557
+ /** GDPR data export. JSON-serializable. */
2558
+ async exportContactData(externalId) {
2559
+ const subscription = await this.collections.subscriptions.findOne({ externalId });
2560
+ const [events, flowRuns, sends, suppressions, tags] = await Promise.all([
2561
+ this.collections.events.find({ externalId }).toArray(),
2562
+ this.collections.flowRuns.find({ externalId }).toArray(),
2563
+ this.collections.sends.find({ externalId }).toArray(),
2564
+ subscription?.emailAtSubscribe ? this.collections.suppressions.find({ email: subscription.emailAtSubscribe }).toArray() : Promise.resolve([]),
2565
+ this.collections.contactTags.find({ externalId }).toArray()
2566
+ ]);
2567
+ return { subscription, events, flowRuns, sends, suppressions, tags };
2568
+ }
2569
+ // -------------------------------------------------------------------------
2570
+ // One-off transactional send (password reset etc.)
2571
+ // -------------------------------------------------------------------------
2572
+ async sendOneOff(input) {
2573
+ const parsed = sendOneOffInputSchema.parse(input);
2574
+ const template = await this.collections.templates.findOne({ slug: parsed.templateSlug });
2575
+ if (!template) throw new Error(`template not found: ${parsed.templateSlug}`);
2576
+ const contact = await this.adapter.getById(parsed.externalId);
2577
+ if (!contact) throw new Error(`contact not found: ${parsed.externalId}`);
2578
+ const dedupeKey = `oneoff:${parsed.dedupeKey}`;
2579
+ const existing = await this.collections.sends.findOne({ dedupeKey });
2580
+ if (existing) return { sendId: String(existing._id) };
2581
+ const providerName = parsed.providerOverride ?? template.providerOverride ?? (template.kind === "transactional" ? this.config.defaultTransactionalProvider : null) ?? this.config.defaultProvider;
2582
+ const sendId = new mongodb.ObjectId();
2583
+ await this.collections.sends.insertOne({
2584
+ _id: sendId,
2585
+ dedupeKey,
2586
+ externalId: parsed.externalId,
2587
+ emailAtSend: contact.email,
2588
+ templateId: template._id,
2589
+ templateSlug: template.slug,
2590
+ flowRunId: null,
2591
+ broadcastId: null,
2592
+ manualSendBy: "sendOneOff",
2593
+ kind: template.kind,
2594
+ provider: providerName,
2595
+ providerMessageId: null,
2596
+ fromName: template.fromName,
2597
+ fromEmail: template.fromEmail,
2598
+ subject: template.subject,
2599
+ bodyHash: "",
2600
+ status: "queued",
2601
+ errorMessage: null,
2602
+ bounceType: null,
2603
+ bounceReason: null,
2604
+ links: [],
2605
+ vars: parsed.vars ?? {},
2606
+ openedAt: null,
2607
+ openCount: 0,
2608
+ firstClickAt: null,
2609
+ clickCount: 0,
2610
+ clickedLinks: [],
2611
+ unsubscribedAt: null,
2612
+ complainedAt: null,
2613
+ queuedAt: /* @__PURE__ */ new Date(),
2614
+ sentAt: null,
2615
+ deliveredAt: null
2616
+ });
2617
+ await this.queues.send.add(
2618
+ "send",
2619
+ { sendId: String(sendId) },
2620
+ { attempts: this.config.sendRetryAttempts, backoff: { type: "exponential", delay: 6e4 } }
2621
+ );
2622
+ return { sendId: String(sendId) };
2623
+ }
2624
+ // -------------------------------------------------------------------------
2625
+ // Audit log helper
2626
+ // -------------------------------------------------------------------------
2627
+ async audit(entry) {
2628
+ await this.collections.auditLog.insertOne({
2629
+ actor: entry.actor,
2630
+ action: entry.action,
2631
+ resource: entry.resource,
2632
+ before: entry.before ?? null,
2633
+ after: entry.after ?? null,
2634
+ diffSummary: entry.diffSummary ?? null,
2635
+ ip: entry.ip ?? null,
2636
+ userAgent: entry.userAgent ?? null,
2637
+ requestId: entry.requestId ?? null,
2638
+ occurredAt: /* @__PURE__ */ new Date()
2639
+ });
2640
+ }
2641
+ // -------------------------------------------------------------------------
2642
+ // Workers
2643
+ // -------------------------------------------------------------------------
2644
+ async startWorkers() {
2645
+ if (this.workers) return;
2646
+ if (!this.redis) throw new Error("startWorkers requires a Redis connection (redis was null in config)");
2647
+ const provider = this.providers[this.config.defaultProvider];
2648
+ const sendRate = provider?.sendRatePerSecond ?? this.config.sendRatePerSecond;
2649
+ this.workers = createWorkers({
2650
+ redis: this.redis,
2651
+ concurrency: { send: this.config.sendConcurrency },
2652
+ sendRateLimit: { max: sendRate, durationMs: 1e3 },
2653
+ handlers: {
2654
+ tick: async () => {
2655
+ await runTick(this.runnerContext);
2656
+ },
2657
+ advance: async (data) => {
2658
+ if (!mongodb.ObjectId.isValid(data.flowRunId)) return;
2659
+ await processOneRunStep(new mongodb.ObjectId(data.flowRunId), this.runnerContext);
2660
+ },
2661
+ send: async (data) => {
2662
+ if (!mongodb.ObjectId.isValid(data.sendId)) return;
2663
+ await dispatchSend(new mongodb.ObjectId(data.sendId), this.runnerContext);
2664
+ },
2665
+ webhook: async () => {
2666
+ await this.processWebhookBacklog();
2667
+ }
2668
+ }
2669
+ });
2670
+ }
2671
+ /** Process unprocessed webhook events in mailer_webhook_events. */
2672
+ async processWebhookBacklog() {
2673
+ const batch = await this.collections.webhookEvents.find({ processed: false }).limit(500).toArray();
2674
+ for (const evt of batch) {
2675
+ try {
2676
+ const normalized = evt.raw?.normalized;
2677
+ const details = normalized?.details ?? {};
2678
+ await applyWebhookEvent(
2679
+ {
2680
+ type: evt.normalizedType,
2681
+ providerEventId: evt.providerEventId,
2682
+ providerMessageId: evt.providerMessageId,
2683
+ email: evt.email,
2684
+ occurredAt: evt.occurredAt,
2685
+ details
2686
+ },
2687
+ this.runnerContext
2688
+ );
2689
+ await this.collections.webhookEvents.updateOne({ _id: evt._id }, { $set: { processed: true } });
2690
+ } catch (err) {
2691
+ console.error("mailery: webhook apply failed", { id: String(evt._id), err });
2692
+ }
2693
+ }
2694
+ }
2695
+ async stop() {
2696
+ if (this.workers) {
2697
+ await closeWorkers(this.workers);
2698
+ this.workers = null;
2699
+ }
2700
+ if (this.bullQueues) {
2701
+ await closeBullQueues(this.bullQueues);
2702
+ this.bullQueues = null;
2703
+ } else {
2704
+ await closeQueues(this.queues);
2705
+ }
2706
+ if (this.redis) {
2707
+ await this.redis.quit().catch(() => {
2708
+ });
2709
+ }
2710
+ }
2711
+ /** Used internally by the admin router and tests; not part of the public API. */
2712
+ getRunnerContext() {
2713
+ return this.runnerContext;
2714
+ }
2715
+ };
2716
+
2717
+ // src/server/index.ts
2718
+ init_mongo();
2719
+
2720
+ // src/server/providers/null.ts
2721
+ var counter = 0;
2722
+ var NullProvider = class {
2723
+ name = "null";
2724
+ sendRatePerSecond = 1e3;
2725
+ sent = [];
2726
+ async send(args) {
2727
+ this.sent.push(args);
2728
+ return {
2729
+ providerId: `null-${Date.now()}-${++counter}`,
2730
+ status: "accepted"
2731
+ };
2732
+ }
2733
+ async verifyWebhook() {
2734
+ return true;
2735
+ }
2736
+ parseWebhookEvents() {
2737
+ return [];
2738
+ }
2739
+ reset() {
2740
+ this.sent.length = 0;
2741
+ }
2742
+ };
2743
+
2744
+ // src/server/index.ts
2745
+ init_sendgrid();
2746
+ var __filename$1 = url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)));
2747
+ var __dirname$1 = path__default.default.dirname(__filename$1);
2748
+ function defaultSpaDir() {
2749
+ return path__default.default.resolve(__dirname$1, "admin/spa");
2750
+ }
2751
+ function createAdminRouter(mailer, opts = {}) {
2752
+ const router = express.Router();
2753
+ const spaDir = opts.spaDir ?? defaultSpaDir();
2754
+ const getActor = opts.getActor ?? ((req) => `human:${req.user?.email ?? "anonymous"}`);
2755
+ router.use(
2756
+ "/_assets",
2757
+ express__default.default.static(spaDir, {
2758
+ maxAge: "1y",
2759
+ immutable: true,
2760
+ index: false
2761
+ })
2762
+ );
2763
+ router.use("/api", express__default.default.json({ limit: "1mb" }));
2764
+ router.use("/api", (req, _res, next) => {
2765
+ req.actor = getActor(req);
2766
+ next();
2767
+ });
2768
+ router.use("/api", apiRouter(mailer));
2769
+ router.get(/.*/, (_req, res) => {
2770
+ res.sendFile(path__default.default.join(spaDir, "index.html"));
2771
+ });
2772
+ return router;
2773
+ }
2774
+ function apiRouter(mailer) {
2775
+ const r = express.Router();
2776
+ const c = mailer.collections;
2777
+ r.get("/me", (req, res) => {
2778
+ res.json({
2779
+ actor: req.actor,
2780
+ permissions: { canPublish: true, canSendBroadcasts: true, canManageSuppressions: true }
2781
+ });
2782
+ });
2783
+ r.get(
2784
+ "/dashboard",
2785
+ asyncHandler(async (_req, res) => {
2786
+ const since24h = new Date(Date.now() - 24 * 60 * 60 * 1e3);
2787
+ const [sentTotal, deliveredCount, bouncedCount, openedCount, clickedCount] = await Promise.all([
2788
+ c.sends.countDocuments({ queuedAt: { $gt: since24h } }),
2789
+ c.sends.countDocuments({ queuedAt: { $gt: since24h }, status: "delivered" }),
2790
+ c.sends.countDocuments({ queuedAt: { $gt: since24h }, status: "bounced" }),
2791
+ c.sends.countDocuments({ queuedAt: { $gt: since24h }, openedAt: { $ne: null } }),
2792
+ c.sends.countDocuments({ queuedAt: { $gt: since24h }, firstClickAt: { $ne: null } })
2793
+ ]);
2794
+ const health = await c.health.findOne({ _id: "singleton" });
2795
+ const recentFlows = await c.flows.find({ enabled: true }).limit(5).toArray();
2796
+ const recentSends = await c.sends.find().sort({ queuedAt: -1 }).limit(6).toArray();
2797
+ const recentAudit = await c.auditLog.find().sort({ occurredAt: -1 }).limit(5).toArray();
2798
+ res.json({
2799
+ kpis: {
2800
+ sends: { value: sentTotal, delta: null },
2801
+ deliveredRate: {
2802
+ value: sentTotal === 0 ? 1 : deliveredCount / sentTotal,
2803
+ delta: null,
2804
+ bounced: bouncedCount
2805
+ },
2806
+ openRate: { value: sentTotal === 0 ? 0 : openedCount / sentTotal, delta: null, exclBots: false },
2807
+ clickRate: { value: sentTotal === 0 ? 0 : clickedCount / sentTotal, delta: null }
2808
+ },
2809
+ health: health ? { status: health.status, rates: health.rates } : {
2810
+ status: "healthy",
2811
+ rates: { hardBounceRate: 0, complaintRate: 0, combinedBounceRate: 0, failureRate: 0 }
2812
+ },
2813
+ queue: { inFlight: 0, delayed: 0, providerOk: true, providerName: mailer.config.defaultProvider },
2814
+ recentFlows,
2815
+ recentSends,
2816
+ recentAudit
2817
+ });
2818
+ })
2819
+ );
2820
+ r.get(
2821
+ "/flows",
2822
+ asyncHandler(async (_req, res) => {
2823
+ const flows = await c.flows.find().sort({ updatedAt: -1 }).toArray();
2824
+ res.json(flows);
2825
+ })
2826
+ );
2827
+ r.get(
2828
+ "/flows/:slug",
2829
+ asyncHandler(async (req, res) => {
2830
+ const flow = await c.flows.findOne({ slug: req.params.slug });
2831
+ if (!flow) return res.status(404).json({ error: "not_found" });
2832
+ return res.json(flow);
2833
+ })
2834
+ );
2835
+ r.post(
2836
+ "/flows/:slug/pause",
2837
+ asyncHandler(async (req, res) => {
2838
+ const before = await c.flows.findOne({ slug: req.params.slug });
2839
+ if (!before) return res.status(404).json({ error: "not_found" });
2840
+ await c.flows.updateOne({ _id: before._id }, { $set: { enabled: false, updatedAt: /* @__PURE__ */ new Date() } });
2841
+ await mailer.audit({
2842
+ actor: req.actor,
2843
+ action: "flow.pause",
2844
+ resource: { collection: "mailer_flows", id: before._id, slug: before.slug }
2845
+ });
2846
+ return res.json({ ok: true });
2847
+ })
2848
+ );
2849
+ r.post(
2850
+ "/flows/:slug/resume",
2851
+ asyncHandler(async (req, res) => {
2852
+ const before = await c.flows.findOne({ slug: req.params.slug });
2853
+ if (!before) return res.status(404).json({ error: "not_found" });
2854
+ await c.flows.updateOne({ _id: before._id }, { $set: { enabled: true, updatedAt: /* @__PURE__ */ new Date() } });
2855
+ await mailer.audit({
2856
+ actor: req.actor,
2857
+ action: "flow.resume",
2858
+ resource: { collection: "mailer_flows", id: before._id, slug: before.slug }
2859
+ });
2860
+ return res.json({ ok: true });
2861
+ })
2862
+ );
2863
+ r.get(
2864
+ "/templates",
2865
+ asyncHandler(async (_req, res) => {
2866
+ const templates = await c.templates.find().sort({ updatedAt: -1 }).toArray();
2867
+ res.json(templates);
2868
+ })
2869
+ );
2870
+ r.get(
2871
+ "/templates/:slug",
2872
+ asyncHandler(async (req, res) => {
2873
+ const template = await c.templates.findOne({ slug: req.params.slug });
2874
+ if (!template) return res.status(404).json({ error: "not_found" });
2875
+ return res.json(template);
2876
+ })
2877
+ );
2878
+ r.get(
2879
+ "/broadcasts",
2880
+ asyncHandler(async (_req, res) => {
2881
+ const broadcasts = await c.broadcasts.find().sort({ createdAt: -1 }).toArray();
2882
+ res.json(broadcasts);
2883
+ })
2884
+ );
2885
+ r.get(
2886
+ "/broadcasts/:slug",
2887
+ asyncHandler(async (req, res) => {
2888
+ const broadcast = await c.broadcasts.findOne({ slug: req.params.slug });
2889
+ if (!broadcast) return res.status(404).json({ error: "not_found" });
2890
+ return res.json(broadcast);
2891
+ })
2892
+ );
2893
+ r.get(
2894
+ "/contacts",
2895
+ asyncHandler(async (req, res) => {
2896
+ const cursor = typeof req.query.cursor === "string" ? req.query.cursor : void 0;
2897
+ const limit = Math.min(Number(req.query.limit ?? 50), 200);
2898
+ const { contacts, nextCursor } = await mailer.adapter.query({}, { limit, cursor });
2899
+ res.json({ contacts, nextCursor });
2900
+ })
2901
+ );
2902
+ r.get(
2903
+ "/contacts/:externalId",
2904
+ asyncHandler(async (req, res) => {
2905
+ const externalId = String(req.params.externalId);
2906
+ const [contact, subscription, recentEvents, recentSends, activeRuns] = await Promise.all([
2907
+ mailer.adapter.getById(externalId),
2908
+ c.subscriptions.findOne({ externalId }),
2909
+ c.events.find({ externalId }).sort({ occurredAt: -1 }).limit(50).toArray(),
2910
+ c.sends.find({ externalId }).sort({ queuedAt: -1 }).limit(50).toArray(),
2911
+ c.flowRuns.find({ externalId, status: "active" }).toArray()
2912
+ ]);
2913
+ if (!contact) return res.status(404).json({ error: "not_found" });
2914
+ return res.json({ contact, subscription, recentEvents, recentSends, activeRuns });
2915
+ })
2916
+ );
2917
+ r.get(
2918
+ "/sends",
2919
+ asyncHandler(async (req, res) => {
2920
+ const limit = Math.min(Number(req.query.limit ?? 100), 500);
2921
+ const status = typeof req.query.status === "string" ? req.query.status : void 0;
2922
+ const filter = {};
2923
+ if (status) filter.status = status;
2924
+ const sends = await c.sends.find(filter).sort({ queuedAt: -1 }).limit(limit).toArray();
2925
+ res.json(sends);
2926
+ })
2927
+ );
2928
+ r.get(
2929
+ "/sends/:id",
2930
+ asyncHandler(async (req, res) => {
2931
+ const id = String(req.params.id);
2932
+ if (!mongodb.ObjectId.isValid(id)) return res.status(400).json({ error: "bad_id" });
2933
+ const send = await c.sends.findOne({ _id: new mongodb.ObjectId(id) });
2934
+ if (!send) return res.status(404).json({ error: "not_found" });
2935
+ const events = send.providerMessageId ? await c.webhookEvents.find({ providerMessageId: send.providerMessageId }).toArray() : [];
2936
+ return res.json({ send, webhookEvents: events });
2937
+ })
2938
+ );
2939
+ r.get(
2940
+ "/suppressions",
2941
+ asyncHandler(async (_req, res) => {
2942
+ const rows = await c.suppressions.find().sort({ addedAt: -1 }).limit(500).toArray();
2943
+ res.json(rows);
2944
+ })
2945
+ );
2946
+ r.post(
2947
+ "/suppressions",
2948
+ asyncHandler(async (req, res) => {
2949
+ const { email, scope, reason, source, notes } = req.body ?? {};
2950
+ await mailer.suppress(email, { scope, reason, source, notes });
2951
+ await mailer.audit({
2952
+ actor: req.actor,
2953
+ action: "suppression.add",
2954
+ resource: { collection: "mailer_suppressions" },
2955
+ diffSummary: `${email} \u2192 ${scope}/${reason}`
2956
+ });
2957
+ res.json({ ok: true });
2958
+ })
2959
+ );
2960
+ r.get(
2961
+ "/audit",
2962
+ asyncHandler(async (_req, res) => {
2963
+ const rows = await c.auditLog.find().sort({ occurredAt: -1 }).limit(200).toArray();
2964
+ res.json(rows);
2965
+ })
2966
+ );
2967
+ r.get(
2968
+ "/health",
2969
+ asyncHandler(async (_req, res) => {
2970
+ const h = await c.health.findOne({ _id: "singleton" });
2971
+ res.json(
2972
+ h ?? {
2973
+ _id: "singleton",
2974
+ status: "healthy",
2975
+ windowStartedAt: new Date(Date.now() - 60 * 60 * 1e3),
2976
+ windowDurationMs: 60 * 60 * 1e3,
2977
+ counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
2978
+ rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
2979
+ }
2980
+ );
2981
+ })
2982
+ );
2983
+ r.post(
2984
+ "/health/resume",
2985
+ asyncHandler(async (req, res) => {
2986
+ await c.health.updateOne(
2987
+ { _id: "singleton" },
2988
+ { $set: { status: "healthy", manuallyResumedAt: /* @__PURE__ */ new Date(), updatedAt: /* @__PURE__ */ new Date() } }
2989
+ );
2990
+ await mailer.audit({
2991
+ actor: req.actor,
2992
+ action: "health.resume",
2993
+ resource: { collection: "mailer_health" }
2994
+ });
2995
+ res.json({ ok: true });
2996
+ })
2997
+ );
2998
+ r.post(
2999
+ "/flows",
3000
+ asyncHandler(async (req, res) => {
3001
+ const { slug, name, description, trigger, goal, audience } = req.body ?? {};
3002
+ if (!slug || !name || !trigger?.eventName) {
3003
+ return res.status(400).json({ error: "validation_failed", message: "slug, name, and trigger.eventName required" });
3004
+ }
3005
+ const now = /* @__PURE__ */ new Date();
3006
+ const doc = {
3007
+ slug,
3008
+ name,
3009
+ description: description ?? "",
3010
+ trigger: { type: "event", eventName: trigger.eventName, once: trigger.once !== false },
3011
+ enabled: false,
3012
+ steps: [],
3013
+ version: 0,
3014
+ draft: {
3015
+ steps: [],
3016
+ notes: "Initial draft",
3017
+ lastModifiedBy: req.actor ?? "unknown",
3018
+ lastModifiedAt: now
3019
+ },
3020
+ goal: goal ?? "activation",
3021
+ audience: audience ?? "",
3022
+ expectedVolumePerWeek: null,
3023
+ stats: { activeRuns: 0, completedRuns: 0, sendsTotal: 0, sendsLast7Days: 0 },
3024
+ lastTriggerScanAt: null,
3025
+ publishedAt: null,
3026
+ publishedBy: null,
3027
+ createdAt: now,
3028
+ updatedAt: now
3029
+ };
3030
+ try {
3031
+ await c.flows.insertOne(doc);
3032
+ } catch (err) {
3033
+ if (err?.code === 11e3) return res.status(409).json({ error: "slug_taken" });
3034
+ throw err;
3035
+ }
3036
+ await mailer.audit({
3037
+ actor: req.actor,
3038
+ action: "flow.create",
3039
+ resource: { collection: "mailer_flows", slug }
3040
+ });
3041
+ return res.json({ ok: true, slug });
3042
+ })
3043
+ );
3044
+ r.patch(
3045
+ "/flows/:slug/draft",
3046
+ asyncHandler(async (req, res) => {
3047
+ const flow = await c.flows.findOne({ slug: req.params.slug });
3048
+ if (!flow) return res.status(404).json({ error: "not_found" });
3049
+ const { steps, notes, trigger, name, description, goal, audience } = req.body ?? {};
3050
+ const set = {
3051
+ "draft.lastModifiedBy": req.actor,
3052
+ "draft.lastModifiedAt": /* @__PURE__ */ new Date(),
3053
+ updatedAt: /* @__PURE__ */ new Date()
3054
+ };
3055
+ if (Array.isArray(steps)) set["draft.steps"] = steps;
3056
+ if (typeof notes === "string") set["draft.notes"] = notes;
3057
+ if (trigger?.eventName) set.trigger = { type: "event", eventName: trigger.eventName, once: trigger.once !== false };
3058
+ if (typeof name === "string") set.name = name;
3059
+ if (typeof description === "string") set.description = description;
3060
+ if (typeof goal === "string") set.goal = goal;
3061
+ if (typeof audience === "string") set.audience = audience;
3062
+ await c.flows.updateOne({ _id: flow._id }, { $set: set });
3063
+ await mailer.audit({
3064
+ actor: req.actor,
3065
+ action: "flow.draft.update",
3066
+ resource: { collection: "mailer_flows", id: flow._id, slug: flow.slug },
3067
+ diffSummary: `Updated draft (${steps ? `${steps.length} steps` : "metadata only"})`
3068
+ });
3069
+ return res.json({ ok: true });
3070
+ })
3071
+ );
3072
+ r.post(
3073
+ "/flows/:slug/publish",
3074
+ asyncHandler(async (req, res) => {
3075
+ const flow = await c.flows.findOne({ slug: req.params.slug });
3076
+ if (!flow) return res.status(404).json({ error: "not_found" });
3077
+ const draftSteps = flow.draft?.steps ?? flow.steps;
3078
+ if (!Array.isArray(draftSteps) || draftSteps.length === 0) {
3079
+ return res.status(400).json({ error: "empty_flow", message: "flow has no steps to publish" });
3080
+ }
3081
+ const nextVersion = (flow.version ?? 0) + 1;
3082
+ const now = /* @__PURE__ */ new Date();
3083
+ await c.flowVersions.insertOne({
3084
+ flowId: flow._id,
3085
+ version: nextVersion,
3086
+ steps: draftSteps,
3087
+ trigger: flow.trigger,
3088
+ publishedAt: now,
3089
+ publishedBy: req.actor
3090
+ });
3091
+ await c.flows.updateOne(
3092
+ { _id: flow._id },
3093
+ {
3094
+ $set: {
3095
+ steps: draftSteps,
3096
+ version: nextVersion,
3097
+ enabled: true,
3098
+ draft: null,
3099
+ publishedAt: now,
3100
+ publishedBy: req.actor,
3101
+ updatedAt: now
3102
+ }
3103
+ }
3104
+ );
3105
+ await mailer.audit({
3106
+ actor: req.actor,
3107
+ action: "flow.publish",
3108
+ resource: { collection: "mailer_flows", id: flow._id, slug: flow.slug },
3109
+ diffSummary: `Published v${nextVersion}`
3110
+ });
3111
+ return res.json({ ok: true, version: nextVersion });
3112
+ })
3113
+ );
3114
+ r.delete(
3115
+ "/flows/:slug",
3116
+ asyncHandler(async (req, res) => {
3117
+ const flow = await c.flows.findOne({ slug: req.params.slug });
3118
+ if (!flow) return res.status(404).json({ error: "not_found" });
3119
+ const everRan = await c.flowRuns.countDocuments({ flowId: flow._id }, { limit: 1 });
3120
+ if (everRan > 0) return res.status(409).json({ error: "has_runs", message: "flow has runs \u2014 pause it instead" });
3121
+ await c.flows.deleteOne({ _id: flow._id });
3122
+ await mailer.audit({
3123
+ actor: req.actor,
3124
+ action: "flow.delete",
3125
+ resource: { collection: "mailer_flows", id: flow._id, slug: flow.slug }
3126
+ });
3127
+ return res.json({ ok: true });
3128
+ })
3129
+ );
3130
+ r.post(
3131
+ "/templates",
3132
+ asyncHandler(async (req, res) => {
3133
+ const { slug, name, kind, subject, preheader, fromName, fromEmail } = req.body ?? {};
3134
+ if (!slug || !name || !kind) {
3135
+ return res.status(400).json({ error: "validation_failed", message: "slug, name, kind required" });
3136
+ }
3137
+ if (kind !== "marketing" && kind !== "transactional") {
3138
+ return res.status(400).json({ error: "validation_failed", message: "kind must be marketing or transactional" });
3139
+ }
3140
+ const now = /* @__PURE__ */ new Date();
3141
+ try {
3142
+ await c.templates.insertOne({
3143
+ slug,
3144
+ name,
3145
+ description: "",
3146
+ kind,
3147
+ fromName: fromName ?? mailer.config.fromDefaults?.name ?? "Mailery",
3148
+ fromEmail: fromEmail ?? mailer.config.fromDefaults?.email ?? "noreply@example.com",
3149
+ replyTo: null,
3150
+ providerOverride: null,
3151
+ subject: subject ?? `Untitled \u2014 ${name}`,
3152
+ preheader: preheader ?? "",
3153
+ body: { mjml: "", editorJson: null, html: "", plainText: "", compiledAt: null },
3154
+ variablesSchema: {},
3155
+ draft: {
3156
+ subject: subject ?? `Untitled \u2014 ${name}`,
3157
+ preheader: preheader ?? "",
3158
+ mjml: "",
3159
+ editorJson: null,
3160
+ notes: "Initial draft",
3161
+ lastModifiedBy: req.actor,
3162
+ lastModifiedAt: now
3163
+ },
3164
+ tags: [],
3165
+ trackOpens: kind === "marketing",
3166
+ trackClicks: kind === "marketing",
3167
+ stats: { sent: 0, delivered: 0, opened: 0, clicked: 0, bounced: 0, complained: 0, unsubscribed: 0, lastSentAt: null },
3168
+ publishedAt: null,
3169
+ publishedBy: null,
3170
+ createdAt: now,
3171
+ updatedAt: now
3172
+ });
3173
+ } catch (err) {
3174
+ if (err?.code === 11e3) return res.status(409).json({ error: "slug_taken" });
3175
+ throw err;
3176
+ }
3177
+ await mailer.audit({
3178
+ actor: req.actor,
3179
+ action: "template.create",
3180
+ resource: { collection: "mailer_templates", slug }
3181
+ });
3182
+ return res.json({ ok: true, slug });
3183
+ })
3184
+ );
3185
+ r.patch(
3186
+ "/templates/:slug/draft",
3187
+ asyncHandler(async (req, res) => {
3188
+ const tpl = await c.templates.findOne({ slug: req.params.slug });
3189
+ if (!tpl) return res.status(404).json({ error: "not_found" });
3190
+ const { subject, preheader, mjml, editorJson, notes, name, fromName, fromEmail, replyTo, kind, trackOpens, trackClicks } = req.body ?? {};
3191
+ const set = {
3192
+ "draft.lastModifiedBy": req.actor,
3193
+ "draft.lastModifiedAt": /* @__PURE__ */ new Date(),
3194
+ updatedAt: /* @__PURE__ */ new Date()
3195
+ };
3196
+ if (typeof subject === "string") set["draft.subject"] = subject;
3197
+ if (typeof preheader === "string") set["draft.preheader"] = preheader;
3198
+ if (typeof mjml === "string") set["draft.mjml"] = mjml;
3199
+ if (editorJson !== void 0) set["draft.editorJson"] = editorJson;
3200
+ if (typeof notes === "string") set["draft.notes"] = notes;
3201
+ if (typeof name === "string") set.name = name;
3202
+ if (typeof fromName === "string") set.fromName = fromName;
3203
+ if (typeof fromEmail === "string") set.fromEmail = fromEmail;
3204
+ if (typeof replyTo === "string" || replyTo === null) set.replyTo = replyTo;
3205
+ if (kind === "marketing" || kind === "transactional") set.kind = kind;
3206
+ if (typeof trackOpens === "boolean") set.trackOpens = trackOpens;
3207
+ if (typeof trackClicks === "boolean") set.trackClicks = trackClicks;
3208
+ await c.templates.updateOne({ _id: tpl._id }, { $set: set });
3209
+ await mailer.audit({
3210
+ actor: req.actor,
3211
+ action: "template.draft.update",
3212
+ resource: { collection: "mailer_templates", id: tpl._id, slug: tpl.slug }
3213
+ });
3214
+ return res.json({ ok: true });
3215
+ })
3216
+ );
3217
+ r.post(
3218
+ "/templates/:slug/publish",
3219
+ asyncHandler(async (req, res) => {
3220
+ const tpl = await c.templates.findOne({ slug: req.params.slug });
3221
+ if (!tpl) return res.status(404).json({ error: "not_found" });
3222
+ const draft = tpl.draft;
3223
+ if (!draft) return res.status(400).json({ error: "no_draft" });
3224
+ let compiled;
3225
+ if (draft.editorJson) {
3226
+ compiled = await compileMailyTemplate(draft.editorJson);
3227
+ } else if (draft.mjml) {
3228
+ compiled = await compileTemplate(draft.mjml);
3229
+ } else {
3230
+ return res.status(400).json({ error: "empty_draft", message: "draft has no MJML or editorJson content" });
3231
+ }
3232
+ const now = /* @__PURE__ */ new Date();
3233
+ const nextVersion = await c.templateVersions.countDocuments({ templateId: tpl._id }) + 1;
3234
+ await c.templateVersions.insertOne({
3235
+ templateId: tpl._id,
3236
+ version: nextVersion,
3237
+ mjml: draft.mjml,
3238
+ html: compiled.html,
3239
+ plainText: compiled.plainText,
3240
+ subject: draft.subject,
3241
+ preheader: draft.preheader,
3242
+ publishedAt: now,
3243
+ publishedBy: req.actor
3244
+ });
3245
+ await c.templates.updateOne(
3246
+ { _id: tpl._id },
3247
+ {
3248
+ $set: {
3249
+ subject: draft.subject,
3250
+ preheader: draft.preheader,
3251
+ body: {
3252
+ mjml: draft.mjml,
3253
+ editorJson: draft.editorJson,
3254
+ html: compiled.html,
3255
+ plainText: compiled.plainText,
3256
+ compiledAt: now
3257
+ },
3258
+ draft: null,
3259
+ publishedAt: now,
3260
+ publishedBy: req.actor,
3261
+ updatedAt: now
3262
+ }
3263
+ }
3264
+ );
3265
+ await mailer.audit({
3266
+ actor: req.actor,
3267
+ action: "template.publish",
3268
+ resource: { collection: "mailer_templates", id: tpl._id, slug: tpl.slug },
3269
+ diffSummary: `Published v${nextVersion}`
3270
+ });
3271
+ return res.json({ ok: true, version: nextVersion, warnings: compiled.errors });
3272
+ })
3273
+ );
3274
+ r.post(
3275
+ "/templates/:slug/preview",
3276
+ asyncHandler(async (req, res) => {
3277
+ const tpl = await c.templates.findOne({ slug: req.params.slug });
3278
+ if (!tpl) return res.status(404).json({ error: "not_found" });
3279
+ const useDraft = req.body?.useDraft !== false;
3280
+ let html = "";
3281
+ let plainText = "";
3282
+ if (useDraft && tpl.draft) {
3283
+ const compiled = tpl.draft.editorJson ? await compileMailyTemplate(tpl.draft.editorJson) : await compileTemplate(tpl.draft.mjml || "<mjml><mj-body></mj-body></mjml>");
3284
+ html = compiled.html;
3285
+ plainText = compiled.plainText;
3286
+ } else {
3287
+ html = tpl.body.html;
3288
+ plainText = tpl.body.plainText;
3289
+ }
3290
+ const sampleContact = req.body?.sampleContact ?? {
3291
+ externalId: "preview-contact",
3292
+ email: "preview@example.com",
3293
+ tags: [],
3294
+ fields: { firstName: "Alex" }
3295
+ };
3296
+ const renderCtx = {
3297
+ contact: sampleContact,
3298
+ vars: req.body?.vars ?? {},
3299
+ unsubscribeUrl: `${mailer.config.publicUrl}/m/unsub/preview`,
3300
+ senderAddress: mailer.config.senderAddress
3301
+ };
3302
+ const previewTpl = { ...tpl, body: { ...tpl.body, html, plainText } };
3303
+ const rendered = await renderTemplate(previewTpl, renderCtx, { helpers: mailer.config.handlebarsHelpers });
3304
+ return res.json({ subject: rendered.subject, preheader: rendered.preheader, html: rendered.html, plainText: rendered.plainText });
3305
+ })
3306
+ );
3307
+ r.post(
3308
+ "/templates/:slug/send-test",
3309
+ asyncHandler(async (req, res) => {
3310
+ const { to, sampleData } = req.body ?? {};
3311
+ if (!to) return res.status(400).json({ error: "to_required" });
3312
+ const tpl = await c.templates.findOne({ slug: req.params.slug });
3313
+ if (!tpl) return res.status(404).json({ error: "not_found" });
3314
+ const contact = sampleData?.contact ?? {
3315
+ externalId: "test-recipient",
3316
+ email: to,
3317
+ tags: [],
3318
+ fields: { firstName: "Test" }
3319
+ };
3320
+ contact.email = to;
3321
+ const renderCtx = {
3322
+ contact,
3323
+ vars: sampleData?.vars ?? {},
3324
+ unsubscribeUrl: `${mailer.config.publicUrl}/m/unsub/test`,
3325
+ senderAddress: mailer.config.senderAddress
3326
+ };
3327
+ const rendered = await renderTemplate(tpl, renderCtx, { helpers: mailer.config.handlebarsHelpers });
3328
+ const tracking = applyTracking(rendered.html, {
3329
+ sendId: `test-${Date.now()}`,
3330
+ publicUrl: mailer.config.publicUrl,
3331
+ trackOpens: false,
3332
+ trackClicks: false
3333
+ });
3334
+ const provider = mailer.providers[tpl.providerOverride ?? mailer.config.defaultProvider];
3335
+ const result = await provider.send({
3336
+ to,
3337
+ fromName: rendered.fromName,
3338
+ fromEmail: rendered.fromEmail,
3339
+ subject: `[TEST] ${rendered.subject}`,
3340
+ html: tracking.html,
3341
+ text: rendered.plainText
3342
+ });
3343
+ await mailer.audit({
3344
+ actor: req.actor,
3345
+ action: "template.send-test",
3346
+ resource: { collection: "mailer_templates", slug: tpl.slug },
3347
+ diffSummary: `to=${to}`
3348
+ });
3349
+ return res.json({ ok: true, providerId: result.providerId });
3350
+ })
3351
+ );
3352
+ r.post(
3353
+ "/broadcasts",
3354
+ asyncHandler(async (req, res) => {
3355
+ const { slug, name, templateSlug, segmentDefinition } = req.body ?? {};
3356
+ if (!slug || !name || !templateSlug) {
3357
+ return res.status(400).json({ error: "validation_failed", message: "slug, name, templateSlug required" });
3358
+ }
3359
+ const now = /* @__PURE__ */ new Date();
3360
+ try {
3361
+ await c.broadcasts.insertOne({
3362
+ slug,
3363
+ name,
3364
+ templateSlug,
3365
+ segmentDefinition: segmentDefinition ?? { filters: [{ kind: "subscriptionStatus", equals: "subscribed" }] },
3366
+ status: "draft",
3367
+ scheduledAt: null,
3368
+ startedAt: null,
3369
+ completedAt: null,
3370
+ confirmationRequired: true,
3371
+ confirmedCount: null,
3372
+ confirmedAt: null,
3373
+ confirmedBy: null,
3374
+ recipientCount: null,
3375
+ stats: { sent: 0, delivered: 0, opened: 0, clicked: 0, bounced: 0, complained: 0, unsubscribed: 0 },
3376
+ createdAt: now,
3377
+ createdBy: req.actor,
3378
+ updatedAt: now
3379
+ });
3380
+ } catch (err) {
3381
+ if (err?.code === 11e3) return res.status(409).json({ error: "slug_taken" });
3382
+ throw err;
3383
+ }
3384
+ await mailer.audit({
3385
+ actor: req.actor,
3386
+ action: "broadcast.create",
3387
+ resource: { collection: "mailer_broadcasts", slug }
3388
+ });
3389
+ return res.json({ ok: true, slug });
3390
+ })
3391
+ );
3392
+ r.patch(
3393
+ "/broadcasts/:slug",
3394
+ asyncHandler(async (req, res) => {
3395
+ const b = await c.broadcasts.findOne({ slug: req.params.slug });
3396
+ if (!b) return res.status(404).json({ error: "not_found" });
3397
+ if (b.status !== "draft") return res.status(409).json({ error: "not_draft" });
3398
+ const { name, templateSlug, segmentDefinition } = req.body ?? {};
3399
+ const set = { updatedAt: /* @__PURE__ */ new Date() };
3400
+ if (typeof name === "string") set.name = name;
3401
+ if (typeof templateSlug === "string") set.templateSlug = templateSlug;
3402
+ if (segmentDefinition) set.segmentDefinition = segmentDefinition;
3403
+ await c.broadcasts.updateOne({ _id: b._id }, { $set: set });
3404
+ return res.json({ ok: true });
3405
+ })
3406
+ );
3407
+ r.post(
3408
+ "/broadcasts/:slug/segment/count",
3409
+ asyncHandler(async (req, res) => {
3410
+ const segmentDefinition = req.body?.segmentDefinition;
3411
+ if (!segmentDefinition?.filters) return res.status(400).json({ error: "segment_required" });
3412
+ const t0 = Date.now();
3413
+ const hostFilter = {};
3414
+ for (const f of segmentDefinition.filters) {
3415
+ if (f.kind === "hasTag") hostFilter.hasTag = f.tag;
3416
+ if (f.kind === "fieldEquals") hostFilter.fieldEquals = { field: f.field, value: f.value };
3417
+ }
3418
+ const stageA = await mailer.adapter.count(hostFilter);
3419
+ return res.json({ stageA, stageB: stageA, afterSuppression: stageA, computedMs: Date.now() - t0 });
3420
+ })
3421
+ );
3422
+ r.post(
3423
+ "/broadcasts/:slug/schedule",
3424
+ asyncHandler(async (req, res) => {
3425
+ const b = await c.broadcasts.findOne({ slug: req.params.slug });
3426
+ if (!b) return res.status(404).json({ error: "not_found" });
3427
+ if (b.status !== "draft") return res.status(409).json({ error: "not_draft" });
3428
+ const { scheduledAt, confirmedCount, respectRecipientTimezone } = req.body ?? {};
3429
+ if (!scheduledAt) return res.status(400).json({ error: "scheduledAt_required" });
3430
+ const scheduled = new Date(scheduledAt);
3431
+ if (Number.isNaN(scheduled.getTime())) return res.status(400).json({ error: "bad_scheduledAt" });
3432
+ const threshold = mailer.config.broadcastConfirmationThreshold;
3433
+ if (typeof confirmedCount !== "number") {
3434
+ return res.status(400).json({ error: "confirmedCount_required" });
3435
+ }
3436
+ const set = {
3437
+ status: "scheduled",
3438
+ scheduledAt: scheduled,
3439
+ confirmedCount,
3440
+ confirmedAt: /* @__PURE__ */ new Date(),
3441
+ confirmedBy: req.actor,
3442
+ updatedAt: /* @__PURE__ */ new Date()
3443
+ };
3444
+ if (respectRecipientTimezone) set.respectRecipientTimezone = true;
3445
+ await c.broadcasts.updateOne({ _id: b._id }, { $set: set });
3446
+ await mailer.audit({
3447
+ actor: req.actor,
3448
+ action: "broadcast.schedule",
3449
+ resource: { collection: "mailer_broadcasts", id: b._id, slug: b.slug },
3450
+ diffSummary: `scheduled at ${scheduled.toISOString()} \xB7 confirmedCount=${confirmedCount} \xB7 threshold=${threshold}`
3451
+ });
3452
+ return res.json({ ok: true });
3453
+ })
3454
+ );
3455
+ r.post(
3456
+ "/broadcasts/:slug/cancel",
3457
+ asyncHandler(async (req, res) => {
3458
+ const b = await c.broadcasts.findOne({ slug: req.params.slug });
3459
+ if (!b) return res.status(404).json({ error: "not_found" });
3460
+ await c.broadcasts.updateOne({ _id: b._id }, { $set: { status: "cancelled", updatedAt: /* @__PURE__ */ new Date() } });
3461
+ await mailer.audit({
3462
+ actor: req.actor,
3463
+ action: "broadcast.cancel",
3464
+ resource: { collection: "mailer_broadcasts", id: b._id, slug: b.slug }
3465
+ });
3466
+ return res.json({ ok: true });
3467
+ })
3468
+ );
3469
+ r.use((_req, res) => res.status(404).json({ error: "not_found" }));
3470
+ return r;
3471
+ }
3472
+ function asyncHandler(fn) {
3473
+ return (req, res, next) => {
3474
+ fn(req, res, next).catch(next);
3475
+ };
3476
+ }
3477
+ var PIXEL = Buffer.from(
3478
+ "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
3479
+ "base64"
3480
+ );
3481
+ function createPublicRouter(mailer, opts = {}) {
3482
+ const router = express.Router();
3483
+ const pendingUnsubsPath = opts.pendingUnsubsPath ?? "/tmp/mailery-pending-unsubs.jsonl";
3484
+ router.use(
3485
+ "/webhooks",
3486
+ express__default.default.json({
3487
+ limit: "5mb",
3488
+ verify: (req, _res, buf) => {
3489
+ req.rawBody = buf;
3490
+ }
3491
+ })
3492
+ );
3493
+ router.use("/unsub", express__default.default.urlencoded({ extended: false }));
3494
+ router.get("/open/:sendId.png", async (req, res) => {
3495
+ res.setHeader("Content-Type", "image/png");
3496
+ res.setHeader("Content-Length", String(PIXEL.length));
3497
+ res.setHeader("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0");
3498
+ res.status(200).end(PIXEL);
3499
+ const id = req.params.sendId;
3500
+ if (!mongodb.ObjectId.isValid(id)) return;
3501
+ const sendId = new mongodb.ObjectId(id);
3502
+ try {
3503
+ const send = await mailer.collections.sends.findOne({ _id: sendId }, { projection: { openedAt: 1 } });
3504
+ if (!send) return;
3505
+ await mailer.collections.sends.updateOne(
3506
+ { _id: sendId },
3507
+ {
3508
+ $set: {
3509
+ openedAt: send.openedAt ?? /* @__PURE__ */ new Date(),
3510
+ status: "delivered"
3511
+ },
3512
+ $inc: { openCount: 1 }
3513
+ }
3514
+ );
3515
+ } catch (err) {
3516
+ console.error("mailery: open pixel update failed", err);
3517
+ }
3518
+ });
3519
+ router.get("/click/:sendId/:linkId", async (req, res) => {
3520
+ const { sendId: sendIdStr, linkId } = req.params;
3521
+ if (!mongodb.ObjectId.isValid(sendIdStr)) return res.status(400).end();
3522
+ const sendId = new mongodb.ObjectId(sendIdStr);
3523
+ const send = await mailer.collections.sends.findOne(
3524
+ { _id: sendId },
3525
+ { projection: { links: 1, firstClickAt: 1 } }
3526
+ );
3527
+ if (!send) return res.status(404).end();
3528
+ const link = (send.links ?? []).find((l) => l.linkId === linkId);
3529
+ if (!link) return res.status(404).end();
3530
+ res.redirect(302, link.url);
3531
+ try {
3532
+ await mailer.collections.sends.updateOne(
3533
+ { _id: sendId },
3534
+ {
3535
+ $set: { firstClickAt: send.firstClickAt ?? /* @__PURE__ */ new Date() },
3536
+ $inc: { clickCount: 1 },
3537
+ $push: {
3538
+ clickedLinks: {
3539
+ url: link.url,
3540
+ linkId,
3541
+ clickedAt: /* @__PURE__ */ new Date()
3542
+ }
3543
+ }
3544
+ }
3545
+ );
3546
+ } catch (err) {
3547
+ console.error("mailery: click recording failed", err);
3548
+ }
3549
+ });
3550
+ router.get("/unsub/:token", (req, res) => {
3551
+ const decoded = verifyUnsubscribeToken(req.params.token, mailer.config.unsubscribeSecret);
3552
+ if (!decoded) return sendUnsubError(res, "Invalid or expired link.");
3553
+ res.status(200).type("html").send(`<!doctype html>
3554
+ <html><head>
3555
+ <meta charset="utf-8" />
3556
+ <title>Unsubscribe</title>
3557
+ <style>body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:48px auto;padding:0 16px;color:#1c1917;line-height:1.5}h1{font-size:20px}button{padding:10px 18px;background:#dc2626;color:#fff;border:0;border-radius:6px;font-size:14px;cursor:pointer}</style>
3558
+ </head><body>
3559
+ <h1>Confirm unsubscribe</h1>
3560
+ <p>Click the button below to unsubscribe <strong>${escapeHtml(decoded.email)}</strong>${decoded.scope === "all" ? " from everything" : " from marketing emails"}.</p>
3561
+ <form method="POST" action="${escapeHtml(req.originalUrl)}">
3562
+ <button type="submit">Unsubscribe</button>
3563
+ </form>
3564
+ </body></html>`);
3565
+ });
3566
+ router.post("/unsub/:token", async (req, res) => {
3567
+ const decoded = verifyUnsubscribeToken(req.params.token, mailer.config.unsubscribeSecret);
3568
+ if (!decoded) {
3569
+ res.status(200).end();
3570
+ return;
3571
+ }
3572
+ res.status(200).type("html").send("<!doctype html><html><body><p>You are unsubscribed.</p></body></html>");
3573
+ try {
3574
+ await mailer.unsubscribe(decoded.email, {
3575
+ scope: decoded.scope,
3576
+ reason: "user_request",
3577
+ source: "one-click"
3578
+ });
3579
+ } catch (err) {
3580
+ try {
3581
+ fs__default.default.appendFileSync(
3582
+ pendingUnsubsPath,
3583
+ JSON.stringify({ email: decoded.email, scope: decoded.scope, at: Date.now() }) + "\n"
3584
+ );
3585
+ } catch (diskErr) {
3586
+ console.error("mailery: unsub disk fallback failed", { err, diskErr });
3587
+ }
3588
+ }
3589
+ });
3590
+ router.get("/confirm-doi/:token", async (req, res) => {
3591
+ const token = req.params.token;
3592
+ const decoded = verifyDoiToken(token, mailer.config.unsubscribeSecret);
3593
+ if (!decoded) {
3594
+ return res.status(400).type("html").send("<!doctype html><html><body><p>Confirmation link is invalid or expired.</p></body></html>");
3595
+ }
3596
+ const now = /* @__PURE__ */ new Date();
3597
+ const result = await mailer.collections.subscriptions.updateOne(
3598
+ { externalId: decoded.externalId, status: "pending_doi" },
3599
+ {
3600
+ $set: {
3601
+ status: "subscribed",
3602
+ subscribedAt: now,
3603
+ doiConfirmedAt: now,
3604
+ doiIp: req.ip ?? null,
3605
+ doiUserAgent: req.headers["user-agent"] ?? null,
3606
+ updatedAt: now
3607
+ }
3608
+ }
3609
+ );
3610
+ if (result.matchedCount === 0) {
3611
+ return res.status(200).type("html").send("<!doctype html><html><body><p>Already confirmed. Thanks.</p></body></html>");
3612
+ }
3613
+ try {
3614
+ await mailer.fire("subscription.confirmed", decoded.externalId, {}, `doi-confirmed:${decoded.externalId}`);
3615
+ } catch {
3616
+ }
3617
+ return res.status(200).type("html").send("<!doctype html><html><body><p>Thanks \u2014 you're subscribed.</p></body></html>");
3618
+ });
3619
+ router.post("/webhooks/:provider", async (req, res) => {
3620
+ const providerName = req.params.provider;
3621
+ const provider = mailer.providers[providerName];
3622
+ if (!provider) return res.status(404).end();
3623
+ const rawBody = req.rawBody;
3624
+ if (!rawBody) return res.status(400).end();
3625
+ const headers = lowercaseHeaders(req.headers);
3626
+ const valid = await provider.verifyWebhook(rawBody, headers);
3627
+ if (!valid) return res.status(401).end();
3628
+ const events = provider.parseWebhookEvents(req.body, headers);
3629
+ res.status(200).end();
3630
+ const rawBodyRef = req.body;
3631
+ for (const evt of events) {
3632
+ try {
3633
+ await mailer.collections.webhookEvents.updateOne(
3634
+ { provider: providerName, providerEventId: evt.providerEventId },
3635
+ {
3636
+ $setOnInsert: {
3637
+ provider: providerName,
3638
+ providerEventId: evt.providerEventId,
3639
+ eventType: evt.type,
3640
+ normalizedType: evt.type,
3641
+ providerMessageId: evt.providerMessageId,
3642
+ email: evt.email,
3643
+ occurredAt: evt.occurredAt,
3644
+ receivedAt: /* @__PURE__ */ new Date(),
3645
+ processed: false,
3646
+ raw: { normalized: evt, providerBody: rawBodyRef }
3647
+ }
3648
+ },
3649
+ { upsert: true }
3650
+ );
3651
+ } catch (err) {
3652
+ console.error("mailery: webhook dedupe insert failed", err);
3653
+ }
3654
+ }
3655
+ if (events.length > 0) {
3656
+ try {
3657
+ await mailer.queues.webhook.add("webhook", { provider: providerName });
3658
+ } catch {
3659
+ }
3660
+ }
3661
+ });
3662
+ return router;
3663
+ }
3664
+ function sendUnsubError(res, msg) {
3665
+ return res.status(400).type("html").send(`<!doctype html><html><body><p>${escapeHtml(msg)}</p></body></html>`);
3666
+ }
3667
+ function escapeHtml(s) {
3668
+ return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
3669
+ }
3670
+ function lowercaseHeaders(h) {
3671
+ const out = {};
3672
+ for (const k of Object.keys(h)) {
3673
+ const v = h[k];
3674
+ out[k.toLowerCase()] = Array.isArray(v) ? v.join(",") : String(v ?? "");
3675
+ }
3676
+ return out;
3677
+ }
3678
+
3679
+ // src/server/index.ts
3680
+ var VERSION = "0.1.0";
3681
+
3682
+ exports.Mailer = Mailer;
3683
+ exports.NullProvider = NullProvider;
6
3684
  exports.VERSION = VERSION;
3685
+ exports.applyTracking = applyTracking;
3686
+ exports.compileTemplate = compileTemplate;
3687
+ exports.createAdminRouter = createAdminRouter;
3688
+ exports.createPublicRouter = createPublicRouter;
3689
+ exports.derivePlaintext = derivePlaintext;
3690
+ exports.ensureIndexes = ensureIndexes;
3691
+ exports.getCollections = getCollections;
3692
+ exports.renderTemplate = renderTemplate;
3693
+ exports.sha256Hex = sha256Hex;
3694
+ exports.signUnsubscribeToken = signUnsubscribeToken;
3695
+ exports.verifyUnsubscribeToken = verifyUnsubscribeToken;
7
3696
  //# sourceMappingURL=index.cjs.map
8
3697
  //# sourceMappingURL=index.cjs.map