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/LICENSE +1 -1
- package/README.md +367 -301
- package/adapters/mongo.d.ts +118 -0
- package/adapters/mongo.js +431 -0
- package/adapters/postgres.d.ts +100 -0
- package/adapters/postgres.js +520 -0
- package/adapters/redis.d.ts +105 -0
- package/adapters/redis.js +565 -0
- package/helpers.js +217 -0
- package/index.cjs +2862 -462
- package/index.d.cts +300 -0
- package/index.d.ts +300 -0
- package/index.js +1002 -475
- package/package.json +66 -16
- package/presets.d.ts +87 -0
- package/presets.js +193 -0
package/index.js
CHANGED
|
@@ -1,633 +1,704 @@
|
|
|
1
|
-
import JoSk from 'josk';
|
|
2
|
-
import
|
|
1
|
+
import { JoSk, RedisAdapter, MongoAdapter, PostgresAdapter } from 'josk';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
import { MongoQueue } from './adapters/mongo.js';
|
|
5
|
+
import { RedisQueue } from './adapters/redis.js';
|
|
6
|
+
import { PostgresQueue } from './adapters/postgres.js';
|
|
7
|
+
import { mailTimePreset, presets, presetNames } from './presets.js';
|
|
8
|
+
import { debug, logError, deepMerge, equals, isPlainObject, extractEmail, toAddressList, filterAddressField } from './helpers.js';
|
|
9
|
+
|
|
10
|
+
const noop = () => {};
|
|
11
|
+
const queueMethods = ['ping', 'iterate', 'getPendingTo', 'push', 'remove', 'update', 'cancel'];
|
|
12
|
+
|
|
13
|
+
const __withLease = (task, updateObj) => {
|
|
14
|
+
if (task?.isSending === true && typeof task.tries === 'number' && typeof task.sendingAt === 'number') {
|
|
15
|
+
return { ...updateObj, leaseTries: task.tries, leaseSendingAt: task.sendingAt };
|
|
8
16
|
}
|
|
9
|
-
|
|
10
|
-
const _logError = (...args) => {
|
|
11
|
-
console.error.call(console, '[ERROR] [mail-time]', `${new Date}`, ...args);
|
|
17
|
+
return updateObj;
|
|
12
18
|
};
|
|
13
19
|
|
|
14
|
-
const
|
|
15
|
-
if (
|
|
16
|
-
|
|
17
|
-
console.trace();
|
|
20
|
+
const __leaseRemoveOpts = (task) => {
|
|
21
|
+
if (task?.isSending === true && typeof task.tries === 'number' && typeof task.sendingAt === 'number') {
|
|
22
|
+
return { leaseTries: task.tries, leaseSendingAt: task.sendingAt };
|
|
18
23
|
}
|
|
24
|
+
return void 0;
|
|
19
25
|
};
|
|
20
26
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
if (
|
|
40
|
-
|
|
41
|
-
|
|
27
|
+
const createPool = (concurrency) => {
|
|
28
|
+
const limit = Math.max(1, concurrency | 0);
|
|
29
|
+
const queue = [];
|
|
30
|
+
let active = 0;
|
|
31
|
+
let drainResolvers = [];
|
|
32
|
+
|
|
33
|
+
const tryStart = () => {
|
|
34
|
+
while (active < limit && queue.length > 0) {
|
|
35
|
+
const job = queue.shift();
|
|
36
|
+
active++;
|
|
37
|
+
job.resolveSlot();
|
|
38
|
+
Promise.resolve()
|
|
39
|
+
.then(job.fn)
|
|
40
|
+
.catch((poolError) => {
|
|
41
|
+
logError('[pool] unhandled send error', poolError);
|
|
42
|
+
})
|
|
43
|
+
.finally(() => {
|
|
44
|
+
active--;
|
|
45
|
+
if (active === 0 && queue.length === 0 && drainResolvers.length > 0) {
|
|
46
|
+
const resolvers = drainResolvers;
|
|
47
|
+
drainResolvers = [];
|
|
48
|
+
for (const r of resolvers) r();
|
|
42
49
|
}
|
|
43
|
-
|
|
50
|
+
tryStart();
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
/**
|
|
57
|
+
* Queue `fn` for execution under the concurrency limit.
|
|
58
|
+
* The returned Promise resolves as soon as a slot is acquired and `fn` has started.
|
|
59
|
+
* It does NOT wait for `fn` to finish. Use `drain()` to wait for all running jobs to settle.
|
|
60
|
+
* @param {() => Promise<void>} fn
|
|
61
|
+
* @returns {Promise<void>}
|
|
62
|
+
*/
|
|
63
|
+
dispatch(fn) {
|
|
64
|
+
return new Promise((resolveSlot) => {
|
|
65
|
+
queue.push({ fn, resolveSlot });
|
|
66
|
+
tryStart();
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
drain() {
|
|
70
|
+
if (active === 0 && queue.length === 0) {
|
|
71
|
+
return Promise.resolve();
|
|
72
|
+
}
|
|
73
|
+
return new Promise((resolve) => drainResolvers.push(resolve));
|
|
74
|
+
},
|
|
75
|
+
get size() {
|
|
76
|
+
return active + queue.length;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
};
|
|
44
80
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
81
|
+
const mailOptionRecipients = (mailOption) => {
|
|
82
|
+
if (!mailOption) {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
return [
|
|
86
|
+
...toAddressList(mailOption.to),
|
|
87
|
+
...toAddressList(mailOption.cc),
|
|
88
|
+
...toAddressList(mailOption.bcc),
|
|
89
|
+
];
|
|
90
|
+
};
|
|
51
91
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
92
|
+
const collectAcceptedSet = (task) => {
|
|
93
|
+
const set = new Set();
|
|
94
|
+
for (const mo of (task?.mailOptions || [])) {
|
|
95
|
+
if (Array.isArray(mo.accepted)) {
|
|
96
|
+
for (const addr of mo.accepted) {
|
|
97
|
+
if (typeof addr === 'string') {
|
|
98
|
+
set.add(addr.toLowerCase());
|
|
55
99
|
}
|
|
56
100
|
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return set;
|
|
104
|
+
};
|
|
57
105
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
106
|
+
const collectAllRecipients = (task) => {
|
|
107
|
+
const set = new Set();
|
|
108
|
+
for (const mo of (task?.mailOptions || [])) {
|
|
109
|
+
for (const addr of mailOptionRecipients(mo)) {
|
|
110
|
+
set.add(addr);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return set;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const buildRejectionErrorMap = (info) => {
|
|
117
|
+
const map = new Map();
|
|
118
|
+
const rejected = Array.isArray(info?.rejected) ? info.rejected : [];
|
|
119
|
+
const rejectedErrors = Array.isArray(info?.rejectedErrors) ? info.rejectedErrors : [];
|
|
120
|
+
for (let i = 0; i < rejected.length; i++) {
|
|
121
|
+
const addr = extractEmail(rejected[i]);
|
|
122
|
+
if (!addr) {
|
|
123
|
+
continue;
|
|
64
124
|
}
|
|
125
|
+
const err = rejectedErrors[i];
|
|
126
|
+
map.set(addr, err ? `${err.message || err}` : 'Recipient rejected by transport');
|
|
65
127
|
}
|
|
128
|
+
return map;
|
|
66
129
|
};
|
|
67
130
|
|
|
131
|
+
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>';
|
|
132
|
+
|
|
68
133
|
/**
|
|
69
|
-
*
|
|
70
|
-
* including edge cases like unordered Objects and Array
|
|
71
|
-
* @function equals
|
|
72
|
-
* @param {mix} a
|
|
73
|
-
* @param {mix} b
|
|
74
|
-
* @returns {boolean}
|
|
134
|
+
* @typedef {import('./presets.js').MailTimePresetName} MailTimePresetName
|
|
75
135
|
*/
|
|
76
|
-
const equals = (a, b) => {
|
|
77
|
-
let i;
|
|
78
|
-
if (a === b) {
|
|
79
|
-
return true;
|
|
80
|
-
}
|
|
81
136
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
137
|
+
/**
|
|
138
|
+
* @typedef {import('./presets.js').MailTimePresetConfig} MailTimePresetConfig
|
|
139
|
+
*/
|
|
85
140
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
141
|
+
/**
|
|
142
|
+
* @typedef {{ status: string, code: number, statusCode: number, error?: unknown }} MailTimePingResult
|
|
143
|
+
*/
|
|
89
144
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
145
|
+
/**
|
|
146
|
+
* @typedef {{ [key: string]: any, query?: (queryText: string, values?: unknown[]) => Promise<{ rows?: unknown[], rowCount?: number | null }> }} MailTimeStorageClient
|
|
147
|
+
*/
|
|
93
148
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
}
|
|
149
|
+
/**
|
|
150
|
+
* @typedef {{ [key: string]: any }} MailTimeMongoDb
|
|
151
|
+
*/
|
|
98
152
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
153
|
+
/**
|
|
154
|
+
* @typedef {{ [key: string]: any }} MailTimeTransport
|
|
155
|
+
*/
|
|
102
156
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
for (i = _a.length - 1; i >= 0; i--) {
|
|
107
|
-
let result = false;
|
|
108
|
-
for (j = _b.length - 1; j >= 0; j--) {
|
|
109
|
-
if (equals(_a[i], _b[j])) {
|
|
110
|
-
result = true;
|
|
111
|
-
_a.splice(i, 1);
|
|
112
|
-
_b.splice(j, 1);
|
|
113
|
-
break;
|
|
114
|
-
}
|
|
115
|
-
}
|
|
157
|
+
/**
|
|
158
|
+
* @typedef {{ [key: string]: any, type?: 'mongo' | 'redis' | 'postgres', client?: MailTimeStorageClient, db?: MailTimeMongoDb, prefix?: string, resetOnInit?: boolean, useHashTags?: boolean }} MailTimeJoSkAdapterOptions
|
|
159
|
+
*/
|
|
116
160
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
}
|
|
121
|
-
return true;
|
|
122
|
-
}
|
|
161
|
+
/**
|
|
162
|
+
* @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
|
|
163
|
+
*/
|
|
123
164
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const bkeys = Object.keys(b);
|
|
165
|
+
/**
|
|
166
|
+
* @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
|
|
167
|
+
*/
|
|
128
168
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
169
|
+
/**
|
|
170
|
+
* @typedef {{ limit?: number, sendingTimeout?: number }} MailTimeIterateOptions
|
|
171
|
+
*/
|
|
132
172
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}
|
|
173
|
+
/**
|
|
174
|
+
* @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
|
|
175
|
+
*/
|
|
137
176
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
177
|
+
/**
|
|
178
|
+
* @typedef {{ address: string, error: string }} MailTimeRejectedRecipient
|
|
179
|
+
*/
|
|
142
180
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
};
|
|
181
|
+
/**
|
|
182
|
+
* @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
|
|
183
|
+
*/
|
|
147
184
|
|
|
148
|
-
|
|
185
|
+
/**
|
|
186
|
+
* @typedef {{ subject?: string }} MailTimeConcatEmailsOptions
|
|
187
|
+
*/
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* @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
|
|
191
|
+
*/
|
|
149
192
|
|
|
150
193
|
/** Class of MailTime */
|
|
151
|
-
|
|
194
|
+
class MailTime {
|
|
195
|
+
/**
|
|
196
|
+
* Create a MailTime instance
|
|
197
|
+
* @param {MailTimeOptions} opts - configuration object
|
|
198
|
+
*/
|
|
152
199
|
constructor (opts) {
|
|
153
|
-
if (!opts || typeof opts !== 'object'
|
|
200
|
+
if (!opts || typeof opts !== 'object') {
|
|
154
201
|
throw new TypeError('[mail-time] Configuration object must be passed into MailTime constructor');
|
|
155
202
|
}
|
|
156
203
|
|
|
157
|
-
if (!opts.
|
|
158
|
-
throw new Error('[mail-time]
|
|
204
|
+
if (!opts.queue || typeof opts.queue !== 'object') {
|
|
205
|
+
throw new Error('[mail-time] {queue} option is required: provide a MongoQueue, RedisQueue, PostgresQueue, or CustomQueue instance');
|
|
159
206
|
}
|
|
160
207
|
|
|
161
|
-
this.
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
this.zombieTime = opts.zombieTime || 32786;
|
|
169
|
-
this.keepHistory = opts.keepHistory || false;
|
|
208
|
+
this.queue = opts.queue;
|
|
209
|
+
|
|
210
|
+
for (let i = queueMethods.length - 1; i >= 0; i--) {
|
|
211
|
+
if (typeof this.queue[queueMethods[i]] !== 'function') {
|
|
212
|
+
throw new Error(`[mail-time] {queue} instance is missing {${queueMethods[i]}} method that is required!`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
170
215
|
|
|
171
|
-
this.
|
|
172
|
-
this.
|
|
173
|
-
|
|
216
|
+
this.debug = opts.debug === true;
|
|
217
|
+
this.__debug = (...args) => {
|
|
218
|
+
debug(this.debug, `[${this.prefix || 'default'}]`, ...args);
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
this.type = (opts.type === 'client' || opts.type === 'server') ? opts.type : 'server';
|
|
222
|
+
this.prefix = (typeof opts.prefix === 'string') ? opts.prefix : '';
|
|
174
223
|
|
|
175
|
-
if (
|
|
176
|
-
|
|
224
|
+
if (typeof opts.retries === 'number') {
|
|
225
|
+
if (opts.retries < 0) {
|
|
226
|
+
throw new Error('[mail-time] {retries} must be a non-negative number');
|
|
227
|
+
}
|
|
228
|
+
this.maxTries = opts.retries + 1;
|
|
229
|
+
} else if (typeof opts.maxTries === 'number') {
|
|
230
|
+
this.maxTries = (opts.maxTries < 1) ? 1 : opts.maxTries;
|
|
231
|
+
} else {
|
|
232
|
+
this.maxTries = 60;
|
|
177
233
|
}
|
|
178
234
|
|
|
179
|
-
if (
|
|
180
|
-
this.
|
|
235
|
+
if (typeof opts.retryDelay === 'number') {
|
|
236
|
+
this.retryDelay = opts.retryDelay;
|
|
237
|
+
} else if (typeof opts.interval === 'number') {
|
|
238
|
+
this.retryDelay = opts.interval * 1000;
|
|
239
|
+
} else {
|
|
240
|
+
this.retryDelay = 60000;
|
|
181
241
|
}
|
|
182
242
|
|
|
183
|
-
this.
|
|
243
|
+
this.template = (typeof opts.template === 'string') ? opts.template : '{{{html}}}';
|
|
244
|
+
this.keepHistory = opts.keepHistory === true;
|
|
245
|
+
this.onSent = (typeof opts.onSent === 'function') ? opts.onSent.bind(this) : noop;
|
|
246
|
+
this.onError = (typeof opts.onError === 'function') ? opts.onError.bind(this) : noop;
|
|
247
|
+
|
|
248
|
+
this.revolvingInterval = (typeof opts.revolvingInterval === 'number' && opts.revolvingInterval > 0) ? opts.revolvingInterval : 1536;
|
|
249
|
+
this.mode = (opts.mode === 'one' || opts.mode === 'batch') ? opts.mode : 'batch';
|
|
250
|
+
this.concurrency = (typeof opts.concurrency === 'number' && opts.concurrency > 0 && Number.isFinite(opts.concurrency)) ? Math.floor(opts.concurrency) : 1;
|
|
251
|
+
this.sendingTimeout = (typeof opts.sendingTimeout === 'number' && opts.sendingTimeout > 0) ? opts.sendingTimeout : 300000;
|
|
252
|
+
this.__isDestroyed = false;
|
|
253
|
+
this.__readyPromise = null;
|
|
254
|
+
this.__schedulerTimer = null;
|
|
255
|
+
this.__inFlight = new Set();
|
|
256
|
+
this.__pool = createPool(this.concurrency);
|
|
257
|
+
|
|
258
|
+
this.failsToNext = (typeof opts.failsToNext === 'number' && opts.failsToNext > 0) ? opts.failsToNext : 4;
|
|
184
259
|
this.strategy = (opts.strategy === 'backup' || opts.strategy === 'balancer') ? opts.strategy : 'backup';
|
|
185
|
-
this.transports = opts.transports
|
|
260
|
+
this.transports = Array.isArray(opts.transports) ? opts.transports : [];
|
|
186
261
|
this.transport = 0;
|
|
187
|
-
this.
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
262
|
+
this.verifyTransports = opts.verifyTransports !== false;
|
|
263
|
+
this.__unhealthyTransports = new Set();
|
|
264
|
+
|
|
265
|
+
if (typeof opts.from === 'string') {
|
|
266
|
+
const fromStr = opts.from;
|
|
267
|
+
this.from = () => fromStr;
|
|
268
|
+
} else if (typeof opts.from === 'function') {
|
|
269
|
+
this.from = opts.from;
|
|
270
|
+
} else {
|
|
271
|
+
this.from = false;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
this.queue.mailTimeInstance = this;
|
|
193
275
|
|
|
194
|
-
|
|
195
|
-
|
|
276
|
+
/** @type {string} */
|
|
277
|
+
this.concatSubject = (typeof opts.concatSubject === 'string' && opts.concatSubject) ? opts.concatSubject : 'Multiple notifications';
|
|
278
|
+
if (opts.concatEmails === true) {
|
|
279
|
+
this.concatEmails = true;
|
|
280
|
+
} else if (isPlainObject(opts.concatEmails)) {
|
|
281
|
+
this.concatEmails = true;
|
|
282
|
+
if (typeof opts.concatEmails.subject === 'string' && opts.concatEmails.subject) {
|
|
283
|
+
this.concatSubject = opts.concatEmails.subject;
|
|
196
284
|
}
|
|
285
|
+
} else {
|
|
286
|
+
this.concatEmails = false;
|
|
287
|
+
}
|
|
288
|
+
this.concatDelimiter = (typeof opts.concatDelimiter === 'string' && opts.concatDelimiter) ? opts.concatDelimiter : '<hr>';
|
|
197
289
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
if (this.concatThrottling < 2048) {
|
|
207
|
-
this.concatThrottling = 3072;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
_debug(this.debug, 'DEBUG ON {debug: true}');
|
|
211
|
-
_debug(this.debug, `INITIALIZING [type: ${this.type}]`);
|
|
212
|
-
_debug(this.debug, `INITIALIZING [strategy: ${this.strategy}]`);
|
|
213
|
-
_debug(this.debug, `INITIALIZING [prefix: ${this.prefix}]`);
|
|
214
|
-
_debug(this.debug, `INITIALIZING [maxTries: ${this.maxTries}]`);
|
|
215
|
-
_debug(this.debug, `INITIALIZING [failsToNext: ${this.failsToNext}]`);
|
|
216
|
-
|
|
217
|
-
if (opts.type === 'server' && (this.transports.constructor !== Array || !this.transports.length)) {
|
|
218
|
-
throw new Error('[mail-time] transports is required and must be an Array, like returned from `nodemailer.createTransport`');
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
this.collection = opts.db.collection(`__mailTimeQueue__${this.prefix}`);
|
|
222
|
-
ensureIndex(this.collection, { isSent: 1, to: 1, sendAt: 1 });
|
|
223
|
-
ensureIndex(this.collection, { isSent: 1, sendAt: 1, tries: 1 }, { background: true });
|
|
224
|
-
|
|
225
|
-
// MongoDB Collection Schema:
|
|
226
|
-
// _id
|
|
227
|
-
// to {String|[String]}
|
|
228
|
-
// tries {Number} - qty of send attempts
|
|
229
|
-
// sendAt {Date} - When letter should be sent
|
|
230
|
-
// isSent {Boolean} - Email status
|
|
231
|
-
// template {String} - Template for this email
|
|
232
|
-
// transport {Number} - Last used transport
|
|
233
|
-
// concatSubject {String|Boolean} - Email concatenation subject
|
|
234
|
-
// ---
|
|
235
|
-
// mailOptions {[Object]} - Array of nodeMailer's `mailOptions`
|
|
236
|
-
// mailOptions.to {String|Array} - [REQUIRED]
|
|
237
|
-
// mailOptions.from {String}
|
|
238
|
-
// mailOptions.text {String|Boolean}
|
|
239
|
-
// mailOptions.html (String)
|
|
240
|
-
// mailOptions.subject {String}
|
|
241
|
-
// mailOptions.Other nodeMailer `sendMail` options...
|
|
290
|
+
if (typeof opts.concatDelay === 'number') {
|
|
291
|
+
this.concatDelay = opts.concatDelay;
|
|
292
|
+
} else if (typeof opts.concatThrottling === 'number') {
|
|
293
|
+
this.concatDelay = opts.concatThrottling * 1000;
|
|
294
|
+
} else {
|
|
295
|
+
this.concatDelay = 60000;
|
|
296
|
+
}
|
|
242
297
|
|
|
298
|
+
this.__debug('DEBUG ON {debug: true}');
|
|
299
|
+
this.__debug(`INITIALIZING [type: ${this.type}]`);
|
|
300
|
+
this.__debug(`INITIALIZING [strategy: ${this.strategy}]`);
|
|
301
|
+
this.__debug(`INITIALIZING [josk.adapter.type: ${opts?.josk?.adapter?.type || 'custom'}]`);
|
|
302
|
+
this.__debug(`INITIALIZING [prefix: ${this.prefix}]`);
|
|
303
|
+
this.__debug(`INITIALIZING [retries: ${this.maxTries - 1}]`);
|
|
304
|
+
this.__debug(`INITIALIZING [failsToNext: ${this.failsToNext}]`);
|
|
305
|
+
this.__debug(`INITIALIZING [mode: ${this.mode}]`);
|
|
306
|
+
this.__debug(`INITIALIZING [concurrency: ${this.concurrency}]`);
|
|
307
|
+
this.__debug(`INITIALIZING [sendingTimeout: ${this.sendingTimeout}]`);
|
|
308
|
+
|
|
309
|
+
/** SERVER-SPECIFIC CHECKS AND CONFIG */
|
|
243
310
|
if (this.type === 'server') {
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
prefix: `mailTimeQueue${this.prefix}`,
|
|
248
|
-
resetOnInit: false,
|
|
249
|
-
zombieTime: this.zombieTime,
|
|
250
|
-
minRevolvingDelay: this.minRevolvingDelay,
|
|
251
|
-
maxRevolvingDelay: this.maxRevolvingDelay
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
scheduler.setInterval(this.___send.bind(this), this.revolvingInterval, `mailTimeQueue${this.prefix}`);
|
|
255
|
-
}
|
|
256
|
-
}
|
|
311
|
+
if (!this.transports.length) {
|
|
312
|
+
throw new Error('[mail-time] {transports} is required for {type: "server"} and must be a non-empty Array, like one returned from `nodemailer.createTransport`');
|
|
313
|
+
}
|
|
257
314
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
315
|
+
if (!opts.josk || typeof opts.josk !== 'object') {
|
|
316
|
+
throw new Error('[mail-time] {josk} option is required {object} for {type: "server"}');
|
|
317
|
+
}
|
|
261
318
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
319
|
+
if (!opts.josk.adapter || typeof opts.josk.adapter !== 'object') {
|
|
320
|
+
throw new Error('[mail-time] {josk.adapter} option is required {object} *or* custom adapter Class');
|
|
321
|
+
}
|
|
265
322
|
|
|
266
|
-
|
|
267
|
-
|
|
323
|
+
this.josk = { ...opts.josk };
|
|
324
|
+
const buildAdapterOptions = () => {
|
|
325
|
+
const adapterOptions = {
|
|
326
|
+
prefix: `mailTimeQueue${this.prefix}`,
|
|
327
|
+
...opts.josk.adapter,
|
|
328
|
+
};
|
|
268
329
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
330
|
+
if (typeof opts.josk.resetOnInit === 'boolean' && typeof adapterOptions.resetOnInit !== 'boolean') {
|
|
331
|
+
adapterOptions.resetOnInit = opts.josk.resetOnInit;
|
|
332
|
+
}
|
|
272
333
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
}
|
|
334
|
+
return adapterOptions;
|
|
335
|
+
};
|
|
276
336
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
337
|
+
const adapterType = opts.josk.adapter.type;
|
|
338
|
+
if (adapterType === 'mongo') {
|
|
339
|
+
if (!opts.josk.adapter.db) {
|
|
340
|
+
throw new Error('[mail-time] {josk.adapter.db} option required for {josk.adapter.type: "mongo"}');
|
|
341
|
+
}
|
|
342
|
+
this.josk.adapter = new MongoAdapter(buildAdapterOptions());
|
|
343
|
+
} else if (adapterType === 'redis') {
|
|
344
|
+
if (!opts.josk.adapter.client) {
|
|
345
|
+
throw new Error('[mail-time] {josk.adapter.client} option required for {josk.adapter.type: "redis"}');
|
|
346
|
+
}
|
|
347
|
+
this.josk.adapter = new RedisAdapter(buildAdapterOptions());
|
|
348
|
+
} else if (adapterType === 'postgres') {
|
|
349
|
+
if (!opts.josk.adapter.client) {
|
|
350
|
+
throw new Error('[mail-time] {josk.adapter.client} option required for {josk.adapter.type: "postgres"}');
|
|
351
|
+
}
|
|
352
|
+
this.josk.adapter = new PostgresAdapter(buildAdapterOptions());
|
|
353
|
+
}
|
|
280
354
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
355
|
+
this.josk.minRevolvingDelay = (typeof opts.josk.minRevolvingDelay === 'number') ? opts.josk.minRevolvingDelay : 512;
|
|
356
|
+
this.josk.maxRevolvingDelay = (typeof opts.josk.maxRevolvingDelay === 'number') ? opts.josk.maxRevolvingDelay : 2048;
|
|
357
|
+
this.josk.zombieTime = (typeof opts.josk.zombieTime === 'number') ? opts.josk.zombieTime : 60000;
|
|
358
|
+
this.josk.execute = (opts.josk.execute === 'one' || opts.josk.execute === 'batch') ? opts.josk.execute : 'batch';
|
|
359
|
+
this.josk.concurrency = (typeof opts.josk.concurrency === 'number' && opts.josk.concurrency > 0) ? opts.josk.concurrency : Infinity;
|
|
360
|
+
this.josk.autoClear = opts.josk.autoClear === true;
|
|
286
361
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
if (task.mailOptions.length > 1) {
|
|
290
|
-
_mailOpts.html += this.___render(this.concatDelimiter, task.mailOptions[i]) + this.___render(task.mailOptions[i].html, task.mailOptions[i]);
|
|
291
|
-
} else {
|
|
292
|
-
_mailOpts.html = this.___render(task.mailOptions[i].html, task.mailOptions[i]);
|
|
293
|
-
}
|
|
294
|
-
delete task.mailOptions[i].html;
|
|
362
|
+
if (typeof opts.josk.lockOwnerId === 'string' && opts.josk.lockOwnerId.length > 0) {
|
|
363
|
+
this.josk.lockOwnerId = opts.josk.lockOwnerId;
|
|
295
364
|
}
|
|
296
365
|
|
|
297
|
-
if (
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
}
|
|
301
|
-
_mailOpts.text = this.___render(task.mailOptions[i].text, task.mailOptions[i]);
|
|
302
|
-
}
|
|
303
|
-
delete task.mailOptions[i].text;
|
|
366
|
+
if (typeof opts.josk.onError !== 'function') {
|
|
367
|
+
this.josk.onError = (title, details) => {
|
|
368
|
+
logError(`[scheduler] ${title}`, details);
|
|
369
|
+
};
|
|
304
370
|
}
|
|
305
371
|
|
|
306
|
-
|
|
307
|
-
|
|
372
|
+
this.scheduler = new JoSk({
|
|
373
|
+
debug: this.debug,
|
|
374
|
+
...this.josk,
|
|
375
|
+
});
|
|
308
376
|
|
|
309
|
-
|
|
310
|
-
_mailOpts.html = this.___render((task.template || this.template), _mailOpts);
|
|
377
|
+
this.__schedulerTimer = this.scheduler.setInterval(this.___iterate.bind(this), this.revolvingInterval, `mailTimeQueue${this.prefix}`);
|
|
311
378
|
}
|
|
312
379
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
380
|
+
this.__readyPromise = this.___ready();
|
|
381
|
+
this.__readyPromise.catch(() => void 0);
|
|
382
|
+
}
|
|
316
383
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
384
|
+
static get Template() {
|
|
385
|
+
return DEFAULT_TEMPLATE;
|
|
386
|
+
}
|
|
320
387
|
|
|
321
|
-
|
|
388
|
+
static set Template(newVal) {
|
|
389
|
+
DEFAULT_TEMPLATE = newVal;
|
|
322
390
|
}
|
|
323
391
|
|
|
324
392
|
/**
|
|
393
|
+
* @async
|
|
325
394
|
* @memberOf MailTime
|
|
326
|
-
* @name
|
|
327
|
-
* @
|
|
328
|
-
* @returns {
|
|
395
|
+
* @name ping
|
|
396
|
+
* @description Check package readiness and connection to Storage
|
|
397
|
+
* @returns {Promise<MailTimePingResult>}
|
|
398
|
+
* @throws {Error}
|
|
329
399
|
*/
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
tries: {
|
|
337
|
-
$lt: this.maxTries
|
|
338
|
-
}
|
|
339
|
-
}, {
|
|
340
|
-
projection: {
|
|
341
|
-
_id: 1,
|
|
342
|
-
tries: 1,
|
|
343
|
-
template: 1,
|
|
344
|
-
transport: 1,
|
|
345
|
-
mailOptions: 1,
|
|
346
|
-
concatSubject: 1
|
|
400
|
+
async ping() {
|
|
401
|
+
this.__debug('[ping]');
|
|
402
|
+
if (this.scheduler) {
|
|
403
|
+
const schedulerPing = await this.scheduler.ping();
|
|
404
|
+
if (schedulerPing.status !== 'OK') {
|
|
405
|
+
return schedulerPing;
|
|
347
406
|
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
try {
|
|
352
|
-
await this.collection.updateOne({
|
|
353
|
-
_id: task._id
|
|
354
|
-
}, {
|
|
355
|
-
$set: {
|
|
356
|
-
isSent: true
|
|
357
|
-
},
|
|
358
|
-
$inc: {
|
|
359
|
-
tries: 1
|
|
360
|
-
}
|
|
361
|
-
});
|
|
362
|
-
|
|
363
|
-
let transport;
|
|
364
|
-
let transportIndex;
|
|
365
|
-
if (this.strategy === 'balancer') {
|
|
366
|
-
this.transport = this.transport + 1;
|
|
367
|
-
if (this.transport >= this.transports.length) {
|
|
368
|
-
this.transport = 0;
|
|
369
|
-
}
|
|
370
|
-
transportIndex = this.transport;
|
|
371
|
-
transport = this.transports[transportIndex];
|
|
372
|
-
} else {
|
|
373
|
-
transportIndex = task.transport;
|
|
374
|
-
transport = this.transports[transportIndex];
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
const _mailOpts = this.___compileMailOpts(transport, task);
|
|
378
|
-
|
|
379
|
-
_debug(this.debug, '[sendMail] [sending] To:', _mailOpts.to);
|
|
380
|
-
transport.sendMail(_mailOpts, (error, info) => {
|
|
381
|
-
if (error) {
|
|
382
|
-
this.___handleError(task, error, info);
|
|
383
|
-
return;
|
|
384
|
-
}
|
|
407
|
+
}
|
|
408
|
+
return await this.queue.ping();
|
|
409
|
+
}
|
|
385
410
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
411
|
+
/**
|
|
412
|
+
* @async
|
|
413
|
+
* @memberOf MailTime
|
|
414
|
+
* @name ready
|
|
415
|
+
* @description Wait until queue and scheduler storage are ready
|
|
416
|
+
* @returns {Promise<MailTime>}
|
|
417
|
+
*/
|
|
418
|
+
async ready() {
|
|
419
|
+
this.__debug('[ready]');
|
|
420
|
+
return await this.__readyPromise;
|
|
421
|
+
}
|
|
390
422
|
|
|
391
|
-
|
|
423
|
+
/**
|
|
424
|
+
* @memberOf MailTime
|
|
425
|
+
* @name destroy
|
|
426
|
+
* @description Stop the scheduler and block future dispatches. Pass `{ drain: true }` to also wait for in-flight SMTP attempts (returns a Promise).
|
|
427
|
+
* @param {{ drain?: boolean }} [opts]
|
|
428
|
+
* @returns {boolean | Promise<boolean>}
|
|
429
|
+
*/
|
|
430
|
+
destroy(opts) {
|
|
431
|
+
this.__debug('[destroy]', opts);
|
|
432
|
+
if (this.__isDestroyed) {
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
392
435
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
436
|
+
this.__isDestroyed = true;
|
|
437
|
+
if (this.scheduler && typeof this.scheduler.destroy === 'function') {
|
|
438
|
+
this.scheduler.destroy();
|
|
439
|
+
}
|
|
440
|
+
if (opts && opts.drain === true && this.__pool) {
|
|
441
|
+
return this.__pool.drain().then(() => true);
|
|
442
|
+
}
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
398
445
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
});
|
|
446
|
+
/**
|
|
447
|
+
* @async
|
|
448
|
+
* @memberOf MailTime
|
|
449
|
+
* @name drain
|
|
450
|
+
* @description Wait for all in-flight email send attempts to settle
|
|
451
|
+
* @returns {Promise<void>}
|
|
452
|
+
*/
|
|
453
|
+
async drain() {
|
|
454
|
+
this.__debug('[drain]');
|
|
455
|
+
if (this.__pool) {
|
|
456
|
+
await this.__pool.drain();
|
|
457
|
+
}
|
|
412
458
|
}
|
|
413
459
|
|
|
414
460
|
/**
|
|
415
461
|
* @memberOf MailTime
|
|
416
462
|
* @name send
|
|
417
463
|
* @description alias of `sendMail`
|
|
418
|
-
* @
|
|
464
|
+
* @param {MailTimeMailOptions} opts - email options
|
|
465
|
+
* @returns {Promise<string>} uuid of the email
|
|
419
466
|
*/
|
|
420
|
-
send(opts
|
|
421
|
-
this.
|
|
467
|
+
async send(opts) {
|
|
468
|
+
this.__debug('[send]', opts);
|
|
469
|
+
return await this.sendMail(opts);
|
|
422
470
|
}
|
|
423
471
|
|
|
424
472
|
/**
|
|
473
|
+
* @async
|
|
425
474
|
* @memberOf MailTime
|
|
426
475
|
* @name sendMail
|
|
427
|
-
* @
|
|
428
|
-
* @param
|
|
429
|
-
* @
|
|
430
|
-
* @
|
|
431
|
-
* @param callback {Function} - [OPTIONAL] Callback function
|
|
432
|
-
* @returns {void}
|
|
476
|
+
* @description add email to the queue or append to existing letter if {concatEmails: true}
|
|
477
|
+
* @param {MailTimeMailOptions} opts - email options
|
|
478
|
+
* @returns {Promise<string>} uuid of the email
|
|
479
|
+
* @throws {Error}
|
|
433
480
|
*/
|
|
434
|
-
sendMail(opts = {}
|
|
435
|
-
|
|
481
|
+
async sendMail(opts = {}) {
|
|
482
|
+
this.__debug('[sendMail]', opts);
|
|
436
483
|
if (!opts.html && !opts.text) {
|
|
437
|
-
throw new Error('`html` nor `text` field is
|
|
484
|
+
throw new Error('[mail-time] [sendMail] `html` nor `text` field is present, at least one of those fields is required');
|
|
438
485
|
}
|
|
439
486
|
|
|
440
|
-
|
|
441
|
-
|
|
487
|
+
let sendAt = opts.sendAt;
|
|
488
|
+
if (sendAt instanceof Date) {
|
|
489
|
+
sendAt = +sendAt;
|
|
442
490
|
}
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
opts.template = false;
|
|
491
|
+
if (typeof sendAt !== 'number' || !Number.isFinite(sendAt)) {
|
|
492
|
+
sendAt = Date.now();
|
|
446
493
|
}
|
|
447
494
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
}
|
|
495
|
+
const template = (typeof opts.template === 'string') ? opts.template : false;
|
|
496
|
+
const concatSubject = (typeof opts.concatSubject === 'string') ? opts.concatSubject : false;
|
|
451
497
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
delete
|
|
456
|
-
delete opts.template;
|
|
457
|
-
delete opts.concatSubject;
|
|
498
|
+
const mailOptions = { ...opts };
|
|
499
|
+
delete mailOptions.sendAt;
|
|
500
|
+
delete mailOptions.template;
|
|
501
|
+
delete mailOptions.concatSubject;
|
|
458
502
|
|
|
459
|
-
if (typeof
|
|
460
|
-
throw new Error('[mail-time] `mailOptions.to` is required and must be a string or non-empty Array');
|
|
503
|
+
if (typeof mailOptions.to !== 'string' && (!Array.isArray(mailOptions.to) || !mailOptions.to.length)) {
|
|
504
|
+
throw new Error('[mail-time] [sendMail] `mailOptions.to` is required and must be a string or non-empty Array');
|
|
461
505
|
}
|
|
462
506
|
|
|
463
507
|
if (this.concatEmails) {
|
|
464
|
-
|
|
465
|
-
this.
|
|
466
|
-
to: opts.to,
|
|
467
|
-
isSent: false,
|
|
468
|
-
sendAt: {
|
|
469
|
-
$lte: _sendAt
|
|
470
|
-
}
|
|
471
|
-
}, {
|
|
472
|
-
projection: {
|
|
473
|
-
_id: 1,
|
|
474
|
-
mailOptions: 1
|
|
475
|
-
}
|
|
476
|
-
}).then((task) => {
|
|
477
|
-
if (task) {
|
|
478
|
-
const queue = task.mailOptions || [];
|
|
508
|
+
sendAt = sendAt + this.concatDelay;
|
|
509
|
+
const task = await this.queue.getPendingTo(mailOptions.to, sendAt);
|
|
479
510
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
return;
|
|
483
|
-
}
|
|
484
|
-
}
|
|
511
|
+
if (task) {
|
|
512
|
+
const pendingMailOptions = task.mailOptions || [];
|
|
485
513
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
}
|
|
490
|
-
$set: {
|
|
491
|
-
mailOptions: queue
|
|
492
|
-
}
|
|
493
|
-
}).then(() => {
|
|
494
|
-
const _id = task._id.toHexString();
|
|
495
|
-
if (!this.callbacks[_id]) {
|
|
496
|
-
this.callbacks[_id] = [];
|
|
497
|
-
}
|
|
498
|
-
this.callbacks[_id].push(callback);
|
|
499
|
-
}).catch(mongoErrorHandler);
|
|
500
|
-
return;
|
|
514
|
+
for (let i = 0; i < pendingMailOptions.length; i++) {
|
|
515
|
+
if (equals(pendingMailOptions[i], mailOptions)) {
|
|
516
|
+
return task.uuid;
|
|
517
|
+
}
|
|
501
518
|
}
|
|
502
519
|
|
|
503
|
-
this.
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
return;
|
|
520
|
+
const appended = await this.queue.update(task, {
|
|
521
|
+
appendMailOption: mailOptions,
|
|
522
|
+
});
|
|
523
|
+
if (appended) {
|
|
524
|
+
return task.uuid;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
512
527
|
}
|
|
513
528
|
|
|
514
|
-
this.___addToQueue({
|
|
515
|
-
sendAt
|
|
516
|
-
template
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
}
|
|
529
|
+
return await this.___addToQueue({
|
|
530
|
+
sendAt,
|
|
531
|
+
template,
|
|
532
|
+
concatSubject,
|
|
533
|
+
mailOptions,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
520
536
|
|
|
521
|
-
|
|
537
|
+
/**
|
|
538
|
+
* @async
|
|
539
|
+
* @memberOf MailTime
|
|
540
|
+
* @name cancel
|
|
541
|
+
* @description alias of `cancelMail`
|
|
542
|
+
* @param {string|Promise<string>} uuid - uuid returned from `send` or `sendMail`
|
|
543
|
+
* @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found was sent or was cancelled previously
|
|
544
|
+
*/
|
|
545
|
+
async cancel(uuid) {
|
|
546
|
+
this.__debug('[cancel]', uuid);
|
|
547
|
+
return await this.cancelMail(uuid);
|
|
522
548
|
}
|
|
523
549
|
|
|
524
550
|
/**
|
|
551
|
+
* @async
|
|
552
|
+
* @memberOf MailTime
|
|
553
|
+
* @name cancelMail
|
|
554
|
+
* @description remove email from the queue or mark as `isCancelled`
|
|
555
|
+
* @param {string|Promise<string>} uuid - uuid returned from `send` or `sendMail`
|
|
556
|
+
* @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found was sent or was cancelled previously
|
|
557
|
+
*/
|
|
558
|
+
async cancelMail(uuid) {
|
|
559
|
+
this.__debug('[cancelMail]', uuid);
|
|
560
|
+
const resolved = (uuid && typeof uuid.then === 'function') ? await uuid : uuid;
|
|
561
|
+
return await this.queue.cancel(resolved);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* @async
|
|
566
|
+
* @internal
|
|
525
567
|
* @memberOf MailTime
|
|
526
568
|
* @name ___handleError
|
|
527
|
-
* @
|
|
528
|
-
* @param
|
|
529
|
-
* @param
|
|
530
|
-
* @
|
|
569
|
+
* @description Handle runtime errors and pass to `onError` callback
|
|
570
|
+
* @param {MailTimeTask} task - Email task record from Storage
|
|
571
|
+
* @param {unknown} error - Error String/Object/Error
|
|
572
|
+
* @param {object} info - Info object returned from nodemailer
|
|
573
|
+
* @returns {Promise<void 0>}
|
|
531
574
|
*/
|
|
532
|
-
___handleError(task, error, info) {
|
|
575
|
+
async ___handleError(task, error, info) {
|
|
576
|
+
this.__debug('[private handleError]', { task, error, info });
|
|
533
577
|
if (!task) {
|
|
534
578
|
return;
|
|
535
579
|
}
|
|
536
580
|
|
|
537
581
|
if (task.tries >= this.maxTries) {
|
|
582
|
+
this.___finalizeRejected(task, info);
|
|
583
|
+
|
|
584
|
+
const leaseRemove = __leaseRemoveOpts(task);
|
|
585
|
+
const leaseUpdate = __withLease(task, {
|
|
586
|
+
isSent: false,
|
|
587
|
+
isFailed: true,
|
|
588
|
+
isSending: false,
|
|
589
|
+
sendingAt: 0,
|
|
590
|
+
mailOptions: task.mailOptions,
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
let finalized = false;
|
|
538
594
|
if (!this.keepHistory) {
|
|
539
|
-
this.
|
|
540
|
-
|
|
541
|
-
|
|
595
|
+
finalized = await this.queue.remove(task, leaseRemove);
|
|
596
|
+
} else {
|
|
597
|
+
finalized = await this.queue.update(task, leaseUpdate);
|
|
542
598
|
}
|
|
543
599
|
|
|
544
|
-
|
|
545
|
-
|
|
600
|
+
if (!finalized) {
|
|
601
|
+
this.__debug('[private handleError] lease lost before final failure update, skipping onError', task.uuid);
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// Persist first, then mirror to in-memory task — a superseded worker must not lie to onError.
|
|
606
|
+
task.isSent = false;
|
|
607
|
+
task.isFailed = true;
|
|
608
|
+
task.isSending = false;
|
|
609
|
+
|
|
610
|
+
this.__debug(`[private handleError] Giving up trying send email after ${task.tries} attempts to: `, task.mailOptions[0].to, error);
|
|
611
|
+
this.onError(error, task, info);
|
|
546
612
|
return;
|
|
547
613
|
}
|
|
548
614
|
|
|
549
615
|
let transportIndex = task.transport;
|
|
550
616
|
|
|
551
617
|
if (this.strategy === 'backup' && this.transports.length > 1 && (task.tries % this.failsToNext) === 0) {
|
|
552
|
-
|
|
553
|
-
if (transportIndex > this.transports.length - 1) {
|
|
554
|
-
transportIndex = 0;
|
|
555
|
-
}
|
|
618
|
+
transportIndex = this.___nextHealthyTransport(transportIndex);
|
|
556
619
|
}
|
|
557
620
|
|
|
558
|
-
this.
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
621
|
+
const released = await this.queue.update(task, __withLease(task, {
|
|
622
|
+
isSending: false,
|
|
623
|
+
sendingAt: 0,
|
|
624
|
+
sendAt: Date.now() + this.retryDelay,
|
|
625
|
+
transport: transportIndex,
|
|
626
|
+
}));
|
|
627
|
+
|
|
628
|
+
if (!released) {
|
|
629
|
+
this.__debug('[private handleError] lease lost before retry release, skipping', task.uuid);
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
567
632
|
|
|
568
|
-
|
|
633
|
+
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);
|
|
569
634
|
}
|
|
570
635
|
|
|
571
636
|
/**
|
|
637
|
+
* @async
|
|
638
|
+
* @internal
|
|
572
639
|
* @memberOf MailTime
|
|
573
640
|
* @name ___addToQueue
|
|
574
|
-
* @
|
|
575
|
-
* @param
|
|
576
|
-
* @
|
|
577
|
-
* @param opts.mailOptions {Object} - MailOptions according to NodeMailer lib
|
|
578
|
-
* @param opts.concatSubject {String} - Email subject used when sending multiple concatenated emails
|
|
579
|
-
* @param callback {Function} - [OPTIONAL] Callback function
|
|
580
|
-
* @returns {void}
|
|
641
|
+
* @description Prepare task's object and push to the queue
|
|
642
|
+
* @param {{ sendAt: number, template: string | false, mailOptions: MailTimeMailOptions, concatSubject: string | false }} opts - Email options
|
|
643
|
+
* @returns {Promise<string>} message uuid
|
|
581
644
|
*/
|
|
582
|
-
___addToQueue(opts
|
|
583
|
-
|
|
645
|
+
async ___addToQueue(opts) {
|
|
646
|
+
this.__debug('[private addToQueue]', opts);
|
|
647
|
+
let transportIndex = this.transport;
|
|
648
|
+
if (this.strategy === 'balancer' && this.transports.length > 0) {
|
|
649
|
+
transportIndex = this.___nextHealthyTransport(this.transport);
|
|
650
|
+
this.transport = transportIndex;
|
|
651
|
+
}
|
|
584
652
|
const task = {
|
|
653
|
+
uuid: randomUUID(),
|
|
585
654
|
tries: 0,
|
|
586
655
|
isSent: false,
|
|
587
656
|
sendAt: opts.sendAt,
|
|
657
|
+
isFailed: false,
|
|
658
|
+
isSending: false,
|
|
659
|
+
sendingAt: 0,
|
|
588
660
|
template: opts.template,
|
|
589
|
-
transport:
|
|
661
|
+
transport: transportIndex,
|
|
662
|
+
isCancelled: false,
|
|
590
663
|
mailOptions: [opts.mailOptions],
|
|
591
|
-
concatSubject: opts.concatSubject
|
|
664
|
+
concatSubject: opts.concatSubject,
|
|
592
665
|
};
|
|
593
666
|
|
|
594
667
|
if (this.concatEmails) {
|
|
595
668
|
task.to = opts.mailOptions.to;
|
|
596
669
|
}
|
|
597
670
|
|
|
598
|
-
this.
|
|
599
|
-
|
|
600
|
-
if (!this.callbacks[_id]) {
|
|
601
|
-
this.callbacks[_id] = [];
|
|
602
|
-
}
|
|
603
|
-
this.callbacks[_id].push(callback);
|
|
604
|
-
}).catch(mongoErrorHandler);
|
|
671
|
+
await this.queue.push(task);
|
|
672
|
+
return task.uuid;
|
|
605
673
|
}
|
|
606
674
|
|
|
607
675
|
/**
|
|
676
|
+
* @internal
|
|
608
677
|
* @memberOf MailTime
|
|
609
678
|
* @name ___render
|
|
610
|
-
* @
|
|
611
|
-
* @param
|
|
612
|
-
* @
|
|
679
|
+
* @description Render Mustache-like placeholders
|
|
680
|
+
* @param {string} _string - Template with Mustache-like placeholders
|
|
681
|
+
* @param {Record<string, any>} replacements - Blaze/Mustache-like helpers Object
|
|
682
|
+
* @returns {string}
|
|
613
683
|
*/
|
|
614
684
|
___render(_string, replacements) {
|
|
615
|
-
let i;
|
|
616
685
|
let string = _string;
|
|
617
|
-
const matchHTML = string.match(/\{{3}\s?([a-zA-Z0-9
|
|
686
|
+
const matchHTML = string.match(/\{{3}\s?([a-zA-Z0-9\-_]+)\s?\}{3}/g);
|
|
618
687
|
if (matchHTML) {
|
|
619
|
-
for (i = 0; i < matchHTML.length; i++) {
|
|
620
|
-
|
|
621
|
-
|
|
688
|
+
for (let i = 0; i < matchHTML.length; i++) {
|
|
689
|
+
const key = matchHTML[i].slice(3, -3).trim();
|
|
690
|
+
if (Object.hasOwn(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
|
|
691
|
+
string = string.replace(matchHTML[i], `${replacements[key]}`);
|
|
622
692
|
}
|
|
623
693
|
}
|
|
624
694
|
}
|
|
625
695
|
|
|
626
|
-
const matchStr = string.match(/\{{2}\s?([a-zA-Z0-9
|
|
696
|
+
const matchStr = string.match(/\{{2}\s?([a-zA-Z0-9\-_]+)\s?\}{2}/g);
|
|
627
697
|
if (matchStr) {
|
|
628
|
-
for (i = 0; i < matchStr.length; i++) {
|
|
629
|
-
|
|
630
|
-
|
|
698
|
+
for (let i = 0; i < matchStr.length; i++) {
|
|
699
|
+
const key = matchStr[i].slice(2, -2).trim();
|
|
700
|
+
if (Object.hasOwn(replacements, key) && replacements[key] !== null && replacements[key] !== void 0) {
|
|
701
|
+
string = string.replace(matchStr[i], `${replacements[key]}`.replace(/<(?:.|\n)*?>/gm, ''));
|
|
631
702
|
}
|
|
632
703
|
}
|
|
633
704
|
}
|
|
@@ -635,20 +706,476 @@ export default class MailTime {
|
|
|
635
706
|
}
|
|
636
707
|
|
|
637
708
|
/**
|
|
709
|
+
* @internal
|
|
638
710
|
* @memberOf MailTime
|
|
639
|
-
* @name
|
|
640
|
-
* @
|
|
641
|
-
* @param
|
|
642
|
-
* @param
|
|
643
|
-
* @returns {
|
|
711
|
+
* @name ___compileMailOpts
|
|
712
|
+
* @description Run various checks, compile options, and render template
|
|
713
|
+
* @param {MailTimeTransport} transport - Current transport
|
|
714
|
+
* @param {MailTimeTask} task - Email task record from Storage
|
|
715
|
+
* @returns {MailTimeMailOptions}
|
|
644
716
|
*/
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
717
|
+
___compileMailOpts(transport, task) {
|
|
718
|
+
if (!transport) {
|
|
719
|
+
throw new Error('[mail-time] [sendMail] [___compileMailOpts] {transport} is not available or misconfigured!');
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
let compiledOpts = {};
|
|
723
|
+
|
|
724
|
+
if (isPlainObject(transport._options) && isPlainObject(transport._options.mailOptions)) {
|
|
725
|
+
compiledOpts = deepMerge(compiledOpts, transport._options.mailOptions);
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
if (isPlainObject(transport.options) && isPlainObject(transport.options.mailOptions)) {
|
|
729
|
+
compiledOpts = deepMerge(compiledOpts, transport.options.mailOptions);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
compiledOpts.html ??= '';
|
|
733
|
+
compiledOpts.text ??= '';
|
|
734
|
+
compiledOpts.subject ??= '';
|
|
735
|
+
|
|
736
|
+
const mailOptionsList = task.mailOptions || [];
|
|
737
|
+
const isMulti = mailOptionsList.length > 1;
|
|
738
|
+
|
|
739
|
+
for (let i = 0; i < mailOptionsList.length; i++) {
|
|
740
|
+
const mailOption = { ...mailOptionsList[i] };
|
|
741
|
+
|
|
742
|
+
if (mailOption.html) {
|
|
743
|
+
const rendered = this.___render(mailOption.html, mailOption);
|
|
744
|
+
if (isMulti) {
|
|
745
|
+
compiledOpts.html += this.___render(this.concatDelimiter, mailOption) + rendered;
|
|
746
|
+
} else {
|
|
747
|
+
compiledOpts.html = rendered;
|
|
748
|
+
}
|
|
749
|
+
delete mailOption.html;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (mailOption.text) {
|
|
753
|
+
const rendered = this.___render(mailOption.text, mailOption);
|
|
754
|
+
if (isMulti) {
|
|
755
|
+
compiledOpts.text += '\r\n' + rendered;
|
|
756
|
+
} else {
|
|
757
|
+
compiledOpts.text = rendered;
|
|
758
|
+
}
|
|
759
|
+
delete mailOption.text;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
compiledOpts = deepMerge(compiledOpts, mailOption);
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
if (compiledOpts.html && (task.template || this.template)) {
|
|
766
|
+
compiledOpts.html = this.___render((task.template || this.template), compiledOpts);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
if (isMulti) {
|
|
770
|
+
const rawSubject = task.concatSubject || this.concatSubject || compiledOpts.subject;
|
|
771
|
+
compiledOpts.subject = this.___render(rawSubject, { count: mailOptionsList.length });
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
if (!compiledOpts.from && this.from) {
|
|
775
|
+
compiledOpts.from = this.from(transport);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const acceptedSet = collectAcceptedSet(task);
|
|
779
|
+
if (acceptedSet.size > 0) {
|
|
780
|
+
compiledOpts.to = filterAddressField(compiledOpts.to, acceptedSet);
|
|
781
|
+
compiledOpts.cc = filterAddressField(compiledOpts.cc, acceptedSet);
|
|
782
|
+
compiledOpts.bcc = filterAddressField(compiledOpts.bcc, acceptedSet);
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
return compiledOpts;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* @internal
|
|
790
|
+
* @memberOf MailTime
|
|
791
|
+
* @name ___trackAcceptedRecipients
|
|
792
|
+
* @description Append newly-accepted addresses to each mailOption's `accepted` list
|
|
793
|
+
* @param {MailTimeTask} task - Email task record from Storage
|
|
794
|
+
* @param {string[]} acceptedAddrs - Lowercased addresses confirmed by transport
|
|
795
|
+
* @returns {void}
|
|
796
|
+
*/
|
|
797
|
+
___trackAcceptedRecipients(task, acceptedAddrs) {
|
|
798
|
+
if (!Array.isArray(task?.mailOptions) || acceptedAddrs.length === 0) {
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
const acceptedSet = new Set(acceptedAddrs);
|
|
802
|
+
for (const mo of task.mailOptions) {
|
|
803
|
+
if (!Array.isArray(mo.accepted)) {
|
|
804
|
+
mo.accepted = [];
|
|
805
|
+
}
|
|
806
|
+
const moRecipients = new Set(mailOptionRecipients(mo));
|
|
807
|
+
if (moRecipients.size === 0) {
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
const alreadyAccepted = new Set(mo.accepted.map((a) => typeof a === 'string' ? a.toLowerCase() : a));
|
|
811
|
+
for (const addr of acceptedSet) {
|
|
812
|
+
if (moRecipients.has(addr) && !alreadyAccepted.has(addr)) {
|
|
813
|
+
mo.accepted.push(addr);
|
|
814
|
+
alreadyAccepted.add(addr);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* @internal
|
|
822
|
+
* @memberOf MailTime
|
|
823
|
+
* @name ___finalizeRejected
|
|
824
|
+
* @description Populate each mailOption's `rejected` list with addresses that never delivered
|
|
825
|
+
* @param {MailTimeTask} task - Email task record from Storage
|
|
826
|
+
* @param {object} info - Info object from the last nodemailer attempt
|
|
827
|
+
* @returns {void}
|
|
828
|
+
*/
|
|
829
|
+
___finalizeRejected(task, info) {
|
|
830
|
+
if (!Array.isArray(task?.mailOptions)) {
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
const errorMap = buildRejectionErrorMap(info);
|
|
834
|
+
for (const mo of task.mailOptions) {
|
|
835
|
+
const moAccepted = new Set((mo.accepted || []).map((a) => typeof a === 'string' ? a.toLowerCase() : a));
|
|
836
|
+
const seen = new Set();
|
|
837
|
+
const rejected = [];
|
|
838
|
+
for (const addr of mailOptionRecipients(mo)) {
|
|
839
|
+
if (moAccepted.has(addr) || seen.has(addr)) {
|
|
840
|
+
continue;
|
|
841
|
+
}
|
|
842
|
+
seen.add(addr);
|
|
843
|
+
rejected.push({
|
|
844
|
+
address: addr,
|
|
845
|
+
error: errorMap.get(addr) || 'Recipient rejected by transport',
|
|
846
|
+
});
|
|
847
|
+
}
|
|
848
|
+
mo.rejected = rejected;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* @async
|
|
854
|
+
* @internal
|
|
855
|
+
* @memberOf MailTime
|
|
856
|
+
* @name ___dispatch
|
|
857
|
+
* @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.
|
|
858
|
+
* @param {MailTimeTask} task - email's task object from Storage
|
|
859
|
+
* @returns {Promise<void 0>}
|
|
860
|
+
*/
|
|
861
|
+
async ___dispatch(task) {
|
|
862
|
+
if (this.__isDestroyed) {
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
if (!task || task.isSent === true || task.isFailed === true || task.isCancelled === true) {
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
if (this.__inFlight.has(task.uuid)) {
|
|
869
|
+
this.__debug('[private dispatch] already in-flight on this instance, skipping', task.uuid);
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
this.__inFlight.add(task.uuid);
|
|
873
|
+
await this.__pool.dispatch(async () => {
|
|
874
|
+
try {
|
|
875
|
+
if (this.__isDestroyed) {
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
878
|
+
await this.___send(task);
|
|
879
|
+
} finally {
|
|
880
|
+
this.__inFlight.delete(task.uuid);
|
|
881
|
+
}
|
|
882
|
+
});
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* @async
|
|
887
|
+
* @internal
|
|
888
|
+
* @memberOf MailTime
|
|
889
|
+
* @name ___send
|
|
890
|
+
* @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.
|
|
891
|
+
* @param {MailTimeTask} task - email's task object from Storage
|
|
892
|
+
* @returns {Promise<void 0>}
|
|
893
|
+
*/
|
|
894
|
+
async ___send(task) {
|
|
895
|
+
this.__debug('[private send]', task);
|
|
896
|
+
try {
|
|
897
|
+
if (!task || task.isSent === true || task.isFailed === true || task.isCancelled === true) {
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const tries = task.tries + 1;
|
|
902
|
+
const sendingAt = Date.now();
|
|
903
|
+
let isClaimed = false;
|
|
904
|
+
try {
|
|
905
|
+
isClaimed = await this.queue.update(task, {
|
|
906
|
+
isSending: true,
|
|
907
|
+
sendingAt,
|
|
908
|
+
tries,
|
|
909
|
+
});
|
|
910
|
+
} catch (claimError) {
|
|
911
|
+
logError('[private send] [claim] storage error during atomic claim', claimError);
|
|
912
|
+
return;
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
if (!isClaimed) {
|
|
916
|
+
this.__debug('[private send] [queue.update] Stale claim, skipping', task.uuid);
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
task.tries = tries;
|
|
921
|
+
task.isSending = true;
|
|
922
|
+
task.sendingAt = sendingAt;
|
|
923
|
+
|
|
924
|
+
let transportIndex = task.transport;
|
|
925
|
+
if (!this.___isHealthyTransport(transportIndex)) {
|
|
926
|
+
transportIndex = this.___nextHealthyTransport(transportIndex);
|
|
927
|
+
task.transport = transportIndex;
|
|
928
|
+
}
|
|
929
|
+
const transport = this.transports[transportIndex];
|
|
930
|
+
|
|
931
|
+
const compiledOpts = this.___compileMailOpts(transport, task);
|
|
932
|
+
|
|
933
|
+
await new Promise((resolve) => {
|
|
934
|
+
transport.sendMail(compiledOpts, async (error, info) => {
|
|
935
|
+
this.__debug('[private send] [sending]', { error, info });
|
|
936
|
+
try {
|
|
937
|
+
if (error) {
|
|
938
|
+
await this.___handleError(task, error, info);
|
|
939
|
+
return;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
const acceptedAddrs = Array.isArray(info?.accepted)
|
|
943
|
+
? info.accepted.map(extractEmail).filter((addr) => typeof addr === 'string')
|
|
944
|
+
: [];
|
|
945
|
+
|
|
946
|
+
if (acceptedAddrs.length === 0) {
|
|
947
|
+
await this.___handleError(task, new Error('Message not accepted or Greeting never received'), info);
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
this.___trackAcceptedRecipients(task, acceptedAddrs);
|
|
952
|
+
|
|
953
|
+
const allRecipients = collectAllRecipients(task);
|
|
954
|
+
const allAccepted = collectAcceptedSet(task);
|
|
955
|
+
let isFullyDelivered = true;
|
|
956
|
+
for (const addr of allRecipients) {
|
|
957
|
+
if (!allAccepted.has(addr)) {
|
|
958
|
+
isFullyDelivered = false;
|
|
959
|
+
break;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
if (isFullyDelivered) {
|
|
964
|
+
this.__debug(`email successfully sent, attempts: #${task.tries}, transport #${transportIndex} to: `, compiledOpts.to);
|
|
965
|
+
|
|
966
|
+
const leaseRemove = __leaseRemoveOpts(task);
|
|
967
|
+
const leaseUpdate = __withLease(task, {
|
|
968
|
+
isSent: true,
|
|
969
|
+
isSending: false,
|
|
970
|
+
sendingAt: 0,
|
|
971
|
+
mailOptions: task.mailOptions,
|
|
972
|
+
});
|
|
973
|
+
|
|
974
|
+
let completed = false;
|
|
975
|
+
if (!this.keepHistory) {
|
|
976
|
+
completed = await this.queue.remove(task, leaseRemove);
|
|
977
|
+
} else {
|
|
978
|
+
completed = await this.queue.update(task, leaseUpdate);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
if (!completed) {
|
|
982
|
+
this.__debug('[private send] lease lost before success completion, skipping onSent', task.uuid);
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// Persist first, then mirror to in-memory task — a superseded worker must not lie to onSent.
|
|
987
|
+
task.isSent = true;
|
|
988
|
+
task.isSending = false;
|
|
989
|
+
task.sendingAt = 0;
|
|
990
|
+
this.onSent(task, info);
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
if (task.tries >= this.maxTries) {
|
|
995
|
+
this.___finalizeRejected(task, info);
|
|
996
|
+
|
|
997
|
+
const leaseRemove = __leaseRemoveOpts(task);
|
|
998
|
+
const leaseUpdate = __withLease(task, {
|
|
999
|
+
isSent: false,
|
|
1000
|
+
isFailed: true,
|
|
1001
|
+
isSending: false,
|
|
1002
|
+
sendingAt: 0,
|
|
1003
|
+
mailOptions: task.mailOptions,
|
|
1004
|
+
});
|
|
1005
|
+
|
|
1006
|
+
let finalized = false;
|
|
1007
|
+
if (!this.keepHistory) {
|
|
1008
|
+
finalized = await this.queue.remove(task, leaseRemove);
|
|
1009
|
+
} else {
|
|
1010
|
+
finalized = await this.queue.update(task, leaseUpdate);
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
if (!finalized) {
|
|
1014
|
+
this.__debug('[private send] lease lost before partial-failure completion, skipping onError', task.uuid);
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// Persist first, then mirror to in-memory task — a superseded worker must not lie to onError.
|
|
1019
|
+
task.isSent = false;
|
|
1020
|
+
task.isFailed = true;
|
|
1021
|
+
task.isSending = false;
|
|
1022
|
+
|
|
1023
|
+
const rejectedAddrs = [];
|
|
1024
|
+
for (const mo of task.mailOptions) {
|
|
1025
|
+
for (const r of (mo.rejected || [])) {
|
|
1026
|
+
rejectedAddrs.push(r.address);
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
const partialError = new Error(`Recipients rejected after ${task.tries} attempts: ${rejectedAddrs.join(', ')}`);
|
|
1030
|
+
this.__debug('[private send] Partial delivery exhausted retries; rejected: ', rejectedAddrs);
|
|
1031
|
+
this.onError(partialError, task, info);
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const nextSendAt = Date.now() + this.retryDelay;
|
|
1036
|
+
const released = await this.queue.update(task, __withLease(task, {
|
|
1037
|
+
isSending: false,
|
|
1038
|
+
sendingAt: 0,
|
|
1039
|
+
sendAt: nextSendAt,
|
|
1040
|
+
mailOptions: task.mailOptions,
|
|
1041
|
+
}));
|
|
1042
|
+
if (!released) {
|
|
1043
|
+
this.__debug('[private send] lease lost before partial retry release', task.uuid);
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
this.__debug(`[private send] Partial delivery, next attempt at ${new Date(nextSendAt)}: #${task.tries}/${this.maxTries} for remaining recipients`);
|
|
1047
|
+
} finally {
|
|
1048
|
+
resolve();
|
|
1049
|
+
}
|
|
1050
|
+
});
|
|
650
1051
|
});
|
|
1052
|
+
} catch (e) {
|
|
1053
|
+
logError('Exception during runtime:', e);
|
|
1054
|
+
await this.___handleError(task, e, {});
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* @internal
|
|
1060
|
+
* @memberOf MailTime
|
|
1061
|
+
* @name ___iterate
|
|
1062
|
+
* @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.
|
|
1063
|
+
* @returns {Promise<void>|void}
|
|
1064
|
+
*/
|
|
1065
|
+
async ___iterate() {
|
|
1066
|
+
this.__debug('[private iterate]');
|
|
1067
|
+
if (this.__isDestroyed) {
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
const limit = this.mode === 'one' ? 1 : Infinity;
|
|
1071
|
+
return await this.queue.iterate({
|
|
1072
|
+
limit,
|
|
1073
|
+
sendingTimeout: this.sendingTimeout,
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/**
|
|
1078
|
+
* @async
|
|
1079
|
+
* @internal
|
|
1080
|
+
* @memberOf MailTime
|
|
1081
|
+
* @name ___ready
|
|
1082
|
+
* @description Internal storage readiness gate
|
|
1083
|
+
* @returns {Promise<MailTime>}
|
|
1084
|
+
*/
|
|
1085
|
+
async ___ready() {
|
|
1086
|
+
if (typeof this.queue.ready === 'function') {
|
|
1087
|
+
await this.queue.ready();
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
if (this.__schedulerTimer) {
|
|
1091
|
+
await this.__schedulerTimer;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
const pingResult = await this.ping();
|
|
1095
|
+
if (pingResult.status !== 'OK') {
|
|
1096
|
+
throw new Error('[mail-time] [MailTime#ready] can not connect to storage, make sure it is available and properly configured', { cause: pingResult.error });
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
if (this.type === 'server' && this.verifyTransports && this.transports.length > 0) {
|
|
1100
|
+
await this.___verifyTransports();
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
return this;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/**
|
|
1107
|
+
* @async
|
|
1108
|
+
* @internal
|
|
1109
|
+
* @memberOf MailTime
|
|
1110
|
+
* @name ___verifyTransports
|
|
1111
|
+
* @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.
|
|
1112
|
+
* @returns {Promise<void>}
|
|
1113
|
+
*/
|
|
1114
|
+
async ___verifyTransports() {
|
|
1115
|
+
this.__debug('[private verifyTransports]');
|
|
1116
|
+
const results = await Promise.all(this.transports.map(async (transport, index) => {
|
|
1117
|
+
if (!transport || typeof transport.verify !== 'function') {
|
|
1118
|
+
return { index, ok: true };
|
|
1119
|
+
}
|
|
1120
|
+
try {
|
|
1121
|
+
await Promise.resolve(transport.verify());
|
|
1122
|
+
return { index, ok: true };
|
|
1123
|
+
} catch (error) {
|
|
1124
|
+
return { index, ok: false, error };
|
|
1125
|
+
}
|
|
1126
|
+
}));
|
|
1127
|
+
|
|
1128
|
+
for (const r of results) {
|
|
1129
|
+
if (r.ok) {
|
|
1130
|
+
continue;
|
|
1131
|
+
}
|
|
1132
|
+
this.__unhealthyTransports.add(r.index);
|
|
1133
|
+
logError(`[mail-time] [verifyTransports] transport #${r.index} failed verification`, r.error);
|
|
1134
|
+
this.onError(r.error, null, { transportIndex: r.index, phase: 'verify' });
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
if (this.__unhealthyTransports.size === this.transports.length) {
|
|
1138
|
+
throw new Error(`[mail-time] [MailTime#ready] all ${this.transports.length} transport(s) failed verification — nothing can be delivered`);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
if (this.__unhealthyTransports.has(this.transport)) {
|
|
1142
|
+
this.transport = this.___nextHealthyTransport(this.transport);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* @internal
|
|
1148
|
+
* @memberOf MailTime
|
|
1149
|
+
* @name ___isHealthyTransport
|
|
1150
|
+
* @description Return true when the transport at `index` has not been marked unusable by verification.
|
|
1151
|
+
* @param {number} index
|
|
1152
|
+
* @returns {boolean}
|
|
1153
|
+
*/
|
|
1154
|
+
___isHealthyTransport(index) {
|
|
1155
|
+
return !this.__unhealthyTransports.has(index);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* @internal
|
|
1160
|
+
* @memberOf MailTime
|
|
1161
|
+
* @name ___nextHealthyTransport
|
|
1162
|
+
* @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.
|
|
1163
|
+
* @param {number} fromIdx
|
|
1164
|
+
* @returns {number}
|
|
1165
|
+
*/
|
|
1166
|
+
___nextHealthyTransport(fromIdx) {
|
|
1167
|
+
if (this.transports.length === 0) {
|
|
1168
|
+
return fromIdx;
|
|
651
1169
|
}
|
|
652
|
-
|
|
1170
|
+
let next = fromIdx;
|
|
1171
|
+
for (let i = 0; i < this.transports.length; i++) {
|
|
1172
|
+
next = (next + 1) % this.transports.length;
|
|
1173
|
+
if (this.___isHealthyTransport(next)) {
|
|
1174
|
+
return next;
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
return fromIdx;
|
|
653
1178
|
}
|
|
654
1179
|
}
|
|
1180
|
+
|
|
1181
|
+
export { MailTime, MongoQueue, RedisQueue, PostgresQueue, mailTimePreset, presets, presetNames };
|