mailery 0.1.2 → 0.2.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
@@ -4,7 +4,6 @@ var mongodb = require('mongodb');
4
4
  var crypto2 = require('crypto');
5
5
  var sgMail = require('@sendgrid/mail');
6
6
  var zod = require('zod');
7
- var bullmq = require('bullmq');
8
7
  var IORedis = require('ioredis');
9
8
  var Handlebars = require('handlebars');
10
9
  var htmlToText = require('html-to-text');
@@ -582,7 +581,8 @@ async function ensureIndexes(db, prefix = "mailer_") {
582
581
  { key: { flowRunId: 1 }, sparse: true },
583
582
  { key: { broadcastId: 1 }, sparse: true },
584
583
  { key: { providerMessageId: 1 }, sparse: true },
585
- { key: { status: 1, queuedAt: 1 } }
584
+ { key: { status: 1, queuedAt: 1 } },
585
+ { key: { status: 1, updatedAt: 1 } }
586
586
  ]),
587
587
  c.suppressions.createIndexes([
588
588
  { key: { email: 1, scope: 1 }, unique: true, partialFilterExpression: { email: { $type: "string" } } },
@@ -729,6 +729,104 @@ function verifyDoiToken(token, secret, now = /* @__PURE__ */ new Date()) {
729
729
  if (body.x < now.getTime()) return null;
730
730
  return { externalId: body.i, expiresAt: new Date(body.x) };
731
731
  }
732
+ var QUEUE_NAMES = {
733
+ tick: "mailer-tick",
734
+ advance: "mailer-advance",
735
+ send: "mailer-send",
736
+ webhook: "mailer-webhook"
737
+ };
738
+ var BullDriver = class _BullDriver {
739
+ queues;
740
+ redis;
741
+ bullQueues;
742
+ workers = null;
743
+ bull;
744
+ static async create(redisConfig) {
745
+ let bull;
746
+ try {
747
+ bull = await import('bullmq');
748
+ } catch {
749
+ throw new Error(
750
+ "mailery: queue driver 'bull' requires the 'bullmq' peer dependency. Run `npm install bullmq ioredis`."
751
+ );
752
+ }
753
+ const redis = isRedisLike(redisConfig) ? redisConfig : connect(redisConfig);
754
+ return new _BullDriver(bull, redis);
755
+ }
756
+ constructor(bull, redis) {
757
+ this.bull = bull;
758
+ this.redis = redis;
759
+ const opts = { connection: redis };
760
+ this.bullQueues = {
761
+ tick: new bull.Queue(QUEUE_NAMES.tick, opts),
762
+ advance: new bull.Queue(QUEUE_NAMES.advance, opts),
763
+ send: new bull.Queue(QUEUE_NAMES.send, opts),
764
+ webhook: new bull.Queue(QUEUE_NAMES.webhook, opts)
765
+ };
766
+ this.queues = {
767
+ tick: adaptBullQueue(this.bullQueues.tick),
768
+ advance: adaptBullQueue(this.bullQueues.advance),
769
+ send: adaptBullQueue(this.bullQueues.send),
770
+ webhook: adaptBullQueue(this.bullQueues.webhook)
771
+ };
772
+ }
773
+ async scheduleRepeatingTick(intervalSeconds) {
774
+ await this.bullQueues.tick.upsertJobScheduler(
775
+ "mailer-tick-repeat",
776
+ { every: intervalSeconds * 1e3 },
777
+ { name: "tick", data: {} }
778
+ );
779
+ }
780
+ async startWorkers(opts) {
781
+ if (this.workers) return;
782
+ const base = { connection: this.redis };
783
+ const { Worker } = this.bull;
784
+ const tick = new Worker(
785
+ QUEUE_NAMES.tick,
786
+ async (job) => opts.handlers.tick(job.data),
787
+ { ...base, concurrency: 1 }
788
+ );
789
+ const advance = new Worker(
790
+ QUEUE_NAMES.advance,
791
+ async (job) => opts.handlers.advance(job.data),
792
+ { ...base, concurrency: 10 }
793
+ );
794
+ const send = new Worker(
795
+ QUEUE_NAMES.send,
796
+ async (job) => opts.handlers.send(job.data),
797
+ {
798
+ ...base,
799
+ concurrency: opts.concurrency.send,
800
+ limiter: opts.sendRateLimit ? { max: opts.sendRateLimit.max, duration: opts.sendRateLimit.durationMs } : void 0
801
+ }
802
+ );
803
+ const webhook = new Worker(
804
+ QUEUE_NAMES.webhook,
805
+ async (job) => opts.handlers.webhook(job.data),
806
+ { ...base, concurrency: 4 }
807
+ );
808
+ this.workers = { tick, advance, send, webhook };
809
+ }
810
+ async stopWorkers() {
811
+ if (!this.workers) return;
812
+ await Promise.all([
813
+ this.workers.tick.close(),
814
+ this.workers.advance.close(),
815
+ this.workers.send.close(),
816
+ this.workers.webhook.close()
817
+ ]);
818
+ this.workers = null;
819
+ }
820
+ async close() {
821
+ await this.stopWorkers();
822
+ await Promise.all([
823
+ this.bullQueues.tick.close(),
824
+ this.bullQueues.advance.close(),
825
+ this.bullQueues.send.close(),
826
+ this.bullQueues.webhook.close()
827
+ ]);
828
+ }
829
+ };
732
830
  function adaptBullQueue(q) {
733
831
  return {
734
832
  add: (name, data, opts) => q.add(name, data, opts),
@@ -736,39 +834,16 @@ function adaptBullQueue(q) {
736
834
  close: () => q.close()
737
835
  };
738
836
  }
739
- function noopQueueAPI() {
740
- return {
741
- add: async () => void 0,
742
- getWaitingCount: async () => 0,
743
- close: async () => void 0
744
- };
745
- }
746
- function noopQueues() {
747
- return {
748
- tick: noopQueueAPI(),
749
- advance: noopQueueAPI(),
750
- send: noopQueueAPI(),
751
- webhook: noopQueueAPI()
752
- };
753
- }
754
- function namespacedQueueNames(prefix) {
755
- return {
756
- tick: "mailer-tick",
757
- advance: "mailer-advance",
758
- send: "mailer-send",
759
- webhook: "mailer-webhook"
760
- };
837
+ function isRedisLike(x) {
838
+ return !!x && typeof x === "object" && typeof x.get === "function" && typeof x.set === "function";
761
839
  }
762
- function makeRedis(opts) {
763
- if (isRedisLike(opts)) return opts;
840
+ function connect(opts) {
764
841
  const config = {
765
842
  maxRetriesPerRequest: null,
766
843
  // BullMQ requirement
767
844
  enableReadyCheck: false
768
845
  };
769
- if (opts.url) {
770
- return new IORedis__default.default(opts.url, config);
771
- }
846
+ if (opts.url) return new IORedis__default.default(opts.url, config);
772
847
  return new IORedis__default.default({
773
848
  ...config,
774
849
  host: opts.host ?? "127.0.0.1",
@@ -779,69 +854,198 @@ function makeRedis(opts) {
779
854
  tls: opts.tls ? {} : void 0
780
855
  });
781
856
  }
782
- function isRedisLike(x) {
783
- return !!x && typeof x === "object" && typeof x.get === "function" && typeof x.set === "function";
784
- }
785
- function createQueues(redis) {
786
- const names = namespacedQueueNames();
787
- const qOpts = { connection: redis };
788
- const bullQueues = {
789
- tick: new bullmq.Queue(names.tick, qOpts),
790
- advance: new bullmq.Queue(names.advance, qOpts),
791
- send: new bullmq.Queue(names.send, qOpts),
792
- webhook: new bullmq.Queue(names.webhook, qOpts)
793
- };
857
+
858
+ // src/server/queues/agenda.ts
859
+ var QUEUE_NAMES2 = {
860
+ tick: "mailer-tick",
861
+ advance: "mailer-advance",
862
+ send: "mailer-send",
863
+ webhook: "mailer-webhook"
864
+ };
865
+ var AgendaDriver = class _AgendaDriver {
866
+ queues;
867
+ agenda;
868
+ agendaMod;
869
+ sendLimiter = null;
870
+ started = false;
871
+ static async create(opts) {
872
+ let agendaMod;
873
+ let backendMod;
874
+ try {
875
+ agendaMod = await import('agenda');
876
+ backendMod = await import('@agendajs/mongo-backend');
877
+ } catch {
878
+ throw new Error(
879
+ "mailery: queue driver 'agenda' requires the 'agenda' and '@agendajs/mongo-backend' peer dependencies. Run `npm install agenda @agendajs/mongo-backend bottleneck`."
880
+ );
881
+ }
882
+ const backend = new backendMod.MongoBackend({
883
+ mongo: opts.db,
884
+ collection: opts.collectionName ?? "_mailerJobs"
885
+ });
886
+ const agenda = new agendaMod.Agenda({
887
+ backend,
888
+ processEvery: `${opts.processEverySeconds ?? 5} seconds`,
889
+ defaultLockLifetime: (opts.lockLifetimeSeconds ?? 10 * 60) * 1e3,
890
+ maxConcurrency: 50,
891
+ defaultConcurrency: 5
892
+ });
893
+ return new _AgendaDriver(agenda, agendaMod, opts.db);
894
+ }
895
+ db;
896
+ constructor(agenda, agendaMod, db) {
897
+ this.agenda = agenda;
898
+ this.agendaMod = agendaMod;
899
+ this.db = db;
900
+ this.queues = {
901
+ tick: this.makeQueueAPI(QUEUE_NAMES2.tick),
902
+ advance: this.makeQueueAPI(QUEUE_NAMES2.advance),
903
+ send: this.makeQueueAPI(QUEUE_NAMES2.send),
904
+ webhook: this.makeQueueAPI(QUEUE_NAMES2.webhook)
905
+ };
906
+ }
907
+ makeQueueAPI(name) {
908
+ return {
909
+ add: async (_jobName, data, opts) => {
910
+ const payload = { ...data };
911
+ if (opts?.jobId) {
912
+ payload.__jobId = opts.jobId;
913
+ if (await this.findPending(name, opts.jobId)) return;
914
+ }
915
+ const job = this.agenda.create(name, payload);
916
+ if (opts?.delay) job.schedule(new Date(Date.now() + opts.delay));
917
+ await job.save();
918
+ },
919
+ getWaitingCount: async () => {
920
+ return this.jobsCollection().countDocuments({
921
+ name,
922
+ $or: [{ lockedAt: null }, { lockedAt: { $exists: false } }],
923
+ nextRunAt: { $lte: /* @__PURE__ */ new Date() }
924
+ });
925
+ },
926
+ close: async () => {
927
+ }
928
+ };
929
+ }
930
+ /** Direct access to the Mongo collection Agenda persists jobs into. */
931
+ jobsCollection() {
932
+ return this.db.collection(this.collectionName());
933
+ }
934
+ collectionName() {
935
+ return "_mailerJobs";
936
+ }
937
+ async findPending(name, jobId) {
938
+ return this.jobsCollection().findOne({
939
+ name,
940
+ "data.__jobId": jobId,
941
+ $or: [{ lastFinishedAt: null }, { lastFinishedAt: { $exists: false } }]
942
+ });
943
+ }
944
+ async scheduleRepeatingTick(intervalSeconds) {
945
+ if (!this.started) {
946
+ this.agenda.define(QUEUE_NAMES2.tick, async () => {
947
+ }, { concurrency: 1 });
948
+ await this.agenda.start();
949
+ this.started = true;
950
+ }
951
+ await this.agenda.every(`${intervalSeconds} seconds`, QUEUE_NAMES2.tick);
952
+ }
953
+ async startWorkers(opts) {
954
+ const exp = this.agendaMod.backoffStrategies.exponential;
955
+ if (opts.sendRateLimit) {
956
+ try {
957
+ const Bottleneck = (await import('bottleneck')).default;
958
+ this.sendLimiter = new Bottleneck({
959
+ minTime: Math.ceil(opts.sendRateLimit.durationMs / opts.sendRateLimit.max),
960
+ maxConcurrent: opts.concurrency.send
961
+ });
962
+ } catch {
963
+ throw new Error(
964
+ "mailery: queue driver 'agenda' with sendRateLimit requires the 'bottleneck' peer dependency. Run `npm install bottleneck`."
965
+ );
966
+ }
967
+ }
968
+ const retryBackoff = exp({ delay: 6e4, maxRetries: Math.max(0, opts.retryAttempts - 1), factor: 2 });
969
+ this.agenda.define(QUEUE_NAMES2.tick, async (job) => {
970
+ await opts.handlers.tick(job.attrs.data);
971
+ }, { concurrency: 1 });
972
+ this.agenda.define(QUEUE_NAMES2.advance, async (job) => {
973
+ await opts.handlers.advance(job.attrs.data);
974
+ }, { concurrency: 10, backoff: retryBackoff });
975
+ this.agenda.define(QUEUE_NAMES2.send, async (job) => {
976
+ const data = job.attrs.data;
977
+ if (this.sendLimiter) {
978
+ await this.sendLimiter.schedule(() => opts.handlers.send(data));
979
+ } else {
980
+ await opts.handlers.send(data);
981
+ }
982
+ }, { concurrency: opts.concurrency.send, backoff: retryBackoff });
983
+ this.agenda.define(QUEUE_NAMES2.webhook, async (job) => {
984
+ await opts.handlers.webhook(job.attrs.data);
985
+ }, { concurrency: 4, backoff: retryBackoff });
986
+ if (!this.started) {
987
+ await this.agenda.start();
988
+ this.started = true;
989
+ }
990
+ }
991
+ async stopWorkers() {
992
+ if (!this.started) return;
993
+ await this.agenda.stop();
994
+ this.started = false;
995
+ if (this.sendLimiter) {
996
+ await this.sendLimiter.stop({ dropWaitingJobs: true }).catch(() => {
997
+ });
998
+ this.sendLimiter = null;
999
+ }
1000
+ }
1001
+ async close() {
1002
+ await this.stopWorkers();
1003
+ }
1004
+ };
1005
+
1006
+ // src/server/queues/noop.ts
1007
+ function noopQueueAPI() {
794
1008
  return {
795
- queues: {
796
- tick: adaptBullQueue(bullQueues.tick),
797
- advance: adaptBullQueue(bullQueues.advance),
798
- send: adaptBullQueue(bullQueues.send),
799
- webhook: adaptBullQueue(bullQueues.webhook)
800
- },
801
- bullQueues
1009
+ add: async () => void 0,
1010
+ getWaitingCount: async () => 0,
1011
+ close: async () => void 0
802
1012
  };
803
1013
  }
804
- async function scheduleTick(bullQueues, intervalSeconds) {
805
- await bullQueues.tick.upsertJobScheduler(
806
- "mailer-tick-repeat",
807
- { every: intervalSeconds * 1e3 },
808
- { name: "tick", data: {} }
809
- );
810
- }
811
- function createWorkers(input) {
812
- const names = namespacedQueueNames();
813
- const base = { connection: input.redis };
814
- const tick = new bullmq.Worker(names.tick, async (job) => input.handlers.tick(job.data), {
815
- ...base,
816
- concurrency: 1
817
- // single tick driver per worker process
818
- });
819
- const advance = new bullmq.Worker(
820
- names.advance,
821
- async (job) => input.handlers.advance(job.data),
822
- { ...base, concurrency: 10 }
823
- );
824
- const sendOpts = {
825
- ...base,
826
- concurrency: input.concurrency.send,
827
- limiter: input.sendRateLimit ? { max: input.sendRateLimit.max, duration: input.sendRateLimit.durationMs } : void 0
1014
+ var NoopDriver = class {
1015
+ queues = {
1016
+ tick: noopQueueAPI(),
1017
+ advance: noopQueueAPI(),
1018
+ send: noopQueueAPI(),
1019
+ webhook: noopQueueAPI()
828
1020
  };
829
- const send = new bullmq.Worker(names.send, async (job) => input.handlers.send(job.data), sendOpts);
830
- const webhook = new bullmq.Worker(
831
- names.webhook,
832
- async (job) => input.handlers.webhook(job.data),
833
- { ...base, concurrency: 4 }
834
- );
835
- return { tick, advance, send, webhook };
836
- }
837
- async function closeQueues(queues) {
838
- await Promise.all([queues.tick.close(), queues.advance.close(), queues.send.close(), queues.webhook.close()]);
839
- }
840
- async function closeBullQueues(b) {
841
- await Promise.all([b.tick.close(), b.advance.close(), b.send.close(), b.webhook.close()]);
842
- }
843
- async function closeWorkers(workers) {
844
- await Promise.all([workers.tick.close(), workers.advance.close(), workers.send.close(), workers.webhook.close()]);
1021
+ async scheduleRepeatingTick(_intervalSeconds) {
1022
+ }
1023
+ async startWorkers(_opts) {
1024
+ }
1025
+ async stopWorkers() {
1026
+ }
1027
+ async close() {
1028
+ }
1029
+ };
1030
+
1031
+ // src/server/queues/index.ts
1032
+ async function createQueueDriver(config, fallbackDb) {
1033
+ switch (config.driver) {
1034
+ case "bull":
1035
+ return BullDriver.create(config.redis);
1036
+ case "agenda":
1037
+ return AgendaDriver.create({
1038
+ db: config.db ?? fallbackDb,
1039
+ processEverySeconds: config.processEverySeconds,
1040
+ lockLifetimeSeconds: config.lockLifetimeSeconds,
1041
+ collectionName: config.collectionName
1042
+ });
1043
+ case "noop":
1044
+ return new NoopDriver();
1045
+ default: {
1046
+ throw new Error(`mailery: unknown queue driver`);
1047
+ }
1048
+ }
845
1049
  }
846
1050
 
847
1051
  // src/server/runner/triggers.ts
@@ -1334,7 +1538,8 @@ async function dispatchSend(sendId, ctx) {
1334
1538
  status: "sending",
1335
1539
  fromName: rendered.fromName,
1336
1540
  fromEmail: rendered.fromEmail,
1337
- subject: rendered.subject
1541
+ subject: rendered.subject,
1542
+ updatedAt: /* @__PURE__ */ new Date()
1338
1543
  }
1339
1544
  }
1340
1545
  );
@@ -1408,6 +1613,7 @@ function buildRenderContext(contact, run, vars, ctx) {
1408
1613
  };
1409
1614
  }
1410
1615
  function newSendDoc(input) {
1616
+ const now = /* @__PURE__ */ new Date();
1411
1617
  return {
1412
1618
  _id: input._id,
1413
1619
  dedupeKey: input.dedupeKey,
@@ -1438,7 +1644,8 @@ function newSendDoc(input) {
1438
1644
  clickedLinks: [],
1439
1645
  unsubscribedAt: null,
1440
1646
  complainedAt: null,
1441
- queuedAt: /* @__PURE__ */ new Date(),
1647
+ queuedAt: now,
1648
+ updatedAt: now,
1442
1649
  sentAt: null,
1443
1650
  deliveredAt: null
1444
1651
  };
@@ -1920,6 +2127,7 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
1920
2127
  unsubscribedAt: null,
1921
2128
  complainedAt: null,
1922
2129
  queuedAt: /* @__PURE__ */ new Date(),
2130
+ updatedAt: /* @__PURE__ */ new Date(),
1923
2131
  sentAt: null,
1924
2132
  deliveredAt: null
1925
2133
  };
@@ -1992,13 +2200,36 @@ async function promoteSoftBounces(ctx) {
1992
2200
  }
1993
2201
 
1994
2202
  // src/server/runner/tick.ts
2203
+ var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
1995
2204
  async function runTick(ctx) {
2205
+ await ctx.collections.health.updateOne(
2206
+ { _id: "singleton" },
2207
+ {
2208
+ $set: { updatedAt: /* @__PURE__ */ new Date() },
2209
+ $setOnInsert: {
2210
+ _id: "singleton",
2211
+ windowStartedAt: /* @__PURE__ */ new Date(),
2212
+ windowDurationMs: ctx.config.circuitBreaker.windowMinutes * 60 * 1e3,
2213
+ status: "healthy",
2214
+ trippedAt: null,
2215
+ trippedReason: null,
2216
+ manuallyResumedAt: null,
2217
+ counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
2218
+ rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
2219
+ }
2220
+ },
2221
+ { upsert: true }
2222
+ ).catch(() => {
2223
+ });
1996
2224
  await processNewlyFiredEventTriggers(ctx).catch((err) => {
1997
2225
  console.error("mailery: triggers scan failed", err);
1998
2226
  });
1999
2227
  await sweepStrandedFlowRuns(ctx).catch((err) => {
2000
2228
  console.error("mailery: sweep failed", err);
2001
2229
  });
2230
+ await sweepStrandedSends(ctx).catch((err) => {
2231
+ console.error("mailery: stranded-send sweep failed", err);
2232
+ });
2002
2233
  await drainOutbox(ctx).catch((err) => {
2003
2234
  console.error("mailery: outbox drain failed", err);
2004
2235
  });
@@ -2012,6 +2243,24 @@ async function runTick(ctx) {
2012
2243
  console.error("mailery: soft-bounce promotion failed", err);
2013
2244
  });
2014
2245
  }
2246
+ async function sweepStrandedSends(ctx) {
2247
+ const cutoff = new Date(Date.now() - STRANDED_SEND_THRESHOLD_MS);
2248
+ const cursor = ctx.collections.sends.find(
2249
+ { status: "sending", updatedAt: { $lt: cutoff } },
2250
+ { projection: { _id: 1 } }
2251
+ ).limit(500);
2252
+ for await (const row of cursor) {
2253
+ const reset = await ctx.collections.sends.updateOne(
2254
+ { _id: row._id, status: "sending", updatedAt: { $lt: cutoff } },
2255
+ { $set: { status: "queued", updatedAt: /* @__PURE__ */ new Date() } }
2256
+ );
2257
+ if (reset.modifiedCount === 0) continue;
2258
+ await ctx.queues.send.add("send", { sendId: String(row._id) }, {
2259
+ attempts: ctx.config.sendRetryAttempts,
2260
+ backoff: { type: "exponential", delay: 6e4 }
2261
+ });
2262
+ }
2263
+ }
2015
2264
  async function drainOutbox(ctx) {
2016
2265
  const batch = await ctx.collections.outbox.find({ status: "pending" }).sort({ enqueuedAt: 1 }).limit(200).toArray();
2017
2266
  for (const row of batch) {
@@ -2182,12 +2431,11 @@ var Mailer = class _Mailer {
2182
2431
  collections;
2183
2432
  adapter;
2184
2433
  providers;
2185
- redis;
2186
2434
  queues;
2187
2435
  config;
2188
2436
  events;
2189
- workers = null;
2190
- bullQueues;
2437
+ queueDriver;
2438
+ workersStarted = false;
2191
2439
  runnerContext;
2192
2440
  constructor(args) {
2193
2441
  this.config = args.config;
@@ -2195,9 +2443,8 @@ var Mailer = class _Mailer {
2195
2443
  this.collections = args.collections;
2196
2444
  this.adapter = args.adapter;
2197
2445
  this.providers = args.providers;
2198
- this.redis = args.redis;
2199
- this.queues = args.queues;
2200
- this.bullQueues = args.bullQueues;
2446
+ this.queueDriver = args.queueDriver;
2447
+ this.queues = args.queueDriver.queues;
2201
2448
  this.events = args.events;
2202
2449
  this.runnerContext = {
2203
2450
  db: this.db,
@@ -2262,10 +2509,12 @@ var Mailer = class _Mailer {
2262
2509
  throw new Error("Mailer.fromEnv: no provider configured (set MAILER_SENDGRID_API_KEY, ...)");
2263
2510
  }
2264
2511
  const defaultProvider = env.MAILER_DEFAULT_PROVIDER ?? Object.keys(providers)[0];
2512
+ const driverEnv = env.MAILER_QUEUE_DRIVER ?? "bull";
2513
+ const queue = driverEnv === "agenda" ? { driver: "agenda" } : driverEnv === "noop" ? { driver: "noop" } : { driver: "bull", redis: { url: required("MAILER_REDIS_URL") } };
2265
2514
  return _Mailer.init({
2266
2515
  db,
2267
2516
  adapter,
2268
- redis: { url: required("MAILER_REDIS_URL") },
2517
+ queue,
2269
2518
  providers,
2270
2519
  defaultProvider,
2271
2520
  publicUrl: required("MAILER_PUBLIC_URL"),
@@ -2281,19 +2530,9 @@ var Mailer = class _Mailer {
2281
2530
  }
2282
2531
  const collections = getCollections(config.db, config.collectionPrefix);
2283
2532
  await ensureIndexes(config.db, config.collectionPrefix);
2284
- let redis = null;
2285
- let queues;
2286
- let bullQueues = null;
2287
- if (config.redis === null) {
2288
- queues = noopQueues();
2289
- } else {
2290
- redis = makeRedis(config.redis);
2291
- const created = createQueues(redis);
2292
- queues = created.queues;
2293
- bullQueues = created.bullQueues;
2294
- if (!config.workerless) {
2295
- await scheduleTick(bullQueues, config.tickIntervalSeconds);
2296
- }
2533
+ const queueDriver = await createQueueDriver(config.queue, config.db);
2534
+ if (!config.workerless && config.queue.driver !== "noop") {
2535
+ await queueDriver.scheduleRepeatingTick(config.tickIntervalSeconds);
2297
2536
  }
2298
2537
  return new _Mailer({
2299
2538
  config,
@@ -2301,9 +2540,7 @@ var Mailer = class _Mailer {
2301
2540
  collections,
2302
2541
  adapter: config.adapter,
2303
2542
  providers: config.providers,
2304
- redis,
2305
- queues,
2306
- bullQueues,
2543
+ queueDriver,
2307
2544
  events: new EventRegistry()
2308
2545
  });
2309
2546
  }
@@ -2611,6 +2848,7 @@ var Mailer = class _Mailer {
2611
2848
  unsubscribedAt: null,
2612
2849
  complainedAt: null,
2613
2850
  queuedAt: /* @__PURE__ */ new Date(),
2851
+ updatedAt: /* @__PURE__ */ new Date(),
2614
2852
  sentAt: null,
2615
2853
  deliveredAt: null
2616
2854
  });
@@ -2642,14 +2880,16 @@ var Mailer = class _Mailer {
2642
2880
  // Workers
2643
2881
  // -------------------------------------------------------------------------
2644
2882
  async startWorkers() {
2645
- if (this.workers) return;
2646
- if (!this.redis) throw new Error("startWorkers requires a Redis connection (redis was null in config)");
2883
+ if (this.workersStarted) return;
2884
+ if (this.config.queue.driver === "noop") {
2885
+ throw new Error("startWorkers requires a non-noop queue driver");
2886
+ }
2647
2887
  const provider = this.providers[this.config.defaultProvider];
2648
2888
  const sendRate = provider?.sendRatePerSecond ?? this.config.sendRatePerSecond;
2649
- this.workers = createWorkers({
2650
- redis: this.redis,
2889
+ await this.queueDriver.startWorkers({
2651
2890
  concurrency: { send: this.config.sendConcurrency },
2652
2891
  sendRateLimit: { max: sendRate, durationMs: 1e3 },
2892
+ retryAttempts: this.config.sendRetryAttempts,
2653
2893
  handlers: {
2654
2894
  tick: async () => {
2655
2895
  await runTick(this.runnerContext);
@@ -2667,6 +2907,7 @@ var Mailer = class _Mailer {
2667
2907
  }
2668
2908
  }
2669
2909
  });
2910
+ this.workersStarted = true;
2670
2911
  }
2671
2912
  /** Process unprocessed webhook events in mailer_webhook_events. */
2672
2913
  async processWebhookBacklog() {
@@ -2693,20 +2934,8 @@ var Mailer = class _Mailer {
2693
2934
  }
2694
2935
  }
2695
2936
  async stop() {
2696
- if (this.workers) {
2697
- await closeWorkers(this.workers);
2698
- this.workers = null;
2699
- }
2700
- if (this.bullQueues) {
2701
- await closeBullQueues(this.bullQueues);
2702
- this.bullQueues = null;
2703
- } else {
2704
- await closeQueues(this.queues);
2705
- }
2706
- if (this.redis) {
2707
- await this.redis.quit().catch(() => {
2708
- });
2709
- }
2937
+ await this.queueDriver.close();
2938
+ this.workersStarted = false;
2710
2939
  }
2711
2940
  /** Used internally by the admin router and tests; not part of the public API. */
2712
2941
  getRunnerContext() {
@@ -2714,6 +2943,43 @@ var Mailer = class _Mailer {
2714
2943
  }
2715
2944
  };
2716
2945
 
2946
+ // src/server/templates/sender-domain.ts
2947
+ function validateSenderDomain(fromEmail, templateKind, registry) {
2948
+ if (!registry || Object.keys(registry).length === 0) return { ok: true };
2949
+ const domain = extractDomain(fromEmail);
2950
+ if (!domain) {
2951
+ return {
2952
+ ok: false,
2953
+ code: "invalid_email",
2954
+ reason: `invalid fromEmail "${fromEmail}" \u2014 expected "name@domain"`
2955
+ };
2956
+ }
2957
+ const entry = registry[domain];
2958
+ if (!entry) {
2959
+ const known = Object.keys(registry).join(", ");
2960
+ return {
2961
+ ok: false,
2962
+ code: "unregistered_domain",
2963
+ reason: `sender domain "${domain}" is not declared in senderDomains (allowed: ${known})`
2964
+ };
2965
+ }
2966
+ if (entry.kind === "both") return { ok: true };
2967
+ if (entry.kind !== templateKind) {
2968
+ return {
2969
+ ok: false,
2970
+ code: "wrong_kind",
2971
+ reason: `sender domain "${domain}" is configured for ${entry.kind} email, but this template's kind is ${templateKind}`
2972
+ };
2973
+ }
2974
+ return { ok: true };
2975
+ }
2976
+ function extractDomain(email) {
2977
+ if (typeof email !== "string") return null;
2978
+ const at = email.lastIndexOf("@");
2979
+ if (at <= 0 || at === email.length - 1) return null;
2980
+ return email.slice(at + 1).toLowerCase().trim();
2981
+ }
2982
+
2717
2983
  // src/server/index.ts
2718
2984
  init_mongo();
2719
2985
 
@@ -2743,6 +3009,227 @@ var NullProvider = class {
2743
3009
 
2744
3010
  // src/server/index.ts
2745
3011
  init_sendgrid();
3012
+
3013
+ // src/server/api/setup-status.ts
3014
+ async function runSetupChecks(mailer) {
3015
+ const checks = [];
3016
+ checks.push(await checkMongo(mailer));
3017
+ checks.push(await checkQueue(mailer));
3018
+ if (mailer.config.queue.driver !== "noop" && !mailer.config.workerless) {
3019
+ checks.push(await checkWorkersHeartbeat(mailer));
3020
+ }
3021
+ checks.push(await checkCircuitBreaker(mailer));
3022
+ checks.push(...checkFromDefaultsAgainstRegistry(mailer));
3023
+ checks.push(...await checkPublishedTemplates(mailer));
3024
+ checks.push(await checkPostalAddress(mailer));
3025
+ checks.push(await checkDoiTemplate(mailer));
3026
+ const overall = checks.some((c) => c.severity === "error") ? "error" : checks.some((c) => c.severity === "warn") ? "warn" : "ok";
3027
+ return {
3028
+ overall,
3029
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
3030
+ checks
3031
+ };
3032
+ }
3033
+ async function checkMongo(mailer) {
3034
+ try {
3035
+ await mailer.db.admin().ping();
3036
+ return { name: "mongo", label: "MongoDB connection", severity: "ok", message: "reachable" };
3037
+ } catch (err) {
3038
+ return {
3039
+ name: "mongo",
3040
+ label: "MongoDB connection",
3041
+ severity: "error",
3042
+ message: `MongoDB ping failed: ${err?.message ?? err}`,
3043
+ hint: "Mailery is configured against an unreachable Mongo. Sends, flow advancement, and admin reads will all fail."
3044
+ };
3045
+ }
3046
+ }
3047
+ async function checkQueue(mailer) {
3048
+ const driver = mailer.config.queue.driver;
3049
+ if (driver === "noop") {
3050
+ return {
3051
+ name: "queue",
3052
+ label: "Queue driver",
3053
+ severity: "ok",
3054
+ message: "driver: noop (synchronous-only mode)"
3055
+ };
3056
+ }
3057
+ try {
3058
+ await mailer.queues.send.getWaitingCount();
3059
+ return { name: "queue", label: "Queue driver", severity: "ok", message: `driver: ${driver}` };
3060
+ } catch (err) {
3061
+ return {
3062
+ name: "queue",
3063
+ label: "Queue driver",
3064
+ severity: "error",
3065
+ message: `${driver} queue is not responding: ${err?.message ?? err}`,
3066
+ hint: driver === "bull" ? "Check Redis connectivity (queue.redis.url)." : "Check the @hokify/agenda + Mongo connection."
3067
+ };
3068
+ }
3069
+ }
3070
+ async function checkWorkersHeartbeat(mailer) {
3071
+ const h = await mailer.collections.health.findOne({ _id: "singleton" });
3072
+ const tickIntervalMs = mailer.config.tickIntervalSeconds * 1e3;
3073
+ const staleAfterMs = Math.max(tickIntervalMs * 3, 3e4);
3074
+ if (!h) {
3075
+ return {
3076
+ name: "workers_heartbeat",
3077
+ label: "Background workers",
3078
+ severity: "warn",
3079
+ message: "no tick has run yet",
3080
+ hint: "If your separate worker process is started, the heartbeat will appear within one tick interval. If you forgot to run `mailer.startWorkers()`, sends will sit queued indefinitely."
3081
+ };
3082
+ }
3083
+ const ageMs = Date.now() - new Date(h.updatedAt).getTime();
3084
+ if (ageMs > staleAfterMs) {
3085
+ return {
3086
+ name: "workers_heartbeat",
3087
+ label: "Background workers",
3088
+ severity: "error",
3089
+ message: `last tick ${humanDuration(ageMs)} ago (expected within ${humanDuration(tickIntervalMs)})`,
3090
+ hint: "Workers appear to be down. Sends and flow advancement are halted. Restart your worker process (`mailer.startWorkers()`)."
3091
+ };
3092
+ }
3093
+ return {
3094
+ name: "workers_heartbeat",
3095
+ label: "Background workers",
3096
+ severity: "ok",
3097
+ message: `last tick ${humanDuration(ageMs)} ago`
3098
+ };
3099
+ }
3100
+ async function checkCircuitBreaker(mailer) {
3101
+ const h = await mailer.collections.health.findOne({ _id: "singleton" });
3102
+ if (!h || h.status === "healthy") {
3103
+ return { name: "circuit_breaker", label: "Circuit breaker", severity: "ok", message: "healthy" };
3104
+ }
3105
+ if (h.status === "degraded") {
3106
+ return {
3107
+ name: "circuit_breaker",
3108
+ label: "Circuit breaker",
3109
+ severity: "warn",
3110
+ message: "degraded (high failure rate)",
3111
+ hint: "Marketing sends still flow but failure rate is above the degraded threshold. Investigate provider errors before they escalate to tripped."
3112
+ };
3113
+ }
3114
+ return {
3115
+ name: "circuit_breaker",
3116
+ label: "Circuit breaker",
3117
+ severity: "error",
3118
+ message: `tripped: ${h.trippedReason ?? "unknown reason"}`,
3119
+ hint: "Marketing sends are held. Investigate the underlying bounce / complaint cause, then POST /api/health/resume."
3120
+ };
3121
+ }
3122
+ function checkFromDefaultsAgainstRegistry(mailer) {
3123
+ const registry = mailer.config.senderDomains;
3124
+ if (!registry || Object.keys(registry).length === 0) return [];
3125
+ const out = [];
3126
+ const from = mailer.config.fromDefaults?.email;
3127
+ const tx = mailer.config.transactionalFromDefaults?.email;
3128
+ if (from) {
3129
+ const r = validateSenderDomain(from, "marketing", registry);
3130
+ if (!r.ok) {
3131
+ out.push({
3132
+ name: "from_defaults_marketing",
3133
+ label: "fromDefaults vs senderDomains",
3134
+ severity: "error",
3135
+ message: r.reason,
3136
+ hint: "New marketing templates that fall back to fromDefaults will fail to publish."
3137
+ });
3138
+ }
3139
+ }
3140
+ if (tx) {
3141
+ const r = validateSenderDomain(tx, "transactional", registry);
3142
+ if (!r.ok) {
3143
+ out.push({
3144
+ name: "transactional_from_defaults",
3145
+ label: "transactionalFromDefaults vs senderDomains",
3146
+ severity: "error",
3147
+ message: r.reason,
3148
+ hint: "New transactional templates that fall back to transactionalFromDefaults will fail to publish."
3149
+ });
3150
+ }
3151
+ } else if (from) {
3152
+ const r = validateSenderDomain(from, "transactional", registry);
3153
+ if (!r.ok) {
3154
+ out.push({
3155
+ name: "transactional_fallback",
3156
+ label: "Transactional fallback",
3157
+ severity: "warn",
3158
+ message: `transactionalFromDefaults is unset and fromDefaults (${from}) is invalid for transactional templates`,
3159
+ hint: 'Set transactionalFromDefaults to a transactional-kind domain, or set senderDomains entry for the existing one to "both".'
3160
+ });
3161
+ }
3162
+ }
3163
+ return out;
3164
+ }
3165
+ async function checkPublishedTemplates(mailer) {
3166
+ const registry = mailer.config.senderDomains;
3167
+ if (!registry || Object.keys(registry).length === 0) return [];
3168
+ const published = await mailer.collections.templates.find({ publishedAt: { $ne: null } }, { projection: { slug: 1, kind: 1, fromEmail: 1 } }).toArray();
3169
+ const broken = [];
3170
+ for (const tpl of published) {
3171
+ const r = validateSenderDomain(tpl.fromEmail, tpl.kind, registry);
3172
+ if (!r.ok) broken.push({ slug: tpl.slug, reason: r.reason });
3173
+ }
3174
+ if (broken.length === 0) return [];
3175
+ const list = broken.slice(0, 5).map((b) => `${b.slug} (${b.reason})`).join("; ");
3176
+ const more = broken.length > 5 ? ` \u2026and ${broken.length - 5} more` : "";
3177
+ return [
3178
+ {
3179
+ name: "published_template_domains",
3180
+ label: "Published templates",
3181
+ severity: "error",
3182
+ message: `${broken.length} published template${broken.length === 1 ? "" : "s"} use a fromEmail that no longer matches senderDomains: ${list}${more}`,
3183
+ hint: "These templates will still send with their stored fromEmail until re-published. Edit each template and republish to surface the validation, or update senderDomains."
3184
+ }
3185
+ ];
3186
+ }
3187
+ async function checkPostalAddress(mailer) {
3188
+ if (mailer.config.senderAddress) {
3189
+ return { name: "postal_address", label: "CAN-SPAM postal address", severity: "ok", message: "set" };
3190
+ }
3191
+ const marketingCount = await mailer.collections.templates.countDocuments({
3192
+ kind: "marketing",
3193
+ publishedAt: { $ne: null }
3194
+ });
3195
+ if (marketingCount === 0) {
3196
+ return { name: "postal_address", label: "CAN-SPAM postal address", severity: "ok", message: "no published marketing templates yet" };
3197
+ }
3198
+ return {
3199
+ name: "postal_address",
3200
+ label: "CAN-SPAM postal address",
3201
+ severity: "warn",
3202
+ message: `${marketingCount} published marketing template${marketingCount === 1 ? "" : "s"} but senderAddress is unset`,
3203
+ hint: "CAN-SPAM requires a postal address in marketing emails. Set `senderAddress` in your Mailer config and reference it via `{{senderAddress}}` in your templates."
3204
+ };
3205
+ }
3206
+ async function checkDoiTemplate(mailer) {
3207
+ if (!mailer.config.requireDoubleOptIn) {
3208
+ return { name: "doi_template", label: "DOI template", severity: "ok", message: "DOI not required" };
3209
+ }
3210
+ const tpl = await mailer.collections.templates.findOne({
3211
+ slug: mailer.config.doiTemplateSlug,
3212
+ publishedAt: { $ne: null }
3213
+ });
3214
+ if (tpl) {
3215
+ return { name: "doi_template", label: "DOI template", severity: "ok", message: `template "${tpl.slug}" published` };
3216
+ }
3217
+ return {
3218
+ name: "doi_template",
3219
+ label: "DOI template",
3220
+ severity: "error",
3221
+ message: `requireDoubleOptIn is true but no published template with slug "${mailer.config.doiTemplateSlug}"`,
3222
+ hint: "New subscriptions will silently fail to send confirmation emails. Create and publish a template with this slug, or unset requireDoubleOptIn."
3223
+ };
3224
+ }
3225
+ function humanDuration(ms) {
3226
+ if (ms < 1e3) return `${ms}ms`;
3227
+ if (ms < 6e4) return `${Math.round(ms / 1e3)}s`;
3228
+ if (ms < 36e5) return `${Math.round(ms / 6e4)}m`;
3229
+ return `${Math.round(ms / 36e5)}h`;
3230
+ }
3231
+
3232
+ // src/server/api/admin.ts
2746
3233
  var __filename$1 = url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('index.cjs', document.baseURI).href)));
2747
3234
  var __dirname$1 = path__default.default.dirname(__filename$1);
2748
3235
  function defaultSpaDir() {
@@ -2964,6 +3451,13 @@ function apiRouter(mailer) {
2964
3451
  res.json(rows);
2965
3452
  })
2966
3453
  );
3454
+ r.get(
3455
+ "/setup-status",
3456
+ asyncHandler(async (_req, res) => {
3457
+ const status = await runSetupChecks(mailer);
3458
+ res.json(status);
3459
+ })
3460
+ );
2967
3461
  r.get(
2968
3462
  "/health",
2969
3463
  asyncHandler(async (_req, res) => {
@@ -3137,6 +3631,15 @@ function apiRouter(mailer) {
3137
3631
  if (kind !== "marketing" && kind !== "transactional") {
3138
3632
  return res.status(400).json({ error: "validation_failed", message: "kind must be marketing or transactional" });
3139
3633
  }
3634
+ const resolvedFromEmail = fromEmail ?? (kind === "transactional" ? mailer.config.transactionalFromDefaults?.email : void 0) ?? mailer.config.fromDefaults?.email ?? "noreply@example.com";
3635
+ const senderCheck = validateSenderDomain(resolvedFromEmail, kind, mailer.config.senderDomains);
3636
+ if (!senderCheck.ok) {
3637
+ return res.status(400).json({
3638
+ error: "sender_domain_invalid",
3639
+ code: senderCheck.code,
3640
+ message: senderCheck.reason
3641
+ });
3642
+ }
3140
3643
  const now = /* @__PURE__ */ new Date();
3141
3644
  try {
3142
3645
  await c.templates.insertOne({
@@ -3144,8 +3647,8 @@ function apiRouter(mailer) {
3144
3647
  name,
3145
3648
  description: "",
3146
3649
  kind,
3147
- fromName: fromName ?? mailer.config.fromDefaults?.name ?? "Mailery",
3148
- fromEmail: fromEmail ?? mailer.config.fromDefaults?.email ?? "noreply@example.com",
3650
+ fromName: fromName ?? (kind === "transactional" ? mailer.config.transactionalFromDefaults?.name : void 0) ?? mailer.config.fromDefaults?.name ?? "Mailery",
3651
+ fromEmail: resolvedFromEmail,
3149
3652
  replyTo: null,
3150
3653
  providerOverride: null,
3151
3654
  subject: subject ?? `Untitled \u2014 ${name}`,
@@ -3203,6 +3706,18 @@ function apiRouter(mailer) {
3203
3706
  if (typeof fromEmail === "string") set.fromEmail = fromEmail;
3204
3707
  if (typeof replyTo === "string" || replyTo === null) set.replyTo = replyTo;
3205
3708
  if (kind === "marketing" || kind === "transactional") set.kind = kind;
3709
+ if (typeof fromEmail === "string" || kind === "marketing" || kind === "transactional") {
3710
+ const resultingKind = set.kind ?? tpl.kind;
3711
+ const resultingFromEmail = set.fromEmail ?? tpl.fromEmail;
3712
+ const senderCheck = validateSenderDomain(resultingFromEmail, resultingKind, mailer.config.senderDomains);
3713
+ if (!senderCheck.ok) {
3714
+ return res.status(400).json({
3715
+ error: "sender_domain_invalid",
3716
+ code: senderCheck.code,
3717
+ message: senderCheck.reason
3718
+ });
3719
+ }
3720
+ }
3206
3721
  if (typeof trackOpens === "boolean") set.trackOpens = trackOpens;
3207
3722
  if (typeof trackClicks === "boolean") set.trackClicks = trackClicks;
3208
3723
  await c.templates.updateOne({ _id: tpl._id }, { $set: set });
@@ -3221,6 +3736,14 @@ function apiRouter(mailer) {
3221
3736
  if (!tpl) return res.status(404).json({ error: "not_found" });
3222
3737
  const draft = tpl.draft;
3223
3738
  if (!draft) return res.status(400).json({ error: "no_draft" });
3739
+ const senderCheck = validateSenderDomain(tpl.fromEmail, tpl.kind, mailer.config.senderDomains);
3740
+ if (!senderCheck.ok) {
3741
+ return res.status(400).json({
3742
+ error: "sender_domain_invalid",
3743
+ code: senderCheck.code,
3744
+ message: senderCheck.reason
3745
+ });
3746
+ }
3224
3747
  let compiled;
3225
3748
  if (draft.editorJson) {
3226
3749
  compiled = await compileMailyTemplate(draft.editorJson);
@@ -3859,6 +4382,7 @@ exports.runTick = runTick;
3859
4382
  exports.sha256Hex = sha256Hex;
3860
4383
  exports.signUnsubscribeToken = signUnsubscribeToken;
3861
4384
  exports.sweepStrandedFlowRuns = sweepStrandedFlowRuns;
4385
+ exports.validateSenderDomain = validateSenderDomain;
3862
4386
  exports.verifyUnsubscribeToken = verifyUnsubscribeToken;
3863
4387
  //# sourceMappingURL=index.cjs.map
3864
4388
  //# sourceMappingURL=index.cjs.map