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