mail-time 4.1.0 → 5.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/adapters/redis.js CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  stripInternalUpdateMeta,
8
8
  isSendLeaseRemove,
9
9
  } from '../helpers.js';
10
+ import { createHash } from 'crypto';
10
11
 
11
12
  /**
12
13
  * @typedef {object} RedisClient
@@ -15,7 +16,11 @@ import {
15
16
  * @property {(key: string, value: string, options?: object) => Promise<unknown>} set
16
17
  * @property {(key: string|string[]) => Promise<number>} del
17
18
  * @property {() => Promise<string>} ping
18
- * @property {(options: object) => AsyncIterable<string|string[]>} scanIterator
19
+ * @property {(options: object) => AsyncIterable<string|string[]>} [scanIterator]
20
+ * @property {(key: string, field: string) => Promise<string|null>} [hGet]
21
+ * @property {(script: string, options: { keys: string[], arguments: string[] }) => Promise<unknown>} [eval]
22
+ * @property {(sha: string, options: { keys: string[], arguments: string[] }) => Promise<unknown>} [evalSha]
23
+ * @property {(script: string) => Promise<string>} [scriptLoad]
19
24
  * @property {(key: string) => Promise<unknown>} [watch]
20
25
  * @property {() => Promise<unknown>} [unwatch]
21
26
  * @property {() => object} [multi]
@@ -25,10 +30,168 @@ import {
25
30
  * @typedef {object} RedisQueueOption
26
31
  * @property {RedisClient} client
27
32
  * @property {string} [prefix]
33
+ * @property {boolean} [useHashTags] - Use Redis Cluster hash-tag keys (`mailtime:{prefix}:*`). Default keeps existing standalone keys.
28
34
  */
29
35
 
30
36
  const KEY_TYPES = new Set(['letter', 'sendat', 'concatletter']);
31
37
  const DEFAULT_PREFIX = 'default';
38
+ const VALID_PREFIX = /^[A-Za-z0-9_\-:.]+$/;
39
+ const TAGGED_ITERATE_LIMIT = 100;
40
+
41
+ const PUSH_TAGGED_TASK_SCRIPT = `
42
+ redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
43
+ redis.call('ZADD', KEYS[2], tonumber(ARGV[3]), ARGV[1])
44
+ if #KEYS > 3 then
45
+ redis.call('SET', KEYS[3], ARGV[1], 'PXAT', tonumber(ARGV[4]))
46
+ redis.call('ZADD', KEYS[4], tonumber(ARGV[4]), KEYS[3])
47
+ end
48
+ return 1
49
+ `;
50
+
51
+ const ITERATE_TAGGED_TASKS_SCRIPT = `
52
+ local now = tonumber(ARGV[1])
53
+ local maxTries = tonumber(ARGV[2])
54
+ local sendingTimeout = tonumber(ARGV[3])
55
+ local limit = tonumber(ARGV[4])
56
+ local scanLimit = tonumber(ARGV[5])
57
+ local tasks = {}
58
+ if #KEYS > 2 then
59
+ local expiredPointers = redis.call('ZRANGEBYSCORE', KEYS[3], '-inf', now, 'LIMIT', 0, 100)
60
+ for _, pointerKey in ipairs(expiredPointers) do
61
+ redis.call('DEL', pointerKey)
62
+ redis.call('ZREM', KEYS[3], pointerKey)
63
+ end
64
+ end
65
+ local due = redis.call('ZRANGEBYSCORE', KEYS[2], '-inf', now, 'LIMIT', 0, scanLimit)
66
+ for _, uuid in ipairs(due) do
67
+ local payload = redis.call('HGET', KEYS[1], uuid)
68
+ if not payload then
69
+ redis.call('ZREM', KEYS[2], uuid)
70
+ else
71
+ local task = cjson.decode(payload)
72
+ if task.isSent or task.isFailed or task.isCancelled or tonumber(task.tries or 0) >= maxTries then
73
+ redis.call('ZREM', KEYS[2], uuid)
74
+ elseif task.isSending then
75
+ local eligibleAt = tonumber(task.sendingAt or 0) + sendingTimeout
76
+ if eligibleAt > now then
77
+ redis.call('ZADD', KEYS[2], eligibleAt, uuid)
78
+ else
79
+ table.insert(tasks, task)
80
+ end
81
+ elseif tonumber(task.sendAt or 0) <= now then
82
+ table.insert(tasks, task)
83
+ else
84
+ redis.call('ZADD', KEYS[2], tonumber(task.sendAt), uuid)
85
+ end
86
+ if #tasks >= limit then
87
+ break
88
+ end
89
+ end
90
+ end
91
+
92
+ return cjson.encode(tasks)
93
+ `;
94
+
95
+ const UPDATE_TAGGED_TASK_SCRIPT = `
96
+ local payload = redis.call('HGET', KEYS[1], ARGV[1])
97
+ if not payload then
98
+ redis.call('ZREM', KEYS[2], ARGV[1])
99
+ return 0
100
+ end
101
+
102
+ local task = cjson.decode(payload)
103
+ local update = cjson.decode(ARGV[2])
104
+ local mode = ARGV[3]
105
+ local now = tonumber(ARGV[4])
106
+ local sendingTimeout = tonumber(ARGV[5])
107
+ local expectedTries = tonumber(ARGV[6])
108
+ local leaseSendingAt = tonumber(ARGV[7])
109
+
110
+ if mode == 'claim' then
111
+ if task.isSent or task.isFailed or task.isCancelled or tonumber(task.tries or 0) ~= expectedTries then
112
+ return 0
113
+ end
114
+ if task.isSending and tonumber(task.sendingAt or 0) > now - sendingTimeout then
115
+ return 0
116
+ end
117
+ elseif mode == 'lease' then
118
+ if task.isCancelled or task.isFailed or not task.isSending
119
+ or tonumber(task.tries or 0) ~= expectedTries
120
+ or tonumber(task.sendingAt or 0) ~= leaseSendingAt then
121
+ return 0
122
+ end
123
+ end
124
+
125
+ for key, value in pairs(update) do
126
+ task[key] = value
127
+ end
128
+ redis.call('HSET', KEYS[1], ARGV[1], cjson.encode(task))
129
+
130
+ if task.isSent or task.isFailed or task.isCancelled then
131
+ redis.call('ZREM', KEYS[2], ARGV[1])
132
+ if #KEYS > 3 then
133
+ local concatUuid = redis.call('GET', KEYS[3])
134
+ if concatUuid == ARGV[1] then
135
+ redis.call('DEL', KEYS[3])
136
+ redis.call('ZREM', KEYS[4], KEYS[3])
137
+ elseif not concatUuid then
138
+ redis.call('ZREM', KEYS[4], KEYS[3])
139
+ end
140
+ end
141
+ elseif task.isSending then
142
+ redis.call('ZADD', KEYS[2], tonumber(task.sendingAt or now) + sendingTimeout, ARGV[1])
143
+ else
144
+ redis.call('ZADD', KEYS[2], tonumber(task.sendAt), ARGV[1])
145
+ end
146
+ return 1
147
+ `;
148
+
149
+ const APPEND_TAGGED_MAIL_OPTION_SCRIPT = `
150
+ local payload = redis.call('HGET', KEYS[1], ARGV[1])
151
+ if not payload then
152
+ return 0
153
+ end
154
+ local task = cjson.decode(payload)
155
+ if task.isSending or task.isSent or task.isFailed or task.isCancelled then
156
+ return 0
157
+ end
158
+ task.mailOptions = task.mailOptions or {}
159
+ table.insert(task.mailOptions, cjson.decode(ARGV[2]))
160
+ redis.call('HSET', KEYS[1], ARGV[1], cjson.encode(task))
161
+ return 1
162
+ `;
163
+
164
+ const REMOVE_TAGGED_TASK_SCRIPT = `
165
+ local payload = redis.call('HGET', KEYS[1], ARGV[1])
166
+ if not payload then
167
+ redis.call('ZREM', KEYS[2], ARGV[1])
168
+ return 0
169
+ end
170
+ local task = cjson.decode(payload)
171
+ if ARGV[2] == 'lease' and (task.isCancelled or task.isFailed or not task.isSending
172
+ or tonumber(task.tries or 0) ~= tonumber(ARGV[3])
173
+ or tonumber(task.sendingAt or 0) ~= tonumber(ARGV[4])) then
174
+ return 0
175
+ end
176
+ redis.call('HDEL', KEYS[1], ARGV[1])
177
+ redis.call('ZREM', KEYS[2], ARGV[1])
178
+ if #KEYS > 3 then
179
+ local concatUuid = redis.call('GET', KEYS[3])
180
+ if concatUuid == ARGV[1] then
181
+ redis.call('DEL', KEYS[3])
182
+ redis.call('ZREM', KEYS[4], KEYS[3])
183
+ elseif not concatUuid then
184
+ redis.call('ZREM', KEYS[4], KEYS[3])
185
+ end
186
+ end
187
+ return 1
188
+ `;
189
+
190
+ const sha1Hex = (string) => createHash('sha1').update(string).digest('hex');
191
+
192
+ const isNoScriptError = (error) => {
193
+ return !!error && (error.code === 'NOSCRIPT' || (typeof error.message === 'string' && error.message.includes('NOSCRIPT')));
194
+ };
32
195
 
33
196
  const canReleaseLease = (currentTask, updateObj) => {
34
197
  return currentTask
@@ -102,7 +265,21 @@ class RedisQueue {
102
265
  throw new Error('[mail-time] [RedisQueue] required {client} option is missing, e.g. returned from `redis.createClient()` or `redis.createCluster()` method');
103
266
  }
104
267
 
268
+ if (opts.useHashTags !== undefined && typeof opts.useHashTags !== 'boolean') {
269
+ throw new TypeError(`[mail-time] [RedisQueue] {useHashTags} option must be a boolean (received: ${typeof opts.useHashTags})`);
270
+ }
271
+
105
272
  this.client = opts.client;
273
+ this.useHashTags = opts.useHashTags === true;
274
+ this.__scriptSources = {
275
+ push: PUSH_TAGGED_TASK_SCRIPT,
276
+ iterate: ITERATE_TAGGED_TASKS_SCRIPT,
277
+ update: UPDATE_TAGGED_TASK_SCRIPT,
278
+ append: APPEND_TAGGED_MAIL_OPTION_SCRIPT,
279
+ remove: REMOVE_TAGGED_TASK_SCRIPT,
280
+ };
281
+ this.__scriptShas = Object.fromEntries(Object.entries(this.__scriptSources).map(([name, source]) => [name, sha1Hex(source)]));
282
+ this.__loadedShas = new Set();
106
283
  if (typeof opts.prefix === 'string') {
107
284
  this.__applyPrefix(opts.prefix);
108
285
  }
@@ -110,8 +287,16 @@ class RedisQueue {
110
287
 
111
288
  /** @internal */
112
289
  __applyPrefix(prefix) {
290
+ if (this.useHashTags && !VALID_PREFIX.test(prefix)) {
291
+ throw new Error(`[mail-time] [RedisQueue] {prefix} option must match ${VALID_PREFIX} when {useHashTags} is true (received: "${prefix}")`);
292
+ }
113
293
  this.prefix = prefix;
114
- this.uniqueName = `mailtime:${prefix}`;
294
+ this.uniqueName = this.useHashTags ? `mailtime:{${prefix}}` : `mailtime:${prefix}`;
295
+ if (this.useHashTags) {
296
+ this.lettersKey = `${this.uniqueName}:letters`;
297
+ this.scheduleKey = `${this.uniqueName}:schedule`;
298
+ this.concatKeysKey = `${this.uniqueName}:concatkeys`;
299
+ }
115
300
  }
116
301
 
117
302
  /** @internal */
@@ -126,6 +311,49 @@ class RedisQueue {
126
311
  debug(this.mailTimeInstance?.debug === true, `[${this.name}]`, ...args);
127
312
  }
128
313
 
314
+ /** @internal */
315
+ async __runScript(scriptKey, options) {
316
+ const source = this.__scriptSources[scriptKey];
317
+ const sha = this.__scriptShas[scriptKey];
318
+ if (!source || !sha) {
319
+ throw new Error(`[mail-time] [RedisQueue] unknown script "${scriptKey}"`);
320
+ }
321
+
322
+ if (this.__loadedShas.has(sha) && typeof this.client.evalSha === 'function') {
323
+ try {
324
+ return await this.client.evalSha(sha, options);
325
+ } catch (error) {
326
+ if (!isNoScriptError(error)) {
327
+ throw error;
328
+ }
329
+ this.__loadedShas.delete(sha);
330
+ }
331
+ }
332
+
333
+ if (typeof this.client.scriptLoad === 'function' && typeof this.client.evalSha === 'function') {
334
+ try {
335
+ await this.client.scriptLoad(source);
336
+ this.__loadedShas.add(sha);
337
+ return await this.client.evalSha(sha, options);
338
+ } catch (error) {
339
+ if (!isNoScriptError(error)) {
340
+ this.__debug(`[script:${scriptKey}] scriptLoad failed; falling back to EVAL`, error);
341
+ }
342
+ }
343
+ }
344
+
345
+ if (typeof this.client.eval !== 'function') {
346
+ throw new Error('[mail-time] [RedisQueue] Redis Cluster client must support EVAL');
347
+ }
348
+ return await this.client.eval(source, options);
349
+ }
350
+
351
+ /** @internal */
352
+ __getTaggedConcatKey(to) {
353
+ this.__ensurePrefix();
354
+ return `${this.uniqueName}:concatletter:${to}`;
355
+ }
356
+
129
357
  /**
130
358
  * @async
131
359
  * @memberOf RedisQueue
@@ -203,6 +431,25 @@ class RedisQueue {
203
431
  const maxTries = (this.mailTimeInstance && typeof this.mailTimeInstance.maxTries === 'number')
204
432
  ? this.mailTimeInstance.maxTries
205
433
  : 60;
434
+
435
+ if (this.useHashTags) {
436
+ this.__ensurePrefix();
437
+ const dispatchLimit = Number.isFinite(limit) && limit > 0 ? Math.floor(limit) : TAGGED_ITERATE_LIMIT;
438
+ const payload = await this.__runScript('iterate', {
439
+ keys: [this.lettersKey, this.scheduleKey, this.concatKeysKey],
440
+ arguments: [`${now}`, `${maxTries}`, `${sendingTimeout}`, `${dispatchLimit}`, `${Math.max(dispatchLimit * 20, 100)}`],
441
+ });
442
+ const candidates = payload ? JSON.parse(String(payload)) : [];
443
+ if (!Array.isArray(candidates)) {
444
+ return;
445
+ }
446
+ for (const candidate of candidates) {
447
+ if (isIterateCandidate(candidate, now, sendingTimeout, maxTries)) {
448
+ await this.mailTimeInstance.___dispatch(candidate);
449
+ }
450
+ }
451
+ return;
452
+ }
206
453
  let dispatched = 0;
207
454
 
208
455
  const matchPattern = this.__getKey('*', 'sendat');
@@ -259,14 +506,15 @@ class RedisQueue {
259
506
  return null;
260
507
  }
261
508
 
262
- const concatKey = this.__getKey(to, 'concatletter');
509
+ const concatKey = this.useHashTags ? this.__getTaggedConcatKey(to) : this.__getKey(to, 'concatletter');
263
510
  const uuid = await this.client.get(concatKey);
264
511
  if (!uuid) {
265
512
  return null;
266
513
  }
267
514
 
268
- const letterKey = this.__getKey(uuid, 'letter');
269
- const taskJSON = await this.client.get(letterKey);
515
+ const taskJSON = this.useHashTags
516
+ ? await this.client.hGet(this.lettersKey, uuid)
517
+ : await this.client.get(this.__getKey(uuid, 'letter'));
270
518
  if (!taskJSON) {
271
519
  return null;
272
520
  }
@@ -297,6 +545,19 @@ class RedisQueue {
297
545
  task.sendAt = +task.sendAt;
298
546
  }
299
547
 
548
+ if (this.useHashTags) {
549
+ this.__ensurePrefix();
550
+ const keys = [this.lettersKey, this.scheduleKey];
551
+ const args = [task.uuid, JSON.stringify(task), `${+task.sendAt}`];
552
+ if (task.to) {
553
+ keys.push(this.__getTaggedConcatKey(task.to));
554
+ keys.push(this.concatKeysKey);
555
+ args.push(`${task.sendAt - 128}`);
556
+ }
557
+ await this.__runScript('push', { keys, arguments: args });
558
+ return;
559
+ }
560
+
300
561
  const letterKey = this.__getKey(task.uuid, 'letter');
301
562
  const sendatKey = this.__getKey(task.uuid, 'sendat');
302
563
  const taskJSON = JSON.stringify(task);
@@ -337,9 +598,14 @@ class RedisQueue {
337
598
  return false;
338
599
  }
339
600
 
340
- await this.client.del(this.__getKey(uuid, 'sendat'));
341
- const letterKey = this.__getKey(uuid, 'letter');
342
- const taskJSON = await this.client.get(letterKey);
601
+ if (this.useHashTags) {
602
+ this.__ensurePrefix();
603
+ } else {
604
+ await this.client.del(this.__getKey(uuid, 'sendat'));
605
+ }
606
+ const taskJSON = this.useHashTags
607
+ ? await this.client.hGet(this.lettersKey, uuid)
608
+ : await this.client.get(this.__getKey(uuid, 'letter'));
343
609
  if (!taskJSON) {
344
610
  return false;
345
611
  }
@@ -373,6 +639,30 @@ class RedisQueue {
373
639
  return false;
374
640
  }
375
641
 
642
+ if (this.useHashTags) {
643
+ this.__ensurePrefix();
644
+ try {
645
+ const keys = [this.lettersKey, this.scheduleKey];
646
+ if (task.to) {
647
+ keys.push(this.__getTaggedConcatKey(task.to));
648
+ keys.push(this.concatKeysKey);
649
+ }
650
+ const result = await this.__runScript('remove', {
651
+ keys,
652
+ arguments: [
653
+ task.uuid,
654
+ isSendLeaseRemove(opts) ? 'lease' : 'plain',
655
+ `${opts?.leaseTries || 0}`,
656
+ `${opts?.leaseSendingAt || 0}`,
657
+ ],
658
+ });
659
+ return Number(result) >= 1;
660
+ } catch (opError) {
661
+ logError('[remove] [tagged] [opError]', opError);
662
+ return false;
663
+ }
664
+ }
665
+
376
666
  const letterKey = this.__getKey(task.uuid, 'letter');
377
667
  if (isSendLeaseRemove(opts)) {
378
668
  if (typeof this.client.watch !== 'function' || typeof this.client.multi !== 'function') {
@@ -447,6 +737,36 @@ class RedisQueue {
447
737
  const sendingTimeout = this.mailTimeInstance?.sendingTimeout || 300000;
448
738
 
449
739
  try {
740
+ if (this.useHashTags) {
741
+ if (isAppend) {
742
+ const result = await this.__runScript('append', {
743
+ keys: [this.lettersKey],
744
+ arguments: [task.uuid, JSON.stringify(updateObj.appendMailOption)],
745
+ });
746
+ return Number(result) >= 1;
747
+ }
748
+
749
+ const mode = isClaim ? 'claim' : (isLeaseRelease ? 'lease' : 'plain');
750
+ const keys = [this.lettersKey, this.scheduleKey];
751
+ if (task.to) {
752
+ keys.push(this.__getTaggedConcatKey(task.to));
753
+ keys.push(this.concatKeysKey);
754
+ }
755
+ const result = await this.__runScript('update', {
756
+ keys,
757
+ arguments: [
758
+ task.uuid,
759
+ JSON.stringify(stripInternalUpdateMeta(updateObj)),
760
+ mode,
761
+ `${now}`,
762
+ `${sendingTimeout}`,
763
+ `${isClaim ? task.tries : (updateObj.leaseTries || 0)}`,
764
+ `${updateObj.leaseSendingAt || 0}`,
765
+ ],
766
+ });
767
+ return Number(result) >= 1;
768
+ }
769
+
450
770
  if (isAppend) {
451
771
  if (typeof this.client.watch !== 'function' || typeof this.client.multi !== 'function') {
452
772
  if (!RedisQueue.__atomicAppendWarned) {
@@ -558,6 +878,15 @@ class RedisQueue {
558
878
  throw new Error(`[mail-time] [RedisQueue] [__getKey] unsupported key "${type}" passed into the second argument`);
559
879
  }
560
880
  this.__ensurePrefix();
881
+ if (this.useHashTags) {
882
+ if (type === 'letter') {
883
+ return this.lettersKey;
884
+ }
885
+ if (type === 'sendat') {
886
+ return this.scheduleKey;
887
+ }
888
+ return this.__getTaggedConcatKey(uuid);
889
+ }
561
890
  return `${this.uniqueName}:${type}:${uuid}`;
562
891
  }
563
892
  }
package/helpers.js CHANGED
@@ -220,6 +220,24 @@ const filterAddressField = (field, acceptedSet) => {
220
220
  return field;
221
221
  };
222
222
 
223
+ const HTML_ESCAPES = {
224
+ '&': '&amp;',
225
+ '<': '&lt;',
226
+ '>': '&gt;',
227
+ '"': '&quot;',
228
+ "'": '&#39;',
229
+ };
230
+
231
+ /**
232
+ * @name escapeHtml - Escape the five HTML-significant characters.
233
+ * @function
234
+ * @param {string} value
235
+ * @returns {string} `value` safe to interpolate into HTML text or an attribute value.
236
+ */
237
+ const escapeHtml = (value) => {
238
+ return `${value}`.replace(/[&<>"']/g, (char) => HTML_ESCAPES[char]);
239
+ };
240
+
223
241
  /**
224
242
  * @name isSendClaimUpdate - Detect an atomic send-claim update (`{ isSending: true, tries: N }`).
225
243
  * @function
@@ -278,6 +296,7 @@ const isSendLeaseRemove = (opts) => {
278
296
 
279
297
  export {
280
298
  debug,
299
+ escapeHtml,
281
300
  logError,
282
301
  hasOwnProp,
283
302
  isPlainObject,