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.js CHANGED
@@ -2,7 +2,6 @@ import { ObjectId } from 'mongodb';
2
2
  import crypto2 from 'crypto';
3
3
  import sgMail from '@sendgrid/mail';
4
4
  import { z } from 'zod';
5
- import { Queue, Worker } from 'bullmq';
6
5
  import IORedis from 'ioredis';
7
6
  import Handlebars from 'handlebars';
8
7
  import { convert } from 'html-to-text';
@@ -568,7 +567,8 @@ async function ensureIndexes(db, prefix = "mailer_") {
568
567
  { key: { flowRunId: 1 }, sparse: true },
569
568
  { key: { broadcastId: 1 }, sparse: true },
570
569
  { key: { providerMessageId: 1 }, sparse: true },
571
- { key: { status: 1, queuedAt: 1 } }
570
+ { key: { status: 1, queuedAt: 1 } },
571
+ { key: { status: 1, updatedAt: 1 } }
572
572
  ]),
573
573
  c.suppressions.createIndexes([
574
574
  { key: { email: 1, scope: 1 }, unique: true, partialFilterExpression: { email: { $type: "string" } } },
@@ -715,6 +715,104 @@ function verifyDoiToken(token, secret, now = /* @__PURE__ */ new Date()) {
715
715
  if (body.x < now.getTime()) return null;
716
716
  return { externalId: body.i, expiresAt: new Date(body.x) };
717
717
  }
718
+ var QUEUE_NAMES = {
719
+ tick: "mailer-tick",
720
+ advance: "mailer-advance",
721
+ send: "mailer-send",
722
+ webhook: "mailer-webhook"
723
+ };
724
+ var BullDriver = class _BullDriver {
725
+ queues;
726
+ redis;
727
+ bullQueues;
728
+ workers = null;
729
+ bull;
730
+ static async create(redisConfig) {
731
+ let bull;
732
+ try {
733
+ bull = await import('bullmq');
734
+ } catch {
735
+ throw new Error(
736
+ "mailery: queue driver 'bull' requires the 'bullmq' peer dependency. Run `npm install bullmq ioredis`."
737
+ );
738
+ }
739
+ const redis = isRedisLike(redisConfig) ? redisConfig : connect(redisConfig);
740
+ return new _BullDriver(bull, redis);
741
+ }
742
+ constructor(bull, redis) {
743
+ this.bull = bull;
744
+ this.redis = redis;
745
+ const opts = { connection: redis };
746
+ this.bullQueues = {
747
+ tick: new bull.Queue(QUEUE_NAMES.tick, opts),
748
+ advance: new bull.Queue(QUEUE_NAMES.advance, opts),
749
+ send: new bull.Queue(QUEUE_NAMES.send, opts),
750
+ webhook: new bull.Queue(QUEUE_NAMES.webhook, opts)
751
+ };
752
+ this.queues = {
753
+ tick: adaptBullQueue(this.bullQueues.tick),
754
+ advance: adaptBullQueue(this.bullQueues.advance),
755
+ send: adaptBullQueue(this.bullQueues.send),
756
+ webhook: adaptBullQueue(this.bullQueues.webhook)
757
+ };
758
+ }
759
+ async scheduleRepeatingTick(intervalSeconds) {
760
+ await this.bullQueues.tick.upsertJobScheduler(
761
+ "mailer-tick-repeat",
762
+ { every: intervalSeconds * 1e3 },
763
+ { name: "tick", data: {} }
764
+ );
765
+ }
766
+ async startWorkers(opts) {
767
+ if (this.workers) return;
768
+ const base = { connection: this.redis };
769
+ const { Worker } = this.bull;
770
+ const tick = new Worker(
771
+ QUEUE_NAMES.tick,
772
+ async (job) => opts.handlers.tick(job.data),
773
+ { ...base, concurrency: 1 }
774
+ );
775
+ const advance = new Worker(
776
+ QUEUE_NAMES.advance,
777
+ async (job) => opts.handlers.advance(job.data),
778
+ { ...base, concurrency: 10 }
779
+ );
780
+ const send = new Worker(
781
+ QUEUE_NAMES.send,
782
+ async (job) => opts.handlers.send(job.data),
783
+ {
784
+ ...base,
785
+ concurrency: opts.concurrency.send,
786
+ limiter: opts.sendRateLimit ? { max: opts.sendRateLimit.max, duration: opts.sendRateLimit.durationMs } : void 0
787
+ }
788
+ );
789
+ const webhook = new Worker(
790
+ QUEUE_NAMES.webhook,
791
+ async (job) => opts.handlers.webhook(job.data),
792
+ { ...base, concurrency: 4 }
793
+ );
794
+ this.workers = { tick, advance, send, webhook };
795
+ }
796
+ async stopWorkers() {
797
+ if (!this.workers) return;
798
+ await Promise.all([
799
+ this.workers.tick.close(),
800
+ this.workers.advance.close(),
801
+ this.workers.send.close(),
802
+ this.workers.webhook.close()
803
+ ]);
804
+ this.workers = null;
805
+ }
806
+ async close() {
807
+ await this.stopWorkers();
808
+ await Promise.all([
809
+ this.bullQueues.tick.close(),
810
+ this.bullQueues.advance.close(),
811
+ this.bullQueues.send.close(),
812
+ this.bullQueues.webhook.close()
813
+ ]);
814
+ }
815
+ };
718
816
  function adaptBullQueue(q) {
719
817
  return {
720
818
  add: (name, data, opts) => q.add(name, data, opts),
@@ -722,39 +820,16 @@ function adaptBullQueue(q) {
722
820
  close: () => q.close()
723
821
  };
724
822
  }
725
- function noopQueueAPI() {
726
- return {
727
- add: async () => void 0,
728
- getWaitingCount: async () => 0,
729
- close: async () => void 0
730
- };
731
- }
732
- function noopQueues() {
733
- return {
734
- tick: noopQueueAPI(),
735
- advance: noopQueueAPI(),
736
- send: noopQueueAPI(),
737
- webhook: noopQueueAPI()
738
- };
739
- }
740
- function namespacedQueueNames(prefix) {
741
- return {
742
- tick: "mailer-tick",
743
- advance: "mailer-advance",
744
- send: "mailer-send",
745
- webhook: "mailer-webhook"
746
- };
823
+ function isRedisLike(x) {
824
+ return !!x && typeof x === "object" && typeof x.get === "function" && typeof x.set === "function";
747
825
  }
748
- function makeRedis(opts) {
749
- if (isRedisLike(opts)) return opts;
826
+ function connect(opts) {
750
827
  const config = {
751
828
  maxRetriesPerRequest: null,
752
829
  // BullMQ requirement
753
830
  enableReadyCheck: false
754
831
  };
755
- if (opts.url) {
756
- return new IORedis(opts.url, config);
757
- }
832
+ if (opts.url) return new IORedis(opts.url, config);
758
833
  return new IORedis({
759
834
  ...config,
760
835
  host: opts.host ?? "127.0.0.1",
@@ -765,69 +840,198 @@ function makeRedis(opts) {
765
840
  tls: opts.tls ? {} : void 0
766
841
  });
767
842
  }
768
- function isRedisLike(x) {
769
- return !!x && typeof x === "object" && typeof x.get === "function" && typeof x.set === "function";
770
- }
771
- function createQueues(redis) {
772
- const names = namespacedQueueNames();
773
- const qOpts = { connection: redis };
774
- const bullQueues = {
775
- tick: new Queue(names.tick, qOpts),
776
- advance: new Queue(names.advance, qOpts),
777
- send: new Queue(names.send, qOpts),
778
- webhook: new Queue(names.webhook, qOpts)
779
- };
843
+
844
+ // src/server/queues/agenda.ts
845
+ var QUEUE_NAMES2 = {
846
+ tick: "mailer-tick",
847
+ advance: "mailer-advance",
848
+ send: "mailer-send",
849
+ webhook: "mailer-webhook"
850
+ };
851
+ var AgendaDriver = class _AgendaDriver {
852
+ queues;
853
+ agenda;
854
+ agendaMod;
855
+ sendLimiter = null;
856
+ started = false;
857
+ static async create(opts) {
858
+ let agendaMod;
859
+ let backendMod;
860
+ try {
861
+ agendaMod = await import('agenda');
862
+ backendMod = await import('@agendajs/mongo-backend');
863
+ } catch {
864
+ throw new Error(
865
+ "mailery: queue driver 'agenda' requires the 'agenda' and '@agendajs/mongo-backend' peer dependencies. Run `npm install agenda @agendajs/mongo-backend bottleneck`."
866
+ );
867
+ }
868
+ const backend = new backendMod.MongoBackend({
869
+ mongo: opts.db,
870
+ collection: opts.collectionName ?? "_mailerJobs"
871
+ });
872
+ const agenda = new agendaMod.Agenda({
873
+ backend,
874
+ processEvery: `${opts.processEverySeconds ?? 5} seconds`,
875
+ defaultLockLifetime: (opts.lockLifetimeSeconds ?? 10 * 60) * 1e3,
876
+ maxConcurrency: 50,
877
+ defaultConcurrency: 5
878
+ });
879
+ return new _AgendaDriver(agenda, agendaMod, opts.db);
880
+ }
881
+ db;
882
+ constructor(agenda, agendaMod, db) {
883
+ this.agenda = agenda;
884
+ this.agendaMod = agendaMod;
885
+ this.db = db;
886
+ this.queues = {
887
+ tick: this.makeQueueAPI(QUEUE_NAMES2.tick),
888
+ advance: this.makeQueueAPI(QUEUE_NAMES2.advance),
889
+ send: this.makeQueueAPI(QUEUE_NAMES2.send),
890
+ webhook: this.makeQueueAPI(QUEUE_NAMES2.webhook)
891
+ };
892
+ }
893
+ makeQueueAPI(name) {
894
+ return {
895
+ add: async (_jobName, data, opts) => {
896
+ const payload = { ...data };
897
+ if (opts?.jobId) {
898
+ payload.__jobId = opts.jobId;
899
+ if (await this.findPending(name, opts.jobId)) return;
900
+ }
901
+ const job = this.agenda.create(name, payload);
902
+ if (opts?.delay) job.schedule(new Date(Date.now() + opts.delay));
903
+ await job.save();
904
+ },
905
+ getWaitingCount: async () => {
906
+ return this.jobsCollection().countDocuments({
907
+ name,
908
+ $or: [{ lockedAt: null }, { lockedAt: { $exists: false } }],
909
+ nextRunAt: { $lte: /* @__PURE__ */ new Date() }
910
+ });
911
+ },
912
+ close: async () => {
913
+ }
914
+ };
915
+ }
916
+ /** Direct access to the Mongo collection Agenda persists jobs into. */
917
+ jobsCollection() {
918
+ return this.db.collection(this.collectionName());
919
+ }
920
+ collectionName() {
921
+ return "_mailerJobs";
922
+ }
923
+ async findPending(name, jobId) {
924
+ return this.jobsCollection().findOne({
925
+ name,
926
+ "data.__jobId": jobId,
927
+ $or: [{ lastFinishedAt: null }, { lastFinishedAt: { $exists: false } }]
928
+ });
929
+ }
930
+ async scheduleRepeatingTick(intervalSeconds) {
931
+ if (!this.started) {
932
+ this.agenda.define(QUEUE_NAMES2.tick, async () => {
933
+ }, { concurrency: 1 });
934
+ await this.agenda.start();
935
+ this.started = true;
936
+ }
937
+ await this.agenda.every(`${intervalSeconds} seconds`, QUEUE_NAMES2.tick);
938
+ }
939
+ async startWorkers(opts) {
940
+ const exp = this.agendaMod.backoffStrategies.exponential;
941
+ if (opts.sendRateLimit) {
942
+ try {
943
+ const Bottleneck = (await import('bottleneck')).default;
944
+ this.sendLimiter = new Bottleneck({
945
+ minTime: Math.ceil(opts.sendRateLimit.durationMs / opts.sendRateLimit.max),
946
+ maxConcurrent: opts.concurrency.send
947
+ });
948
+ } catch {
949
+ throw new Error(
950
+ "mailery: queue driver 'agenda' with sendRateLimit requires the 'bottleneck' peer dependency. Run `npm install bottleneck`."
951
+ );
952
+ }
953
+ }
954
+ const retryBackoff = exp({ delay: 6e4, maxRetries: Math.max(0, opts.retryAttempts - 1), factor: 2 });
955
+ this.agenda.define(QUEUE_NAMES2.tick, async (job) => {
956
+ await opts.handlers.tick(job.attrs.data);
957
+ }, { concurrency: 1 });
958
+ this.agenda.define(QUEUE_NAMES2.advance, async (job) => {
959
+ await opts.handlers.advance(job.attrs.data);
960
+ }, { concurrency: 10, backoff: retryBackoff });
961
+ this.agenda.define(QUEUE_NAMES2.send, async (job) => {
962
+ const data = job.attrs.data;
963
+ if (this.sendLimiter) {
964
+ await this.sendLimiter.schedule(() => opts.handlers.send(data));
965
+ } else {
966
+ await opts.handlers.send(data);
967
+ }
968
+ }, { concurrency: opts.concurrency.send, backoff: retryBackoff });
969
+ this.agenda.define(QUEUE_NAMES2.webhook, async (job) => {
970
+ await opts.handlers.webhook(job.attrs.data);
971
+ }, { concurrency: 4, backoff: retryBackoff });
972
+ if (!this.started) {
973
+ await this.agenda.start();
974
+ this.started = true;
975
+ }
976
+ }
977
+ async stopWorkers() {
978
+ if (!this.started) return;
979
+ await this.agenda.stop();
980
+ this.started = false;
981
+ if (this.sendLimiter) {
982
+ await this.sendLimiter.stop({ dropWaitingJobs: true }).catch(() => {
983
+ });
984
+ this.sendLimiter = null;
985
+ }
986
+ }
987
+ async close() {
988
+ await this.stopWorkers();
989
+ }
990
+ };
991
+
992
+ // src/server/queues/noop.ts
993
+ function noopQueueAPI() {
780
994
  return {
781
- queues: {
782
- tick: adaptBullQueue(bullQueues.tick),
783
- advance: adaptBullQueue(bullQueues.advance),
784
- send: adaptBullQueue(bullQueues.send),
785
- webhook: adaptBullQueue(bullQueues.webhook)
786
- },
787
- bullQueues
995
+ add: async () => void 0,
996
+ getWaitingCount: async () => 0,
997
+ close: async () => void 0
788
998
  };
789
999
  }
790
- async function scheduleTick(bullQueues, intervalSeconds) {
791
- await bullQueues.tick.upsertJobScheduler(
792
- "mailer-tick-repeat",
793
- { every: intervalSeconds * 1e3 },
794
- { name: "tick", data: {} }
795
- );
796
- }
797
- function createWorkers(input) {
798
- const names = namespacedQueueNames();
799
- const base = { connection: input.redis };
800
- const tick = new Worker(names.tick, async (job) => input.handlers.tick(job.data), {
801
- ...base,
802
- concurrency: 1
803
- // single tick driver per worker process
804
- });
805
- const advance = new Worker(
806
- names.advance,
807
- async (job) => input.handlers.advance(job.data),
808
- { ...base, concurrency: 10 }
809
- );
810
- const sendOpts = {
811
- ...base,
812
- concurrency: input.concurrency.send,
813
- limiter: input.sendRateLimit ? { max: input.sendRateLimit.max, duration: input.sendRateLimit.durationMs } : void 0
1000
+ var NoopDriver = class {
1001
+ queues = {
1002
+ tick: noopQueueAPI(),
1003
+ advance: noopQueueAPI(),
1004
+ send: noopQueueAPI(),
1005
+ webhook: noopQueueAPI()
814
1006
  };
815
- const send = new Worker(names.send, async (job) => input.handlers.send(job.data), sendOpts);
816
- const webhook = new Worker(
817
- names.webhook,
818
- async (job) => input.handlers.webhook(job.data),
819
- { ...base, concurrency: 4 }
820
- );
821
- return { tick, advance, send, webhook };
822
- }
823
- async function closeQueues(queues) {
824
- await Promise.all([queues.tick.close(), queues.advance.close(), queues.send.close(), queues.webhook.close()]);
825
- }
826
- async function closeBullQueues(b) {
827
- await Promise.all([b.tick.close(), b.advance.close(), b.send.close(), b.webhook.close()]);
828
- }
829
- async function closeWorkers(workers) {
830
- await Promise.all([workers.tick.close(), workers.advance.close(), workers.send.close(), workers.webhook.close()]);
1007
+ async scheduleRepeatingTick(_intervalSeconds) {
1008
+ }
1009
+ async startWorkers(_opts) {
1010
+ }
1011
+ async stopWorkers() {
1012
+ }
1013
+ async close() {
1014
+ }
1015
+ };
1016
+
1017
+ // src/server/queues/index.ts
1018
+ async function createQueueDriver(config, fallbackDb) {
1019
+ switch (config.driver) {
1020
+ case "bull":
1021
+ return BullDriver.create(config.redis);
1022
+ case "agenda":
1023
+ return AgendaDriver.create({
1024
+ db: config.db ?? fallbackDb,
1025
+ processEverySeconds: config.processEverySeconds,
1026
+ lockLifetimeSeconds: config.lockLifetimeSeconds,
1027
+ collectionName: config.collectionName
1028
+ });
1029
+ case "noop":
1030
+ return new NoopDriver();
1031
+ default: {
1032
+ throw new Error(`mailery: unknown queue driver`);
1033
+ }
1034
+ }
831
1035
  }
832
1036
 
833
1037
  // src/server/runner/triggers.ts
@@ -1320,7 +1524,8 @@ async function dispatchSend(sendId, ctx) {
1320
1524
  status: "sending",
1321
1525
  fromName: rendered.fromName,
1322
1526
  fromEmail: rendered.fromEmail,
1323
- subject: rendered.subject
1527
+ subject: rendered.subject,
1528
+ updatedAt: /* @__PURE__ */ new Date()
1324
1529
  }
1325
1530
  }
1326
1531
  );
@@ -1394,6 +1599,7 @@ function buildRenderContext(contact, run, vars, ctx) {
1394
1599
  };
1395
1600
  }
1396
1601
  function newSendDoc(input) {
1602
+ const now = /* @__PURE__ */ new Date();
1397
1603
  return {
1398
1604
  _id: input._id,
1399
1605
  dedupeKey: input.dedupeKey,
@@ -1424,7 +1630,8 @@ function newSendDoc(input) {
1424
1630
  clickedLinks: [],
1425
1631
  unsubscribedAt: null,
1426
1632
  complainedAt: null,
1427
- queuedAt: /* @__PURE__ */ new Date(),
1633
+ queuedAt: now,
1634
+ updatedAt: now,
1428
1635
  sentAt: null,
1429
1636
  deliveredAt: null
1430
1637
  };
@@ -1906,6 +2113,7 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
1906
2113
  unsubscribedAt: null,
1907
2114
  complainedAt: null,
1908
2115
  queuedAt: /* @__PURE__ */ new Date(),
2116
+ updatedAt: /* @__PURE__ */ new Date(),
1909
2117
  sentAt: null,
1910
2118
  deliveredAt: null
1911
2119
  };
@@ -1978,13 +2186,36 @@ async function promoteSoftBounces(ctx) {
1978
2186
  }
1979
2187
 
1980
2188
  // src/server/runner/tick.ts
2189
+ var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
1981
2190
  async function runTick(ctx) {
2191
+ await ctx.collections.health.updateOne(
2192
+ { _id: "singleton" },
2193
+ {
2194
+ $set: { updatedAt: /* @__PURE__ */ new Date() },
2195
+ $setOnInsert: {
2196
+ _id: "singleton",
2197
+ windowStartedAt: /* @__PURE__ */ new Date(),
2198
+ windowDurationMs: ctx.config.circuitBreaker.windowMinutes * 60 * 1e3,
2199
+ status: "healthy",
2200
+ trippedAt: null,
2201
+ trippedReason: null,
2202
+ manuallyResumedAt: null,
2203
+ counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
2204
+ rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
2205
+ }
2206
+ },
2207
+ { upsert: true }
2208
+ ).catch(() => {
2209
+ });
1982
2210
  await processNewlyFiredEventTriggers(ctx).catch((err) => {
1983
2211
  console.error("mailery: triggers scan failed", err);
1984
2212
  });
1985
2213
  await sweepStrandedFlowRuns(ctx).catch((err) => {
1986
2214
  console.error("mailery: sweep failed", err);
1987
2215
  });
2216
+ await sweepStrandedSends(ctx).catch((err) => {
2217
+ console.error("mailery: stranded-send sweep failed", err);
2218
+ });
1988
2219
  await drainOutbox(ctx).catch((err) => {
1989
2220
  console.error("mailery: outbox drain failed", err);
1990
2221
  });
@@ -1998,6 +2229,24 @@ async function runTick(ctx) {
1998
2229
  console.error("mailery: soft-bounce promotion failed", err);
1999
2230
  });
2000
2231
  }
2232
+ async function sweepStrandedSends(ctx) {
2233
+ const cutoff = new Date(Date.now() - STRANDED_SEND_THRESHOLD_MS);
2234
+ const cursor = ctx.collections.sends.find(
2235
+ { status: "sending", updatedAt: { $lt: cutoff } },
2236
+ { projection: { _id: 1 } }
2237
+ ).limit(500);
2238
+ for await (const row of cursor) {
2239
+ const reset = await ctx.collections.sends.updateOne(
2240
+ { _id: row._id, status: "sending", updatedAt: { $lt: cutoff } },
2241
+ { $set: { status: "queued", updatedAt: /* @__PURE__ */ new Date() } }
2242
+ );
2243
+ if (reset.modifiedCount === 0) continue;
2244
+ await ctx.queues.send.add("send", { sendId: String(row._id) }, {
2245
+ attempts: ctx.config.sendRetryAttempts,
2246
+ backoff: { type: "exponential", delay: 6e4 }
2247
+ });
2248
+ }
2249
+ }
2001
2250
  async function drainOutbox(ctx) {
2002
2251
  const batch = await ctx.collections.outbox.find({ status: "pending" }).sort({ enqueuedAt: 1 }).limit(200).toArray();
2003
2252
  for (const row of batch) {
@@ -2168,12 +2417,11 @@ var Mailer = class _Mailer {
2168
2417
  collections;
2169
2418
  adapter;
2170
2419
  providers;
2171
- redis;
2172
2420
  queues;
2173
2421
  config;
2174
2422
  events;
2175
- workers = null;
2176
- bullQueues;
2423
+ queueDriver;
2424
+ workersStarted = false;
2177
2425
  runnerContext;
2178
2426
  constructor(args) {
2179
2427
  this.config = args.config;
@@ -2181,9 +2429,8 @@ var Mailer = class _Mailer {
2181
2429
  this.collections = args.collections;
2182
2430
  this.adapter = args.adapter;
2183
2431
  this.providers = args.providers;
2184
- this.redis = args.redis;
2185
- this.queues = args.queues;
2186
- this.bullQueues = args.bullQueues;
2432
+ this.queueDriver = args.queueDriver;
2433
+ this.queues = args.queueDriver.queues;
2187
2434
  this.events = args.events;
2188
2435
  this.runnerContext = {
2189
2436
  db: this.db,
@@ -2248,10 +2495,12 @@ var Mailer = class _Mailer {
2248
2495
  throw new Error("Mailer.fromEnv: no provider configured (set MAILER_SENDGRID_API_KEY, ...)");
2249
2496
  }
2250
2497
  const defaultProvider = env.MAILER_DEFAULT_PROVIDER ?? Object.keys(providers)[0];
2498
+ const driverEnv = env.MAILER_QUEUE_DRIVER ?? "bull";
2499
+ const queue = driverEnv === "agenda" ? { driver: "agenda" } : driverEnv === "noop" ? { driver: "noop" } : { driver: "bull", redis: { url: required("MAILER_REDIS_URL") } };
2251
2500
  return _Mailer.init({
2252
2501
  db,
2253
2502
  adapter,
2254
- redis: { url: required("MAILER_REDIS_URL") },
2503
+ queue,
2255
2504
  providers,
2256
2505
  defaultProvider,
2257
2506
  publicUrl: required("MAILER_PUBLIC_URL"),
@@ -2267,19 +2516,9 @@ var Mailer = class _Mailer {
2267
2516
  }
2268
2517
  const collections = getCollections(config.db, config.collectionPrefix);
2269
2518
  await ensureIndexes(config.db, config.collectionPrefix);
2270
- let redis = null;
2271
- let queues;
2272
- let bullQueues = null;
2273
- if (config.redis === null) {
2274
- queues = noopQueues();
2275
- } else {
2276
- redis = makeRedis(config.redis);
2277
- const created = createQueues(redis);
2278
- queues = created.queues;
2279
- bullQueues = created.bullQueues;
2280
- if (!config.workerless) {
2281
- await scheduleTick(bullQueues, config.tickIntervalSeconds);
2282
- }
2519
+ const queueDriver = await createQueueDriver(config.queue, config.db);
2520
+ if (!config.workerless && config.queue.driver !== "noop") {
2521
+ await queueDriver.scheduleRepeatingTick(config.tickIntervalSeconds);
2283
2522
  }
2284
2523
  return new _Mailer({
2285
2524
  config,
@@ -2287,9 +2526,7 @@ var Mailer = class _Mailer {
2287
2526
  collections,
2288
2527
  adapter: config.adapter,
2289
2528
  providers: config.providers,
2290
- redis,
2291
- queues,
2292
- bullQueues,
2529
+ queueDriver,
2293
2530
  events: new EventRegistry()
2294
2531
  });
2295
2532
  }
@@ -2597,6 +2834,7 @@ var Mailer = class _Mailer {
2597
2834
  unsubscribedAt: null,
2598
2835
  complainedAt: null,
2599
2836
  queuedAt: /* @__PURE__ */ new Date(),
2837
+ updatedAt: /* @__PURE__ */ new Date(),
2600
2838
  sentAt: null,
2601
2839
  deliveredAt: null
2602
2840
  });
@@ -2628,14 +2866,16 @@ var Mailer = class _Mailer {
2628
2866
  // Workers
2629
2867
  // -------------------------------------------------------------------------
2630
2868
  async startWorkers() {
2631
- if (this.workers) return;
2632
- if (!this.redis) throw new Error("startWorkers requires a Redis connection (redis was null in config)");
2869
+ if (this.workersStarted) return;
2870
+ if (this.config.queue.driver === "noop") {
2871
+ throw new Error("startWorkers requires a non-noop queue driver");
2872
+ }
2633
2873
  const provider = this.providers[this.config.defaultProvider];
2634
2874
  const sendRate = provider?.sendRatePerSecond ?? this.config.sendRatePerSecond;
2635
- this.workers = createWorkers({
2636
- redis: this.redis,
2875
+ await this.queueDriver.startWorkers({
2637
2876
  concurrency: { send: this.config.sendConcurrency },
2638
2877
  sendRateLimit: { max: sendRate, durationMs: 1e3 },
2878
+ retryAttempts: this.config.sendRetryAttempts,
2639
2879
  handlers: {
2640
2880
  tick: async () => {
2641
2881
  await runTick(this.runnerContext);
@@ -2653,6 +2893,7 @@ var Mailer = class _Mailer {
2653
2893
  }
2654
2894
  }
2655
2895
  });
2896
+ this.workersStarted = true;
2656
2897
  }
2657
2898
  /** Process unprocessed webhook events in mailer_webhook_events. */
2658
2899
  async processWebhookBacklog() {
@@ -2679,20 +2920,8 @@ var Mailer = class _Mailer {
2679
2920
  }
2680
2921
  }
2681
2922
  async stop() {
2682
- if (this.workers) {
2683
- await closeWorkers(this.workers);
2684
- this.workers = null;
2685
- }
2686
- if (this.bullQueues) {
2687
- await closeBullQueues(this.bullQueues);
2688
- this.bullQueues = null;
2689
- } else {
2690
- await closeQueues(this.queues);
2691
- }
2692
- if (this.redis) {
2693
- await this.redis.quit().catch(() => {
2694
- });
2695
- }
2923
+ await this.queueDriver.close();
2924
+ this.workersStarted = false;
2696
2925
  }
2697
2926
  /** Used internally by the admin router and tests; not part of the public API. */
2698
2927
  getRunnerContext() {
@@ -2700,6 +2929,43 @@ var Mailer = class _Mailer {
2700
2929
  }
2701
2930
  };
2702
2931
 
2932
+ // src/server/templates/sender-domain.ts
2933
+ function validateSenderDomain(fromEmail, templateKind, registry) {
2934
+ if (!registry || Object.keys(registry).length === 0) return { ok: true };
2935
+ const domain = extractDomain(fromEmail);
2936
+ if (!domain) {
2937
+ return {
2938
+ ok: false,
2939
+ code: "invalid_email",
2940
+ reason: `invalid fromEmail "${fromEmail}" \u2014 expected "name@domain"`
2941
+ };
2942
+ }
2943
+ const entry = registry[domain];
2944
+ if (!entry) {
2945
+ const known = Object.keys(registry).join(", ");
2946
+ return {
2947
+ ok: false,
2948
+ code: "unregistered_domain",
2949
+ reason: `sender domain "${domain}" is not declared in senderDomains (allowed: ${known})`
2950
+ };
2951
+ }
2952
+ if (entry.kind === "both") return { ok: true };
2953
+ if (entry.kind !== templateKind) {
2954
+ return {
2955
+ ok: false,
2956
+ code: "wrong_kind",
2957
+ reason: `sender domain "${domain}" is configured for ${entry.kind} email, but this template's kind is ${templateKind}`
2958
+ };
2959
+ }
2960
+ return { ok: true };
2961
+ }
2962
+ function extractDomain(email) {
2963
+ if (typeof email !== "string") return null;
2964
+ const at = email.lastIndexOf("@");
2965
+ if (at <= 0 || at === email.length - 1) return null;
2966
+ return email.slice(at + 1).toLowerCase().trim();
2967
+ }
2968
+
2703
2969
  // src/server/index.ts
2704
2970
  init_mongo();
2705
2971
 
@@ -2729,6 +2995,227 @@ var NullProvider = class {
2729
2995
 
2730
2996
  // src/server/index.ts
2731
2997
  init_sendgrid();
2998
+
2999
+ // src/server/api/setup-status.ts
3000
+ async function runSetupChecks(mailer) {
3001
+ const checks = [];
3002
+ checks.push(await checkMongo(mailer));
3003
+ checks.push(await checkQueue(mailer));
3004
+ if (mailer.config.queue.driver !== "noop" && !mailer.config.workerless) {
3005
+ checks.push(await checkWorkersHeartbeat(mailer));
3006
+ }
3007
+ checks.push(await checkCircuitBreaker(mailer));
3008
+ checks.push(...checkFromDefaultsAgainstRegistry(mailer));
3009
+ checks.push(...await checkPublishedTemplates(mailer));
3010
+ checks.push(await checkPostalAddress(mailer));
3011
+ checks.push(await checkDoiTemplate(mailer));
3012
+ const overall = checks.some((c) => c.severity === "error") ? "error" : checks.some((c) => c.severity === "warn") ? "warn" : "ok";
3013
+ return {
3014
+ overall,
3015
+ generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
3016
+ checks
3017
+ };
3018
+ }
3019
+ async function checkMongo(mailer) {
3020
+ try {
3021
+ await mailer.db.admin().ping();
3022
+ return { name: "mongo", label: "MongoDB connection", severity: "ok", message: "reachable" };
3023
+ } catch (err) {
3024
+ return {
3025
+ name: "mongo",
3026
+ label: "MongoDB connection",
3027
+ severity: "error",
3028
+ message: `MongoDB ping failed: ${err?.message ?? err}`,
3029
+ hint: "Mailery is configured against an unreachable Mongo. Sends, flow advancement, and admin reads will all fail."
3030
+ };
3031
+ }
3032
+ }
3033
+ async function checkQueue(mailer) {
3034
+ const driver = mailer.config.queue.driver;
3035
+ if (driver === "noop") {
3036
+ return {
3037
+ name: "queue",
3038
+ label: "Queue driver",
3039
+ severity: "ok",
3040
+ message: "driver: noop (synchronous-only mode)"
3041
+ };
3042
+ }
3043
+ try {
3044
+ await mailer.queues.send.getWaitingCount();
3045
+ return { name: "queue", label: "Queue driver", severity: "ok", message: `driver: ${driver}` };
3046
+ } catch (err) {
3047
+ return {
3048
+ name: "queue",
3049
+ label: "Queue driver",
3050
+ severity: "error",
3051
+ message: `${driver} queue is not responding: ${err?.message ?? err}`,
3052
+ hint: driver === "bull" ? "Check Redis connectivity (queue.redis.url)." : "Check the @hokify/agenda + Mongo connection."
3053
+ };
3054
+ }
3055
+ }
3056
+ async function checkWorkersHeartbeat(mailer) {
3057
+ const h = await mailer.collections.health.findOne({ _id: "singleton" });
3058
+ const tickIntervalMs = mailer.config.tickIntervalSeconds * 1e3;
3059
+ const staleAfterMs = Math.max(tickIntervalMs * 3, 3e4);
3060
+ if (!h) {
3061
+ return {
3062
+ name: "workers_heartbeat",
3063
+ label: "Background workers",
3064
+ severity: "warn",
3065
+ message: "no tick has run yet",
3066
+ 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."
3067
+ };
3068
+ }
3069
+ const ageMs = Date.now() - new Date(h.updatedAt).getTime();
3070
+ if (ageMs > staleAfterMs) {
3071
+ return {
3072
+ name: "workers_heartbeat",
3073
+ label: "Background workers",
3074
+ severity: "error",
3075
+ message: `last tick ${humanDuration(ageMs)} ago (expected within ${humanDuration(tickIntervalMs)})`,
3076
+ hint: "Workers appear to be down. Sends and flow advancement are halted. Restart your worker process (`mailer.startWorkers()`)."
3077
+ };
3078
+ }
3079
+ return {
3080
+ name: "workers_heartbeat",
3081
+ label: "Background workers",
3082
+ severity: "ok",
3083
+ message: `last tick ${humanDuration(ageMs)} ago`
3084
+ };
3085
+ }
3086
+ async function checkCircuitBreaker(mailer) {
3087
+ const h = await mailer.collections.health.findOne({ _id: "singleton" });
3088
+ if (!h || h.status === "healthy") {
3089
+ return { name: "circuit_breaker", label: "Circuit breaker", severity: "ok", message: "healthy" };
3090
+ }
3091
+ if (h.status === "degraded") {
3092
+ return {
3093
+ name: "circuit_breaker",
3094
+ label: "Circuit breaker",
3095
+ severity: "warn",
3096
+ message: "degraded (high failure rate)",
3097
+ hint: "Marketing sends still flow but failure rate is above the degraded threshold. Investigate provider errors before they escalate to tripped."
3098
+ };
3099
+ }
3100
+ return {
3101
+ name: "circuit_breaker",
3102
+ label: "Circuit breaker",
3103
+ severity: "error",
3104
+ message: `tripped: ${h.trippedReason ?? "unknown reason"}`,
3105
+ hint: "Marketing sends are held. Investigate the underlying bounce / complaint cause, then POST /api/health/resume."
3106
+ };
3107
+ }
3108
+ function checkFromDefaultsAgainstRegistry(mailer) {
3109
+ const registry = mailer.config.senderDomains;
3110
+ if (!registry || Object.keys(registry).length === 0) return [];
3111
+ const out = [];
3112
+ const from = mailer.config.fromDefaults?.email;
3113
+ const tx = mailer.config.transactionalFromDefaults?.email;
3114
+ if (from) {
3115
+ const r = validateSenderDomain(from, "marketing", registry);
3116
+ if (!r.ok) {
3117
+ out.push({
3118
+ name: "from_defaults_marketing",
3119
+ label: "fromDefaults vs senderDomains",
3120
+ severity: "error",
3121
+ message: r.reason,
3122
+ hint: "New marketing templates that fall back to fromDefaults will fail to publish."
3123
+ });
3124
+ }
3125
+ }
3126
+ if (tx) {
3127
+ const r = validateSenderDomain(tx, "transactional", registry);
3128
+ if (!r.ok) {
3129
+ out.push({
3130
+ name: "transactional_from_defaults",
3131
+ label: "transactionalFromDefaults vs senderDomains",
3132
+ severity: "error",
3133
+ message: r.reason,
3134
+ hint: "New transactional templates that fall back to transactionalFromDefaults will fail to publish."
3135
+ });
3136
+ }
3137
+ } else if (from) {
3138
+ const r = validateSenderDomain(from, "transactional", registry);
3139
+ if (!r.ok) {
3140
+ out.push({
3141
+ name: "transactional_fallback",
3142
+ label: "Transactional fallback",
3143
+ severity: "warn",
3144
+ message: `transactionalFromDefaults is unset and fromDefaults (${from}) is invalid for transactional templates`,
3145
+ hint: 'Set transactionalFromDefaults to a transactional-kind domain, or set senderDomains entry for the existing one to "both".'
3146
+ });
3147
+ }
3148
+ }
3149
+ return out;
3150
+ }
3151
+ async function checkPublishedTemplates(mailer) {
3152
+ const registry = mailer.config.senderDomains;
3153
+ if (!registry || Object.keys(registry).length === 0) return [];
3154
+ const published = await mailer.collections.templates.find({ publishedAt: { $ne: null } }, { projection: { slug: 1, kind: 1, fromEmail: 1 } }).toArray();
3155
+ const broken = [];
3156
+ for (const tpl of published) {
3157
+ const r = validateSenderDomain(tpl.fromEmail, tpl.kind, registry);
3158
+ if (!r.ok) broken.push({ slug: tpl.slug, reason: r.reason });
3159
+ }
3160
+ if (broken.length === 0) return [];
3161
+ const list = broken.slice(0, 5).map((b) => `${b.slug} (${b.reason})`).join("; ");
3162
+ const more = broken.length > 5 ? ` \u2026and ${broken.length - 5} more` : "";
3163
+ return [
3164
+ {
3165
+ name: "published_template_domains",
3166
+ label: "Published templates",
3167
+ severity: "error",
3168
+ message: `${broken.length} published template${broken.length === 1 ? "" : "s"} use a fromEmail that no longer matches senderDomains: ${list}${more}`,
3169
+ 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."
3170
+ }
3171
+ ];
3172
+ }
3173
+ async function checkPostalAddress(mailer) {
3174
+ if (mailer.config.senderAddress) {
3175
+ return { name: "postal_address", label: "CAN-SPAM postal address", severity: "ok", message: "set" };
3176
+ }
3177
+ const marketingCount = await mailer.collections.templates.countDocuments({
3178
+ kind: "marketing",
3179
+ publishedAt: { $ne: null }
3180
+ });
3181
+ if (marketingCount === 0) {
3182
+ return { name: "postal_address", label: "CAN-SPAM postal address", severity: "ok", message: "no published marketing templates yet" };
3183
+ }
3184
+ return {
3185
+ name: "postal_address",
3186
+ label: "CAN-SPAM postal address",
3187
+ severity: "warn",
3188
+ message: `${marketingCount} published marketing template${marketingCount === 1 ? "" : "s"} but senderAddress is unset`,
3189
+ hint: "CAN-SPAM requires a postal address in marketing emails. Set `senderAddress` in your Mailer config and reference it via `{{senderAddress}}` in your templates."
3190
+ };
3191
+ }
3192
+ async function checkDoiTemplate(mailer) {
3193
+ if (!mailer.config.requireDoubleOptIn) {
3194
+ return { name: "doi_template", label: "DOI template", severity: "ok", message: "DOI not required" };
3195
+ }
3196
+ const tpl = await mailer.collections.templates.findOne({
3197
+ slug: mailer.config.doiTemplateSlug,
3198
+ publishedAt: { $ne: null }
3199
+ });
3200
+ if (tpl) {
3201
+ return { name: "doi_template", label: "DOI template", severity: "ok", message: `template "${tpl.slug}" published` };
3202
+ }
3203
+ return {
3204
+ name: "doi_template",
3205
+ label: "DOI template",
3206
+ severity: "error",
3207
+ message: `requireDoubleOptIn is true but no published template with slug "${mailer.config.doiTemplateSlug}"`,
3208
+ hint: "New subscriptions will silently fail to send confirmation emails. Create and publish a template with this slug, or unset requireDoubleOptIn."
3209
+ };
3210
+ }
3211
+ function humanDuration(ms) {
3212
+ if (ms < 1e3) return `${ms}ms`;
3213
+ if (ms < 6e4) return `${Math.round(ms / 1e3)}s`;
3214
+ if (ms < 36e5) return `${Math.round(ms / 6e4)}m`;
3215
+ return `${Math.round(ms / 36e5)}h`;
3216
+ }
3217
+
3218
+ // src/server/api/admin.ts
2732
3219
  var __filename$1 = fileURLToPath(import.meta.url);
2733
3220
  var __dirname$1 = path.dirname(__filename$1);
2734
3221
  function defaultSpaDir() {
@@ -2950,6 +3437,13 @@ function apiRouter(mailer) {
2950
3437
  res.json(rows);
2951
3438
  })
2952
3439
  );
3440
+ r.get(
3441
+ "/setup-status",
3442
+ asyncHandler(async (_req, res) => {
3443
+ const status = await runSetupChecks(mailer);
3444
+ res.json(status);
3445
+ })
3446
+ );
2953
3447
  r.get(
2954
3448
  "/health",
2955
3449
  asyncHandler(async (_req, res) => {
@@ -3123,6 +3617,15 @@ function apiRouter(mailer) {
3123
3617
  if (kind !== "marketing" && kind !== "transactional") {
3124
3618
  return res.status(400).json({ error: "validation_failed", message: "kind must be marketing or transactional" });
3125
3619
  }
3620
+ const resolvedFromEmail = fromEmail ?? (kind === "transactional" ? mailer.config.transactionalFromDefaults?.email : void 0) ?? mailer.config.fromDefaults?.email ?? "noreply@example.com";
3621
+ const senderCheck = validateSenderDomain(resolvedFromEmail, kind, mailer.config.senderDomains);
3622
+ if (!senderCheck.ok) {
3623
+ return res.status(400).json({
3624
+ error: "sender_domain_invalid",
3625
+ code: senderCheck.code,
3626
+ message: senderCheck.reason
3627
+ });
3628
+ }
3126
3629
  const now = /* @__PURE__ */ new Date();
3127
3630
  try {
3128
3631
  await c.templates.insertOne({
@@ -3130,8 +3633,8 @@ function apiRouter(mailer) {
3130
3633
  name,
3131
3634
  description: "",
3132
3635
  kind,
3133
- fromName: fromName ?? mailer.config.fromDefaults?.name ?? "Mailery",
3134
- fromEmail: fromEmail ?? mailer.config.fromDefaults?.email ?? "noreply@example.com",
3636
+ fromName: fromName ?? (kind === "transactional" ? mailer.config.transactionalFromDefaults?.name : void 0) ?? mailer.config.fromDefaults?.name ?? "Mailery",
3637
+ fromEmail: resolvedFromEmail,
3135
3638
  replyTo: null,
3136
3639
  providerOverride: null,
3137
3640
  subject: subject ?? `Untitled \u2014 ${name}`,
@@ -3189,6 +3692,18 @@ function apiRouter(mailer) {
3189
3692
  if (typeof fromEmail === "string") set.fromEmail = fromEmail;
3190
3693
  if (typeof replyTo === "string" || replyTo === null) set.replyTo = replyTo;
3191
3694
  if (kind === "marketing" || kind === "transactional") set.kind = kind;
3695
+ if (typeof fromEmail === "string" || kind === "marketing" || kind === "transactional") {
3696
+ const resultingKind = set.kind ?? tpl.kind;
3697
+ const resultingFromEmail = set.fromEmail ?? tpl.fromEmail;
3698
+ const senderCheck = validateSenderDomain(resultingFromEmail, resultingKind, mailer.config.senderDomains);
3699
+ if (!senderCheck.ok) {
3700
+ return res.status(400).json({
3701
+ error: "sender_domain_invalid",
3702
+ code: senderCheck.code,
3703
+ message: senderCheck.reason
3704
+ });
3705
+ }
3706
+ }
3192
3707
  if (typeof trackOpens === "boolean") set.trackOpens = trackOpens;
3193
3708
  if (typeof trackClicks === "boolean") set.trackClicks = trackClicks;
3194
3709
  await c.templates.updateOne({ _id: tpl._id }, { $set: set });
@@ -3207,6 +3722,14 @@ function apiRouter(mailer) {
3207
3722
  if (!tpl) return res.status(404).json({ error: "not_found" });
3208
3723
  const draft = tpl.draft;
3209
3724
  if (!draft) return res.status(400).json({ error: "no_draft" });
3725
+ const senderCheck = validateSenderDomain(tpl.fromEmail, tpl.kind, mailer.config.senderDomains);
3726
+ if (!senderCheck.ok) {
3727
+ return res.status(400).json({
3728
+ error: "sender_domain_invalid",
3729
+ code: senderCheck.code,
3730
+ message: senderCheck.reason
3731
+ });
3732
+ }
3210
3733
  let compiled;
3211
3734
  if (draft.editorJson) {
3212
3735
  compiled = await compileMailyTemplate(draft.editorJson);
@@ -3817,6 +4340,6 @@ var DEDUPE_POLICIES = [
3817
4340
  // src/server/index.ts
3818
4341
  var VERSION = "0.1.0";
3819
4342
 
3820
- export { DEDUPE_POLICIES, FLOW_STEP_KINDS, Mailer, MongoContactAdapter, NullProvider, PREDICATE_KINDS, SEGMENT_FILTER_KINDS, SendGridProvider, VERSION, applyTracking, applyWebhookEvent, compileMailyTemplate, compileTemplate, createAdminRouter, createPublicRouter, defaultFlowStep, defaultPredicate, defaultSegmentFilter, derivePlaintext, dispatchSend, ensureIndexes, getCollections, predicateKind, processNewlyFiredEventTriggers, processOneRunStep, renderTemplate, runTick, sha256Hex, signUnsubscribeToken, sweepStrandedFlowRuns, verifyUnsubscribeToken };
4343
+ export { DEDUPE_POLICIES, FLOW_STEP_KINDS, Mailer, MongoContactAdapter, NullProvider, PREDICATE_KINDS, SEGMENT_FILTER_KINDS, SendGridProvider, VERSION, applyTracking, applyWebhookEvent, compileMailyTemplate, compileTemplate, createAdminRouter, createPublicRouter, defaultFlowStep, defaultPredicate, defaultSegmentFilter, derivePlaintext, dispatchSend, ensureIndexes, getCollections, predicateKind, processNewlyFiredEventTriggers, processOneRunStep, renderTemplate, runTick, sha256Hex, signUnsubscribeToken, sweepStrandedFlowRuns, validateSenderDomain, verifyUnsubscribeToken };
3821
4344
  //# sourceMappingURL=index.js.map
3822
4345
  //# sourceMappingURL=index.js.map