mail-time 5.1.0 → 5.2.1

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/presets.js CHANGED
@@ -60,12 +60,12 @@ const PRESETS = Object.freeze({
60
60
  transactional: Object.freeze({
61
61
  concatEmails: false,
62
62
  retries: 30,
63
- retryDelay: 10_000,
63
+ retryDelay: 10000,
64
64
  mode: 'batch',
65
65
  concurrency: 1,
66
66
  onError: defaultPresetOnError,
67
67
  josk: Object.freeze({
68
- zombieTime: 120_000,
68
+ zombieTime: 120000,
69
69
  }),
70
70
  }),
71
71
  otp: Object.freeze({
@@ -73,52 +73,52 @@ const PRESETS = Object.freeze({
73
73
  retries: 5,
74
74
  retryDelay: 2000,
75
75
  revolvingInterval: 1024,
76
- sendingTimeout: 120_000,
76
+ sendingTimeout: 120000,
77
77
  mode: 'batch',
78
78
  concurrency: 4,
79
79
  onError: defaultPresetOnError,
80
80
  josk: Object.freeze({
81
81
  minRevolvingDelay: 256,
82
82
  maxRevolvingDelay: 1024,
83
- zombieTime: 60_000,
83
+ zombieTime: 60000,
84
84
  }),
85
85
  }),
86
86
  newsletter: Object.freeze({
87
87
  concatEmails: true,
88
- concatDelay: 5 * 60_000,
88
+ concatDelay: 5 * 60000,
89
89
  concatSubject: 'Your updates',
90
90
  retries: 5,
91
- retryDelay: 60_000,
92
- sendingTimeout: 600_000,
91
+ retryDelay: 60000,
92
+ sendingTimeout: 600000,
93
93
  mode: 'batch',
94
94
  concurrency: 2,
95
95
  onError: defaultPresetOnError,
96
96
  josk: Object.freeze({
97
- zombieTime: 300_000,
97
+ zombieTime: 300000,
98
98
  }),
99
99
  }),
100
100
  marketing: Object.freeze({
101
101
  concatEmails: false,
102
102
  retries: 10,
103
- retryDelay: 30_000,
103
+ retryDelay: 30000,
104
104
  mode: 'batch',
105
105
  concurrency: 5,
106
106
  onError: defaultPresetOnError,
107
107
  josk: Object.freeze({
108
- zombieTime: 180_000,
108
+ zombieTime: 180000,
109
109
  }),
110
110
  }),
111
111
  notifications: Object.freeze({
112
112
  concatEmails: true,
113
- concatDelay: 60_000,
113
+ concatDelay: 60000,
114
114
  concatSubject: 'New activity',
115
115
  retries: 8,
116
- retryDelay: 30_000,
116
+ retryDelay: 30000,
117
117
  mode: 'batch',
118
118
  concurrency: 3,
119
119
  onError: defaultPresetOnError,
120
120
  josk: Object.freeze({
121
- zombieTime: 180_000,
121
+ zombieTime: 180000,
122
122
  }),
123
123
  }),
124
124
  alerts: Object.freeze({
@@ -126,14 +126,14 @@ const PRESETS = Object.freeze({
126
126
  retries: 20,
127
127
  retryDelay: 5000,
128
128
  revolvingInterval: 1024,
129
- sendingTimeout: 120_000,
129
+ sendingTimeout: 120000,
130
130
  mode: 'batch',
131
131
  concurrency: 2,
132
132
  onError: defaultPresetOnError,
133
133
  josk: Object.freeze({
134
134
  minRevolvingDelay: 256,
135
135
  maxRevolvingDelay: 1024,
136
- zombieTime: 60_000,
136
+ zombieTime: 60000,
137
137
  }),
138
138
  }),
139
139
  });
@@ -0,0 +1,90 @@
1
+ class RecipientPolicyLease {
2
+ constructor({ task, queue, interval, maxRenewals, shouldAbort, report }) {
3
+ this.__task = task;
4
+ this.__queue = queue;
5
+ this.__shouldAbort = shouldAbort;
6
+ this.__report = report;
7
+ this.__closed = false;
8
+ this.__finishing = false;
9
+ this.__tail = Promise.resolve();
10
+ this.__timer = null;
11
+ let pending = false;
12
+ let renewals = 0;
13
+ if (interval && maxRenewals) {
14
+ this.__timer = setInterval(() => {
15
+ if (!this.active || this.__finishing || renewals >= maxRenewals) {
16
+ this.__clearTimer();
17
+ return;
18
+ }
19
+ if (pending) return;
20
+ pending = true;
21
+ renewals++;
22
+ this.__enqueue(async (guard) => {
23
+ const fields = { isSending: true, sendingAt: Math.max(Date.now(), this.__task.sendingAt + 1) };
24
+ return await this.__write(fields, guard);
25
+ }).finally(() => { pending = false; });
26
+ }, interval);
27
+ this.__timer.unref?.();
28
+ }
29
+ }
30
+
31
+ get active() { return !this.__closed && !this.__shouldAbort(); }
32
+
33
+ __clearTimer() {
34
+ if (this.__timer) clearInterval(this.__timer);
35
+ this.__timer = null;
36
+ }
37
+
38
+ __halt() {
39
+ this.__clearTimer();
40
+ this.__closed = true;
41
+ }
42
+
43
+ __enqueue(operation) {
44
+ const pending = this.__tail.then(async () => {
45
+ if (!this.active) return false;
46
+ const guard = { leaseTries: this.__task.tries, leaseSendingAt: this.__task.sendingAt };
47
+ try {
48
+ const ok = await operation(guard);
49
+ if (!ok) this.__halt();
50
+ return ok;
51
+ } catch (error) {
52
+ this.__halt();
53
+ if (!this.__shouldAbort()) this.__report(error);
54
+ return false;
55
+ }
56
+ });
57
+ this.__tail = pending.then(() => void 0);
58
+ return pending;
59
+ }
60
+
61
+ async __write(fields, guard) {
62
+ const ok = await this.__queue.update(this.__task, { ...fields, ...guard });
63
+ if (ok) Object.assign(this.__task, fields);
64
+ return ok;
65
+ }
66
+
67
+ update(fields) {
68
+ if (this.__finishing) return Promise.resolve(false);
69
+ return this.__enqueue((guard) => this.__write(fields, guard));
70
+ }
71
+
72
+ finish(fields, remove) {
73
+ if (this.__finishing) return Promise.resolve(false);
74
+ this.__finishing = true;
75
+ this.__clearTimer();
76
+ return this.__enqueue(async (guard) => {
77
+ const ok = remove ? await this.__queue.remove(this.__task, guard) : await this.__queue.update(this.__task, { ...fields, ...guard });
78
+ if (ok) Object.assign(this.__task, fields);
79
+ this.__halt();
80
+ return ok;
81
+ });
82
+ }
83
+
84
+ async stop() {
85
+ this.__halt();
86
+ await this.__tail;
87
+ }
88
+ }
89
+
90
+ export { RecipientPolicyLease };
@@ -0,0 +1,166 @@
1
+ import { hasOwnProp, isPlainObject } from './helpers.js';
2
+
3
+ const policyError = (message) => new Error(`[mail-time] [recipientPolicies] ${message}`);
4
+ const SIMPLE_MAILBOX = /^[^\s<>,;:"()\\\[\]@]+@[^\s<>,;:"()\\\[\]@]+$/u;
5
+
6
+ const normalizePolicyAddress = (value) => {
7
+ const input = typeof value === 'string' ? value : (!Array.isArray(value) && value?.address);
8
+ if (typeof input !== 'string' || /[\r\n]/u.test(input)) {
9
+ throw policyError('recipient requires a simple address without line breaks');
10
+ }
11
+ let address = input.trim();
12
+ if (address.includes('<') || address.includes('>')) {
13
+ const match = address.match(/^[^<>,;:"\r\n]*<([^<>]+)>$/u);
14
+ if (!match) throw policyError('use a simple explicit envelope.to');
15
+ address = match[1].trim();
16
+ }
17
+ if (!SIMPLE_MAILBOX.test(address)) throw policyError('use a simple explicit envelope.to');
18
+ return address.toLowerCase();
19
+ };
20
+
21
+ const preparePolicyEnvelope = (compiled, previousResults = []) => {
22
+ const recipients = new Map();
23
+ const explicit = compiled.envelope && hasOwnProp(compiled.envelope, 'to');
24
+ const add = (value, source, authoritative) => {
25
+ for (const entry of Array.isArray(value) ? value : [value]) {
26
+ let address;
27
+ try { address = normalizePolicyAddress(entry); }
28
+ catch (error) { if (authoritative) throw error; else continue; }
29
+ if (!recipients.has(address)) {
30
+ if (!authoritative) continue;
31
+ recipients.set(address, { address, sources: [] });
32
+ }
33
+ const sources = recipients.get(address).sources;
34
+ if (!sources.includes(source)) sources.push(source);
35
+ }
36
+ };
37
+ if (explicit) add(compiled.envelope.to, 'envelope', true);
38
+ for (const source of ['to', 'cc', 'bcc']) {
39
+ if (hasOwnProp(compiled, source) && compiled[source] !== void 0) add(compiled[source], source, !explicit);
40
+ }
41
+ if (!recipients.size) throw policyError('envelope must contain at least one recipient');
42
+ if (previousResults.length && (previousResults.length !== recipients.size || previousResults.some((r) => !recipients.has(r.address)))) {
43
+ throw policyError('envelope recipient set changed between attempts');
44
+ }
45
+ const envelope = { to: [...recipients.keys()] };
46
+ if (compiled.envelope && hasOwnProp(compiled.envelope, 'from')) {
47
+ envelope.from = compiled.envelope.from === '' ? '' : normalizePolicyAddress(compiled.envelope.from);
48
+ } else {
49
+ const from = compiled.from || compiled.sender || compiled.replyTo;
50
+ if (from) envelope.from = normalizePolicyAddress(from);
51
+ }
52
+ return { envelope, recipients: [...recipients.values()] };
53
+ };
54
+
55
+ const validateRecipientPolicies = (value, queue) => {
56
+ if (value === void 0) return null;
57
+ if (!Array.isArray(value) || !value.length) throw policyError('recipientPolicies must be a nonempty array');
58
+ if (queue.supportsRecipientPolicies !== true) throw policyError('queue must declare recipient policy support');
59
+ const names = new Set();
60
+ return value.map((provider) => {
61
+ if (!isPlainObject(provider)) throw policyError('each provider must be a plain object');
62
+ const name = typeof provider.name === 'string' ? provider.name.trim() : '';
63
+ if (!name || name.length > 128 || names.has(name)) throw policyError('provider names must be unique and contain 1-128 characters');
64
+ names.add(name);
65
+ const failureMode = provider.failureMode === void 0 ? 'retry' : provider.failureMode;
66
+ if (failureMode !== 'retry' && failureMode !== 'continue') throw policyError('failureMode must be retry or continue');
67
+ const normalized = { name, failureMode };
68
+ let count = 0;
69
+ for (const hook of ['beforeSend', 'classifyRejections', 'observeAttempt']) {
70
+ if (hasOwnProp(provider, hook)) {
71
+ if (typeof provider[hook] !== 'function') throw policyError(`${hook} must be a function`);
72
+ normalized[hook] = provider[hook].bind(provider);
73
+ count++;
74
+ }
75
+ }
76
+ if (!count) throw policyError('provider requires at least one supported hook');
77
+ return normalized;
78
+ });
79
+ };
80
+
81
+ const validatePolicyResult = (raw, hook, context) => {
82
+ if (raw === void 0) return [];
83
+ if (!isPlainObject(raw)) throw policyError('hook must return a result object or nothing');
84
+ if (raw.decisions === void 0) return [];
85
+ if (!Array.isArray(raw.decisions)) throw policyError('decisions must be an array');
86
+ const batch = new Set(context.recipients.map((r) => r.address));
87
+ const attributable = new Set((context.rejections || []).map((r) => r.address).filter(Boolean));
88
+ const decisions = new Map();
89
+ for (const item of raw.decisions) {
90
+ if (!isPlainObject(item)) throw policyError('decision must be an object');
91
+ const address = normalizePolicyAddress(item.address);
92
+ if (!batch.has(address)) throw policyError('decision address is outside the input batch');
93
+ const allowed = hook === 'beforeSend' ? item.status === 'suppressed' : (item.status === 'retry' || item.status === 'rejected');
94
+ if (!allowed) throw policyError('invalid decision status for this phase');
95
+ if (item.status === 'rejected' && !attributable.has(address)) throw policyError('permanent rejection requires an attributable record');
96
+ if (typeof item.reason !== 'string' || !item.reason.trim() || item.reason.length > 512) throw policyError('reason must contain 1-512 characters');
97
+ const previous = decisions.get(address);
98
+ if (previous && (previous.status !== item.status || previous.reason !== item.reason)) throw policyError('conflicting duplicate decisions');
99
+ decisions.set(address, { address, status: item.status, reason: item.reason });
100
+ }
101
+ return [...decisions.values()];
102
+ };
103
+
104
+ const evaluatePolicyPhase = async (providers, hook, context, report) => {
105
+ const decisions = [];
106
+ let retryFailure = false;
107
+ for (const provider of providers) {
108
+ if (typeof provider[hook] !== 'function') continue;
109
+ try {
110
+ const validated = validatePolicyResult(await provider[hook](context), hook, context);
111
+ for (const decision of validated) decisions.push({ ...decision, provider: provider.name });
112
+ } catch (error) {
113
+ report(error, provider.name, hook);
114
+ if (provider.failureMode !== 'continue') retryFailure = true;
115
+ }
116
+ }
117
+ return { retryFailure, decisions };
118
+ };
119
+
120
+ const mergePolicyResults = (previous, recipients, evaluated, details) => {
121
+ const results = new Map(previous.map((r) => [r.address, r]));
122
+ const accepted = new Set(details.accepted || []);
123
+ const decisions = new Map();
124
+ for (const decision of evaluated.decisions) {
125
+ if (!decisions.has(decision.address)) decisions.set(decision.address, []);
126
+ decisions.get(decision.address).push(decision);
127
+ }
128
+ const diagnostics = new Map();
129
+ for (const record of details.rejections || []) {
130
+ if (record.address && !diagnostics.has(record.address)) diagnostics.set(record.address, record);
131
+ }
132
+ for (const recipient of recipients) {
133
+ const prior = results.get(recipient.address);
134
+ if (prior && prior.status !== 'error') continue;
135
+ const candidates = decisions.get(recipient.address) || [];
136
+ const terminal = details.phase === 'beforeSend' ? 'suppressed' : 'rejected';
137
+ let status = 'error';
138
+ if (accepted.has(recipient.address)) status = 'sent';
139
+ else if (!evaluated.retryFailure && candidates.some((d) => d.status === terminal)) status = terminal;
140
+ const reasons = [];
141
+ if (!evaluated.retryFailure && status !== 'sent') {
142
+ for (const candidate of candidates) {
143
+ if (candidate.status === (status === 'error' ? 'retry' : status)) reasons.push({ provider: candidate.provider, reason: candidate.reason });
144
+ }
145
+ }
146
+ const result = { address: recipient.address, status, sources: [...recipient.sources], reasons, attempt: details.attempt, transportIndex: details.transportIndex };
147
+ if (typeof details.transportName === 'string') result.transportName = details.transportName.slice(0, 128);
148
+ const record = diagnostics.get(recipient.address);
149
+ if (record) {
150
+ for (const key of ['command', 'response', 'message']) {
151
+ if (typeof record[key] === 'string') result[key] = record[key].slice(0, 2048);
152
+ }
153
+ if (Number.isFinite(record.responseCode)) result.responseCode = record.responseCode;
154
+ }
155
+ results.set(recipient.address, result);
156
+ }
157
+ return [...results.values()];
158
+ };
159
+
160
+ const summarizePolicyTask = (task, isSettled) => {
161
+ const recipients = { sent: [], error: [], suppressed: [], rejected: [] };
162
+ for (const result of task.recipientResults || []) recipients[result.status].push(result);
163
+ return { uuid: task.uuid, tries: task.tries, isSettled, recipients };
164
+ };
165
+
166
+ export { policyError, normalizePolicyAddress, preparePolicyEnvelope, validateRecipientPolicies, evaluatePolicyPhase, mergePolicyResults, summarizePolicyTask };
@@ -0,0 +1,62 @@
1
+ import { hasOwnProp } from './helpers.js';
2
+ import { normalizePolicyAddress } from './recipient-policy.js';
3
+
4
+ const diagnosticFields = (record) => {
5
+ const result = {};
6
+ for (const key of ['command', 'response', 'message']) {
7
+ if (typeof record?.[key] === 'string') result[key] = record[key].slice(0, 2048);
8
+ }
9
+ if (Number.isFinite(record?.responseCode)) result.responseCode = record.responseCode;
10
+ return result;
11
+ };
12
+
13
+ const normalizeRejections = (error, info, transport) => {
14
+ const records = [];
15
+ const ancestors = new WeakSet();
16
+ const addressOf = (value) => {
17
+ try { return normalizePolicyAddress(value); }
18
+ catch { return null; }
19
+ };
20
+ const append = (node, address) => {
21
+ const record = { address, ...diagnosticFields(node), transportIndex: transport.index };
22
+ if (typeof transport.name === 'string') record.transportName = transport.name.slice(0, 128);
23
+ if (typeof node === 'string') record.message = node.slice(0, 2048);
24
+ records.push(record);
25
+ };
26
+ const visit = (node, positional = null, root = false, errorRoot = false) => {
27
+ if (node === null || node === void 0) {
28
+ if (!root) append(node, positional);
29
+ return;
30
+ }
31
+ if (typeof node !== 'object') {
32
+ append(node, positional);
33
+ return;
34
+ }
35
+ if (ancestors.has(node)) return;
36
+ ancestors.add(node);
37
+ const start = records.length;
38
+ const rejected = Array.isArray(node.rejected) ? node.rejected.map(addressOf) : [];
39
+ for (const key of ['errors', 'rejectedErrors']) {
40
+ if (Array.isArray(node[key])) {
41
+ for (let i = 0; i < node[key].length; i++) visit(node[key][i], rejected[i] || null);
42
+ }
43
+ }
44
+ if (records.length === start) {
45
+ let address = positional;
46
+ for (const key of ['recipient', 'address', 'to']) {
47
+ if (hasOwnProp(node, key)) { address = addressOf(node[key]); break; }
48
+ }
49
+ if (!root || errorRoot || address || Object.keys(diagnosticFields(node)).length) append(node, address);
50
+ }
51
+ const attributed = new Set(records.slice(start).map((r) => r.address));
52
+ for (const address of rejected) {
53
+ if (address && !attributed.has(address)) append({ message: 'Recipient rejected by transport' }, address);
54
+ }
55
+ ancestors.delete(node);
56
+ };
57
+ visit(error, null, true, true);
58
+ visit(info, null, true);
59
+ return records;
60
+ };
61
+
62
+ export { normalizeRejections, diagnosticFields };
@@ -26,6 +26,10 @@ const scheduleKey = `${taggedName}:schedule`;
26
26
  const concatKeysKey = `${taggedName}:concatkeys`;
27
27
  const source = await createClient({ url: sourceUrl }).connect();
28
28
  const target = createCluster({ rootNodes: [{ url: targetUrl }] });
29
+ const disconnect = async (client) => {
30
+ if (typeof client.close === 'function') await client.close();
31
+ else await client.quit();
32
+ };
29
33
 
30
34
  const scan = async function * (client, pattern) {
31
35
  for await (const batch of client.scanIterator({ TYPE: 'string', MATCH: pattern, COUNT: 9999 })) {
@@ -104,5 +108,5 @@ try {
104
108
 
105
109
  console.info(`Migrated ${migrated} RedisQueue tasks from "${legacyName}" to "${taggedName}".`);
106
110
  } finally {
107
- await Promise.all([source.close(), target.close()]);
111
+ await Promise.all([disconnect(source), disconnect(target)]);
108
112
  }