diff --git a/README.md b/README.md index 99aaf4de..19e05425 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,10 @@ if($mail->hasAttachments()) { // Print all information of $mail print_r($mail); +// Access arbitrary headers without adding custom properties to the library +$originMessageId = $mail->getHeader('Origin-MessageID'); +$receivedHeaders = $mail->getHeaders('Received'); + // Print all attachements of $mail echo "\n\nAttachments:\n"; print_r($mail->getAttachments()); diff --git a/src/PhpImap/IncomingMailHeader.php b/src/PhpImap/IncomingMailHeader.php index 7e522b6f..8351a2d8 100644 --- a/src/PhpImap/IncomingMailHeader.php +++ b/src/PhpImap/IncomingMailHeader.php @@ -44,6 +44,13 @@ class IncomingMailHeader /** @var string|null */ public $headersRaw; + /** + * @var string[][] + * + * @psalm-var array> + */ + public $headersByName = []; + /** @var object|null */ public $headers; @@ -152,4 +159,134 @@ class IncomingMailHeader /** @var string|null */ public $references; + + public function setHeadersRaw(string $headersRaw): void + { + $this->headersRaw = $headersRaw; + $this->headersByName = $this->parseHeadersRaw($headersRaw); + } + + public function getHeader(string $headerName): ?string + { + $headers = $this->getHeaders($headerName); + + if ([] === $headers) { + return null; + } + + return $headers[0]; + } + + /** + * @return string[] + * + * @psalm-return list + */ + public function getHeaders(string $headerName): array + { + $this->ensureParsedHeadersByName(); + + return $this->headersByName[$this->normalizeHeaderName($headerName)] ?? []; + } + + /** + * @return string[][] + * + * @psalm-return array> + */ + public function getAllHeaders(): array + { + $this->ensureParsedHeadersByName(); + + return $this->headersByName; + } + + protected function ensureParsedHeadersByName(): void + { + if ([] !== $this->headersByName || null === $this->headersRaw) { + return; + } + + $this->headersByName = $this->parseHeadersRaw($this->headersRaw); + } + + /** + * @return string[][] + * + * @psalm-return array> + */ + protected function parseHeadersRaw(string $headersRaw): array + { + $parsedHeaders = []; + $currentHeaderName = null; + $currentHeaderValue = ''; + + foreach ($this->splitHeaderLines($headersRaw) as $line) { + if ('' === $line) { + $this->storeParsedHeader($parsedHeaders, $currentHeaderName, $currentHeaderValue); + $currentHeaderName = null; + $currentHeaderValue = ''; + + continue; + } + + if (null !== $currentHeaderName && 1 === \preg_match('/^[ \t]/', $line)) { + $currentHeaderValue .= ' '.\ltrim($line); + + continue; + } + + $this->storeParsedHeader($parsedHeaders, $currentHeaderName, $currentHeaderValue); + + $separatorPosition = \strpos($line, ':'); + + if (false === $separatorPosition) { + $currentHeaderName = null; + $currentHeaderValue = ''; + + continue; + } + + $currentHeaderName = \substr($line, 0, $separatorPosition); + $currentHeaderValue = \trim(\substr($line, $separatorPosition + 1)); + } + + $this->storeParsedHeader($parsedHeaders, $currentHeaderName, $currentHeaderValue); + + return $parsedHeaders; + } + + /** + * @return string[] + * + * @psalm-return list + */ + protected function splitHeaderLines(string $headersRaw): array + { + /** @var list */ + return \explode("\n", \str_replace(["\r\n", "\r"], "\n", $headersRaw)); + } + + /** + * @param string[][] $parsedHeaders + * @param string|null $headerName + * + * @psalm-param array> $parsedHeaders + */ + protected function storeParsedHeader(array &$parsedHeaders, ?string $headerName, string $headerValue): void + { + if (null === $headerName || '' === \trim($headerName)) { + return; + } + + $headerName = $this->normalizeHeaderName($headerName); + + $parsedHeaders[$headerName] ??= []; + $parsedHeaders[$headerName][] = \trim($headerValue); + } + + protected function normalizeHeaderName(string $headerName): string + { + return \strtolower(\trim($headerName)); + } } diff --git a/src/PhpImap/Mailbox.php b/src/PhpImap/Mailbox.php index b621aa92..d6e2514c 100644 --- a/src/PhpImap/Mailbox.php +++ b/src/PhpImap/Mailbox.php @@ -1290,7 +1290,7 @@ public function getMailHeader(int $mailId): IncomingMailHeader } $header = new IncomingMailHeader(); - $header->headersRaw = $headersRaw; + $header->setHeadersRaw($headersRaw); $header->headers = $head; $header->id = $mailId; $header->imapPath = $this->imapPath; diff --git a/tests/unit/IncomingMailHeaderTest.php b/tests/unit/IncomingMailHeaderTest.php new file mode 100644 index 00000000..d81001ab --- /dev/null +++ b/tests/unit/IncomingMailHeaderTest.php @@ -0,0 +1,69 @@ +setHeadersRaw( + "Origin-MessageID: \r\n". + "X-Trace: first\r\n". + "\tcontinued\r\n". + "x-trace: second\r\n". + "Subject: Example\r\n" + ); + + $this->assertSame('', $header->getHeader('origin-messageid')); + $this->assertSame( + ['first continued', 'second'], + $header->getHeaders('X-TRACE') + ); + $this->assertSame( + [ + 'origin-messageid' => [''], + 'x-trace' => ['first continued', 'second'], + 'subject' => ['Example'], + ], + $header->getAllHeaders() + ); + } + + public function testGetHeaderLazilyParsesDirectlyAssignedHeadersRaw(): void + { + $header = new IncomingMailHeader(); + $header->headersRaw = "X-Custom-Header: custom value\r\n"; + + $this->assertSame('custom value', $header->getHeader('x-custom-header')); + $this->assertSame( + ['custom value'], + $header->headersByName['x-custom-header'] + ); + $this->assertNull($header->getHeader('missing-header')); + $this->assertSame([], $header->getHeaders('missing-header')); + } + + public function testIncomingMailRetainsParsedHeadersAfterSetHeader(): void + { + $header = new IncomingMailHeader(); + $header->setHeadersRaw( + "X-Custom-Header: custom value\r\n". + "Received: mx1.example.test\r\n". + "Received: mx2.example.test\r\n" + ); + + $mail = new IncomingMail(); + $mail->setHeader($header); + + $this->assertSame('custom value', $mail->getHeader('X-Custom-Header')); + $this->assertSame( + ['mx1.example.test', 'mx2.example.test'], + $mail->getHeaders('received') + ); + } +}