* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\ProviderImapMail\Providers; use DateTimeImmutable; use DateTimeInterface; use Gricob\IMAP\Mime\Part\Part as MimePart; use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure; use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\MultiPart; use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\Part; use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\BodyStructure\SinglePart; use Gricob\IMAP\Protocol\Response\Line\Data\Fetch\Envelope; use KTXF\Mail\Object\MessagePropertiesMutableAbstract; /** * Mail Message Properties Implementation */ class MessageProperties extends MessagePropertiesMutableAbstract { /** * Convert IMAP data to mail message properties object * * @param array $flags IMAP flags (e.g. ['\Seen', '\Flagged', ...]) * @param ?Envelope $envelope parsed envelope from gricob * @param ?BodyStructure $bodyStructure parsed body structure from gricob * @param int $size RFC822.SIZE byte count */ public function fromImap( array $flags, ?Envelope $envelope, ?BodyStructure $bodyStructure, int $size = 0, ?MimePart $bodyPart = null, ): static { // ── Size ────────────────────────────────────────────────────── $this->data['size'] = $size; // ── Flags ───────────────────────────────────────────────────── $this->data['flags'] = []; foreach ($flags as $flag) { $flag = ltrim($flag, '\\'); $normalized = match (strtolower($flag)) { 'seen' => 'read', 'flagged' => 'flagged', 'answered' => 'answered', 'draft' => 'draft', 'deleted' => 'deleted', default => strtolower($flag), }; $this->data['flags'][$normalized] = true; } // ── Envelope ────────────────────────────────────────────────── if ($envelope !== null) { if ($envelope->messageId !== null) { $this->data['urid'] = trim($envelope->messageId, '<>'); } if ($envelope->subject !== null) { // Decode MIME encoded-word in subject $this->data['subject'] = mb_decode_mimeheader($envelope->subject); } if ($envelope->date !== null) { $date = $envelope->date instanceof DateTimeImmutable ? $envelope->date : new DateTimeImmutable($envelope->date); $this->data['date'] = $date->format(DateTimeInterface::ATOM); } if ($envelope->inReplyTo !== null) { $this->data['inReplyTo'] = $envelope->inReplyTo; } $addressToArray = static function ($addr): array { $email = ''; if ($addr->mailboxName !== null && $addr->hostName !== null) { $email = $addr->mailboxName . '@' . $addr->hostName; } elseif ($addr->mailboxName !== null) { $email = $addr->mailboxName; } return [ 'address' => $email, 'label' => $addr->displayName ?? null, ]; }; if (!empty($envelope->from)) { $this->data['from'] = $addressToArray($envelope->from[0]); } if (!empty($envelope->sender)) { $this->data['sender'] = $addressToArray($envelope->sender[0]); } foreach (['to', 'cc', 'bcc', 'replyTo'] as $field) { $envField = $field === 'replyTo' ? 'replyTo' : $field; if (!empty($envelope->$envField)) { $this->data[$field] = []; foreach ($envelope->$envField as $addr) { $this->data[$field][] = $addressToArray($addr); } } } } // ── Body Structure ──────────────────────────────────────────── if ($bodyStructure !== null) { $rootPart = (new MessagePart())->fromImap($bodyStructure->part, '1'); // ── Body Content: inject decoded content onto part nodes ────── if ($bodyPart !== null) { $rootPart->injectBodyContent($bodyPart); } $this->data['body'] = $rootPart->toStore(); // Collect attachments: non-body parts with name or attachment disposition $attachments = []; self::collectAttachments($bodyStructure->part, '1', $attachments); if (!empty($attachments)) { $this->data['attachments'] = $attachments; } } return $this; } /** * Convert a string to UTF-8 from the given charset. * * Tries mb_convert_encoding first; falls back to iconv when mbstring does * not recognise the charset name (e.g. "windows-1250"). */ public static function toUtf8(string $content, string $charset): string { if ($charset === '' || in_array(strtolower($charset), ['utf-8', 'utf8'], true)) { // Content claims to be UTF-8 but may still have invalid sequences; scrub to be safe. return mb_convert_encoding($content, 'UTF-8', 'UTF-8'); } // Try mbstring first try { $converted = mb_convert_encoding($content, 'UTF-8', $charset); if ($converted !== false) { return $converted; } } catch (\ValueError) { // charset not recognised by mbstring — fall through to iconv } // iconv fallback (handles Windows-125x, ISO-8859-*, etc.) $converted = @iconv($charset, 'UTF-8//TRANSLIT//IGNORE', $content); $content = ($converted !== false) ? $converted : $content; // Final scrub: strip any residual invalid UTF-8 bytes so json_encode never fails. return mb_convert_encoding($content, 'UTF-8', 'UTF-8'); } /** * Recursively collect attachment parts from body structure */ private static function collectAttachments( Part $part, string $partId, array &$attachments, ): void { if ($part instanceof SinglePart) { $type = strtolower($part->type ?? ''); $subtype = strtolower($part->subtype ?? ''); $disposition = strtolower($part->disposition?->type ?? ''); $name = null; if (!empty($part->attributes)) { foreach ($part->attributes as $k => $v) { if (strtolower($k) === 'name') { $name = $v; break; } } } if (!empty($part->disposition?->attributes)) { foreach ($part->disposition->attributes as $k => $v) { if (strtolower($k) === 'filename') { $name = $name ?? $v; } } } $isInlineText = ($type === 'text' && ($subtype === 'plain' || $subtype === 'html') && $disposition !== 'attachment'); if (!$isInlineText && ($disposition !== '' || $name !== null)) { $mp = (new MessagePart())->fromImap($part, $partId); $attachments[] = $mp->toStore(); } } elseif ($part instanceof MultiPart) { foreach ($part->parts as $index => $subPart) { self::collectAttachments($subPart, $partId . '.' . ($index + 1), $attachments); } } } /** * Serialize to store array */ public function toStore(): array { return $this->data; } /** * Hydrate from store array */ public function fromStore(array $data): static { $this->data = $data; return $this; } }