feat: add mime builder for smtp and imap

Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
2026-06-23 16:27:37 -04:00
parent d94b1ea211
commit e031831719
3 changed files with 763 additions and 6 deletions
+4 -2
View File
@@ -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"
}
}
}
Generated
+488 -4
View File
@@ -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"
}
+271
View File
@@ -0,0 +1,271 @@
<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* 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<string>
*/
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<string>
*/
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<string,string>
*/
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<string,string> $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<int,AddressInterface> $addresses
* @return list<MimeAddress>
*/
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<>");
}
}