mailery 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -831,6 +831,11 @@ function adaptBullQueue(q) {
831
831
  return {
832
832
  add: (name, data, opts) => q.add(name, data, opts),
833
833
  getWaitingCount: () => q.getWaitingCount(),
834
+ getInFlightCount: async () => {
835
+ const [active, waiting] = await Promise.all([q.getActiveCount(), q.getWaitingCount()]);
836
+ return active + waiting;
837
+ },
838
+ getDelayedCount: () => q.getDelayedCount(),
834
839
  close: () => q.close()
835
840
  };
836
841
  }
@@ -3261,43 +3266,131 @@ function createAdminRouter(mailer, opts = {}) {
3261
3266
  function apiRouter(mailer) {
3262
3267
  const r = express.Router();
3263
3268
  const c = mailer.collections;
3264
- r.get("/me", (req, res) => {
3265
- res.json({
3266
- actor: req.actor,
3267
- permissions: { canPublish: true, canSendBroadcasts: true, canManageSuppressions: true }
3268
- });
3269
- });
3269
+ r.get(
3270
+ "/me",
3271
+ asyncHandler(async (req, res) => {
3272
+ const [flows, templates, broadcasts, contacts, suppressions, health] = await Promise.all([
3273
+ c.flows.estimatedDocumentCount(),
3274
+ c.templates.estimatedDocumentCount(),
3275
+ c.broadcasts.estimatedDocumentCount(),
3276
+ c.subscriptions.countDocuments({ status: "subscribed" }),
3277
+ c.suppressions.estimatedDocumentCount(),
3278
+ c.health.findOne({ _id: "singleton" })
3279
+ ]);
3280
+ res.json({
3281
+ actor: req.actor,
3282
+ counts: { flows, templates, broadcasts, contacts, suppressions },
3283
+ health: { status: health?.status ?? null },
3284
+ providers: {
3285
+ names: Object.keys(mailer.config.providers),
3286
+ default: mailer.config.defaultProvider
3287
+ },
3288
+ broadcastConfirmationThreshold: mailer.config.broadcastConfirmationThreshold
3289
+ });
3290
+ })
3291
+ );
3270
3292
  r.get(
3271
3293
  "/dashboard",
3272
3294
  asyncHandler(async (_req, res) => {
3273
- const since24h = new Date(Date.now() - 24 * 60 * 60 * 1e3);
3274
- const [sentTotal, deliveredCount, bouncedCount, openedCount, clickedCount] = await Promise.all([
3295
+ const now = Date.now();
3296
+ const HOUR = 60 * 60 * 1e3;
3297
+ const since24h = new Date(now - 24 * HOUR);
3298
+ const since48h = new Date(now - 48 * HOUR);
3299
+ const [
3300
+ sentTotal,
3301
+ deliveredCount,
3302
+ bouncedCount,
3303
+ openedCount,
3304
+ clickedCount,
3305
+ sentPrev,
3306
+ deliveredPrev,
3307
+ openedPrev,
3308
+ clickedPrev,
3309
+ hourly
3310
+ ] = await Promise.all([
3275
3311
  c.sends.countDocuments({ queuedAt: { $gt: since24h } }),
3276
3312
  c.sends.countDocuments({ queuedAt: { $gt: since24h }, status: "delivered" }),
3277
3313
  c.sends.countDocuments({ queuedAt: { $gt: since24h }, status: "bounced" }),
3278
3314
  c.sends.countDocuments({ queuedAt: { $gt: since24h }, openedAt: { $ne: null } }),
3279
- c.sends.countDocuments({ queuedAt: { $gt: since24h }, firstClickAt: { $ne: null } })
3315
+ c.sends.countDocuments({ queuedAt: { $gt: since24h }, firstClickAt: { $ne: null } }),
3316
+ c.sends.countDocuments({ queuedAt: { $gt: since48h, $lte: since24h } }),
3317
+ c.sends.countDocuments({ queuedAt: { $gt: since48h, $lte: since24h }, status: "delivered" }),
3318
+ c.sends.countDocuments({ queuedAt: { $gt: since48h, $lte: since24h }, openedAt: { $ne: null } }),
3319
+ c.sends.countDocuments({ queuedAt: { $gt: since48h, $lte: since24h }, firstClickAt: { $ne: null } }),
3320
+ c.sends.aggregate([
3321
+ { $match: { queuedAt: { $gt: since24h } } },
3322
+ {
3323
+ $project: {
3324
+ hour: {
3325
+ $toInt: {
3326
+ $divide: [{ $subtract: [now, { $toLong: "$queuedAt" }] }, HOUR]
3327
+ }
3328
+ },
3329
+ opened: { $cond: [{ $ifNull: ["$openedAt", false] }, 1, 0] }
3330
+ }
3331
+ },
3332
+ { $group: { _id: "$hour", sends: { $sum: 1 }, opens: { $sum: "$opened" } } }
3333
+ ]).toArray()
3280
3334
  ]);
3335
+ const sendSeries = new Array(24).fill(0);
3336
+ const openSeries = new Array(24).fill(0);
3337
+ for (const row of hourly) {
3338
+ const idx = 23 - Math.max(0, Math.min(23, row._id));
3339
+ sendSeries[idx] = row.sends;
3340
+ openSeries[idx] = row.opens;
3341
+ }
3342
+ const delta = (cur, prev) => {
3343
+ if (prev === 0) return null;
3344
+ return (cur - prev) / prev;
3345
+ };
3346
+ const rateDelta = (curN, curD, prevN, prevD) => {
3347
+ if (prevD === 0 || curD === 0) return null;
3348
+ return curN / curD - prevN / prevD;
3349
+ };
3281
3350
  const health = await c.health.findOne({ _id: "singleton" });
3282
- const recentFlows = await c.flows.find({ enabled: true }).limit(5).toArray();
3351
+ const recentFlowsRaw = await c.flows.find({ enabled: true }).limit(5).toArray();
3352
+ const flowStatsMap = await computeFlowStats(mailer);
3353
+ const recentFlows = recentFlowsRaw.map((f) => ({ ...f, stats: flowStatsMap.get(f.slug) ?? emptyFlowStats() }));
3283
3354
  const recentSends = await c.sends.find().sort({ queuedAt: -1 }).limit(6).toArray();
3284
3355
  const recentAudit = await c.auditLog.find().sort({ occurredAt: -1 }).limit(5).toArray();
3356
+ const queueCounts = await collectQueueCounts(mailer);
3357
+ const lastSendError = await c.sends.findOne({ status: { $in: ["bounced", "failed"] } }, { sort: { queuedAt: -1 } });
3358
+ const lastSendOk = await c.sends.findOne({ status: "delivered" }, { sort: { queuedAt: -1 } });
3359
+ const providerOk = lastSendOk && lastSendError ? new Date(lastSendOk.queuedAt).getTime() >= new Date(lastSendError.queuedAt).getTime() : lastSendOk ? true : lastSendError ? false : null;
3285
3360
  res.json({
3286
3361
  kpis: {
3287
- sends: { value: sentTotal, delta: null },
3362
+ sends: { value: sentTotal, delta: delta(sentTotal, sentPrev) },
3288
3363
  deliveredRate: {
3289
- value: sentTotal === 0 ? 1 : deliveredCount / sentTotal,
3290
- delta: null,
3364
+ value: sentTotal === 0 ? null : deliveredCount / sentTotal,
3365
+ delta: rateDelta(deliveredCount, sentTotal, deliveredPrev, sentPrev),
3291
3366
  bounced: bouncedCount
3292
3367
  },
3293
- openRate: { value: sentTotal === 0 ? 0 : openedCount / sentTotal, delta: null, exclBots: false },
3294
- clickRate: { value: sentTotal === 0 ? 0 : clickedCount / sentTotal, delta: null }
3368
+ openRate: {
3369
+ value: sentTotal === 0 ? null : openedCount / sentTotal,
3370
+ delta: rateDelta(openedCount, sentTotal, openedPrev, sentPrev)
3371
+ },
3372
+ clickRate: {
3373
+ value: sentTotal === 0 ? null : clickedCount / sentTotal,
3374
+ delta: rateDelta(clickedCount, sentTotal, clickedPrev, sentPrev)
3375
+ }
3376
+ },
3377
+ series: { hourly: { sends: sendSeries, opens: openSeries } },
3378
+ health: {
3379
+ status: health?.status ?? null,
3380
+ rates: health?.rates ?? null,
3381
+ thresholds: {
3382
+ hardBounceRatePctTrip: mailer.config.circuitBreaker.hardBounceRatePctTrip,
3383
+ complaintRatePctTrip: mailer.config.circuitBreaker.complaintRatePctTrip,
3384
+ combinedBounceRatePctTrip: mailer.config.circuitBreaker.combinedBounceRatePctTrip,
3385
+ failedToSendRatePctDegrade: mailer.config.circuitBreaker.failedToSendRatePctDegrade
3386
+ }
3295
3387
  },
3296
- health: health ? { status: health.status, rates: health.rates } : {
3297
- status: "healthy",
3298
- rates: { hardBounceRate: 0, complaintRate: 0, combinedBounceRate: 0, failureRate: 0 }
3388
+ queue: {
3389
+ inFlight: queueCounts?.inFlight ?? null,
3390
+ delayed: queueCounts?.delayed ?? null,
3391
+ providerOk,
3392
+ providerName: mailer.config.defaultProvider
3299
3393
  },
3300
- queue: { inFlight: 0, delayed: 0, providerOk: true, providerName: mailer.config.defaultProvider },
3301
3394
  recentFlows,
3302
3395
  recentSends,
3303
3396
  recentAudit
@@ -3308,7 +3401,8 @@ function apiRouter(mailer) {
3308
3401
  "/flows",
3309
3402
  asyncHandler(async (_req, res) => {
3310
3403
  const flows = await c.flows.find().sort({ updatedAt: -1 }).toArray();
3311
- res.json(flows);
3404
+ const stats = await computeFlowStats(mailer);
3405
+ res.json(flows.map((f) => ({ ...f, stats: stats.get(f.slug) ?? emptyFlowStats() })));
3312
3406
  })
3313
3407
  );
3314
3408
  r.get(
@@ -3316,7 +3410,8 @@ function apiRouter(mailer) {
3316
3410
  asyncHandler(async (req, res) => {
3317
3411
  const flow = await c.flows.findOne({ slug: req.params.slug });
3318
3412
  if (!flow) return res.status(404).json({ error: "not_found" });
3319
- return res.json(flow);
3413
+ const stats = (await computeFlowStats(mailer, flow.slug)).get(flow.slug) ?? emptyFlowStats();
3414
+ return res.json({ ...flow, stats });
3320
3415
  })
3321
3416
  );
3322
3417
  r.post(
@@ -3351,7 +3446,8 @@ function apiRouter(mailer) {
3351
3446
  "/templates",
3352
3447
  asyncHandler(async (_req, res) => {
3353
3448
  const templates = await c.templates.find().sort({ updatedAt: -1 }).toArray();
3354
- res.json(templates);
3449
+ const stats = await computeTemplateStats(mailer);
3450
+ res.json(templates.map((t) => ({ ...t, stats: stats.get(t.slug) ?? emptyTemplateStats() })));
3355
3451
  })
3356
3452
  );
3357
3453
  r.get(
@@ -3359,14 +3455,16 @@ function apiRouter(mailer) {
3359
3455
  asyncHandler(async (req, res) => {
3360
3456
  const template = await c.templates.findOne({ slug: req.params.slug });
3361
3457
  if (!template) return res.status(404).json({ error: "not_found" });
3362
- return res.json(template);
3458
+ const stats = (await computeTemplateStats(mailer, template.slug)).get(template.slug) ?? emptyTemplateStats();
3459
+ return res.json({ ...template, stats });
3363
3460
  })
3364
3461
  );
3365
3462
  r.get(
3366
3463
  "/broadcasts",
3367
3464
  asyncHandler(async (_req, res) => {
3368
3465
  const broadcasts = await c.broadcasts.find().sort({ createdAt: -1 }).toArray();
3369
- res.json(broadcasts);
3466
+ const stats = await computeBroadcastStats(mailer);
3467
+ res.json(broadcasts.map((b) => ({ ...b, stats: stats.get(String(b._id)) ?? emptyBroadcastStats() })));
3370
3468
  })
3371
3469
  );
3372
3470
  r.get(
@@ -3374,7 +3472,8 @@ function apiRouter(mailer) {
3374
3472
  asyncHandler(async (req, res) => {
3375
3473
  const broadcast = await c.broadcasts.findOne({ slug: req.params.slug });
3376
3474
  if (!broadcast) return res.status(404).json({ error: "not_found" });
3377
- return res.json(broadcast);
3475
+ const stats = (await computeBroadcastStats(mailer, broadcast._id)).get(String(broadcast._id)) ?? emptyBroadcastStats();
3476
+ return res.json({ ...broadcast, stats });
3378
3477
  })
3379
3478
  );
3380
3479
  r.get(
@@ -3382,8 +3481,20 @@ function apiRouter(mailer) {
3382
3481
  asyncHandler(async (req, res) => {
3383
3482
  const cursor = typeof req.query.cursor === "string" ? req.query.cursor : void 0;
3384
3483
  const limit = Math.min(Number(req.query.limit ?? 50), 200);
3385
- const { contacts, nextCursor } = await mailer.adapter.query({}, { limit, cursor });
3386
- res.json({ contacts, nextCursor });
3484
+ const [{ contacts, nextCursor }, counts] = await Promise.all([
3485
+ mailer.adapter.query({}, { limit, cursor }),
3486
+ (async () => {
3487
+ const rows = await c.subscriptions.aggregate([{ $group: { _id: "$status", n: { $sum: 1 } } }]).toArray();
3488
+ const out = { subscribed: 0, pending_doi: 0, unsubscribed: 0, bounced: 0, complained: 0 };
3489
+ let total = 0;
3490
+ for (const r2 of rows) {
3491
+ if (r2._id) out[r2._id] = r2.n;
3492
+ total += r2.n;
3493
+ }
3494
+ return { ...out, total };
3495
+ })()
3496
+ ]);
3497
+ res.json({ contacts, nextCursor, counts });
3387
3498
  })
3388
3499
  );
3389
3500
  r.get(
@@ -3462,16 +3573,25 @@ function apiRouter(mailer) {
3462
3573
  "/health",
3463
3574
  asyncHandler(async (_req, res) => {
3464
3575
  const h = await c.health.findOne({ _id: "singleton" });
3465
- res.json(
3466
- h ?? {
3467
- _id: "singleton",
3468
- status: "healthy",
3469
- windowStartedAt: new Date(Date.now() - 60 * 60 * 1e3),
3470
- windowDurationMs: 60 * 60 * 1e3,
3471
- counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
3472
- rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
3473
- }
3474
- );
3576
+ const cb = mailer.config.circuitBreaker;
3577
+ const thresholds = {
3578
+ hardBounceRatePctTrip: cb.hardBounceRatePctTrip,
3579
+ complaintRatePctTrip: cb.complaintRatePctTrip,
3580
+ combinedBounceRatePctTrip: cb.combinedBounceRatePctTrip,
3581
+ failedToSendRatePctDegrade: cb.failedToSendRatePctDegrade
3582
+ };
3583
+ if (!h) {
3584
+ res.json({ status: null, rates: null, counters: null, thresholds });
3585
+ return;
3586
+ }
3587
+ res.json({ ...h, thresholds });
3588
+ })
3589
+ );
3590
+ r.get(
3591
+ "/health/trips",
3592
+ asyncHandler(async (_req, res) => {
3593
+ const rows = await c.auditLog.find({ action: { $in: ["health.trip", "health.resume"] } }).sort({ occurredAt: -1 }).limit(50).toArray();
3594
+ res.json(rows);
3475
3595
  })
3476
3596
  );
3477
3597
  r.post(
@@ -3807,6 +3927,9 @@ function apiRouter(mailer) {
3807
3927
  html = compiled.html;
3808
3928
  plainText = compiled.plainText;
3809
3929
  } else {
3930
+ if (!tpl.body?.html) {
3931
+ return res.status(409).json({ error: "not_published", message: "Template has not been published yet." });
3932
+ }
3810
3933
  html = tpl.body.html;
3811
3934
  plainText = tpl.body.plainText;
3812
3935
  }
@@ -3938,8 +4061,15 @@ function apiRouter(mailer) {
3938
4061
  if (f.kind === "hasTag") hostFilter.hasTag = f.tag;
3939
4062
  if (f.kind === "fieldEquals") hostFilter.fieldEquals = { field: f.field, value: f.value };
3940
4063
  }
3941
- const stageA = await mailer.adapter.count(hostFilter);
3942
- return res.json({ stageA, stageB: stageA, afterSuppression: stageA, computedMs: Date.now() - t0 });
4064
+ const hasMailerFilters = segmentDefinition.filters.some(
4065
+ (f) => ["subscriptionStatus", "firedEvent", "notFiredEvent", "notHasTag", "opened", "notOpened", "subscribedAfter", "subscribedBefore"].includes(f.kind)
4066
+ );
4067
+ const upperBound = await mailer.adapter.count(hostFilter);
4068
+ return res.json({
4069
+ upperBound,
4070
+ approximate: hasMailerFilters,
4071
+ computedMs: Date.now() - t0
4072
+ });
3943
4073
  })
3944
4074
  );
3945
4075
  r.post(
@@ -3997,6 +4127,141 @@ function asyncHandler(fn) {
3997
4127
  fn(req, res, next).catch(next);
3998
4128
  };
3999
4129
  }
4130
+ async function collectQueueCounts(mailer) {
4131
+ const qs = Object.values(mailer.queues);
4132
+ const sum = async (key) => {
4133
+ let total = 0;
4134
+ let supported = false;
4135
+ for (const q of qs) {
4136
+ const fn = q[key];
4137
+ if (!fn) continue;
4138
+ try {
4139
+ const v = await fn.call(q);
4140
+ if (v == null) continue;
4141
+ total += v;
4142
+ supported = true;
4143
+ } catch {
4144
+ return null;
4145
+ }
4146
+ }
4147
+ return supported ? total : null;
4148
+ };
4149
+ const [inFlight, delayed] = await Promise.all([sum("getInFlightCount"), sum("getDelayedCount")]);
4150
+ return { inFlight, delayed };
4151
+ }
4152
+ function emptyFlowStats() {
4153
+ return { activeRuns: 0, completedRuns: 0, sendsLast7Days: 0, sendsTotal: 0 };
4154
+ }
4155
+ async function computeFlowStats(mailer, slugFilter) {
4156
+ const out = /* @__PURE__ */ new Map();
4157
+ const match = slugFilter ? { flowSlug: slugFilter } : {};
4158
+ const runRows = await mailer.collections.flowRuns.aggregate([
4159
+ { $match: match },
4160
+ { $group: { _id: { flowSlug: "$flowSlug", status: "$status" }, count: { $sum: 1 } } }
4161
+ ]).toArray();
4162
+ for (const row of runRows) {
4163
+ const slug = row._id.flowSlug;
4164
+ if (!slug) continue;
4165
+ const cur = out.get(slug) ?? emptyFlowStats();
4166
+ if (row._id.status === "active") cur.activeRuns += row.count;
4167
+ else if (row._id.status === "completed") cur.completedRuns += row.count;
4168
+ out.set(slug, cur);
4169
+ }
4170
+ const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1e3);
4171
+ const sendRows = await mailer.collections.sends.aggregate([
4172
+ { $match: { flowRunId: { $ne: null } } },
4173
+ {
4174
+ $lookup: {
4175
+ from: mailer.collections.flowRuns.collectionName,
4176
+ localField: "flowRunId",
4177
+ foreignField: "_id",
4178
+ as: "run",
4179
+ pipeline: slugFilter ? [{ $match: { flowSlug: slugFilter } }, { $project: { flowSlug: 1 } }] : [{ $project: { flowSlug: 1 } }]
4180
+ }
4181
+ },
4182
+ { $unwind: "$run" },
4183
+ {
4184
+ $group: {
4185
+ _id: "$run.flowSlug",
4186
+ total: { $sum: 1 },
4187
+ last7: { $sum: { $cond: [{ $gte: ["$queuedAt", sevenDaysAgo] }, 1, 0] } }
4188
+ }
4189
+ }
4190
+ ]).toArray();
4191
+ for (const row of sendRows) {
4192
+ if (!row._id) continue;
4193
+ const cur = out.get(row._id) ?? emptyFlowStats();
4194
+ cur.sendsTotal = row.total;
4195
+ cur.sendsLast7Days = row.last7;
4196
+ out.set(row._id, cur);
4197
+ }
4198
+ return out;
4199
+ }
4200
+ function emptyTemplateStats() {
4201
+ return { sent: 0, opened: 0, clicked: 0, bounced: 0, sentLast7Days: 0, lastSentAt: null };
4202
+ }
4203
+ async function computeTemplateStats(mailer, slugFilter) {
4204
+ const out = /* @__PURE__ */ new Map();
4205
+ const match = {};
4206
+ if (slugFilter) match.templateSlug = slugFilter;
4207
+ const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1e3);
4208
+ const rows = await mailer.collections.sends.aggregate([
4209
+ { $match: match },
4210
+ {
4211
+ $group: {
4212
+ _id: "$templateSlug",
4213
+ sent: { $sum: 1 },
4214
+ opened: { $sum: { $cond: [{ $ifNull: ["$openedAt", false] }, 1, 0] } },
4215
+ clicked: { $sum: { $cond: [{ $ifNull: ["$firstClickAt", false] }, 1, 0] } },
4216
+ bounced: { $sum: { $cond: [{ $eq: ["$status", "bounced"] }, 1, 0] } },
4217
+ sentLast7Days: { $sum: { $cond: [{ $gte: ["$queuedAt", sevenDaysAgo] }, 1, 0] } },
4218
+ lastSentAt: { $max: "$queuedAt" }
4219
+ }
4220
+ }
4221
+ ]).toArray();
4222
+ for (const row of rows) {
4223
+ if (!row._id) continue;
4224
+ out.set(row._id, {
4225
+ sent: row.sent,
4226
+ opened: row.opened,
4227
+ clicked: row.clicked,
4228
+ bounced: row.bounced,
4229
+ sentLast7Days: row.sentLast7Days,
4230
+ lastSentAt: row.lastSentAt ?? null
4231
+ });
4232
+ }
4233
+ return out;
4234
+ }
4235
+ function emptyBroadcastStats() {
4236
+ return { delivered: 0, opened: 0, clicked: 0, bounced: 0 };
4237
+ }
4238
+ async function computeBroadcastStats(mailer, idFilter) {
4239
+ const out = /* @__PURE__ */ new Map();
4240
+ const match = { broadcastId: { $ne: null } };
4241
+ if (idFilter) match.broadcastId = idFilter;
4242
+ const rows = await mailer.collections.sends.aggregate([
4243
+ { $match: match },
4244
+ {
4245
+ $group: {
4246
+ _id: "$broadcastId",
4247
+ delivered: { $sum: { $cond: [{ $eq: ["$status", "delivered"] }, 1, 0] } },
4248
+ opened: { $sum: { $cond: [{ $ifNull: ["$openedAt", false] }, 1, 0] } },
4249
+ clicked: { $sum: { $cond: [{ $ifNull: ["$firstClickAt", false] }, 1, 0] } },
4250
+ bounced: { $sum: { $cond: [{ $eq: ["$status", "bounced"] }, 1, 0] } }
4251
+ }
4252
+ }
4253
+ ]).toArray();
4254
+ for (const row of rows) {
4255
+ if (!row._id) continue;
4256
+ out.set(String(row._id), {
4257
+ delivered: row.delivered,
4258
+ opened: row.opened,
4259
+ clicked: row.clicked,
4260
+ bounced: row.bounced
4261
+ });
4262
+ }
4263
+ return out;
4264
+ }
4000
4265
  var PIXEL = Buffer.from(
4001
4266
  "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
4002
4267
  "base64"