mailery 0.0.0 → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +103 -11
- package/dist/admin/spa/index-B6riKdpB.css +1 -0
- package/dist/admin/spa/index-C_FJL_E_.js +41 -0
- package/dist/admin/spa/index-C_FJL_E_.js.map +1 -0
- package/dist/admin/spa/index.html +16 -0
- package/dist/admin/spa/template-editor-Cx1ky7rr.js +502 -0
- package/dist/admin/spa/template-editor-Cx1ky7rr.js.map +1 -0
- package/dist/index.cjs +3691 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +242 -2
- package/dist/index.d.ts +242 -2
- package/dist/index.js +3667 -3
- package/dist/index.js.map +1 -1
- package/dist/null-7gnz1V98.d.cts +959 -0
- package/dist/null-7gnz1V98.d.ts +959 -0
- package/dist/testing.cjs +16565 -2
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +63 -2
- package/dist/testing.d.ts +63 -2
- package/dist/testing.js +16555 -2
- package/dist/testing.js.map +1 -1
- package/package.json +38 -5
- package/dist/admin/static/.gitkeep +0 -0
- package/dist/admin/views/.gitkeep +0 -0
package/dist/index.cjs
CHANGED
|
@@ -1,8 +1,3697 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
var
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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
|