* 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 { /** * Part ids of text sections that were cut off when fetched (cache only, not part of the API shape) * * @var list */ private array $incompleteSections = []; /** * Convert IMAP data to mail message properties object. */ public function fromImap(Message $message): static { $this->data[static::PROPERTY_SIZE] = $message->size(); $this->incompleteSections = $message->truncatedSections(); if ($message->messageId() !== null) { $this->data[static::PROPERTY_URID] = $message->messageId(); } if ($message->inReplyTo() !== null) { $this->data[static::PROPERTY_IN_REPLY_TO] = $message->inReplyTo(); } if ($message->references() !== []) { $this->data[static::PROPERTY_REFERENCES] = $message->references(); } $receivedAt = $message->receivedAt() ?? $message->internalDate(); if ($receivedAt !== null) { $date = new DateTimeImmutable($receivedAt); $this->data[static::PROPERTY_RECEIVED] = $date->format(DateTimeInterface::ATOM); } if ($message->sentAt() !== null) { $date = new DateTimeImmutable($message->sentAt()); $this->data[static::PROPERTY_SENT] = $date->format(DateTimeInterface::ATOM); } if ($message->sender() !== []) { $this->data[static::PROPERTY_SENDER] = $message->sender()[0]->toArray(); } if ($message->from() !== []) { $this->data[static::PROPERTY_FROM] = $message->from()[0]->toArray(); } $addressProperties = [ 'to' => static::PROPERTY_TO, 'cc' => static::PROPERTY_CC, 'bcc' => static::PROPERTY_BCC, 'replyTo' => static::PROPERTY_REPLY_TO, ]; foreach ($addressProperties as $field => $property) { $addresses = $message->{$field}(); if ($addresses === []) { continue; } $this->data[$property] = array_map( static fn ($address): array => $address->toArray(), $addresses, ); } if ($message->subject() !== null) { $this->data[static::PROPERTY_SUBJECT] = $message->subject(); } if ($message->bodyStructure() !== null) { $body = $message->bodyStructure()->withInjectedSections($message->bodySections() ?? [])->toArray(); $this->data[static::PROPERTY_BODY] = $body; $attachments = []; $this->collectAttachments($body, $attachments); if ($attachments !== []) { $this->data[static::PROPERTY_ATTACHMENTS] = $attachments; } } $this->data[static::PROPERTY_FLAGS] = array_fill_keys(self::normalizeFlags($message->flags()), true); return $this; } /** * Normalise IMAP flags (e.g. "\\Seen", "$Forwarded") to flag names (e.g. "seen", "$forwarded"). * * @param string[] $flags * @return list */ public static function normalizeFlags(array $flags): array { $normalized = []; foreach ($flags as $flag) { $normalized[] = strtolower(ltrim($flag, '\\')); } return array_values(array_unique($normalized)); } // ── Cache (meta store / content store) ─────────────────────────────────── /** * Part ids of text sections that were cut off when fetched and must be completed on open. * * @return list */ public function getIncompleteSections(): array { return $this->incompleteSections; } public function setIncompleteSections(string ...$partIds): static { $this->incompleteSections = array_values(array_unique($partIds)); return $this; } /** * Serialise the fields the meta store needs for list, filter and sort. * * Dates are normalised to UTC so they sort correctly as strings; the original * values (with their offsets) are kept in the content store. */ public function toCacheMeta(): array { return [ 'received' => self::cacheDate($this->data[static::PROPERTY_RECEIVED] ?? null), 'sent' => self::cacheDate($this->data[static::PROPERTY_SENT] ?? null), 'size' => $this->data[static::PROPERTY_SIZE] ?? 0, 'subject' => $this->data[static::PROPERTY_SUBJECT] ?? '', 'from' => $this->data[static::PROPERTY_FROM] ?? null, 'to' => $this->data[static::PROPERTY_TO] ?? [], 'cc' => $this->data[static::PROPERTY_CC] ?? [], 'urid' => $this->data[static::PROPERTY_URID] ?? null, 'inReplyTo' => $this->data[static::PROPERTY_IN_REPLY_TO] ?? null, 'references' => $this->data[static::PROPERTY_REFERENCES] ?? [], // list of set flags, not a map: keywords such as $Forwarded are not valid field names 'flags' => array_keys(array_filter($this->data[static::PROPERTY_FLAGS] ?? [])), 'hasAttachments' => ($this->data[static::PROPERTY_ATTACHMENTS] ?? []) !== [], 'preview' => $this->cachePreview(), ]; } /** * Restore the mutable fields from a meta store document. * * Only flags are taken from the meta store; everything else comes from the * content store, which holds the original values. */ public function fromCacheMeta(array $document): static { $this->data[static::PROPERTY_FLAGS] = array_fill_keys( array_map('strval', (array) ($document['flags'] ?? [])), true, ); return $this; } /** * Serialise the immutable message content for the content store. */ public function toCacheContent(): array { $content = $this->data; unset($content[static::PROPERTY_FLAGS]); return $content; } /** * Restore the immutable message content from the content store, keeping any flags already set. */ public function fromCacheContent(array $content): static { unset($content[static::PROPERTY_FLAGS]); $flags = $this->data[static::PROPERTY_FLAGS] ?? null; $this->data = $content; if ($flags !== null) { $this->data[static::PROPERTY_FLAGS] = $flags; } return $this; } private function cachePreview(int $length = 200): string { $text = $this->getBodyTextPlain(); if ($text === null) { $html = $this->getBodyTextHtml(); $text = $html !== null ? html_entity_decode(strip_tags((string) preg_replace('/<(style|script)\b[^>]*>.*?<\/\1>/is', ' ', $html)), ENT_QUOTES | ENT_HTML5, 'UTF-8') : ''; } $text = trim((string) preg_replace('/\s+/u', ' ', $text)); return mb_substr($text, 0, $length); } private static function cacheDate(?string $value): ?string { if ($value === null || $value === '') { return null; } try { return (new DateTimeImmutable($value)) ->setTimezone(new \DateTimeZone('UTC')) ->format('Y-m-d\TH:i:s\Z'); } catch (\Exception) { return null; } } /** * Recursively collect attachment parts from body structure */ private function collectAttachments(array $part, array &$attachments): void { $children = $part['subParts'] ?? []; if ($children !== []) { foreach ($children as $childPart) { $this->collectAttachments($childPart, $attachments); } return; } $mimeType = strtolower((string)($part['type'] ?? '')); $disposition = strtolower((string)($part['disposition'] ?? '')); $name = $part['name'] ?? 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; } }