Skip to content

Commit 3c64659

Browse files
HNygardclaude
andcommitted
Email extraction: address-header fallback + better parse diagnostics
The Oslo kommune email with the mangled From header still fails in production even though the exact header from the error log parses fine locally (verified on PHP 8.2 and 8.3) - the real bytes must differ from what the log shows. Three changes to both recover and get evidence: 1. Address-header fallback: when From/To/Cc/Bcc/Reply-To/Sender still fails after all repairs but contains an angle-addr, drop the display name, keep the address and retry. The display name is cosmetic; the address is what extraction needs. 2. Diagnostic loop now accumulates complete folded headers before test-parsing. Previously a folded address header was cut in half by the partial parse (angle-addr left on the continuation line), which could blame the wrong header and blocked the fallback above. 3. Hex dump of the problematic header in the diagnostic output, so the next occurrence reveals the exact bytes the log cannot show. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 03c1d12 commit 3c64659

2 files changed

Lines changed: 92 additions & 5 deletions

File tree

organizer/src/class/Extraction/ThreadEmailExtractorEmailBody.php

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -877,15 +877,18 @@ public static function readLaminasMessage_withErrorHandling($eml) {
877877

878878
$headers = preg_split('/\r?\n/', $eml);
879879
$currentHeader = '';
880+
$currentHeaderIndices = [];
880881
$partialEml = '';
881882
$firstLine = true;
883+
$addressListHeaders = ['from', 'to', 'cc', 'bcc', 'reply-to', 'sender'];
882884
foreach ($headers as $lineIndex => $line) {
883885
if (preg_match('/^([A-Za-z-]+):\s*/', $line, $matches)) {
884886
// New header
885887
$currentHeader = $matches[1];
888+
$currentHeaderIndices = [$lineIndex];
886889
} elseif (substr($line, 0, 1) === ' ' || substr($line, 0, 1) === "\t") {
887890
// Continuation line
888-
// Do nothing, just continue
891+
$currentHeaderIndices[] = $lineIndex;
889892
} else {
890893
// Not a header line, skip
891894
continue;
@@ -896,21 +899,58 @@ public static function readLaminasMessage_withErrorHandling($eml) {
896899
}
897900
$partialEml .= $line;
898901
$firstLine = false;
902+
903+
// Wait for the complete header before test-parsing: a folded header cut in
904+
// half (e.g. an address header whose angle-addr is on the continuation line)
905+
// fails parsing and would blame the wrong header
906+
$nextLine = $headers[$lineIndex + 1] ?? '';
907+
if ($nextLine !== '' && (substr($nextLine, 0, 1) === ' ' || substr($nextLine, 0, 1) === "\t")) {
908+
continue;
909+
}
910+
899911
try {
900912
// Try to parse the email up to the current header
901913
$message = new \Laminas\Mail\Storage\Message(['raw' => self::stripProblematicHeaders($partialEml)]);
902914
} catch (\Laminas\Mail\Exception\InvalidArgumentException | \Laminas\Mail\Exception\RuntimeException $e2) {
915+
// The complete (possibly folded) header that failed to parse
916+
$problematicUnit = implode("\n", array_map(function($i) use ($headers) {
917+
return $headers[$i];
918+
}, $currentHeaderIndices));
919+
920+
// Fallback for address-list headers: the display name is cosmetic, the
921+
// address is what matters for extraction. If an angle-addr can be
922+
// extracted, drop the unparseable display name and retry the full email.
923+
if (in_array(strtolower($currentHeader), $addressListHeaders, true)
924+
&& preg_match('/<([^<>\s]+@[^<>\s]+)>/', $problematicUnit, $addrMatch)) {
925+
$repairedHeaders = $headers;
926+
$repairedHeaders[$currentHeaderIndices[0]] = $currentHeader . ': <' . $addrMatch[1] . '>';
927+
foreach (array_slice($currentHeaderIndices, 1) as $i) {
928+
unset($repairedHeaders[$i]);
929+
}
930+
try {
931+
$message = new \Laminas\Mail\Storage\Message(['raw' => implode("\n", $repairedHeaders)]);
932+
error_log("Email parsing: dropped unparseable display name in " . $currentHeader
933+
. " header, kept address <" . $addrMatch[1] . ">."
934+
. " Original header: " . self::truncateLineForLog($problematicUnit)
935+
. " (hex: " . bin2hex(substr($problematicUnit, 0, self::ERROR_LOG_LINE_PREVIEW_LENGTH)) . ")");
936+
return $message;
937+
} catch (\Laminas\Mail\Exception\InvalidArgumentException | \Laminas\Mail\Exception\RuntimeException $e3) {
938+
// Retry failed too, fall through to the diagnostic below
939+
}
940+
}
941+
903942
// Failed to parse at this header, analyze the header value for problematic characters
904-
$headerValue = preg_replace('/^[A-Za-z-]+:\s*/', '', $line);
943+
$headerValue = preg_replace('/^[A-Za-z-]+:\s*/', '', $problematicUnit);
905944
$analysis = self::debuggingAnalyzeHeaderValue($headerValue);
906-
907-
$lineNumber = $lineIndex + 1;
945+
946+
$lineNumber = $currentHeaderIndices[0] + 1;
908947
$debugInfo = "Failed to parse email due to problematic header on line " . $lineNumber . "\n"
909948
. "Header name: " . $currentHeader . "\n"
910949
. "Exception type: " . get_class($e2) . "\n"
911950
. "Original error: " . $e->getMessage() . "\n"
912951
. "New error: " . $e2->getMessage() . "\n"
913-
. "Problematic line: " . self::truncateLineForLog($line) . "\n\n";
952+
. "Problematic line: " . self::truncateLineForLog($problematicUnit) . "\n"
953+
. "Problematic line (hex): " . bin2hex(substr($problematicUnit, 0, self::ERROR_LOG_LINE_PREVIEW_LENGTH)) . "\n\n";
914954

915955
// Add character-level debugging information
916956
if (!empty($analysis['issues'])) {

organizer/src/tests/Extraction/ThreadEmailExtractorEmailBodyTest.php

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,53 @@ public function testReadLaminasMessage_withErrorHandling_NestedEncodedWordInFrom
324324
);
325325
}
326326

327+
public function testReadLaminasMessage_withErrorHandling_AddressHeaderFallback() {
328+
// :: Setup
329+
// An unquoted comma in the display name makes Laminas split the address list
330+
// and fail on the fragment without an @. The fallback should keep just the
331+
// angle-addr and drop the unparseable display name.
332+
$emailWithCommaInName = "From: Postmottak BYR, Byradsavdelingene <postmottak@example.com>\r\n" .
333+
"To: recipient@example.com\r\n" .
334+
"Subject: Test Email\r\n" .
335+
"Content-Type: text/plain\r\n" .
336+
"\r\n" .
337+
"This is a test email body";
338+
339+
// :: Act
340+
$result = ThreadEmailExtractorEmailBody::readLaminasMessage_withErrorHandling($emailWithCommaInName);
341+
342+
// :: Assert
343+
$this->assertInstanceOf(\Laminas\Mail\Storage\Message::class, $result);
344+
$this->assertEquals(
345+
'postmottak@example.com',
346+
$result->getHeader('from')->getFieldValue()
347+
);
348+
}
349+
350+
public function testReadLaminasMessage_withErrorHandling_AddressHeaderFallbackFolded() {
351+
// :: Setup
352+
// Same as above but with the address on a folded continuation line. The
353+
// diagnostic loop must accumulate the complete folded header before
354+
// test-parsing, otherwise the angle-addr on the continuation line is lost.
355+
$emailWithFoldedFrom = "From: Postmottak BYR, Byradsavdelingene\r\n" .
356+
" <postmottak@example.com>\r\n" .
357+
"To: recipient@example.com\r\n" .
358+
"Subject: Test Email\r\n" .
359+
"Content-Type: text/plain\r\n" .
360+
"\r\n" .
361+
"This is a test email body";
362+
363+
// :: Act
364+
$result = ThreadEmailExtractorEmailBody::readLaminasMessage_withErrorHandling($emailWithFoldedFrom);
365+
366+
// :: Assert
367+
$this->assertInstanceOf(\Laminas\Mail\Storage\Message::class, $result);
368+
$this->assertEquals(
369+
'postmottak@example.com',
370+
$result->getHeader('from')->getFieldValue()
371+
);
372+
}
373+
327374
public function testReadLaminasMessage_withErrorHandling_InvalidAddressHeader() {
328375
// :: Setup
329376
// Laminas\Mail\Address::__construct throws Laminas\Mail\Exception\InvalidArgumentException

0 commit comments

Comments
 (0)