mailery 0.3.2 → 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
@@ -3279,13 +3279,13 @@ function apiRouter(mailer) {
3279
3279
  ]);
3280
3280
  res.json({
3281
3281
  actor: req.actor,
3282
- permissions: { canPublish: true, canSendBroadcasts: true, canManageSuppressions: true },
3283
3282
  counts: { flows, templates, broadcasts, contacts, suppressions },
3284
- health: { status: health?.status ?? "healthy" },
3283
+ health: { status: health?.status ?? null },
3285
3284
  providers: {
3286
3285
  names: Object.keys(mailer.config.providers),
3287
3286
  default: mailer.config.defaultProvider
3288
- }
3287
+ },
3288
+ broadcastConfirmationThreshold: mailer.config.broadcastConfirmationThreshold
3289
3289
  });
3290
3290
  })
3291
3291
  );
@@ -3348,7 +3348,9 @@ function apiRouter(mailer) {
3348
3348
  return curN / curD - prevN / prevD;
3349
3349
  };
3350
3350
  const health = await c.health.findOne({ _id: "singleton" });
3351
- 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() }));
3352
3354
  const recentSends = await c.sends.find().sort({ queuedAt: -1 }).limit(6).toArray();
3353
3355
  const recentAudit = await c.auditLog.find().sort({ occurredAt: -1 }).limit(5).toArray();
3354
3356
  const queueCounts = await collectQueueCounts(mailer);
@@ -3365,8 +3367,7 @@ function apiRouter(mailer) {
3365
3367
  },
3366
3368
  openRate: {
3367
3369
  value: sentTotal === 0 ? null : openedCount / sentTotal,
3368
- delta: rateDelta(openedCount, sentTotal, openedPrev, sentPrev),
3369
- exclBots: false
3370
+ delta: rateDelta(openedCount, sentTotal, openedPrev, sentPrev)
3370
3371
  },
3371
3372
  clickRate: {
3372
3373
  value: sentTotal === 0 ? null : clickedCount / sentTotal,
@@ -3374,7 +3375,16 @@ function apiRouter(mailer) {
3374
3375
  }
3375
3376
  },
3376
3377
  series: { hourly: { sends: sendSeries, opens: openSeries } },
3377
- health: health ? { status: health.status, rates: health.rates } : { status: "healthy", rates: { hardBounceRate: 0, complaintRate: 0, combinedBounceRate: 0, failureRate: 0 } },
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
+ }
3387
+ },
3378
3388
  queue: {
3379
3389
  inFlight: queueCounts?.inFlight ?? null,
3380
3390
  delayed: queueCounts?.delayed ?? null,
@@ -3391,7 +3401,8 @@ function apiRouter(mailer) {
3391
3401
  "/flows",
3392
3402
  asyncHandler(async (_req, res) => {
3393
3403
  const flows = await c.flows.find().sort({ updatedAt: -1 }).toArray();
3394
- res.json(flows);
3404
+ const stats = await computeFlowStats(mailer);
3405
+ res.json(flows.map((f) => ({ ...f, stats: stats.get(f.slug) ?? emptyFlowStats() })));
3395
3406
  })
3396
3407
  );
3397
3408
  r.get(
@@ -3399,7 +3410,8 @@ function apiRouter(mailer) {
3399
3410
  asyncHandler(async (req, res) => {
3400
3411
  const flow = await c.flows.findOne({ slug: req.params.slug });
3401
3412
  if (!flow) return res.status(404).json({ error: "not_found" });
3402
- return res.json(flow);
3413
+ const stats = (await computeFlowStats(mailer, flow.slug)).get(flow.slug) ?? emptyFlowStats();
3414
+ return res.json({ ...flow, stats });
3403
3415
  })
3404
3416
  );
3405
3417
  r.post(
@@ -3434,7 +3446,8 @@ function apiRouter(mailer) {
3434
3446
  "/templates",
3435
3447
  asyncHandler(async (_req, res) => {
3436
3448
  const templates = await c.templates.find().sort({ updatedAt: -1 }).toArray();
3437
- res.json(templates);
3449
+ const stats = await computeTemplateStats(mailer);
3450
+ res.json(templates.map((t) => ({ ...t, stats: stats.get(t.slug) ?? emptyTemplateStats() })));
3438
3451
  })
3439
3452
  );
3440
3453
  r.get(
@@ -3442,14 +3455,16 @@ function apiRouter(mailer) {
3442
3455
  asyncHandler(async (req, res) => {
3443
3456
  const template = await c.templates.findOne({ slug: req.params.slug });
3444
3457
  if (!template) return res.status(404).json({ error: "not_found" });
3445
- return res.json(template);
3458
+ const stats = (await computeTemplateStats(mailer, template.slug)).get(template.slug) ?? emptyTemplateStats();
3459
+ return res.json({ ...template, stats });
3446
3460
  })
3447
3461
  );
3448
3462
  r.get(
3449
3463
  "/broadcasts",
3450
3464
  asyncHandler(async (_req, res) => {
3451
3465
  const broadcasts = await c.broadcasts.find().sort({ createdAt: -1 }).toArray();
3452
- res.json(broadcasts);
3466
+ const stats = await computeBroadcastStats(mailer);
3467
+ res.json(broadcasts.map((b) => ({ ...b, stats: stats.get(String(b._id)) ?? emptyBroadcastStats() })));
3453
3468
  })
3454
3469
  );
3455
3470
  r.get(
@@ -3457,7 +3472,8 @@ function apiRouter(mailer) {
3457
3472
  asyncHandler(async (req, res) => {
3458
3473
  const broadcast = await c.broadcasts.findOne({ slug: req.params.slug });
3459
3474
  if (!broadcast) return res.status(404).json({ error: "not_found" });
3460
- return res.json(broadcast);
3475
+ const stats = (await computeBroadcastStats(mailer, broadcast._id)).get(String(broadcast._id)) ?? emptyBroadcastStats();
3476
+ return res.json({ ...broadcast, stats });
3461
3477
  })
3462
3478
  );
3463
3479
  r.get(
@@ -3465,8 +3481,20 @@ function apiRouter(mailer) {
3465
3481
  asyncHandler(async (req, res) => {
3466
3482
  const cursor = typeof req.query.cursor === "string" ? req.query.cursor : void 0;
3467
3483
  const limit = Math.min(Number(req.query.limit ?? 50), 200);
3468
- const { contacts, nextCursor } = await mailer.adapter.query({}, { limit, cursor });
3469
- 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 });
3470
3498
  })
3471
3499
  );
3472
3500
  r.get(
@@ -3545,16 +3573,18 @@ function apiRouter(mailer) {
3545
3573
  "/health",
3546
3574
  asyncHandler(async (_req, res) => {
3547
3575
  const h = await c.health.findOne({ _id: "singleton" });
3548
- res.json(
3549
- h ?? {
3550
- _id: "singleton",
3551
- status: "healthy",
3552
- windowStartedAt: new Date(Date.now() - 60 * 60 * 1e3),
3553
- windowDurationMs: 60 * 60 * 1e3,
3554
- counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
3555
- rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
3556
- }
3557
- );
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 });
3558
3588
  })
3559
3589
  );
3560
3590
  r.get(
@@ -3897,6 +3927,9 @@ function apiRouter(mailer) {
3897
3927
  html = compiled.html;
3898
3928
  plainText = compiled.plainText;
3899
3929
  } else {
3930
+ if (!tpl.body?.html) {
3931
+ return res.status(409).json({ error: "not_published", message: "Template has not been published yet." });
3932
+ }
3900
3933
  html = tpl.body.html;
3901
3934
  plainText = tpl.body.plainText;
3902
3935
  }
@@ -4028,8 +4061,15 @@ function apiRouter(mailer) {
4028
4061
  if (f.kind === "hasTag") hostFilter.hasTag = f.tag;
4029
4062
  if (f.kind === "fieldEquals") hostFilter.fieldEquals = { field: f.field, value: f.value };
4030
4063
  }
4031
- const stageA = await mailer.adapter.count(hostFilter);
4032
- 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
+ });
4033
4073
  })
4034
4074
  );
4035
4075
  r.post(
@@ -4109,6 +4149,119 @@ async function collectQueueCounts(mailer) {
4109
4149
  const [inFlight, delayed] = await Promise.all([sum("getInFlightCount"), sum("getDelayedCount")]);
4110
4150
  return { inFlight, delayed };
4111
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
+ }
4112
4265
  var PIXEL = Buffer.from(
4113
4266
  "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
4114
4267
  "base64"