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.
@@ -0,0 +1,118 @@
1
+ export type MongoCollection = {
2
+ collectionName?: string | undefined;
3
+ createIndex: (keys: object, opts?: object) => Promise<unknown>;
4
+ indexes: () => Promise<{
5
+ name: string;
6
+ key: Record<string, unknown>;
7
+ }[]>;
8
+ dropIndex: (name: string) => Promise<unknown>;
9
+ find: (query: object, opts?: object) => unknown;
10
+ findOne: (query: object, opts?: object) => Promise<object | null>;
11
+ insertOne: (doc: object) => Promise<unknown>;
12
+ deleteOne: (query: object) => Promise<{
13
+ deletedCount?: number;
14
+ }>;
15
+ updateOne: (query: object, update: object) => Promise<{
16
+ modifiedCount?: number;
17
+ }>;
18
+ };
19
+ export type Db = {
20
+ collection: (name: string) => MongoCollection;
21
+ command: (cmd: object) => Promise<{
22
+ ok?: number;
23
+ }>;
24
+ };
25
+ export type MongoQueueOption = {
26
+ db: Db;
27
+ prefix?: string | undefined;
28
+ };
29
+ /** Class representing MongoDB Queue for MailTime */
30
+ export class MongoQueue {
31
+ /**
32
+ * Create a MongoQueue instance
33
+ * @param {MongoQueueOption} opts - configuration object
34
+ */
35
+ constructor(opts: MongoQueueOption);
36
+ name: string;
37
+ db: Db;
38
+ prefix: any;
39
+ collection: MongoCollection | undefined;
40
+ /**
41
+ * @async
42
+ * @memberOf MongoQueue
43
+ * @name ready
44
+ * @description Wait until indexes are created
45
+ * @returns {Promise<void 0>}
46
+ */
47
+ ready(): Promise<void>;
48
+ /**
49
+ * @async
50
+ * @memberOf MongoQueue
51
+ * @name ping
52
+ * @description Check connection to Storage
53
+ * @returns {Promise<object>}
54
+ */
55
+ ping(): Promise<object>;
56
+ /**
57
+ * @memberOf MongoQueue
58
+ * @name iterate
59
+ * @description iterate over queued tasks passing each to `mailTimeInstance.___dispatch` (the bounded send pool)
60
+ * @param {{ limit?: number, sendingTimeout?: number }} [opts] - iteration options
61
+ * @returns {Promise<void>}
62
+ */
63
+ iterate(opts?: {
64
+ limit?: number;
65
+ sendingTimeout?: number;
66
+ }): Promise<void>;
67
+ /**
68
+ * @async
69
+ * @memberOf MongoQueue
70
+ * @name getPendingTo
71
+ * @description get queued task by `to` field (addressee)
72
+ * @param to {string} - email address
73
+ * @param sendAt {number} - timestamp
74
+ * @returns {Promise<object|null>}
75
+ */
76
+ getPendingTo(to: string, sendAt: number): Promise<object | null>;
77
+ /**
78
+ * @async
79
+ * @memberOf MongoQueue
80
+ * @name push
81
+ * @description push task to the queue/storage
82
+ * @param task {object} - task's object
83
+ * @returns {Promise<void 0>}
84
+ */
85
+ push(task: object): Promise<void>;
86
+ /**
87
+ * @async
88
+ * @memberOf MongoQueue
89
+ * @name cancel
90
+ * @description cancel scheduled email
91
+ * @param uuid {string} - email's uuid
92
+ * @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found, was sent, or was cancelled previously
93
+ */
94
+ cancel(uuid: string): Promise<boolean>;
95
+ /**
96
+ * @async
97
+ * @memberOf MongoQueue
98
+ * @name remove
99
+ * @description remove task from queue
100
+ * @param task {object} - task's object
101
+ * @param {{ leaseTries: number, leaseSendingAt: number }} [opts] - lease guard: only remove if this worker still holds the lease (tries + sendingAt match, row not cancelled/failed)
102
+ * @returns {Promise<boolean>} returns `true` if removed or `false` if not found
103
+ */
104
+ remove(task: object, opts?: {
105
+ leaseTries: number;
106
+ leaseSendingAt: number;
107
+ }): Promise<boolean>;
108
+ /**
109
+ * @async
110
+ * @memberOf MongoQueue
111
+ * @name update
112
+ * @description update task in queue
113
+ * @param task {object} - task's object
114
+ * @param updateObj {object} - fields with new values to update
115
+ * @returns {Promise<boolean>} returns `true` if updated or `false` if not found or no changes was made
116
+ */
117
+ update(task: object, updateObj: object): Promise<boolean>;
118
+ }
@@ -0,0 +1,431 @@
1
+ import {
2
+ debug,
3
+ logError,
4
+ isSendClaimUpdate,
5
+ isSendLeaseGuardedUpdate,
6
+ isAppendMailOptionUpdate,
7
+ stripInternalUpdateMeta,
8
+ isSendLeaseRemove,
9
+ } from '../helpers.js';
10
+
11
+ const DEFAULT_PREFIX = '';
12
+
13
+ /**
14
+ * @typedef {object} MongoCollection
15
+ * @property {string} [collectionName]
16
+ * @property {(keys: object, opts?: object) => Promise<unknown>} createIndex
17
+ * @property {() => Promise<{ name: string, key: Record<string, unknown> }[]>} indexes
18
+ * @property {(name: string) => Promise<unknown>} dropIndex
19
+ * @property {(query: object, opts?: object) => unknown} find
20
+ * @property {(query: object, opts?: object) => Promise<object|null>} findOne
21
+ * @property {(doc: object) => Promise<unknown>} insertOne
22
+ * @property {(query: object) => Promise<{ deletedCount?: number }>} deleteOne
23
+ * @property {(query: object, update: object) => Promise<{ modifiedCount?: number }>} updateOne
24
+ */
25
+
26
+ /**
27
+ * @typedef {object} Db
28
+ * @property {(name: string) => MongoCollection} collection
29
+ * @property {(cmd: object) => Promise<{ ok?: number }>} command
30
+ */
31
+
32
+ /**
33
+ * @typedef {object} MongoQueueOption
34
+ * @property {Db} db
35
+ * @property {string} [prefix]
36
+ */
37
+
38
+ /** @internal */
39
+ const ensureIndex = async (collection, keys, opts) => {
40
+ try {
41
+ await collection.createIndex(keys, opts);
42
+ } catch (e) {
43
+ if (e?.code === 85) {
44
+ let indexName;
45
+ const indexes = await collection.indexes();
46
+ const keyNames = Object.keys(keys);
47
+ for (const index of indexes) {
48
+ const indexKeys = Object.keys(index.key);
49
+ if (indexKeys.length !== keyNames.length) {
50
+ continue;
51
+ }
52
+ let match = true;
53
+ for (const k of keyNames) {
54
+ if (typeof index.key[k] === 'undefined') {
55
+ match = false;
56
+ break;
57
+ }
58
+ }
59
+ if (match) {
60
+ indexName = index.name;
61
+ break;
62
+ }
63
+ }
64
+
65
+ if (indexName) {
66
+ await collection.dropIndex(indexName);
67
+ await collection.createIndex(keys, opts);
68
+ }
69
+ } else {
70
+ logError(`[ensureIndex] Can not set ${Object.keys(keys).join(' + ')} index on "${collection?.collectionName || 'MongoDB'}" collection`, { keys, opts, details: e });
71
+ }
72
+ }
73
+ };
74
+
75
+ /** Class representing MongoDB Queue for MailTime */
76
+ class MongoQueue {
77
+ /**
78
+ * Create a MongoQueue instance
79
+ * @param {MongoQueueOption} opts - configuration object
80
+ */
81
+ constructor (opts) {
82
+ this.name = 'mongo-queue';
83
+ if (!opts || typeof opts !== 'object') {
84
+ throw new TypeError('[mail-time] Configuration object must be passed into MongoQueue constructor');
85
+ }
86
+
87
+ if (!opts.db) {
88
+ throw new Error('[mail-time] [MongoQueue] requires MongoDB database {db} option, like returned from `MongoClient#db()`');
89
+ }
90
+
91
+ this.db = opts.db;
92
+ if (typeof opts.prefix === 'string') {
93
+ this.__applyPrefix(opts.prefix);
94
+ }
95
+ }
96
+
97
+ /** @internal */
98
+ __applyPrefix(prefix) {
99
+ this.prefix = prefix;
100
+ this.collection = this.db.collection(`__mailTimeQueue__${prefix}`);
101
+ this.__readyPromise = Promise.all([
102
+ ensureIndex(this.collection, { uuid: 1 }, { background: false }),
103
+ ensureIndex(this.collection, { isSent: 1, isFailed: 1, isCancelled: 1, to: 1, sendAt: 1 }, { background: false }),
104
+ ensureIndex(this.collection, { isSent: 1, isFailed: 1, isCancelled: 1, isSending: 1, sendingAt: 1, sendAt: 1, tries: 1 }, { background: false }),
105
+ ]).then(() => void 0);
106
+ }
107
+
108
+ /** @internal */
109
+ __ensurePrefix() {
110
+ if (typeof this.prefix !== 'string') {
111
+ this.__applyPrefix(this.mailTimeInstance?.prefix || DEFAULT_PREFIX);
112
+ }
113
+ }
114
+
115
+ /** @internal */
116
+ __debug(...args) {
117
+ debug(this.mailTimeInstance?.debug === true, `[${this.name}]`, ...args);
118
+ }
119
+
120
+ /**
121
+ * @async
122
+ * @memberOf MongoQueue
123
+ * @name ready
124
+ * @description Wait until indexes are created
125
+ * @returns {Promise<void 0>}
126
+ */
127
+ async ready() {
128
+ this.__ensurePrefix();
129
+ this.__debug('[ready]');
130
+ await this.__readyPromise;
131
+ }
132
+
133
+ /**
134
+ * @async
135
+ * @memberOf MongoQueue
136
+ * @name ping
137
+ * @description Check connection to Storage
138
+ * @returns {Promise<object>}
139
+ */
140
+ async ping() {
141
+ this.__debug('[ping]');
142
+ if (!this.mailTimeInstance) {
143
+ return {
144
+ status: 'Service Unavailable',
145
+ code: 503,
146
+ statusCode: 503,
147
+ error: new Error('MailTime instance not yet assigned to {mailTimeInstance} of Queue Adapter context'),
148
+ };
149
+ }
150
+
151
+ try {
152
+ const ping = await this.db.command({ ping: 1 });
153
+ if (ping?.ok === 1) {
154
+ return {
155
+ status: 'OK',
156
+ code: 200,
157
+ statusCode: 200,
158
+ };
159
+ }
160
+ } catch (pingError) {
161
+ return {
162
+ status: 'Internal Server Error',
163
+ code: 500,
164
+ statusCode: 500,
165
+ error: pingError,
166
+ };
167
+ }
168
+
169
+ return {
170
+ status: 'Service Unavailable',
171
+ code: 503,
172
+ statusCode: 503,
173
+ error: new Error('Service Unavailable'),
174
+ };
175
+ }
176
+
177
+ /**
178
+ * @memberOf MongoQueue
179
+ * @name iterate
180
+ * @description iterate over queued tasks passing each to `mailTimeInstance.___dispatch` (the bounded send pool)
181
+ * @param {{ limit?: number, sendingTimeout?: number }} [opts] - iteration options
182
+ * @returns {Promise<void>}
183
+ */
184
+ async iterate(opts) {
185
+ this.__debug('[iterate]', opts);
186
+ this.__ensurePrefix();
187
+ const now = Date.now();
188
+ const sendingTimeout = (opts && typeof opts.sendingTimeout === 'number' && opts.sendingTimeout > 0)
189
+ ? opts.sendingTimeout
190
+ : 300000;
191
+ const limit = (opts && typeof opts.limit === 'number' && Number.isFinite(opts.limit) && opts.limit > 0)
192
+ ? Math.floor(opts.limit)
193
+ : 0;
194
+
195
+ try {
196
+ const cursor = this.collection.find({
197
+ isSent: false,
198
+ isFailed: false,
199
+ isCancelled: false,
200
+ sendAt: {
201
+ $lte: now,
202
+ },
203
+ tries: {
204
+ $lt: this.mailTimeInstance.maxTries,
205
+ },
206
+ $or: [
207
+ { isSending: { $ne: true } },
208
+ { sendingAt: { $lte: now - sendingTimeout } },
209
+ ],
210
+ }, {
211
+ projection: {
212
+ _id: 1,
213
+ uuid: 1,
214
+ tries: 1,
215
+ template: 1,
216
+ transport: 1,
217
+ isSent: 1,
218
+ isFailed: 1,
219
+ isCancelled: 1,
220
+ isSending: 1,
221
+ sendingAt: 1,
222
+ mailOptions: 1,
223
+ concatSubject: 1,
224
+ },
225
+ });
226
+
227
+ if (limit > 0) {
228
+ cursor.limit(limit);
229
+ }
230
+
231
+ while (await cursor.hasNext()) {
232
+ await this.mailTimeInstance.___dispatch(await cursor.next());
233
+ }
234
+ await cursor.close();
235
+ } catch (iterateError) {
236
+ logError('[iterate] [while/await] [iterateError]', iterateError);
237
+ }
238
+ }
239
+
240
+ /**
241
+ * @async
242
+ * @memberOf MongoQueue
243
+ * @name getPendingTo
244
+ * @description get queued task by `to` field (addressee)
245
+ * @param to {string} - email address
246
+ * @param sendAt {number} - timestamp
247
+ * @returns {Promise<object|null>}
248
+ */
249
+ async getPendingTo(to, sendAt) {
250
+ this.__debug('[getPendingTo]', to, sendAt);
251
+ if (typeof to !== 'string' || typeof sendAt !== 'number') {
252
+ return null;
253
+ }
254
+ this.__ensurePrefix();
255
+
256
+ return await this.collection.findOne({
257
+ to,
258
+ isSent: false,
259
+ isFailed: false,
260
+ isCancelled: false,
261
+ isSending: { $ne: true },
262
+ tries: { $lt: this.mailTimeInstance.maxTries },
263
+ sendAt: {
264
+ $lte: sendAt,
265
+ },
266
+ }, {
267
+ projection: {
268
+ _id: 1,
269
+ to: 1,
270
+ uuid: 1,
271
+ tries: 1,
272
+ isSent: 1,
273
+ isFailed: 1,
274
+ isCancelled: 1,
275
+ mailOptions: 1,
276
+ },
277
+ });
278
+ }
279
+
280
+ /**
281
+ * @async
282
+ * @memberOf MongoQueue
283
+ * @name push
284
+ * @description push task to the queue/storage
285
+ * @param task {object} - task's object
286
+ * @returns {Promise<void 0>}
287
+ */
288
+ async push(task) {
289
+ this.__debug('[push]', task?.uuid);
290
+ if (!task || typeof task !== 'object') {
291
+ return;
292
+ }
293
+ this.__ensurePrefix();
294
+
295
+ if (task.sendAt instanceof Date) {
296
+ task.sendAt = +task.sendAt;
297
+ }
298
+ await this.collection.insertOne(task);
299
+ }
300
+
301
+ /**
302
+ * @async
303
+ * @memberOf MongoQueue
304
+ * @name cancel
305
+ * @description cancel scheduled email
306
+ * @param uuid {string} - email's uuid
307
+ * @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found, was sent, or was cancelled previously
308
+ */
309
+ async cancel(uuid) {
310
+ this.__debug('[cancel]', uuid);
311
+ if (typeof uuid !== 'string') {
312
+ return false;
313
+ }
314
+ this.__ensurePrefix();
315
+
316
+ const task = await this.collection.findOne({ uuid }, {
317
+ projection: {
318
+ _id: 1,
319
+ uuid: 1,
320
+ isSent: 1,
321
+ isCancelled: 1,
322
+ },
323
+ });
324
+
325
+ if (!task || task.isSent === true || task.isCancelled === true) {
326
+ return false;
327
+ }
328
+
329
+ if (!this.mailTimeInstance.keepHistory) {
330
+ return await this.remove(task);
331
+ }
332
+
333
+ return await this.update(task, {
334
+ isCancelled: true,
335
+ });
336
+ }
337
+
338
+ /**
339
+ * @async
340
+ * @memberOf MongoQueue
341
+ * @name remove
342
+ * @description remove task from queue
343
+ * @param task {object} - task's object
344
+ * @param {{ leaseTries: number, leaseSendingAt: number }} [opts] - lease guard: only remove if this worker still holds the lease (tries + sendingAt match, row not cancelled/failed)
345
+ * @returns {Promise<boolean>} returns `true` if removed or `false` if not found
346
+ */
347
+ async remove(task, opts) {
348
+ this.__debug('[remove]', task?.uuid);
349
+ if (!task || typeof task !== 'object') {
350
+ return false;
351
+ }
352
+ this.__ensurePrefix();
353
+
354
+ const query = { _id: task._id };
355
+ if (isSendLeaseRemove(opts)) {
356
+ query.tries = opts.leaseTries;
357
+ query.isSending = true;
358
+ query.sendingAt = opts.leaseSendingAt;
359
+ query.isCancelled = false;
360
+ query.isFailed = false;
361
+ }
362
+
363
+ const res = await this.collection.deleteOne(query);
364
+ return (res?.deletedCount || 0) >= 1;
365
+ }
366
+
367
+ /**
368
+ * @async
369
+ * @memberOf MongoQueue
370
+ * @name update
371
+ * @description update task in queue
372
+ * @param task {object} - task's object
373
+ * @param updateObj {object} - fields with new values to update
374
+ * @returns {Promise<boolean>} returns `true` if updated or `false` if not found or no changes was made
375
+ */
376
+ async update(task, updateObj) {
377
+ this.__debug('[update]', task?.uuid);
378
+ if (!task || typeof task !== 'object' || !updateObj || typeof updateObj !== 'object') {
379
+ return false;
380
+ }
381
+ this.__ensurePrefix();
382
+
383
+ if (isAppendMailOptionUpdate(updateObj)) {
384
+ const res = await this.collection.updateOne({
385
+ _id: task._id,
386
+ isSent: false,
387
+ isFailed: false,
388
+ isCancelled: false,
389
+ isSending: { $ne: true },
390
+ }, {
391
+ $push: {
392
+ mailOptions: updateObj.appendMailOption,
393
+ },
394
+ });
395
+ return (res?.modifiedCount || 0) >= 1;
396
+ }
397
+
398
+ const query = {
399
+ _id: task._id,
400
+ };
401
+
402
+ if (isSendClaimUpdate(updateObj)) {
403
+ const now = typeof updateObj.sendingAt === 'number' ? updateObj.sendingAt : Date.now();
404
+ const sendingTimeout = this.mailTimeInstance?.sendingTimeout || 300000;
405
+ query.isSent = false;
406
+ query.isFailed = false;
407
+ query.isCancelled = false;
408
+ query.tries = task.tries;
409
+ query.$or = [
410
+ { isSending: { $ne: true } },
411
+ { sendingAt: { $lte: now - sendingTimeout } },
412
+ ];
413
+ } else if (isSendLeaseGuardedUpdate(updateObj)) {
414
+ query.tries = updateObj.leaseTries;
415
+ query.isSending = true;
416
+ query.sendingAt = updateObj.leaseSendingAt;
417
+ query.isCancelled = false;
418
+ query.isFailed = false;
419
+ }
420
+
421
+ const res = await this.collection.updateOne(query, {
422
+ $set: stripInternalUpdateMeta(updateObj),
423
+ });
424
+ if (isSendClaimUpdate(updateObj)) {
425
+ return (res?.modifiedCount || res?.matchedCount || 0) >= 1;
426
+ }
427
+ return (res?.modifiedCount || 0) >= 1;
428
+ }
429
+ }
430
+
431
+ export { MongoQueue };
@@ -0,0 +1,100 @@
1
+ export type PostgresQueryResult = {
2
+ rowCount?: number | null | undefined;
3
+ rows?: unknown[] | undefined;
4
+ };
5
+ export type PostgresClient = {
6
+ query: (queryText: string, values?: unknown[]) => Promise<PostgresQueryResult>;
7
+ };
8
+ export type PostgresQueueOption = {
9
+ client: PostgresClient;
10
+ prefix?: string | undefined;
11
+ };
12
+ /** Class representing PostgreSQL Queue for MailTime */
13
+ export class PostgresQueue {
14
+ /**
15
+ * Create a PostgresQueue instance
16
+ * @param {PostgresQueueOption} opts - configuration object
17
+ */
18
+ constructor(opts: PostgresQueueOption);
19
+ name: string;
20
+ client: PostgresClient;
21
+ prefix: any;
22
+ /**
23
+ * @async
24
+ * @memberOf PostgresQueue
25
+ * @name ready
26
+ * @description Wait until PostgreSQL schema is ready
27
+ * @returns {Promise<void 0>}
28
+ */
29
+ ready(): Promise<void>;
30
+ /**
31
+ * @async
32
+ * @memberOf PostgresQueue
33
+ * @name ping
34
+ * @description Check connection to Storage
35
+ * @returns {Promise<object>}
36
+ */
37
+ ping(): Promise<object>;
38
+ /**
39
+ * @memberOf PostgresQueue
40
+ * @name iterate
41
+ * @description iterate over queued tasks passing each to `mailTimeInstance.___dispatch` (the bounded send pool). Postgres reads buffer the full result, so each tick is bounded by `opts.limit` (or 1000 when caller passes `Infinity` / no limit) to keep memory predictable; high-throughput deployments should shard prefixes.
42
+ * @param {{ limit?: number, sendingTimeout?: number }} [opts] - iteration options
43
+ * @returns {Promise<void>}
44
+ */
45
+ iterate(opts?: {
46
+ limit?: number;
47
+ sendingTimeout?: number;
48
+ }): Promise<void>;
49
+ /**
50
+ * @async
51
+ * @memberOf PostgresQueue
52
+ * @name getPendingTo
53
+ * @description get queued task by `to` field (addressee)
54
+ * @param to {string} - email address
55
+ * @param sendAt {number} - timestamp
56
+ * @returns {Promise<object|null>}
57
+ */
58
+ getPendingTo(to: string, sendAt: number): Promise<object | null>;
59
+ /**
60
+ * @async
61
+ * @memberOf PostgresQueue
62
+ * @name push
63
+ * @description push task to the queue/storage
64
+ * @param task {object} - task's object
65
+ * @returns {Promise<void 0>}
66
+ */
67
+ push(task: object): Promise<void>;
68
+ /**
69
+ * @async
70
+ * @memberOf PostgresQueue
71
+ * @name cancel
72
+ * @description cancel scheduled email
73
+ * @param uuid {string} - email's uuid
74
+ * @returns {Promise<boolean>} returns `true` if cancelled or `false` if not found, was sent, or was cancelled previously
75
+ */
76
+ cancel(uuid: string): Promise<boolean>;
77
+ /**
78
+ * @async
79
+ * @memberOf PostgresQueue
80
+ * @name remove
81
+ * @description remove task from queue
82
+ * @param task {object} - task's object
83
+ * @param {{ leaseTries: number, leaseSendingAt: number }} [opts] - lease guard: only remove if this worker still holds the lease (tries + sendingAt match, row not cancelled/failed)
84
+ * @returns {Promise<boolean>} returns `true` if removed or `false` if not found
85
+ */
86
+ remove(task: object, opts?: {
87
+ leaseTries: number;
88
+ leaseSendingAt: number;
89
+ }): Promise<boolean>;
90
+ /**
91
+ * @async
92
+ * @memberOf PostgresQueue
93
+ * @name update
94
+ * @description update task in queue
95
+ * @param task {object} - task's object
96
+ * @param updateObj {object} - fields with new values to update
97
+ * @returns {Promise<boolean>} returns `true` if updated or `false` if not found or no changes was made
98
+ */
99
+ update(task: object, updateObj: object): Promise<boolean>;
100
+ }