mailery 0.3.0 → 0.3.2

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,121 @@ 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
+ permissions: { canPublish: true, canSendBroadcasts: true, canManageSuppressions: true },
3283
+ counts: { flows, templates, broadcasts, contacts, suppressions },
3284
+ health: { status: health?.status ?? "healthy" },
3285
+ providers: {
3286
+ names: Object.keys(mailer.config.providers),
3287
+ default: mailer.config.defaultProvider
3288
+ }
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
3351
  const recentFlows = await c.flows.find({ enabled: true }).limit(5).toArray();
3283
3352
  const recentSends = await c.sends.find().sort({ queuedAt: -1 }).limit(6).toArray();
3284
3353
  const recentAudit = await c.auditLog.find().sort({ occurredAt: -1 }).limit(5).toArray();
3354
+ const queueCounts = await collectQueueCounts(mailer);
3355
+ const lastSendError = await c.sends.findOne({ status: { $in: ["bounced", "failed"] } }, { sort: { queuedAt: -1 } });
3356
+ const lastSendOk = await c.sends.findOne({ status: "delivered" }, { sort: { queuedAt: -1 } });
3357
+ const providerOk = lastSendOk && lastSendError ? new Date(lastSendOk.queuedAt).getTime() >= new Date(lastSendError.queuedAt).getTime() : lastSendOk ? true : lastSendError ? false : null;
3285
3358
  res.json({
3286
3359
  kpis: {
3287
- sends: { value: sentTotal, delta: null },
3360
+ sends: { value: sentTotal, delta: delta(sentTotal, sentPrev) },
3288
3361
  deliveredRate: {
3289
- value: sentTotal === 0 ? 1 : deliveredCount / sentTotal,
3290
- delta: null,
3362
+ value: sentTotal === 0 ? null : deliveredCount / sentTotal,
3363
+ delta: rateDelta(deliveredCount, sentTotal, deliveredPrev, sentPrev),
3291
3364
  bounced: bouncedCount
3292
3365
  },
3293
- openRate: { value: sentTotal === 0 ? 0 : openedCount / sentTotal, delta: null, exclBots: false },
3294
- clickRate: { value: sentTotal === 0 ? 0 : clickedCount / sentTotal, delta: null }
3366
+ openRate: {
3367
+ value: sentTotal === 0 ? null : openedCount / sentTotal,
3368
+ delta: rateDelta(openedCount, sentTotal, openedPrev, sentPrev),
3369
+ exclBots: false
3370
+ },
3371
+ clickRate: {
3372
+ value: sentTotal === 0 ? null : clickedCount / sentTotal,
3373
+ delta: rateDelta(clickedCount, sentTotal, clickedPrev, sentPrev)
3374
+ }
3295
3375
  },
3296
- health: health ? { status: health.status, rates: health.rates } : {
3297
- status: "healthy",
3298
- rates: { hardBounceRate: 0, complaintRate: 0, combinedBounceRate: 0, failureRate: 0 }
3376
+ 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
+ queue: {
3379
+ inFlight: queueCounts?.inFlight ?? null,
3380
+ delayed: queueCounts?.delayed ?? null,
3381
+ providerOk,
3382
+ providerName: mailer.config.defaultProvider
3299
3383
  },
3300
- queue: { inFlight: 0, delayed: 0, providerOk: true, providerName: mailer.config.defaultProvider },
3301
3384
  recentFlows,
3302
3385
  recentSends,
3303
3386
  recentAudit
@@ -3474,6 +3557,13 @@ function apiRouter(mailer) {
3474
3557
  );
3475
3558
  })
3476
3559
  );
3560
+ r.get(
3561
+ "/health/trips",
3562
+ asyncHandler(async (_req, res) => {
3563
+ const rows = await c.auditLog.find({ action: { $in: ["health.trip", "health.resume"] } }).sort({ occurredAt: -1 }).limit(50).toArray();
3564
+ res.json(rows);
3565
+ })
3566
+ );
3477
3567
  r.post(
3478
3568
  "/health/resume",
3479
3569
  asyncHandler(async (req, res) => {
@@ -3997,6 +4087,28 @@ function asyncHandler(fn) {
3997
4087
  fn(req, res, next).catch(next);
3998
4088
  };
3999
4089
  }
4090
+ async function collectQueueCounts(mailer) {
4091
+ const qs = Object.values(mailer.queues);
4092
+ const sum = async (key) => {
4093
+ let total = 0;
4094
+ let supported = false;
4095
+ for (const q of qs) {
4096
+ const fn = q[key];
4097
+ if (!fn) continue;
4098
+ try {
4099
+ const v = await fn.call(q);
4100
+ if (v == null) continue;
4101
+ total += v;
4102
+ supported = true;
4103
+ } catch {
4104
+ return null;
4105
+ }
4106
+ }
4107
+ return supported ? total : null;
4108
+ };
4109
+ const [inFlight, delayed] = await Promise.all([sum("getInFlightCount"), sum("getDelayedCount")]);
4110
+ return { inFlight, delayed };
4111
+ }
4000
4112
  var PIXEL = Buffer.from(
4001
4113
  "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=",
4002
4114
  "base64"