diff --git a/composer.json b/composer.json index e5ec9a8..6920478 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,9 @@ "ext-iconv": "*", "ext-ctype": "*", "psr/log": "^1.0|^2.0|^3.0", - "doctrine/lexer": "^3.0" + "doctrine/lexer": "^3.0", + "symfony/mime": "^7.0", + "egulias/email-validator": "^4.0" }, "require-dev": { "phpunit/phpunit": "^11.0" @@ -43,4 +45,4 @@ "test:unit": "phpunit --configuration tests/php/phpunit.unit.xml --colors=always --testdox", "test:coverage": "XDEBUG_MODE=coverage phpunit --configuration tests/php/phpunit.unit.xml --coverage-html .phpunit.coverage --coverage-text" } -} \ No newline at end of file +} diff --git a/composer.lock b/composer.lock index a45a0f4..b95625e 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "46ea36f63cc6f77b99348c76fc0f72b2", + "content-hash": "63573b43a17b583b8766d74720f367a3", "packages": [ { "name": "doctrine/lexer", @@ -83,6 +83,73 @@ ], "time": "2024-02-05T11:56:58+00:00" }, + { + "name": "egulias/email-validator", + "version": "4.0.4", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "reference": "d42c8731f0624ad6bdc8d3e5e9a4524f68801cfa", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^2.0 || ^3.0", + "php": ">=8.1", + "symfony/polyfill-intl-idn": "^1.26" + }, + "require-dev": { + "phpunit/phpunit": "^10.2", + "vimeo/psalm": "^5.12" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/4.0.4" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2025-03-06T22:45:56+00:00" + }, { "name": "psr/log", "version": "3.0.2", @@ -132,6 +199,423 @@ "source": "https://github.com/php-fig/log/tree/3.0.2" }, "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.7.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.7-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-04-13T15:52:40+00:00" + }, + { + "name": "symfony/mime", + "version": "v7.4.13", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "a845722765c4f6b2ce88beaf4f4479975b186770" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/a845722765c4f6b2ce88beaf4f4479975b186770", + "reference": "a845722765c4f6b2ce88beaf4f4479975b186770", + "shasum": "" + }, + "require": { + "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0" + }, + "conflict": { + "egulias/email-validator": "~3.0.0", + "phpdocumentor/reflection-docblock": "<5.2|>=7", + "phpdocumentor/type-resolver": "<1.5.1", + "symfony/mailer": "<6.4", + "symfony/serializer": "<6.4.3|>7.0,<7.0.3" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10|^3.1|^4", + "league/html-to-markdown": "^5.0", + "phpdocumentor/reflection-docblock": "^5.2|^6.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-info": "^6.4|^7.0|^8.0", + "symfony/serializer": "^6.4.3|^7.0.3|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v7.4.13" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-23T16:22:37+00:00" + }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.38.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "dc21118016c039a66235cf93d96b435ffb282412" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/dc21118016c039a66235cf93d96b435ffb282412", + "reference": "dc21118016c039a66235cf93d96b435ffb282412", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "symfony/polyfill-intl-normalizer": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.38.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T15:22:23+00:00" + }, + { + "name": "symfony/polyfill-intl-normalizer", + "version": "v1.38.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-normalizer.git", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "reference": "2d446c214bdbe5b71bde5011b060a05fece3ae6b", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Intl\\Normalizer\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's Normalizer class and related functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "intl", + "normalizer", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.38.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-25T13:48:31+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.38.2", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "reference": "d3d318bad5e7a1bfbd026009c8bfb8d8f99ae6b6", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.38.2" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-27T06:59:30+00:00" } ], "packages-dev": [ @@ -1919,7 +2403,7 @@ ], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": true, "prefer-lowest": false, "platform": { @@ -1927,9 +2411,9 @@ "ext-iconv": "*", "ext-ctype": "*" }, - "platform-dev": [], + "platform-dev": {}, "platform-overrides": { "php": "8.2" }, - "plugin-api-version": "2.3.0" + "plugin-api-version": "2.6.0" } diff --git a/lib/Mime/MessageBuilder.php b/lib/Mime/MessageBuilder.php new file mode 100644 index 0000000..70f06b6 --- /dev/null +++ b/lib/Mime/MessageBuilder.php @@ -0,0 +1,271 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ProviderImap\Mime; + +use DateTimeImmutable; +use KTXF\Mail\Object\AddressInterface; +use KTXF\Mail\Object\MessagePartInterface; +use KTXF\Mail\Object\MessagePropertiesBaseInterface; +use Symfony\Component\Mime\Address as MimeAddress; +use Symfony\Component\Mime\Email; +use Symfony\Component\Mime\Part\DataPart; + +/** + * MIME Message Builder + * + * Converts a framework {@see MessagePropertiesBaseInterface} into raw RFC822 + * bytes. The output is transport-neutral and used by both of this module's + * outbound paths: the SMTP DATA payload (submission) and the IMAP APPEND + * literal (saving a copy to the Sent collection). + * + * The MIME engine (symfony/mime) is intentionally hidden behind this API so it + * can be swapped without touching consumers. + * + * @since 2026.06.22 + */ +final class MessageBuilder +{ + /** + * Headers that are derived from structured properties and therefore must + * not be copied verbatim from {@see MessagePropertiesBaseInterface::getHeaders()}. + * + * @var list + */ + private const RESERVED_HEADERS = [ + 'from', 'sender', 'reply-to', 'to', 'cc', 'bcc', 'subject', 'date', + 'message-id', 'mime-version', 'content-type', 'content-transfer-encoding', + 'in-reply-to', 'references', 'bcc', + ]; + + /** + * Build the complete RFC822 message bytes for the given properties. + */ + public function build(MessagePropertiesBaseInterface $message): string + { + return $this->toEmail($message)->toString(); + } + + /** + * Collect the envelope recipients (To + Cc + Bcc) as bare e-mail addresses. + * + * Useful for the SMTP RCPT TO phase, which needs every recipient including + * Bcc, even though Bcc never appears in the rendered headers. + * + * @return list + */ + public static function recipients(MessagePropertiesBaseInterface $message): array + { + $recipients = []; + + foreach ([...$message->getTo(), ...$message->getCc(), ...$message->getBcc()] as $address) { + $email = trim($address->getAddress()); + if ($email !== '' && !in_array($email, $recipients, true)) { + $recipients[] = $email; + } + } + + return $recipients; + } + + private function toEmail(MessagePropertiesBaseInterface $message): Email + { + $email = new Email(); + + if (($from = $message->getFrom()) !== null) { + $email->from($this->address($from)); + } + if (($sender = $message->getSender()) !== null) { + $email->sender($this->address($sender)); + } + + $replyTo = $this->addresses($message->getReplyTo()); + if ($replyTo !== []) { + $email->replyTo(...$replyTo); + } + + $to = $this->addresses($message->getTo()); + if ($to !== []) { + $email->to(...$to); + } + + $cc = $this->addresses($message->getCc()); + if ($cc !== []) { + $email->cc(...$cc); + } + + $bcc = $this->addresses($message->getBcc()); + if ($bcc !== []) { + $email->bcc(...$bcc); + } + + $email->subject($message->getSubject()); + + if (($sent = $message->getSent()) !== null) { + $email->date($sent instanceof DateTimeImmutable ? $sent : DateTimeImmutable::createFromInterface($sent)); + } + + $text = $message->getBodyTextPlain(); + $html = $message->getBodyTextHtml(); + // Inline attachments may carry Content-IDs without an "@"; those must be + // normalised (symfony requires an "@") and the matching cid: references + // in the HTML body rewritten so the parts still resolve. + $cidRewrites = $this->inlineCidRewrites($message); + if ($text !== null && $text !== '') { + $email->text($text); + } + if ($html !== null && $html !== '') { + $email->html($this->rewriteCids($html, $cidRewrites)); + } + // Guarantee at least an (empty) text part so the message is well-formed. + if (($text === null || $text === '') && ($html === null || $html === '')) { + $email->text(''); + } + + foreach ($message->getAttachments() as $attachment) { + $part = $this->attachmentPart($attachment); + if ($part !== null) { + $email->addPart($part); + } + } + + $this->applyThreadHeaders($email, $message); + $this->applyCustomHeaders($email, $message); + + return $email; + } + + private function attachmentPart(MessagePartInterface $attachment): ?DataPart + { + $content = $attachment->getContent(); + if ($content === null) { + return null; + } + + $part = new DataPart( + $content, + $attachment->getName(), + $attachment->getType() ?? 'application/octet-stream', + ); + + if ($attachment->getDisposition() === 'inline') { + $part->asInline(); + $cid = $attachment->getContentId(); + if ($cid !== null && $cid !== '') { + $part->setContentId($this->normalizeContentId(trim($cid, '<>'))); + } + } + + return $part; + } + + /** + * Build a map of original => normalised Content-IDs for inline parts whose + * id required normalisation (i.e. lacked an "@"). + * + * @return array + */ + private function inlineCidRewrites(MessagePropertiesBaseInterface $message): array + { + $rewrites = []; + + foreach ($message->getAttachments() as $attachment) { + if ($attachment->getDisposition() !== 'inline') { + continue; + } + $cid = $attachment->getContentId(); + if ($cid === null || $cid === '') { + continue; + } + $original = trim($cid, '<>'); + $normalized = $this->normalizeContentId($original); + if ($original !== $normalized) { + $rewrites[$original] = $normalized; + } + } + + return $rewrites; + } + + /** + * @param array $rewrites + */ + private function rewriteCids(string $html, array $rewrites): string + { + foreach ($rewrites as $original => $normalized) { + $html = str_replace('cid:' . $original, 'cid:' . $normalized, $html); + } + + return $html; + } + + private function normalizeContentId(string $cid): string + { + return str_contains($cid, '@') ? $cid : $cid . '@ktrix'; + } + + private function applyThreadHeaders(Email $email, MessagePropertiesBaseInterface $message): void + { + $headers = $email->getHeaders(); + + $inReplyTo = $message->getInReplyTo(); + if ($inReplyTo !== null && $inReplyTo !== '') { + $headers->addIdHeader('In-Reply-To', $this->stripBrackets($inReplyTo)); + } + + $references = array_values(array_filter(array_map( + fn (string $id): string => $this->stripBrackets($id), + $message->getReferences(), + ), static fn (string $id): bool => $id !== '')); + + if ($references !== []) { + $headers->addIdHeader('References', $references); + } + } + + private function applyCustomHeaders(Email $email, MessagePropertiesBaseInterface $message): void + { + $headers = $email->getHeaders(); + + foreach ($message->getHeaders() as $name => $value) { + if (!is_string($name) || $value === null) { + continue; + } + if (in_array(strtolower($name), self::RESERVED_HEADERS, true)) { + continue; + } + $headers->addTextHeader($name, is_array($value) ? implode(', ', $value) : (string) $value); + } + } + + /** + * @param array $addresses + * @return list + */ + private function addresses(array $addresses): array + { + $result = []; + foreach ($addresses as $address) { + if ($address instanceof AddressInterface && trim($address->getAddress()) !== '') { + $result[] = $this->address($address); + } + } + return $result; + } + + private function address(AddressInterface $address): MimeAddress + { + return new MimeAddress($address->getAddress(), $address->getLabel() ?? ''); + } + + private function stripBrackets(string $id): string + { + return trim($id, " \t<>"); + } +}