mail-time 3.0.0 → 4.0.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/index.cjs CHANGED
@@ -1,51 +1,259 @@
1
1
  'use strict';
2
2
 
3
3
  const josk = require('josk');
4
- const merge = require('deepmerge');
5
- const crypto = require('crypto');
4
+ const node_crypto = require('node:crypto');
5
+
6
+ const hasOwn = Object.prototype.hasOwnProperty;
6
7
 
7
8
  const debug = (isDebug, ...args) => {
8
9
  if (isDebug) {
9
- console.info.call(console, '[DEBUG] [mail-time]', `${new Date}`, ...args);
10
+ console.info('[DEBUG] [mail-time]', `${new Date()}`, ...args);
10
11
  }
11
12
  };
12
13
 
13
14
  const logError = (...args) => {
14
- console.error.call(console, '[ERROR] [mail-time]', `${new Date}`, ...args);
15
+ console.error('[ERROR] [mail-time]', `${new Date()}`, ...args);
16
+ };
17
+
18
+ const isPlainObject = (value) => {
19
+ if (value === null || typeof value !== 'object') {
20
+ return false;
21
+ }
22
+ const proto = Object.getPrototypeOf(value);
23
+ return proto === null || proto === Object.prototype;
24
+ };
25
+
26
+ /**
27
+ * Minimal deep-merge sufficient for nodemailer-shaped mail options:
28
+ * - plain objects merge key-by-key
29
+ * - arrays concatenate
30
+ * - other values (strings, numbers, Date, Buffer, streams, classes) replace
31
+ * Source values override target values.
32
+ */
33
+ const deepMerge = (target, source) => {
34
+ if (!isPlainObject(source)) {
35
+ return target;
36
+ }
37
+
38
+ const out = isPlainObject(target) ? { ...target } : {};
39
+
40
+ for (const key of Object.keys(source)) {
41
+ const sVal = source[key];
42
+ const tVal = out[key];
43
+
44
+ if (Array.isArray(sVal)) {
45
+ out[key] = Array.isArray(tVal) ? tVal.concat(sVal) : sVal.slice();
46
+ } else if (isPlainObject(sVal)) {
47
+ out[key] = isPlainObject(tVal) ? deepMerge(tVal, sVal) : deepMerge({}, sVal);
48
+ } else {
49
+ out[key] = sVal;
50
+ }
51
+ }
52
+
53
+ return out;
54
+ };
55
+
56
+ /**
57
+ * Order-insensitive deep equality. Treats arrays as multisets and
58
+ * objects as unordered maps. Designed for the small `mailOptions`
59
+ * shape used by MailTime's email concatenation dedup.
60
+ */
61
+ const equals = (a, b) => {
62
+ if (a === b) {
63
+ return true;
64
+ }
65
+
66
+ if (!a || !b || typeof a !== 'object' || typeof b !== 'object') {
67
+ return false;
68
+ }
69
+
70
+ if (a instanceof Date && b instanceof Date) {
71
+ return a.valueOf() === b.valueOf();
72
+ }
73
+
74
+ if (Array.isArray(a)) {
75
+ if (!Array.isArray(b) || a.length !== b.length) {
76
+ return false;
77
+ }
78
+
79
+ const matched = new Array(b.length).fill(false);
80
+ for (let i = 0; i < a.length; i++) {
81
+ let found = false;
82
+ for (let j = 0; j < b.length; j++) {
83
+ if (!matched[j] && equals(a[i], b[j])) {
84
+ matched[j] = true;
85
+ found = true;
86
+ break;
87
+ }
88
+ }
89
+ if (!found) {
90
+ return false;
91
+ }
92
+ }
93
+ return true;
94
+ }
95
+
96
+ if (Array.isArray(b)) {
97
+ return false;
98
+ }
99
+
100
+ const aKeys = Object.keys(a);
101
+ const bKeys = Object.keys(b);
102
+
103
+ if (aKeys.length !== bKeys.length) {
104
+ return false;
105
+ }
106
+
107
+ for (let i = 0; i < aKeys.length; i++) {
108
+ const key = aKeys[i];
109
+ if (!hasOwn.call(b, key) || !equals(a[key], b[key])) {
110
+ return false;
111
+ }
112
+ }
113
+
114
+ return true;
115
+ };
116
+
117
+ /**
118
+ * Extract the email part of a nodemailer-shaped recipient entry.
119
+ * Accepts `'a@x.com'`, `'Name <a@x.com>'`, or `{ name, address }`.
120
+ * Returns the address lowercased, or `null` when none can be parsed.
121
+ */
122
+ const extractEmail = (entry) => {
123
+ if (!entry) {
124
+ return null;
125
+ }
126
+ if (typeof entry === 'object' && typeof entry.address === 'string') {
127
+ return entry.address.trim().toLowerCase();
128
+ }
129
+ if (typeof entry !== 'string') {
130
+ return null;
131
+ }
132
+ const angled = entry.match(/<([^>]+)>/);
133
+ return (angled ? angled[1] : entry).trim().toLowerCase();
134
+ };
135
+
136
+ /**
137
+ * Normalize a `to`/`cc`/`bcc` field into a flat list of lowercase addresses.
138
+ */
139
+ const toAddressList = (field) => {
140
+ if (!field) {
141
+ return [];
142
+ }
143
+ if (Array.isArray(field)) {
144
+ const out = [];
145
+ for (const entry of field) {
146
+ const addr = extractEmail(entry);
147
+ if (addr) {
148
+ out.push(addr);
149
+ }
150
+ }
151
+ return out;
152
+ }
153
+ const single = extractEmail(field);
154
+ return single ? [single] : [];
155
+ };
156
+
157
+ /**
158
+ * Remove entries whose extracted address is in `acceptedSet` from a
159
+ * nodemailer `to`/`cc`/`bcc` field. Returns `void 0` when the filtered
160
+ * array would be empty or the single string is dropped.
161
+ */
162
+ const filterAddressField = (field, acceptedSet) => {
163
+ if (!field || !(acceptedSet instanceof Set) || acceptedSet.size === 0) {
164
+ return field;
165
+ }
166
+ if (Array.isArray(field)) {
167
+ const filtered = [];
168
+ for (const entry of field) {
169
+ const addr = extractEmail(entry);
170
+ if (!addr || !acceptedSet.has(addr)) {
171
+ filtered.push(entry);
172
+ }
173
+ }
174
+ return filtered.length ? filtered : void 0;
175
+ }
176
+ const addr = extractEmail(field);
177
+ if (addr && acceptedSet.has(addr)) {
178
+ return void 0;
179
+ }
180
+ return field;
15
181
  };
16
182
 
183
+ const isSendClaimUpdate = (updateObj) => {
184
+ return updateObj && updateObj.isSending === true && typeof updateObj.tries === 'number';
185
+ };
186
+
187
+ const isSendLeaseGuardedUpdate = (updateObj) => {
188
+ return updateObj && typeof updateObj.leaseTries === 'number' && typeof updateObj.leaseSendingAt === 'number';
189
+ };
190
+
191
+ const isAppendMailOptionUpdate = (updateObj) => {
192
+ return updateObj && updateObj.appendMailOption !== void 0;
193
+ };
194
+
195
+ /** Strip MailTime-internal update keys before persisting to storage. */
196
+ const stripInternalUpdateMeta = (updateObj) => {
197
+ const out = { ...updateObj };
198
+ delete out.leaseTries;
199
+ delete out.leaseSendingAt;
200
+ delete out.appendMailOption;
201
+ return out;
202
+ };
203
+
204
+ const isSendLeaseRemove = (opts) => {
205
+ return opts && typeof opts.leaseTries === 'number' && typeof opts.leaseSendingAt === 'number';
206
+ };
207
+
208
+ const DEFAULT_PREFIX$2 = '';
209
+
210
+ /**
211
+ * @typedef {object} MongoCollection
212
+ * @property {string} [collectionName]
213
+ * @property {(keys: object, opts?: object) => Promise<unknown>} createIndex
214
+ * @property {() => Promise<{ name: string, key: Record<string, unknown> }[]>} indexes
215
+ * @property {(name: string) => Promise<unknown>} dropIndex
216
+ * @property {(query: object, opts?: object) => unknown} find
217
+ * @property {(query: object, opts?: object) => Promise<object|null>} findOne
218
+ * @property {(doc: object) => Promise<unknown>} insertOne
219
+ * @property {(query: object) => Promise<{ deletedCount?: number }>} deleteOne
220
+ * @property {(query: object, update: object) => Promise<{ modifiedCount?: number }>} updateOne
221
+ */
222
+
223
+ /**
224
+ * @typedef {object} Db
225
+ * @property {(name: string) => MongoCollection} collection
226
+ * @property {(cmd: object) => Promise<{ ok?: number }>} command
227
+ */
228
+
17
229
  /**
18
- * Ensure (create) index on MongoDB collection, catch and log exception if thrown
19
- * @function ensureIndex
20
- * @param {Collection} collection - Mongo's driver Collection instance
21
- * @param {object} keys - Field and value pairs where the field is the index key and the value describes the type of index for that field
22
- * @param {object} opts - Set of options that controls the creation of the index
23
- * @returns {void 0}
230
+ * @typedef {object} MongoQueueOption
231
+ * @property {Db} db
232
+ * @property {string} [prefix]
24
233
  */
234
+
235
+ /** @internal */
25
236
  const ensureIndex = async (collection, keys, opts) => {
26
237
  try {
27
238
  await collection.createIndex(keys, opts);
28
239
  } catch (e) {
29
- if (e.code === 85) {
240
+ if (e?.code === 85) {
30
241
  let indexName;
31
242
  const indexes = await collection.indexes();
243
+ const keyNames = Object.keys(keys);
32
244
  for (const index of indexes) {
33
- let drop = true;
34
- for (const indexKey of Object.keys(keys)) {
35
- if (typeof index.key[indexKey] === 'undefined') {
36
- drop = false;
37
- break;
38
- }
245
+ const indexKeys = Object.keys(index.key);
246
+ if (indexKeys.length !== keyNames.length) {
247
+ continue;
39
248
  }
40
-
41
- for (const indexKey of Object.keys(index.key)) {
42
- if (typeof keys[indexKey] === 'undefined') {
43
- drop = false;
249
+ let match = true;
250
+ for (const k of keyNames) {
251
+ if (typeof index.key[k] === 'undefined') {
252
+ match = false;
44
253
  break;
45
254
  }
46
255
  }
47
-
48
- if (drop) {
256
+ if (match) {
49
257
  indexName = index.name;
50
258
  break;
51
259
  }
@@ -56,7 +264,7 @@ const ensureIndex = async (collection, keys, opts) => {
56
264
  await collection.createIndex(keys, opts);
57
265
  }
58
266
  } else {
59
- logError(`[ensureIndex] Can not set ${Object.keys(keys).join(' + ')} index on "${collection._name || 'MongoDB'}" collection`, { keys, opts, details: e });
267
+ logError(`[ensureIndex] Can not set ${Object.keys(keys).join(' + ')} index on "${collection?.collectionName || 'MongoDB'}" collection`, { keys, opts, details: e });
60
268
  }
61
269
  }
62
270
  };
@@ -65,47 +273,58 @@ const ensureIndex = async (collection, keys, opts) => {
65
273
  class MongoQueue {
66
274
  /**
67
275
  * Create a MongoQueue instance
68
- * @param {object} opts - configuration object
69
- * @param {Db} opts.db - Required, Mongo's `Db` instance, like one returned from `MongoClient#db()` method
70
- * @param {string} [opts.prefix] - Optional prefix for scope isolation; use when creating multiple MailTime instances within the single application
276
+ * @param {MongoQueueOption} opts - configuration object
71
277
  */
72
278
  constructor (opts) {
73
279
  this.name = 'mongo-queue';
74
- if (!opts || typeof opts !== 'object' || opts === null) {
280
+ if (!opts || typeof opts !== 'object') {
75
281
  throw new TypeError('[mail-time] Configuration object must be passed into MongoQueue constructor');
76
282
  }
77
283
 
78
284
  if (!opts.db) {
79
- throw new Error('[mail-time] [MongoQueue] requires MongoDB database {db} option, like returned from `MongoClient.connect`');
285
+ throw new Error('[mail-time] [MongoQueue] requires MongoDB database {db} option, like returned from `MongoClient#db()`');
80
286
  }
81
287
 
82
- this.prefix = (typeof opts.prefix === 'string') ? opts.prefix : '';
83
288
  this.db = opts.db;
84
- this.collection = opts.db.collection(`__mailTimeQueue__${this.prefix}`);
85
- ensureIndex(this.collection, { uuid: 1 }, { background: false });
86
- ensureIndex(this.collection, { isSent: 1, isFailed: 1, isCancelled: 1, to: 1, sendAt: 1 }, { background: false });
87
- ensureIndex(this.collection, { isSent: 1, isFailed: 1, isCancelled: 1, sendAt: 1, tries: 1 }, { background: false });
88
-
89
- // MongoDB Collection Schema:
90
- // _id
91
- // uuid {string}
92
- // to {string|[string]}
93
- // tries {number} - qty of send attempts
94
- // sendAt {number} - When letter should be sent
95
- // isSent {boolean} - Email status
96
- // isCancelled {boolean} - `true` if email was cancelled before it was sent
97
- // isFailed {boolean} - `true` if email has failed to send
98
- // template {string} - Template for this email
99
- // transport {number} - Last used transport
100
- // concatSubject {string|boolean} - Email concatenation subject
101
- // ---
102
- // mailOptions {[object]} - Array of nodeMailer's `mailOptions`
103
- // mailOptions.to {string|[string]} - [REQUIRED]
104
- // mailOptions.from {string}
105
- // mailOptions.text {string|boolean}
106
- // mailOptions.html {string}
107
- // mailOptions.subject {string}
108
- // mailOptions.Other nodeMailer `sendMail` options...
289
+ if (typeof opts.prefix === 'string') {
290
+ this.__applyPrefix(opts.prefix);
291
+ }
292
+ }
293
+
294
+ /** @internal */
295
+ __applyPrefix(prefix) {
296
+ this.prefix = prefix;
297
+ this.collection = this.db.collection(`__mailTimeQueue__${prefix}`);
298
+ this.__readyPromise = Promise.all([
299
+ ensureIndex(this.collection, { uuid: 1 }, { background: false }),
300
+ ensureIndex(this.collection, { isSent: 1, isFailed: 1, isCancelled: 1, to: 1, sendAt: 1 }, { background: false }),
301
+ ensureIndex(this.collection, { isSent: 1, isFailed: 1, isCancelled: 1, isSending: 1, sendingAt: 1, sendAt: 1, tries: 1 }, { background: false }),
302
+ ]).then(() => void 0);
303
+ }
304
+
305
+ /** @internal */
306
+ __ensurePrefix() {
307
+ if (typeof this.prefix !== 'string') {
308
+ this.__applyPrefix(this.mailTimeInstance?.prefix || DEFAULT_PREFIX$2);
309
+ }
310
+ }
311
+
312
+ /** @internal */
313
+ __debug(...args) {
314
+ debug(this.mailTimeInstance?.debug === true, `[${this.name}]`, ...args);
315
+ }
316
+
317
+ /**
318
+ * @async
319
+ * @memberOf MongoQueue
320
+ * @name ready
321
+ * @description Wait until indexes are created
322
+ * @returns {Promise<void 0>}
323
+ */
324
+ async ready() {
325
+ this.__ensurePrefix();
326
+ this.__debug('[ready]');
327
+ await this.__readyPromise;
109
328
  }
110
329
 
111
330
  /**
@@ -116,6 +335,7 @@ class MongoQueue {
116
335
  * @returns {Promise<object>}
117
336
  */
118
337
  async ping() {
338
+ this.__debug('[ping]');
119
339
  if (!this.mailTimeInstance) {
120
340
  return {
121
341
  status: 'Service Unavailable',
@@ -139,7 +359,7 @@ class MongoQueue {
139
359
  status: 'Internal Server Error',
140
360
  code: 500,
141
361
  statusCode: 500,
142
- error: pingError
362
+ error: pingError,
143
363
  };
144
364
  }
145
365
 
@@ -147,28 +367,43 @@ class MongoQueue {
147
367
  status: 'Service Unavailable',
148
368
  code: 503,
149
369
  statusCode: 503,
150
- error: new Error('Service Unavailable')
370
+ error: new Error('Service Unavailable'),
151
371
  };
152
372
  }
153
373
 
154
374
  /**
155
375
  * @memberOf MongoQueue
156
376
  * @name iterate
157
- * @description iterate over queued tasks passing to `mailTimeInstance.___send` method
158
- * @returns {void 0}
377
+ * @description iterate over queued tasks passing each to `mailTimeInstance.___dispatch` (the bounded send pool)
378
+ * @param {{ limit?: number, sendingTimeout?: number }} [opts] - iteration options
379
+ * @returns {Promise<void>}
159
380
  */
160
- async iterate() {
381
+ async iterate(opts) {
382
+ this.__debug('[iterate]', opts);
383
+ this.__ensurePrefix();
384
+ const now = Date.now();
385
+ const sendingTimeout = (opts && typeof opts.sendingTimeout === 'number' && opts.sendingTimeout > 0)
386
+ ? opts.sendingTimeout
387
+ : 300000;
388
+ const limit = (opts && typeof opts.limit === 'number' && Number.isFinite(opts.limit) && opts.limit > 0)
389
+ ? Math.floor(opts.limit)
390
+ : 0;
391
+
161
392
  try {
162
393
  const cursor = this.collection.find({
163
394
  isSent: false,
164
395
  isFailed: false,
165
396
  isCancelled: false,
166
397
  sendAt: {
167
- $lte: Date.now()
398
+ $lte: now,
168
399
  },
169
400
  tries: {
170
- $lt: this.mailTimeInstance.maxTries
171
- }
401
+ $lt: this.mailTimeInstance.maxTries,
402
+ },
403
+ $or: [
404
+ { isSending: { $ne: true } },
405
+ { sendingAt: { $lte: now - sendingTimeout } },
406
+ ],
172
407
  }, {
173
408
  projection: {
174
409
  _id: 1,
@@ -179,13 +414,19 @@ class MongoQueue {
179
414
  isSent: 1,
180
415
  isFailed: 1,
181
416
  isCancelled: 1,
417
+ isSending: 1,
418
+ sendingAt: 1,
182
419
  mailOptions: 1,
183
420
  concatSubject: 1,
184
- }
421
+ },
185
422
  });
186
423
 
424
+ if (limit > 0) {
425
+ cursor.limit(limit);
426
+ }
427
+
187
428
  while (await cursor.hasNext()) {
188
- await this.mailTimeInstance.___send(await cursor.next());
429
+ await this.mailTimeInstance.___dispatch(await cursor.next());
189
430
  }
190
431
  await cursor.close();
191
432
  } catch (iterateError) {
@@ -203,18 +444,22 @@ class MongoQueue {
203
444
  * @returns {Promise<object|null>}
204
445
  */
205
446
  async getPendingTo(to, sendAt) {
447
+ this.__debug('[getPendingTo]', to, sendAt);
206
448
  if (typeof to !== 'string' || typeof sendAt !== 'number') {
207
449
  return null;
208
450
  }
451
+ this.__ensurePrefix();
209
452
 
210
453
  return await this.collection.findOne({
211
- to: to,
454
+ to,
212
455
  isSent: false,
213
456
  isFailed: false,
214
457
  isCancelled: false,
458
+ isSending: { $ne: true },
459
+ tries: { $lt: this.mailTimeInstance.maxTries },
215
460
  sendAt: {
216
- $lte: sendAt
217
- }
461
+ $lte: sendAt,
462
+ },
218
463
  }, {
219
464
  projection: {
220
465
  _id: 1,
@@ -225,7 +470,7 @@ class MongoQueue {
225
470
  isFailed: 1,
226
471
  isCancelled: 1,
227
472
  mailOptions: 1,
228
- }
473
+ },
229
474
  });
230
475
  }
231
476
 
@@ -238,9 +483,11 @@ class MongoQueue {
238
483
  * @returns {Promise<void 0>}
239
484
  */
240
485
  async push(task) {
241
- if (typeof task !== 'object') {
486
+ this.__debug('[push]', task?.uuid);
487
+ if (!task || typeof task !== 'object') {
242
488
  return;
243
489
  }
490
+ this.__ensurePrefix();
244
491
 
245
492
  if (task.sendAt instanceof Date) {
246
493
  task.sendAt = +task.sendAt;
@@ -257,9 +504,11 @@ class MongoQueue {
257
504
  * @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found, was sent, or was cancelled previously
258
505
  */
259
506
  async cancel(uuid) {
507
+ this.__debug('[cancel]', uuid);
260
508
  if (typeof uuid !== 'string') {
261
509
  return false;
262
510
  }
511
+ this.__ensurePrefix();
263
512
 
264
513
  const task = await this.collection.findOne({ uuid }, {
265
514
  projection: {
@@ -267,7 +516,7 @@ class MongoQueue {
267
516
  uuid: 1,
268
517
  isSent: 1,
269
518
  isCancelled: 1,
270
- }
519
+ },
271
520
  });
272
521
 
273
522
  if (!task || task.isSent === true || task.isCancelled === true) {
@@ -289,49 +538,180 @@ class MongoQueue {
289
538
  * @name remove
290
539
  * @description remove task from queue
291
540
  * @param task {object} - task's object
541
+ * @param {{ leaseTries: number, leaseSendingAt: number }} [opts] - lease guard: only remove if this worker still holds the lease (tries + sendingAt match, row not cancelled/failed)
292
542
  * @returns {Promise<boolean>} returns `true` if removed or `false` if not found
293
543
  */
294
- async remove(task) {
295
- if (typeof task !== 'object') {
544
+ async remove(task, opts) {
545
+ this.__debug('[remove]', task?.uuid);
546
+ if (!task || typeof task !== 'object') {
296
547
  return false;
297
548
  }
549
+ this.__ensurePrefix();
298
550
 
299
- return (await this.collection.deleteOne({ _id: task._id }))?.deletedCount >= 1;
551
+ const query = { _id: task._id };
552
+ if (isSendLeaseRemove(opts)) {
553
+ query.tries = opts.leaseTries;
554
+ query.isSending = true;
555
+ query.sendingAt = opts.leaseSendingAt;
556
+ query.isCancelled = false;
557
+ query.isFailed = false;
558
+ }
559
+
560
+ const res = await this.collection.deleteOne(query);
561
+ return (res?.deletedCount || 0) >= 1;
300
562
  }
301
563
 
302
564
  /**
303
565
  * @async
304
566
  * @memberOf MongoQueue
305
567
  * @name update
306
- * @description remove task from queue
568
+ * @description update task in queue
307
569
  * @param task {object} - task's object
308
570
  * @param updateObj {object} - fields with new values to update
309
571
  * @returns {Promise<boolean>} returns `true` if updated or `false` if not found or no changes was made
310
572
  */
311
573
  async update(task, updateObj) {
312
- if (typeof task !== 'object' || typeof updateObj !== 'object') {
574
+ this.__debug('[update]', task?.uuid);
575
+ if (!task || typeof task !== 'object' || !updateObj || typeof updateObj !== 'object') {
313
576
  return false;
314
577
  }
578
+ this.__ensurePrefix();
315
579
 
316
- return (await this.collection.updateOne({
317
- _id: task._id
318
- }, {
319
- $set: updateObj
320
- }))?.modifiedCount >= 1;
580
+ if (isAppendMailOptionUpdate(updateObj)) {
581
+ const res = await this.collection.updateOne({
582
+ _id: task._id,
583
+ isSent: false,
584
+ isFailed: false,
585
+ isCancelled: false,
586
+ isSending: { $ne: true },
587
+ }, {
588
+ $push: {
589
+ mailOptions: updateObj.appendMailOption,
590
+ },
591
+ });
592
+ return (res?.modifiedCount || 0) >= 1;
593
+ }
594
+
595
+ const query = {
596
+ _id: task._id,
597
+ };
598
+
599
+ if (isSendClaimUpdate(updateObj)) {
600
+ const now = typeof updateObj.sendingAt === 'number' ? updateObj.sendingAt : Date.now();
601
+ const sendingTimeout = this.mailTimeInstance?.sendingTimeout || 300000;
602
+ query.isSent = false;
603
+ query.isFailed = false;
604
+ query.isCancelled = false;
605
+ query.tries = task.tries;
606
+ query.$or = [
607
+ { isSending: { $ne: true } },
608
+ { sendingAt: { $lte: now - sendingTimeout } },
609
+ ];
610
+ } else if (isSendLeaseGuardedUpdate(updateObj)) {
611
+ query.tries = updateObj.leaseTries;
612
+ query.isSending = true;
613
+ query.sendingAt = updateObj.leaseSendingAt;
614
+ query.isCancelled = false;
615
+ query.isFailed = false;
616
+ }
617
+
618
+ const res = await this.collection.updateOne(query, {
619
+ $set: stripInternalUpdateMeta(updateObj),
620
+ });
621
+ if (isSendClaimUpdate(updateObj)) {
622
+ return (res?.modifiedCount || res?.matchedCount || 0) >= 1;
623
+ }
624
+ return (res?.modifiedCount || 0) >= 1;
321
625
  }
322
626
  }
323
627
 
628
+ /**
629
+ * @typedef {object} RedisClient
630
+ * @property {(key: string) => Promise<number>} exists
631
+ * @property {(key: string) => Promise<string|null>} get
632
+ * @property {(key: string, value: string, options?: object) => Promise<unknown>} set
633
+ * @property {(key: string|string[]) => Promise<number>} del
634
+ * @property {() => Promise<string>} ping
635
+ * @property {(options: object) => AsyncIterable<string|string[]>} scanIterator
636
+ * @property {(key: string) => Promise<unknown>} [watch]
637
+ * @property {() => Promise<unknown>} [unwatch]
638
+ * @property {() => object} [multi]
639
+ */
640
+
641
+ /**
642
+ * @typedef {object} RedisQueueOption
643
+ * @property {RedisClient} client
644
+ * @property {string} [prefix]
645
+ */
646
+
647
+ const KEY_TYPES = new Set(['letter', 'sendat', 'concatletter']);
648
+ const DEFAULT_PREFIX$1 = 'default';
649
+
650
+ const canReleaseLease = (currentTask, updateObj) => {
651
+ return currentTask
652
+ && currentTask.tries === updateObj.leaseTries
653
+ && currentTask.isSending === true
654
+ && (typeof currentTask.sendingAt === 'number' ? currentTask.sendingAt : 0) === updateObj.leaseSendingAt
655
+ && currentTask.isCancelled !== true
656
+ && currentTask.isFailed !== true;
657
+ };
658
+
659
+ const canClaimTask = (currentTask, task, now, sendingTimeout) => {
660
+ if (!currentTask) {
661
+ return false;
662
+ }
663
+ if (currentTask.isSent === true || currentTask.isFailed === true || currentTask.isCancelled === true) {
664
+ return false;
665
+ }
666
+ if (currentTask.tries !== task.tries) {
667
+ return false;
668
+ }
669
+ if (currentTask.isSending === true) {
670
+ const sendingAt = typeof currentTask.sendingAt === 'number' ? currentTask.sendingAt : 0;
671
+ if (sendingAt > now - sendingTimeout) {
672
+ return false;
673
+ }
674
+ }
675
+ return true;
676
+ };
677
+
678
+ const isIterateCandidate = (candidate, now, sendingTimeout, maxTries) => {
679
+ if (!candidate || typeof candidate !== 'object') {
680
+ return false;
681
+ }
682
+ if (candidate.isSent === true || candidate.isFailed === true || candidate.isCancelled === true) {
683
+ return false;
684
+ }
685
+ const tries = typeof candidate.tries === 'number' ? candidate.tries : 0;
686
+ if (tries >= maxTries) {
687
+ return false;
688
+ }
689
+ if (candidate.isSending === true) {
690
+ const sendingAt = typeof candidate.sendingAt === 'number' ? candidate.sendingAt : 0;
691
+ if (sendingAt > now - sendingTimeout) {
692
+ return false;
693
+ }
694
+ }
695
+ return true;
696
+ };
697
+
698
+ const parseUuidFromKey = (key, uniqueName) => {
699
+ const prefix = `${uniqueName}:sendat:`;
700
+ if (!key.startsWith(prefix)) {
701
+ return null;
702
+ }
703
+ return key.slice(prefix.length);
704
+ };
705
+
324
706
  /** Class representing Redis Queue for MailTime */
325
707
  class RedisQueue {
326
708
  /**
327
709
  * Create a RedisQueue instance
328
- * @param {object} opts - configuration object
329
- * @param {RedisClient} opts.client - Required, Redis'es `RedisClient` instance, like one returned from `await redis.createClient().connect()` method
330
- * @param {string} [opts.prefix] - Optional prefix for scope isolation; use when creating multiple MailTime instances within the single application
710
+ * @param {RedisQueueOption} opts - configuration object
331
711
  */
332
712
  constructor (opts) {
333
713
  this.name = 'redis-queue';
334
- if (!opts || typeof opts !== 'object' || opts === null) {
714
+ if (!opts || typeof opts !== 'object') {
335
715
  throw new TypeError('[mail-time] Configuration object must be passed into RedisQueue constructor');
336
716
  }
337
717
 
@@ -339,34 +719,41 @@ class RedisQueue {
339
719
  throw new Error('[mail-time] [RedisQueue] required {client} option is missing, e.g. returned from `redis.createClient()` or `redis.createCluster()` method');
340
720
  }
341
721
 
342
- this.prefix = (typeof opts.prefix === 'string') ? opts.prefix : 'default';
343
- this.uniqueName = `mailtime:${this.prefix}`;
344
722
  this.client = opts.client;
723
+ if (typeof opts.prefix === 'string') {
724
+ this.__applyPrefix(opts.prefix);
725
+ }
726
+ }
727
+
728
+ /** @internal */
729
+ __applyPrefix(prefix) {
730
+ this.prefix = prefix;
731
+ this.uniqueName = `mailtime:${prefix}`;
732
+ }
733
+
734
+ /** @internal */
735
+ __ensurePrefix() {
736
+ if (typeof this.prefix !== 'string') {
737
+ this.__applyPrefix(this.mailTimeInstance?.prefix || DEFAULT_PREFIX$1);
738
+ }
739
+ }
740
+
741
+ /** @internal */
742
+ __debug(...args) {
743
+ debug(this.mailTimeInstance?.debug === true, `[${this.name}]`, ...args);
744
+ }
345
745
 
346
- // 3 types of keys are stored in Redis:
347
- // 'letter' - JSON with email details
348
- // 'sendat' - Timestamp used to scan/find/iterate over scheduled emails
349
- // 'concatletter' — uuid of "pending" email for concatenation used when {concatEmails: true}
350
-
351
- // Stored JSON structure:
352
- // to {string|[string]}
353
- // uuid {string}
354
- // tries {number} - qty of send attempts
355
- // sendAt {number} - When letter should be sent
356
- // isSent {boolean} - Email status
357
- // isCancelled {boolean} - `true` if email was cancelled before it was sent
358
- // isFailed {boolean} - `true` if email has failed to send
359
- // template {string} - Template for this email
360
- // transport {number} - Last used transport
361
- // concatSubject {string|boolean} - Email concatenation subject
362
- // ---
363
- // mailOptions {[object]} - Array of nodeMailer's `mailOptions`
364
- // mailOptions.to {string|[string]} - [REQUIRED]
365
- // mailOptions.from {string}
366
- // mailOptions.text {string|boolean}
367
- // mailOptions.html {string}
368
- // mailOptions.subject {string}
369
- // mailOptions.Other nodeMailer `sendMail` options...
746
+ /**
747
+ * @async
748
+ * @memberOf RedisQueue
749
+ * @name ready
750
+ * @description Storage adapter has no async setup
751
+ * @returns {Promise<void 0>}
752
+ */
753
+ async ready() {
754
+ this.__ensurePrefix();
755
+ this.__debug('[ready]');
756
+ return void 0;
370
757
  }
371
758
 
372
759
  /**
@@ -377,6 +764,7 @@ class RedisQueue {
377
764
  * @returns {Promise<object>}
378
765
  */
379
766
  async ping() {
767
+ this.__debug('[ping]');
380
768
  if (!this.mailTimeInstance) {
381
769
  return {
382
770
  status: 'Service Unavailable',
@@ -400,7 +788,7 @@ class RedisQueue {
400
788
  status: 'Internal Server Error',
401
789
  code: 500,
402
790
  statusCode: 500,
403
- error: pingError
791
+ error: pingError,
404
792
  };
405
793
  }
406
794
 
@@ -408,28 +796,64 @@ class RedisQueue {
408
796
  status: 'Service Unavailable',
409
797
  code: 503,
410
798
  statusCode: 503,
411
- error: new Error('Service Unavailable')
799
+ error: new Error('Service Unavailable'),
412
800
  };
413
801
  }
414
802
 
415
803
  /**
416
804
  * @memberOf RedisQueue
417
805
  * @name iterate
418
- * @description iterate over queued tasks passing to `mailTimeInstance.___send` method
419
- * @returns {void 0}
806
+ * @description iterate over queued tasks passing each to `mailTimeInstance.___dispatch` (the bounded send pool)
807
+ * @param {{ limit?: number, sendingTimeout?: number }} [opts] - iteration options
808
+ * @returns {Promise<void>}
420
809
  */
421
- async iterate() {
810
+ async iterate(opts) {
811
+ this.__debug('[iterate]', opts);
422
812
  try {
423
813
  const now = Date.now();
814
+ const sendingTimeout = (opts && typeof opts.sendingTimeout === 'number' && opts.sendingTimeout > 0)
815
+ ? opts.sendingTimeout
816
+ : 300000;
817
+ const limit = (opts && typeof opts.limit === 'number' && Number.isFinite(opts.limit) && opts.limit > 0)
818
+ ? Math.floor(opts.limit)
819
+ : 0;
820
+ const maxTries = (this.mailTimeInstance && typeof this.mailTimeInstance.maxTries === 'number')
821
+ ? this.mailTimeInstance.maxTries
822
+ : 60;
823
+ let dispatched = 0;
824
+
825
+ const matchPattern = this.__getKey('*', 'sendat');
424
826
  const cursor = this.client.scanIterator({
425
827
  TYPE: 'string',
426
- MATCH: this.__getKey('*', 'sendat'),
828
+ MATCH: matchPattern,
427
829
  COUNT: 9999,
428
830
  });
429
831
 
430
- for await (const sendatKey of cursor) {
431
- if (parseInt(await this.client.get(sendatKey)) <= now) {
432
- await this.mailTimeInstance.___send(JSON.parse(await this.client.get(this.__getKey(sendatKey.split(':')[3]))));
832
+ outer:
833
+ for await (const cursorValue of cursor) {
834
+ const sendatKeys = Array.isArray(cursorValue) ? cursorValue : [cursorValue];
835
+ for (const sendatKey of sendatKeys) {
836
+ const raw = await this.client.get(sendatKey);
837
+ if (raw === null || parseInt(raw, 10) > now) {
838
+ continue;
839
+ }
840
+ const uuid = parseUuidFromKey(sendatKey, this.uniqueName);
841
+ if (!uuid) {
842
+ continue;
843
+ }
844
+ const taskJSON = await this.client.get(this.__getKey(uuid));
845
+ if (!taskJSON) {
846
+ continue;
847
+ }
848
+ const candidate = JSON.parse(taskJSON);
849
+ if (!isIterateCandidate(candidate, now, sendingTimeout, maxTries)) {
850
+ continue;
851
+ }
852
+ await this.mailTimeInstance.___dispatch(candidate);
853
+ dispatched++;
854
+ if (limit > 0 && dispatched >= limit) {
855
+ break outer;
856
+ }
433
857
  }
434
858
  }
435
859
  } catch (iterateError) {
@@ -447,25 +871,25 @@ class RedisQueue {
447
871
  * @returns {Promise<object|null>}
448
872
  */
449
873
  async getPendingTo(to, sendAt) {
874
+ this.__debug('[getPendingTo]', to, sendAt);
450
875
  if (typeof to !== 'string' || typeof sendAt !== 'number') {
451
876
  return null;
452
877
  }
453
878
 
454
879
  const concatKey = this.__getKey(to, 'concatletter');
455
- let exists = await this.client.exists(concatKey);
456
- if (!exists) {
880
+ const uuid = await this.client.get(concatKey);
881
+ if (!uuid) {
457
882
  return null;
458
883
  }
459
884
 
460
- const uuid = await this.client.get(concatKey);
461
885
  const letterKey = this.__getKey(uuid, 'letter');
462
- exists = await this.client.exists(letterKey);
463
- if (!exists) {
886
+ const taskJSON = await this.client.get(letterKey);
887
+ if (!taskJSON) {
464
888
  return null;
465
889
  }
466
890
 
467
- const task = JSON.parse(await this.client.get(letterKey));
468
- if (!task || task.isSent === true || task.isCancelled === true || task.isFailed === true) {
891
+ const task = JSON.parse(taskJSON);
892
+ if (!task || task.isSent === true || task.isCancelled === true || task.isFailed === true || task.sendAt > sendAt || task.isSending === true || task.tries >= this.mailTimeInstance.maxTries) {
469
893
  return null;
470
894
  }
471
895
 
@@ -481,7 +905,8 @@ class RedisQueue {
481
905
  * @returns {Promise<void 0>}
482
906
  */
483
907
  async push(task) {
484
- if (typeof task !== 'object') {
908
+ this.__debug('[push]', task?.uuid);
909
+ if (!task || typeof task !== 'object') {
485
910
  return;
486
911
  }
487
912
 
@@ -489,11 +914,28 @@ class RedisQueue {
489
914
  task.sendAt = +task.sendAt;
490
915
  }
491
916
 
492
- await this.client.set(this.__getKey(task.uuid, 'letter'), JSON.stringify(task));
493
- await this.client.set(this.__getKey(task.uuid, 'sendat'), `${task.sendAt}`);
917
+ const letterKey = this.__getKey(task.uuid, 'letter');
918
+ const sendatKey = this.__getKey(task.uuid, 'sendat');
919
+ const taskJSON = JSON.stringify(task);
920
+
921
+ if (typeof this.client.multi === 'function') {
922
+ const multi = this.client.multi();
923
+ multi.set(letterKey, taskJSON);
924
+ multi.set(sendatKey, `${task.sendAt}`);
925
+ if (task.to) {
926
+ multi.set(this.__getKey(task.to, 'concatletter'), task.uuid, {
927
+ PXAT: task.sendAt - 128,
928
+ });
929
+ }
930
+ await multi.exec();
931
+ return;
932
+ }
933
+
934
+ await this.client.set(letterKey, taskJSON);
935
+ await this.client.set(sendatKey, `${task.sendAt}`);
494
936
  if (task.to) {
495
937
  await this.client.set(this.__getKey(task.to, 'concatletter'), task.uuid, {
496
- PXAT: task.sendAt - 128
938
+ PXAT: task.sendAt - 128,
497
939
  });
498
940
  }
499
941
  }
@@ -507,18 +949,19 @@ class RedisQueue {
507
949
  * @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found, was sent, or was cancelled previously
508
950
  */
509
951
  async cancel(uuid) {
952
+ this.__debug('[cancel]', uuid);
510
953
  if (typeof uuid !== 'string') {
511
954
  return false;
512
955
  }
513
956
 
514
957
  await this.client.del(this.__getKey(uuid, 'sendat'));
515
958
  const letterKey = this.__getKey(uuid, 'letter');
516
- const exists = await this.client.exists(letterKey);
517
- if (!exists) {
959
+ const taskJSON = await this.client.get(letterKey);
960
+ if (!taskJSON) {
518
961
  return false;
519
962
  }
520
963
 
521
- const task = JSON.parse(await this.client.get(letterKey));
964
+ const task = JSON.parse(taskJSON);
522
965
  if (!task || task.isSent === true || task.isCancelled === true) {
523
966
  return false;
524
967
  }
@@ -538,23 +981,62 @@ class RedisQueue {
538
981
  * @name remove
539
982
  * @description remove task from queue
540
983
  * @param task {object} - task's object
984
+ * @param {{ leaseTries: number, leaseSendingAt: number }} [opts] - lease guard: only remove if this worker still holds the lease (tries + sendingAt match, row not cancelled/failed)
541
985
  * @returns {Promise<boolean>} returns `true` if removed or `false` if not found
542
986
  */
543
- async remove(task) {
544
- if (typeof task !== 'object' || typeof task.uuid !== 'string') {
987
+ async remove(task, opts) {
988
+ this.__debug('[remove]', task?.uuid);
989
+ if (!task || typeof task !== 'object' || typeof task.uuid !== 'string') {
545
990
  return false;
546
991
  }
547
992
 
548
993
  const letterKey = this.__getKey(task.uuid, 'letter');
994
+ if (isSendLeaseRemove(opts)) {
995
+ if (typeof this.client.watch !== 'function' || typeof this.client.multi !== 'function') {
996
+ return false;
997
+ }
998
+ try {
999
+ await this.client.watch(letterKey);
1000
+ const taskJSON = await this.client.get(letterKey);
1001
+ if (!taskJSON) {
1002
+ await this.client.unwatch?.();
1003
+ return false;
1004
+ }
1005
+ const currentTask = JSON.parse(taskJSON);
1006
+ if (currentTask.tries !== opts.leaseTries
1007
+ || currentTask.isSending !== true
1008
+ || (typeof currentTask.sendingAt === 'number' ? currentTask.sendingAt : 0) !== opts.leaseSendingAt
1009
+ || currentTask.isCancelled === true
1010
+ || currentTask.isFailed === true) {
1011
+ await this.client.unwatch?.();
1012
+ return false;
1013
+ }
1014
+ const keysToDelete = [letterKey, this.__getKey(task.uuid, 'sendat')];
1015
+ if (task.to) {
1016
+ keysToDelete.push(this.__getKey(task.to, 'concatletter'));
1017
+ }
1018
+ const multi = this.client.multi();
1019
+ for (const key of keysToDelete) {
1020
+ multi.del(key);
1021
+ }
1022
+ const result = await multi.exec();
1023
+ return result !== null;
1024
+ } catch (opError) {
1025
+ logError('[remove] [lease] [opError]', opError);
1026
+ return false;
1027
+ }
1028
+ }
1029
+
549
1030
  const exists = await this.client.exists(letterKey);
550
1031
  if (!exists) {
551
1032
  return false;
552
1033
  }
553
1034
 
554
- await this.client.del([
555
- letterKey,
556
- this.__getKey(task.uuid, 'sendat'),
557
- ]);
1035
+ const keysToDelete = [letterKey, this.__getKey(task.uuid, 'sendat')];
1036
+ if (task.to) {
1037
+ keysToDelete.push(this.__getKey(task.to, 'concatletter'));
1038
+ }
1039
+ await this.client.del(keysToDelete);
558
1040
  return true;
559
1041
  }
560
1042
 
@@ -562,31 +1044,115 @@ class RedisQueue {
562
1044
  * @async
563
1045
  * @memberOf RedisQueue
564
1046
  * @name update
565
- * @description remove task from queue
1047
+ * @description update task in queue
566
1048
  * @param task {object} - task's object
567
1049
  * @param updateObj {object} - fields with new values to update
568
1050
  * @returns {Promise<boolean>} returns `true` if updated or `false` if not found or no changes was made
569
1051
  */
570
1052
  async update(task, updateObj) {
571
- if (typeof task !== 'object' || typeof task.uuid !== 'string' || typeof updateObj !== 'object') {
1053
+ this.__debug('[update]', task?.uuid);
1054
+ if (!task || typeof task !== 'object' || typeof task.uuid !== 'string' || !updateObj || typeof updateObj !== 'object') {
572
1055
  return false;
573
1056
  }
574
1057
 
575
1058
  const letterKey = this.__getKey(task.uuid, 'letter');
1059
+ const sendatKey = this.__getKey(task.uuid, 'sendat');
1060
+ const isClaim = isSendClaimUpdate(updateObj);
1061
+ const isAppend = isAppendMailOptionUpdate(updateObj);
1062
+ const isLeaseRelease = isSendLeaseGuardedUpdate(updateObj);
1063
+ const now = isClaim && typeof updateObj.sendingAt === 'number' ? updateObj.sendingAt : Date.now();
1064
+ const sendingTimeout = this.mailTimeInstance?.sendingTimeout || 300000;
576
1065
 
577
1066
  try {
578
- const exists = await this.client.exists(letterKey);
579
- if (!exists) {
1067
+ if (isAppend) {
1068
+ if (typeof this.client.watch !== 'function' || typeof this.client.multi !== 'function') {
1069
+ if (!RedisQueue.__atomicAppendWarned) {
1070
+ RedisQueue.__atomicAppendWarned = true;
1071
+ logError('[update] Redis client without watch()/multi() — concat appendMailOption falls back to non-atomic read-modify-write; concurrent folds into the same row may lose letters');
1072
+ }
1073
+ const taskJSON = await this.client.get(letterKey);
1074
+ if (!taskJSON) {
1075
+ return false;
1076
+ }
1077
+ const currentTask = JSON.parse(taskJSON);
1078
+ if (currentTask.isSending === true || currentTask.isSent === true || currentTask.isFailed === true || currentTask.isCancelled === true) {
1079
+ return false;
1080
+ }
1081
+ currentTask.mailOptions = [...(currentTask.mailOptions || []), updateObj.appendMailOption];
1082
+ await this.client.set(letterKey, JSON.stringify(currentTask));
1083
+ return true;
1084
+ }
1085
+
1086
+ await this.client.watch(letterKey);
1087
+ const taskJSON = await this.client.get(letterKey);
1088
+ if (!taskJSON) {
1089
+ await this.client.unwatch?.();
1090
+ return false;
1091
+ }
1092
+ const currentTask = JSON.parse(taskJSON);
1093
+ if (currentTask.isSending === true || currentTask.isSent === true || currentTask.isFailed === true || currentTask.isCancelled === true) {
1094
+ await this.client.unwatch?.();
1095
+ return false;
1096
+ }
1097
+ currentTask.mailOptions = [...(currentTask.mailOptions || []), updateObj.appendMailOption];
1098
+ const multi = this.client.multi();
1099
+ multi.set(letterKey, JSON.stringify(currentTask));
1100
+ const result = await multi.exec();
1101
+ return result !== null;
1102
+ }
1103
+
1104
+ if ((isClaim || isLeaseRelease) && (typeof this.client.watch !== 'function' || typeof this.client.multi !== 'function')) {
1105
+ if (isClaim && !RedisQueue.__atomicClaimWarned) {
1106
+ RedisQueue.__atomicClaimWarned = true;
1107
+ logError('[update] Redis client must support watch() and multi() for atomic send claims');
1108
+ }
1109
+ return false;
1110
+ }
1111
+
1112
+ if (isClaim || isLeaseRelease) {
1113
+ await this.client.watch(letterKey);
1114
+ const taskJSON = await this.client.get(letterKey);
1115
+ if (!taskJSON) {
1116
+ await this.client.unwatch?.();
1117
+ return false;
1118
+ }
1119
+
1120
+ const currentTask = JSON.parse(taskJSON);
1121
+ if (isClaim && !canClaimTask(currentTask, task, now, sendingTimeout)) {
1122
+ await this.client.unwatch?.();
1123
+ return false;
1124
+ }
1125
+ if (isLeaseRelease && !canReleaseLease(currentTask, updateObj)) {
1126
+ await this.client.unwatch?.();
1127
+ return false;
1128
+ }
1129
+
1130
+ const updatedTask = { ...currentTask, ...stripInternalUpdateMeta(updateObj) };
1131
+ const multi = this.client.multi();
1132
+ multi.set(letterKey, JSON.stringify(updatedTask));
1133
+ if (updatedTask.isSent === true || updatedTask.isFailed === true || updatedTask.isCancelled === true) {
1134
+ multi.del(sendatKey);
1135
+ } else if (updatedTask.sendAt) {
1136
+ multi.set(sendatKey, `${+updatedTask.sendAt}`);
1137
+ }
1138
+
1139
+ const result = await multi.exec();
1140
+ return result !== null;
1141
+ }
1142
+
1143
+ const taskJSON = await this.client.get(letterKey);
1144
+ if (!taskJSON) {
580
1145
  return false;
581
1146
  }
582
- const updatedTask = { ...task, ...updateObj };
1147
+
1148
+ const currentTask = JSON.parse(taskJSON);
1149
+ const updatedTask = { ...currentTask, ...stripInternalUpdateMeta(updateObj) };
583
1150
  await this.client.set(letterKey, JSON.stringify(updatedTask));
584
1151
 
585
- const sendatKey = this.__getKey(task.uuid, 'sendat');
586
1152
  if (updatedTask.isSent === true || updatedTask.isFailed === true || updatedTask.isCancelled === true) {
587
1153
  await this.client.del(sendatKey);
588
- } else if (task.sendAt) {
589
- await this.client.set(sendatKey, `${+task.sendAt}`);
1154
+ } else if (updatedTask.sendAt) {
1155
+ await this.client.set(sendatKey, `${+updatedTask.sendAt}`);
590
1156
  }
591
1157
  return true;
592
1158
  } catch (opError) {
@@ -595,171 +1161,944 @@ class RedisQueue {
595
1161
  }
596
1162
  }
597
1163
 
598
-
599
1164
  /**
1165
+ * @internal
600
1166
  * @memberOf RedisQueue
601
1167
  * @name __getKey
602
1168
  * @description helper to generate scoped key
603
- * @param uuid {string} - letter's uuid
1169
+ * @param uuid {string} - letter's uuid (or "to" address for `concatletter` keys)
604
1170
  * @param type {string} - "letter" or "sendat" or "concatletter"
605
1171
  * @returns {string} returns key used by Redis
606
1172
  */
607
1173
  __getKey(uuid, type = 'letter') {
608
- if (!this.__keyTypes.includes(type)) {
1174
+ if (!KEY_TYPES.has(type)) {
609
1175
  throw new Error(`[mail-time] [RedisQueue] [__getKey] unsupported key "${type}" passed into the second argument`);
610
1176
  }
1177
+ this.__ensurePrefix();
611
1178
  return `${this.uniqueName}:${type}:${uuid}`;
612
1179
  }
613
-
614
- /**
615
- * @memberOf RedisQueue
616
- * @name __keyTypes
617
- * @description list of supported key type
618
- * @returns {string} returns key used by Redis
619
- */
620
- __keyTypes = ['letter', 'sendat', 'concatletter'];
621
1180
  }
622
1181
 
623
- const noop = () => {};
1182
+ /**
1183
+ * @typedef {object} PostgresQueryResult
1184
+ * @property {number | null | undefined} [rowCount]
1185
+ * @property {unknown[]} [rows]
1186
+ */
624
1187
 
625
1188
  /**
626
- * Check if entities of various types are equal
627
- * including edge cases like unordered Objects and Array
628
- * @function equals
629
- * @param {mix} a
630
- * @param {mix} b
631
- * @returns {boolean}
1189
+ * @typedef {object} PostgresClient
1190
+ * @property {(queryText: string, values?: unknown[]) => Promise<PostgresQueryResult>} query
632
1191
  */
633
- const equals = (a, b) => {
634
- let i;
635
- if (a === b) {
636
- return true;
1192
+
1193
+ /**
1194
+ * @typedef {object} PostgresQueueOption
1195
+ * @property {PostgresClient} client
1196
+ * @property {string} [prefix]
1197
+ */
1198
+
1199
+ const DEFAULT_PREFIX = 'default';
1200
+ const setupLockId = 93824519;
1201
+
1202
+ const fieldMap = {
1203
+ to: 'to_address',
1204
+ tries: 'tries',
1205
+ sendAt: 'send_at',
1206
+ isSent: 'is_sent',
1207
+ isCancelled: 'is_cancelled',
1208
+ isFailed: 'is_failed',
1209
+ isSending: 'is_sending',
1210
+ sendingAt: 'sending_at',
1211
+ template: 'template',
1212
+ transport: 'transport',
1213
+ concatSubject: 'concat_subject',
1214
+ mailOptions: 'mail_options',
1215
+ };
1216
+
1217
+ const parseMailOptions = (mailOptions) => {
1218
+ if (typeof mailOptions === 'string') {
1219
+ return JSON.parse(mailOptions);
637
1220
  }
1221
+ return mailOptions;
1222
+ };
638
1223
 
639
- if (!a || !b) {
640
- return false;
1224
+ const normalizeRow = (row) => {
1225
+ if (!row) {
1226
+ return null;
641
1227
  }
642
1228
 
643
- if (!(typeof a === 'object' && typeof b === 'object')) {
644
- return false;
1229
+ return {
1230
+ id: row.id,
1231
+ uuid: row.uuid,
1232
+ to: row.to_address,
1233
+ tries: parseInt(row.tries, 10),
1234
+ sendAt: parseInt(row.send_at, 10),
1235
+ isSent: row.is_sent,
1236
+ isCancelled: row.is_cancelled,
1237
+ isFailed: row.is_failed,
1238
+ isSending: row.is_sending === true,
1239
+ sendingAt: row.sending_at !== null && row.sending_at !== undefined ? parseInt(row.sending_at, 10) : 0,
1240
+ template: row.template || false,
1241
+ transport: parseInt(row.transport, 10),
1242
+ concatSubject: row.concat_subject || false,
1243
+ mailOptions: parseMailOptions(row.mail_options),
1244
+ };
1245
+ };
1246
+
1247
+ /** Class representing PostgreSQL Queue for MailTime */
1248
+ class PostgresQueue {
1249
+ /**
1250
+ * Create a PostgresQueue instance
1251
+ * @param {PostgresQueueOption} opts - configuration object
1252
+ */
1253
+ constructor(opts) {
1254
+ this.name = 'postgres-queue';
1255
+ if (!opts || typeof opts !== 'object') {
1256
+ throw new TypeError('[mail-time] Configuration object must be passed into PostgresQueue constructor');
1257
+ }
1258
+
1259
+ if (!opts.client || typeof opts.client.query !== 'function') {
1260
+ throw new Error('[mail-time] [PostgresQueue] required {client} option is missing or does not expose a `query` method');
1261
+ }
1262
+
1263
+ this.client = opts.client;
1264
+ if (typeof opts.prefix === 'string' && opts.prefix.length > 0) {
1265
+ this.__applyPrefix(opts.prefix);
1266
+ }
645
1267
  }
646
1268
 
647
- if (a instanceof Date && b instanceof Date) {
648
- return a.valueOf() === b.valueOf();
1269
+ /** @internal */
1270
+ __applyPrefix(prefix) {
1271
+ this.prefix = prefix;
1272
+ this.__readyPromise = this.__setup();
1273
+ this.__readyPromise.catch(() => void 0);
649
1274
  }
650
1275
 
651
- if (a instanceof Array) {
652
- if (!(b instanceof Array)) {
653
- return false;
1276
+ /** @internal */
1277
+ __ensurePrefix() {
1278
+ if (typeof this.prefix !== 'string') {
1279
+ this.__applyPrefix(this.mailTimeInstance?.prefix || DEFAULT_PREFIX);
654
1280
  }
1281
+ }
655
1282
 
656
- if (a.length !== b.length) {
657
- return false;
1283
+ /** @internal */
1284
+ __debug(...args) {
1285
+ debug(this.mailTimeInstance?.debug === true, `[${this.name}]`, ...args);
1286
+ }
1287
+
1288
+ /**
1289
+ * @async
1290
+ * @memberOf PostgresQueue
1291
+ * @name ready
1292
+ * @description Wait until PostgreSQL schema is ready
1293
+ * @returns {Promise<void 0>}
1294
+ */
1295
+ async ready() {
1296
+ this.__ensurePrefix();
1297
+ this.__debug('[ready]');
1298
+ await this.__readyPromise;
1299
+ }
1300
+
1301
+ /** @internal */
1302
+ async __setup() {
1303
+ await this.client.query('SELECT pg_advisory_lock($1)', [setupLockId]);
1304
+
1305
+ try {
1306
+ await this.client.query(`CREATE TABLE IF NOT EXISTS mail_time_queue (
1307
+ id BIGSERIAL PRIMARY KEY,
1308
+ prefix TEXT NOT NULL DEFAULT 'default',
1309
+ uuid TEXT NOT NULL,
1310
+ to_address TEXT,
1311
+ tries INTEGER NOT NULL DEFAULT 0,
1312
+ send_at BIGINT NOT NULL,
1313
+ is_sent BOOLEAN NOT NULL DEFAULT false,
1314
+ is_cancelled BOOLEAN NOT NULL DEFAULT false,
1315
+ is_failed BOOLEAN NOT NULL DEFAULT false,
1316
+ is_sending BOOLEAN NOT NULL DEFAULT false,
1317
+ sending_at BIGINT NOT NULL DEFAULT 0,
1318
+ template TEXT,
1319
+ transport INTEGER NOT NULL DEFAULT 0,
1320
+ concat_subject TEXT,
1321
+ mail_options JSONB NOT NULL,
1322
+ created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
1323
+ updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
1324
+ )`);
1325
+ await this.client.query(`CREATE UNIQUE INDEX IF NOT EXISTS idx_mail_time_queue_prefix_uuid
1326
+ ON mail_time_queue (prefix, uuid)`);
1327
+
1328
+ await this.client.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_due
1329
+ ON mail_time_queue (prefix, is_sent, is_failed, is_cancelled, is_sending, sending_at, send_at, tries)`);
1330
+
1331
+ await this.client.query(`CREATE INDEX IF NOT EXISTS idx_mail_time_queue_pending_to
1332
+ ON mail_time_queue (prefix, to_address, is_sent, is_failed, is_cancelled, send_at)`);
1333
+ } finally {
1334
+ await this.client.query('SELECT pg_advisory_unlock($1)', [setupLockId]);
658
1335
  }
1336
+ }
659
1337
 
660
- const _a = a.slice();
661
- const _b = b.slice();
662
- let j;
663
- for (i = _a.length - 1; i >= 0; i--) {
664
- let result = false;
665
- for (j = _b.length - 1; j >= 0; j--) {
666
- if (equals(_a[i], _b[j])) {
667
- result = true;
668
- _a.splice(i, 1);
669
- _b.splice(j, 1);
670
- break;
671
- }
1338
+ /**
1339
+ * @async
1340
+ * @memberOf PostgresQueue
1341
+ * @name ping
1342
+ * @description Check connection to Storage
1343
+ * @returns {Promise<object>}
1344
+ */
1345
+ async ping() {
1346
+ this.__debug('[ping]');
1347
+ if (!this.mailTimeInstance) {
1348
+ return {
1349
+ status: 'Service Unavailable',
1350
+ code: 503,
1351
+ statusCode: 503,
1352
+ error: new Error('MailTime instance not yet assigned to {mailTimeInstance} of Queue Adapter context'),
1353
+ };
1354
+ }
1355
+
1356
+ try {
1357
+ await this.ready();
1358
+ const ping = await this.client.query('SELECT 1 as ping');
1359
+ if (ping?.rows?.[0]?.ping === 1) {
1360
+ return {
1361
+ status: 'OK',
1362
+ code: 200,
1363
+ statusCode: 200,
1364
+ };
672
1365
  }
1366
+ } catch (pingError) {
1367
+ return {
1368
+ status: 'Internal Server Error',
1369
+ code: 500,
1370
+ statusCode: 500,
1371
+ error: pingError,
1372
+ };
1373
+ }
673
1374
 
674
- if (!result) {
675
- return false;
1375
+ return {
1376
+ status: 'Service Unavailable',
1377
+ code: 503,
1378
+ statusCode: 503,
1379
+ error: new Error('Service Unavailable'),
1380
+ };
1381
+ }
1382
+
1383
+ /**
1384
+ * @memberOf PostgresQueue
1385
+ * @name iterate
1386
+ * @description iterate over queued tasks passing each to `mailTimeInstance.___dispatch` (the bounded send pool). Postgres reads buffer the full result, so each tick is bounded by `opts.limit` (or 1000 when caller passes `Infinity` / no limit) to keep memory predictable; high-throughput deployments should shard prefixes.
1387
+ * @param {{ limit?: number, sendingTimeout?: number }} [opts] - iteration options
1388
+ * @returns {Promise<void>}
1389
+ */
1390
+ async iterate(opts) {
1391
+ this.__debug('[iterate]', opts);
1392
+ if (!this.mailTimeInstance) {
1393
+ return;
1394
+ }
1395
+ await this.ready();
1396
+
1397
+ const now = Date.now();
1398
+ const sendingTimeout = (opts && typeof opts.sendingTimeout === 'number' && opts.sendingTimeout > 0)
1399
+ ? opts.sendingTimeout
1400
+ : 300000;
1401
+ const limit = (opts && typeof opts.limit === 'number' && Number.isFinite(opts.limit) && opts.limit > 0)
1402
+ ? Math.max(1, Math.floor(opts.limit))
1403
+ : 1000;
1404
+
1405
+ try {
1406
+ const res = await this.client.query(`SELECT id, uuid, to_address, tries, send_at, is_sent, is_cancelled, is_failed,
1407
+ is_sending, sending_at, template, transport, concat_subject, mail_options
1408
+ FROM mail_time_queue
1409
+ WHERE prefix = $1
1410
+ AND is_sent = false
1411
+ AND is_failed = false
1412
+ AND is_cancelled = false
1413
+ AND send_at <= $2
1414
+ AND tries < $3
1415
+ AND (is_sending = false OR sending_at <= $4)
1416
+ ORDER BY send_at ASC
1417
+ LIMIT $5`, [this.prefix, now, this.mailTimeInstance.maxTries, now - sendingTimeout, limit]);
1418
+
1419
+ for (const row of res.rows || []) {
1420
+ await this.mailTimeInstance.___dispatch(normalizeRow(row));
676
1421
  }
1422
+ } catch (iterateError) {
1423
+ logError('[PostgresQueue] [iterate] [iterateError]', iterateError);
677
1424
  }
678
- return true;
679
1425
  }
680
1426
 
681
- i = 0;
682
- if (typeof a === 'object' && typeof b === 'object') {
683
- const akeys = Object.keys(a);
684
- const bkeys = Object.keys(b);
1427
+ /**
1428
+ * @async
1429
+ * @memberOf PostgresQueue
1430
+ * @name getPendingTo
1431
+ * @description get queued task by `to` field (addressee)
1432
+ * @param to {string} - email address
1433
+ * @param sendAt {number} - timestamp
1434
+ * @returns {Promise<object|null>}
1435
+ */
1436
+ async getPendingTo(to, sendAt) {
1437
+ this.__debug('[getPendingTo]', to, sendAt);
1438
+ if (typeof to !== 'string' || typeof sendAt !== 'number') {
1439
+ return null;
1440
+ }
1441
+
1442
+ await this.ready();
1443
+
1444
+ const res = await this.client.query(`SELECT id, uuid, to_address, tries, send_at, is_sent, is_cancelled, is_failed,
1445
+ is_sending, sending_at, template, transport, concat_subject, mail_options
1446
+ FROM mail_time_queue
1447
+ WHERE prefix = $1
1448
+ AND to_address = $2
1449
+ AND is_sent = false
1450
+ AND is_failed = false
1451
+ AND is_cancelled = false
1452
+ AND is_sending = false
1453
+ AND tries < $4
1454
+ AND send_at <= $3
1455
+ ORDER BY send_at DESC
1456
+ LIMIT 1`, [this.prefix, to, sendAt, this.mailTimeInstance.maxTries]);
1457
+
1458
+ return normalizeRow(res.rows?.[0]);
1459
+ }
1460
+
1461
+ /**
1462
+ * @async
1463
+ * @memberOf PostgresQueue
1464
+ * @name push
1465
+ * @description push task to the queue/storage
1466
+ * @param task {object} - task's object
1467
+ * @returns {Promise<void 0>}
1468
+ */
1469
+ async push(task) {
1470
+ this.__debug('[push]', task?.uuid);
1471
+ if (!task || typeof task !== 'object') {
1472
+ return;
1473
+ }
1474
+
1475
+ await this.ready();
685
1476
 
686
- if (akeys.length !== bkeys.length) {
1477
+ if (task.sendAt instanceof Date) {
1478
+ task.sendAt = +task.sendAt;
1479
+ }
1480
+
1481
+ await this.client.query(`INSERT INTO mail_time_queue (
1482
+ prefix, uuid, to_address, tries, send_at, is_sent, is_cancelled, is_failed,
1483
+ is_sending, sending_at, template, transport, concat_subject, mail_options, created_at, updated_at
1484
+ )
1485
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
1486
+ ON CONFLICT (prefix, uuid) DO UPDATE SET
1487
+ to_address = EXCLUDED.to_address,
1488
+ tries = EXCLUDED.tries,
1489
+ send_at = EXCLUDED.send_at,
1490
+ is_sent = EXCLUDED.is_sent,
1491
+ is_cancelled = EXCLUDED.is_cancelled,
1492
+ is_failed = EXCLUDED.is_failed,
1493
+ is_sending = EXCLUDED.is_sending,
1494
+ sending_at = EXCLUDED.sending_at,
1495
+ template = EXCLUDED.template,
1496
+ transport = EXCLUDED.transport,
1497
+ concat_subject = EXCLUDED.concat_subject,
1498
+ mail_options = EXCLUDED.mail_options,
1499
+ updated_at = CURRENT_TIMESTAMP`, [
1500
+ this.prefix,
1501
+ task.uuid,
1502
+ typeof task.to === 'string' ? task.to : null,
1503
+ task.tries,
1504
+ task.sendAt,
1505
+ task.isSent,
1506
+ task.isCancelled,
1507
+ task.isFailed,
1508
+ task.isSending === true,
1509
+ typeof task.sendingAt === 'number' ? task.sendingAt : 0,
1510
+ task.template || null,
1511
+ task.transport,
1512
+ task.concatSubject || null,
1513
+ JSON.stringify(task.mailOptions || []),
1514
+ ]);
1515
+ }
1516
+
1517
+ /**
1518
+ * @async
1519
+ * @memberOf PostgresQueue
1520
+ * @name cancel
1521
+ * @description cancel scheduled email
1522
+ * @param uuid {string} - email's uuid
1523
+ * @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found, was sent, or was cancelled previously
1524
+ */
1525
+ async cancel(uuid) {
1526
+ this.__debug('[cancel]', uuid);
1527
+ if (typeof uuid !== 'string') {
1528
+ return false;
1529
+ }
1530
+
1531
+ await this.ready();
1532
+
1533
+ const task = normalizeRow((await this.client.query(`SELECT id, uuid, to_address, tries, send_at, is_sent, is_cancelled, is_failed,
1534
+ template, transport, concat_subject, mail_options
1535
+ FROM mail_time_queue
1536
+ WHERE prefix = $1
1537
+ AND uuid = $2
1538
+ LIMIT 1`, [this.prefix, uuid])).rows?.[0]);
1539
+
1540
+ if (!task || task.isSent === true || task.isCancelled === true) {
1541
+ return false;
1542
+ }
1543
+
1544
+ if (!this.mailTimeInstance.keepHistory) {
1545
+ return await this.remove(task);
1546
+ }
1547
+
1548
+ return await this.update(task, {
1549
+ isCancelled: true,
1550
+ });
1551
+ }
1552
+
1553
+ /**
1554
+ * @async
1555
+ * @memberOf PostgresQueue
1556
+ * @name remove
1557
+ * @description remove task from queue
1558
+ * @param task {object} - task's object
1559
+ * @param {{ leaseTries: number, leaseSendingAt: number }} [opts] - lease guard: only remove if this worker still holds the lease (tries + sendingAt match, row not cancelled/failed)
1560
+ * @returns {Promise<boolean>} returns `true` if removed or `false` if not found
1561
+ */
1562
+ async remove(task, opts) {
1563
+ this.__debug('[remove]', task?.uuid);
1564
+ if (!task || typeof task !== 'object') {
1565
+ return false;
1566
+ }
1567
+
1568
+ await this.ready();
1569
+
1570
+ const where = task.id ? 'id = $2' : 'uuid = $2';
1571
+ const value = task.id || task.uuid;
1572
+ let leaseWhere = '';
1573
+ const params = [this.prefix, value];
1574
+ if (isSendLeaseRemove(opts)) {
1575
+ params.push(opts.leaseTries, opts.leaseSendingAt);
1576
+ leaseWhere = ` AND tries = $3 AND is_sending = true AND sending_at = $4 AND is_cancelled = false AND is_failed = false`;
1577
+ }
1578
+
1579
+ const res = await this.client.query(`DELETE FROM mail_time_queue
1580
+ WHERE prefix = $1
1581
+ AND ${where}${leaseWhere}`, params);
1582
+ return (res.rowCount || 0) >= 1;
1583
+ }
1584
+
1585
+ /**
1586
+ * @async
1587
+ * @memberOf PostgresQueue
1588
+ * @name update
1589
+ * @description update task in queue
1590
+ * @param task {object} - task's object
1591
+ * @param updateObj {object} - fields with new values to update
1592
+ * @returns {Promise<boolean>} returns `true` if updated or `false` if not found or no changes was made
1593
+ */
1594
+ async update(task, updateObj) {
1595
+ this.__debug('[update]', task?.uuid);
1596
+ if (!task || typeof task !== 'object' || !updateObj || typeof updateObj !== 'object') {
687
1597
  return false;
688
1598
  }
689
1599
 
690
- for (i = akeys.length - 1; i >= 0; i--) {
691
- if (!hasOwnProperty.call(b, akeys[i])) {
692
- return false;
1600
+ await this.ready();
1601
+
1602
+ if (isAppendMailOptionUpdate(updateObj)) {
1603
+ const where = task.id ? 'id = $3' : 'uuid = $3';
1604
+ const value = task.id || task.uuid;
1605
+ const res = await this.client.query(`UPDATE mail_time_queue
1606
+ SET mail_options = mail_options || $1::jsonb,
1607
+ updated_at = CURRENT_TIMESTAMP
1608
+ WHERE prefix = $2
1609
+ AND ${where}
1610
+ AND is_sent = false
1611
+ AND is_failed = false
1612
+ AND is_cancelled = false
1613
+ AND is_sending = false`, [
1614
+ JSON.stringify([updateObj.appendMailOption]),
1615
+ this.prefix,
1616
+ value,
1617
+ ]);
1618
+ return (res.rowCount || 0) >= 1;
1619
+ }
1620
+
1621
+ const persistObj = stripInternalUpdateMeta(updateObj);
1622
+ const sets = [];
1623
+ const values = [];
1624
+ for (const key of Object.keys(persistObj)) {
1625
+ if (!fieldMap[key]) {
1626
+ continue;
693
1627
  }
694
1628
 
695
- if (!equals(a[akeys[i]], b[akeys[i]])) {
696
- return false;
1629
+ let value = persistObj[key];
1630
+ if (key === 'sendAt' && value instanceof Date) {
1631
+ value = +value;
697
1632
  }
1633
+ if (key === 'mailOptions') {
1634
+ value = JSON.stringify(value);
1635
+ }
1636
+
1637
+ values.push(value);
1638
+ sets.push(`${fieldMap[key]} = $${values.length}`);
698
1639
  }
699
1640
 
700
- return true;
1641
+ if (!sets.length) {
1642
+ return false;
1643
+ }
1644
+
1645
+ let claimWhere = '';
1646
+ if (isSendClaimUpdate(updateObj)) {
1647
+ const now = typeof updateObj.sendingAt === 'number' ? updateObj.sendingAt : Date.now();
1648
+ const sendingTimeout = this.mailTimeInstance?.sendingTimeout || 300000;
1649
+ values.push(task.tries);
1650
+ const triesIndex = values.length;
1651
+ values.push(now - sendingTimeout);
1652
+ const staleIndex = values.length;
1653
+ claimWhere = `
1654
+ AND is_sent = false
1655
+ AND is_failed = false
1656
+ AND is_cancelled = false
1657
+ AND tries = $${triesIndex}
1658
+ AND (is_sending = false OR sending_at <= $${staleIndex})
1659
+ `;
1660
+ } else if (isSendLeaseGuardedUpdate(updateObj)) {
1661
+ values.push(updateObj.leaseTries);
1662
+ const triesIndex = values.length;
1663
+ values.push(updateObj.leaseSendingAt);
1664
+ const sendingAtIndex = values.length;
1665
+ claimWhere = `
1666
+ AND tries = $${triesIndex}
1667
+ AND is_sending = true
1668
+ AND sending_at = $${sendingAtIndex}
1669
+ AND is_cancelled = false
1670
+ AND is_failed = false
1671
+ `;
1672
+ }
1673
+
1674
+ values.push(this.prefix);
1675
+ const prefixIndex = values.length;
1676
+ values.push(task.id || task.uuid);
1677
+ const taskIndex = values.length;
1678
+ const where = task.id ? 'id' : 'uuid';
1679
+
1680
+ const res = await this.client.query(`UPDATE mail_time_queue
1681
+ SET ${sets.join(', ')},
1682
+ updated_at = CURRENT_TIMESTAMP
1683
+ WHERE prefix = $${prefixIndex}
1684
+ AND ${where} = $${taskIndex}
1685
+ ${claimWhere}`, values);
1686
+
1687
+ return (res.rowCount || 0) >= 1;
701
1688
  }
702
- return false;
1689
+ }
1690
+
1691
+ /**
1692
+ * Default `onError` hook used by every built-in preset. Logs via the
1693
+ * shared `logError` helper and tags the line with the MailTime instance
1694
+ * `prefix` (or `'default'` when unset) so multi-queue deployments can
1695
+ * tell their streams apart. Defined as a regular function so `this`
1696
+ * resolves to the MailTime instance at call time — `this.onError(...)`
1697
+ * in `index.js` binds the receiver. Users override by passing their own
1698
+ * `onError` through `mailTimePreset(name, { onError })` or the
1699
+ * constructor.
1700
+ */
1701
+ function defaultPresetOnError(error, email, info) {
1702
+ logError(`[${this?.prefix || 'default'}] [onError]`, { error, email, info });
1703
+ }
1704
+
1705
+ /**
1706
+ * @typedef {object} MailTimePresetConfig
1707
+ * @property {boolean} [concatEmails]
1708
+ * @property {number} [concatDelay]
1709
+ * @property {string} [concatSubject]
1710
+ * @property {number} [retries]
1711
+ * @property {number} [retryDelay]
1712
+ * @property {number} [revolvingInterval]
1713
+ * @property {number} [sendingTimeout]
1714
+ * @property {'one' | 'batch'} [mode]
1715
+ * @property {number} [concurrency]
1716
+ * @property {(error: unknown, email: object, details?: object) => void} [onError]
1717
+ * @property {object} [josk]
1718
+ */
1719
+
1720
+ /**
1721
+ * Built-in MailTime presets keyed by use-case. Each value is a partial
1722
+ * MailTime constructor options object — pass it through `mailTimePreset`
1723
+ * (or spread directly) and supply your own `queue` / `transports` /
1724
+ * `josk.adapter` / `prefix`. Presets only set the knobs that differ from
1725
+ * MailTime defaults so the rest of the constructor stays in your hands.
1726
+ *
1727
+ * | Preset | Shape | Best for |
1728
+ * |-----------------|-------|----------|
1729
+ * | `transactional` | High retries, single SMTP per instance, no concat | Receipts, password resets, account changes, welcome emails. |
1730
+ * | `otp` | Few retries, fast retryDelay, snappy polling, parallel SMTPs | Sign-in codes, 2FA, verification codes — stale OTPs aren't worth resending forever. |
1731
+ * | `newsletter` | `concatEmails: true` with 5-min fold window | Scheduled digests / weekly updates / "what's new" emails. |
1732
+ * | `marketing` | High concurrency, moderate retries, no concat | Promotional / campaign blasts where each letter is unique. |
1733
+ * | `notifications` | `concatEmails: true` with 60-s fold window | App / social activity (likes, mentions) where bursts collapse into one letter. |
1734
+ * | `alerts` | Many retries, fast retryDelay, modest concurrency | Ops / admin alerts: monitoring, error reports, escalations. |
1735
+ *
1736
+ * @type {Readonly<Record<'transactional' | 'otp' | 'newsletter' | 'marketing' | 'notifications' | 'alerts', Readonly<MailTimePresetConfig>>>}
1737
+ */
1738
+ // Every preset pins `mode: 'batch'` explicitly. `'one'` would trade per-tick
1739
+ // throughput for cluster-wide fairness across pods on the same `prefix`, but:
1740
+ // - urgent classes (`otp`, `alerts`, `transactional`) want all due rows claimed
1741
+ // immediately, not spread one-per-tick;
1742
+ // - bulk classes (`newsletter`, `marketing`, `notifications`) are bursty and
1743
+ // need fast drains during the send window;
1744
+ // - multiple `server` pods on the same `prefix` is already an anti-pattern in
1745
+ // this library (one JoSk lease per prefix), so the fairness payoff is moot.
1746
+ // If you do want `'one'`, pass it via `mailTimePreset(name, { mode: 'one' })`.
1747
+ const PRESETS = Object.freeze({
1748
+ transactional: Object.freeze({
1749
+ concatEmails: false,
1750
+ retries: 30,
1751
+ retryDelay: 10_000,
1752
+ mode: 'batch',
1753
+ concurrency: 1,
1754
+ onError: defaultPresetOnError,
1755
+ josk: Object.freeze({
1756
+ zombieTime: 120_000,
1757
+ }),
1758
+ }),
1759
+ otp: Object.freeze({
1760
+ concatEmails: false,
1761
+ retries: 5,
1762
+ retryDelay: 2000,
1763
+ revolvingInterval: 1024,
1764
+ sendingTimeout: 60_000,
1765
+ mode: 'batch',
1766
+ concurrency: 4,
1767
+ onError: defaultPresetOnError,
1768
+ josk: Object.freeze({
1769
+ minRevolvingDelay: 256,
1770
+ maxRevolvingDelay: 1024,
1771
+ zombieTime: 60_000,
1772
+ }),
1773
+ }),
1774
+ newsletter: Object.freeze({
1775
+ concatEmails: true,
1776
+ concatDelay: 5 * 60_000,
1777
+ concatSubject: 'Your updates',
1778
+ retries: 5,
1779
+ retryDelay: 60_000,
1780
+ sendingTimeout: 600_000,
1781
+ mode: 'batch',
1782
+ concurrency: 2,
1783
+ onError: defaultPresetOnError,
1784
+ josk: Object.freeze({
1785
+ zombieTime: 300_000,
1786
+ }),
1787
+ }),
1788
+ marketing: Object.freeze({
1789
+ concatEmails: false,
1790
+ retries: 10,
1791
+ retryDelay: 30_000,
1792
+ mode: 'batch',
1793
+ concurrency: 5,
1794
+ onError: defaultPresetOnError,
1795
+ josk: Object.freeze({
1796
+ zombieTime: 180_000,
1797
+ }),
1798
+ }),
1799
+ notifications: Object.freeze({
1800
+ concatEmails: true,
1801
+ concatDelay: 60_000,
1802
+ concatSubject: 'New activity',
1803
+ retries: 8,
1804
+ retryDelay: 30_000,
1805
+ mode: 'batch',
1806
+ concurrency: 3,
1807
+ onError: defaultPresetOnError,
1808
+ josk: Object.freeze({
1809
+ zombieTime: 180_000,
1810
+ }),
1811
+ }),
1812
+ alerts: Object.freeze({
1813
+ concatEmails: false,
1814
+ retries: 20,
1815
+ retryDelay: 5000,
1816
+ revolvingInterval: 1024,
1817
+ sendingTimeout: 60_000,
1818
+ mode: 'batch',
1819
+ concurrency: 2,
1820
+ onError: defaultPresetOnError,
1821
+ josk: Object.freeze({
1822
+ minRevolvingDelay: 256,
1823
+ maxRevolvingDelay: 1024,
1824
+ zombieTime: 60_000,
1825
+ }),
1826
+ }),
1827
+ });
1828
+
1829
+ /**
1830
+ * Read-only map of preset name → partial MailTime config. Use
1831
+ * `mailTimePreset(name, overrides)` to materialize a merged copy; use
1832
+ * this object directly only when you want to introspect or compose
1833
+ * presets manually.
1834
+ */
1835
+ const presets = PRESETS;
1836
+
1837
+ /**
1838
+ * @typedef {keyof typeof PRESETS} MailTimePresetName
1839
+ */
1840
+
1841
+ /**
1842
+ * Names of every built-in preset.
1843
+ * @type {ReadonlyArray<MailTimePresetName>}
1844
+ */
1845
+ const presetNames = Object.freeze(/** @type {MailTimePresetName[]} */ (Object.keys(PRESETS)));
1846
+
1847
+ /**
1848
+ * Materialize a MailTime constructor config from a built-in preset.
1849
+ * The named preset is deep-cloned (so the result is freely mutable) and
1850
+ * `overrides` is deep-merged on top — overrides win for scalar keys, and
1851
+ * nested objects like `josk` are merged so the preset's defaults compose
1852
+ * with the caller's `adapter`, `lockOwnerId`, `onError`, etc.
1853
+ *
1854
+ * ```js
1855
+ * import { MailTime, RedisQueue, mailTimePreset } from 'mail-time';
1856
+ * const mailTime = new MailTime(mailTimePreset('otp', {
1857
+ * prefix: 'otp',
1858
+ * queue: new RedisQueue({ client }),
1859
+ * transports: [otpTransport],
1860
+ * josk: { adapter: { type: 'redis', client } },
1861
+ * }));
1862
+ * ```
1863
+ *
1864
+ * @param {MailTimePresetName} name - one of `presetNames`
1865
+ * @param {object} [overrides] - additional MailTime constructor options
1866
+ * @returns {object} fresh, mutable MailTime constructor options
1867
+ * @throws {Error} when `name` is unknown
1868
+ * @throws {TypeError} when `overrides` is provided but not a plain object
1869
+ */
1870
+ const mailTimePreset = (name, overrides) => {
1871
+ if (typeof name !== 'string' || !Object.hasOwn(PRESETS, name)) {
1872
+ throw new Error(`[mail-time] [mailTimePreset] unknown preset "${name}". Available: ${presetNames.join(', ')}`);
1873
+ }
1874
+ if (overrides !== void 0 && !isPlainObject(overrides)) {
1875
+ throw new TypeError('[mail-time] [mailTimePreset] {overrides} must be a plain object when provided');
1876
+ }
1877
+ const cloned = deepMerge({}, PRESETS[name]);
1878
+ return overrides ? deepMerge(cloned, overrides) : cloned;
1879
+ };
1880
+
1881
+ const noop = () => {};
1882
+ const queueMethods = ['ping', 'iterate', 'getPendingTo', 'push', 'remove', 'update', 'cancel'];
1883
+
1884
+ const __withLease = (task, updateObj) => {
1885
+ if (task?.isSending === true && typeof task.tries === 'number' && typeof task.sendingAt === 'number') {
1886
+ return { ...updateObj, leaseTries: task.tries, leaseSendingAt: task.sendingAt };
1887
+ }
1888
+ return updateObj;
1889
+ };
1890
+
1891
+ const __leaseRemoveOpts = (task) => {
1892
+ if (task?.isSending === true && typeof task.tries === 'number' && typeof task.sendingAt === 'number') {
1893
+ return { leaseTries: task.tries, leaseSendingAt: task.sendingAt };
1894
+ }
1895
+ return void 0;
1896
+ };
1897
+
1898
+ const createPool = (concurrency) => {
1899
+ const limit = Math.max(1, concurrency | 0);
1900
+ const queue = [];
1901
+ let active = 0;
1902
+ let drainResolvers = [];
1903
+
1904
+ const tryStart = () => {
1905
+ while (active < limit && queue.length > 0) {
1906
+ const job = queue.shift();
1907
+ active++;
1908
+ job.resolveSlot();
1909
+ Promise.resolve()
1910
+ .then(job.fn)
1911
+ .catch((poolError) => {
1912
+ logError('[pool] unhandled send error', poolError);
1913
+ })
1914
+ .finally(() => {
1915
+ active--;
1916
+ if (active === 0 && queue.length === 0 && drainResolvers.length > 0) {
1917
+ const resolvers = drainResolvers;
1918
+ drainResolvers = [];
1919
+ for (const r of resolvers) r();
1920
+ }
1921
+ tryStart();
1922
+ });
1923
+ }
1924
+ };
1925
+
1926
+ return {
1927
+ /**
1928
+ * Queue `fn` for execution under the concurrency limit.
1929
+ * The returned Promise resolves as soon as a slot is acquired and `fn` has started.
1930
+ * It does NOT wait for `fn` to finish. Use `drain()` to wait for all running jobs to settle.
1931
+ * @param {() => Promise<void>} fn
1932
+ * @returns {Promise<void>}
1933
+ */
1934
+ dispatch(fn) {
1935
+ return new Promise((resolveSlot) => {
1936
+ queue.push({ fn, resolveSlot });
1937
+ tryStart();
1938
+ });
1939
+ },
1940
+ drain() {
1941
+ if (active === 0 && queue.length === 0) {
1942
+ return Promise.resolve();
1943
+ }
1944
+ return new Promise((resolve) => drainResolvers.push(resolve));
1945
+ },
1946
+ get size() {
1947
+ return active + queue.length;
1948
+ },
1949
+ };
1950
+ };
1951
+
1952
+ const mailOptionRecipients = (mailOption) => {
1953
+ if (!mailOption) {
1954
+ return [];
1955
+ }
1956
+ return [
1957
+ ...toAddressList(mailOption.to),
1958
+ ...toAddressList(mailOption.cc),
1959
+ ...toAddressList(mailOption.bcc),
1960
+ ];
1961
+ };
1962
+
1963
+ const collectAcceptedSet = (task) => {
1964
+ const set = new Set();
1965
+ for (const mo of (task?.mailOptions || [])) {
1966
+ if (Array.isArray(mo.accepted)) {
1967
+ for (const addr of mo.accepted) {
1968
+ if (typeof addr === 'string') {
1969
+ set.add(addr.toLowerCase());
1970
+ }
1971
+ }
1972
+ }
1973
+ }
1974
+ return set;
1975
+ };
1976
+
1977
+ const collectAllRecipients = (task) => {
1978
+ const set = new Set();
1979
+ for (const mo of (task?.mailOptions || [])) {
1980
+ for (const addr of mailOptionRecipients(mo)) {
1981
+ set.add(addr);
1982
+ }
1983
+ }
1984
+ return set;
1985
+ };
1986
+
1987
+ const buildRejectionErrorMap = (info) => {
1988
+ const map = new Map();
1989
+ const rejected = Array.isArray(info?.rejected) ? info.rejected : [];
1990
+ const rejectedErrors = Array.isArray(info?.rejectedErrors) ? info.rejectedErrors : [];
1991
+ for (let i = 0; i < rejected.length; i++) {
1992
+ const addr = extractEmail(rejected[i]);
1993
+ if (!addr) {
1994
+ continue;
1995
+ }
1996
+ const err = rejectedErrors[i];
1997
+ map.set(addr, err ? `${err.message || err}` : 'Recipient rejected by transport');
1998
+ }
1999
+ return map;
703
2000
  };
704
2001
 
705
2002
  let DEFAULT_TEMPLATE = '<!DOCTYPE html><html xmlns=http://www.w3.org/1999/xhtml><meta content="text/html; charset=utf-8"http-equiv=Content-Type><meta content="width=device-width,initial-scale=1"name=viewport><title>{{subject}}</title><style>body{-webkit-font-smoothing:antialiased;-webkit-text-size-adjust:none;font-family:Tiempos,Georgia,Times,serif;font-weight:400;width:100%;height:100%;background:#fff;font-size:15px;color:#000;line-height:1.5}a{text-decoration:underline;border:0;color:#000;outline:0;color:inherit}a:hover{text-decoration:none}a[href^=sms],a[href^=tel]{text-decoration:none;color:#000;cursor:default}a img{border:none;text-decoration:none}td{font-family:Tiempos,Georgia,Times,serif;font-weight:400}hr{height:1px;border:none;width:100%;margin:0;margin-top:25px;margin-bottom:25px;background-color:#ECECEC}h1,h2,h3,h4,h5,h6{font-family:HelveticaNeue,"Helvetica Neue",Helvetica,Arial,sans-serif;font-weight:300;line-height:normal;margin-top:35px;margin-bottom:4px;margin-left:0;margin-right:0}h1{margin:23px 15px;font-size:25px}h2{margin-top:15px;font-size:21px}h3{font-weight:400;font-size:19px;border-bottom:1px solid #ECECEC}h4{font-weight:400;font-size:18px}h5{font-weight:400;font-size:17px}h6{font-weight:600;font-size:16px}h1 a,h2 a,h3 a,h4 a,h5 a,h6 a{text-decoration:none}pre{font-family:Consolas,Menlo,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New,monospace,sans-serif;display:block;font-size:13px;padding:9.5px;margin:0 0 10px;line-height:1.42;color:#333;word-break:break-all;word-wrap:break-word;background-color:#f5f5f5;border:1px solid #ccc;border-radius:4px;text-align:left!important;max-width:100%;white-space:pre-wrap;width:auto;overflow:auto}code{font-size:13px;font-family:font-family: Consolas,Menlo,Monaco,Lucida Console,Liberation Mono,DejaVu Sans Mono,Bitstream Vera Sans Mono,Courier New,monospace,sans-serif;border:1px solid rgba(0,0,0,.223);border-radius:2px;padding:1px 2px;word-break:break-all;word-wrap:break-word}pre code{padding:0;font-size:inherit;color:inherit;white-space:pre-wrap;background-color:transparent;border:none;border-radius:0;word-break:break-all;word-wrap:break-word}td{text-align:center}table{border-collapse:collapse!important}.force-full-width{width:100%!important}</style><style media=screen>@media screen{h1,h2,h3,h4,h5,h6{font-family:\'Helvetica Neue\',Arial,sans-serif!important}td{font-family:Tiempos,Georgia,Times,serif!important}code,pre{font-family:Consolas,Menlo,Monaco,\'Lucida Console\',\'Liberation Mono\',\'DejaVu Sans Mono\',\'Bitstream Vera Sans Mono\',\'Courier New\',monospace,sans-serif!important}}</style><style media="only screen and (max-width:480px)">@media only screen and (max-width:480px){table[class=w320]{width:100%!important}}</style><body bgcolor=#FFFFFF class=body style=padding:0;margin:0;display:block;background:#fff;-webkit-text-size-adjust:none><table cellpadding=0 cellspacing=0 width=100% align=center><tr><td align=center valign=top bgcolor=#FFFFFF width=100%><center><table cellpadding=0 cellspacing=0 width=600 style="margin:0 auto"class=w320><tr><td align=center valign=top><table cellpadding=0 cellspacing=0 width=100% style="margin:0 auto;border-bottom:1px solid #ddd"bgcolor=#ECECEC><tr><td><h1>{{{subject}}}</h1></table><table cellpadding=0 cellspacing=0 width=100% style="margin:0 auto"bgcolor=#F2F2F2><tr><td><center><table cellpadding=0 cellspacing=0 width=100% style="margin:0 auto"><tr><td align=left style="text-align:left;padding:30px 25px">{{{html}}}</table></center></table></table></center></table>';
706
2003
 
2004
+ /**
2005
+ * @typedef {import('./presets.js').MailTimePresetName} MailTimePresetName
2006
+ */
2007
+
2008
+ /**
2009
+ * @typedef {import('./presets.js').MailTimePresetConfig} MailTimePresetConfig
2010
+ */
2011
+
2012
+ /**
2013
+ * @typedef {{ status: string, code: number, statusCode: number, error?: unknown }} MailTimePingResult
2014
+ */
2015
+
2016
+ /**
2017
+ * @typedef {{ [key: string]: any, query?: (queryText: string, values?: unknown[]) => Promise<{ rows?: unknown[], rowCount?: number | null }> }} MailTimeStorageClient
2018
+ */
2019
+
2020
+ /**
2021
+ * @typedef {{ [key: string]: any }} MailTimeMongoDb
2022
+ */
2023
+
2024
+ /**
2025
+ * @typedef {{ [key: string]: any }} MailTimeTransport
2026
+ */
2027
+
2028
+ /**
2029
+ * @typedef {{ [key: string]: any, type?: 'mongo' | 'redis' | 'postgres', client?: MailTimeStorageClient, db?: MailTimeMongoDb, prefix?: string, resetOnInit?: boolean, useHashTags?: boolean }} MailTimeJoSkAdapterOptions
2030
+ */
2031
+
2032
+ /**
2033
+ * @typedef {{ [key: string]: any, adapter: MailTimeJoSkAdapterOptions | object, debug?: boolean, autoClear?: boolean, zombieTime?: number, minRevolvingDelay?: number, maxRevolvingDelay?: number, execute?: 'batch' | 'one', concurrency?: number, lockOwnerId?: string, resetOnInit?: boolean, onError?: (title: string, details: object) => void, onExecuted?: (uid: string, details: object) => void }} MailTimeJoSkOptions
2034
+ */
2035
+
2036
+ /**
2037
+ * @typedef {{ uuid: string, to?: string | string[], tries: number, sendAt: number, isSent: boolean, isCancelled: boolean, isFailed: boolean, isSending?: boolean, sendingAt?: number, template?: string | false, transport: number, concatSubject?: string | false, mailOptions: MailTimeMailOptions[] }} MailTimeTask
2038
+ */
2039
+
2040
+ /**
2041
+ * @typedef {{ limit?: number, sendingTimeout?: number }} MailTimeIterateOptions
2042
+ */
2043
+
2044
+ /**
2045
+ * @typedef {{ ping: () => Promise<MailTimePingResult>, iterate: (opts?: MailTimeIterateOptions) => Promise<void> | void, getPendingTo: (to: string, sendAt: number) => Promise<MailTimeTask | object | null>, push: (email: MailTimeTask) => Promise<void> | void, cancel: (uuid: string) => Promise<boolean>, remove: (email: MailTimeTask | object, opts?: { leaseTries: number, leaseSendingAt: number }) => Promise<boolean>, update: (email: MailTimeTask | object, updateObj: object) => Promise<boolean>, ready?: () => Promise<void> }} CustomQueue
2046
+ */
2047
+
2048
+ /**
2049
+ * @typedef {{ address: string, error: string }} MailTimeRejectedRecipient
2050
+ */
2051
+
2052
+ /**
2053
+ * @typedef {{ [key: string]: any, to: string | string[], sendAt?: Date | number, template?: string, concatSubject?: string, text?: string | false, html?: string | false, subject?: string, accepted?: string[], rejected?: MailTimeRejectedRecipient[] }} MailTimeMailOptions
2054
+ */
2055
+
2056
+ /**
2057
+ * @typedef {{ subject?: string }} MailTimeConcatEmailsOptions
2058
+ */
2059
+
2060
+ /**
2061
+ * @typedef {{ queue: RedisQueue | MongoQueue | PostgresQueue | CustomQueue, type?: 'server' | 'client', from?: string | ((transport: MailTimeTransport) => string), transports?: MailTimeTransport[], strategy?: 'backup' | 'balancer', failsToNext?: number, retries?: number, maxTries?: number, retryDelay?: number, interval?: number, keepHistory?: boolean, concatEmails?: boolean | MailTimeConcatEmailsOptions, concatSubject?: string, concatDelimiter?: string, concatDelay?: number, concatThrottling?: number, revolvingInterval?: number, mode?: 'one' | 'batch', concurrency?: number, sendingTimeout?: number, verifyTransports?: boolean, template?: string, prefix?: string, debug?: boolean, josk?: MailTimeJoSkOptions, onError?: (error: unknown, email: MailTimeTask | null, details?: object) => void, onSent?: (email: MailTimeTask, details?: object) => void }} MailTimeOptions
2062
+ */
2063
+
707
2064
  /** Class of MailTime */
708
2065
  class MailTime {
709
2066
  /**
710
2067
  * Create a MailTime instance
711
- * @param {object} opts - configuration object
712
- * @param {RedisQueue|MongoQueue|CustomQueue} opts.queue - Queue Storage Driver instance
713
- * @param {string} [opts.type] - "server" or "client" type of MailTime instance
714
- * @param {function} [opts.from] - A function returning *String* for `from` header, format: `"MyApp" <user@example.com>`
715
- * @param {[object]} [opts.transports] - An array of `nodemailer`'s transports, returned from `nodemailer.createTransport({})`; Required for `{type: 'server'}`
716
- * @param {string} [opts.strategy] - `backup` or `balancer`
717
- * @param {number} [opts.failsToNext] - After how many failed "send attempts" switch to the next transport? Applied only for `backup` strategy, default - `4`
718
- * @param {number} [opts.retries] - How many times resend failed emails
719
- * @param {number} [opts.retryDelay] - Interval in milliseconds between send re-tries
720
- * @param {boolean} [opts.keepHistory] - Keep queue task as it is in the database
721
- * @param {boolean} [opts.concatEmails] - Concatenate email by `to` field, default - `false`
722
- * @param {string} [opts.concatSubject] - Email subject used in concatenated email, default - `Multiple notifications`
723
- * @param {string} [opts.concatDelimiter] - HTML or plain string delimiter used between concatenated email, default - `<hr>`
724
- * @param {number} [opts.concatDelay] - HTML or plain string delimiter used between concatenated email, default - `<hr>`
725
- * @param {number} [opts.revolvingInterval] - Interval in *milliseconds* in between queue checks, default - `256`
726
- * @param {object|RedisAdapter|MongoAdapter|CustomAdapter} [opts.josk.adapter] - Interval in milliseconds between send re-tries
727
- * @param {string} [opts.josk.adapter.type] - One of `mongo` or `redis`
728
- * @param {string} [opts.prefix] - Optional prefix for scope isolation; use when creating multiple MailTime instances within the single application; By default prefix inherited from MailTime instance
2068
+ * @param {MailTimeOptions} opts - configuration object
729
2069
  */
730
2070
  constructor (opts) {
731
- if (!opts || typeof opts !== 'object' || opts === null) {
2071
+ if (!opts || typeof opts !== 'object') {
732
2072
  throw new TypeError('[mail-time] Configuration object must be passed into MailTime constructor');
733
2073
  }
734
2074
 
735
2075
  if (!opts.queue || typeof opts.queue !== 'object') {
736
- throw new Error('[mail-time] {queue} option is required', {
737
- description: 'MailTime requires MongoQueue, RedisQueue, or CustomQueue to connect to an intermediate database'
738
- });
2076
+ throw new Error('[mail-time] {queue} option is required: provide a MongoQueue, RedisQueue, PostgresQueue, or CustomQueue instance');
739
2077
  }
740
2078
 
741
2079
  this.queue = opts.queue;
742
- this.queue.mailTimeInstance = this;
743
- const queueMethods = ['ping', 'iterate', 'getPendingTo', 'push', 'remove', 'update', 'cancel'];
744
2080
 
745
2081
  for (let i = queueMethods.length - 1; i >= 0; i--) {
746
2082
  if (typeof this.queue[queueMethods[i]] !== 'function') {
747
- throw new Error(`{queue} instance is missing {${queueMethods[i]}} method that is required!`);
2083
+ throw new Error(`[mail-time] {queue} instance is missing {${queueMethods[i]}} method that is required!`);
748
2084
  }
749
2085
  }
750
2086
 
751
- this.debug = (opts.debug !== true) ? false : true;
752
- this._debug = (...args) => {
753
- debug(this.debug, ...args);
2087
+ this.debug = opts.debug === true;
2088
+ this.__debug = (...args) => {
2089
+ debug(this.debug, `[${this.prefix || 'default'}]`, ...args);
754
2090
  };
755
2091
 
756
- this.type = (typeof opts.type !== 'string' || (opts.type !== 'client' && opts.type !== 'server')) ? 'server' : opts.type;
2092
+ this.type = (opts.type === 'client' || opts.type === 'server') ? opts.type : 'server';
757
2093
  this.prefix = (typeof opts.prefix === 'string') ? opts.prefix : '';
758
2094
 
759
2095
  if (typeof opts.retries === 'number') {
2096
+ if (opts.retries < 0) {
2097
+ throw new Error('[mail-time] {retries} must be a non-negative number');
2098
+ }
760
2099
  this.maxTries = opts.retries + 1;
761
2100
  } else if (typeof opts.maxTries === 'number') {
762
- this.maxTries = (opts.maxTries < 1) ? 1 : 0;
2101
+ this.maxTries = (opts.maxTries < 1) ? 1 : opts.maxTries;
763
2102
  } else {
764
2103
  this.maxTries = 60;
765
2104
  }
@@ -773,33 +2112,51 @@ class MailTime {
773
2112
  }
774
2113
 
775
2114
  this.template = (typeof opts.template === 'string') ? opts.template : '{{{html}}}';
776
- this.keepHistory = (typeof opts.keepHistory === 'boolean') ? opts.keepHistory : false;
777
- this.onSent = opts.onSent || noop;
778
- this.onError = opts.onError || noop;
779
-
780
- this.revolvingInterval = opts.revolvingInterval || 1536;
781
-
782
- this.failsToNext = (typeof opts.failsToNext === 'number') ? opts.failsToNext : 4;
2115
+ this.keepHistory = opts.keepHistory === true;
2116
+ this.onSent = (typeof opts.onSent === 'function') ? opts.onSent.bind(this) : noop;
2117
+ this.onError = (typeof opts.onError === 'function') ? opts.onError.bind(this) : noop;
2118
+
2119
+ this.revolvingInterval = (typeof opts.revolvingInterval === 'number' && opts.revolvingInterval > 0) ? opts.revolvingInterval : 1536;
2120
+ this.mode = (opts.mode === 'one' || opts.mode === 'batch') ? opts.mode : 'batch';
2121
+ this.concurrency = (typeof opts.concurrency === 'number' && opts.concurrency > 0 && Number.isFinite(opts.concurrency)) ? Math.floor(opts.concurrency) : 1;
2122
+ this.sendingTimeout = (typeof opts.sendingTimeout === 'number' && opts.sendingTimeout > 0) ? opts.sendingTimeout : 300000;
2123
+ this.__isDestroyed = false;
2124
+ this.__readyPromise = null;
2125
+ this.__schedulerTimer = null;
2126
+ this.__inFlight = new Set();
2127
+ this.__pool = createPool(this.concurrency);
2128
+
2129
+ this.failsToNext = (typeof opts.failsToNext === 'number' && opts.failsToNext > 0) ? opts.failsToNext : 4;
783
2130
  this.strategy = (opts.strategy === 'backup' || opts.strategy === 'balancer') ? opts.strategy : 'backup';
784
- this.transports = opts.transports || [];
2131
+ this.transports = Array.isArray(opts.transports) ? opts.transports : [];
785
2132
  this.transport = 0;
786
- this.from = (() => {
787
- if (typeof opts.from === 'string') {
788
- return () => {
789
- return opts.from;
790
- };
791
- }
792
-
793
- if (typeof opts.from === 'function') {
794
- return opts.from;
795
- }
2133
+ this.verifyTransports = opts.verifyTransports !== false;
2134
+ this.__unhealthyTransports = new Set();
2135
+
2136
+ if (typeof opts.from === 'string') {
2137
+ const fromStr = opts.from;
2138
+ this.from = () => fromStr;
2139
+ } else if (typeof opts.from === 'function') {
2140
+ this.from = opts.from;
2141
+ } else {
2142
+ this.from = false;
2143
+ }
796
2144
 
797
- return false;
798
- })();
2145
+ this.queue.mailTimeInstance = this;
799
2146
 
800
- this.concatEmails = (opts.concatEmails !== true) ? false : true;
801
- this.concatSubject = (opts.concatSubject && typeof opts.concatSubject === 'string') ? opts.concatSubject : 'Multiple notifications';
802
- this.concatDelimiter = (opts.concatDelimiter && typeof opts.concatDelimiter === 'string') ? opts.concatDelimiter : '<hr>';
2147
+ /** @type {string} */
2148
+ this.concatSubject = (typeof opts.concatSubject === 'string' && opts.concatSubject) ? opts.concatSubject : 'Multiple notifications';
2149
+ if (opts.concatEmails === true) {
2150
+ this.concatEmails = true;
2151
+ } else if (isPlainObject(opts.concatEmails)) {
2152
+ this.concatEmails = true;
2153
+ if (typeof opts.concatEmails.subject === 'string' && opts.concatEmails.subject) {
2154
+ this.concatSubject = opts.concatEmails.subject;
2155
+ }
2156
+ } else {
2157
+ this.concatEmails = false;
2158
+ }
2159
+ this.concatDelimiter = (typeof opts.concatDelimiter === 'string' && opts.concatDelimiter) ? opts.concatDelimiter : '<hr>';
803
2160
 
804
2161
  if (typeof opts.concatDelay === 'number') {
805
2162
  this.concatDelay = opts.concatDelay;
@@ -809,63 +2166,90 @@ class MailTime {
809
2166
  this.concatDelay = 60000;
810
2167
  }
811
2168
 
812
- this._debug('DEBUG ON {debug: true}');
813
- this._debug(`INITIALIZING [type: ${this.type}]`);
814
- this._debug(`INITIALIZING [strategy: ${this.strategy}]`);
815
- this._debug(`INITIALIZING [josk.adapter: ${opts?.josk?.adapter}]`);
816
- this._debug(`INITIALIZING [prefix: ${this.prefix}]`);
817
- this._debug(`INITIALIZING [retries: ${this.retries}]`);
818
- this._debug(`INITIALIZING [failsToNext: ${this.failsToNext}]`);
819
- this._debug(`INITIALIZING [onError: ${this.onError}]`);
820
- this._debug(`INITIALIZING [onSent: ${this.onSent}]`);
2169
+ this.__debug('DEBUG ON {debug: true}');
2170
+ this.__debug(`INITIALIZING [type: ${this.type}]`);
2171
+ this.__debug(`INITIALIZING [strategy: ${this.strategy}]`);
2172
+ this.__debug(`INITIALIZING [josk.adapter.type: ${opts?.josk?.adapter?.type || 'custom'}]`);
2173
+ this.__debug(`INITIALIZING [prefix: ${this.prefix}]`);
2174
+ this.__debug(`INITIALIZING [retries: ${this.maxTries - 1}]`);
2175
+ this.__debug(`INITIALIZING [failsToNext: ${this.failsToNext}]`);
2176
+ this.__debug(`INITIALIZING [mode: ${this.mode}]`);
2177
+ this.__debug(`INITIALIZING [concurrency: ${this.concurrency}]`);
2178
+ this.__debug(`INITIALIZING [sendingTimeout: ${this.sendingTimeout}]`);
821
2179
 
822
2180
  /** SERVER-SPECIFIC CHECKS AND CONFIG */
823
2181
  if (this.type === 'server') {
824
- if (this.transports.constructor !== Array || !this.transports.length) {
825
- throw new Error('[mail-time] {transports} is required for {type: "server"} and must be an Array, like returned from `nodemailer.createTransport`');
2182
+ if (!this.transports.length) {
2183
+ throw new Error('[mail-time] {transports} is required for {type: "server"} and must be a non-empty Array, like one returned from `nodemailer.createTransport`');
826
2184
  }
827
2185
 
828
- if (typeof opts.josk !== 'object') {
2186
+ if (!opts.josk || typeof opts.josk !== 'object') {
829
2187
  throw new Error('[mail-time] {josk} option is required {object} for {type: "server"}');
830
2188
  }
831
2189
 
832
- if (typeof opts.josk.adapter !== 'object') {
2190
+ if (!opts.josk.adapter || typeof opts.josk.adapter !== 'object') {
833
2191
  throw new Error('[mail-time] {josk.adapter} option is required {object} *or* custom adapter Class');
834
2192
  }
835
2193
 
836
2194
  this.josk = { ...opts.josk };
2195
+ const buildAdapterOptions = () => {
2196
+ const adapterOptions = {
2197
+ prefix: `mailTimeQueue${this.prefix}`,
2198
+ ...opts.josk.adapter,
2199
+ };
2200
+
2201
+ if (typeof opts.josk.resetOnInit === 'boolean' && typeof adapterOptions.resetOnInit !== 'boolean') {
2202
+ adapterOptions.resetOnInit = opts.josk.resetOnInit;
2203
+ }
837
2204
 
838
- if (typeof opts.josk.adapter?.type === 'string' && opts.josk.adapter?.type === 'mongo') {
2205
+ return adapterOptions;
2206
+ };
2207
+
2208
+ const adapterType = opts.josk.adapter.type;
2209
+ if (adapterType === 'mongo') {
839
2210
  if (!opts.josk.adapter.db) {
840
2211
  throw new Error('[mail-time] {josk.adapter.db} option required for {josk.adapter.type: "mongo"}');
841
2212
  }
842
- this.josk.adapter = new josk.MongoAdapter({ prefix: `mailTimeQueue${this.prefix}`, ...opts.josk.adapter });
843
- }
844
-
845
- if (typeof opts.josk.adapter?.type === 'string' && opts.josk.adapter?.type === 'redis') {
2213
+ this.josk.adapter = new josk.MongoAdapter(buildAdapterOptions());
2214
+ } else if (adapterType === 'redis') {
846
2215
  if (!opts.josk.adapter.client) {
847
2216
  throw new Error('[mail-time] {josk.adapter.client} option required for {josk.adapter.type: "redis"}');
848
2217
  }
849
- this.josk.adapter = new josk.RedisAdapter({ prefix: `mailTimeQueue${this.prefix}`, ...opts.josk.adapter });
2218
+ this.josk.adapter = new josk.RedisAdapter(buildAdapterOptions());
2219
+ } else if (adapterType === 'postgres') {
2220
+ if (!opts.josk.adapter.client) {
2221
+ throw new Error('[mail-time] {josk.adapter.client} option required for {josk.adapter.type: "postgres"}');
2222
+ }
2223
+ this.josk.adapter = new josk.PostgresAdapter(buildAdapterOptions());
850
2224
  }
851
2225
 
852
2226
  this.josk.minRevolvingDelay = (typeof opts.josk.minRevolvingDelay === 'number') ? opts.josk.minRevolvingDelay : 512;
853
2227
  this.josk.maxRevolvingDelay = (typeof opts.josk.maxRevolvingDelay === 'number') ? opts.josk.maxRevolvingDelay : 2048;
854
- this.josk.zombieTime = (typeof opts.josk.zombieTime === 'number') ? opts.josk.zombieTime : 32786;
2228
+ this.josk.zombieTime = (typeof opts.josk.zombieTime === 'number') ? opts.josk.zombieTime : 60000;
2229
+ this.josk.execute = (opts.josk.execute === 'one' || opts.josk.execute === 'batch') ? opts.josk.execute : 'batch';
2230
+ this.josk.concurrency = (typeof opts.josk.concurrency === 'number' && opts.josk.concurrency > 0) ? opts.josk.concurrency : Infinity;
2231
+ this.josk.autoClear = opts.josk.autoClear === true;
2232
+
2233
+ if (typeof opts.josk.lockOwnerId === 'string' && opts.josk.lockOwnerId.length > 0) {
2234
+ this.josk.lockOwnerId = opts.josk.lockOwnerId;
2235
+ }
2236
+
2237
+ if (typeof opts.josk.onError !== 'function') {
2238
+ this.josk.onError = (title, details) => {
2239
+ logError(`[scheduler] ${title}`, details);
2240
+ };
2241
+ }
855
2242
 
856
2243
  this.scheduler = new josk.JoSk({
857
2244
  debug: this.debug,
858
2245
  ...this.josk,
859
2246
  });
860
2247
 
861
- process.nextTick(async () => {
862
- if ((await this.scheduler.ping()).status !== 'OK') {
863
- throw new Error('[mail-time] [MailTime#ping] can not connect to storage, make sure it is available and properly configured');
864
- }
865
- });
866
-
867
- this.scheduler.setInterval(this.___iterate.bind(this), this.revolvingInterval, `mailTimeQueue${this.prefix}`);
2248
+ this.__schedulerTimer = this.scheduler.setInterval(this.___iterate.bind(this), this.revolvingInterval, `mailTimeQueue${this.prefix}`);
868
2249
  }
2250
+
2251
+ this.__readyPromise = this.___ready();
2252
+ this.__readyPromise.catch(() => void 0);
869
2253
  }
870
2254
 
871
2255
  static get Template() {
@@ -881,26 +2265,78 @@ class MailTime {
881
2265
  * @memberOf MailTime
882
2266
  * @name ping
883
2267
  * @description Check package readiness and connection to Storage
884
- * @returns {Promise<object>}
885
- * @throws {mix}
2268
+ * @returns {Promise<MailTimePingResult>}
2269
+ * @throws {Error}
886
2270
  */
887
2271
  async ping() {
888
- this._debug('[ping]');
889
- const schedulerPing = await this.scheduler.ping();
890
- if (schedulerPing.status !== 'OK') {
891
- return schedulerPing;
2272
+ this.__debug('[ping]');
2273
+ if (this.scheduler) {
2274
+ const schedulerPing = await this.scheduler.ping();
2275
+ if (schedulerPing.status !== 'OK') {
2276
+ return schedulerPing;
2277
+ }
892
2278
  }
893
2279
  return await this.queue.ping();
894
2280
  }
895
2281
 
2282
+ /**
2283
+ * @async
2284
+ * @memberOf MailTime
2285
+ * @name ready
2286
+ * @description Wait until queue and scheduler storage are ready
2287
+ * @returns {Promise<MailTime>}
2288
+ */
2289
+ async ready() {
2290
+ this.__debug('[ready]');
2291
+ return await this.__readyPromise;
2292
+ }
2293
+
2294
+ /**
2295
+ * @memberOf MailTime
2296
+ * @name destroy
2297
+ * @description Stop the scheduler and block future dispatches. Pass `{ drain: true }` to also wait for in-flight SMTP attempts (returns a Promise).
2298
+ * @param {{ drain?: boolean }} [opts]
2299
+ * @returns {boolean | Promise<boolean>}
2300
+ */
2301
+ destroy(opts) {
2302
+ this.__debug('[destroy]', opts);
2303
+ if (this.__isDestroyed) {
2304
+ return false;
2305
+ }
2306
+
2307
+ this.__isDestroyed = true;
2308
+ if (this.scheduler && typeof this.scheduler.destroy === 'function') {
2309
+ this.scheduler.destroy();
2310
+ }
2311
+ if (opts && opts.drain === true && this.__pool) {
2312
+ return this.__pool.drain().then(() => true);
2313
+ }
2314
+ return true;
2315
+ }
2316
+
2317
+ /**
2318
+ * @async
2319
+ * @memberOf MailTime
2320
+ * @name drain
2321
+ * @description Wait for all in-flight email send attempts to settle
2322
+ * @returns {Promise<void>}
2323
+ */
2324
+ async drain() {
2325
+ this.__debug('[drain]');
2326
+ if (this.__pool) {
2327
+ await this.__pool.drain();
2328
+ }
2329
+ }
2330
+
896
2331
  /**
897
2332
  * @memberOf MailTime
898
2333
  * @name send
899
2334
  * @description alias of `sendMail`
2335
+ * @param {MailTimeMailOptions} opts - email options
900
2336
  * @returns {Promise<string>} uuid of the email
901
2337
  */
902
2338
  async send(opts) {
903
- this._debug('[send]', opts);
2339
+ this.__debug('[send]', opts);
904
2340
  return await this.sendMail(opts);
905
2341
  }
906
2342
 
@@ -909,49 +2345,34 @@ class MailTime {
909
2345
  * @memberOf MailTime
910
2346
  * @name sendMail
911
2347
  * @description add email to the queue or append to existing letter if {concatEmails: true}
912
- * @param opts {object} - email options
913
- * @param opts.sendAt {Date|number} - When email should be sent
914
- * @param opts.template {string} - Email-specific template
915
- * @param opts[key] {mix} - Other NodeMailer's options
2348
+ * @param {MailTimeMailOptions} opts - email options
916
2349
  * @returns {Promise<string>} uuid of the email
917
2350
  * @throws {Error}
918
2351
  */
919
2352
  async sendMail(opts = {}) {
920
- this._debug('[sendMail]', opts);
2353
+ this.__debug('[sendMail]', opts);
921
2354
  if (!opts.html && !opts.text) {
922
- throw new Error('`html` nor `text` field is presented, at least one of those fields is required');
2355
+ throw new Error('[mail-time] [sendMail] `html` nor `text` field is present, at least one of those fields is required');
923
2356
  }
924
2357
 
925
2358
  let sendAt = opts.sendAt;
926
- if (!sendAt) {
927
- sendAt = Date.now();
928
- }
929
-
930
2359
  if (sendAt instanceof Date) {
931
2360
  sendAt = +sendAt;
932
2361
  }
933
-
934
- if (typeof sendAt !== 'number') {
2362
+ if (typeof sendAt !== 'number' || !Number.isFinite(sendAt)) {
935
2363
  sendAt = Date.now();
936
2364
  }
937
2365
 
938
- let template = opts.template;
939
- if (typeof template !== 'string') {
940
- template = false;
941
- }
942
-
943
- let concatSubject = opts.concatSubject;
944
- if (typeof concatSubject !== 'string') {
945
- concatSubject = false;
946
- }
2366
+ const template = (typeof opts.template === 'string') ? opts.template : false;
2367
+ const concatSubject = (typeof opts.concatSubject === 'string') ? opts.concatSubject : false;
947
2368
 
948
2369
  const mailOptions = { ...opts };
949
2370
  delete mailOptions.sendAt;
950
2371
  delete mailOptions.template;
951
2372
  delete mailOptions.concatSubject;
952
2373
 
953
- if (typeof mailOptions.to !== 'string' && (!(mailOptions.to instanceof Array) || !mailOptions.to.length)) {
954
- throw new Error('[mail-time] `mailOptions.to` is required and must be a string or non-empty Array');
2374
+ if (typeof mailOptions.to !== 'string' && (!Array.isArray(mailOptions.to) || !mailOptions.to.length)) {
2375
+ throw new Error('[mail-time] [sendMail] `mailOptions.to` is required and must be a string or non-empty Array');
955
2376
  }
956
2377
 
957
2378
  if (this.concatEmails) {
@@ -967,11 +2388,12 @@ class MailTime {
967
2388
  }
968
2389
  }
969
2390
 
970
- pendingMailOptions.push(opts);
971
- await this.queue.update(task, {
972
- mailOptions: pendingMailOptions
2391
+ const appended = await this.queue.update(task, {
2392
+ appendMailOption: mailOptions,
973
2393
  });
974
- return task.uuid;
2394
+ if (appended) {
2395
+ return task.uuid;
2396
+ }
975
2397
  }
976
2398
  }
977
2399
 
@@ -979,18 +2401,20 @@ class MailTime {
979
2401
  sendAt,
980
2402
  template,
981
2403
  concatSubject,
982
- mailOptions: mailOptions,
2404
+ mailOptions,
983
2405
  });
984
2406
  }
985
2407
 
986
2408
  /**
2409
+ * @async
987
2410
  * @memberOf MailTime
988
2411
  * @name cancel
989
2412
  * @description alias of `cancelMail`
2413
+ * @param {string|Promise<string>} uuid - uuid returned from `send` or `sendMail`
990
2414
  * @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found was sent or was cancelled previously
991
2415
  */
992
2416
  async cancel(uuid) {
993
- this._debug('[cancel]', uuid);
2417
+ this.__debug('[cancel]', uuid);
994
2418
  return await this.cancelMail(uuid);
995
2419
  }
996
2420
 
@@ -999,47 +2423,62 @@ class MailTime {
999
2423
  * @memberOf MailTime
1000
2424
  * @name cancelMail
1001
2425
  * @description remove email from the queue or mark as `isCancelled`
1002
- * @param uuid {string|Promise<string>} - uuid returned from `send` or `sendMail`
2426
+ * @param {string|Promise<string>} uuid - uuid returned from `send` or `sendMail`
1003
2427
  * @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found was sent or was cancelled previously
1004
2428
  */
1005
2429
  async cancelMail(uuid) {
1006
- this._debug('[cancelMail]', uuid);
1007
- if (typeof uuid === 'object' && uuid instanceof Promise) {
1008
- return await this.queue.cancel(await uuid);
1009
- }
1010
- return await this.queue.cancel(uuid);
2430
+ this.__debug('[cancelMail]', uuid);
2431
+ const resolved = (uuid && typeof uuid.then === 'function') ? await uuid : uuid;
2432
+ return await this.queue.cancel(resolved);
1011
2433
  }
1012
2434
 
1013
2435
  /**
1014
2436
  * @async
2437
+ * @internal
1015
2438
  * @memberOf MailTime
1016
2439
  * @name ___handleError
1017
2440
  * @description Handle runtime errors and pass to `onError` callback
1018
- * @param task {object} - Email task record form Storage
1019
- * @param error {mix} - Error String/Object/Error
1020
- * @param info {object} - Info object returned from nodemailer
2441
+ * @param {MailTimeTask} task - Email task record from Storage
2442
+ * @param {unknown} error - Error String/Object/Error
2443
+ * @param {object} info - Info object returned from nodemailer
1021
2444
  * @returns {Promise<void 0>}
1022
2445
  */
1023
2446
  async ___handleError(task, error, info) {
1024
- this._debug('[private handleError]', { task, error, info });
2447
+ this.__debug('[private handleError]', { task, error, info });
1025
2448
  if (!task) {
1026
2449
  return;
1027
2450
  }
1028
2451
 
1029
2452
  if (task.tries >= this.maxTries) {
1030
- task.isSent = false;
1031
- task.isFailed = true;
2453
+ this.___finalizeRejected(task, info);
1032
2454
 
2455
+ const leaseRemove = __leaseRemoveOpts(task);
2456
+ const leaseUpdate = __withLease(task, {
2457
+ isSent: false,
2458
+ isFailed: true,
2459
+ isSending: false,
2460
+ sendingAt: 0,
2461
+ mailOptions: task.mailOptions,
2462
+ });
2463
+
2464
+ let finalized = false;
1033
2465
  if (!this.keepHistory) {
1034
- await this.queue.remove(task);
2466
+ finalized = await this.queue.remove(task, leaseRemove);
1035
2467
  } else {
1036
- await this.queue.update(task, {
1037
- isSent: task.isSent,
1038
- isFailed: task.isFailed,
1039
- });
2468
+ finalized = await this.queue.update(task, leaseUpdate);
2469
+ }
2470
+
2471
+ if (!finalized) {
2472
+ this.__debug('[private handleError] lease lost before final failure update, skipping onError', task.uuid);
2473
+ return;
1040
2474
  }
1041
2475
 
1042
- this._debug(`[private handleError] Giving up trying send email after ${task.tries} attempts to: `, task.mailOptions[0].to, error);
2476
+ // Persist first, then mirror to in-memory task — a superseded worker must not lie to onError.
2477
+ task.isSent = false;
2478
+ task.isFailed = true;
2479
+ task.isSending = false;
2480
+
2481
+ this.__debug(`[private handleError] Giving up trying send email after ${task.tries} attempts to: `, task.mailOptions[0].to, error);
1043
2482
  this.onError(error, task, info);
1044
2483
  return;
1045
2484
  }
@@ -1047,43 +2486,50 @@ class MailTime {
1047
2486
  let transportIndex = task.transport;
1048
2487
 
1049
2488
  if (this.strategy === 'backup' && this.transports.length > 1 && (task.tries % this.failsToNext) === 0) {
1050
- ++transportIndex;
1051
- if (transportIndex > this.transports.length - 1) {
1052
- transportIndex = 0;
1053
- }
2489
+ transportIndex = this.___nextHealthyTransport(transportIndex);
1054
2490
  }
1055
2491
 
1056
- await this.queue.update(task, {
1057
- isSent: false,
2492
+ const released = await this.queue.update(task, __withLease(task, {
2493
+ isSending: false,
2494
+ sendingAt: 0,
1058
2495
  sendAt: Date.now() + this.retryDelay,
1059
2496
  transport: transportIndex,
1060
- });
2497
+ }));
1061
2498
 
1062
- this._debug(`[private handleError] Next re-send attempt at ${new Date(Date.now() + this.retryDelay)}: #${task.tries}/${this.maxTries}, transport #${transportIndex} to: `, task.mailOptions[0].to, error);
2499
+ if (!released) {
2500
+ this.__debug('[private handleError] lease lost before retry release, skipping', task.uuid);
2501
+ return;
2502
+ }
2503
+
2504
+ this.__debug(`[private handleError] Next re-send attempt at ${new Date(Date.now() + this.retryDelay)}: #${task.tries}/${this.maxTries}, transport #${transportIndex} to: `, task.mailOptions[0].to, error);
1063
2505
  }
1064
2506
 
1065
2507
  /**
1066
2508
  * @async
2509
+ * @internal
1067
2510
  * @memberOf MailTime
1068
2511
  * @name ___addToQueue
1069
2512
  * @description Prepare task's object and push to the queue
1070
- * @param opts {object} - Email options with next properties:
1071
- * @param opts.sendAt {number} - When email should be sent
1072
- * @param opts.template {string} - Email-specific template
1073
- * @param opts.mailOptions {object} - MailOptions according to NodeMailer lib
1074
- * @param opts.concatSubject {string} - Email subject used when sending concatenated email
2513
+ * @param {{ sendAt: number, template: string | false, mailOptions: MailTimeMailOptions, concatSubject: string | false }} opts - Email options
1075
2514
  * @returns {Promise<string>} message uuid
1076
2515
  */
1077
2516
  async ___addToQueue(opts) {
1078
- this._debug('[private addToQueue]', opts);
2517
+ this.__debug('[private addToQueue]', opts);
2518
+ let transportIndex = this.transport;
2519
+ if (this.strategy === 'balancer' && this.transports.length > 0) {
2520
+ transportIndex = this.___nextHealthyTransport(this.transport);
2521
+ this.transport = transportIndex;
2522
+ }
1079
2523
  const task = {
1080
- uuid: crypto.randomUUID(),
2524
+ uuid: node_crypto.randomUUID(),
1081
2525
  tries: 0,
1082
2526
  isSent: false,
1083
2527
  sendAt: opts.sendAt,
1084
2528
  isFailed: false,
2529
+ isSending: false,
2530
+ sendingAt: 0,
1085
2531
  template: opts.template,
1086
- transport: this.transport,
2532
+ transport: transportIndex,
1087
2533
  isCancelled: false,
1088
2534
  mailOptions: [opts.mailOptions],
1089
2535
  concatSubject: opts.concatSubject,
@@ -1098,30 +2544,32 @@ class MailTime {
1098
2544
  }
1099
2545
 
1100
2546
  /**
2547
+ * @internal
1101
2548
  * @memberOf MailTime
1102
2549
  * @name ___render
1103
- * @description Render templates
1104
- * @param string {string} - Template with Mustache-like placeholders
1105
- * @param replacements {object} - Blaze/Mustache-like helpers Object
2550
+ * @description Render Mustache-like placeholders
2551
+ * @param {string} _string - Template with Mustache-like placeholders
2552
+ * @param {Record<string, any>} replacements - Blaze/Mustache-like helpers Object
1106
2553
  * @returns {string}
1107
2554
  */
1108
2555
  ___render(_string, replacements) {
1109
- let i;
1110
2556
  let string = _string;
1111
- const matchHTML = string.match(/\{{3}\s?([a-zA-Z0-9\-\_]+)\s?\}{3}/g);
2557
+ const matchHTML = string.match(/\{{3}\s?([a-zA-Z0-9\-_]+)\s?\}{3}/g);
1112
2558
  if (matchHTML) {
1113
- for (i = 0; i < matchHTML.length; i++) {
1114
- if (replacements[matchHTML[i].replace('{{{', '').replace('}}}', '').trim()]) {
1115
- string = string.replace(matchHTML[i], replacements[matchHTML[i].replace('{{{', '').replace('}}}', '').trim()]);
2559
+ for (let i = 0; i < matchHTML.length; i++) {
2560
+ const key = matchHTML[i].slice(3, -3).trim();
2561
+ if (Object.hasOwn(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
2562
+ string = string.replace(matchHTML[i], `${replacements[key]}`);
1116
2563
  }
1117
2564
  }
1118
2565
  }
1119
2566
 
1120
- const matchStr = string.match(/\{{2}\s?([a-zA-Z0-9\-\_]+)\s?\}{2}/g);
2567
+ const matchStr = string.match(/\{{2}\s?([a-zA-Z0-9\-_]+)\s?\}{2}/g);
1121
2568
  if (matchStr) {
1122
- for (i = 0; i < matchStr.length; i++) {
1123
- if (replacements[matchStr[i].replace('{{', '').replace('}}', '').trim()]) {
1124
- string = string.replace(matchStr[i], replacements[matchStr[i].replace('{{', '').replace('}}', '').trim()].replace(/<(?:.|\n)*?>/gm, ''));
2569
+ for (let i = 0; i < matchStr.length; i++) {
2570
+ const key = matchStr[i].slice(2, -2).trim();
2571
+ if (Object.hasOwn(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
2572
+ string = string.replace(matchStr[i], `${replacements[key]}`.replace(/<(?:.|\n)*?>/gm, ''));
1125
2573
  }
1126
2574
  }
1127
2575
  }
@@ -1129,157 +2577,482 @@ class MailTime {
1129
2577
  }
1130
2578
 
1131
2579
  /**
2580
+ * @internal
1132
2581
  * @memberOf MailTime
1133
2582
  * @name ___compileMailOpts
1134
2583
  * @description Run various checks, compile options, and render template
1135
- * @param transport {object} - Current transport
1136
- * @param info {object} - Info object returned from NodeMailer's `sendMail` method
1137
- * @returns {void 0}
2584
+ * @param {MailTimeTransport} transport - Current transport
2585
+ * @param {MailTimeTask} task - Email task record from Storage
2586
+ * @returns {MailTimeMailOptions}
1138
2587
  */
1139
2588
  ___compileMailOpts(transport, task) {
1140
- let compiledOpts = {};
1141
-
1142
2589
  if (!transport) {
1143
2590
  throw new Error('[mail-time] [sendMail] [___compileMailOpts] {transport} is not available or misconfigured!');
1144
2591
  }
1145
2592
 
1146
- if (transport._options && typeof transport._options === 'object' && transport._options !== null && transport._options.mailOptions) {
1147
- compiledOpts = merge(compiledOpts, transport._options.mailOptions);
2593
+ let compiledOpts = {};
2594
+
2595
+ if (isPlainObject(transport._options) && isPlainObject(transport._options.mailOptions)) {
2596
+ compiledOpts = deepMerge(compiledOpts, transport._options.mailOptions);
1148
2597
  }
1149
2598
 
1150
- if (transport.options && typeof transport.options === 'object' && transport.options !== null && transport.options.mailOptions) {
1151
- compiledOpts = merge(compiledOpts, transport.options.mailOptions);
2599
+ if (isPlainObject(transport.options) && isPlainObject(transport.options.mailOptions)) {
2600
+ compiledOpts = deepMerge(compiledOpts, transport.options.mailOptions);
1152
2601
  }
1153
2602
 
1154
- compiledOpts = merge(compiledOpts, {
1155
- html: '',
1156
- text: '',
1157
- subject: ''
1158
- });
2603
+ compiledOpts.html ??= '';
2604
+ compiledOpts.text ??= '';
2605
+ compiledOpts.subject ??= '';
2606
+
2607
+ const mailOptionsList = task.mailOptions || [];
2608
+ const isMulti = mailOptionsList.length > 1;
1159
2609
 
1160
- for (let i = 0; i < task.mailOptions.length; i++) {
1161
- if (task.mailOptions[i].html) {
1162
- if (task.mailOptions.length > 1) {
1163
- compiledOpts.html += this.___render(this.concatDelimiter, task.mailOptions[i]) + this.___render(task.mailOptions[i].html, task.mailOptions[i]);
2610
+ for (let i = 0; i < mailOptionsList.length; i++) {
2611
+ const mailOption = { ...mailOptionsList[i] };
2612
+
2613
+ if (mailOption.html) {
2614
+ const rendered = this.___render(mailOption.html, mailOption);
2615
+ if (isMulti) {
2616
+ compiledOpts.html += this.___render(this.concatDelimiter, mailOption) + rendered;
1164
2617
  } else {
1165
- compiledOpts.html = this.___render(task.mailOptions[i].html, task.mailOptions[i]);
2618
+ compiledOpts.html = rendered;
1166
2619
  }
1167
- delete task.mailOptions[i].html;
2620
+ delete mailOption.html;
1168
2621
  }
1169
2622
 
1170
- if (task.mailOptions[i].text) {
1171
- if (task.mailOptions.length > 1) {
1172
- compiledOpts.text += '\r\n' + this.___render(task.mailOptions[i].text, task.mailOptions[i]);
2623
+ if (mailOption.text) {
2624
+ const rendered = this.___render(mailOption.text, mailOption);
2625
+ if (isMulti) {
2626
+ compiledOpts.text += '\r\n' + rendered;
1173
2627
  } else {
1174
- compiledOpts.text = this.___render(task.mailOptions[i].text, task.mailOptions[i]);
2628
+ compiledOpts.text = rendered;
1175
2629
  }
1176
- delete task.mailOptions[i].text;
2630
+ delete mailOption.text;
1177
2631
  }
1178
2632
 
1179
- compiledOpts = merge(compiledOpts, task.mailOptions[i]);
2633
+ compiledOpts = deepMerge(compiledOpts, mailOption);
1180
2634
  }
1181
2635
 
1182
2636
  if (compiledOpts.html && (task.template || this.template)) {
1183
2637
  compiledOpts.html = this.___render((task.template || this.template), compiledOpts);
1184
2638
  }
1185
2639
 
1186
- if (task.mailOptions.length > 1) {
1187
- compiledOpts.subject = task.concatSubject || this.concatSubject || compiledOpts.subject;
2640
+ if (isMulti) {
2641
+ const rawSubject = task.concatSubject || this.concatSubject || compiledOpts.subject;
2642
+ compiledOpts.subject = this.___render(rawSubject, { count: mailOptionsList.length });
1188
2643
  }
1189
2644
 
1190
2645
  if (!compiledOpts.from && this.from) {
1191
2646
  compiledOpts.from = this.from(transport);
1192
2647
  }
1193
2648
 
2649
+ const acceptedSet = collectAcceptedSet(task);
2650
+ if (acceptedSet.size > 0) {
2651
+ compiledOpts.to = filterAddressField(compiledOpts.to, acceptedSet);
2652
+ compiledOpts.cc = filterAddressField(compiledOpts.cc, acceptedSet);
2653
+ compiledOpts.bcc = filterAddressField(compiledOpts.bcc, acceptedSet);
2654
+ }
2655
+
1194
2656
  return compiledOpts;
1195
2657
  }
1196
2658
 
2659
+ /**
2660
+ * @internal
2661
+ * @memberOf MailTime
2662
+ * @name ___trackAcceptedRecipients
2663
+ * @description Append newly-accepted addresses to each mailOption's `accepted` list
2664
+ * @param {MailTimeTask} task - Email task record from Storage
2665
+ * @param {string[]} acceptedAddrs - Lowercased addresses confirmed by transport
2666
+ * @returns {void}
2667
+ */
2668
+ ___trackAcceptedRecipients(task, acceptedAddrs) {
2669
+ if (!Array.isArray(task?.mailOptions) || acceptedAddrs.length === 0) {
2670
+ return;
2671
+ }
2672
+ const acceptedSet = new Set(acceptedAddrs);
2673
+ for (const mo of task.mailOptions) {
2674
+ if (!Array.isArray(mo.accepted)) {
2675
+ mo.accepted = [];
2676
+ }
2677
+ const moRecipients = new Set(mailOptionRecipients(mo));
2678
+ if (moRecipients.size === 0) {
2679
+ continue;
2680
+ }
2681
+ const alreadyAccepted = new Set(mo.accepted.map((a) => typeof a === 'string' ? a.toLowerCase() : a));
2682
+ for (const addr of acceptedSet) {
2683
+ if (moRecipients.has(addr) && !alreadyAccepted.has(addr)) {
2684
+ mo.accepted.push(addr);
2685
+ alreadyAccepted.add(addr);
2686
+ }
2687
+ }
2688
+ }
2689
+ }
2690
+
2691
+ /**
2692
+ * @internal
2693
+ * @memberOf MailTime
2694
+ * @name ___finalizeRejected
2695
+ * @description Populate each mailOption's `rejected` list with addresses that never delivered
2696
+ * @param {MailTimeTask} task - Email task record from Storage
2697
+ * @param {object} info - Info object from the last nodemailer attempt
2698
+ * @returns {void}
2699
+ */
2700
+ ___finalizeRejected(task, info) {
2701
+ if (!Array.isArray(task?.mailOptions)) {
2702
+ return;
2703
+ }
2704
+ const errorMap = buildRejectionErrorMap(info);
2705
+ for (const mo of task.mailOptions) {
2706
+ const moAccepted = new Set((mo.accepted || []).map((a) => typeof a === 'string' ? a.toLowerCase() : a));
2707
+ const seen = new Set();
2708
+ const rejected = [];
2709
+ for (const addr of mailOptionRecipients(mo)) {
2710
+ if (moAccepted.has(addr) || seen.has(addr)) {
2711
+ continue;
2712
+ }
2713
+ seen.add(addr);
2714
+ rejected.push({
2715
+ address: addr,
2716
+ error: errorMap.get(addr) || 'Recipient rejected by transport',
2717
+ });
2718
+ }
2719
+ mo.rejected = rejected;
2720
+ }
2721
+ }
2722
+
1197
2723
  /**
1198
2724
  * @async
2725
+ * @internal
2726
+ * @memberOf MailTime
2727
+ * @name ___dispatch
2728
+ * @description Queue full-lifecycle send for `task` under the bounded send pool. Resolves as soon as a pool slot is acquired and the send has started — the SMTP roundtrip continues in the background so the adapter's `iterate` can move on to the next due row and the JoSk lease can be released. Call `await mailTime.drain()` to await in-flight sends; use `destroy({ drain: true })` for graceful shutdown.
2729
+ * @param {MailTimeTask} task - email's task object from Storage
2730
+ * @returns {Promise<void 0>}
2731
+ */
2732
+ async ___dispatch(task) {
2733
+ if (this.__isDestroyed) {
2734
+ return;
2735
+ }
2736
+ if (!task || task.isSent === true || task.isFailed === true || task.isCancelled === true) {
2737
+ return;
2738
+ }
2739
+ if (this.__inFlight.has(task.uuid)) {
2740
+ this.__debug('[private dispatch] already in-flight on this instance, skipping', task.uuid);
2741
+ return;
2742
+ }
2743
+ this.__inFlight.add(task.uuid);
2744
+ await this.__pool.dispatch(async () => {
2745
+ try {
2746
+ if (this.__isDestroyed) {
2747
+ return;
2748
+ }
2749
+ await this.___send(task);
2750
+ } finally {
2751
+ this.__inFlight.delete(task.uuid);
2752
+ }
2753
+ });
2754
+ }
2755
+
2756
+ /**
2757
+ * @async
2758
+ * @internal
1199
2759
  * @memberOf MailTime
1200
2760
  * @name ___send
1201
- * @description send email using nodemailer's transport
1202
- * @param task {object} - email's task object from Storage
2761
+ * @description Full send lifecycle for a single task: atomic claim (`isSending=true, sendingAt=now, tries=tries+1`), SMTP roundtrip, completion. Returns when the lifecycle ends.
2762
+ * @param {MailTimeTask} task - email's task object from Storage
1203
2763
  * @returns {Promise<void 0>}
1204
2764
  */
1205
2765
  async ___send(task) {
1206
- this._debug('[private send]', task);
2766
+ this.__debug('[private send]', task);
1207
2767
  try {
1208
- if (task.isSent === true || task.isFailed === true || task.isCancelled === true) {
2768
+ if (!task || task.isSent === true || task.isFailed === true || task.isCancelled === true) {
1209
2769
  return;
1210
2770
  }
1211
2771
 
1212
- task.tries++;
1213
- const isUpdated = await this.queue.update(task, {
1214
- isSent: true,
1215
- tries: task.tries
1216
- });
2772
+ const tries = task.tries + 1;
2773
+ const sendingAt = Date.now();
2774
+ let isClaimed = false;
2775
+ try {
2776
+ isClaimed = await this.queue.update(task, {
2777
+ isSending: true,
2778
+ sendingAt,
2779
+ tries,
2780
+ });
2781
+ } catch (claimError) {
2782
+ logError('[private send] [claim] storage error during atomic claim', claimError);
2783
+ return;
2784
+ }
1217
2785
 
1218
- if (!isUpdated) {
1219
- logError('[private send] [queue.update] Not updated!');
2786
+ if (!isClaimed) {
2787
+ this.__debug('[private send] [queue.update] Stale claim, skipping', task.uuid);
1220
2788
  return;
1221
2789
  }
1222
2790
 
1223
- let transport;
1224
- let transportIndex;
1225
- if (this.strategy === 'balancer') {
1226
- this.transport = this.transport + 1;
1227
- if (this.transport >= this.transports.length) {
1228
- this.transport = 0;
1229
- }
1230
- transportIndex = this.transport;
1231
- transport = this.transports[transportIndex];
1232
- } else {
1233
- transportIndex = task.transport;
1234
- transport = this.transports[transportIndex];
2791
+ task.tries = tries;
2792
+ task.isSending = true;
2793
+ task.sendingAt = sendingAt;
2794
+
2795
+ let transportIndex = task.transport;
2796
+ if (!this.___isHealthyTransport(transportIndex)) {
2797
+ transportIndex = this.___nextHealthyTransport(transportIndex);
2798
+ task.transport = transportIndex;
1235
2799
  }
2800
+ const transport = this.transports[transportIndex];
1236
2801
 
1237
2802
  const compiledOpts = this.___compileMailOpts(transport, task);
1238
2803
 
1239
2804
  await new Promise((resolve) => {
1240
2805
  transport.sendMail(compiledOpts, async (error, info) => {
1241
- this._debug('[private send] [sending]', { error, info });
1242
- if (error) {
1243
- await this.___handleError(task, error, info);
2806
+ this.__debug('[private send] [sending]', { error, info });
2807
+ try {
2808
+ if (error) {
2809
+ await this.___handleError(task, error, info);
2810
+ return;
2811
+ }
2812
+
2813
+ const acceptedAddrs = Array.isArray(info?.accepted)
2814
+ ? info.accepted.map(extractEmail).filter((addr) => typeof addr === 'string')
2815
+ : [];
2816
+
2817
+ if (acceptedAddrs.length === 0) {
2818
+ await this.___handleError(task, new Error('Message not accepted or Greeting never received'), info);
2819
+ return;
2820
+ }
2821
+
2822
+ this.___trackAcceptedRecipients(task, acceptedAddrs);
2823
+
2824
+ const allRecipients = collectAllRecipients(task);
2825
+ const allAccepted = collectAcceptedSet(task);
2826
+ let isFullyDelivered = true;
2827
+ for (const addr of allRecipients) {
2828
+ if (!allAccepted.has(addr)) {
2829
+ isFullyDelivered = false;
2830
+ break;
2831
+ }
2832
+ }
2833
+
2834
+ if (isFullyDelivered) {
2835
+ this.__debug(`email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, compiledOpts.to);
2836
+
2837
+ const leaseRemove = __leaseRemoveOpts(task);
2838
+ const leaseUpdate = __withLease(task, {
2839
+ isSent: true,
2840
+ isSending: false,
2841
+ sendingAt: 0,
2842
+ mailOptions: task.mailOptions,
2843
+ });
2844
+
2845
+ let completed = false;
2846
+ if (!this.keepHistory) {
2847
+ completed = await this.queue.remove(task, leaseRemove);
2848
+ } else {
2849
+ completed = await this.queue.update(task, leaseUpdate);
2850
+ }
2851
+
2852
+ if (!completed) {
2853
+ this.__debug('[private send] lease lost before success completion, skipping onSent', task.uuid);
2854
+ return;
2855
+ }
2856
+
2857
+ // Persist first, then mirror to in-memory task — a superseded worker must not lie to onSent.
2858
+ task.isSent = true;
2859
+ task.isSending = false;
2860
+ task.sendingAt = 0;
2861
+ this.onSent(task, info);
2862
+ return;
2863
+ }
2864
+
2865
+ if (task.tries >= this.maxTries) {
2866
+ this.___finalizeRejected(task, info);
2867
+
2868
+ const leaseRemove = __leaseRemoveOpts(task);
2869
+ const leaseUpdate = __withLease(task, {
2870
+ isSent: false,
2871
+ isFailed: true,
2872
+ isSending: false,
2873
+ sendingAt: 0,
2874
+ mailOptions: task.mailOptions,
2875
+ });
2876
+
2877
+ let finalized = false;
2878
+ if (!this.keepHistory) {
2879
+ finalized = await this.queue.remove(task, leaseRemove);
2880
+ } else {
2881
+ finalized = await this.queue.update(task, leaseUpdate);
2882
+ }
2883
+
2884
+ if (!finalized) {
2885
+ this.__debug('[private send] lease lost before partial-failure completion, skipping onError', task.uuid);
2886
+ return;
2887
+ }
2888
+
2889
+ // Persist first, then mirror to in-memory task — a superseded worker must not lie to onError.
2890
+ task.isSent = false;
2891
+ task.isFailed = true;
2892
+ task.isSending = false;
2893
+
2894
+ const rejectedAddrs = [];
2895
+ for (const mo of task.mailOptions) {
2896
+ for (const r of (mo.rejected || [])) {
2897
+ rejectedAddrs.push(r.address);
2898
+ }
2899
+ }
2900
+ const partialError = new Error(`Recipients rejected after ${task.tries} attempts: ${rejectedAddrs.join(', ')}`);
2901
+ this.__debug('[private send] Partial delivery exhausted retries; rejected: ', rejectedAddrs);
2902
+ this.onError(partialError, task, info);
2903
+ return;
2904
+ }
2905
+
2906
+ const nextSendAt = Date.now() + this.retryDelay;
2907
+ const released = await this.queue.update(task, __withLease(task, {
2908
+ isSending: false,
2909
+ sendingAt: 0,
2910
+ sendAt: nextSendAt,
2911
+ mailOptions: task.mailOptions,
2912
+ }));
2913
+ if (!released) {
2914
+ this.__debug('[private send] lease lost before partial retry release', task.uuid);
2915
+ return;
2916
+ }
2917
+ this.__debug(`[private send] Partial delivery, next attempt at ${new Date(nextSendAt)}: #${task.tries}/${this.maxTries} for remaining recipients`);
2918
+ } finally {
1244
2919
  resolve();
1245
- return;
1246
2920
  }
1247
-
1248
- if (info.accepted && !info.accepted.length) {
1249
- await this.___handleError(task, new Error('Message not accepted or Greeting never received'), info);
1250
- resolve();
1251
- return;
1252
- }
1253
-
1254
- this._debug(`email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, compiledOpts.to);
1255
-
1256
- if (!this.keepHistory) {
1257
- await this.queue.remove(task);
1258
- }
1259
-
1260
- task.isSent = true;
1261
- this.onSent(task, info);
1262
- resolve();
1263
2921
  });
1264
2922
  });
1265
2923
  } catch (e) {
1266
2924
  logError('Exception during runtime:', e);
1267
- this.___handleError(task, e, {});
2925
+ await this.___handleError(task, e, {});
1268
2926
  }
1269
2927
  }
1270
2928
 
1271
2929
  /**
2930
+ * @internal
1272
2931
  * @memberOf MailTime
1273
2932
  * @name ___iterate
1274
- * @description Iterate over queued tasks
1275
- * @returns {Promise}
2933
+ * @description JoSk handler — claim and dispatch due tasks. Returns as soon as the scan finishes so the JoSk lease is released; in-flight SMTP work continues in the background pool.
2934
+ * @returns {Promise<void>|void}
1276
2935
  */
1277
2936
  async ___iterate() {
1278
- this._debug('[private iterate]');
1279
- return await this.queue.iterate();
2937
+ this.__debug('[private iterate]');
2938
+ if (this.__isDestroyed) {
2939
+ return;
2940
+ }
2941
+ const limit = this.mode === 'one' ? 1 : Infinity;
2942
+ return await this.queue.iterate({
2943
+ limit,
2944
+ sendingTimeout: this.sendingTimeout,
2945
+ });
2946
+ }
2947
+
2948
+ /**
2949
+ * @async
2950
+ * @internal
2951
+ * @memberOf MailTime
2952
+ * @name ___ready
2953
+ * @description Internal storage readiness gate
2954
+ * @returns {Promise<MailTime>}
2955
+ */
2956
+ async ___ready() {
2957
+ if (typeof this.queue.ready === 'function') {
2958
+ await this.queue.ready();
2959
+ }
2960
+
2961
+ if (this.__schedulerTimer) {
2962
+ await this.__schedulerTimer;
2963
+ }
2964
+
2965
+ const pingResult = await this.ping();
2966
+ if (pingResult.status !== 'OK') {
2967
+ throw new Error('[mail-time] [MailTime#ready] can not connect to storage, make sure it is available and properly configured', { cause: pingResult.error });
2968
+ }
2969
+
2970
+ if (this.type === 'server' && this.verifyTransports && this.transports.length > 0) {
2971
+ await this.___verifyTransports();
2972
+ }
2973
+
2974
+ return this;
2975
+ }
2976
+
2977
+ /**
2978
+ * @async
2979
+ * @internal
2980
+ * @memberOf MailTime
2981
+ * @name ___verifyTransports
2982
+ * @description Probe each transport's `verify()` once at startup. Failing transports are marked unusable and skipped during rotation; the failure is surfaced through `onError(error, null, { transportIndex, phase: 'verify' })`. Throws if every transport fails — there is nothing left that could deliver.
2983
+ * @returns {Promise<void>}
2984
+ */
2985
+ async ___verifyTransports() {
2986
+ this.__debug('[private verifyTransports]');
2987
+ const results = await Promise.all(this.transports.map(async (transport, index) => {
2988
+ if (!transport || typeof transport.verify !== 'function') {
2989
+ return { index, ok: true };
2990
+ }
2991
+ try {
2992
+ await Promise.resolve(transport.verify());
2993
+ return { index, ok: true };
2994
+ } catch (error) {
2995
+ return { index, ok: false, error };
2996
+ }
2997
+ }));
2998
+
2999
+ for (const r of results) {
3000
+ if (r.ok) {
3001
+ continue;
3002
+ }
3003
+ this.__unhealthyTransports.add(r.index);
3004
+ logError(`[mail-time] [verifyTransports] transport #${r.index} failed verification`, r.error);
3005
+ this.onError(r.error, null, { transportIndex: r.index, phase: 'verify' });
3006
+ }
3007
+
3008
+ if (this.__unhealthyTransports.size === this.transports.length) {
3009
+ throw new Error(`[mail-time] [MailTime#ready] all ${this.transports.length} transport(s) failed verification — nothing can be delivered`);
3010
+ }
3011
+
3012
+ if (this.__unhealthyTransports.has(this.transport)) {
3013
+ this.transport = this.___nextHealthyTransport(this.transport);
3014
+ }
3015
+ }
3016
+
3017
+ /**
3018
+ * @internal
3019
+ * @memberOf MailTime
3020
+ * @name ___isHealthyTransport
3021
+ * @description Return true when the transport at `index` has not been marked unusable by verification.
3022
+ * @param {number} index
3023
+ * @returns {boolean}
3024
+ */
3025
+ ___isHealthyTransport(index) {
3026
+ return !this.__unhealthyTransports.has(index);
3027
+ }
3028
+
3029
+ /**
3030
+ * @internal
3031
+ * @memberOf MailTime
3032
+ * @name ___nextHealthyTransport
3033
+ * @description Advance at least one position from `fromIdx` and return the next healthy transport index (wrapping). Falls back to `fromIdx` if no healthy transport exists.
3034
+ * @param {number} fromIdx
3035
+ * @returns {number}
3036
+ */
3037
+ ___nextHealthyTransport(fromIdx) {
3038
+ if (this.transports.length === 0) {
3039
+ return fromIdx;
3040
+ }
3041
+ let next = fromIdx;
3042
+ for (let i = 0; i < this.transports.length; i++) {
3043
+ next = (next + 1) % this.transports.length;
3044
+ if (this.___isHealthyTransport(next)) {
3045
+ return next;
3046
+ }
3047
+ }
3048
+ return fromIdx;
1280
3049
  }
1281
3050
  }
1282
3051
 
1283
3052
  exports.MailTime = MailTime;
1284
3053
  exports.MongoQueue = MongoQueue;
3054
+ exports.PostgresQueue = PostgresQueue;
1285
3055
  exports.RedisQueue = RedisQueue;
3056
+ exports.mailTimePreset = mailTimePreset;
3057
+ exports.presetNames = presetNames;
3058
+ exports.presets = presets;