* 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[static::PROPERTY_SIZE] = $message->size(); 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] = []; foreach ($message->flags() as $flag) { $flag = ltrim($flag, '\\'); $normalized = match (strtolower($flag)) { 'seen' => 'seen', 'flagged' => 'flagged', 'answered' => 'answered', 'draft' => 'draft', 'deleted' => 'deleted', default => strtolower($flag), }; $this->data[static::PROPERTY_FLAGS][$normalized] = true; } return $this; } // ── Cache (meta store / content store) ─────────────────────────────────── /** * 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; } }