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/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,13 +15941,36 @@ 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) {
15946
+ await ctx.collections.health.updateOne(
15947
+ { _id: "singleton" },
15948
+ {
15949
+ $set: { updatedAt: /* @__PURE__ */ new Date() },
15950
+ $setOnInsert: {
15951
+ _id: "singleton",
15952
+ windowStartedAt: /* @__PURE__ */ new Date(),
15953
+ windowDurationMs: ctx.config.circuitBreaker.windowMinutes * 60 * 1e3,
15954
+ status: "healthy",
15955
+ trippedAt: null,
15956
+ trippedReason: null,
15957
+ manuallyResumedAt: null,
15958
+ counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
15959
+ rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
15960
+ }
15961
+ },
15962
+ { upsert: true }
15963
+ ).catch(() => {
15964
+ });
15737
15965
  await processNewlyFiredEventTriggers(ctx).catch((err) => {
15738
15966
  console.error("mailery: triggers scan failed", err);
15739
15967
  });
15740
15968
  await sweepStrandedFlowRuns(ctx).catch((err) => {
15741
15969
  console.error("mailery: sweep failed", err);
15742
15970
  });
15971
+ await sweepStrandedSends(ctx).catch((err) => {
15972
+ console.error("mailery: stranded-send sweep failed", err);
15973
+ });
15743
15974
  await drainOutbox(ctx).catch((err) => {
15744
15975
  console.error("mailery: outbox drain failed", err);
15745
15976
  });
@@ -15753,6 +15984,24 @@ async function runTick(ctx) {
15753
15984
  console.error("mailery: soft-bounce promotion failed", err);
15754
15985
  });
15755
15986
  }
15987
+ async function sweepStrandedSends(ctx) {
15988
+ const cutoff = new Date(Date.now() - STRANDED_SEND_THRESHOLD_MS);
15989
+ const cursor = ctx.collections.sends.find(
15990
+ { status: "sending", updatedAt: { $lt: cutoff } },
15991
+ { projection: { _id: 1 } }
15992
+ ).limit(500);
15993
+ for await (const row of cursor) {
15994
+ const reset = await ctx.collections.sends.updateOne(
15995
+ { _id: row._id, status: "sending", updatedAt: { $lt: cutoff } },
15996
+ { $set: { status: "queued", updatedAt: /* @__PURE__ */ new Date() } }
15997
+ );
15998
+ if (reset.modifiedCount === 0) continue;
15999
+ await ctx.queues.send.add("send", { sendId: String(row._id) }, {
16000
+ attempts: ctx.config.sendRetryAttempts,
16001
+ backoff: { type: "exponential", delay: 6e4 }
16002
+ });
16003
+ }
16004
+ }
15756
16005
  async function drainOutbox(ctx) {
15757
16006
  const batch = await ctx.collections.outbox.find({ status: "pending" }).sort({ enqueuedAt: 1 }).limit(200).toArray();
15758
16007
  for (const row of batch) {
@@ -15923,12 +16172,11 @@ var Mailer = class _Mailer {
15923
16172
  collections;
15924
16173
  adapter;
15925
16174
  providers;
15926
- redis;
15927
16175
  queues;
15928
16176
  config;
15929
16177
  events;
15930
- workers = null;
15931
- bullQueues;
16178
+ queueDriver;
16179
+ workersStarted = false;
15932
16180
  runnerContext;
15933
16181
  constructor(args) {
15934
16182
  this.config = args.config;
@@ -15936,9 +16184,8 @@ var Mailer = class _Mailer {
15936
16184
  this.collections = args.collections;
15937
16185
  this.adapter = args.adapter;
15938
16186
  this.providers = args.providers;
15939
- this.redis = args.redis;
15940
- this.queues = args.queues;
15941
- this.bullQueues = args.bullQueues;
16187
+ this.queueDriver = args.queueDriver;
16188
+ this.queues = args.queueDriver.queues;
15942
16189
  this.events = args.events;
15943
16190
  this.runnerContext = {
15944
16191
  db: this.db,
@@ -16003,10 +16250,12 @@ var Mailer = class _Mailer {
16003
16250
  throw new Error("Mailer.fromEnv: no provider configured (set MAILER_SENDGRID_API_KEY, ...)");
16004
16251
  }
16005
16252
  const defaultProvider = env.MAILER_DEFAULT_PROVIDER ?? Object.keys(providers)[0];
16253
+ const driverEnv = env.MAILER_QUEUE_DRIVER ?? "bull";
16254
+ const queue = driverEnv === "agenda" ? { driver: "agenda" } : driverEnv === "noop" ? { driver: "noop" } : { driver: "bull", redis: { url: required("MAILER_REDIS_URL") } };
16006
16255
  return _Mailer.init({
16007
16256
  db,
16008
16257
  adapter,
16009
- redis: { url: required("MAILER_REDIS_URL") },
16258
+ queue,
16010
16259
  providers,
16011
16260
  defaultProvider,
16012
16261
  publicUrl: required("MAILER_PUBLIC_URL"),
@@ -16022,19 +16271,9 @@ var Mailer = class _Mailer {
16022
16271
  }
16023
16272
  const collections = getCollections(config.db, config.collectionPrefix);
16024
16273
  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
- }
16274
+ const queueDriver = await createQueueDriver(config.queue, config.db);
16275
+ if (!config.workerless && config.queue.driver !== "noop") {
16276
+ await queueDriver.scheduleRepeatingTick(config.tickIntervalSeconds);
16038
16277
  }
16039
16278
  return new _Mailer({
16040
16279
  config,
@@ -16042,9 +16281,7 @@ var Mailer = class _Mailer {
16042
16281
  collections,
16043
16282
  adapter: config.adapter,
16044
16283
  providers: config.providers,
16045
- redis,
16046
- queues,
16047
- bullQueues,
16284
+ queueDriver,
16048
16285
  events: new EventRegistry()
16049
16286
  });
16050
16287
  }
@@ -16352,6 +16589,7 @@ var Mailer = class _Mailer {
16352
16589
  unsubscribedAt: null,
16353
16590
  complainedAt: null,
16354
16591
  queuedAt: /* @__PURE__ */ new Date(),
16592
+ updatedAt: /* @__PURE__ */ new Date(),
16355
16593
  sentAt: null,
16356
16594
  deliveredAt: null
16357
16595
  });
@@ -16383,14 +16621,16 @@ var Mailer = class _Mailer {
16383
16621
  // Workers
16384
16622
  // -------------------------------------------------------------------------
16385
16623
  async startWorkers() {
16386
- if (this.workers) return;
16387
- if (!this.redis) throw new Error("startWorkers requires a Redis connection (redis was null in config)");
16624
+ if (this.workersStarted) return;
16625
+ if (this.config.queue.driver === "noop") {
16626
+ throw new Error("startWorkers requires a non-noop queue driver");
16627
+ }
16388
16628
  const provider = this.providers[this.config.defaultProvider];
16389
16629
  const sendRate = provider?.sendRatePerSecond ?? this.config.sendRatePerSecond;
16390
- this.workers = createWorkers({
16391
- redis: this.redis,
16630
+ await this.queueDriver.startWorkers({
16392
16631
  concurrency: { send: this.config.sendConcurrency },
16393
16632
  sendRateLimit: { max: sendRate, durationMs: 1e3 },
16633
+ retryAttempts: this.config.sendRetryAttempts,
16394
16634
  handlers: {
16395
16635
  tick: async () => {
16396
16636
  await runTick(this.runnerContext);
@@ -16408,6 +16648,7 @@ var Mailer = class _Mailer {
16408
16648
  }
16409
16649
  }
16410
16650
  });
16651
+ this.workersStarted = true;
16411
16652
  }
16412
16653
  /** Process unprocessed webhook events in mailer_webhook_events. */
16413
16654
  async processWebhookBacklog() {
@@ -16434,20 +16675,8 @@ var Mailer = class _Mailer {
16434
16675
  }
16435
16676
  }
16436
16677
  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
- }
16678
+ await this.queueDriver.close();
16679
+ this.workersStarted = false;
16451
16680
  }
16452
16681
  /** Used internally by the admin router and tests; not part of the public API. */
16453
16682
  getRunnerContext() {
@@ -16543,7 +16772,7 @@ async function createTestMailer(opts = {}) {
16543
16772
  const mailer = await Mailer.init({
16544
16773
  db,
16545
16774
  adapter,
16546
- redis: null,
16775
+ queue: { driver: "noop" },
16547
16776
  providers: { null: provider },
16548
16777
  defaultProvider: "null",
16549
16778
  publicUrl: "http://localhost:3000",