Skip to content

Commit 13f8b3a

Browse files
Merge pull request #7 from Alexandr-Penkin/master
Fix Message-ID extraction for 2-part MIME bounces and add References/…
2 parents 60af3e4 + 8517bfb commit 13f8b3a

File tree

4 files changed

+369
-1
lines changed

4 files changed

+369
-1
lines changed

eml/fixture_bounce_009.eml

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
Delivered-To: postmaster@example.test
2+
Return-Path: <postmaster@mailer2.example.test>
3+
X-Failed-Recipients: recipient.three@example.test
4+
Auto-Submitted: auto-replied
5+
From: Mail Delivery System <Mailer-Daemon@mailer2.example.test>
6+
To: postmaster@example.test
7+
References: <ref-message-001@example.test>
8+
Content-Type: multipart/report; report-type=delivery-status; boundary=BOUNDARY-BOUNCE-009
9+
MIME-Version: 1.0
10+
Subject: Mail delivery failed
11+
Message-Id: <bounce-message-001@example.test>
12+
Date: Wed, 11 Feb 2026 11:18:40 +0300
13+
14+
--BOUNDARY-BOUNCE-009
15+
Content-type: text/plain; charset=us-ascii
16+
17+
This message was created automatically by mail delivery software.
18+
19+
A message sent by
20+
21+
<sender@example.test>
22+
23+
could not be delivered to one or more of its recipients. The following
24+
address(es) failed:
25+
26+
recipient.three@example.test
27+
host mx.remote-3.example.net [198.51.100.30]
28+
SMTP error from remote mail server after RCPT TO:<recipient.three@example.test>:
29+
550 zen.example.test Listed by CSS
30+
31+
--BOUNDARY-BOUNCE-009
32+
Content-type: message/delivery-status
33+
34+
Reporting-MTA: dns; mailer2.example.test
35+
36+
Action: failed
37+
Final-Recipient: rfc822;recipient.three@example.test
38+
Status: 5.0.0
39+
Remote-MTA: dns; mx.remote-3.example.net
40+
Diagnostic-Code: smtp; 550 zen.example.test Listed by CSS
41+
42+
--BOUNDARY-BOUNCE-009
43+
Content-type: message/rfc822
44+
45+
Return-path: <sender@example.test>
46+
From: Sender Team <sender@example.test>
47+
Message-ID: <original-message-001@example.test>
48+
Subject: Monthly report: Sample Org
49+
To: recipient.three@example.test
50+
MIME-Version: 1.0
51+
Date: Wed, 11 Feb 2026 11:18:37 +0300
52+
Content-Type: text/plain; charset=utf-8
53+
54+
Original message body.
55+
56+
--BOUNDARY-BOUNCE-009--

src/BounceHandler.php

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,16 @@ public function parse(string $rawEmail): array {
133133
if (array_key_exists('Message-id', $originalLetterHeader) && is_string($originalLetterHeader['Message-id'])) {
134134
$messageId = $originalLetterHeader['Message-id'];
135135
}
136+
if ($messageId === '' && array_key_exists('References', $headHash) && is_string($headHash['References'])) {
137+
if (preg_match('/<[^>]+>/', $headHash['References'], $refMatch) === 1) {
138+
$messageId = $refMatch[0];
139+
}
140+
}
141+
if ($messageId === '' && array_key_exists('In-reply-to', $headHash) && is_string($headHash['In-reply-to'])) {
142+
if (preg_match('/<[^>]+>/', $headHash['In-reply-to'], $replyMatch) === 1) {
143+
$messageId = $replyMatch[0];
144+
}
145+
}
136146

137147
$subject = '';
138148
if (array_key_exists('Subject', $originalLetterHeader) && is_string($originalLetterHeader['Subject'])) {
@@ -200,8 +210,20 @@ private function recoverOriginalLetter(
200210
): string {
201211
if ($mimeSections['returnedMessageBodyPart'] !== '') {
202212
[, $letter] = MimeParser::splitHeadAndBody($mimeSections['returnedMessageBodyPart']);
213+
if ($letter !== '') {
214+
return $letter;
215+
}
216+
}
203217

204-
return $letter;
218+
// Fallback for 2-part MIME bounces where original message is in part 2 instead of part 3
219+
if ($mimeSections['machineParsableBodyPart'] !== '') {
220+
[$mpbpHead] = MimeParser::splitHeadAndBody($mimeSections['machineParsableBodyPart']);
221+
if (stripos($mpbpHead, 'message/rfc822') !== false) {
222+
[, $letter] = MimeParser::splitHeadAndBody($mimeSections['machineParsableBodyPart']);
223+
if ($letter !== '') {
224+
return $letter;
225+
}
226+
}
205227
}
206228

207229
$yourCopyMarker = '------ This is a copy of your message, including all the headers. ------';

tests/HeaderParserTest.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Zoon\BounceHandler\Tests;
6+
7+
use PHPUnit\Framework\TestCase;
8+
use Zoon\BounceHandler\Parser\HeaderParser;
9+
10+
final class HeaderParserTest extends TestCase {
11+
public function testParseKeyValueDoesNotEmitWarningsOnInvalidMimeEncodedHeader(): void {
12+
$headers = [
13+
'Subject: =?UTF-8?Q?Broken_=ZZ?=',
14+
' =?UTF-8?Q?continuation_=XX?=',
15+
];
16+
17+
set_error_handler(static function (int $severity, string $message): bool {
18+
throw new \ErrorException($message, 0, $severity);
19+
});
20+
21+
try {
22+
$parsed = HeaderParser::parseKeyValue($headers);
23+
} finally {
24+
restore_error_handler();
25+
}
26+
27+
self::assertArrayHasKey('Subject', $parsed);
28+
self::assertIsString($parsed['Subject']);
29+
self::assertNotSame('', $parsed['Subject']);
30+
}
31+
}

tests/MessageIdExtractionTest.php

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Zoon\BounceHandler\Tests;
6+
7+
use PHPUnit\Framework\TestCase;
8+
use Zoon\BounceHandler\BounceHandler;
9+
10+
final class MessageIdExtractionTest extends TestCase {
11+
private BounceHandler $handler;
12+
13+
protected function setUp(): void {
14+
$this->handler = new BounceHandler();
15+
}
16+
17+
public function testMessageIdFromStandard3PartMime(): void {
18+
$eml = $this->buildEml([
19+
'Return-Path' => '<>',
20+
'From' => 'mailer-daemon@example.com',
21+
'Subject' => 'Mail delivery failed',
22+
'Content-Type' => 'multipart/report; report-type=delivery-status; boundary="BOUND3PART"',
23+
], implode("\r\n", [
24+
'--BOUND3PART',
25+
'Content-Type: text/plain',
26+
'',
27+
'The message could not be delivered.',
28+
'user@example.com: mailbox not found',
29+
'',
30+
'--BOUND3PART',
31+
'Content-Type: message/delivery-status',
32+
'',
33+
'Final-Recipient: rfc822;user@example.com',
34+
'Action: failed',
35+
'Status: 5.1.1',
36+
'',
37+
'--BOUND3PART',
38+
'Content-Type: message/rfc822',
39+
'',
40+
'Message-ID: <original-3part@example.com>',
41+
'From: sender@example.com',
42+
'To: user@example.com',
43+
'Subject: Original Subject',
44+
'',
45+
'Original body text',
46+
'--BOUND3PART--',
47+
]));
48+
49+
$results = $this->handler->parse($eml);
50+
51+
self::assertCount(1, $results);
52+
self::assertSame('<original-3part@example.com>', $results[0]->messageId);
53+
self::assertSame('Original Subject', $results[0]->subject);
54+
}
55+
56+
public function testMessageIdFrom2PartMimeFallback(): void {
57+
$eml = $this->buildEml([
58+
'Return-Path' => '<>',
59+
'From' => 'mailer-daemon@example.com',
60+
'Subject' => 'Mail delivery failed',
61+
'Content-Type' => 'multipart/mixed; boundary="BOUND2PART"',
62+
], implode("\r\n", [
63+
'--BOUND2PART',
64+
'',
65+
'Su mensaje no pudo ser entregado.',
66+
'user@example.com: mailbox not found',
67+
'',
68+
'--- Mensaje original adjunto.',
69+
'',
70+
'--BOUND2PART',
71+
'Content-Type: message/rfc822',
72+
'',
73+
'Message-ID: <original-2part@example.com>',
74+
'From: sender@example.com',
75+
'To: user@example.com',
76+
'Subject: Two Part Subject',
77+
'',
78+
'Original body text',
79+
'--BOUND2PART--',
80+
]));
81+
82+
$results = $this->handler->parse($eml);
83+
84+
self::assertCount(1, $results);
85+
self::assertSame('<original-2part@example.com>', $results[0]->messageId);
86+
self::assertSame('Two Part Subject', $results[0]->subject);
87+
}
88+
89+
public function testMessageIdFromReferencesHeader(): void {
90+
$eml = $this->buildEml([
91+
'Return-Path' => '<>',
92+
'From' => 'mailer-daemon@example.com',
93+
'Subject' => 'Mail delivery failed',
94+
'References' => '<ref-fallback@example.com>',
95+
'X-Failed-Recipients' => 'user@example.com',
96+
], implode("\r\n", [
97+
'This message was created automatically by mail delivery software.',
98+
'',
99+
'A message that you sent could not be delivered.',
100+
'',
101+
' user@example.com',
102+
' mailbox is full',
103+
]));
104+
105+
$results = $this->handler->parse($eml);
106+
107+
self::assertCount(1, $results);
108+
self::assertSame('<ref-fallback@example.com>', $results[0]->messageId);
109+
}
110+
111+
public function testMessageIdFromInReplyToHeader(): void {
112+
$eml = $this->buildEml([
113+
'Return-Path' => '<>',
114+
'From' => 'mailer-daemon@example.com',
115+
'Subject' => 'Mail delivery failed',
116+
'In-Reply-To' => '<inreply-fallback@example.com>',
117+
'X-Failed-Recipients' => 'user@example.com',
118+
], implode("\r\n", [
119+
'This message was created automatically by mail delivery software.',
120+
'',
121+
'A message that you sent could not be delivered.',
122+
'',
123+
' user@example.com',
124+
' mailbox is full',
125+
]));
126+
127+
$results = $this->handler->parse($eml);
128+
129+
self::assertCount(1, $results);
130+
self::assertSame('<inreply-fallback@example.com>', $results[0]->messageId);
131+
}
132+
133+
public function testReferencesPreferredOverInReplyTo(): void {
134+
$eml = $this->buildEml([
135+
'Return-Path' => '<>',
136+
'From' => 'mailer-daemon@example.com',
137+
'Subject' => 'Mail delivery failed',
138+
'References' => '<from-references@example.com>',
139+
'In-Reply-To' => '<from-inreply@example.com>',
140+
'X-Failed-Recipients' => 'user@example.com',
141+
], implode("\r\n", [
142+
'This message was created automatically by mail delivery software.',
143+
'',
144+
' user@example.com',
145+
' mailbox is full',
146+
]));
147+
148+
$results = $this->handler->parse($eml);
149+
150+
self::assertCount(1, $results);
151+
self::assertSame('<from-references@example.com>', $results[0]->messageId);
152+
}
153+
154+
public function testOriginalLetterMessageIdPreferredOverReferences(): void {
155+
$eml = $this->buildEml([
156+
'Return-Path' => '<>',
157+
'From' => 'mailer-daemon@example.com',
158+
'Subject' => 'Mail delivery failed',
159+
'References' => '<should-not-use@example.com>',
160+
'Content-Type' => 'multipart/report; report-type=delivery-status; boundary="BOUNDPRIO"',
161+
], implode("\r\n", [
162+
'--BOUNDPRIO',
163+
'Content-Type: text/plain',
164+
'',
165+
'user@example.com: mailbox not found',
166+
'',
167+
'--BOUNDPRIO',
168+
'Content-Type: message/delivery-status',
169+
'',
170+
'Final-Recipient: rfc822;user@example.com',
171+
'Action: failed',
172+
'Status: 5.1.1',
173+
'',
174+
'--BOUNDPRIO',
175+
'Content-Type: message/rfc822',
176+
'',
177+
'Message-ID: <from-original@example.com>',
178+
'From: sender@example.com',
179+
'To: user@example.com',
180+
'Subject: Test',
181+
'',
182+
'Body',
183+
'--BOUNDPRIO--',
184+
]));
185+
186+
$results = $this->handler->parse($eml);
187+
188+
self::assertCount(1, $results);
189+
self::assertSame('<from-original@example.com>', $results[0]->messageId);
190+
}
191+
192+
public function testEmptyMessageIdWhenNoSourceAvailable(): void {
193+
$eml = $this->buildEml([
194+
'Return-Path' => '<>',
195+
'From' => 'mailer-daemon@example.com',
196+
'Subject' => 'Mail delivery failed',
197+
'X-Failed-Recipients' => 'user@example.com',
198+
], implode("\r\n", [
199+
'This message was created automatically by mail delivery software.',
200+
'',
201+
'A message that you sent could not be delivered.',
202+
'',
203+
' user@example.com',
204+
' mailbox is full',
205+
]));
206+
207+
$results = $this->handler->parse($eml);
208+
209+
self::assertCount(1, $results);
210+
self::assertSame('', $results[0]->messageId);
211+
}
212+
213+
public function testReferencesWithMultipleMessageIds(): void {
214+
$eml = $this->buildEml([
215+
'Return-Path' => '<>',
216+
'From' => 'mailer-daemon@example.com',
217+
'Subject' => 'Mail delivery failed',
218+
'References' => '<first@example.com> <second@example.com>',
219+
'X-Failed-Recipients' => 'user@example.com',
220+
], implode("\r\n", [
221+
'This message was created automatically by mail delivery software.',
222+
'',
223+
' user@example.com',
224+
' mailbox is full',
225+
]));
226+
227+
$results = $this->handler->parse($eml);
228+
229+
self::assertCount(1, $results);
230+
self::assertSame('<first@example.com>', $results[0]->messageId);
231+
}
232+
233+
public function testRealEmlFile1HasMessageId(): void {
234+
$emlPath = __DIR__ . '/../eml/1.eml';
235+
if (!file_exists($emlPath)) {
236+
self::markTestSkipped('eml/1.eml not found');
237+
}
238+
239+
$results = $this->handler->parse(file_get_contents($emlPath));
240+
241+
self::assertGreaterThan(0, count($results));
242+
self::assertSame(
243+
'<11885fd3ea338f04de950704ee6b30f5@ar1.outmailing.com>',
244+
$results[0]->messageId,
245+
);
246+
}
247+
248+
/**
249+
* @param array<string, string> $headers
250+
*/
251+
private function buildEml(array $headers, string $body): string {
252+
$lines = [];
253+
foreach ($headers as $name => $value) {
254+
$lines[] = "{$name}: {$value}";
255+
}
256+
257+
return implode("\r\n", $lines) . "\r\n\r\n" . $body;
258+
}
259+
}

0 commit comments

Comments
 (0)