emailengine-app 2.60.1 → 2.61.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/CHANGELOG.md +19 -0
- package/bin/emailengine.js +388 -8
- package/data/google-crawlers.json +1 -1
- package/lib/arf-detect.js +3 -1
- package/lib/bounce-detect.js +271 -53
- package/lib/email-client/base-client.js +44 -6
- package/lib/email-client/imap/mailbox.js +45 -6
- package/package.json +4 -5
- package/sbom.json +1 -1
- package/static/licenses.html +6 -6
- package/test/autoreply-test.js +327 -0
- package/test/complaint-test.js +256 -0
- package/test/fixtures/autoreply/LICENSE +27 -0
- package/test/fixtures/autoreply/rfc3834-01.eml +23 -0
- package/test/fixtures/autoreply/rfc3834-02.eml +24 -0
- package/test/fixtures/autoreply/rfc3834-03.eml +26 -0
- package/test/fixtures/autoreply/rfc3834-04.eml +48 -0
- package/test/fixtures/autoreply/rfc3834-05.eml +19 -0
- package/test/fixtures/autoreply/rfc3834-06.eml +59 -0
- package/test/fixtures/complaints/LICENSE +27 -0
- package/test/fixtures/complaints/amazonses.eml +72 -0
- package/test/fixtures/complaints/dmarc.eml +59 -0
- package/test/fixtures/complaints/hotmail.eml +49 -0
- package/test/fixtures/complaints/optout.eml +40 -0
- package/test/fixtures/complaints/standard-arf.eml +68 -0
- package/test/fixtures/complaints/yahoo.eml +68 -0
- package/translations/messages.pot +13 -13
- package/workers/api.js +4 -4
- package/help.txt +0 -84
package/lib/bounce-detect.js
CHANGED
|
@@ -196,6 +196,11 @@ function parseDeliveryHeaders(content) {
|
|
|
196
196
|
* @returns {Object} [returns.messageHeaders] - Headers from the original message
|
|
197
197
|
*/
|
|
198
198
|
async function bounceDetect(sourceStream) {
|
|
199
|
+
// Validate input parameter
|
|
200
|
+
if (!sourceStream) {
|
|
201
|
+
return {};
|
|
202
|
+
}
|
|
203
|
+
|
|
199
204
|
let parsed;
|
|
200
205
|
|
|
201
206
|
// Use pre-parsed email if available to avoid parsing twice
|
|
@@ -209,17 +214,19 @@ async function bounceDetect(sourceStream) {
|
|
|
209
214
|
let result = {};
|
|
210
215
|
|
|
211
216
|
// Look for standard bounce attachments as defined in RFC 3464
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
let
|
|
217
|
+
// Use case-insensitive comparison per RFC 2045 and handle missing attachments array
|
|
218
|
+
const attachments = parsed.attachments || [];
|
|
219
|
+
let deliveryStatus = attachments.find(attachment => attachment.contentType?.toLowerCase() === 'message/delivery-status');
|
|
220
|
+
let messageHeaders = attachments.find(attachment => attachment.contentType?.toLowerCase() === 'text/rfc822-headers');
|
|
221
|
+
let originalMessage = attachments.find(attachment => attachment.contentType?.toLowerCase() === 'message/rfc822');
|
|
215
222
|
|
|
216
223
|
// Zoho Mail uses non-standard content type for original message
|
|
217
|
-
let zohoOriginalMessage =
|
|
224
|
+
let zohoOriginalMessage = attachments.find(attachment => attachment.contentType?.toLowerCase() === 'text/rfc822');
|
|
218
225
|
|
|
219
226
|
let parsedMessageHeaders;
|
|
220
227
|
|
|
221
228
|
// Handle Exchange/Outlook specific header for failed recipients
|
|
222
|
-
if (parsed.headers.has('x-failed-recipients')) {
|
|
229
|
+
if (parsed.headers && parsed.headers.has('x-failed-recipients')) {
|
|
223
230
|
// Parse comma and space separated list of failed recipients
|
|
224
231
|
let list = []
|
|
225
232
|
.concat(parsed.headers.get('x-failed-recipients'))
|
|
@@ -236,7 +243,7 @@ async function bounceDetect(sourceStream) {
|
|
|
236
243
|
|
|
237
244
|
// Special handling for Amazon WorkMail bounces
|
|
238
245
|
// WorkMail doesn't use standard delivery-status attachments
|
|
239
|
-
if (parsed.headers.has('x-mailer')) {
|
|
246
|
+
if (parsed.headers && parsed.headers.has('x-mailer')) {
|
|
240
247
|
let xMailer = []
|
|
241
248
|
.concat(parsed.headers.get('x-mailer') || '')
|
|
242
249
|
.shift()
|
|
@@ -280,13 +287,15 @@ async function bounceDetect(sourceStream) {
|
|
|
280
287
|
// Build headers object from headerLines for consistent access
|
|
281
288
|
let headers = {};
|
|
282
289
|
|
|
283
|
-
parsedOriginal.headerLines.
|
|
284
|
-
|
|
285
|
-
headers[entry.key]
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
+
if (parsedOriginal.headerLines && Array.isArray(parsedOriginal.headerLines)) {
|
|
291
|
+
parsedOriginal.headerLines.forEach(entry => {
|
|
292
|
+
if (!headers[entry.key]) {
|
|
293
|
+
headers[entry.key] = [];
|
|
294
|
+
}
|
|
295
|
+
// Preserve the original header value (after the key and colon)
|
|
296
|
+
headers[entry.key].push(entry.line.substring(entry.key.length + 1).trim());
|
|
297
|
+
});
|
|
298
|
+
}
|
|
290
299
|
|
|
291
300
|
parsedMessageHeaders = headers;
|
|
292
301
|
|
|
@@ -296,8 +305,9 @@ async function bounceDetect(sourceStream) {
|
|
|
296
305
|
}
|
|
297
306
|
|
|
298
307
|
// Some bounces include delivery-status within the original message attachment
|
|
308
|
+
const originalAttachments = parsedOriginal.attachments || [];
|
|
299
309
|
if (!deliveryStatus) {
|
|
300
|
-
deliveryStatus =
|
|
310
|
+
deliveryStatus = originalAttachments.find(attachment => attachment.contentType?.toLowerCase() === 'message/delivery-status');
|
|
301
311
|
if (deliveryStatus) {
|
|
302
312
|
result = Object.assign(result, parseDeliveryStatus(deliveryStatus.content) || {});
|
|
303
313
|
}
|
|
@@ -305,7 +315,7 @@ async function bounceDetect(sourceStream) {
|
|
|
305
315
|
|
|
306
316
|
// Similarly for headers attachment
|
|
307
317
|
if (!messageHeaders) {
|
|
308
|
-
messageHeaders =
|
|
318
|
+
messageHeaders = originalAttachments.find(attachment => attachment.contentType?.toLowerCase() === 'text/rfc822-headers');
|
|
309
319
|
if (messageHeaders) {
|
|
310
320
|
result = Object.assign(result, parseDeliveryHeaders(messageHeaders.content) || {});
|
|
311
321
|
}
|
|
@@ -319,23 +329,61 @@ async function bounceDetect(sourceStream) {
|
|
|
319
329
|
}
|
|
320
330
|
|
|
321
331
|
// Handle Zoho's non-standard bounce format
|
|
332
|
+
// Zoho uses text/rfc822 content type (instead of standard message/rfc822)
|
|
333
|
+
// and includes original message headers in a non-standard format
|
|
322
334
|
if (zohoOriginalMessage) {
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
335
|
+
// Verify this is actually a Zoho bounce to prevent false positives
|
|
336
|
+
let isZohoBounce = false;
|
|
337
|
+
|
|
338
|
+
// Check for Zoho-specific indicators in the bounce email headers
|
|
339
|
+
if (parsed.headers) {
|
|
340
|
+
// Check X-Mailer header
|
|
341
|
+
const xMailer = parsed.headers.has('x-mailer')
|
|
342
|
+
? []
|
|
343
|
+
.concat(parsed.headers.get('x-mailer') || '')
|
|
344
|
+
.join(' ')
|
|
345
|
+
.toLowerCase()
|
|
346
|
+
: '';
|
|
347
|
+
// Check From header for Zoho domain
|
|
348
|
+
const fromHeader = parsed.headers.has('from')
|
|
349
|
+
? []
|
|
350
|
+
.concat(parsed.headers.get('from') || '')
|
|
351
|
+
.map(f => (f.address || f.text || f || '').toLowerCase())
|
|
352
|
+
.join(' ')
|
|
353
|
+
: '';
|
|
354
|
+
// Check User-Agent header
|
|
355
|
+
const userAgent = parsed.headers.has('user-agent')
|
|
356
|
+
? []
|
|
357
|
+
.concat(parsed.headers.get('user-agent') || '')
|
|
358
|
+
.join(' ')
|
|
359
|
+
.toLowerCase()
|
|
360
|
+
: '';
|
|
361
|
+
|
|
362
|
+
isZohoBounce = xMailer.includes('zoho') || fromHeader.includes('zoho') || userAgent.includes('zoho');
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (isZohoBounce) {
|
|
366
|
+
try {
|
|
367
|
+
let parsedOriginal = await simpleParser(zohoOriginalMessage.content, { keepDeliveryStatus: true });
|
|
368
|
+
// Zoho sometimes includes headers in the text body with no actual header structure
|
|
369
|
+
if (parsedOriginal && parsedOriginal.text && (!parsedOriginal.headerLines || !parsedOriginal.headerLines.filter(h => h.key).length)) {
|
|
370
|
+
// Remove leading spaces that indicate header continuation
|
|
371
|
+
let headerContent = (parsedOriginal.text || '').toString().replace(/^ (?=[^ ])/gm, '');
|
|
372
|
+
|
|
373
|
+
// Additional validation: content should look like email headers
|
|
374
|
+
// Check for common header patterns (Received:, Message-ID:, Date:, From:)
|
|
375
|
+
if (/^\s*(Received|Message-ID|Date|From):/im.test(headerContent)) {
|
|
376
|
+
let headers = libmime.decodeHeaders(headerContent);
|
|
377
|
+
if (headers && headers['message-id']) {
|
|
378
|
+
parsedMessageHeaders = headers;
|
|
379
|
+
result = Object.assign(result, parseDeliveryHeaders(headerContent) || {});
|
|
380
|
+
}
|
|
381
|
+
}
|
|
333
382
|
}
|
|
383
|
+
} catch (E) {
|
|
384
|
+
// Failed to parse Zoho attachment
|
|
385
|
+
// Continue processing as this is not critical
|
|
334
386
|
}
|
|
335
|
-
} catch (E) {
|
|
336
|
-
// Failed to parse Zoho attachment
|
|
337
|
-
// Continue processing as this is not critical
|
|
338
|
-
// TODO: Consider logging this error for debugging
|
|
339
387
|
}
|
|
340
388
|
}
|
|
341
389
|
|
|
@@ -343,13 +391,20 @@ async function bounceDetect(sourceStream) {
|
|
|
343
391
|
let text = (parsed.text || '').toString();
|
|
344
392
|
|
|
345
393
|
// Some bounces put the content in an attachment with no content-type
|
|
346
|
-
if (!text && !parsed.html &&
|
|
347
|
-
let emptyContent =
|
|
394
|
+
if (!text && !parsed.html && attachments.length) {
|
|
395
|
+
let emptyContent = attachments.find(attachment => !attachment.contentType);
|
|
348
396
|
if (emptyContent) {
|
|
349
397
|
text = (emptyContent.content || '').toString();
|
|
350
398
|
}
|
|
351
399
|
}
|
|
352
400
|
|
|
401
|
+
// Limit text size for pattern matching to prevent ReDoS attacks
|
|
402
|
+
// Bounce information is typically in the first part of the message
|
|
403
|
+
const MAX_TEXT_LENGTH = 50000;
|
|
404
|
+
if (text.length > MAX_TEXT_LENGTH) {
|
|
405
|
+
text = text.substring(0, MAX_TEXT_LENGTH);
|
|
406
|
+
}
|
|
407
|
+
|
|
353
408
|
// Pattern matching for non-standard bounce formats
|
|
354
409
|
// Only proceed if we haven't found the recipient or response message yet
|
|
355
410
|
|
|
@@ -397,10 +452,11 @@ async function bounceDetect(sourceStream) {
|
|
|
397
452
|
* Delivery to the following recipient failed permanently:
|
|
398
453
|
* user@example.com
|
|
399
454
|
*/
|
|
400
|
-
|
|
455
|
+
// Use bounded quantifier to prevent ReDoS with nested [\s\w]* patterns
|
|
456
|
+
let m = text.match(/Delivery[^:]{0,100}failed[^:]{0,50}:/im);
|
|
401
457
|
if (m) {
|
|
402
458
|
// Look for email address on the next line
|
|
403
|
-
let addrMatch = text.
|
|
459
|
+
let addrMatch = text.substring(m.index + m[0].length).match(/\s*\n*\s*([^\s\n]+)/);
|
|
404
460
|
if (addrMatch && addrMatch[1].indexOf('@') >= 0) {
|
|
405
461
|
result.recipient = addrMatch[1].trim();
|
|
406
462
|
}
|
|
@@ -463,16 +519,17 @@ async function bounceDetect(sourceStream) {
|
|
|
463
519
|
* <user@example.com>:
|
|
464
520
|
* 550: 5.1.1 <user@example.com>: Recipient address rejected: User unknown in relay recipient table
|
|
465
521
|
*/
|
|
466
|
-
|
|
522
|
+
// Use bounded quantifier to prevent ReDoS
|
|
523
|
+
let m = text.match(/\n<([^>@]+@[^>]+)>:[\s\r\n]{1,100}/);
|
|
467
524
|
if (m) {
|
|
468
525
|
result.recipient = result.recipient || m[1];
|
|
469
526
|
|
|
470
|
-
let end = text.
|
|
527
|
+
let end = text.substring(m.index + m[0].length).match(/\r?\n\r?\n|$/);
|
|
471
528
|
if (end) {
|
|
472
529
|
result.action = result.action || 'failed';
|
|
473
530
|
|
|
474
531
|
let message = text
|
|
475
|
-
.
|
|
532
|
+
.substring(m.index + m[0].length, m.index + m[0].length + end.index)
|
|
476
533
|
.replace(/\s+/g, ' ')
|
|
477
534
|
.trim();
|
|
478
535
|
|
|
@@ -509,6 +566,39 @@ async function bounceDetect(sourceStream) {
|
|
|
509
566
|
}
|
|
510
567
|
}
|
|
511
568
|
|
|
569
|
+
// Google's old bounce format (pre-2015 style)
|
|
570
|
+
if (!result.action || !result.response || !result.response.message) {
|
|
571
|
+
/*
|
|
572
|
+
* Match pattern like:
|
|
573
|
+
* Delivery to the following recipient failed permanently:
|
|
574
|
+
*
|
|
575
|
+
* user@example.com
|
|
576
|
+
*
|
|
577
|
+
* Technical details of permanent failure:
|
|
578
|
+
* Google tried to deliver your message, but it was rejected...
|
|
579
|
+
*/
|
|
580
|
+
let m = text.match(/Delivery to the following recipient(?:s)? failed permanently:\s+([^\s@]+@[^\s]+)/i);
|
|
581
|
+
if (m) {
|
|
582
|
+
result.recipient = result.recipient || m[1].trim();
|
|
583
|
+
result.action = result.action || 'failed';
|
|
584
|
+
|
|
585
|
+
// Look for technical details
|
|
586
|
+
let techMatch = text.match(/Technical details of (?:permanent )?failure:\s*(.+?)(?:\n\n|-{3,}|Original message)/is);
|
|
587
|
+
if (techMatch && techMatch[1]) {
|
|
588
|
+
result.response = result.response || {};
|
|
589
|
+
result.response.message = result.response.message || techMatch[1].replace(/\s+/g, ' ').trim();
|
|
590
|
+
|
|
591
|
+
// Extract enhanced status code if present
|
|
592
|
+
if (!result.response.status) {
|
|
593
|
+
let statusMatch = techMatch[1].match(/(\d+\.\d+\.\d+)/);
|
|
594
|
+
if (statusMatch) {
|
|
595
|
+
result.response.status = statusMatch[1];
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
|
|
512
602
|
if (!result.response || !result.response.message) {
|
|
513
603
|
/*
|
|
514
604
|
* Match pattern like:
|
|
@@ -559,7 +649,7 @@ async function bounceDetect(sourceStream) {
|
|
|
559
649
|
}
|
|
560
650
|
}
|
|
561
651
|
|
|
562
|
-
// Complex pattern for certain MTA formats
|
|
652
|
+
// Complex pattern for certain MTA formats (Postfix style)
|
|
563
653
|
if (result.recipient && result.response && !result.response.message && /A message that you sent could not be delivered/.test(text)) {
|
|
564
654
|
/*
|
|
565
655
|
* Match pattern like:
|
|
@@ -569,26 +659,154 @@ async function bounceDetect(sourceStream) {
|
|
|
569
659
|
* Recipient address rejected: User unknown in relay recipient table
|
|
570
660
|
*/
|
|
571
661
|
|
|
572
|
-
//
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
if
|
|
577
|
-
|
|
662
|
+
// Use iterative approach instead of complex regex to prevent ReDoS
|
|
663
|
+
// Split by double newlines to find blocks, then look for the recipient's block
|
|
664
|
+
const blocks = text.split(/\r?\n\r?\n/);
|
|
665
|
+
for (const block of blocks) {
|
|
666
|
+
// Check if this block starts with or contains our recipient email
|
|
667
|
+
if (block.includes(result.recipient)) {
|
|
668
|
+
// Look for "host ... :" pattern followed by error message
|
|
669
|
+
const hostMatch = block.match(/\shost\s+[^\n:]+:\s*(.+)/s);
|
|
670
|
+
if (hostMatch && hostMatch[1]) {
|
|
671
|
+
const message = hostMatch[1].replace(/\s+/g, ' ').trim();
|
|
672
|
+
if (message) {
|
|
673
|
+
result.response = result.response || {};
|
|
674
|
+
result.response.message = message;
|
|
675
|
+
break;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
578
678
|
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
579
681
|
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
682
|
+
// Exim-style bounce format
|
|
683
|
+
if (result.recipient && (!result.response || !result.response.message) && /The following address\(?es\)? failed:/i.test(text)) {
|
|
684
|
+
/*
|
|
685
|
+
* Match pattern like (Exim MTA):
|
|
686
|
+
* The following address(es) failed:
|
|
687
|
+
*
|
|
688
|
+
* info@example.com
|
|
689
|
+
* Domain example.org has exceeded the max defers and failures per hour (5/5 (100%)) allowed. Message discarded.
|
|
690
|
+
*
|
|
691
|
+
* The recipient is indented, and the error message follows with deeper indentation.
|
|
692
|
+
*/
|
|
693
|
+
const blocks = text.split(/\r?\n\r?\n/);
|
|
694
|
+
for (const block of blocks) {
|
|
695
|
+
// Check if this block contains our recipient email
|
|
696
|
+
if (block.includes(result.recipient)) {
|
|
697
|
+
// Split block into lines and find the recipient line
|
|
698
|
+
const lines = block.split(/\r?\n/);
|
|
699
|
+
let foundRecipient = false;
|
|
700
|
+
let messageLines = [];
|
|
701
|
+
|
|
702
|
+
for (const line of lines) {
|
|
703
|
+
if (!foundRecipient) {
|
|
704
|
+
// Look for line containing recipient email
|
|
705
|
+
if (line.includes(result.recipient)) {
|
|
706
|
+
foundRecipient = true;
|
|
707
|
+
}
|
|
708
|
+
} else {
|
|
709
|
+
// After recipient line, collect indented lines as error message
|
|
710
|
+
// Error lines typically start with more indentation than the recipient
|
|
711
|
+
if (/^\s{2,}/.test(line) && line.trim()) {
|
|
712
|
+
messageLines.push(line.trim());
|
|
713
|
+
} else if (line.trim() === '') {
|
|
714
|
+
// Empty line might separate multiple errors, stop here
|
|
715
|
+
break;
|
|
716
|
+
} else {
|
|
717
|
+
// Non-indented non-empty line means end of this recipient's block
|
|
718
|
+
break;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
586
722
|
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
723
|
+
if (messageLines.length > 0) {
|
|
724
|
+
const message = messageLines.join(' ').replace(/\s+/g, ' ').trim();
|
|
725
|
+
if (message) {
|
|
726
|
+
result.response = result.response || {};
|
|
727
|
+
result.response.message = message;
|
|
728
|
+
result.action = result.action || 'failed';
|
|
729
|
+
break;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// KDDI / Japanese carrier style bounce
|
|
737
|
+
if (!result.recipient) {
|
|
738
|
+
/*
|
|
739
|
+
* Match pattern like:
|
|
740
|
+
* Could not be delivered to: <kijitora@example.com>
|
|
741
|
+
*/
|
|
742
|
+
let m = text.match(/Could not be delivered to:\s*<?([^\s<>\n]+@[^\s<>\n]+)>?/i);
|
|
743
|
+
if (m) {
|
|
744
|
+
result.recipient = m[1].trim();
|
|
745
|
+
result.action = result.action || 'failed';
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// X6 / Generic MTA style with "permanent errors"
|
|
750
|
+
if (!result.recipient) {
|
|
751
|
+
/*
|
|
752
|
+
* Match pattern like:
|
|
753
|
+
* The following recipients returned permanent errors: kijitora@example.com
|
|
754
|
+
*/
|
|
755
|
+
let m = text.match(/returned permanent errors?:\s*<?([^\s<>,\n]+@[^\s<>,\n]+)>?/i);
|
|
756
|
+
if (m) {
|
|
757
|
+
result.recipient = m[1].trim();
|
|
758
|
+
result.action = result.action || 'failed';
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Extract error message from Verizon MMS / mobile carrier style
|
|
763
|
+
if (!result.response || !result.response.message) {
|
|
764
|
+
/*
|
|
765
|
+
* Match pattern like:
|
|
766
|
+
* Message could not be delivered to mobile.
|
|
767
|
+
* Error: No valid recipients for this MM
|
|
768
|
+
*/
|
|
769
|
+
let m = text.match(/(?:Message could not be delivered|Error:[^\n]*(?:Invalid|No valid|not taken)[^\n]*)/i);
|
|
770
|
+
if (m) {
|
|
771
|
+
result.action = result.action || 'failed';
|
|
772
|
+
result.response = result.response || {};
|
|
773
|
+
result.response.message = result.response.message || m[0].replace(/\s+/g, ' ').trim();
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// Apache James / Verizon text style with RCPT TO
|
|
778
|
+
if (!result.recipient) {
|
|
779
|
+
/*
|
|
780
|
+
* Match pattern like:
|
|
781
|
+
* RCPT TO: 000000000000@example.com
|
|
782
|
+
*/
|
|
783
|
+
let m = text.match(/RCPT TO:\s*<?([^\s<>\n]+@[^\s<>\n]+)>?/i);
|
|
784
|
+
if (m) {
|
|
785
|
+
result.recipient = m[1].trim();
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// Generic error code extraction (550, 551, 552, etc.)
|
|
790
|
+
if (!result.response || !result.response.message) {
|
|
791
|
+
/*
|
|
792
|
+
* Match pattern like:
|
|
793
|
+
* 550 - Requested action not taken: no such user here
|
|
794
|
+
* 550 5.1.1 user unknown
|
|
795
|
+
*/
|
|
796
|
+
let m = text.match(/\b(5[0-5]\d)\s*[-:]\s*([^\n]{10,100})/);
|
|
797
|
+
if (m) {
|
|
798
|
+
result.action = result.action || 'failed';
|
|
799
|
+
result.response = result.response || {};
|
|
800
|
+
result.response.message = result.response.message || m[1] + ' ' + m[2].trim();
|
|
801
|
+
|
|
802
|
+
// Try to extract enhanced status code
|
|
803
|
+
if (!result.response.status) {
|
|
804
|
+
let statusMatch = m[2].match(/(\d+\.\d+\.\d+)/);
|
|
805
|
+
if (statusMatch) {
|
|
806
|
+
result.response.status = statusMatch[1];
|
|
807
|
+
}
|
|
590
808
|
}
|
|
591
|
-
}
|
|
809
|
+
}
|
|
592
810
|
}
|
|
593
811
|
|
|
594
812
|
// Try to find embedded message headers in the bounce body
|
|
@@ -2385,8 +2385,15 @@ class BaseClient {
|
|
|
2385
2385
|
* @returns {boolean} True if message appears to be an auto-reply
|
|
2386
2386
|
*/
|
|
2387
2387
|
isAutoreply(messageData) {
|
|
2388
|
-
// Check subject patterns
|
|
2389
|
-
|
|
2388
|
+
// Check subject patterns - these are strong autoreply indicators
|
|
2389
|
+
// Note: "Automatic reply:" and "Auto reply:" (with space) are common variants
|
|
2390
|
+
// "Out of the Office" is also valid (with "the")
|
|
2391
|
+
if (/^(auto(matic)?\s*(reply|response)|Out of(?: the)? Office|OOF:|OOO:)/i.test(messageData.subject)) {
|
|
2392
|
+
return true;
|
|
2393
|
+
}
|
|
2394
|
+
|
|
2395
|
+
// Weaker subject patterns require inReplyTo as confirmation
|
|
2396
|
+
if (/^auto:/i.test(messageData.subject) && messageData.inReplyTo) {
|
|
2390
2397
|
return true;
|
|
2391
2398
|
}
|
|
2392
2399
|
|
|
@@ -2399,11 +2406,17 @@ class BaseClient {
|
|
|
2399
2406
|
return true;
|
|
2400
2407
|
}
|
|
2401
2408
|
|
|
2402
|
-
// Check Auto-Submitted header
|
|
2409
|
+
// Check Auto-Submitted header (RFC 3834)
|
|
2403
2410
|
if (messageData.headers['auto-submitted'] && messageData.headers['auto-submitted'].some(e => /auto[_-]?replied/.test(e))) {
|
|
2404
2411
|
return true;
|
|
2405
2412
|
}
|
|
2406
2413
|
|
|
2414
|
+
// Check X-Auto-Response-Suppress header (Microsoft Exchange)
|
|
2415
|
+
// Values like "All", "OOF", "AutoReply" indicate this is an autoreply
|
|
2416
|
+
if (messageData.headers['x-auto-response-suppress'] && messageData.headers['x-auto-response-suppress'].length) {
|
|
2417
|
+
return true;
|
|
2418
|
+
}
|
|
2419
|
+
|
|
2407
2420
|
// Check various vendor-specific headers
|
|
2408
2421
|
for (let headerKey of ['x-autoresponder', 'x-autorespond', 'x-autoreply']) {
|
|
2409
2422
|
if (messageData.headers[headerKey] && messageData.headers[headerKey].length) {
|
|
@@ -3102,12 +3115,13 @@ class BaseClient {
|
|
|
3102
3115
|
let address = (messageData.from && messageData.from.address) || '';
|
|
3103
3116
|
|
|
3104
3117
|
// Common bounce sender names
|
|
3105
|
-
if (/Mail Delivery System|Mail Delivery Subsystem|Internet Mail Delivery/i.test(name)) {
|
|
3118
|
+
if (/Mail Delivery System|Mail Delivery Subsystem|Internet Mail Delivery|Mailer[- ]?Daemon|NDR Administrator/i.test(name)) {
|
|
3106
3119
|
return true;
|
|
3107
3120
|
}
|
|
3108
3121
|
|
|
3109
|
-
// Common bounce addresses
|
|
3110
|
-
|
|
3122
|
+
// Common bounce addresses (including mailer-daemon without @)
|
|
3123
|
+
// Note: Some MTAs use "mailerdaemon" without hyphen (e.g., ZoneMTA)
|
|
3124
|
+
if (/mailer-?daemon@|postmaster@|^mailer-?daemon$/i.test(address)) {
|
|
3111
3125
|
return true;
|
|
3112
3126
|
}
|
|
3113
3127
|
|
|
@@ -3118,16 +3132,40 @@ class BaseClient {
|
|
|
3118
3132
|
|
|
3119
3133
|
// Check for delivery-status attachment with undeliverable subject
|
|
3120
3134
|
let hasDeliveryStatus = false;
|
|
3135
|
+
let hasOriginalMessage = false;
|
|
3121
3136
|
for (let attachment of messageData.attachments || []) {
|
|
3122
3137
|
if (attachment.contentType === 'message/delivery-status') {
|
|
3123
3138
|
hasDeliveryStatus = true;
|
|
3124
3139
|
}
|
|
3140
|
+
if (attachment.contentType === 'message/rfc822') {
|
|
3141
|
+
hasOriginalMessage = true;
|
|
3142
|
+
}
|
|
3125
3143
|
}
|
|
3126
3144
|
|
|
3127
3145
|
if (hasDeliveryStatus && /Undeliver(able|ed)/i.test(messageData.subject)) {
|
|
3128
3146
|
return true;
|
|
3129
3147
|
}
|
|
3130
3148
|
|
|
3149
|
+
// Has delivery-status attachment (strong indicator)
|
|
3150
|
+
if (hasDeliveryStatus) {
|
|
3151
|
+
return true;
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3154
|
+
// Has original message attachment with bounce-like subject
|
|
3155
|
+
if (hasOriginalMessage && /Undeliver|Failed|Returned|Failure|Error/i.test(messageData.subject)) {
|
|
3156
|
+
return true;
|
|
3157
|
+
}
|
|
3158
|
+
|
|
3159
|
+
// Additional subject patterns for various bounce formats
|
|
3160
|
+
// Includes Japanese carrier patterns and generic NDR subjects
|
|
3161
|
+
if (
|
|
3162
|
+
/^Undeliverable\s+Message$|Mail delivery failed|Delivery Status Notification|Returned mail|Failure Notice|error sending your mail/i.test(
|
|
3163
|
+
messageData.subject
|
|
3164
|
+
)
|
|
3165
|
+
) {
|
|
3166
|
+
return true;
|
|
3167
|
+
}
|
|
3168
|
+
|
|
3131
3169
|
return false;
|
|
3132
3170
|
}
|
|
3133
3171
|
|
|
@@ -3577,27 +3577,52 @@ class Mailbox {
|
|
|
3577
3577
|
let address = (messageInfo.from && messageInfo.from.address) || '';
|
|
3578
3578
|
|
|
3579
3579
|
// Check common bounce sender names
|
|
3580
|
-
if (/Mail Delivery System|Mail Delivery Subsystem|Internet Mail Delivery/i.test(name)) {
|
|
3580
|
+
if (/Mail Delivery System|Mail Delivery Subsystem|Internet Mail Delivery|Mailer[- ]?Daemon|NDR Administrator/i.test(name)) {
|
|
3581
3581
|
return true;
|
|
3582
3582
|
}
|
|
3583
3583
|
|
|
3584
|
-
// Check common bounce sender addresses
|
|
3585
|
-
|
|
3584
|
+
// Check common bounce sender addresses (including mailer-daemon without @)
|
|
3585
|
+
// Note: Some MTAs use "mailerdaemon" without hyphen (e.g., ZoneMTA)
|
|
3586
|
+
if (/mailer-?daemon@|postmaster@|^mailer-?daemon$/i.test(address)) {
|
|
3586
3587
|
return true;
|
|
3587
3588
|
}
|
|
3588
3589
|
|
|
3589
3590
|
// Check for delivery-status attachment + subject pattern
|
|
3590
3591
|
let hasDeliveryStatus = false;
|
|
3592
|
+
let hasOriginalMessage = false;
|
|
3591
3593
|
for (let attachment of messageInfo.attachments || []) {
|
|
3592
3594
|
if (attachment.contentType === 'message/delivery-status') {
|
|
3593
3595
|
hasDeliveryStatus = true;
|
|
3594
3596
|
}
|
|
3597
|
+
if (attachment.contentType === 'message/rfc822') {
|
|
3598
|
+
hasOriginalMessage = true;
|
|
3599
|
+
}
|
|
3595
3600
|
}
|
|
3596
3601
|
|
|
3597
3602
|
if (hasDeliveryStatus && /Undeliver(able|ed)/i.test(messageInfo.subject)) {
|
|
3598
3603
|
return true;
|
|
3599
3604
|
}
|
|
3600
3605
|
|
|
3606
|
+
// Has delivery-status attachment (strong indicator)
|
|
3607
|
+
if (hasDeliveryStatus) {
|
|
3608
|
+
return true;
|
|
3609
|
+
}
|
|
3610
|
+
|
|
3611
|
+
// Has original message attachment with bounce-like subject
|
|
3612
|
+
if (hasOriginalMessage && /Undeliver|Failed|Returned|Failure|Error/i.test(messageInfo.subject)) {
|
|
3613
|
+
return true;
|
|
3614
|
+
}
|
|
3615
|
+
|
|
3616
|
+
// Additional subject patterns for various bounce formats
|
|
3617
|
+
// Includes Japanese carrier patterns and generic NDR subjects
|
|
3618
|
+
if (
|
|
3619
|
+
/^Undeliverable\s+Message$|Mail delivery failed|Delivery Status Notification|Returned mail|Failure Notice|error sending your mail/i.test(
|
|
3620
|
+
messageInfo.subject
|
|
3621
|
+
)
|
|
3622
|
+
) {
|
|
3623
|
+
return true;
|
|
3624
|
+
}
|
|
3625
|
+
|
|
3601
3626
|
return false;
|
|
3602
3627
|
}
|
|
3603
3628
|
|
|
@@ -3620,15 +3645,29 @@ class Mailbox {
|
|
|
3620
3645
|
}
|
|
3621
3646
|
|
|
3622
3647
|
// Check for embedded message (complaint might contain original)
|
|
3623
|
-
|
|
3648
|
+
// Include text/rfc822-headers variant used by some senders
|
|
3649
|
+
if (['message/rfc822', 'message/rfc822-headers', 'text/rfc822-headers', 'text/rfc822-header'].includes(attachment.contentType)) {
|
|
3624
3650
|
hasEmbeddedMessage = true;
|
|
3625
3651
|
}
|
|
3626
3652
|
}
|
|
3627
3653
|
|
|
3628
3654
|
let fromAddress = (messageInfo.from && messageInfo.from.address) || '';
|
|
3629
3655
|
|
|
3630
|
-
// Hotmail
|
|
3631
|
-
if (
|
|
3656
|
+
// Hotmail/Microsoft complaint pattern (may not have parsed attachments)
|
|
3657
|
+
if (fromAddress === 'staff@hotmail.com' && /complaint/i.test(messageInfo.subject)) {
|
|
3658
|
+
return true;
|
|
3659
|
+
}
|
|
3660
|
+
|
|
3661
|
+
// Generic feedback loop sender patterns
|
|
3662
|
+
if (/^(feedbackloop|fbl|complaints|abuse)@/i.test(fromAddress)) {
|
|
3663
|
+
// Common FBL sender addresses indicate likely complaint
|
|
3664
|
+
if (hasEmbeddedMessage || /abuse|complaint|feedback|report/i.test(messageInfo.subject)) {
|
|
3665
|
+
return true;
|
|
3666
|
+
}
|
|
3667
|
+
}
|
|
3668
|
+
|
|
3669
|
+
// ARF-style subject patterns with embedded message
|
|
3670
|
+
if (hasEmbeddedMessage && /abuse report|feedback report|spam report/i.test(messageInfo.subject)) {
|
|
3632
3671
|
return true;
|
|
3633
3672
|
}
|
|
3634
3673
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "emailengine-app",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.61.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"productTitle": "EmailEngine",
|
|
6
6
|
"description": "Email Sync Engine",
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"@zone-eu/wild-config": "1.7.3",
|
|
69
69
|
"ace-builds": "1.43.5",
|
|
70
70
|
"base32.js": "0.1.0",
|
|
71
|
-
"bullmq": "5.66.
|
|
71
|
+
"bullmq": "5.66.2",
|
|
72
72
|
"compare-versions": "6.1.1",
|
|
73
73
|
"dotenv": "17.2.3",
|
|
74
74
|
"encoding-japanese": "2.2.0",
|
|
@@ -82,7 +82,7 @@
|
|
|
82
82
|
"html-to-text": "9.0.5",
|
|
83
83
|
"ical.js": "1.5.0",
|
|
84
84
|
"iconv-lite": "0.7.1",
|
|
85
|
-
"imapflow": "1.2.
|
|
85
|
+
"imapflow": "1.2.3",
|
|
86
86
|
"ioredfour": "1.3.0-ioredis-07",
|
|
87
87
|
"ioredis": "5.8.2",
|
|
88
88
|
"ipaddr.js": "2.3.0",
|
|
@@ -106,7 +106,7 @@
|
|
|
106
106
|
"pubface": "1.0.17",
|
|
107
107
|
"punycode.js": "2.3.1",
|
|
108
108
|
"qrcode": "1.5.4",
|
|
109
|
-
"smtp-server": "3.
|
|
109
|
+
"smtp-server": "3.18.0",
|
|
110
110
|
"socks": "2.8.7",
|
|
111
111
|
"speakeasy": "2.0.0",
|
|
112
112
|
"startbootstrap-sb-admin-2": "3.3.7",
|
|
@@ -153,7 +153,6 @@
|
|
|
153
153
|
"node_modules/@postalsys/bounce-classifier/model/**/*",
|
|
154
154
|
"node_modules/jsdom/lib/jsdom/browser/default-stylesheet.css",
|
|
155
155
|
"LICENSE_EMAILENGINE.txt",
|
|
156
|
-
"help.txt",
|
|
157
156
|
"version-info.json",
|
|
158
157
|
"sbom.json"
|
|
159
158
|
],
|