feat: initial version

Signed-off-by: Sebastian Krupinski <root@LAPTOP-7DVOR6NC>
This commit was merged in pull request #1.
This commit is contained in:
Sebastian Krupinski
2026-02-20 16:41:19 -05:00
committed by Sebastian Krupinski
parent a313767846
commit e51c65bf19
139 changed files with 11256 additions and 0 deletions

View File

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