generated from Nodarx/template
feat: add mime builder for smtp and imap
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -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<>");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user