spamscanner 6.0.0 → 6.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.
@@ -0,0 +1,514 @@
1
+ // src/arf.js
2
+ import { simpleParser } from "mailparser";
3
+
4
+ // src/reputation.js
5
+ import { debuglog } from "node:util";
6
+ var debug = debuglog("spamscanner:reputation");
7
+ var DEFAULT_API_URL = "https://api.forwardemail.net/v1/reputation";
8
+ var cache = /* @__PURE__ */ new Map();
9
+ var CACHE_TTL = 5 * 60 * 1e3;
10
+ async function checkReputation(value, options = {}) {
11
+ const {
12
+ apiUrl = DEFAULT_API_URL,
13
+ timeout = 1e4
14
+ } = options;
15
+ if (!value || typeof value !== "string") {
16
+ return {
17
+ isTruthSource: false,
18
+ truthSourceValue: null,
19
+ isAllowlisted: false,
20
+ allowlistValue: null,
21
+ isDenylisted: false,
22
+ denylistValue: null
23
+ };
24
+ }
25
+ const cacheKey = `${apiUrl}:${value}`;
26
+ const cached = cache.get(cacheKey);
27
+ if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
28
+ debug("Cache hit for %s", value);
29
+ return cached.result;
30
+ }
31
+ try {
32
+ const url = new URL(apiUrl);
33
+ url.searchParams.set("q", value);
34
+ const controller = new AbortController();
35
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
36
+ const response = await fetch(url.toString(), {
37
+ method: "GET",
38
+ headers: {
39
+ Accept: "application/json",
40
+ "User-Agent": "SpamScanner/6.0"
41
+ },
42
+ signal: controller.signal
43
+ });
44
+ clearTimeout(timeoutId);
45
+ if (!response.ok) {
46
+ debug("API returned status %d for %s", response.status, value);
47
+ return {
48
+ isTruthSource: false,
49
+ truthSourceValue: null,
50
+ isAllowlisted: false,
51
+ allowlistValue: null,
52
+ isDenylisted: false,
53
+ denylistValue: null
54
+ };
55
+ }
56
+ const result = await response.json();
57
+ const normalizedResult = {
58
+ isTruthSource: Boolean(result.isTruthSource),
59
+ truthSourceValue: result.truthSourceValue || null,
60
+ isAllowlisted: Boolean(result.isAllowlisted),
61
+ allowlistValue: result.allowlistValue || null,
62
+ isDenylisted: Boolean(result.isDenylisted),
63
+ denylistValue: result.denylistValue || null
64
+ };
65
+ cache.set(cacheKey, {
66
+ result: normalizedResult,
67
+ timestamp: Date.now()
68
+ });
69
+ debug("Reputation check for %s: %o", value, normalizedResult);
70
+ return normalizedResult;
71
+ } catch (error) {
72
+ debug("Reputation check failed for %s: %s", value, error.message);
73
+ return {
74
+ isTruthSource: false,
75
+ truthSourceValue: null,
76
+ isAllowlisted: false,
77
+ allowlistValue: null,
78
+ isDenylisted: false,
79
+ denylistValue: null
80
+ };
81
+ }
82
+ }
83
+
84
+ // src/arf.js
85
+ var VALID_FEEDBACK_TYPES = /* @__PURE__ */ new Set([
86
+ "abuse",
87
+ "fraud",
88
+ "virus",
89
+ "other",
90
+ "not-spam",
91
+ "auth-failure",
92
+ // RFC 6591
93
+ "dmarc"
94
+ // RFC 7489
95
+ ]);
96
+ function extractBoundary(contentType) {
97
+ const match = /boundary=["']?([^"';\s]+)["']?/i.exec(contentType);
98
+ return match ? match[1] : null;
99
+ }
100
+ function parseMimeParts(content, boundary) {
101
+ const parts = [];
102
+ const boundaryRegex = new RegExp(`--${boundary.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)}(?:--)?`, "g");
103
+ const segments = content.split(boundaryRegex);
104
+ for (let index = 1; index < segments.length; index++) {
105
+ const segment = segments[index].trim();
106
+ if (!segment || segment === "--") {
107
+ continue;
108
+ }
109
+ const headerBodySplit = segment.indexOf("\r\n\r\n");
110
+ const headerBodySplitAlt = segment.indexOf("\n\n");
111
+ let headerEnd;
112
+ let bodyStart;
113
+ if (headerBodySplit !== -1 && (headerBodySplitAlt === -1 || headerBodySplit < headerBodySplitAlt)) {
114
+ headerEnd = headerBodySplit;
115
+ bodyStart = headerBodySplit + 4;
116
+ } else if (headerBodySplitAlt === -1) {
117
+ continue;
118
+ } else {
119
+ headerEnd = headerBodySplitAlt;
120
+ bodyStart = headerBodySplitAlt + 2;
121
+ }
122
+ const headerSection = segment.slice(0, headerEnd);
123
+ const body = segment.slice(bodyStart);
124
+ const headers = {};
125
+ const headerLines = headerSection.split(/\r?\n/);
126
+ let currentHeader = null;
127
+ let currentValue = "";
128
+ for (const line of headerLines) {
129
+ if (/^\s+/.test(line) && currentHeader) {
130
+ currentValue += " " + line.trim();
131
+ } else {
132
+ if (currentHeader) {
133
+ headers[currentHeader.toLowerCase()] = currentValue;
134
+ }
135
+ const colonIndex = line.indexOf(":");
136
+ if (colonIndex !== -1) {
137
+ currentHeader = line.slice(0, colonIndex).trim();
138
+ currentValue = line.slice(colonIndex + 1).trim();
139
+ }
140
+ }
141
+ }
142
+ if (currentHeader) {
143
+ headers[currentHeader.toLowerCase()] = currentValue;
144
+ }
145
+ parts.push({ headers, body });
146
+ }
147
+ return parts;
148
+ }
149
+ function parseArfHeaders(content) {
150
+ const headers = {};
151
+ const lines = content.split(/\r?\n/);
152
+ let currentField = null;
153
+ let currentValue = "";
154
+ for (const line of lines) {
155
+ if (/^\s+/.test(line) && currentField) {
156
+ currentValue += " " + line.trim();
157
+ continue;
158
+ }
159
+ if (currentField) {
160
+ const fieldName = currentField.toLowerCase().replaceAll("-", "_");
161
+ if (headers[fieldName]) {
162
+ if (Array.isArray(headers[fieldName])) {
163
+ headers[fieldName].push(currentValue);
164
+ } else {
165
+ headers[fieldName] = [headers[fieldName], currentValue];
166
+ }
167
+ } else {
168
+ headers[fieldName] = currentValue;
169
+ }
170
+ }
171
+ const match = /^([^:]+):\s*(.*)$/.exec(line);
172
+ if (match) {
173
+ currentField = match[1];
174
+ currentValue = match[2];
175
+ } else {
176
+ currentField = null;
177
+ currentValue = "";
178
+ }
179
+ }
180
+ if (currentField) {
181
+ const fieldName = currentField.toLowerCase().replaceAll("-", "_");
182
+ if (headers[fieldName]) {
183
+ if (Array.isArray(headers[fieldName])) {
184
+ headers[fieldName].push(currentValue);
185
+ } else {
186
+ headers[fieldName] = [headers[fieldName], currentValue];
187
+ }
188
+ } else {
189
+ headers[fieldName] = currentValue;
190
+ }
191
+ }
192
+ return headers;
193
+ }
194
+ function extractEmail(value) {
195
+ if (!value) {
196
+ return null;
197
+ }
198
+ const angleMatch = /<([^>]+)>/.exec(value);
199
+ if (angleMatch) {
200
+ return angleMatch[1];
201
+ }
202
+ const emailMatch = /[\w.+-]+@[\w.-]+\.\w+/.exec(value);
203
+ if (emailMatch) {
204
+ return emailMatch[0];
205
+ }
206
+ return value.trim();
207
+ }
208
+ function parseSourceIp(value) {
209
+ if (!value) {
210
+ return null;
211
+ }
212
+ const ipv4Match = /((?:\d{1,3}\.){3}\d{1,3})/.exec(value);
213
+ if (ipv4Match) {
214
+ return ipv4Match[1];
215
+ }
216
+ const ipv6Match = /([a-fA-F\d:]+:+[a-fA-F\d:]+)/.exec(value);
217
+ if (ipv6Match) {
218
+ return ipv6Match[1];
219
+ }
220
+ return value.trim();
221
+ }
222
+ function parseDate(value) {
223
+ if (!value) {
224
+ return null;
225
+ }
226
+ try {
227
+ const date = new Date(value);
228
+ if (Number.isNaN(date.getTime())) {
229
+ return null;
230
+ }
231
+ return date;
232
+ } catch {
233
+ return null;
234
+ }
235
+ }
236
+ function parseReportingMta(value) {
237
+ if (!value) {
238
+ return null;
239
+ }
240
+ const match = /^(\w+);\s*(.+)$/.exec(value.trim());
241
+ if (match) {
242
+ return {
243
+ type: match[1].toLowerCase(),
244
+ name: match[2].trim()
245
+ };
246
+ }
247
+ return {
248
+ type: "unknown",
249
+ name: value.trim()
250
+ };
251
+ }
252
+ function processFeedbackReport(content, result) {
253
+ result.rawFeedbackReport = content;
254
+ const headers = parseArfHeaders(content);
255
+ result.feedbackType = headers.feedback_type?.toLowerCase() || null;
256
+ result.userAgent = headers.user_agent || null;
257
+ result.version = headers.version || "1";
258
+ result.arrivalDate = parseDate(headers.arrival_date || headers.received_date);
259
+ result.sourceIp = parseSourceIp(headers.source_ip);
260
+ result.originalMailFrom = extractEmail(headers.original_mail_from);
261
+ result.originalEnvelopeId = headers.original_envelope_id || null;
262
+ result.reportingMta = parseReportingMta(headers.reporting_mta);
263
+ result.incidents = headers.incidents ? Number.parseInt(headers.incidents, 10) : 1;
264
+ if (headers.original_rcpt_to) {
265
+ const rcptTo = Array.isArray(headers.original_rcpt_to) ? headers.original_rcpt_to : [headers.original_rcpt_to];
266
+ result.originalRcptTo = rcptTo.map((r) => extractEmail(r)).filter(Boolean);
267
+ }
268
+ if (headers.authentication_results) {
269
+ result.authenticationResults = Array.isArray(headers.authentication_results) ? headers.authentication_results : [headers.authentication_results];
270
+ }
271
+ if (headers.reported_domain) {
272
+ result.reportedDomain = Array.isArray(headers.reported_domain) ? headers.reported_domain : [headers.reported_domain];
273
+ }
274
+ if (headers.reported_uri) {
275
+ result.reportedUri = Array.isArray(headers.reported_uri) ? headers.reported_uri : [headers.reported_uri];
276
+ }
277
+ }
278
+ async function processOriginalMessage(content, result) {
279
+ result.originalMessage = content;
280
+ try {
281
+ const originalParsed = await simpleParser(content, {
282
+ skipHtmlToText: true,
283
+ skipTextToHtml: true,
284
+ skipImageLinks: true
285
+ });
286
+ result.originalHeaders = {};
287
+ for (const [key, value] of originalParsed.headers) {
288
+ result.originalHeaders[key] = value;
289
+ }
290
+ } catch {
291
+ }
292
+ }
293
+ var ArfParser = {
294
+ /**
295
+ * Check if a message is an ARF report
296
+ * @param {object} parsed - Parsed email message from mailparser
297
+ * @returns {boolean} True if message is ARF
298
+ */
299
+ isArfMessage(parsed) {
300
+ const contentType = parsed.headers?.get("content-type");
301
+ if (!contentType) {
302
+ return false;
303
+ }
304
+ const value = typeof contentType === "object" ? contentType.value : contentType;
305
+ const parameters = typeof contentType === "object" ? contentType.params : {};
306
+ if (!value?.toLowerCase().includes("multipart/report")) {
307
+ return false;
308
+ }
309
+ const reportType = parameters?.["report-type"] || "";
310
+ return reportType.toLowerCase() === "feedback-report";
311
+ },
312
+ /**
313
+ * Parse an ARF message
314
+ * @param {Buffer|string} source - Raw email message
315
+ * @returns {Promise<object>} Parsed ARF report
316
+ */
317
+ async parse(source) {
318
+ const rawContent = typeof source === "string" ? source : source.toString("utf8");
319
+ const parsed = await simpleParser(source, {
320
+ skipHtmlToText: true,
321
+ skipTextToHtml: true,
322
+ skipImageLinks: true
323
+ });
324
+ if (!ArfParser.isArfMessage(parsed)) {
325
+ throw new Error("Not a valid ARF message: missing multipart/report with report-type=feedback-report");
326
+ }
327
+ const contentType = parsed.headers.get("content-type");
328
+ const boundary = typeof contentType === "object" ? contentType.params?.boundary : extractBoundary(contentType);
329
+ if (!boundary) {
330
+ throw new Error("Not a valid ARF message: missing MIME boundary");
331
+ }
332
+ const result = {
333
+ isArf: true,
334
+ version: null,
335
+ feedbackType: null,
336
+ userAgent: null,
337
+ arrivalDate: null,
338
+ sourceIp: null,
339
+ originalMailFrom: null,
340
+ originalRcptTo: null,
341
+ reportingMta: null,
342
+ originalEnvelopeId: null,
343
+ authenticationResults: null,
344
+ reportedDomain: null,
345
+ reportedUri: null,
346
+ incidents: 1,
347
+ humanReadable: null,
348
+ originalMessage: null,
349
+ originalHeaders: null,
350
+ rawFeedbackReport: null,
351
+ // Reputation fields (populated if sourceIp is available)
352
+ isTruthSource: false,
353
+ isAllowlisted: false,
354
+ isDenylisted: false,
355
+ allowlistValue: null,
356
+ denylistValue: null
357
+ };
358
+ const parts = parseMimeParts(rawContent, boundary);
359
+ let rfc822Part = null;
360
+ for (const part of parts) {
361
+ const partContentType = part.headers["content-type"]?.toLowerCase() || "";
362
+ if (partContentType.includes("text/plain")) {
363
+ result.humanReadable = part.body.trim();
364
+ } else if (partContentType.includes("message/feedback-report")) {
365
+ processFeedbackReport(part.body, result);
366
+ } else if (partContentType.includes("message/rfc822")) {
367
+ rfc822Part = part;
368
+ } else if (partContentType.includes("text/rfc822-headers")) {
369
+ result.originalHeaders = part.body.trim();
370
+ }
371
+ }
372
+ if (rfc822Part) {
373
+ await processOriginalMessage(rfc822Part.body, result);
374
+ }
375
+ if (!result.feedbackType) {
376
+ throw new Error("Invalid ARF message: missing required Feedback-Type field");
377
+ }
378
+ if (!result.userAgent) {
379
+ throw new Error("Invalid ARF message: missing required User-Agent field");
380
+ }
381
+ if (!VALID_FEEDBACK_TYPES.has(result.feedbackType)) {
382
+ result.feedbackTypeOriginal = result.feedbackType;
383
+ result.feedbackType = "other";
384
+ }
385
+ if (result.sourceIp) {
386
+ try {
387
+ const reputation = await checkReputation(result.sourceIp);
388
+ result.isTruthSource = reputation.isTruthSource;
389
+ result.isAllowlisted = reputation.isAllowlisted;
390
+ result.isDenylisted = reputation.isDenylisted;
391
+ result.allowlistValue = reputation.allowlistValue;
392
+ result.denylistValue = reputation.denylistValue;
393
+ } catch {
394
+ }
395
+ }
396
+ return result;
397
+ },
398
+ /**
399
+ * Try to parse a message as ARF, return null if not ARF
400
+ * @param {Buffer|string} source - Raw email message
401
+ * @returns {Promise<object|null>} Parsed ARF report or null
402
+ */
403
+ async tryParse(source) {
404
+ try {
405
+ return await ArfParser.parse(source);
406
+ } catch {
407
+ return null;
408
+ }
409
+ },
410
+ /**
411
+ * Create an ARF report message
412
+ * @param {object} options - Report options
413
+ * @param {string} options.feedbackType - Type of feedback (abuse, fraud, virus, other)
414
+ * @param {string} options.userAgent - User agent string
415
+ * @param {string} options.from - From address for the report
416
+ * @param {string} options.to - To address for the report
417
+ * @param {string} options.originalMessage - Original message content
418
+ * @param {string} [options.humanReadable] - Human-readable description
419
+ * @param {string} [options.sourceIp] - Source IP of original message
420
+ * @param {string} [options.originalMailFrom] - Original MAIL FROM
421
+ * @param {string[]} [options.originalRcptTo] - Original RCPT TO addresses
422
+ * @param {Date} [options.arrivalDate] - Arrival date of original message
423
+ * @param {string} [options.reportingMta] - Reporting MTA name
424
+ * @returns {string} ARF message as string
425
+ */
426
+ create(options) {
427
+ const {
428
+ feedbackType,
429
+ userAgent,
430
+ from,
431
+ to,
432
+ originalMessage,
433
+ humanReadable = "This is an abuse report.",
434
+ sourceIp,
435
+ originalMailFrom,
436
+ originalRcptTo,
437
+ arrivalDate,
438
+ reportingMta
439
+ } = options;
440
+ if (!feedbackType || !userAgent || !from || !to || !originalMessage) {
441
+ throw new Error("Missing required fields for ARF report");
442
+ }
443
+ const boundary = `arf_boundary_${Date.now()}_${Math.random().toString(36).slice(2)}`;
444
+ const date = (/* @__PURE__ */ new Date()).toUTCString();
445
+ let feedbackReport = `Feedback-Type: ${feedbackType}\r
446
+ `;
447
+ feedbackReport += `User-Agent: ${userAgent}\r
448
+ `;
449
+ feedbackReport += "Version: 1\r\n";
450
+ if (sourceIp) {
451
+ feedbackReport += `Source-IP: ${sourceIp}\r
452
+ `;
453
+ }
454
+ if (originalMailFrom) {
455
+ feedbackReport += `Original-Mail-From: <${originalMailFrom}>\r
456
+ `;
457
+ }
458
+ if (originalRcptTo && originalRcptTo.length > 0) {
459
+ for (const rcpt of originalRcptTo) {
460
+ feedbackReport += `Original-Rcpt-To: <${rcpt}>\r
461
+ `;
462
+ }
463
+ }
464
+ if (arrivalDate) {
465
+ feedbackReport += `Arrival-Date: ${arrivalDate.toUTCString()}\r
466
+ `;
467
+ }
468
+ if (reportingMta) {
469
+ feedbackReport += `Reporting-MTA: dns; ${reportingMta}\r
470
+ `;
471
+ }
472
+ let message = `From: ${from}\r
473
+ `;
474
+ message += `To: ${to}\r
475
+ `;
476
+ message += `Date: ${date}\r
477
+ `;
478
+ message += "Subject: Abuse Report\r\n";
479
+ message += "MIME-Version: 1.0\r\n";
480
+ message += `Content-Type: multipart/report; report-type=feedback-report; boundary="${boundary}"\r
481
+ `;
482
+ message += "\r\n";
483
+ message += `--${boundary}\r
484
+ `;
485
+ message += 'Content-Type: text/plain; charset="utf-8"\r\n';
486
+ message += "Content-Transfer-Encoding: 7bit\r\n";
487
+ message += "\r\n";
488
+ message += humanReadable + "\r\n";
489
+ message += "\r\n";
490
+ message += `--${boundary}\r
491
+ `;
492
+ message += "Content-Type: message/feedback-report\r\n";
493
+ message += "\r\n";
494
+ message += feedbackReport;
495
+ message += "\r\n";
496
+ message += `--${boundary}\r
497
+ `;
498
+ message += "Content-Type: message/rfc822\r\n";
499
+ message += "Content-Disposition: inline\r\n";
500
+ message += "\r\n";
501
+ message += originalMessage;
502
+ message += "\r\n";
503
+ message += `--${boundary}--\r
504
+ `;
505
+ return message;
506
+ }
507
+ };
508
+ var arf_default = ArfParser;
509
+ export {
510
+ ArfParser,
511
+ VALID_FEEDBACK_TYPES,
512
+ arf_default as default
513
+ };
514
+ //# sourceMappingURL=arf.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../../src/arf.js", "../../src/reputation.js"],
4
+ "sourcesContent": ["/**\n * ARF (Abuse Reporting Format) Parser\n *\n * Parses email feedback reports according to RFC 5965.\n * ARF messages are multipart/report MIME messages with report-type=feedback-report.\n *\n * Structure of an ARF message:\n * 1. First part: Human-readable description (text/plain)\n * 2. Second part: Machine-readable report (message/feedback-report)\n * 3. Third part: Original message (message/rfc822 or text/rfc822-headers)\n *\n * @see https://www.rfc-editor.org/rfc/rfc5965.html\n */\n\nimport {simpleParser} from 'mailparser';\nimport {checkReputation} from './reputation.js';\n\n/**\n * Valid feedback types as defined in RFC 5965 and extensions\n */\nconst VALID_FEEDBACK_TYPES = new Set([\n\t'abuse',\n\t'fraud',\n\t'virus',\n\t'other',\n\t'not-spam',\n\t'auth-failure', // RFC 6591\n\t'dmarc', // RFC 7489\n]);\n\n/**\n * Extract boundary from Content-Type header\n * @param {string} contentType - Content-Type header value\n * @returns {string|null} Boundary string\n */\nfunction extractBoundary(contentType) {\n\tconst match = /boundary=[\"']?([^\"';\\s]+)[\"']?/i.exec(contentType);\n\treturn match ? match[1] : null;\n}\n\n/**\n * Parse MIME parts from raw email content\n * @param {string} content - Raw email content\n * @param {string} boundary - MIME boundary\n * @returns {object[]} Array of parsed parts\n */\nfunction parseMimeParts(content, boundary) {\n\tconst parts = [];\n\tconst boundaryRegex = new RegExp(`--${boundary.replaceAll(/[.*+?^${}()|[\\]\\\\]/g, String.raw`\\$&`)}(?:--)?`, 'g');\n\n\t// Split by boundary\n\tconst segments = content.split(boundaryRegex);\n\n\t// Skip first segment (preamble) and last if it's empty or just whitespace\n\tfor (let index = 1; index < segments.length; index++) {\n\t\tconst segment = segments[index].trim();\n\t\tif (!segment || segment === '--') {\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Split headers from body\n\t\tconst headerBodySplit = segment.indexOf('\\r\\n\\r\\n');\n\t\tconst headerBodySplitAlt = segment.indexOf('\\n\\n');\n\n\t\tlet headerEnd;\n\t\tlet bodyStart;\n\n\t\tif (headerBodySplit !== -1 && (headerBodySplitAlt === -1 || headerBodySplit < headerBodySplitAlt)) {\n\t\t\theaderEnd = headerBodySplit;\n\t\t\tbodyStart = headerBodySplit + 4;\n\t\t} else if (headerBodySplitAlt === -1) {\n\t\t\tcontinue;\n\t\t} else {\n\t\t\theaderEnd = headerBodySplitAlt;\n\t\t\tbodyStart = headerBodySplitAlt + 2;\n\t\t}\n\n\t\tconst headerSection = segment.slice(0, headerEnd);\n\t\tconst body = segment.slice(bodyStart);\n\n\t\t// Parse headers\n\t\tconst headers = {};\n\t\tconst headerLines = headerSection.split(/\\r?\\n/);\n\t\tlet currentHeader = null;\n\t\tlet currentValue = '';\n\n\t\tfor (const line of headerLines) {\n\t\t\tif (/^\\s+/.test(line) && currentHeader) {\n\t\t\t\tcurrentValue += ' ' + line.trim();\n\t\t\t} else {\n\t\t\t\tif (currentHeader) {\n\t\t\t\t\theaders[currentHeader.toLowerCase()] = currentValue;\n\t\t\t\t}\n\n\t\t\t\tconst colonIndex = line.indexOf(':');\n\t\t\t\tif (colonIndex !== -1) {\n\t\t\t\t\tcurrentHeader = line.slice(0, colonIndex).trim();\n\t\t\t\t\tcurrentValue = line.slice(colonIndex + 1).trim();\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tif (currentHeader) {\n\t\t\theaders[currentHeader.toLowerCase()] = currentValue;\n\t\t}\n\n\t\tparts.push({headers, body});\n\t}\n\n\treturn parts;\n}\n\n/**\n * Parse ARF header fields from the machine-readable part\n * @param {string} content - Raw content of message/feedback-report part\n * @returns {object} Parsed header fields\n */\nfunction parseArfHeaders(content) {\n\tconst headers = {};\n\tconst lines = content.split(/\\r?\\n/);\n\tlet currentField = null;\n\tlet currentValue = '';\n\n\tfor (const line of lines) {\n\t\t// Check for continuation (line starts with whitespace)\n\t\tif (/^\\s+/.test(line) && currentField) {\n\t\t\tcurrentValue += ' ' + line.trim();\n\t\t\tcontinue;\n\t\t}\n\n\t\t// Save previous field if exists\n\t\tif (currentField) {\n\t\t\tconst fieldName = currentField.toLowerCase().replaceAll('-', '_');\n\t\t\tif (headers[fieldName]) {\n\t\t\t\t// Handle multiple occurrences\n\t\t\t\tif (Array.isArray(headers[fieldName])) {\n\t\t\t\t\theaders[fieldName].push(currentValue);\n\t\t\t\t} else {\n\t\t\t\t\theaders[fieldName] = [headers[fieldName], currentValue];\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\theaders[fieldName] = currentValue;\n\t\t\t}\n\t\t}\n\n\t\t// Parse new field\n\t\tconst match = /^([^:]+):\\s*(.*)$/.exec(line);\n\t\tif (match) {\n\t\t\tcurrentField = match[1];\n\t\t\tcurrentValue = match[2];\n\t\t} else {\n\t\t\tcurrentField = null;\n\t\t\tcurrentValue = '';\n\t\t}\n\t}\n\n\t// Save last field\n\tif (currentField) {\n\t\tconst fieldName = currentField.toLowerCase().replaceAll('-', '_');\n\t\tif (headers[fieldName]) {\n\t\t\tif (Array.isArray(headers[fieldName])) {\n\t\t\t\theaders[fieldName].push(currentValue);\n\t\t\t} else {\n\t\t\t\theaders[fieldName] = [headers[fieldName], currentValue];\n\t\t\t}\n\t\t} else {\n\t\t\theaders[fieldName] = currentValue;\n\t\t}\n\t}\n\n\treturn headers;\n}\n\n/**\n * Extract email address from various formats\n * @param {string} value - Email field value\n * @returns {string|null} Extracted email address\n */\nfunction extractEmail(value) {\n\tif (!value) {\n\t\treturn null;\n\t}\n\n\t// Handle <email@example.com> format\n\tconst angleMatch = /<([^>]+)>/.exec(value);\n\tif (angleMatch) {\n\t\treturn angleMatch[1];\n\t}\n\n\t// Handle plain email\n\tconst emailMatch = /[\\w.+-]+@[\\w.-]+\\.\\w+/.exec(value);\n\tif (emailMatch) {\n\t\treturn emailMatch[0];\n\t}\n\n\treturn value.trim();\n}\n\n/**\n * Parse IP address from Source-IP field\n * @param {string} value - Source-IP field value\n * @returns {string|null} Parsed IP address\n */\nfunction parseSourceIp(value) {\n\tif (!value) {\n\t\treturn null;\n\t}\n\n\t// Handle IPv4\n\tconst ipv4Match = /((?:\\d{1,3}\\.){3}\\d{1,3})/.exec(value);\n\tif (ipv4Match) {\n\t\treturn ipv4Match[1];\n\t}\n\n\t// Handle IPv6\n\tconst ipv6Match = /([a-fA-F\\d:]+:+[a-fA-F\\d:]+)/.exec(value);\n\tif (ipv6Match) {\n\t\treturn ipv6Match[1];\n\t}\n\n\treturn value.trim();\n}\n\n/**\n * Parse date from various formats\n * @param {string} value - Date field value\n * @returns {Date|null} Parsed date\n */\nfunction parseDate(value) {\n\tif (!value) {\n\t\treturn null;\n\t}\n\n\ttry {\n\t\tconst date = new Date(value);\n\t\tif (Number.isNaN(date.getTime())) {\n\t\t\treturn null;\n\t\t}\n\n\t\treturn date;\n\t} catch {\n\t\treturn null;\n\t}\n}\n\n/**\n * Parse Reporting-MTA field\n * @param {string} value - Reporting-MTA field value\n * @returns {object} Parsed MTA info\n */\nfunction parseReportingMta(value) {\n\tif (!value) {\n\t\treturn null;\n\t}\n\n\t// Format: dns; hostname or smtp; hostname\n\tconst match = /^(\\w+);\\s*(.+)$/.exec(value.trim());\n\tif (match) {\n\t\treturn {\n\t\t\ttype: match[1].toLowerCase(),\n\t\t\tname: match[2].trim(),\n\t\t};\n\t}\n\n\treturn {\n\t\ttype: 'unknown',\n\t\tname: value.trim(),\n\t};\n}\n\n/**\n * Process feedback report part\n * @param {string} content - Feedback report content\n * @param {object} result - Result object to populate\n */\nfunction processFeedbackReport(content, result) {\n\tresult.rawFeedbackReport = content;\n\n\tconst headers = parseArfHeaders(content);\n\n\t// Required fields\n\tresult.feedbackType = headers.feedback_type?.toLowerCase() || null;\n\tresult.userAgent = headers.user_agent || null;\n\tresult.version = headers.version || '1';\n\n\t// Optional fields (single occurrence)\n\tresult.arrivalDate = parseDate(headers.arrival_date || headers.received_date);\n\tresult.sourceIp = parseSourceIp(headers.source_ip);\n\tresult.originalMailFrom = extractEmail(headers.original_mail_from);\n\tresult.originalEnvelopeId = headers.original_envelope_id || null;\n\tresult.reportingMta = parseReportingMta(headers.reporting_mta);\n\tresult.incidents = headers.incidents ? Number.parseInt(headers.incidents, 10) : 1;\n\n\t// Optional fields (multiple occurrences)\n\tif (headers.original_rcpt_to) {\n\t\tconst rcptTo = Array.isArray(headers.original_rcpt_to)\n\t\t\t? headers.original_rcpt_to\n\t\t\t: [headers.original_rcpt_to];\n\t\tresult.originalRcptTo = rcptTo.map(r => extractEmail(r)).filter(Boolean);\n\t}\n\n\tif (headers.authentication_results) {\n\t\tresult.authenticationResults = Array.isArray(headers.authentication_results)\n\t\t\t? headers.authentication_results\n\t\t\t: [headers.authentication_results];\n\t}\n\n\tif (headers.reported_domain) {\n\t\tresult.reportedDomain = Array.isArray(headers.reported_domain)\n\t\t\t? headers.reported_domain\n\t\t\t: [headers.reported_domain];\n\t}\n\n\tif (headers.reported_uri) {\n\t\tresult.reportedUri = Array.isArray(headers.reported_uri)\n\t\t\t? headers.reported_uri\n\t\t\t: [headers.reported_uri];\n\t}\n}\n\n/**\n * Process original message part\n * @param {string} content - Original message content\n * @param {object} result - Result object to populate\n * @returns {Promise<void>}\n */\nasync function processOriginalMessage(content, result) {\n\tresult.originalMessage = content;\n\n\t// Also parse the original message headers\n\ttry {\n\t\tconst originalParsed = await simpleParser(content, {\n\t\t\tskipHtmlToText: true,\n\t\t\tskipTextToHtml: true,\n\t\t\tskipImageLinks: true,\n\t\t});\n\t\tresult.originalHeaders = {};\n\t\tfor (const [key, value] of originalParsed.headers) {\n\t\t\tresult.originalHeaders[key] = value;\n\t\t}\n\t} catch {\n\t\t// Ignore parsing errors for original message\n\t}\n}\n\n/**\n * ARF Parser class\n */\nconst ArfParser = {\n\t/**\n\t * Check if a message is an ARF report\n\t * @param {object} parsed - Parsed email message from mailparser\n\t * @returns {boolean} True if message is ARF\n\t */\n\tisArfMessage(parsed) {\n\t\tconst contentType = parsed.headers?.get('content-type');\n\t\tif (!contentType) {\n\t\t\treturn false;\n\t\t}\n\n\t\t// Check for multipart/report with report-type=feedback-report\n\t\tconst value = typeof contentType === 'object' ? contentType.value : contentType;\n\t\tconst parameters = typeof contentType === 'object' ? contentType.params : {};\n\n\t\tif (!value?.toLowerCase().includes('multipart/report')) {\n\t\t\treturn false;\n\t\t}\n\n\t\tconst reportType = parameters?.['report-type'] || '';\n\t\treturn reportType.toLowerCase() === 'feedback-report';\n\t},\n\n\t/**\n\t * Parse an ARF message\n\t * @param {Buffer|string} source - Raw email message\n\t * @returns {Promise<object>} Parsed ARF report\n\t */\n\tasync parse(source) {\n\t\tconst rawContent = typeof source === 'string' ? source : source.toString('utf8');\n\n\t\t// Parse the email message for headers and basic validation\n\t\tconst parsed = await simpleParser(source, {\n\t\t\tskipHtmlToText: true,\n\t\t\tskipTextToHtml: true,\n\t\t\tskipImageLinks: true,\n\t\t});\n\n\t\t// Validate it's an ARF message\n\t\tif (!ArfParser.isArfMessage(parsed)) {\n\t\t\tthrow new Error('Not a valid ARF message: missing multipart/report with report-type=feedback-report');\n\t\t}\n\n\t\t// Get the boundary from the Content-Type header\n\t\tconst contentType = parsed.headers.get('content-type');\n\t\tconst boundary = typeof contentType === 'object'\n\t\t\t? contentType.params?.boundary\n\t\t\t: extractBoundary(contentType);\n\n\t\tif (!boundary) {\n\t\t\tthrow new Error('Not a valid ARF message: missing MIME boundary');\n\t\t}\n\n\t\tconst result = {\n\t\t\tisArf: true,\n\t\t\tversion: null,\n\t\t\tfeedbackType: null,\n\t\t\tuserAgent: null,\n\t\t\tarrivalDate: null,\n\t\t\tsourceIp: null,\n\t\t\toriginalMailFrom: null,\n\t\t\toriginalRcptTo: null,\n\t\t\treportingMta: null,\n\t\t\toriginalEnvelopeId: null,\n\t\t\tauthenticationResults: null,\n\t\t\treportedDomain: null,\n\t\t\treportedUri: null,\n\t\t\tincidents: 1,\n\t\t\thumanReadable: null,\n\t\t\toriginalMessage: null,\n\t\t\toriginalHeaders: null,\n\t\t\trawFeedbackReport: null,\n\t\t\t// Reputation fields (populated if sourceIp is available)\n\t\t\tisTruthSource: false,\n\t\t\tisAllowlisted: false,\n\t\t\tisDenylisted: false,\n\t\t\tallowlistValue: null,\n\t\t\tdenylistValue: null,\n\t\t};\n\n\t\t// Parse MIME parts manually to get message/rfc822 content\n\t\tconst parts = parseMimeParts(rawContent, boundary);\n\n\t\t// Find and categorize parts\n\t\tlet rfc822Part = null;\n\n\t\tfor (const part of parts) {\n\t\t\tconst partContentType = part.headers['content-type']?.toLowerCase() || '';\n\n\t\t\tif (partContentType.includes('text/plain')) {\n\t\t\t\tresult.humanReadable = part.body.trim();\n\t\t\t} else if (partContentType.includes('message/feedback-report')) {\n\t\t\t\tprocessFeedbackReport(part.body, result);\n\t\t\t} else if (partContentType.includes('message/rfc822')) {\n\t\t\t\trfc822Part = part;\n\t\t\t} else if (partContentType.includes('text/rfc822-headers')) {\n\t\t\t\tresult.originalHeaders = part.body.trim();\n\t\t\t}\n\t\t}\n\n\t\t// Process original message outside the loop to avoid await-in-loop\n\t\tif (rfc822Part) {\n\t\t\tawait processOriginalMessage(rfc822Part.body, result);\n\t\t}\n\n\t\t// Validate required fields\n\t\tif (!result.feedbackType) {\n\t\t\tthrow new Error('Invalid ARF message: missing required Feedback-Type field');\n\t\t}\n\n\t\tif (!result.userAgent) {\n\t\t\tthrow new Error('Invalid ARF message: missing required User-Agent field');\n\t\t}\n\n\t\t// Validate feedback type\n\t\tif (!VALID_FEEDBACK_TYPES.has(result.feedbackType)) {\n\t\t\t// Allow unknown types but mark as other\n\t\t\tresult.feedbackTypeOriginal = result.feedbackType;\n\t\t\tresult.feedbackType = 'other';\n\t\t}\n\n\t\t// Check reputation if sourceIp is available\n\t\tif (result.sourceIp) {\n\t\t\ttry {\n\t\t\t\tconst reputation = await checkReputation(result.sourceIp);\n\t\t\t\tresult.isTruthSource = reputation.isTruthSource;\n\t\t\t\tresult.isAllowlisted = reputation.isAllowlisted;\n\t\t\t\tresult.isDenylisted = reputation.isDenylisted;\n\t\t\t\tresult.allowlistValue = reputation.allowlistValue;\n\t\t\t\tresult.denylistValue = reputation.denylistValue;\n\t\t\t} catch {\n\t\t\t\t// Ignore reputation check errors\n\t\t\t}\n\t\t}\n\n\t\treturn result;\n\t},\n\n\t/**\n\t * Try to parse a message as ARF, return null if not ARF\n\t * @param {Buffer|string} source - Raw email message\n\t * @returns {Promise<object|null>} Parsed ARF report or null\n\t */\n\tasync tryParse(source) {\n\t\ttry {\n\t\t\treturn await ArfParser.parse(source);\n\t\t} catch {\n\t\t\treturn null;\n\t\t}\n\t},\n\n\t/**\n\t * Create an ARF report message\n\t * @param {object} options - Report options\n\t * @param {string} options.feedbackType - Type of feedback (abuse, fraud, virus, other)\n\t * @param {string} options.userAgent - User agent string\n\t * @param {string} options.from - From address for the report\n\t * @param {string} options.to - To address for the report\n\t * @param {string} options.originalMessage - Original message content\n\t * @param {string} [options.humanReadable] - Human-readable description\n\t * @param {string} [options.sourceIp] - Source IP of original message\n\t * @param {string} [options.originalMailFrom] - Original MAIL FROM\n\t * @param {string[]} [options.originalRcptTo] - Original RCPT TO addresses\n\t * @param {Date} [options.arrivalDate] - Arrival date of original message\n\t * @param {string} [options.reportingMta] - Reporting MTA name\n\t * @returns {string} ARF message as string\n\t */\n\tcreate(options) {\n\t\tconst {\n\t\t\tfeedbackType,\n\t\t\tuserAgent,\n\t\t\tfrom,\n\t\t\tto,\n\t\t\toriginalMessage,\n\t\t\thumanReadable = 'This is an abuse report.',\n\t\t\tsourceIp,\n\t\t\toriginalMailFrom,\n\t\t\toriginalRcptTo,\n\t\t\tarrivalDate,\n\t\t\treportingMta,\n\t\t} = options;\n\n\t\tif (!feedbackType || !userAgent || !from || !to || !originalMessage) {\n\t\t\tthrow new Error('Missing required fields for ARF report');\n\t\t}\n\n\t\tconst boundary = `arf_boundary_${Date.now()}_${Math.random().toString(36).slice(2)}`;\n\t\tconst date = new Date().toUTCString();\n\n\t\t// Build feedback report part\n\t\tlet feedbackReport = `Feedback-Type: ${feedbackType}\\r\\n`;\n\t\tfeedbackReport += `User-Agent: ${userAgent}\\r\\n`;\n\t\tfeedbackReport += 'Version: 1\\r\\n';\n\n\t\tif (sourceIp) {\n\t\t\tfeedbackReport += `Source-IP: ${sourceIp}\\r\\n`;\n\t\t}\n\n\t\tif (originalMailFrom) {\n\t\t\tfeedbackReport += `Original-Mail-From: <${originalMailFrom}>\\r\\n`;\n\t\t}\n\n\t\tif (originalRcptTo && originalRcptTo.length > 0) {\n\t\t\tfor (const rcpt of originalRcptTo) {\n\t\t\t\tfeedbackReport += `Original-Rcpt-To: <${rcpt}>\\r\\n`;\n\t\t\t}\n\t\t}\n\n\t\tif (arrivalDate) {\n\t\t\tfeedbackReport += `Arrival-Date: ${arrivalDate.toUTCString()}\\r\\n`;\n\t\t}\n\n\t\tif (reportingMta) {\n\t\t\tfeedbackReport += `Reporting-MTA: dns; ${reportingMta}\\r\\n`;\n\t\t}\n\n\t\t// Build the full message\n\t\tlet message = `From: ${from}\\r\\n`;\n\t\tmessage += `To: ${to}\\r\\n`;\n\t\tmessage += `Date: ${date}\\r\\n`;\n\t\tmessage += 'Subject: Abuse Report\\r\\n';\n\t\tmessage += 'MIME-Version: 1.0\\r\\n';\n\t\tmessage += `Content-Type: multipart/report; report-type=feedback-report; boundary=\"${boundary}\"\\r\\n`;\n\t\tmessage += '\\r\\n';\n\n\t\t// Part 1: Human-readable\n\t\tmessage += `--${boundary}\\r\\n`;\n\t\tmessage += 'Content-Type: text/plain; charset=\"utf-8\"\\r\\n';\n\t\tmessage += 'Content-Transfer-Encoding: 7bit\\r\\n';\n\t\tmessage += '\\r\\n';\n\t\tmessage += humanReadable + '\\r\\n';\n\t\tmessage += '\\r\\n';\n\n\t\t// Part 2: Machine-readable\n\t\tmessage += `--${boundary}\\r\\n`;\n\t\tmessage += 'Content-Type: message/feedback-report\\r\\n';\n\t\tmessage += '\\r\\n';\n\t\tmessage += feedbackReport;\n\t\tmessage += '\\r\\n';\n\n\t\t// Part 3: Original message\n\t\tmessage += `--${boundary}\\r\\n`;\n\t\tmessage += 'Content-Type: message/rfc822\\r\\n';\n\t\tmessage += 'Content-Disposition: inline\\r\\n';\n\t\tmessage += '\\r\\n';\n\t\tmessage += originalMessage;\n\t\tmessage += '\\r\\n';\n\n\t\tmessage += `--${boundary}--\\r\\n`;\n\n\t\treturn message;\n\t},\n};\n\nexport default ArfParser;\nexport {ArfParser, VALID_FEEDBACK_TYPES};\n", "/**\n * Forward Email Reputation API Client\n * Checks IP addresses, domains, and emails against Forward Email's reputation database\n */\n\nimport {debuglog} from 'node:util';\n\nconst debug = debuglog('spamscanner:reputation');\n\n// Default Forward Email API URL\nconst DEFAULT_API_URL = 'https://api.forwardemail.net/v1/reputation';\n\n// Cache for reputation results (TTL: 5 minutes)\nconst cache = new Map();\nconst CACHE_TTL = 5 * 60 * 1000; // 5 minutes\n\n/**\n * @typedef {Object} ReputationResult\n * @property {boolean} isTruthSource - Whether the sender is a known truth source\n * @property {string|null} truthSourceValue - The truth source entry that matched\n * @property {boolean} isAllowlisted - Whether the sender is allowlisted\n * @property {string|null} allowlistValue - The allowlist entry that matched\n * @property {boolean} isDenylisted - Whether the sender is denylisted\n * @property {string|null} denylistValue - The denylist entry that matched\n */\n\n/**\n * Check reputation for a single value (IP, domain, or email)\n * @param {string} value - The value to check\n * @param {Object} options - Options\n * @param {string} [options.apiUrl] - Custom API URL\n * @param {number} [options.timeout] - Request timeout in ms\n * @returns {Promise<ReputationResult>}\n */\nasync function checkReputation(value, options = {}) {\n\tconst {\n\t\tapiUrl = DEFAULT_API_URL,\n\t\ttimeout = 10_000,\n\t} = options;\n\n\tif (!value || typeof value !== 'string') {\n\t\treturn {\n\t\t\tisTruthSource: false,\n\t\t\ttruthSourceValue: null,\n\t\t\tisAllowlisted: false,\n\t\t\tallowlistValue: null,\n\t\t\tisDenylisted: false,\n\t\t\tdenylistValue: null,\n\t\t};\n\t}\n\n\t// Check cache first\n\tconst cacheKey = `${apiUrl}:${value}`;\n\tconst cached = cache.get(cacheKey);\n\tif (cached && Date.now() - cached.timestamp < CACHE_TTL) {\n\t\tdebug('Cache hit for %s', value);\n\t\treturn cached.result;\n\t}\n\n\ttry {\n\t\tconst url = new URL(apiUrl);\n\t\turl.searchParams.set('q', value);\n\n\t\tconst controller = new AbortController();\n\t\tconst timeoutId = setTimeout(() => controller.abort(), timeout);\n\n\t\tconst response = await fetch(url.toString(), {\n\t\t\tmethod: 'GET',\n\t\t\theaders: {\n\t\t\t\tAccept: 'application/json',\n\t\t\t\t'User-Agent': 'SpamScanner/6.0',\n\t\t\t},\n\t\t\tsignal: controller.signal,\n\t\t});\n\n\t\tclearTimeout(timeoutId);\n\n\t\tif (!response.ok) {\n\t\t\tdebug('API returned status %d for %s', response.status, value);\n\t\t\t// Return default values on error\n\t\t\treturn {\n\t\t\t\tisTruthSource: false,\n\t\t\t\ttruthSourceValue: null,\n\t\t\t\tisAllowlisted: false,\n\t\t\t\tallowlistValue: null,\n\t\t\t\tisDenylisted: false,\n\t\t\t\tdenylistValue: null,\n\t\t\t};\n\t\t}\n\n\t\tconst result = await response.json();\n\n\t\t// Normalize the result\n\t\tconst normalizedResult = {\n\t\t\tisTruthSource: Boolean(result.isTruthSource),\n\t\t\ttruthSourceValue: result.truthSourceValue || null,\n\t\t\tisAllowlisted: Boolean(result.isAllowlisted),\n\t\t\tallowlistValue: result.allowlistValue || null,\n\t\t\tisDenylisted: Boolean(result.isDenylisted),\n\t\t\tdenylistValue: result.denylistValue || null,\n\t\t};\n\n\t\t// Cache the result\n\t\tcache.set(cacheKey, {\n\t\t\tresult: normalizedResult,\n\t\t\ttimestamp: Date.now(),\n\t\t});\n\n\t\tdebug('Reputation check for %s: %o', value, normalizedResult);\n\t\treturn normalizedResult;\n\t} catch (error) {\n\t\tdebug('Reputation check failed for %s: %s', value, error.message);\n\t\t// Return default values on error\n\t\treturn {\n\t\t\tisTruthSource: false,\n\t\t\ttruthSourceValue: null,\n\t\t\tisAllowlisted: false,\n\t\t\tallowlistValue: null,\n\t\t\tisDenylisted: false,\n\t\t\tdenylistValue: null,\n\t\t};\n\t}\n}\n\n/**\n * Check reputation for multiple values in parallel\n * @param {string[]} values - Array of values to check (IPs, domains, emails)\n * @param {Object} options - Options\n * @returns {Promise<Map<string, ReputationResult>>}\n */\nasync function checkReputationBatch(values, options = {}) {\n\tconst uniqueValues = [...new Set(values.filter(Boolean))];\n\n\tconst results = await Promise.all(uniqueValues.map(async value => {\n\t\tconst result = await checkReputation(value, options);\n\t\treturn [value, result];\n\t}));\n\n\treturn new Map(results);\n}\n\n/**\n * Aggregate reputation results from multiple checks\n * @param {ReputationResult[]} results - Array of reputation results\n * @returns {ReputationResult}\n */\nfunction aggregateReputationResults(results) {\n\tconst aggregated = {\n\t\tisTruthSource: false,\n\t\ttruthSourceValue: null,\n\t\tisAllowlisted: false,\n\t\tallowlistValue: null,\n\t\tisDenylisted: false,\n\t\tdenylistValue: null,\n\t};\n\n\tfor (const result of results) {\n\t\t// Any truth source match is a truth source\n\t\tif (result.isTruthSource) {\n\t\t\taggregated.isTruthSource = true;\n\t\t\taggregated.truthSourceValue ||= result.truthSourceValue;\n\t\t}\n\n\t\t// Any allowlist match is allowlisted\n\t\tif (result.isAllowlisted) {\n\t\t\taggregated.isAllowlisted = true;\n\t\t\taggregated.allowlistValue ||= result.allowlistValue;\n\t\t}\n\n\t\t// Any denylist match is denylisted (takes precedence)\n\t\tif (result.isDenylisted) {\n\t\t\taggregated.isDenylisted = true;\n\t\t\taggregated.denylistValue ||= result.denylistValue;\n\t\t}\n\t}\n\n\treturn aggregated;\n}\n\n/**\n * Clear the reputation cache\n */\nfunction clearCache() {\n\tcache.clear();\n}\n\nexport {\n\tcheckReputation,\n\tcheckReputationBatch,\n\taggregateReputationResults,\n\tclearCache,\n\tDEFAULT_API_URL,\n};\n"],
5
+ "mappings": ";AAcA,SAAQ,oBAAmB;;;ACT3B,SAAQ,gBAAe;AAEvB,IAAM,QAAQ,SAAS,wBAAwB;AAG/C,IAAM,kBAAkB;AAGxB,IAAM,QAAQ,oBAAI,IAAI;AACtB,IAAM,YAAY,IAAI,KAAK;AAoB3B,eAAe,gBAAgB,OAAO,UAAU,CAAC,GAAG;AACnD,QAAM;AAAA,IACL,SAAS;AAAA,IACT,UAAU;AAAA,EACX,IAAI;AAEJ,MAAI,CAAC,SAAS,OAAO,UAAU,UAAU;AACxC,WAAO;AAAA,MACN,eAAe;AAAA,MACf,kBAAkB;AAAA,MAClB,eAAe;AAAA,MACf,gBAAgB;AAAA,MAChB,cAAc;AAAA,MACd,eAAe;AAAA,IAChB;AAAA,EACD;AAGA,QAAM,WAAW,GAAG,MAAM,IAAI,KAAK;AACnC,QAAM,SAAS,MAAM,IAAI,QAAQ;AACjC,MAAI,UAAU,KAAK,IAAI,IAAI,OAAO,YAAY,WAAW;AACxD,UAAM,oBAAoB,KAAK;AAC/B,WAAO,OAAO;AAAA,EACf;AAEA,MAAI;AACH,UAAM,MAAM,IAAI,IAAI,MAAM;AAC1B,QAAI,aAAa,IAAI,KAAK,KAAK;AAE/B,UAAM,aAAa,IAAI,gBAAgB;AACvC,UAAM,YAAY,WAAW,MAAM,WAAW,MAAM,GAAG,OAAO;AAE9D,UAAM,WAAW,MAAM,MAAM,IAAI,SAAS,GAAG;AAAA,MAC5C,QAAQ;AAAA,MACR,SAAS;AAAA,QACR,QAAQ;AAAA,QACR,cAAc;AAAA,MACf;AAAA,MACA,QAAQ,WAAW;AAAA,IACpB,CAAC;AAED,iBAAa,SAAS;AAEtB,QAAI,CAAC,SAAS,IAAI;AACjB,YAAM,iCAAiC,SAAS,QAAQ,KAAK;AAE7D,aAAO;AAAA,QACN,eAAe;AAAA,QACf,kBAAkB;AAAA,QAClB,eAAe;AAAA,QACf,gBAAgB;AAAA,QAChB,cAAc;AAAA,QACd,eAAe;AAAA,MAChB;AAAA,IACD;AAEA,UAAM,SAAS,MAAM,SAAS,KAAK;AAGnC,UAAM,mBAAmB;AAAA,MACxB,eAAe,QAAQ,OAAO,aAAa;AAAA,MAC3C,kBAAkB,OAAO,oBAAoB;AAAA,MAC7C,eAAe,QAAQ,OAAO,aAAa;AAAA,MAC3C,gBAAgB,OAAO,kBAAkB;AAAA,MACzC,cAAc,QAAQ,OAAO,YAAY;AAAA,MACzC,eAAe,OAAO,iBAAiB;AAAA,IACxC;AAGA,UAAM,IAAI,UAAU;AAAA,MACnB,QAAQ;AAAA,MACR,WAAW,KAAK,IAAI;AAAA,IACrB,CAAC;AAED,UAAM,+BAA+B,OAAO,gBAAgB;AAC5D,WAAO;AAAA,EACR,SAAS,OAAO;AACf,UAAM,sCAAsC,OAAO,MAAM,OAAO;AAEhE,WAAO;AAAA,MACN,eAAe;AAAA,MACf,kBAAkB;AAAA,MAClB,eAAe;AAAA,MACf,gBAAgB;AAAA,MAChB,cAAc;AAAA,MACd,eAAe;AAAA,IAChB;AAAA,EACD;AACD;;;ADtGA,IAAM,uBAAuB,oBAAI,IAAI;AAAA,EACpC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AACD,CAAC;AAOD,SAAS,gBAAgB,aAAa;AACrC,QAAM,QAAQ,kCAAkC,KAAK,WAAW;AAChE,SAAO,QAAQ,MAAM,CAAC,IAAI;AAC3B;AAQA,SAAS,eAAe,SAAS,UAAU;AAC1C,QAAM,QAAQ,CAAC;AACf,QAAM,gBAAgB,IAAI,OAAO,KAAK,SAAS,WAAW,uBAAuB,OAAO,QAAQ,CAAC,WAAW,GAAG;AAG/G,QAAM,WAAW,QAAQ,MAAM,aAAa;AAG5C,WAAS,QAAQ,GAAG,QAAQ,SAAS,QAAQ,SAAS;AACrD,UAAM,UAAU,SAAS,KAAK,EAAE,KAAK;AACrC,QAAI,CAAC,WAAW,YAAY,MAAM;AACjC;AAAA,IACD;AAGA,UAAM,kBAAkB,QAAQ,QAAQ,UAAU;AAClD,UAAM,qBAAqB,QAAQ,QAAQ,MAAM;AAEjD,QAAI;AACJ,QAAI;AAEJ,QAAI,oBAAoB,OAAO,uBAAuB,MAAM,kBAAkB,qBAAqB;AAClG,kBAAY;AACZ,kBAAY,kBAAkB;AAAA,IAC/B,WAAW,uBAAuB,IAAI;AACrC;AAAA,IACD,OAAO;AACN,kBAAY;AACZ,kBAAY,qBAAqB;AAAA,IAClC;AAEA,UAAM,gBAAgB,QAAQ,MAAM,GAAG,SAAS;AAChD,UAAM,OAAO,QAAQ,MAAM,SAAS;AAGpC,UAAM,UAAU,CAAC;AACjB,UAAM,cAAc,cAAc,MAAM,OAAO;AAC/C,QAAI,gBAAgB;AACpB,QAAI,eAAe;AAEnB,eAAW,QAAQ,aAAa;AAC/B,UAAI,OAAO,KAAK,IAAI,KAAK,eAAe;AACvC,wBAAgB,MAAM,KAAK,KAAK;AAAA,MACjC,OAAO;AACN,YAAI,eAAe;AAClB,kBAAQ,cAAc,YAAY,CAAC,IAAI;AAAA,QACxC;AAEA,cAAM,aAAa,KAAK,QAAQ,GAAG;AACnC,YAAI,eAAe,IAAI;AACtB,0BAAgB,KAAK,MAAM,GAAG,UAAU,EAAE,KAAK;AAC/C,yBAAe,KAAK,MAAM,aAAa,CAAC,EAAE,KAAK;AAAA,QAChD;AAAA,MACD;AAAA,IACD;AAEA,QAAI,eAAe;AAClB,cAAQ,cAAc,YAAY,CAAC,IAAI;AAAA,IACxC;AAEA,UAAM,KAAK,EAAC,SAAS,KAAI,CAAC;AAAA,EAC3B;AAEA,SAAO;AACR;AAOA,SAAS,gBAAgB,SAAS;AACjC,QAAM,UAAU,CAAC;AACjB,QAAM,QAAQ,QAAQ,MAAM,OAAO;AACnC,MAAI,eAAe;AACnB,MAAI,eAAe;AAEnB,aAAW,QAAQ,OAAO;AAEzB,QAAI,OAAO,KAAK,IAAI,KAAK,cAAc;AACtC,sBAAgB,MAAM,KAAK,KAAK;AAChC;AAAA,IACD;AAGA,QAAI,cAAc;AACjB,YAAM,YAAY,aAAa,YAAY,EAAE,WAAW,KAAK,GAAG;AAChE,UAAI,QAAQ,SAAS,GAAG;AAEvB,YAAI,MAAM,QAAQ,QAAQ,SAAS,CAAC,GAAG;AACtC,kBAAQ,SAAS,EAAE,KAAK,YAAY;AAAA,QACrC,OAAO;AACN,kBAAQ,SAAS,IAAI,CAAC,QAAQ,SAAS,GAAG,YAAY;AAAA,QACvD;AAAA,MACD,OAAO;AACN,gBAAQ,SAAS,IAAI;AAAA,MACtB;AAAA,IACD;AAGA,UAAM,QAAQ,oBAAoB,KAAK,IAAI;AAC3C,QAAI,OAAO;AACV,qBAAe,MAAM,CAAC;AACtB,qBAAe,MAAM,CAAC;AAAA,IACvB,OAAO;AACN,qBAAe;AACf,qBAAe;AAAA,IAChB;AAAA,EACD;AAGA,MAAI,cAAc;AACjB,UAAM,YAAY,aAAa,YAAY,EAAE,WAAW,KAAK,GAAG;AAChE,QAAI,QAAQ,SAAS,GAAG;AACvB,UAAI,MAAM,QAAQ,QAAQ,SAAS,CAAC,GAAG;AACtC,gBAAQ,SAAS,EAAE,KAAK,YAAY;AAAA,MACrC,OAAO;AACN,gBAAQ,SAAS,IAAI,CAAC,QAAQ,SAAS,GAAG,YAAY;AAAA,MACvD;AAAA,IACD,OAAO;AACN,cAAQ,SAAS,IAAI;AAAA,IACtB;AAAA,EACD;AAEA,SAAO;AACR;AAOA,SAAS,aAAa,OAAO;AAC5B,MAAI,CAAC,OAAO;AACX,WAAO;AAAA,EACR;AAGA,QAAM,aAAa,YAAY,KAAK,KAAK;AACzC,MAAI,YAAY;AACf,WAAO,WAAW,CAAC;AAAA,EACpB;AAGA,QAAM,aAAa,wBAAwB,KAAK,KAAK;AACrD,MAAI,YAAY;AACf,WAAO,WAAW,CAAC;AAAA,EACpB;AAEA,SAAO,MAAM,KAAK;AACnB;AAOA,SAAS,cAAc,OAAO;AAC7B,MAAI,CAAC,OAAO;AACX,WAAO;AAAA,EACR;AAGA,QAAM,YAAY,4BAA4B,KAAK,KAAK;AACxD,MAAI,WAAW;AACd,WAAO,UAAU,CAAC;AAAA,EACnB;AAGA,QAAM,YAAY,+BAA+B,KAAK,KAAK;AAC3D,MAAI,WAAW;AACd,WAAO,UAAU,CAAC;AAAA,EACnB;AAEA,SAAO,MAAM,KAAK;AACnB;AAOA,SAAS,UAAU,OAAO;AACzB,MAAI,CAAC,OAAO;AACX,WAAO;AAAA,EACR;AAEA,MAAI;AACH,UAAM,OAAO,IAAI,KAAK,KAAK;AAC3B,QAAI,OAAO,MAAM,KAAK,QAAQ,CAAC,GAAG;AACjC,aAAO;AAAA,IACR;AAEA,WAAO;AAAA,EACR,QAAQ;AACP,WAAO;AAAA,EACR;AACD;AAOA,SAAS,kBAAkB,OAAO;AACjC,MAAI,CAAC,OAAO;AACX,WAAO;AAAA,EACR;AAGA,QAAM,QAAQ,kBAAkB,KAAK,MAAM,KAAK,CAAC;AACjD,MAAI,OAAO;AACV,WAAO;AAAA,MACN,MAAM,MAAM,CAAC,EAAE,YAAY;AAAA,MAC3B,MAAM,MAAM,CAAC,EAAE,KAAK;AAAA,IACrB;AAAA,EACD;AAEA,SAAO;AAAA,IACN,MAAM;AAAA,IACN,MAAM,MAAM,KAAK;AAAA,EAClB;AACD;AAOA,SAAS,sBAAsB,SAAS,QAAQ;AAC/C,SAAO,oBAAoB;AAE3B,QAAM,UAAU,gBAAgB,OAAO;AAGvC,SAAO,eAAe,QAAQ,eAAe,YAAY,KAAK;AAC9D,SAAO,YAAY,QAAQ,cAAc;AACzC,SAAO,UAAU,QAAQ,WAAW;AAGpC,SAAO,cAAc,UAAU,QAAQ,gBAAgB,QAAQ,aAAa;AAC5E,SAAO,WAAW,cAAc,QAAQ,SAAS;AACjD,SAAO,mBAAmB,aAAa,QAAQ,kBAAkB;AACjE,SAAO,qBAAqB,QAAQ,wBAAwB;AAC5D,SAAO,eAAe,kBAAkB,QAAQ,aAAa;AAC7D,SAAO,YAAY,QAAQ,YAAY,OAAO,SAAS,QAAQ,WAAW,EAAE,IAAI;AAGhF,MAAI,QAAQ,kBAAkB;AAC7B,UAAM,SAAS,MAAM,QAAQ,QAAQ,gBAAgB,IAClD,QAAQ,mBACR,CAAC,QAAQ,gBAAgB;AAC5B,WAAO,iBAAiB,OAAO,IAAI,OAAK,aAAa,CAAC,CAAC,EAAE,OAAO,OAAO;AAAA,EACxE;AAEA,MAAI,QAAQ,wBAAwB;AACnC,WAAO,wBAAwB,MAAM,QAAQ,QAAQ,sBAAsB,IACxE,QAAQ,yBACR,CAAC,QAAQ,sBAAsB;AAAA,EACnC;AAEA,MAAI,QAAQ,iBAAiB;AAC5B,WAAO,iBAAiB,MAAM,QAAQ,QAAQ,eAAe,IAC1D,QAAQ,kBACR,CAAC,QAAQ,eAAe;AAAA,EAC5B;AAEA,MAAI,QAAQ,cAAc;AACzB,WAAO,cAAc,MAAM,QAAQ,QAAQ,YAAY,IACpD,QAAQ,eACR,CAAC,QAAQ,YAAY;AAAA,EACzB;AACD;AAQA,eAAe,uBAAuB,SAAS,QAAQ;AACtD,SAAO,kBAAkB;AAGzB,MAAI;AACH,UAAM,iBAAiB,MAAM,aAAa,SAAS;AAAA,MAClD,gBAAgB;AAAA,MAChB,gBAAgB;AAAA,MAChB,gBAAgB;AAAA,IACjB,CAAC;AACD,WAAO,kBAAkB,CAAC;AAC1B,eAAW,CAAC,KAAK,KAAK,KAAK,eAAe,SAAS;AAClD,aAAO,gBAAgB,GAAG,IAAI;AAAA,IAC/B;AAAA,EACD,QAAQ;AAAA,EAER;AACD;AAKA,IAAM,YAAY;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAMjB,aAAa,QAAQ;AACpB,UAAM,cAAc,OAAO,SAAS,IAAI,cAAc;AACtD,QAAI,CAAC,aAAa;AACjB,aAAO;AAAA,IACR;AAGA,UAAM,QAAQ,OAAO,gBAAgB,WAAW,YAAY,QAAQ;AACpE,UAAM,aAAa,OAAO,gBAAgB,WAAW,YAAY,SAAS,CAAC;AAE3E,QAAI,CAAC,OAAO,YAAY,EAAE,SAAS,kBAAkB,GAAG;AACvD,aAAO;AAAA,IACR;AAEA,UAAM,aAAa,aAAa,aAAa,KAAK;AAClD,WAAO,WAAW,YAAY,MAAM;AAAA,EACrC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAM,MAAM,QAAQ;AACnB,UAAM,aAAa,OAAO,WAAW,WAAW,SAAS,OAAO,SAAS,MAAM;AAG/E,UAAM,SAAS,MAAM,aAAa,QAAQ;AAAA,MACzC,gBAAgB;AAAA,MAChB,gBAAgB;AAAA,MAChB,gBAAgB;AAAA,IACjB,CAAC;AAGD,QAAI,CAAC,UAAU,aAAa,MAAM,GAAG;AACpC,YAAM,IAAI,MAAM,oFAAoF;AAAA,IACrG;AAGA,UAAM,cAAc,OAAO,QAAQ,IAAI,cAAc;AACrD,UAAM,WAAW,OAAO,gBAAgB,WACrC,YAAY,QAAQ,WACpB,gBAAgB,WAAW;AAE9B,QAAI,CAAC,UAAU;AACd,YAAM,IAAI,MAAM,gDAAgD;AAAA,IACjE;AAEA,UAAM,SAAS;AAAA,MACd,OAAO;AAAA,MACP,SAAS;AAAA,MACT,cAAc;AAAA,MACd,WAAW;AAAA,MACX,aAAa;AAAA,MACb,UAAU;AAAA,MACV,kBAAkB;AAAA,MAClB,gBAAgB;AAAA,MAChB,cAAc;AAAA,MACd,oBAAoB;AAAA,MACpB,uBAAuB;AAAA,MACvB,gBAAgB;AAAA,MAChB,aAAa;AAAA,MACb,WAAW;AAAA,MACX,eAAe;AAAA,MACf,iBAAiB;AAAA,MACjB,iBAAiB;AAAA,MACjB,mBAAmB;AAAA;AAAA,MAEnB,eAAe;AAAA,MACf,eAAe;AAAA,MACf,cAAc;AAAA,MACd,gBAAgB;AAAA,MAChB,eAAe;AAAA,IAChB;AAGA,UAAM,QAAQ,eAAe,YAAY,QAAQ;AAGjD,QAAI,aAAa;AAEjB,eAAW,QAAQ,OAAO;AACzB,YAAM,kBAAkB,KAAK,QAAQ,cAAc,GAAG,YAAY,KAAK;AAEvE,UAAI,gBAAgB,SAAS,YAAY,GAAG;AAC3C,eAAO,gBAAgB,KAAK,KAAK,KAAK;AAAA,MACvC,WAAW,gBAAgB,SAAS,yBAAyB,GAAG;AAC/D,8BAAsB,KAAK,MAAM,MAAM;AAAA,MACxC,WAAW,gBAAgB,SAAS,gBAAgB,GAAG;AACtD,qBAAa;AAAA,MACd,WAAW,gBAAgB,SAAS,qBAAqB,GAAG;AAC3D,eAAO,kBAAkB,KAAK,KAAK,KAAK;AAAA,MACzC;AAAA,IACD;AAGA,QAAI,YAAY;AACf,YAAM,uBAAuB,WAAW,MAAM,MAAM;AAAA,IACrD;AAGA,QAAI,CAAC,OAAO,cAAc;AACzB,YAAM,IAAI,MAAM,2DAA2D;AAAA,IAC5E;AAEA,QAAI,CAAC,OAAO,WAAW;AACtB,YAAM,IAAI,MAAM,wDAAwD;AAAA,IACzE;AAGA,QAAI,CAAC,qBAAqB,IAAI,OAAO,YAAY,GAAG;AAEnD,aAAO,uBAAuB,OAAO;AACrC,aAAO,eAAe;AAAA,IACvB;AAGA,QAAI,OAAO,UAAU;AACpB,UAAI;AACH,cAAM,aAAa,MAAM,gBAAgB,OAAO,QAAQ;AACxD,eAAO,gBAAgB,WAAW;AAClC,eAAO,gBAAgB,WAAW;AAClC,eAAO,eAAe,WAAW;AACjC,eAAO,iBAAiB,WAAW;AACnC,eAAO,gBAAgB,WAAW;AAAA,MACnC,QAAQ;AAAA,MAER;AAAA,IACD;AAEA,WAAO;AAAA,EACR;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAM,SAAS,QAAQ;AACtB,QAAI;AACH,aAAO,MAAM,UAAU,MAAM,MAAM;AAAA,IACpC,QAAQ;AACP,aAAO;AAAA,IACR;AAAA,EACD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAkBA,OAAO,SAAS;AACf,UAAM;AAAA,MACL;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,gBAAgB;AAAA,MAChB;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACD,IAAI;AAEJ,QAAI,CAAC,gBAAgB,CAAC,aAAa,CAAC,QAAQ,CAAC,MAAM,CAAC,iBAAiB;AACpE,YAAM,IAAI,MAAM,wCAAwC;AAAA,IACzD;AAEA,UAAM,WAAW,gBAAgB,KAAK,IAAI,CAAC,IAAI,KAAK,OAAO,EAAE,SAAS,EAAE,EAAE,MAAM,CAAC,CAAC;AAClF,UAAM,QAAO,oBAAI,KAAK,GAAE,YAAY;AAGpC,QAAI,iBAAiB,kBAAkB,YAAY;AAAA;AACnD,sBAAkB,eAAe,SAAS;AAAA;AAC1C,sBAAkB;AAElB,QAAI,UAAU;AACb,wBAAkB,cAAc,QAAQ;AAAA;AAAA,IACzC;AAEA,QAAI,kBAAkB;AACrB,wBAAkB,wBAAwB,gBAAgB;AAAA;AAAA,IAC3D;AAEA,QAAI,kBAAkB,eAAe,SAAS,GAAG;AAChD,iBAAW,QAAQ,gBAAgB;AAClC,0BAAkB,sBAAsB,IAAI;AAAA;AAAA,MAC7C;AAAA,IACD;AAEA,QAAI,aAAa;AAChB,wBAAkB,iBAAiB,YAAY,YAAY,CAAC;AAAA;AAAA,IAC7D;AAEA,QAAI,cAAc;AACjB,wBAAkB,uBAAuB,YAAY;AAAA;AAAA,IACtD;AAGA,QAAI,UAAU,SAAS,IAAI;AAAA;AAC3B,eAAW,OAAO,EAAE;AAAA;AACpB,eAAW,SAAS,IAAI;AAAA;AACxB,eAAW;AACX,eAAW;AACX,eAAW,0EAA0E,QAAQ;AAAA;AAC7F,eAAW;AAGX,eAAW,KAAK,QAAQ;AAAA;AACxB,eAAW;AACX,eAAW;AACX,eAAW;AACX,eAAW,gBAAgB;AAC3B,eAAW;AAGX,eAAW,KAAK,QAAQ;AAAA;AACxB,eAAW;AACX,eAAW;AACX,eAAW;AACX,eAAW;AAGX,eAAW,KAAK,QAAQ;AAAA;AACxB,eAAW;AACX,eAAW;AACX,eAAW;AACX,eAAW;AACX,eAAW;AAEX,eAAW,KAAK,QAAQ;AAAA;AAExB,WAAO;AAAA,EACR;AACD;AAEA,IAAO,cAAQ;",
6
+ "names": []
7
+ }