* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\ProviderImapMail\Providers; 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 KTXF\Mail\Object\MessagePartMutableAbstract; /** * Mail Message Part Implementation */ class MessagePart extends MessagePartMutableAbstract { /** * Convert gricob BodyStructure part to message part object * * @param Part $part gricob BodyStructure Part (SinglePart or MultiPart) * @param string $partId numeric part identifier (e.g. "1", "1.1", "2") */ public function fromImap(Part $part, string $partId = '1'): static { $this->data['partId'] = $partId; if ($part instanceof SinglePart) { $mimeType = strtolower($part->type) . '/' . strtolower($part->subtype); $this->data['type'] = $mimeType; if ($part->id !== null) { $this->data['blobId'] = trim($part->id, '<>'); } // Content-Type parameters (name, charset, etc.) if (!empty($part->attributes)) { foreach ($part->attributes as $key => $value) { $keyLower = strtolower($key); if ($keyLower === 'name') { $this->data['name'] = $value; } elseif ($keyLower === 'charset') { $this->data['charset'] = $value; } } } if ($part->encoding !== null) { $this->data['encoding'] = strtolower($part->encoding); } if ($part->size !== null) { $this->data['size'] = $part->size; } if ($part->disposition !== null) { $this->data['disposition'] = strtolower($part->disposition->type); // disposition filename attribute if (!empty($part->disposition->attributes)) { foreach ($part->disposition->attributes as $key => $value) { if (strtolower($key) === 'filename') { $this->data['name'] = $this->data['name'] ?? $value; } } } } if (!empty($part->language)) { $this->data['language'] = implode(',', $part->language); } if ($part->location !== null) { $this->data['location'] = $part->location; } } elseif ($part instanceof MultiPart) { $this->data['type'] = 'multipart/' . strtolower($part->subtype); if ($part->disposition !== null) { $this->data['disposition'] = strtolower($part->disposition->type); } if (!empty($part->language)) { $this->data['language'] = implode(',', $part->language); } if ($part->location !== null) { $this->data['location'] = $part->location; } // Recursively process sub-parts // When this part has no section ID (root multipart) children are // numbered "1", "2", … to match IMAP section numbering. foreach ($part->parts as $index => $subPart) { $subPartId = ($partId === '') ? (string)($index + 1) : $partId . '.' . ($index + 1); $this->parts[] = (new MessagePart())->fromImap($subPart, $subPartId); } } return $this; } /** * Convert message part to store array */ public function toStore(): array { $data = $this->data; if (count($this->parts) > 0) { $data['subParts'] = []; foreach ($this->parts as $subPart) { if ($subPart instanceof MessagePart) { $data['subParts'][] = $subPart->toStore(); } } } else { $data['subParts'] = null; } return $data; } /** * Hydrate message part from store array */ public function fromStore(array $data): static { if (isset($data['subParts']) && is_array($data['subParts'])) { foreach ($data['subParts'] as $subPart) { $this->parts[] = (new MessagePart())->fromStore($subPart); } unset($data['subParts']); } $this->data = $data; return $this; } /** * Inject decoded body content from a map of IMAP section-ID → raw encoded text. * * Walks the MessagePart tree recursively. For each text/* leaf part whose * partId is present in $sectionMap the raw text is decoded according to the * part's Content-Transfer-Encoding and converted to UTF-8 before being * stored in 'content'. Binary parts (images, PDFs, …) are skipped. * * @param array $sectionMap Keys: IMAP section IDs (e.g. "1", "1.2"); * Values: raw (transfer-encoded) body text */ public function injectSections(array $sectionMap): void { // MultiPart: recurse into children if (!empty($this->parts)) { foreach ($this->parts as $childPart) { if ($childPart instanceof MessagePart) { $childPart->injectSections($sectionMap); } } return; } // SinglePart: only inject decoded content for text/* MIME types $type = strtolower($this->data['type'] ?? ''); if (!str_starts_with($type, 'text/')) { return; } $partId = $this->data['partId'] ?? null; if ($partId === null || !array_key_exists($partId, $sectionMap)) { return; } $raw = $sectionMap[$partId]; $encoding = strtolower($this->data['encoding'] ?? '7bit'); $decoded = match ($encoding) { 'quoted-printable' => quoted_printable_decode($raw), 'base64' => base64_decode($raw, strict: false), default => $raw, // 7bit, 8bit, binary }; $charset = $this->data['charset'] ?? 'us-ascii'; $this->data['content'] = MessageProperties::toUtf8($decoded, $charset); } }