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.d.cts CHANGED
@@ -1,5 +1,5 @@
1
- import { R as RunnerContext, N as NormalizedEvent, C as ContactAdapter, a as Contact, A as AdapterFilter, M as MailProvider, S as SendArgs, b as SendResult, c as Mailer, T as TemplateDoc, d as SuppressionScope, F as FlowStep, e as SegmentFilter, P as Predicate } from './null-DaisDvB_.cjs';
2
- export { f as AuditLogDoc, B as BroadcastDoc, g as BroadcastStatus, h as CircuitBreakerThresholds, i as Collections, j as ContactTagDoc, E as EventDoc, k as FlowDoc, l as FlowGoal, m as FlowRunDoc, n as FlowRunStatus, o as FlowVersionDoc, H as HealthDoc, p as HealthStatus, L as LeadDoc, q as MailerConfig, r as NullProvider, O as OutboxDoc, s as RedisOptions, t as SegmentDefinition, u as SendDoc, v as SendStatus, w as SenderDomainConfig, x as SenderDomainRegistry, y as SenderDomainValidation, z as SubscriptionDoc, D as SubscriptionStatus, G as SuppressionDoc, I as SuppressionReason, J as TemplateKind, K as TemplateVersionDoc, W as WebhookEventDoc, Q as ensureIndexes, U as getCollections, V as validateSenderDomain } from './null-DaisDvB_.cjs';
1
+ import { R as RunnerContext, N as NormalizedEvent, C as ContactAdapter, a as Contact, A as AdapterFilter, M as MailProvider, S as SendArgs, b as SendResult, c as Mailer, T as TemplateDoc, d as SuppressionScope, F as FlowStep, e as SegmentFilter, P as Predicate } from './null-CGaNDDlQ.cjs';
2
+ export { f as AuditLogDoc, B as BroadcastDoc, g as BroadcastStatus, h as CircuitBreakerThresholds, i as Collections, j as ContactTagDoc, E as EventDoc, k as FlowDoc, l as FlowGoal, m as FlowRunDoc, n as FlowRunStatus, o as FlowVersionDoc, H as HealthDoc, p as HealthStatus, L as LeadDoc, q as MailerConfig, r as NullProvider, O as OutboxDoc, s as RedisOptions, t as SegmentDefinition, u as SendDoc, v as SendStatus, w as SenderDomainConfig, x as SenderDomainRegistry, y as SenderDomainValidation, z as SubscriptionDoc, D as SubscriptionStatus, G as SuppressionDoc, I as SuppressionReason, J as TemplateKind, K as TemplateVersionDoc, W as WebhookEventDoc, Q as ensureIndexes, U as getCollections, V as validateSenderDomain } from './null-CGaNDDlQ.cjs';
3
3
  import { ObjectId, Db, Filter } from 'mongodb';
4
4
  import { Request, Router } from 'express';
5
5
  import Handlebars from 'handlebars';
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { R as RunnerContext, N as NormalizedEvent, C as ContactAdapter, a as Contact, A as AdapterFilter, M as MailProvider, S as SendArgs, b as SendResult, c as Mailer, T as TemplateDoc, d as SuppressionScope, F as FlowStep, e as SegmentFilter, P as Predicate } from './null-DaisDvB_.js';
2
- export { f as AuditLogDoc, B as BroadcastDoc, g as BroadcastStatus, h as CircuitBreakerThresholds, i as Collections, j as ContactTagDoc, E as EventDoc, k as FlowDoc, l as FlowGoal, m as FlowRunDoc, n as FlowRunStatus, o as FlowVersionDoc, H as HealthDoc, p as HealthStatus, L as LeadDoc, q as MailerConfig, r as NullProvider, O as OutboxDoc, s as RedisOptions, t as SegmentDefinition, u as SendDoc, v as SendStatus, w as SenderDomainConfig, x as SenderDomainRegistry, y as SenderDomainValidation, z as SubscriptionDoc, D as SubscriptionStatus, G as SuppressionDoc, I as SuppressionReason, J as TemplateKind, K as TemplateVersionDoc, W as WebhookEventDoc, Q as ensureIndexes, U as getCollections, V as validateSenderDomain } from './null-DaisDvB_.js';
1
+ import { R as RunnerContext, N as NormalizedEvent, C as ContactAdapter, a as Contact, A as AdapterFilter, M as MailProvider, S as SendArgs, b as SendResult, c as Mailer, T as TemplateDoc, d as SuppressionScope, F as FlowStep, e as SegmentFilter, P as Predicate } from './null-CGaNDDlQ.js';
2
+ export { f as AuditLogDoc, B as BroadcastDoc, g as BroadcastStatus, h as CircuitBreakerThresholds, i as Collections, j as ContactTagDoc, E as EventDoc, k as FlowDoc, l as FlowGoal, m as FlowRunDoc, n as FlowRunStatus, o as FlowVersionDoc, H as HealthDoc, p as HealthStatus, L as LeadDoc, q as MailerConfig, r as NullProvider, O as OutboxDoc, s as RedisOptions, t as SegmentDefinition, u as SendDoc, v as SendStatus, w as SenderDomainConfig, x as SenderDomainRegistry, y as SenderDomainValidation, z as SubscriptionDoc, D as SubscriptionStatus, G as SuppressionDoc, I as SuppressionReason, J as TemplateKind, K as TemplateVersionDoc, W as WebhookEventDoc, Q as ensureIndexes, U as getCollections, V as validateSenderDomain } from './null-CGaNDDlQ.js';
3
3
  import { ObjectId, Db, Filter } from 'mongodb';
4
4
  import { Request, Router } from 'express';
5
5
  import Handlebars from 'handlebars';
package/dist/index.js CHANGED
@@ -817,6 +817,11 @@ function adaptBullQueue(q) {
817
817
  return {
818
818
  add: (name, data, opts) => q.add(name, data, opts),
819
819
  getWaitingCount: () => q.getWaitingCount(),
820
+ getInFlightCount: async () => {
821
+ const [active, waiting] = await Promise.all([q.getActiveCount(), q.getWaitingCount()]);
822
+ return active + waiting;
823
+ },
824
+ getDelayedCount: () => q.getDelayedCount(),
820
825
  close: () => q.close()
821
826
  };
822
827
  }
@@ -3247,43 +3252,131 @@ function createAdminRouter(mailer, opts = {}) {
3247
3252
  function apiRouter(mailer) {
3248
3253
  const r = Router();
3249
3254
  const c = mailer.collections;
3250
- r.get("/me", (req, res) => {
3251
- res.json({
3252
- actor: req.actor,
3253
- permissions: { canPublish: true, canSendBroadcasts: true, canManageSuppressions: true }
3254
- });
3255
- });
3255
+ r.get(
3256
+ "/me",
3257
+ asyncHandler(async (req, res) => {
3258
+ const [flows, templates, broadcasts, contacts, suppressions, health] = await Promise.all([
3259
+ c.flows.estimatedDocumentCount(),
3260
+ c.templates.estimatedDocumentCount(),
3261
+ c.broadcasts.estimatedDocumentCount(),
3262
+ c.subscriptions.countDocuments({ status: "subscribed" }),
3263
+ c.suppressions.estimatedDocumentCount(),
3264
+ c.health.findOne({ _id: "singleton" })
3265
+ ]);
3266
+ res.json({
3267
+ actor: req.actor,
3268
+ counts: { flows, templates, broadcasts, contacts, suppressions },
3269
+ health: { status: health?.status ?? null },
3270
+ providers: {
3271
+ names: Object.keys(mailer.config.providers),
3272
+ default: mailer.config.defaultProvider
3273
+ },
3274
+ broadcastConfirmationThreshold: mailer.config.broadcastConfirmationThreshold
3275
+ });
3276
+ })
3277
+ );
3256
3278
  r.get(
3257
3279
  "/dashboard",
3258
3280
  asyncHandler(async (_req, res) => {
3259
- const since24h = new Date(Date.now() - 24 * 60 * 60 * 1e3);
3260
- const [sentTotal, deliveredCount, bouncedCount, openedCount, clickedCount] = await Promise.all([
3281
+ const now = Date.now();
3282
+ const HOUR = 60 * 60 * 1e3;
3283
+ const since24h = new Date(now - 24 * HOUR);
3284
+ const since48h = new Date(now - 48 * HOUR);
3285
+ const [
3286
+ sentTotal,
3287
+ deliveredCount,
3288
+ bouncedCount,
3289
+ openedCount,
3290
+ clickedCount,
3291
+ sentPrev,
3292
+ deliveredPrev,
3293
+ openedPrev,
3294
+ clickedPrev,
3295
+ hourly
3296
+ ] = await Promise.all([
3261
3297
  c.sends.countDocuments({ queuedAt: { $gt: since24h } }),
3262
3298
  c.sends.countDocuments({ queuedAt: { $gt: since24h }, status: "delivered" }),
3263
3299
  c.sends.countDocuments({ queuedAt: { $gt: since24h }, status: "bounced" }),
3264
3300
  c.sends.countDocuments({ queuedAt: { $gt: since24h }, openedAt: { $ne: null } }),
3265
- c.sends.countDocuments({ queuedAt: { $gt: since24h }, firstClickAt: { $ne: null } })
3301
+ c.sends.countDocuments({ queuedAt: { $gt: since24h }, firstClickAt: { $ne: null } }),
3302
+ c.sends.countDocuments({ queuedAt: { $gt: since48h, $lte: since24h } }),
3303
+ c.sends.countDocuments({ queuedAt: { $gt: since48h, $lte: since24h }, status: "delivered" }),
3304
+ c.sends.countDocuments({ queuedAt: { $gt: since48h, $lte: since24h }, openedAt: { $ne: null } }),
3305
+ c.sends.countDocuments({ queuedAt: { $gt: since48h, $lte: since24h }, firstClickAt: { $ne: null } }),
3306
+ c.sends.aggregate([
3307
+ { $match: { queuedAt: { $gt: since24h } } },
3308
+ {
3309
+ $project: {
3310
+ hour: {
3311
+ $toInt: {
3312
+ $divide: [{ $subtract: [now, { $toLong: "$queuedAt" }] }, HOUR]
3313
+ }
3314
+ },
3315
+ opened: { $cond: [{ $ifNull: ["$openedAt", false] }, 1, 0] }
3316
+ }
3317
+ },
3318
+ { $group: { _id: "$hour", sends: { $sum: 1 }, opens: { $sum: "$opened" } } }
3319
+ ]).toArray()
3266
3320
  ]);
3321
+ const sendSeries = new Array(24).fill(0);
3322
+ const openSeries = new Array(24).fill(0);
3323
+ for (const row of hourly) {
3324
+ const idx = 23 - Math.max(0, Math.min(23, row._id));
3325
+ sendSeries[idx] = row.sends;
3326
+ openSeries[idx] = row.opens;
3327
+ }
3328
+ const delta = (cur, prev) => {
3329
+ if (prev === 0) return null;
3330
+ return (cur - prev) / prev;
3331
+ };
3332
+ const rateDelta = (curN, curD, prevN, prevD) => {
3333
+ if (prevD === 0 || curD === 0) return null;
3334
+ return curN / curD - prevN / prevD;
3335
+ };
3267
3336
  const health = await c.health.findOne({ _id: "singleton" });
3268
- 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() }));
3269
3340
  const recentSends = await c.sends.find().sort({ queuedAt: -1 }).limit(6).toArray();
3270
3341
  const recentAudit = await c.auditLog.find().sort({ occurredAt: -1 }).limit(5).toArray();
3342
+ const queueCounts = await collectQueueCounts(mailer);
3343
+ const lastSendError = await c.sends.findOne({ status: { $in: ["bounced", "failed"] } }, { sort: { queuedAt: -1 } });
3344
+ const lastSendOk = await c.sends.findOne({ status: "delivered" }, { sort: { queuedAt: -1 } });
3345
+ const providerOk = lastSendOk && lastSendError ? new Date(lastSendOk.queuedAt).getTime() >= new Date(lastSendError.queuedAt).getTime() : lastSendOk ? true : lastSendError ? false : null;
3271
3346
  res.json({
3272
3347
  kpis: {
3273
- sends: { value: sentTotal, delta: null },
3348
+ sends: { value: sentTotal, delta: delta(sentTotal, sentPrev) },
3274
3349
  deliveredRate: {
3275
- value: sentTotal === 0 ? 1 : deliveredCount / sentTotal,
3276
- delta: null,
3350
+ value: sentTotal === 0 ? null : deliveredCount / sentTotal,
3351
+ delta: rateDelta(deliveredCount, sentTotal, deliveredPrev, sentPrev),
3277
3352
  bounced: bouncedCount
3278
3353
  },
3279
- openRate: { value: sentTotal === 0 ? 0 : openedCount / sentTotal, delta: null, exclBots: false },
3280
- clickRate: { value: sentTotal === 0 ? 0 : clickedCount / sentTotal, delta: null }
3354
+ openRate: {
3355
+ value: sentTotal === 0 ? null : openedCount / sentTotal,
3356
+ delta: rateDelta(openedCount, sentTotal, openedPrev, sentPrev)
3357
+ },
3358
+ clickRate: {
3359
+ value: sentTotal === 0 ? null : clickedCount / sentTotal,
3360
+ delta: rateDelta(clickedCount, sentTotal, clickedPrev, sentPrev)
3361
+ }
3362
+ },
3363
+ series: { hourly: { sends: sendSeries, opens: openSeries } },
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
+ }
3281
3373
  },
3282
- health: health ? { status: health.status, rates: health.rates } : {
3283
- status: "healthy",
3284
- rates: { hardBounceRate: 0, complaintRate: 0, combinedBounceRate: 0, failureRate: 0 }
3374
+ queue: {
3375
+ inFlight: queueCounts?.inFlight ?? null,
3376
+ delayed: queueCounts?.delayed ?? null,
3377
+ providerOk,
3378
+ providerName: mailer.config.defaultProvider
3285
3379
  },
3286
- queue: { inFlight: 0, delayed: 0, providerOk: true, providerName: mailer.config.defaultProvider },
3287
3380
  recentFlows,
3288
3381
  recentSends,
3289
3382
  recentAudit
@@ -3294,7 +3387,8 @@ function apiRouter(mailer) {
3294
3387
  "/flows",
3295
3388
  asyncHandler(async (_req, res) => {
3296
3389
  const flows = await c.flows.find().sort({ updatedAt: -1 }).toArray();
3297
- res.json(flows);
3390
+ const stats = await computeFlowStats(mailer);
3391
+ res.json(flows.map((f) => ({ ...f, stats: stats.get(f.slug) ?? emptyFlowStats() })));
3298
3392
  })
3299
3393
  );
3300
3394
  r.get(
@@ -3302,7 +3396,8 @@ function apiRouter(mailer) {
3302
3396
  asyncHandler(async (req, res) => {
3303
3397
  const flow = await c.flows.findOne({ slug: req.params.slug });
3304
3398
  if (!flow) return res.status(404).json({ error: "not_found" });
3305
- return res.json(flow);
3399
+ const stats = (await computeFlowStats(mailer, flow.slug)).get(flow.slug) ?? emptyFlowStats();
3400
+ return res.json({ ...flow, stats });
3306
3401
  })
3307
3402
  );
3308
3403
  r.post(
@@ -3337,7 +3432,8 @@ function apiRouter(mailer) {
3337
3432
  "/templates",
3338
3433
  asyncHandler(async (_req, res) => {
3339
3434
  const templates = await c.templates.find().sort({ updatedAt: -1 }).toArray();
3340
- res.json(templates);
3435
+ const stats = await computeTemplateStats(mailer);
3436
+ res.json(templates.map((t) => ({ ...t, stats: stats.get(t.slug) ?? emptyTemplateStats() })));
3341
3437
  })
3342
3438
  );
3343
3439
  r.get(
@@ -3345,14 +3441,16 @@ function apiRouter(mailer) {
3345
3441
  asyncHandler(async (req, res) => {
3346
3442
  const template = await c.templates.findOne({ slug: req.params.slug });
3347
3443
  if (!template) return res.status(404).json({ error: "not_found" });
3348
- return res.json(template);
3444
+ const stats = (await computeTemplateStats(mailer, template.slug)).get(template.slug) ?? emptyTemplateStats();
3445
+ return res.json({ ...template, stats });
3349
3446
  })
3350
3447
  );
3351
3448
  r.get(
3352
3449
  "/broadcasts",
3353
3450
  asyncHandler(async (_req, res) => {
3354
3451
  const broadcasts = await c.broadcasts.find().sort({ createdAt: -1 }).toArray();
3355
- res.json(broadcasts);
3452
+ const stats = await computeBroadcastStats(mailer);
3453
+ res.json(broadcasts.map((b) => ({ ...b, stats: stats.get(String(b._id)) ?? emptyBroadcastStats() })));
3356
3454
  })
3357
3455
  );
3358
3456
  r.get(
@@ -3360,7 +3458,8 @@ function apiRouter(mailer) {
3360
3458
  asyncHandler(async (req, res) => {
3361
3459
  const broadcast = await c.broadcasts.findOne({ slug: req.params.slug });
3362
3460
  if (!broadcast) return res.status(404).json({ error: "not_found" });
3363
- return res.json(broadcast);
3461
+ const stats = (await computeBroadcastStats(mailer, broadcast._id)).get(String(broadcast._id)) ?? emptyBroadcastStats();
3462
+ return res.json({ ...broadcast, stats });
3364
3463
  })
3365
3464
  );
3366
3465
  r.get(
@@ -3368,8 +3467,20 @@ function apiRouter(mailer) {
3368
3467
  asyncHandler(async (req, res) => {
3369
3468
  const cursor = typeof req.query.cursor === "string" ? req.query.cursor : void 0;
3370
3469
  const limit = Math.min(Number(req.query.limit ?? 50), 200);
3371
- const { contacts, nextCursor } = await mailer.adapter.query({}, { limit, cursor });
3372
- 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 });
3373
3484
  })
3374
3485
  );
3375
3486
  r.get(
@@ -3448,16 +3559,25 @@ function apiRouter(mailer) {
3448
3559
  "/health",
3449
3560
  asyncHandler(async (_req, res) => {
3450
3561
  const h = await c.health.findOne({ _id: "singleton" });
3451
- res.json(
3452
- h ?? {
3453
- _id: "singleton",
3454
- status: "healthy",
3455
- windowStartedAt: new Date(Date.now() - 60 * 60 * 1e3),
3456
- windowDurationMs: 60 * 60 * 1e3,
3457
- counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
3458
- rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
3459
- }
3460
- );
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 });
3574
+ })
3575
+ );
3576
+ r.get(
3577
+ "/health/trips",
3578
+ asyncHandler(async (_req, res) => {
3579
+ const rows = await c.auditLog.find({ action: { $in: ["health.trip", "health.resume"] } }).sort({ occurredAt: -1 }).limit(50).toArray();
3580
+ res.json(rows);
3461
3581
  })
3462
3582
  );
3463
3583
  r.post(
@@ -3793,6 +3913,9 @@ function apiRouter(mailer) {
3793
3913
  html = compiled.html;
3794
3914
  plainText = compiled.plainText;
3795
3915
  } else {
3916
+ if (!tpl.body?.html) {
3917
+ return res.status(409).json({ error: "not_published", message: "Template has not been published yet." });
3918
+ }
3796
3919
  html = tpl.body.html;
3797
3920
  plainText = tpl.body.plainText;
3798
3921
  }
@@ -3924,8 +4047,15 @@ function apiRouter(mailer) {
3924
4047
  if (f.kind === "hasTag") hostFilter.hasTag = f.tag;
3925
4048
  if (f.kind === "fieldEquals") hostFilter.fieldEquals = { field: f.field, value: f.value };
3926
4049
  }
3927
- const stageA = await mailer.adapter.count(hostFilter);
3928
- 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
+ });
3929
4059
  })
3930
4060
  );
3931
4061
  r.post(
@@ -3983,6 +4113,141 @@ function asyncHandler(fn) {
3983
4113
  fn(req, res, next).catch(next);
3984
4114
  };
3985
4115
  }
4116
+ async function collectQueueCounts(mailer) {
4117
+ const qs = Object.values(mailer.queues);
4118
+ const sum = async (key) => {
4119
+ let total = 0;
4120
+ let supported = false;
4121
+ for (const q of qs) {
4122
+ const fn = q[key];
4123
+ if (!fn) continue;
4124
+ try {
4125
+ const v = await fn.call(q);
4126
+ if (v == null) continue;
4127
+ total += v;
4128
+ supported = true;
4129
+ } catch {
4130
+ return null;
4131
+ }
4132
+ }
4133
+ return supported ? total : null;
4134
+ };
4135
+ const [inFlight, delayed] = await Promise.all([sum("getInFlightCount"), sum("getDelayedCount")]);
4136
+ return { inFlight, delayed };
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
+ }
3986
4251
  var PIXEL = Buffer.from(
3987
4252
  "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
3988
4253
  "base64"