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.js CHANGED
@@ -3265,13 +3265,13 @@ function apiRouter(mailer) {
3265
3265
  ]);
3266
3266
  res.json({
3267
3267
  actor: req.actor,
3268
- permissions: { canPublish: true, canSendBroadcasts: true, canManageSuppressions: true },
3269
3268
  counts: { flows, templates, broadcasts, contacts, suppressions },
3270
- health: { status: health?.status ?? "healthy" },
3269
+ health: { status: health?.status ?? null },
3271
3270
  providers: {
3272
3271
  names: Object.keys(mailer.config.providers),
3273
3272
  default: mailer.config.defaultProvider
3274
- }
3273
+ },
3274
+ broadcastConfirmationThreshold: mailer.config.broadcastConfirmationThreshold
3275
3275
  });
3276
3276
  })
3277
3277
  );
@@ -3334,7 +3334,9 @@ function apiRouter(mailer) {
3334
3334
  return curN / curD - prevN / prevD;
3335
3335
  };
3336
3336
  const health = await c.health.findOne({ _id: "singleton" });
3337
- const recentFlows = await c.flows.find({ enabled: true }).limit(5).toArray();
3337
+ const recentFlowsRaw = await c.flows.find({ enabled: true }).limit(5).toArray();
3338
+ const flowStatsMap = await computeFlowStats(mailer);
3339
+ const recentFlows = recentFlowsRaw.map((f) => ({ ...f, stats: flowStatsMap.get(f.slug) ?? emptyFlowStats() }));
3338
3340
  const recentSends = await c.sends.find().sort({ queuedAt: -1 }).limit(6).toArray();
3339
3341
  const recentAudit = await c.auditLog.find().sort({ occurredAt: -1 }).limit(5).toArray();
3340
3342
  const queueCounts = await collectQueueCounts(mailer);
@@ -3351,8 +3353,7 @@ function apiRouter(mailer) {
3351
3353
  },
3352
3354
  openRate: {
3353
3355
  value: sentTotal === 0 ? null : openedCount / sentTotal,
3354
- delta: rateDelta(openedCount, sentTotal, openedPrev, sentPrev),
3355
- exclBots: false
3356
+ delta: rateDelta(openedCount, sentTotal, openedPrev, sentPrev)
3356
3357
  },
3357
3358
  clickRate: {
3358
3359
  value: sentTotal === 0 ? null : clickedCount / sentTotal,
@@ -3360,7 +3361,16 @@ function apiRouter(mailer) {
3360
3361
  }
3361
3362
  },
3362
3363
  series: { hourly: { sends: sendSeries, opens: openSeries } },
3363
- health: health ? { status: health.status, rates: health.rates } : { status: "healthy", rates: { hardBounceRate: 0, complaintRate: 0, combinedBounceRate: 0, failureRate: 0 } },
3364
+ health: {
3365
+ status: health?.status ?? null,
3366
+ rates: health?.rates ?? null,
3367
+ thresholds: {
3368
+ hardBounceRatePctTrip: mailer.config.circuitBreaker.hardBounceRatePctTrip,
3369
+ complaintRatePctTrip: mailer.config.circuitBreaker.complaintRatePctTrip,
3370
+ combinedBounceRatePctTrip: mailer.config.circuitBreaker.combinedBounceRatePctTrip,
3371
+ failedToSendRatePctDegrade: mailer.config.circuitBreaker.failedToSendRatePctDegrade
3372
+ }
3373
+ },
3364
3374
  queue: {
3365
3375
  inFlight: queueCounts?.inFlight ?? null,
3366
3376
  delayed: queueCounts?.delayed ?? null,
@@ -3377,7 +3387,8 @@ function apiRouter(mailer) {
3377
3387
  "/flows",
3378
3388
  asyncHandler(async (_req, res) => {
3379
3389
  const flows = await c.flows.find().sort({ updatedAt: -1 }).toArray();
3380
- res.json(flows);
3390
+ const stats = await computeFlowStats(mailer);
3391
+ res.json(flows.map((f) => ({ ...f, stats: stats.get(f.slug) ?? emptyFlowStats() })));
3381
3392
  })
3382
3393
  );
3383
3394
  r.get(
@@ -3385,7 +3396,8 @@ function apiRouter(mailer) {
3385
3396
  asyncHandler(async (req, res) => {
3386
3397
  const flow = await c.flows.findOne({ slug: req.params.slug });
3387
3398
  if (!flow) return res.status(404).json({ error: "not_found" });
3388
- return res.json(flow);
3399
+ const stats = (await computeFlowStats(mailer, flow.slug)).get(flow.slug) ?? emptyFlowStats();
3400
+ return res.json({ ...flow, stats });
3389
3401
  })
3390
3402
  );
3391
3403
  r.post(
@@ -3420,7 +3432,8 @@ function apiRouter(mailer) {
3420
3432
  "/templates",
3421
3433
  asyncHandler(async (_req, res) => {
3422
3434
  const templates = await c.templates.find().sort({ updatedAt: -1 }).toArray();
3423
- res.json(templates);
3435
+ const stats = await computeTemplateStats(mailer);
3436
+ res.json(templates.map((t) => ({ ...t, stats: stats.get(t.slug) ?? emptyTemplateStats() })));
3424
3437
  })
3425
3438
  );
3426
3439
  r.get(
@@ -3428,14 +3441,16 @@ function apiRouter(mailer) {
3428
3441
  asyncHandler(async (req, res) => {
3429
3442
  const template = await c.templates.findOne({ slug: req.params.slug });
3430
3443
  if (!template) return res.status(404).json({ error: "not_found" });
3431
- return res.json(template);
3444
+ const stats = (await computeTemplateStats(mailer, template.slug)).get(template.slug) ?? emptyTemplateStats();
3445
+ return res.json({ ...template, stats });
3432
3446
  })
3433
3447
  );
3434
3448
  r.get(
3435
3449
  "/broadcasts",
3436
3450
  asyncHandler(async (_req, res) => {
3437
3451
  const broadcasts = await c.broadcasts.find().sort({ createdAt: -1 }).toArray();
3438
- res.json(broadcasts);
3452
+ const stats = await computeBroadcastStats(mailer);
3453
+ res.json(broadcasts.map((b) => ({ ...b, stats: stats.get(String(b._id)) ?? emptyBroadcastStats() })));
3439
3454
  })
3440
3455
  );
3441
3456
  r.get(
@@ -3443,7 +3458,8 @@ function apiRouter(mailer) {
3443
3458
  asyncHandler(async (req, res) => {
3444
3459
  const broadcast = await c.broadcasts.findOne({ slug: req.params.slug });
3445
3460
  if (!broadcast) return res.status(404).json({ error: "not_found" });
3446
- return res.json(broadcast);
3461
+ const stats = (await computeBroadcastStats(mailer, broadcast._id)).get(String(broadcast._id)) ?? emptyBroadcastStats();
3462
+ return res.json({ ...broadcast, stats });
3447
3463
  })
3448
3464
  );
3449
3465
  r.get(
@@ -3451,8 +3467,20 @@ function apiRouter(mailer) {
3451
3467
  asyncHandler(async (req, res) => {
3452
3468
  const cursor = typeof req.query.cursor === "string" ? req.query.cursor : void 0;
3453
3469
  const limit = Math.min(Number(req.query.limit ?? 50), 200);
3454
- const { contacts, nextCursor } = await mailer.adapter.query({}, { limit, cursor });
3455
- res.json({ contacts, nextCursor });
3470
+ const [{ contacts, nextCursor }, counts] = await Promise.all([
3471
+ mailer.adapter.query({}, { limit, cursor }),
3472
+ (async () => {
3473
+ const rows = await c.subscriptions.aggregate([{ $group: { _id: "$status", n: { $sum: 1 } } }]).toArray();
3474
+ const out = { subscribed: 0, pending_doi: 0, unsubscribed: 0, bounced: 0, complained: 0 };
3475
+ let total = 0;
3476
+ for (const r2 of rows) {
3477
+ if (r2._id) out[r2._id] = r2.n;
3478
+ total += r2.n;
3479
+ }
3480
+ return { ...out, total };
3481
+ })()
3482
+ ]);
3483
+ res.json({ contacts, nextCursor, counts });
3456
3484
  })
3457
3485
  );
3458
3486
  r.get(
@@ -3531,16 +3559,18 @@ function apiRouter(mailer) {
3531
3559
  "/health",
3532
3560
  asyncHandler(async (_req, res) => {
3533
3561
  const h = await c.health.findOne({ _id: "singleton" });
3534
- res.json(
3535
- h ?? {
3536
- _id: "singleton",
3537
- status: "healthy",
3538
- windowStartedAt: new Date(Date.now() - 60 * 60 * 1e3),
3539
- windowDurationMs: 60 * 60 * 1e3,
3540
- counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
3541
- rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
3542
- }
3543
- );
3562
+ const cb = mailer.config.circuitBreaker;
3563
+ const thresholds = {
3564
+ hardBounceRatePctTrip: cb.hardBounceRatePctTrip,
3565
+ complaintRatePctTrip: cb.complaintRatePctTrip,
3566
+ combinedBounceRatePctTrip: cb.combinedBounceRatePctTrip,
3567
+ failedToSendRatePctDegrade: cb.failedToSendRatePctDegrade
3568
+ };
3569
+ if (!h) {
3570
+ res.json({ status: null, rates: null, counters: null, thresholds });
3571
+ return;
3572
+ }
3573
+ res.json({ ...h, thresholds });
3544
3574
  })
3545
3575
  );
3546
3576
  r.get(
@@ -3883,6 +3913,9 @@ function apiRouter(mailer) {
3883
3913
  html = compiled.html;
3884
3914
  plainText = compiled.plainText;
3885
3915
  } else {
3916
+ if (!tpl.body?.html) {
3917
+ return res.status(409).json({ error: "not_published", message: "Template has not been published yet." });
3918
+ }
3886
3919
  html = tpl.body.html;
3887
3920
  plainText = tpl.body.plainText;
3888
3921
  }
@@ -4014,8 +4047,15 @@ function apiRouter(mailer) {
4014
4047
  if (f.kind === "hasTag") hostFilter.hasTag = f.tag;
4015
4048
  if (f.kind === "fieldEquals") hostFilter.fieldEquals = { field: f.field, value: f.value };
4016
4049
  }
4017
- const stageA = await mailer.adapter.count(hostFilter);
4018
- return res.json({ stageA, stageB: stageA, afterSuppression: stageA, computedMs: Date.now() - t0 });
4050
+ const hasMailerFilters = segmentDefinition.filters.some(
4051
+ (f) => ["subscriptionStatus", "firedEvent", "notFiredEvent", "notHasTag", "opened", "notOpened", "subscribedAfter", "subscribedBefore"].includes(f.kind)
4052
+ );
4053
+ const upperBound = await mailer.adapter.count(hostFilter);
4054
+ return res.json({
4055
+ upperBound,
4056
+ approximate: hasMailerFilters,
4057
+ computedMs: Date.now() - t0
4058
+ });
4019
4059
  })
4020
4060
  );
4021
4061
  r.post(
@@ -4095,6 +4135,119 @@ async function collectQueueCounts(mailer) {
4095
4135
  const [inFlight, delayed] = await Promise.all([sum("getInFlightCount"), sum("getDelayedCount")]);
4096
4136
  return { inFlight, delayed };
4097
4137
  }
4138
+ function emptyFlowStats() {
4139
+ return { activeRuns: 0, completedRuns: 0, sendsLast7Days: 0, sendsTotal: 0 };
4140
+ }
4141
+ async function computeFlowStats(mailer, slugFilter) {
4142
+ const out = /* @__PURE__ */ new Map();
4143
+ const match = slugFilter ? { flowSlug: slugFilter } : {};
4144
+ const runRows = await mailer.collections.flowRuns.aggregate([
4145
+ { $match: match },
4146
+ { $group: { _id: { flowSlug: "$flowSlug", status: "$status" }, count: { $sum: 1 } } }
4147
+ ]).toArray();
4148
+ for (const row of runRows) {
4149
+ const slug = row._id.flowSlug;
4150
+ if (!slug) continue;
4151
+ const cur = out.get(slug) ?? emptyFlowStats();
4152
+ if (row._id.status === "active") cur.activeRuns += row.count;
4153
+ else if (row._id.status === "completed") cur.completedRuns += row.count;
4154
+ out.set(slug, cur);
4155
+ }
4156
+ const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1e3);
4157
+ const sendRows = await mailer.collections.sends.aggregate([
4158
+ { $match: { flowRunId: { $ne: null } } },
4159
+ {
4160
+ $lookup: {
4161
+ from: mailer.collections.flowRuns.collectionName,
4162
+ localField: "flowRunId",
4163
+ foreignField: "_id",
4164
+ as: "run",
4165
+ pipeline: slugFilter ? [{ $match: { flowSlug: slugFilter } }, { $project: { flowSlug: 1 } }] : [{ $project: { flowSlug: 1 } }]
4166
+ }
4167
+ },
4168
+ { $unwind: "$run" },
4169
+ {
4170
+ $group: {
4171
+ _id: "$run.flowSlug",
4172
+ total: { $sum: 1 },
4173
+ last7: { $sum: { $cond: [{ $gte: ["$queuedAt", sevenDaysAgo] }, 1, 0] } }
4174
+ }
4175
+ }
4176
+ ]).toArray();
4177
+ for (const row of sendRows) {
4178
+ if (!row._id) continue;
4179
+ const cur = out.get(row._id) ?? emptyFlowStats();
4180
+ cur.sendsTotal = row.total;
4181
+ cur.sendsLast7Days = row.last7;
4182
+ out.set(row._id, cur);
4183
+ }
4184
+ return out;
4185
+ }
4186
+ function emptyTemplateStats() {
4187
+ return { sent: 0, opened: 0, clicked: 0, bounced: 0, sentLast7Days: 0, lastSentAt: null };
4188
+ }
4189
+ async function computeTemplateStats(mailer, slugFilter) {
4190
+ const out = /* @__PURE__ */ new Map();
4191
+ const match = {};
4192
+ if (slugFilter) match.templateSlug = slugFilter;
4193
+ const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1e3);
4194
+ const rows = await mailer.collections.sends.aggregate([
4195
+ { $match: match },
4196
+ {
4197
+ $group: {
4198
+ _id: "$templateSlug",
4199
+ sent: { $sum: 1 },
4200
+ opened: { $sum: { $cond: [{ $ifNull: ["$openedAt", false] }, 1, 0] } },
4201
+ clicked: { $sum: { $cond: [{ $ifNull: ["$firstClickAt", false] }, 1, 0] } },
4202
+ bounced: { $sum: { $cond: [{ $eq: ["$status", "bounced"] }, 1, 0] } },
4203
+ sentLast7Days: { $sum: { $cond: [{ $gte: ["$queuedAt", sevenDaysAgo] }, 1, 0] } },
4204
+ lastSentAt: { $max: "$queuedAt" }
4205
+ }
4206
+ }
4207
+ ]).toArray();
4208
+ for (const row of rows) {
4209
+ if (!row._id) continue;
4210
+ out.set(row._id, {
4211
+ sent: row.sent,
4212
+ opened: row.opened,
4213
+ clicked: row.clicked,
4214
+ bounced: row.bounced,
4215
+ sentLast7Days: row.sentLast7Days,
4216
+ lastSentAt: row.lastSentAt ?? null
4217
+ });
4218
+ }
4219
+ return out;
4220
+ }
4221
+ function emptyBroadcastStats() {
4222
+ return { delivered: 0, opened: 0, clicked: 0, bounced: 0 };
4223
+ }
4224
+ async function computeBroadcastStats(mailer, idFilter) {
4225
+ const out = /* @__PURE__ */ new Map();
4226
+ const match = { broadcastId: { $ne: null } };
4227
+ if (idFilter) match.broadcastId = idFilter;
4228
+ const rows = await mailer.collections.sends.aggregate([
4229
+ { $match: match },
4230
+ {
4231
+ $group: {
4232
+ _id: "$broadcastId",
4233
+ delivered: { $sum: { $cond: [{ $eq: ["$status", "delivered"] }, 1, 0] } },
4234
+ opened: { $sum: { $cond: [{ $ifNull: ["$openedAt", false] }, 1, 0] } },
4235
+ clicked: { $sum: { $cond: [{ $ifNull: ["$firstClickAt", false] }, 1, 0] } },
4236
+ bounced: { $sum: { $cond: [{ $eq: ["$status", "bounced"] }, 1, 0] } }
4237
+ }
4238
+ }
4239
+ ]).toArray();
4240
+ for (const row of rows) {
4241
+ if (!row._id) continue;
4242
+ out.set(String(row._id), {
4243
+ delivered: row.delivered,
4244
+ opened: row.opened,
4245
+ clicked: row.clicked,
4246
+ bounced: row.bounced
4247
+ });
4248
+ }
4249
+ return out;
4250
+ }
4098
4251
  var PIXEL = Buffer.from(
4099
4252
  "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
4100
4253
  "base64"