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