mail-time 1.4.0 → 4.0.0

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