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.
@@ -1,8 +1,8 @@
1
1
  import { Db } from 'mongodb';
2
- import { C as ContactAdapter, a as Contact, A as AdapterFilter, c as Mailer, r as NullProvider } from './null-OzIqP7A8.cjs';
3
- import 'ioredis';
2
+ import { C as ContactAdapter, a as Contact, A as AdapterFilter, c as Mailer, r as NullProvider } from './null-CWw3Gpbl.cjs';
4
3
  import 'zod';
5
4
  import 'handlebars';
5
+ import 'ioredis';
6
6
 
7
7
  /**
8
8
  * MemoryContactAdapter — drop-in in-process ContactAdapter for tests.
@@ -49,7 +49,7 @@ interface TestMailerOptions {
49
49
  seedContacts?: Contact[];
50
50
  provider?: NullProvider;
51
51
  /** Override Mailer config (excluding required fields the harness fills in). */
52
- config?: Partial<Omit<Parameters<typeof Mailer.init>[0], 'db' | 'adapter' | 'redis' | 'providers' | 'defaultProvider'>>;
52
+ config?: Partial<Omit<Parameters<typeof Mailer.init>[0], 'db' | 'adapter' | 'queue' | 'providers' | 'defaultProvider'>>;
53
53
  }
54
54
  interface TestMailerHarness {
55
55
  mailer: Mailer;
package/dist/testing.d.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import { Db } from 'mongodb';
2
- import { C as ContactAdapter, a as Contact, A as AdapterFilter, c as Mailer, r as NullProvider } from './null-OzIqP7A8.js';
3
- import 'ioredis';
2
+ import { C as ContactAdapter, a as Contact, A as AdapterFilter, c as Mailer, r as NullProvider } from './null-CWw3Gpbl.js';
4
3
  import 'zod';
5
4
  import 'handlebars';
5
+ import 'ioredis';
6
6
 
7
7
  /**
8
8
  * MemoryContactAdapter — drop-in in-process ContactAdapter for tests.
@@ -49,7 +49,7 @@ interface TestMailerOptions {
49
49
  seedContacts?: Contact[];
50
50
  provider?: NullProvider;
51
51
  /** Override Mailer config (excluding required fields the harness fills in). */
52
- config?: Partial<Omit<Parameters<typeof Mailer.init>[0], 'db' | 'adapter' | 'redis' | 'providers' | 'defaultProvider'>>;
52
+ config?: Partial<Omit<Parameters<typeof Mailer.init>[0], 'db' | 'adapter' | 'queue' | 'providers' | 'defaultProvider'>>;
53
53
  }
54
54
  interface TestMailerHarness {
55
55
  mailer: Mailer;
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,6 +15931,7 @@ 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) {
15727
15936
  await processNewlyFiredEventTriggers(ctx).catch((err) => {
15728
15937
  console.error("mailery: triggers scan failed", err);
@@ -15730,6 +15939,9 @@ async function runTick(ctx) {
15730
15939
  await sweepStrandedFlowRuns(ctx).catch((err) => {
15731
15940
  console.error("mailery: sweep failed", err);
15732
15941
  });
15942
+ await sweepStrandedSends(ctx).catch((err) => {
15943
+ console.error("mailery: stranded-send sweep failed", err);
15944
+ });
15733
15945
  await drainOutbox(ctx).catch((err) => {
15734
15946
  console.error("mailery: outbox drain failed", err);
15735
15947
  });
@@ -15743,6 +15955,24 @@ async function runTick(ctx) {
15743
15955
  console.error("mailery: soft-bounce promotion failed", err);
15744
15956
  });
15745
15957
  }
15958
+ async function sweepStrandedSends(ctx) {
15959
+ const cutoff = new Date(Date.now() - STRANDED_SEND_THRESHOLD_MS);
15960
+ const cursor = ctx.collections.sends.find(
15961
+ { status: "sending", updatedAt: { $lt: cutoff } },
15962
+ { projection: { _id: 1 } }
15963
+ ).limit(500);
15964
+ for await (const row of cursor) {
15965
+ const reset = await ctx.collections.sends.updateOne(
15966
+ { _id: row._id, status: "sending", updatedAt: { $lt: cutoff } },
15967
+ { $set: { status: "queued", updatedAt: /* @__PURE__ */ new Date() } }
15968
+ );
15969
+ if (reset.modifiedCount === 0) continue;
15970
+ await ctx.queues.send.add("send", { sendId: String(row._id) }, {
15971
+ attempts: ctx.config.sendRetryAttempts,
15972
+ backoff: { type: "exponential", delay: 6e4 }
15973
+ });
15974
+ }
15975
+ }
15746
15976
  async function drainOutbox(ctx) {
15747
15977
  const batch = await ctx.collections.outbox.find({ status: "pending" }).sort({ enqueuedAt: 1 }).limit(200).toArray();
15748
15978
  for (const row of batch) {
@@ -15913,12 +16143,11 @@ var Mailer = class _Mailer {
15913
16143
  collections;
15914
16144
  adapter;
15915
16145
  providers;
15916
- redis;
15917
16146
  queues;
15918
16147
  config;
15919
16148
  events;
15920
- workers = null;
15921
- bullQueues;
16149
+ queueDriver;
16150
+ workersStarted = false;
15922
16151
  runnerContext;
15923
16152
  constructor(args) {
15924
16153
  this.config = args.config;
@@ -15926,9 +16155,8 @@ var Mailer = class _Mailer {
15926
16155
  this.collections = args.collections;
15927
16156
  this.adapter = args.adapter;
15928
16157
  this.providers = args.providers;
15929
- this.redis = args.redis;
15930
- this.queues = args.queues;
15931
- this.bullQueues = args.bullQueues;
16158
+ this.queueDriver = args.queueDriver;
16159
+ this.queues = args.queueDriver.queues;
15932
16160
  this.events = args.events;
15933
16161
  this.runnerContext = {
15934
16162
  db: this.db,
@@ -15993,10 +16221,12 @@ var Mailer = class _Mailer {
15993
16221
  throw new Error("Mailer.fromEnv: no provider configured (set MAILER_SENDGRID_API_KEY, ...)");
15994
16222
  }
15995
16223
  const defaultProvider = env.MAILER_DEFAULT_PROVIDER ?? Object.keys(providers)[0];
16224
+ const driverEnv = env.MAILER_QUEUE_DRIVER ?? "bull";
16225
+ const queue = driverEnv === "agenda" ? { driver: "agenda" } : driverEnv === "noop" ? { driver: "noop" } : { driver: "bull", redis: { url: required("MAILER_REDIS_URL") } };
15996
16226
  return _Mailer.init({
15997
16227
  db,
15998
16228
  adapter,
15999
- redis: { url: required("MAILER_REDIS_URL") },
16229
+ queue,
16000
16230
  providers,
16001
16231
  defaultProvider,
16002
16232
  publicUrl: required("MAILER_PUBLIC_URL"),
@@ -16012,19 +16242,9 @@ var Mailer = class _Mailer {
16012
16242
  }
16013
16243
  const collections = getCollections(config.db, config.collectionPrefix);
16014
16244
  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
- }
16245
+ const queueDriver = await createQueueDriver(config.queue, config.db);
16246
+ if (!config.workerless && config.queue.driver !== "noop") {
16247
+ await queueDriver.scheduleRepeatingTick(config.tickIntervalSeconds);
16028
16248
  }
16029
16249
  return new _Mailer({
16030
16250
  config,
@@ -16032,9 +16252,7 @@ var Mailer = class _Mailer {
16032
16252
  collections,
16033
16253
  adapter: config.adapter,
16034
16254
  providers: config.providers,
16035
- redis,
16036
- queues,
16037
- bullQueues,
16255
+ queueDriver,
16038
16256
  events: new EventRegistry()
16039
16257
  });
16040
16258
  }
@@ -16342,6 +16560,7 @@ var Mailer = class _Mailer {
16342
16560
  unsubscribedAt: null,
16343
16561
  complainedAt: null,
16344
16562
  queuedAt: /* @__PURE__ */ new Date(),
16563
+ updatedAt: /* @__PURE__ */ new Date(),
16345
16564
  sentAt: null,
16346
16565
  deliveredAt: null
16347
16566
  });
@@ -16373,14 +16592,16 @@ var Mailer = class _Mailer {
16373
16592
  // Workers
16374
16593
  // -------------------------------------------------------------------------
16375
16594
  async startWorkers() {
16376
- if (this.workers) return;
16377
- if (!this.redis) throw new Error("startWorkers requires a Redis connection (redis was null in config)");
16595
+ if (this.workersStarted) return;
16596
+ if (this.config.queue.driver === "noop") {
16597
+ throw new Error("startWorkers requires a non-noop queue driver");
16598
+ }
16378
16599
  const provider = this.providers[this.config.defaultProvider];
16379
16600
  const sendRate = provider?.sendRatePerSecond ?? this.config.sendRatePerSecond;
16380
- this.workers = createWorkers({
16381
- redis: this.redis,
16601
+ await this.queueDriver.startWorkers({
16382
16602
  concurrency: { send: this.config.sendConcurrency },
16383
16603
  sendRateLimit: { max: sendRate, durationMs: 1e3 },
16604
+ retryAttempts: this.config.sendRetryAttempts,
16384
16605
  handlers: {
16385
16606
  tick: async () => {
16386
16607
  await runTick(this.runnerContext);
@@ -16398,6 +16619,7 @@ var Mailer = class _Mailer {
16398
16619
  }
16399
16620
  }
16400
16621
  });
16622
+ this.workersStarted = true;
16401
16623
  }
16402
16624
  /** Process unprocessed webhook events in mailer_webhook_events. */
16403
16625
  async processWebhookBacklog() {
@@ -16424,20 +16646,8 @@ var Mailer = class _Mailer {
16424
16646
  }
16425
16647
  }
16426
16648
  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
- }
16649
+ await this.queueDriver.close();
16650
+ this.workersStarted = false;
16441
16651
  }
16442
16652
  /** Used internally by the admin router and tests; not part of the public API. */
16443
16653
  getRunnerContext() {
@@ -16533,7 +16743,7 @@ async function createTestMailer(opts = {}) {
16533
16743
  const mailer = await Mailer.init({
16534
16744
  db,
16535
16745
  adapter,
16536
- redis: null,
16746
+ queue: { driver: "noop" },
16537
16747
  providers: { null: provider },
16538
16748
  defaultProvider: "null",
16539
16749
  publicUrl: "http://localhost:3000",