Files
provider_imap/lib/Providers/MessageProperties.php
T
2026-10-05 20:34:02 -04:00

269 lines
8.7 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
* 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<string>
*/
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<string>
*/
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<string>
*/
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;
}
}