mail-time 5.1.0 → 5.2.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,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
  }