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.
@@ -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
- let deliveryStatus = parsed.attachments.find(attachment => attachment.contentType === 'message/delivery-status');
213
- let messageHeaders = parsed.attachments.find(attachment => attachment.contentType === 'text/rfc822-headers');
214
- let originalMessage = parsed.attachments.find(attachment => attachment.contentType === 'message/rfc822');
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 = parsed.attachments.find(attachment => attachment.contentType === 'text/rfc822');
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.forEach(entry => {
284
- if (!headers[entry.key]) {
285
- headers[entry.key] = [];
286
- }
287
- // Preserve the original header value (after the key and colon)
288
- headers[entry.key].push(entry.line.substring(entry.key.length + 1).trim());
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 = parsedOriginal.attachments.find(attachment => attachment.contentType === 'message/delivery-status');
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 = parsedOriginal.attachments.find(attachment => attachment.contentType === 'text/rfc822-headers');
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
- try {
324
- let parsedOriginal = await simpleParser(zohoOriginalMessage.content, { keepDeliveryStatus: true });
325
- // Zoho sometimes includes headers in the text body with no actual header structure
326
- if (parsedOriginal && parsedOriginal.text && (!parsedOriginal.headerLines || !parsedOriginal.headerLines.filter(h => h.key).length)) {
327
- // Remove leading spaces that indicate header continuation
328
- let headerContent = (parsedOriginal.text || '').toString().replace(/^ (?=[^ ])/gm, '');
329
- let headers = libmime.decodeHeaders(headerContent);
330
- if (headers && headers['message-id']) {
331
- parsedMessageHeaders = headers;
332
- result = Object.assign(result, parseDeliveryHeaders(headerContent) || {});
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 && parsed.attachments) {
347
- let emptyContent = parsed.attachments.find(attachment => !attachment.contentType);
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
- let m = text.match(/Delivery\s[\s\w]*failed[\s\w]*:/im);
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.substr(m.index + m[0].length).match(/\s*\n*\s*([^\s\n]+)/);
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
- let m = text.match(/\n<([^>@]+@[^>]+)>:[\s\r\n]+/);
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.substr(m.index + m[0].length).match(/\r?\n\r?\n|$/);
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
- .substr(m.index + m[0].length, end.index)
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
- // Replace newlines with a special character to make regex matching easier
573
- text.replace(/\r?\n/g, '\x04').replace(/\x04\s*\x04\s*([^\s@]+@[^\s@\x04]+)\s*((?:\x04\s*[^\x04]+)+)/g, (...a) => {
574
- let addr = a[1];
575
- // Only process if this is our recipient and we don't have a message yet
576
- if (addr !== result.recipient || (result.response && result.response.message)) {
577
- return;
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
- // Extract the error message after "host ... :"
581
- let m = (a[2] || '')
582
- .replace(/\x04/g, ' ')
583
- .replace(/\s+/g, ' ')
584
- .trim()
585
- .match(/\shost\s[^:]+:\s*(.*)$/);
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
- if (m && m[1]) {
588
- result.response = result.response || {};
589
- result.response.message = m[1];
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
- if (/^(auto:|Out of Office|OOF:|OOO:)/i.test(messageData.subject) && messageData.inReplyTo) {
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
- if (/mailer-daemon@|postmaster@/i.test(address)) {
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
- if (/mailer-daemon@|postmaster@/i.test(address)) {
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
- if (['message/rfc822', 'message/rfc822-headers'].includes(attachment.contentType)) {
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-specific complaint pattern
3631
- if (hasEmbeddedMessage && fromAddress === 'staff@hotmail.com' && /complaint/i.test(messageInfo.subject)) {
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.60.1",
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.1",
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.1",
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.17.1",
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
  ],