mailery 0.1.2 → 0.2.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/testing.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');
@@ -9500,10 +9499,10 @@ var require_dist2 = __commonJS({
9500
9499
  const proxyResponsePromise = (0, parse_proxy_response_1.parseProxyResponse)(socket);
9501
9500
  socket.write(`${payload}\r
9502
9501
  `);
9503
- const { connect, buffered } = await proxyResponsePromise;
9504
- req.emit("proxyConnect", connect);
9505
- this.emit("proxyConnect", connect, req);
9506
- if (connect.statusCode === 200) {
9502
+ const { connect: connect2, buffered } = await proxyResponsePromise;
9503
+ req.emit("proxyConnect", connect2);
9504
+ this.emit("proxyConnect", connect2, req);
9505
+ if (connect2.statusCode === 200) {
9507
9506
  req.once("socket", resume);
9508
9507
  if (opts.secureEndpoint) {
9509
9508
  debug("Upgrading socket connection to TLS");
@@ -14384,7 +14383,8 @@ async function ensureIndexes(db, prefix = "mailer_") {
14384
14383
  { key: { flowRunId: 1 }, sparse: true },
14385
14384
  { key: { broadcastId: 1 }, sparse: true },
14386
14385
  { key: { providerMessageId: 1 }, sparse: true },
14387
- { key: { status: 1, queuedAt: 1 } }
14386
+ { key: { status: 1, queuedAt: 1 } },
14387
+ { key: { status: 1, updatedAt: 1 } }
14388
14388
  ]),
14389
14389
  c.suppressions.createIndexes([
14390
14390
  { key: { email: 1, scope: 1 }, unique: true, partialFilterExpression: { email: { $type: "string" } } },
@@ -14476,6 +14476,104 @@ function signDoiToken(payload, secret) {
14476
14476
  const hmac = crypto2__default.default.createHmac("sha256", secret).update(bodyB64).digest();
14477
14477
  return `${bodyB64}.${b64url(hmac)}`;
14478
14478
  }
14479
+ var QUEUE_NAMES = {
14480
+ tick: "mailer-tick",
14481
+ advance: "mailer-advance",
14482
+ send: "mailer-send",
14483
+ webhook: "mailer-webhook"
14484
+ };
14485
+ var BullDriver = class _BullDriver {
14486
+ queues;
14487
+ redis;
14488
+ bullQueues;
14489
+ workers = null;
14490
+ bull;
14491
+ static async create(redisConfig) {
14492
+ let bull;
14493
+ try {
14494
+ bull = await import('bullmq');
14495
+ } catch {
14496
+ throw new Error(
14497
+ "mailery: queue driver 'bull' requires the 'bullmq' peer dependency. Run `npm install bullmq ioredis`."
14498
+ );
14499
+ }
14500
+ const redis = isRedisLike(redisConfig) ? redisConfig : connect(redisConfig);
14501
+ return new _BullDriver(bull, redis);
14502
+ }
14503
+ constructor(bull, redis) {
14504
+ this.bull = bull;
14505
+ this.redis = redis;
14506
+ const opts = { connection: redis };
14507
+ this.bullQueues = {
14508
+ tick: new bull.Queue(QUEUE_NAMES.tick, opts),
14509
+ advance: new bull.Queue(QUEUE_NAMES.advance, opts),
14510
+ send: new bull.Queue(QUEUE_NAMES.send, opts),
14511
+ webhook: new bull.Queue(QUEUE_NAMES.webhook, opts)
14512
+ };
14513
+ this.queues = {
14514
+ tick: adaptBullQueue(this.bullQueues.tick),
14515
+ advance: adaptBullQueue(this.bullQueues.advance),
14516
+ send: adaptBullQueue(this.bullQueues.send),
14517
+ webhook: adaptBullQueue(this.bullQueues.webhook)
14518
+ };
14519
+ }
14520
+ async scheduleRepeatingTick(intervalSeconds) {
14521
+ await this.bullQueues.tick.upsertJobScheduler(
14522
+ "mailer-tick-repeat",
14523
+ { every: intervalSeconds * 1e3 },
14524
+ { name: "tick", data: {} }
14525
+ );
14526
+ }
14527
+ async startWorkers(opts) {
14528
+ if (this.workers) return;
14529
+ const base = { connection: this.redis };
14530
+ const { Worker } = this.bull;
14531
+ const tick = new Worker(
14532
+ QUEUE_NAMES.tick,
14533
+ async (job) => opts.handlers.tick(job.data),
14534
+ { ...base, concurrency: 1 }
14535
+ );
14536
+ const advance = new Worker(
14537
+ QUEUE_NAMES.advance,
14538
+ async (job) => opts.handlers.advance(job.data),
14539
+ { ...base, concurrency: 10 }
14540
+ );
14541
+ const send = new Worker(
14542
+ QUEUE_NAMES.send,
14543
+ async (job) => opts.handlers.send(job.data),
14544
+ {
14545
+ ...base,
14546
+ concurrency: opts.concurrency.send,
14547
+ limiter: opts.sendRateLimit ? { max: opts.sendRateLimit.max, duration: opts.sendRateLimit.durationMs } : void 0
14548
+ }
14549
+ );
14550
+ const webhook = new Worker(
14551
+ QUEUE_NAMES.webhook,
14552
+ async (job) => opts.handlers.webhook(job.data),
14553
+ { ...base, concurrency: 4 }
14554
+ );
14555
+ this.workers = { tick, advance, send, webhook };
14556
+ }
14557
+ async stopWorkers() {
14558
+ if (!this.workers) return;
14559
+ await Promise.all([
14560
+ this.workers.tick.close(),
14561
+ this.workers.advance.close(),
14562
+ this.workers.send.close(),
14563
+ this.workers.webhook.close()
14564
+ ]);
14565
+ this.workers = null;
14566
+ }
14567
+ async close() {
14568
+ await this.stopWorkers();
14569
+ await Promise.all([
14570
+ this.bullQueues.tick.close(),
14571
+ this.bullQueues.advance.close(),
14572
+ this.bullQueues.send.close(),
14573
+ this.bullQueues.webhook.close()
14574
+ ]);
14575
+ }
14576
+ };
14479
14577
  function adaptBullQueue(q) {
14480
14578
  return {
14481
14579
  add: (name, data, opts) => q.add(name, data, opts),
@@ -14483,39 +14581,16 @@ function adaptBullQueue(q) {
14483
14581
  close: () => q.close()
14484
14582
  };
14485
14583
  }
14486
- function noopQueueAPI() {
14487
- return {
14488
- add: async () => void 0,
14489
- getWaitingCount: async () => 0,
14490
- close: async () => void 0
14491
- };
14492
- }
14493
- function noopQueues() {
14494
- return {
14495
- tick: noopQueueAPI(),
14496
- advance: noopQueueAPI(),
14497
- send: noopQueueAPI(),
14498
- webhook: noopQueueAPI()
14499
- };
14500
- }
14501
- function namespacedQueueNames(prefix) {
14502
- return {
14503
- tick: "mailer-tick",
14504
- advance: "mailer-advance",
14505
- send: "mailer-send",
14506
- webhook: "mailer-webhook"
14507
- };
14584
+ function isRedisLike(x) {
14585
+ return !!x && typeof x === "object" && typeof x.get === "function" && typeof x.set === "function";
14508
14586
  }
14509
- function makeRedis(opts) {
14510
- if (isRedisLike(opts)) return opts;
14587
+ function connect(opts) {
14511
14588
  const config = {
14512
14589
  maxRetriesPerRequest: null,
14513
14590
  // BullMQ requirement
14514
14591
  enableReadyCheck: false
14515
14592
  };
14516
- if (opts.url) {
14517
- return new IORedis__default.default(opts.url, config);
14518
- }
14593
+ if (opts.url) return new IORedis__default.default(opts.url, config);
14519
14594
  return new IORedis__default.default({
14520
14595
  ...config,
14521
14596
  host: opts.host ?? "127.0.0.1",
@@ -14526,69 +14601,198 @@ function makeRedis(opts) {
14526
14601
  tls: opts.tls ? {} : void 0
14527
14602
  });
14528
14603
  }
14529
- function isRedisLike(x) {
14530
- return !!x && typeof x === "object" && typeof x.get === "function" && typeof x.set === "function";
14531
- }
14532
- function createQueues(redis) {
14533
- const names = namespacedQueueNames();
14534
- const qOpts = { connection: redis };
14535
- const bullQueues = {
14536
- tick: new bullmq.Queue(names.tick, qOpts),
14537
- advance: new bullmq.Queue(names.advance, qOpts),
14538
- send: new bullmq.Queue(names.send, qOpts),
14539
- webhook: new bullmq.Queue(names.webhook, qOpts)
14540
- };
14604
+
14605
+ // src/server/queues/agenda.ts
14606
+ var QUEUE_NAMES2 = {
14607
+ tick: "mailer-tick",
14608
+ advance: "mailer-advance",
14609
+ send: "mailer-send",
14610
+ webhook: "mailer-webhook"
14611
+ };
14612
+ var AgendaDriver = class _AgendaDriver {
14613
+ queues;
14614
+ agenda;
14615
+ agendaMod;
14616
+ sendLimiter = null;
14617
+ started = false;
14618
+ static async create(opts) {
14619
+ let agendaMod;
14620
+ let backendMod;
14621
+ try {
14622
+ agendaMod = await import('agenda');
14623
+ backendMod = await import('@agendajs/mongo-backend');
14624
+ } catch {
14625
+ throw new Error(
14626
+ "mailery: queue driver 'agenda' requires the 'agenda' and '@agendajs/mongo-backend' peer dependencies. Run `npm install agenda @agendajs/mongo-backend bottleneck`."
14627
+ );
14628
+ }
14629
+ const backend = new backendMod.MongoBackend({
14630
+ mongo: opts.db,
14631
+ collection: opts.collectionName ?? "_mailerJobs"
14632
+ });
14633
+ const agenda = new agendaMod.Agenda({
14634
+ backend,
14635
+ processEvery: `${opts.processEverySeconds ?? 5} seconds`,
14636
+ defaultLockLifetime: (opts.lockLifetimeSeconds ?? 10 * 60) * 1e3,
14637
+ maxConcurrency: 50,
14638
+ defaultConcurrency: 5
14639
+ });
14640
+ return new _AgendaDriver(agenda, agendaMod, opts.db);
14641
+ }
14642
+ db;
14643
+ constructor(agenda, agendaMod, db) {
14644
+ this.agenda = agenda;
14645
+ this.agendaMod = agendaMod;
14646
+ this.db = db;
14647
+ this.queues = {
14648
+ tick: this.makeQueueAPI(QUEUE_NAMES2.tick),
14649
+ advance: this.makeQueueAPI(QUEUE_NAMES2.advance),
14650
+ send: this.makeQueueAPI(QUEUE_NAMES2.send),
14651
+ webhook: this.makeQueueAPI(QUEUE_NAMES2.webhook)
14652
+ };
14653
+ }
14654
+ makeQueueAPI(name) {
14655
+ return {
14656
+ add: async (_jobName, data, opts) => {
14657
+ const payload = { ...data };
14658
+ if (opts?.jobId) {
14659
+ payload.__jobId = opts.jobId;
14660
+ if (await this.findPending(name, opts.jobId)) return;
14661
+ }
14662
+ const job = this.agenda.create(name, payload);
14663
+ if (opts?.delay) job.schedule(new Date(Date.now() + opts.delay));
14664
+ await job.save();
14665
+ },
14666
+ getWaitingCount: async () => {
14667
+ return this.jobsCollection().countDocuments({
14668
+ name,
14669
+ $or: [{ lockedAt: null }, { lockedAt: { $exists: false } }],
14670
+ nextRunAt: { $lte: /* @__PURE__ */ new Date() }
14671
+ });
14672
+ },
14673
+ close: async () => {
14674
+ }
14675
+ };
14676
+ }
14677
+ /** Direct access to the Mongo collection Agenda persists jobs into. */
14678
+ jobsCollection() {
14679
+ return this.db.collection(this.collectionName());
14680
+ }
14681
+ collectionName() {
14682
+ return "_mailerJobs";
14683
+ }
14684
+ async findPending(name, jobId) {
14685
+ return this.jobsCollection().findOne({
14686
+ name,
14687
+ "data.__jobId": jobId,
14688
+ $or: [{ lastFinishedAt: null }, { lastFinishedAt: { $exists: false } }]
14689
+ });
14690
+ }
14691
+ async scheduleRepeatingTick(intervalSeconds) {
14692
+ if (!this.started) {
14693
+ this.agenda.define(QUEUE_NAMES2.tick, async () => {
14694
+ }, { concurrency: 1 });
14695
+ await this.agenda.start();
14696
+ this.started = true;
14697
+ }
14698
+ await this.agenda.every(`${intervalSeconds} seconds`, QUEUE_NAMES2.tick);
14699
+ }
14700
+ async startWorkers(opts) {
14701
+ const exp = this.agendaMod.backoffStrategies.exponential;
14702
+ if (opts.sendRateLimit) {
14703
+ try {
14704
+ const Bottleneck = (await import('bottleneck')).default;
14705
+ this.sendLimiter = new Bottleneck({
14706
+ minTime: Math.ceil(opts.sendRateLimit.durationMs / opts.sendRateLimit.max),
14707
+ maxConcurrent: opts.concurrency.send
14708
+ });
14709
+ } catch {
14710
+ throw new Error(
14711
+ "mailery: queue driver 'agenda' with sendRateLimit requires the 'bottleneck' peer dependency. Run `npm install bottleneck`."
14712
+ );
14713
+ }
14714
+ }
14715
+ const retryBackoff = exp({ delay: 6e4, maxRetries: Math.max(0, opts.retryAttempts - 1), factor: 2 });
14716
+ this.agenda.define(QUEUE_NAMES2.tick, async (job) => {
14717
+ await opts.handlers.tick(job.attrs.data);
14718
+ }, { concurrency: 1 });
14719
+ this.agenda.define(QUEUE_NAMES2.advance, async (job) => {
14720
+ await opts.handlers.advance(job.attrs.data);
14721
+ }, { concurrency: 10, backoff: retryBackoff });
14722
+ this.agenda.define(QUEUE_NAMES2.send, async (job) => {
14723
+ const data = job.attrs.data;
14724
+ if (this.sendLimiter) {
14725
+ await this.sendLimiter.schedule(() => opts.handlers.send(data));
14726
+ } else {
14727
+ await opts.handlers.send(data);
14728
+ }
14729
+ }, { concurrency: opts.concurrency.send, backoff: retryBackoff });
14730
+ this.agenda.define(QUEUE_NAMES2.webhook, async (job) => {
14731
+ await opts.handlers.webhook(job.attrs.data);
14732
+ }, { concurrency: 4, backoff: retryBackoff });
14733
+ if (!this.started) {
14734
+ await this.agenda.start();
14735
+ this.started = true;
14736
+ }
14737
+ }
14738
+ async stopWorkers() {
14739
+ if (!this.started) return;
14740
+ await this.agenda.stop();
14741
+ this.started = false;
14742
+ if (this.sendLimiter) {
14743
+ await this.sendLimiter.stop({ dropWaitingJobs: true }).catch(() => {
14744
+ });
14745
+ this.sendLimiter = null;
14746
+ }
14747
+ }
14748
+ async close() {
14749
+ await this.stopWorkers();
14750
+ }
14751
+ };
14752
+
14753
+ // src/server/queues/noop.ts
14754
+ function noopQueueAPI() {
14541
14755
  return {
14542
- queues: {
14543
- tick: adaptBullQueue(bullQueues.tick),
14544
- advance: adaptBullQueue(bullQueues.advance),
14545
- send: adaptBullQueue(bullQueues.send),
14546
- webhook: adaptBullQueue(bullQueues.webhook)
14547
- },
14548
- bullQueues
14756
+ add: async () => void 0,
14757
+ getWaitingCount: async () => 0,
14758
+ close: async () => void 0
14549
14759
  };
14550
14760
  }
14551
- async function scheduleTick(bullQueues, intervalSeconds) {
14552
- await bullQueues.tick.upsertJobScheduler(
14553
- "mailer-tick-repeat",
14554
- { every: intervalSeconds * 1e3 },
14555
- { name: "tick", data: {} }
14556
- );
14557
- }
14558
- function createWorkers(input) {
14559
- const names = namespacedQueueNames();
14560
- const base = { connection: input.redis };
14561
- const tick = new bullmq.Worker(names.tick, async (job) => input.handlers.tick(job.data), {
14562
- ...base,
14563
- concurrency: 1
14564
- // single tick driver per worker process
14565
- });
14566
- const advance = new bullmq.Worker(
14567
- names.advance,
14568
- async (job) => input.handlers.advance(job.data),
14569
- { ...base, concurrency: 10 }
14570
- );
14571
- const sendOpts = {
14572
- ...base,
14573
- concurrency: input.concurrency.send,
14574
- limiter: input.sendRateLimit ? { max: input.sendRateLimit.max, duration: input.sendRateLimit.durationMs } : void 0
14761
+ var NoopDriver = class {
14762
+ queues = {
14763
+ tick: noopQueueAPI(),
14764
+ advance: noopQueueAPI(),
14765
+ send: noopQueueAPI(),
14766
+ webhook: noopQueueAPI()
14575
14767
  };
14576
- const send = new bullmq.Worker(names.send, async (job) => input.handlers.send(job.data), sendOpts);
14577
- const webhook = new bullmq.Worker(
14578
- names.webhook,
14579
- async (job) => input.handlers.webhook(job.data),
14580
- { ...base, concurrency: 4 }
14581
- );
14582
- return { tick, advance, send, webhook };
14583
- }
14584
- async function closeQueues(queues) {
14585
- await Promise.all([queues.tick.close(), queues.advance.close(), queues.send.close(), queues.webhook.close()]);
14586
- }
14587
- async function closeBullQueues(b) {
14588
- await Promise.all([b.tick.close(), b.advance.close(), b.send.close(), b.webhook.close()]);
14589
- }
14590
- async function closeWorkers(workers) {
14591
- await Promise.all([workers.tick.close(), workers.advance.close(), workers.send.close(), workers.webhook.close()]);
14768
+ async scheduleRepeatingTick(_intervalSeconds) {
14769
+ }
14770
+ async startWorkers(_opts) {
14771
+ }
14772
+ async stopWorkers() {
14773
+ }
14774
+ async close() {
14775
+ }
14776
+ };
14777
+
14778
+ // src/server/queues/index.ts
14779
+ async function createQueueDriver(config, fallbackDb) {
14780
+ switch (config.driver) {
14781
+ case "bull":
14782
+ return BullDriver.create(config.redis);
14783
+ case "agenda":
14784
+ return AgendaDriver.create({
14785
+ db: config.db ?? fallbackDb,
14786
+ processEverySeconds: config.processEverySeconds,
14787
+ lockLifetimeSeconds: config.lockLifetimeSeconds,
14788
+ collectionName: config.collectionName
14789
+ });
14790
+ case "noop":
14791
+ return new NoopDriver();
14792
+ default: {
14793
+ throw new Error(`mailery: unknown queue driver`);
14794
+ }
14795
+ }
14592
14796
  }
14593
14797
 
14594
14798
  // src/server/runner/triggers.ts
@@ -15075,7 +15279,8 @@ async function dispatchSend(sendId, ctx) {
15075
15279
  status: "sending",
15076
15280
  fromName: rendered.fromName,
15077
15281
  fromEmail: rendered.fromEmail,
15078
- subject: rendered.subject
15282
+ subject: rendered.subject,
15283
+ updatedAt: /* @__PURE__ */ new Date()
15079
15284
  }
15080
15285
  }
15081
15286
  );
@@ -15149,6 +15354,7 @@ function buildRenderContext(contact, run, vars, ctx) {
15149
15354
  };
15150
15355
  }
15151
15356
  function newSendDoc(input) {
15357
+ const now = /* @__PURE__ */ new Date();
15152
15358
  return {
15153
15359
  _id: input._id,
15154
15360
  dedupeKey: input.dedupeKey,
@@ -15179,7 +15385,8 @@ function newSendDoc(input) {
15179
15385
  clickedLinks: [],
15180
15386
  unsubscribedAt: null,
15181
15387
  complainedAt: null,
15182
- queuedAt: /* @__PURE__ */ new Date(),
15388
+ queuedAt: now,
15389
+ updatedAt: now,
15183
15390
  sentAt: null,
15184
15391
  deliveredAt: null
15185
15392
  };
@@ -15661,6 +15868,7 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
15661
15868
  unsubscribedAt: null,
15662
15869
  complainedAt: null,
15663
15870
  queuedAt: /* @__PURE__ */ new Date(),
15871
+ updatedAt: /* @__PURE__ */ new Date(),
15664
15872
  sentAt: null,
15665
15873
  deliveredAt: null
15666
15874
  };
@@ -15733,6 +15941,7 @@ async function promoteSoftBounces(ctx) {
15733
15941
  }
15734
15942
 
15735
15943
  // src/server/runner/tick.ts
15944
+ var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
15736
15945
  async function runTick(ctx) {
15737
15946
  await processNewlyFiredEventTriggers(ctx).catch((err) => {
15738
15947
  console.error("mailery: triggers scan failed", err);
@@ -15740,6 +15949,9 @@ async function runTick(ctx) {
15740
15949
  await sweepStrandedFlowRuns(ctx).catch((err) => {
15741
15950
  console.error("mailery: sweep failed", err);
15742
15951
  });
15952
+ await sweepStrandedSends(ctx).catch((err) => {
15953
+ console.error("mailery: stranded-send sweep failed", err);
15954
+ });
15743
15955
  await drainOutbox(ctx).catch((err) => {
15744
15956
  console.error("mailery: outbox drain failed", err);
15745
15957
  });
@@ -15753,6 +15965,24 @@ async function runTick(ctx) {
15753
15965
  console.error("mailery: soft-bounce promotion failed", err);
15754
15966
  });
15755
15967
  }
15968
+ async function sweepStrandedSends(ctx) {
15969
+ const cutoff = new Date(Date.now() - STRANDED_SEND_THRESHOLD_MS);
15970
+ const cursor = ctx.collections.sends.find(
15971
+ { status: "sending", updatedAt: { $lt: cutoff } },
15972
+ { projection: { _id: 1 } }
15973
+ ).limit(500);
15974
+ for await (const row of cursor) {
15975
+ const reset = await ctx.collections.sends.updateOne(
15976
+ { _id: row._id, status: "sending", updatedAt: { $lt: cutoff } },
15977
+ { $set: { status: "queued", updatedAt: /* @__PURE__ */ new Date() } }
15978
+ );
15979
+ if (reset.modifiedCount === 0) continue;
15980
+ await ctx.queues.send.add("send", { sendId: String(row._id) }, {
15981
+ attempts: ctx.config.sendRetryAttempts,
15982
+ backoff: { type: "exponential", delay: 6e4 }
15983
+ });
15984
+ }
15985
+ }
15756
15986
  async function drainOutbox(ctx) {
15757
15987
  const batch = await ctx.collections.outbox.find({ status: "pending" }).sort({ enqueuedAt: 1 }).limit(200).toArray();
15758
15988
  for (const row of batch) {
@@ -15923,12 +16153,11 @@ var Mailer = class _Mailer {
15923
16153
  collections;
15924
16154
  adapter;
15925
16155
  providers;
15926
- redis;
15927
16156
  queues;
15928
16157
  config;
15929
16158
  events;
15930
- workers = null;
15931
- bullQueues;
16159
+ queueDriver;
16160
+ workersStarted = false;
15932
16161
  runnerContext;
15933
16162
  constructor(args) {
15934
16163
  this.config = args.config;
@@ -15936,9 +16165,8 @@ var Mailer = class _Mailer {
15936
16165
  this.collections = args.collections;
15937
16166
  this.adapter = args.adapter;
15938
16167
  this.providers = args.providers;
15939
- this.redis = args.redis;
15940
- this.queues = args.queues;
15941
- this.bullQueues = args.bullQueues;
16168
+ this.queueDriver = args.queueDriver;
16169
+ this.queues = args.queueDriver.queues;
15942
16170
  this.events = args.events;
15943
16171
  this.runnerContext = {
15944
16172
  db: this.db,
@@ -16003,10 +16231,12 @@ var Mailer = class _Mailer {
16003
16231
  throw new Error("Mailer.fromEnv: no provider configured (set MAILER_SENDGRID_API_KEY, ...)");
16004
16232
  }
16005
16233
  const defaultProvider = env.MAILER_DEFAULT_PROVIDER ?? Object.keys(providers)[0];
16234
+ const driverEnv = env.MAILER_QUEUE_DRIVER ?? "bull";
16235
+ const queue = driverEnv === "agenda" ? { driver: "agenda" } : driverEnv === "noop" ? { driver: "noop" } : { driver: "bull", redis: { url: required("MAILER_REDIS_URL") } };
16006
16236
  return _Mailer.init({
16007
16237
  db,
16008
16238
  adapter,
16009
- redis: { url: required("MAILER_REDIS_URL") },
16239
+ queue,
16010
16240
  providers,
16011
16241
  defaultProvider,
16012
16242
  publicUrl: required("MAILER_PUBLIC_URL"),
@@ -16022,19 +16252,9 @@ var Mailer = class _Mailer {
16022
16252
  }
16023
16253
  const collections = getCollections(config.db, config.collectionPrefix);
16024
16254
  await ensureIndexes(config.db, config.collectionPrefix);
16025
- let redis = null;
16026
- let queues;
16027
- let bullQueues = null;
16028
- if (config.redis === null) {
16029
- queues = noopQueues();
16030
- } else {
16031
- redis = makeRedis(config.redis);
16032
- const created = createQueues(redis);
16033
- queues = created.queues;
16034
- bullQueues = created.bullQueues;
16035
- if (!config.workerless) {
16036
- await scheduleTick(bullQueues, config.tickIntervalSeconds);
16037
- }
16255
+ const queueDriver = await createQueueDriver(config.queue, config.db);
16256
+ if (!config.workerless && config.queue.driver !== "noop") {
16257
+ await queueDriver.scheduleRepeatingTick(config.tickIntervalSeconds);
16038
16258
  }
16039
16259
  return new _Mailer({
16040
16260
  config,
@@ -16042,9 +16262,7 @@ var Mailer = class _Mailer {
16042
16262
  collections,
16043
16263
  adapter: config.adapter,
16044
16264
  providers: config.providers,
16045
- redis,
16046
- queues,
16047
- bullQueues,
16265
+ queueDriver,
16048
16266
  events: new EventRegistry()
16049
16267
  });
16050
16268
  }
@@ -16352,6 +16570,7 @@ var Mailer = class _Mailer {
16352
16570
  unsubscribedAt: null,
16353
16571
  complainedAt: null,
16354
16572
  queuedAt: /* @__PURE__ */ new Date(),
16573
+ updatedAt: /* @__PURE__ */ new Date(),
16355
16574
  sentAt: null,
16356
16575
  deliveredAt: null
16357
16576
  });
@@ -16383,14 +16602,16 @@ var Mailer = class _Mailer {
16383
16602
  // Workers
16384
16603
  // -------------------------------------------------------------------------
16385
16604
  async startWorkers() {
16386
- if (this.workers) return;
16387
- if (!this.redis) throw new Error("startWorkers requires a Redis connection (redis was null in config)");
16605
+ if (this.workersStarted) return;
16606
+ if (this.config.queue.driver === "noop") {
16607
+ throw new Error("startWorkers requires a non-noop queue driver");
16608
+ }
16388
16609
  const provider = this.providers[this.config.defaultProvider];
16389
16610
  const sendRate = provider?.sendRatePerSecond ?? this.config.sendRatePerSecond;
16390
- this.workers = createWorkers({
16391
- redis: this.redis,
16611
+ await this.queueDriver.startWorkers({
16392
16612
  concurrency: { send: this.config.sendConcurrency },
16393
16613
  sendRateLimit: { max: sendRate, durationMs: 1e3 },
16614
+ retryAttempts: this.config.sendRetryAttempts,
16394
16615
  handlers: {
16395
16616
  tick: async () => {
16396
16617
  await runTick(this.runnerContext);
@@ -16408,6 +16629,7 @@ var Mailer = class _Mailer {
16408
16629
  }
16409
16630
  }
16410
16631
  });
16632
+ this.workersStarted = true;
16411
16633
  }
16412
16634
  /** Process unprocessed webhook events in mailer_webhook_events. */
16413
16635
  async processWebhookBacklog() {
@@ -16434,20 +16656,8 @@ var Mailer = class _Mailer {
16434
16656
  }
16435
16657
  }
16436
16658
  async stop() {
16437
- if (this.workers) {
16438
- await closeWorkers(this.workers);
16439
- this.workers = null;
16440
- }
16441
- if (this.bullQueues) {
16442
- await closeBullQueues(this.bullQueues);
16443
- this.bullQueues = null;
16444
- } else {
16445
- await closeQueues(this.queues);
16446
- }
16447
- if (this.redis) {
16448
- await this.redis.quit().catch(() => {
16449
- });
16450
- }
16659
+ await this.queueDriver.close();
16660
+ this.workersStarted = false;
16451
16661
  }
16452
16662
  /** Used internally by the admin router and tests; not part of the public API. */
16453
16663
  getRunnerContext() {
@@ -16543,7 +16753,7 @@ async function createTestMailer(opts = {}) {
16543
16753
  const mailer = await Mailer.init({
16544
16754
  db,
16545
16755
  adapter,
16546
- redis: null,
16756
+ queue: { driver: "noop" },
16547
16757
  providers: { null: provider },
16548
16758
  defaultProvider: "null",
16549
16759
  publicUrl: "http://localhost:3000",