* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\ProviderImap\Providers; use DateTimeImmutable; use DateTimeInterface; use KTXM\ProviderImap\Client\Message; use KTXM\ProviderImap\Client\MessagePart as ClientMessagePart; use KTXF\Mail\Object\MessagePropertiesMutableAbstract; /** * Mail Message Properties Implementation */ class MessageProperties extends MessagePropertiesMutableAbstract { /** * Convert IMAP data to mail message properties object. */ public function fromImap(Message $message): static { $this->data['size'] = $message->size(); $this->data['flags'] = []; foreach ($message->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; } if ($message->messageId() !== null) { $this->data['urid'] = $message->messageId(); } if ($message->subject() !== null) { $this->data['subject'] = $message->subject(); } if ($message->sentAt() !== null) { $date = new DateTimeImmutable($message->sentAt()); $this->data['date'] = $date->format(DateTimeInterface::ATOM); } if ($message->inReplyTo() !== null) { $this->data['inReplyTo'] = $message->inReplyTo(); } if ($message->from() !== []) { $this->data['from'] = $message->from()[0]->toArray(); } if ($message->sender() !== []) { $this->data['sender'] = $message->sender()[0]->toArray(); } foreach (['to', 'cc', 'bcc', 'replyTo'] as $field) { $addresses = $message->{$field}(); if ($addresses === []) { continue; } $this->data[$field] = array_map( static fn ($address): array => $address->toArray(), $addresses, ); } if ($message->bodyStructure() !== null) { $this->data['body'] = $message->bodyStructure()->toArray(); $attachments = []; self::collectAttachments($message->bodyStructure(), $attachments); if ($attachments !== []) { $this->data['attachments'] = $attachments; } } if ($message->bodyStructure() !== null) { $this->data['body'] = $message->bodyStructure()->toArray(); // Recursively add content from bodyValues to matching parts if (is_array($message->bodySections())) { $addContentToParts = function(&$structure, $bodyValues) use (&$addContentToParts) { // If this part has a partId and matching bodyValue, add content if (isset($structure['partId']) && isset($bodyValues[$structure['partId']])) { $structure['content'] = $bodyValues[$structure['partId']] ?? null; } // Recursively process subParts if (isset($structure['subParts']) && is_array($structure['subParts'])) { foreach ($structure['subParts'] as &$subPart) { $addContentToParts($subPart, $bodyValues); } } }; $addContentToParts($this->data['body'], $message->bodySections()); } } return $this; } private static function normalizeFlag(string $flag): string { $flag = ltrim($flag, '\\'); return match (strtolower($flag)) { 'seen' => 'read', 'flagged' => 'flagged', 'answered' => 'answered', 'draft' => 'draft', 'deleted' => 'deleted', default => strtolower($flag), }; } /** * 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(ClientMessagePart $part, array &$attachments): void { $children = $part->parts(); if ($children !== []) { foreach ($children as $childPart) { self::collectAttachments($childPart, $attachments); } return; } $mimeType = strtolower($part->mimeType()); $disposition = strtolower($part->disposition() ?? ''); $name = $part->parameters()['name'] ?? $part->dispositionParameters()['filename'] ?? null; $isInlineText = str_starts_with($mimeType, 'text/') && in_array($mimeType, ['text/plain', 'text/html'], true) && $disposition !== 'attachment'; if ($isInlineText || ($disposition === '' && $name === null)) { return; } $attachments[] = $part->toArray(); } /** * 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; } }