* 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<>"); } }