diff --git a/lib/IMAP/ImapMessageFetcher.php b/lib/IMAP/ImapMessageFetcher.php index e9e6bba5e9..6bc2b4620d 100644 --- a/lib/IMAP/ImapMessageFetcher.php +++ b/lib/IMAP/ImapMessageFetcher.php @@ -12,6 +12,7 @@ use Horde_Imap_Client_Base; use Horde_Imap_Client_Data_Envelope; use Horde_Imap_Client_Data_Fetch; +use Horde_Imap_Client_DateTime; use Horde_Imap_Client_Exception; use Horde_Imap_Client_Exception_NoSupportExtension; use Horde_Imap_Client_Fetch_Query; @@ -63,6 +64,7 @@ class ImapMessageFetcher { private bool $isOneClickUnsubscribe = false; private ?string $unsubscribeMailto = null; private bool $isPgpMimeEncrypted = false; + private ?Horde_Imap_Client_DateTime $messageDate = null; public function __construct( int $uid, @@ -263,7 +265,7 @@ public function fetchMessage(?Horde_Imap_Client_Data_Fetch $fetch = null): IMAPM $this->inlineAttachments, $this->hasAnyAttachment, $this->scheduling, - $fetch->getImapDate(), + $this->messageDate ?? $fetch->getImapDate(), $this->rawReferences, $this->dispositionNotificationTo, $this->hasDkimSignature, @@ -531,6 +533,8 @@ private function parseHeaders(Horde_Imap_Client_Data_Fetch $fetch): void { $dkimSignatureHeader = $parsedHeaders->getHeader('dkim-signature'); $this->hasDkimSignature = $dkimSignatureHeader !== null; + $this->messageDate = $this->resolveMessageDate($fetch, $parsedHeaders); + if ($this->runPhishingCheck) { $this->phishingDetails = $this->phishingDetectionService->checkHeadersForPhishing($parsedHeaders, $this->hasHtmlMessage, $this->htmlMessage); } @@ -560,4 +564,28 @@ private function parseHeaders(Horde_Imap_Client_Data_Fetch $fetch): void { } } } + + private function resolveMessageDate(Horde_Imap_Client_Data_Fetch $fetch, Horde_Mime_Headers $parsedHeaders): Horde_Imap_Client_DateTime { + $dateHeader = $parsedHeaders->getHeader('Date'); + if ($dateHeader instanceof \Horde_Mime_Headers_Date) { + $dateValue = $dateHeader->value ?? null; + if (!empty($dateValue)) { + try { + $date = new Horde_Imap_Client_DateTime($dateValue); + if ($date->getTimestamp() > 0) { + return $date; + } + } catch (\Throwable) { + // Ignore invalid header value and fall back to the internal date + } + } + } + + $internalDate = $fetch->getImapDate(); + if ($internalDate instanceof Horde_Imap_Client_DateTime) { + return $internalDate; + } + + return new Horde_Imap_Client_DateTime(); + } } diff --git a/tests/Unit/IMAP/ImapMessageFetcherTest.php b/tests/Unit/IMAP/ImapMessageFetcherTest.php new file mode 100644 index 0000000000..a7bd45a26d --- /dev/null +++ b/tests/Unit/IMAP/ImapMessageFetcherTest.php @@ -0,0 +1,107 @@ +htmlService = $this->createMock(Html::class); + $this->smimeService = $this->createMock(SmimeService::class); + $this->converter = $this->createMock(Converter::class); + $this->phishingDetectionService = $this->createMock(PhishingDetectionService::class); + $this->client = $this->createMock(Horde_Imap_Client_Base::class); + + $this->fetcher = new ImapMessageFetcher( + 42, + 'INBOX', + $this->client, + 'user', + $this->htmlService, + $this->smimeService, + $this->converter, + $this->phishingDetectionService, + ); + } + + private function invokeResolveMessageDate(Horde_Imap_Client_Data_Fetch $fetch, Horde_Mime_Headers $headers): Horde_Imap_Client_DateTime { + $method = new ReflectionMethod(ImapMessageFetcher::class, 'resolveMessageDate'); + $method->setAccessible(true); + /** @var Horde_Imap_Client_DateTime $result */ + $result = $method->invoke($this->fetcher, $fetch, $headers); + return $result; + } + + public function testResolveMessageDatePrefersHeader(): void { + $fetch = $this->createMock(Horde_Imap_Client_Data_Fetch::class); + $fetch->method('getImapDate') + ->willReturn(new Horde_Imap_Client_DateTime('2025-10-20 10:00:00 +0000')); + $headers = Horde_Mime_Headers::parseHeaders("Date: Mon, 01 Jan 2001 12:00:00 +0000\r\n"); + + $result = $this->invokeResolveMessageDate($fetch, $headers); + + self::assertSame('2001-01-01T12:00:00+00:00', $result->format('c')); + } + + public function testResolveMessageDateFallsBackToInternalWithoutHeader(): void { + $internal = new Horde_Imap_Client_DateTime('2025-10-20 10:00:00 +0000'); + $fetch = $this->createMock(Horde_Imap_Client_Data_Fetch::class); + $fetch->method('getImapDate')->willReturn($internal); + $headers = Horde_Mime_Headers::parseHeaders(''); + + $result = $this->invokeResolveMessageDate($fetch, $headers); + + self::assertSame($internal->format('c'), $result->format('c')); + } + + public function testResolveMessageDateFallsBackToInternalOnInvalidHeader(): void { + $internal = new Horde_Imap_Client_DateTime('2025-10-20 10:00:00 +0000'); + $fetch = $this->createMock(Horde_Imap_Client_Data_Fetch::class); + $fetch->method('getImapDate')->willReturn($internal); + $headers = Horde_Mime_Headers::parseHeaders("Date: not-a-valid-date\r\n"); + + $result = $this->invokeResolveMessageDate($fetch, $headers); + + self::assertSame($internal->format('c'), $result->format('c')); + } + + public function testResolveMessageDateFallsBackToNowWhenNoDateAvailable(): void { + $fetch = $this->createMock(Horde_Imap_Client_Data_Fetch::class); + $fetch->method('getImapDate')->willReturn(null); + $headers = Horde_Mime_Headers::parseHeaders(''); + + $before = time(); + $result = $this->invokeResolveMessageDate($fetch, $headers); + $after = time(); + + self::assertGreaterThanOrEqual($before, $result->getTimestamp()); + self::assertLessThanOrEqual($after, $result->getTimestamp()); + } +}