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.js CHANGED
@@ -2,7 +2,6 @@ import { MongoClient, 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';
@@ -9490,10 +9489,10 @@ var require_dist2 = __commonJS({
9490
9489
  const proxyResponsePromise = (0, parse_proxy_response_1.parseProxyResponse)(socket);
9491
9490
  socket.write(`${payload}\r
9492
9491
  `);
9493
- const { connect, buffered } = await proxyResponsePromise;
9494
- req.emit("proxyConnect", connect);
9495
- this.emit("proxyConnect", connect, req);
9496
- if (connect.statusCode === 200) {
9492
+ const { connect: connect2, buffered } = await proxyResponsePromise;
9493
+ req.emit("proxyConnect", connect2);
9494
+ this.emit("proxyConnect", connect2, req);
9495
+ if (connect2.statusCode === 200) {
9497
9496
  req.once("socket", resume);
9498
9497
  if (opts.secureEndpoint) {
9499
9498
  debug("Upgrading socket connection to TLS");
@@ -14374,7 +14373,8 @@ async function ensureIndexes(db, prefix = "mailer_") {
14374
14373
  { key: { flowRunId: 1 }, sparse: true },
14375
14374
  { key: { broadcastId: 1 }, sparse: true },
14376
14375
  { key: { providerMessageId: 1 }, sparse: true },
14377
- { key: { status: 1, queuedAt: 1 } }
14376
+ { key: { status: 1, queuedAt: 1 } },
14377
+ { key: { status: 1, updatedAt: 1 } }
14378
14378
  ]),
14379
14379
  c.suppressions.createIndexes([
14380
14380
  { key: { email: 1, scope: 1 }, unique: true, partialFilterExpression: { email: { $type: "string" } } },
@@ -14466,6 +14466,104 @@ function signDoiToken(payload, secret) {
14466
14466
  const hmac = crypto2.createHmac("sha256", secret).update(bodyB64).digest();
14467
14467
  return `${bodyB64}.${b64url(hmac)}`;
14468
14468
  }
14469
+ var QUEUE_NAMES = {
14470
+ tick: "mailer-tick",
14471
+ advance: "mailer-advance",
14472
+ send: "mailer-send",
14473
+ webhook: "mailer-webhook"
14474
+ };
14475
+ var BullDriver = class _BullDriver {
14476
+ queues;
14477
+ redis;
14478
+ bullQueues;
14479
+ workers = null;
14480
+ bull;
14481
+ static async create(redisConfig) {
14482
+ let bull;
14483
+ try {
14484
+ bull = await import('bullmq');
14485
+ } catch {
14486
+ throw new Error(
14487
+ "mailery: queue driver 'bull' requires the 'bullmq' peer dependency. Run `npm install bullmq ioredis`."
14488
+ );
14489
+ }
14490
+ const redis = isRedisLike(redisConfig) ? redisConfig : connect(redisConfig);
14491
+ return new _BullDriver(bull, redis);
14492
+ }
14493
+ constructor(bull, redis) {
14494
+ this.bull = bull;
14495
+ this.redis = redis;
14496
+ const opts = { connection: redis };
14497
+ this.bullQueues = {
14498
+ tick: new bull.Queue(QUEUE_NAMES.tick, opts),
14499
+ advance: new bull.Queue(QUEUE_NAMES.advance, opts),
14500
+ send: new bull.Queue(QUEUE_NAMES.send, opts),
14501
+ webhook: new bull.Queue(QUEUE_NAMES.webhook, opts)
14502
+ };
14503
+ this.queues = {
14504
+ tick: adaptBullQueue(this.bullQueues.tick),
14505
+ advance: adaptBullQueue(this.bullQueues.advance),
14506
+ send: adaptBullQueue(this.bullQueues.send),
14507
+ webhook: adaptBullQueue(this.bullQueues.webhook)
14508
+ };
14509
+ }
14510
+ async scheduleRepeatingTick(intervalSeconds) {
14511
+ await this.bullQueues.tick.upsertJobScheduler(
14512
+ "mailer-tick-repeat",
14513
+ { every: intervalSeconds * 1e3 },
14514
+ { name: "tick", data: {} }
14515
+ );
14516
+ }
14517
+ async startWorkers(opts) {
14518
+ if (this.workers) return;
14519
+ const base = { connection: this.redis };
14520
+ const { Worker } = this.bull;
14521
+ const tick = new Worker(
14522
+ QUEUE_NAMES.tick,
14523
+ async (job) => opts.handlers.tick(job.data),
14524
+ { ...base, concurrency: 1 }
14525
+ );
14526
+ const advance = new Worker(
14527
+ QUEUE_NAMES.advance,
14528
+ async (job) => opts.handlers.advance(job.data),
14529
+ { ...base, concurrency: 10 }
14530
+ );
14531
+ const send = new Worker(
14532
+ QUEUE_NAMES.send,
14533
+ async (job) => opts.handlers.send(job.data),
14534
+ {
14535
+ ...base,
14536
+ concurrency: opts.concurrency.send,
14537
+ limiter: opts.sendRateLimit ? { max: opts.sendRateLimit.max, duration: opts.sendRateLimit.durationMs } : void 0
14538
+ }
14539
+ );
14540
+ const webhook = new Worker(
14541
+ QUEUE_NAMES.webhook,
14542
+ async (job) => opts.handlers.webhook(job.data),
14543
+ { ...base, concurrency: 4 }
14544
+ );
14545
+ this.workers = { tick, advance, send, webhook };
14546
+ }
14547
+ async stopWorkers() {
14548
+ if (!this.workers) return;
14549
+ await Promise.all([
14550
+ this.workers.tick.close(),
14551
+ this.workers.advance.close(),
14552
+ this.workers.send.close(),
14553
+ this.workers.webhook.close()
14554
+ ]);
14555
+ this.workers = null;
14556
+ }
14557
+ async close() {
14558
+ await this.stopWorkers();
14559
+ await Promise.all([
14560
+ this.bullQueues.tick.close(),
14561
+ this.bullQueues.advance.close(),
14562
+ this.bullQueues.send.close(),
14563
+ this.bullQueues.webhook.close()
14564
+ ]);
14565
+ }
14566
+ };
14469
14567
  function adaptBullQueue(q) {
14470
14568
  return {
14471
14569
  add: (name, data, opts) => q.add(name, data, opts),
@@ -14473,39 +14571,16 @@ function adaptBullQueue(q) {
14473
14571
  close: () => q.close()
14474
14572
  };
14475
14573
  }
14476
- function noopQueueAPI() {
14477
- return {
14478
- add: async () => void 0,
14479
- getWaitingCount: async () => 0,
14480
- close: async () => void 0
14481
- };
14482
- }
14483
- function noopQueues() {
14484
- return {
14485
- tick: noopQueueAPI(),
14486
- advance: noopQueueAPI(),
14487
- send: noopQueueAPI(),
14488
- webhook: noopQueueAPI()
14489
- };
14490
- }
14491
- function namespacedQueueNames(prefix) {
14492
- return {
14493
- tick: "mailer-tick",
14494
- advance: "mailer-advance",
14495
- send: "mailer-send",
14496
- webhook: "mailer-webhook"
14497
- };
14574
+ function isRedisLike(x) {
14575
+ return !!x && typeof x === "object" && typeof x.get === "function" && typeof x.set === "function";
14498
14576
  }
14499
- function makeRedis(opts) {
14500
- if (isRedisLike(opts)) return opts;
14577
+ function connect(opts) {
14501
14578
  const config = {
14502
14579
  maxRetriesPerRequest: null,
14503
14580
  // BullMQ requirement
14504
14581
  enableReadyCheck: false
14505
14582
  };
14506
- if (opts.url) {
14507
- return new IORedis(opts.url, config);
14508
- }
14583
+ if (opts.url) return new IORedis(opts.url, config);
14509
14584
  return new IORedis({
14510
14585
  ...config,
14511
14586
  host: opts.host ?? "127.0.0.1",
@@ -14516,69 +14591,198 @@ function makeRedis(opts) {
14516
14591
  tls: opts.tls ? {} : void 0
14517
14592
  });
14518
14593
  }
14519
- function isRedisLike(x) {
14520
- return !!x && typeof x === "object" && typeof x.get === "function" && typeof x.set === "function";
14521
- }
14522
- function createQueues(redis) {
14523
- const names = namespacedQueueNames();
14524
- const qOpts = { connection: redis };
14525
- const bullQueues = {
14526
- tick: new Queue(names.tick, qOpts),
14527
- advance: new Queue(names.advance, qOpts),
14528
- send: new Queue(names.send, qOpts),
14529
- webhook: new Queue(names.webhook, qOpts)
14530
- };
14594
+
14595
+ // src/server/queues/agenda.ts
14596
+ var QUEUE_NAMES2 = {
14597
+ tick: "mailer-tick",
14598
+ advance: "mailer-advance",
14599
+ send: "mailer-send",
14600
+ webhook: "mailer-webhook"
14601
+ };
14602
+ var AgendaDriver = class _AgendaDriver {
14603
+ queues;
14604
+ agenda;
14605
+ agendaMod;
14606
+ sendLimiter = null;
14607
+ started = false;
14608
+ static async create(opts) {
14609
+ let agendaMod;
14610
+ let backendMod;
14611
+ try {
14612
+ agendaMod = await import('agenda');
14613
+ backendMod = await import('@agendajs/mongo-backend');
14614
+ } catch {
14615
+ throw new Error(
14616
+ "mailery: queue driver 'agenda' requires the 'agenda' and '@agendajs/mongo-backend' peer dependencies. Run `npm install agenda @agendajs/mongo-backend bottleneck`."
14617
+ );
14618
+ }
14619
+ const backend = new backendMod.MongoBackend({
14620
+ mongo: opts.db,
14621
+ collection: opts.collectionName ?? "_mailerJobs"
14622
+ });
14623
+ const agenda = new agendaMod.Agenda({
14624
+ backend,
14625
+ processEvery: `${opts.processEverySeconds ?? 5} seconds`,
14626
+ defaultLockLifetime: (opts.lockLifetimeSeconds ?? 10 * 60) * 1e3,
14627
+ maxConcurrency: 50,
14628
+ defaultConcurrency: 5
14629
+ });
14630
+ return new _AgendaDriver(agenda, agendaMod, opts.db);
14631
+ }
14632
+ db;
14633
+ constructor(agenda, agendaMod, db) {
14634
+ this.agenda = agenda;
14635
+ this.agendaMod = agendaMod;
14636
+ this.db = db;
14637
+ this.queues = {
14638
+ tick: this.makeQueueAPI(QUEUE_NAMES2.tick),
14639
+ advance: this.makeQueueAPI(QUEUE_NAMES2.advance),
14640
+ send: this.makeQueueAPI(QUEUE_NAMES2.send),
14641
+ webhook: this.makeQueueAPI(QUEUE_NAMES2.webhook)
14642
+ };
14643
+ }
14644
+ makeQueueAPI(name) {
14645
+ return {
14646
+ add: async (_jobName, data, opts) => {
14647
+ const payload = { ...data };
14648
+ if (opts?.jobId) {
14649
+ payload.__jobId = opts.jobId;
14650
+ if (await this.findPending(name, opts.jobId)) return;
14651
+ }
14652
+ const job = this.agenda.create(name, payload);
14653
+ if (opts?.delay) job.schedule(new Date(Date.now() + opts.delay));
14654
+ await job.save();
14655
+ },
14656
+ getWaitingCount: async () => {
14657
+ return this.jobsCollection().countDocuments({
14658
+ name,
14659
+ $or: [{ lockedAt: null }, { lockedAt: { $exists: false } }],
14660
+ nextRunAt: { $lte: /* @__PURE__ */ new Date() }
14661
+ });
14662
+ },
14663
+ close: async () => {
14664
+ }
14665
+ };
14666
+ }
14667
+ /** Direct access to the Mongo collection Agenda persists jobs into. */
14668
+ jobsCollection() {
14669
+ return this.db.collection(this.collectionName());
14670
+ }
14671
+ collectionName() {
14672
+ return "_mailerJobs";
14673
+ }
14674
+ async findPending(name, jobId) {
14675
+ return this.jobsCollection().findOne({
14676
+ name,
14677
+ "data.__jobId": jobId,
14678
+ $or: [{ lastFinishedAt: null }, { lastFinishedAt: { $exists: false } }]
14679
+ });
14680
+ }
14681
+ async scheduleRepeatingTick(intervalSeconds) {
14682
+ if (!this.started) {
14683
+ this.agenda.define(QUEUE_NAMES2.tick, async () => {
14684
+ }, { concurrency: 1 });
14685
+ await this.agenda.start();
14686
+ this.started = true;
14687
+ }
14688
+ await this.agenda.every(`${intervalSeconds} seconds`, QUEUE_NAMES2.tick);
14689
+ }
14690
+ async startWorkers(opts) {
14691
+ const exp = this.agendaMod.backoffStrategies.exponential;
14692
+ if (opts.sendRateLimit) {
14693
+ try {
14694
+ const Bottleneck = (await import('bottleneck')).default;
14695
+ this.sendLimiter = new Bottleneck({
14696
+ minTime: Math.ceil(opts.sendRateLimit.durationMs / opts.sendRateLimit.max),
14697
+ maxConcurrent: opts.concurrency.send
14698
+ });
14699
+ } catch {
14700
+ throw new Error(
14701
+ "mailery: queue driver 'agenda' with sendRateLimit requires the 'bottleneck' peer dependency. Run `npm install bottleneck`."
14702
+ );
14703
+ }
14704
+ }
14705
+ const retryBackoff = exp({ delay: 6e4, maxRetries: Math.max(0, opts.retryAttempts - 1), factor: 2 });
14706
+ this.agenda.define(QUEUE_NAMES2.tick, async (job) => {
14707
+ await opts.handlers.tick(job.attrs.data);
14708
+ }, { concurrency: 1 });
14709
+ this.agenda.define(QUEUE_NAMES2.advance, async (job) => {
14710
+ await opts.handlers.advance(job.attrs.data);
14711
+ }, { concurrency: 10, backoff: retryBackoff });
14712
+ this.agenda.define(QUEUE_NAMES2.send, async (job) => {
14713
+ const data = job.attrs.data;
14714
+ if (this.sendLimiter) {
14715
+ await this.sendLimiter.schedule(() => opts.handlers.send(data));
14716
+ } else {
14717
+ await opts.handlers.send(data);
14718
+ }
14719
+ }, { concurrency: opts.concurrency.send, backoff: retryBackoff });
14720
+ this.agenda.define(QUEUE_NAMES2.webhook, async (job) => {
14721
+ await opts.handlers.webhook(job.attrs.data);
14722
+ }, { concurrency: 4, backoff: retryBackoff });
14723
+ if (!this.started) {
14724
+ await this.agenda.start();
14725
+ this.started = true;
14726
+ }
14727
+ }
14728
+ async stopWorkers() {
14729
+ if (!this.started) return;
14730
+ await this.agenda.stop();
14731
+ this.started = false;
14732
+ if (this.sendLimiter) {
14733
+ await this.sendLimiter.stop({ dropWaitingJobs: true }).catch(() => {
14734
+ });
14735
+ this.sendLimiter = null;
14736
+ }
14737
+ }
14738
+ async close() {
14739
+ await this.stopWorkers();
14740
+ }
14741
+ };
14742
+
14743
+ // src/server/queues/noop.ts
14744
+ function noopQueueAPI() {
14531
14745
  return {
14532
- queues: {
14533
- tick: adaptBullQueue(bullQueues.tick),
14534
- advance: adaptBullQueue(bullQueues.advance),
14535
- send: adaptBullQueue(bullQueues.send),
14536
- webhook: adaptBullQueue(bullQueues.webhook)
14537
- },
14538
- bullQueues
14746
+ add: async () => void 0,
14747
+ getWaitingCount: async () => 0,
14748
+ close: async () => void 0
14539
14749
  };
14540
14750
  }
14541
- async function scheduleTick(bullQueues, intervalSeconds) {
14542
- await bullQueues.tick.upsertJobScheduler(
14543
- "mailer-tick-repeat",
14544
- { every: intervalSeconds * 1e3 },
14545
- { name: "tick", data: {} }
14546
- );
14547
- }
14548
- function createWorkers(input) {
14549
- const names = namespacedQueueNames();
14550
- const base = { connection: input.redis };
14551
- const tick = new Worker(names.tick, async (job) => input.handlers.tick(job.data), {
14552
- ...base,
14553
- concurrency: 1
14554
- // single tick driver per worker process
14555
- });
14556
- const advance = new Worker(
14557
- names.advance,
14558
- async (job) => input.handlers.advance(job.data),
14559
- { ...base, concurrency: 10 }
14560
- );
14561
- const sendOpts = {
14562
- ...base,
14563
- concurrency: input.concurrency.send,
14564
- limiter: input.sendRateLimit ? { max: input.sendRateLimit.max, duration: input.sendRateLimit.durationMs } : void 0
14751
+ var NoopDriver = class {
14752
+ queues = {
14753
+ tick: noopQueueAPI(),
14754
+ advance: noopQueueAPI(),
14755
+ send: noopQueueAPI(),
14756
+ webhook: noopQueueAPI()
14565
14757
  };
14566
- const send = new Worker(names.send, async (job) => input.handlers.send(job.data), sendOpts);
14567
- const webhook = new Worker(
14568
- names.webhook,
14569
- async (job) => input.handlers.webhook(job.data),
14570
- { ...base, concurrency: 4 }
14571
- );
14572
- return { tick, advance, send, webhook };
14573
- }
14574
- async function closeQueues(queues) {
14575
- await Promise.all([queues.tick.close(), queues.advance.close(), queues.send.close(), queues.webhook.close()]);
14576
- }
14577
- async function closeBullQueues(b) {
14578
- await Promise.all([b.tick.close(), b.advance.close(), b.send.close(), b.webhook.close()]);
14579
- }
14580
- async function closeWorkers(workers) {
14581
- await Promise.all([workers.tick.close(), workers.advance.close(), workers.send.close(), workers.webhook.close()]);
14758
+ async scheduleRepeatingTick(_intervalSeconds) {
14759
+ }
14760
+ async startWorkers(_opts) {
14761
+ }
14762
+ async stopWorkers() {
14763
+ }
14764
+ async close() {
14765
+ }
14766
+ };
14767
+
14768
+ // src/server/queues/index.ts
14769
+ async function createQueueDriver(config, fallbackDb) {
14770
+ switch (config.driver) {
14771
+ case "bull":
14772
+ return BullDriver.create(config.redis);
14773
+ case "agenda":
14774
+ return AgendaDriver.create({
14775
+ db: config.db ?? fallbackDb,
14776
+ processEverySeconds: config.processEverySeconds,
14777
+ lockLifetimeSeconds: config.lockLifetimeSeconds,
14778
+ collectionName: config.collectionName
14779
+ });
14780
+ case "noop":
14781
+ return new NoopDriver();
14782
+ default: {
14783
+ throw new Error(`mailery: unknown queue driver`);
14784
+ }
14785
+ }
14582
14786
  }
14583
14787
 
14584
14788
  // src/server/runner/triggers.ts
@@ -15065,7 +15269,8 @@ async function dispatchSend(sendId, ctx) {
15065
15269
  status: "sending",
15066
15270
  fromName: rendered.fromName,
15067
15271
  fromEmail: rendered.fromEmail,
15068
- subject: rendered.subject
15272
+ subject: rendered.subject,
15273
+ updatedAt: /* @__PURE__ */ new Date()
15069
15274
  }
15070
15275
  }
15071
15276
  );
@@ -15139,6 +15344,7 @@ function buildRenderContext(contact, run, vars, ctx) {
15139
15344
  };
15140
15345
  }
15141
15346
  function newSendDoc(input) {
15347
+ const now = /* @__PURE__ */ new Date();
15142
15348
  return {
15143
15349
  _id: input._id,
15144
15350
  dedupeKey: input.dedupeKey,
@@ -15169,7 +15375,8 @@ function newSendDoc(input) {
15169
15375
  clickedLinks: [],
15170
15376
  unsubscribedAt: null,
15171
15377
  complainedAt: null,
15172
- queuedAt: /* @__PURE__ */ new Date(),
15378
+ queuedAt: now,
15379
+ updatedAt: now,
15173
15380
  sentAt: null,
15174
15381
  deliveredAt: null
15175
15382
  };
@@ -15651,6 +15858,7 @@ async function buildSendDoc(broadcast, template, contact, ctx, scheduledMs, resp
15651
15858
  unsubscribedAt: null,
15652
15859
  complainedAt: null,
15653
15860
  queuedAt: /* @__PURE__ */ new Date(),
15861
+ updatedAt: /* @__PURE__ */ new Date(),
15654
15862
  sentAt: null,
15655
15863
  deliveredAt: null
15656
15864
  };
@@ -15723,13 +15931,36 @@ async function promoteSoftBounces(ctx) {
15723
15931
  }
15724
15932
 
15725
15933
  // src/server/runner/tick.ts
15934
+ var STRANDED_SEND_THRESHOLD_MS = 5 * 60 * 1e3;
15726
15935
  async function runTick(ctx) {
15936
+ await ctx.collections.health.updateOne(
15937
+ { _id: "singleton" },
15938
+ {
15939
+ $set: { updatedAt: /* @__PURE__ */ new Date() },
15940
+ $setOnInsert: {
15941
+ _id: "singleton",
15942
+ windowStartedAt: /* @__PURE__ */ new Date(),
15943
+ windowDurationMs: ctx.config.circuitBreaker.windowMinutes * 60 * 1e3,
15944
+ status: "healthy",
15945
+ trippedAt: null,
15946
+ trippedReason: null,
15947
+ manuallyResumedAt: null,
15948
+ counters: { sent: 0, delivered: 0, bounced: 0, hardBounced: 0, softBounced: 0, complained: 0, failedToSend: 0 },
15949
+ rates: { bounceRate: 0, hardBounceRate: 0, complaintRate: 0, failureRate: 0 }
15950
+ }
15951
+ },
15952
+ { upsert: true }
15953
+ ).catch(() => {
15954
+ });
15727
15955
  await processNewlyFiredEventTriggers(ctx).catch((err) => {
15728
15956
  console.error("mailery: triggers scan failed", err);
15729
15957
  });
15730
15958
  await sweepStrandedFlowRuns(ctx).catch((err) => {
15731
15959
  console.error("mailery: sweep failed", err);
15732
15960
  });
15961
+ await sweepStrandedSends(ctx).catch((err) => {
15962
+ console.error("mailery: stranded-send sweep failed", err);
15963
+ });
15733
15964
  await drainOutbox(ctx).catch((err) => {
15734
15965
  console.error("mailery: outbox drain failed", err);
15735
15966
  });
@@ -15743,6 +15974,24 @@ async function runTick(ctx) {
15743
15974
  console.error("mailery: soft-bounce promotion failed", err);
15744
15975
  });
15745
15976
  }
15977
+ async function sweepStrandedSends(ctx) {
15978
+ const cutoff = new Date(Date.now() - STRANDED_SEND_THRESHOLD_MS);
15979
+ const cursor = ctx.collections.sends.find(
15980
+ { status: "sending", updatedAt: { $lt: cutoff } },
15981
+ { projection: { _id: 1 } }
15982
+ ).limit(500);
15983
+ for await (const row of cursor) {
15984
+ const reset = await ctx.collections.sends.updateOne(
15985
+ { _id: row._id, status: "sending", updatedAt: { $lt: cutoff } },
15986
+ { $set: { status: "queued", updatedAt: /* @__PURE__ */ new Date() } }
15987
+ );
15988
+ if (reset.modifiedCount === 0) continue;
15989
+ await ctx.queues.send.add("send", { sendId: String(row._id) }, {
15990
+ attempts: ctx.config.sendRetryAttempts,
15991
+ backoff: { type: "exponential", delay: 6e4 }
15992
+ });
15993
+ }
15994
+ }
15746
15995
  async function drainOutbox(ctx) {
15747
15996
  const batch = await ctx.collections.outbox.find({ status: "pending" }).sort({ enqueuedAt: 1 }).limit(200).toArray();
15748
15997
  for (const row of batch) {
@@ -15913,12 +16162,11 @@ var Mailer = class _Mailer {
15913
16162
  collections;
15914
16163
  adapter;
15915
16164
  providers;
15916
- redis;
15917
16165
  queues;
15918
16166
  config;
15919
16167
  events;
15920
- workers = null;
15921
- bullQueues;
16168
+ queueDriver;
16169
+ workersStarted = false;
15922
16170
  runnerContext;
15923
16171
  constructor(args) {
15924
16172
  this.config = args.config;
@@ -15926,9 +16174,8 @@ var Mailer = class _Mailer {
15926
16174
  this.collections = args.collections;
15927
16175
  this.adapter = args.adapter;
15928
16176
  this.providers = args.providers;
15929
- this.redis = args.redis;
15930
- this.queues = args.queues;
15931
- this.bullQueues = args.bullQueues;
16177
+ this.queueDriver = args.queueDriver;
16178
+ this.queues = args.queueDriver.queues;
15932
16179
  this.events = args.events;
15933
16180
  this.runnerContext = {
15934
16181
  db: this.db,
@@ -15993,10 +16240,12 @@ var Mailer = class _Mailer {
15993
16240
  throw new Error("Mailer.fromEnv: no provider configured (set MAILER_SENDGRID_API_KEY, ...)");
15994
16241
  }
15995
16242
  const defaultProvider = env.MAILER_DEFAULT_PROVIDER ?? Object.keys(providers)[0];
16243
+ const driverEnv = env.MAILER_QUEUE_DRIVER ?? "bull";
16244
+ const queue = driverEnv === "agenda" ? { driver: "agenda" } : driverEnv === "noop" ? { driver: "noop" } : { driver: "bull", redis: { url: required("MAILER_REDIS_URL") } };
15996
16245
  return _Mailer.init({
15997
16246
  db,
15998
16247
  adapter,
15999
- redis: { url: required("MAILER_REDIS_URL") },
16248
+ queue,
16000
16249
  providers,
16001
16250
  defaultProvider,
16002
16251
  publicUrl: required("MAILER_PUBLIC_URL"),
@@ -16012,19 +16261,9 @@ var Mailer = class _Mailer {
16012
16261
  }
16013
16262
  const collections = getCollections(config.db, config.collectionPrefix);
16014
16263
  await ensureIndexes(config.db, config.collectionPrefix);
16015
- let redis = null;
16016
- let queues;
16017
- let bullQueues = null;
16018
- if (config.redis === null) {
16019
- queues = noopQueues();
16020
- } else {
16021
- redis = makeRedis(config.redis);
16022
- const created = createQueues(redis);
16023
- queues = created.queues;
16024
- bullQueues = created.bullQueues;
16025
- if (!config.workerless) {
16026
- await scheduleTick(bullQueues, config.tickIntervalSeconds);
16027
- }
16264
+ const queueDriver = await createQueueDriver(config.queue, config.db);
16265
+ if (!config.workerless && config.queue.driver !== "noop") {
16266
+ await queueDriver.scheduleRepeatingTick(config.tickIntervalSeconds);
16028
16267
  }
16029
16268
  return new _Mailer({
16030
16269
  config,
@@ -16032,9 +16271,7 @@ var Mailer = class _Mailer {
16032
16271
  collections,
16033
16272
  adapter: config.adapter,
16034
16273
  providers: config.providers,
16035
- redis,
16036
- queues,
16037
- bullQueues,
16274
+ queueDriver,
16038
16275
  events: new EventRegistry()
16039
16276
  });
16040
16277
  }
@@ -16342,6 +16579,7 @@ var Mailer = class _Mailer {
16342
16579
  unsubscribedAt: null,
16343
16580
  complainedAt: null,
16344
16581
  queuedAt: /* @__PURE__ */ new Date(),
16582
+ updatedAt: /* @__PURE__ */ new Date(),
16345
16583
  sentAt: null,
16346
16584
  deliveredAt: null
16347
16585
  });
@@ -16373,14 +16611,16 @@ var Mailer = class _Mailer {
16373
16611
  // Workers
16374
16612
  // -------------------------------------------------------------------------
16375
16613
  async startWorkers() {
16376
- if (this.workers) return;
16377
- if (!this.redis) throw new Error("startWorkers requires a Redis connection (redis was null in config)");
16614
+ if (this.workersStarted) return;
16615
+ if (this.config.queue.driver === "noop") {
16616
+ throw new Error("startWorkers requires a non-noop queue driver");
16617
+ }
16378
16618
  const provider = this.providers[this.config.defaultProvider];
16379
16619
  const sendRate = provider?.sendRatePerSecond ?? this.config.sendRatePerSecond;
16380
- this.workers = createWorkers({
16381
- redis: this.redis,
16620
+ await this.queueDriver.startWorkers({
16382
16621
  concurrency: { send: this.config.sendConcurrency },
16383
16622
  sendRateLimit: { max: sendRate, durationMs: 1e3 },
16623
+ retryAttempts: this.config.sendRetryAttempts,
16384
16624
  handlers: {
16385
16625
  tick: async () => {
16386
16626
  await runTick(this.runnerContext);
@@ -16398,6 +16638,7 @@ var Mailer = class _Mailer {
16398
16638
  }
16399
16639
  }
16400
16640
  });
16641
+ this.workersStarted = true;
16401
16642
  }
16402
16643
  /** Process unprocessed webhook events in mailer_webhook_events. */
16403
16644
  async processWebhookBacklog() {
@@ -16424,20 +16665,8 @@ var Mailer = class _Mailer {
16424
16665
  }
16425
16666
  }
16426
16667
  async stop() {
16427
- if (this.workers) {
16428
- await closeWorkers(this.workers);
16429
- this.workers = null;
16430
- }
16431
- if (this.bullQueues) {
16432
- await closeBullQueues(this.bullQueues);
16433
- this.bullQueues = null;
16434
- } else {
16435
- await closeQueues(this.queues);
16436
- }
16437
- if (this.redis) {
16438
- await this.redis.quit().catch(() => {
16439
- });
16440
- }
16668
+ await this.queueDriver.close();
16669
+ this.workersStarted = false;
16441
16670
  }
16442
16671
  /** Used internally by the admin router and tests; not part of the public API. */
16443
16672
  getRunnerContext() {
@@ -16533,7 +16762,7 @@ async function createTestMailer(opts = {}) {
16533
16762
  const mailer = await Mailer.init({
16534
16763
  db,
16535
16764
  adapter,
16536
- redis: null,
16765
+ queue: { driver: "noop" },
16537
16766
  providers: { null: provider },
16538
16767
  defaultProvider: "null",
16539
16768
  publicUrl: "http://localhost:3000",