MultipartMessage.php

PHP

Path: src/Mail/MultipartMessage.php

<?php

namespace mini\Mail;

use ArrayAccess;
use Countable;
use IteratorAggregate;
use Psr\Http\Message\MessageInterface;
use Psr\Http\Message\StreamInterface;
use Traversable;
use ArrayIterator;

/**
 * Multipart Message
 *
 * A PSR-7 MessageInterface that contains multiple child MessageInterfaces.
 * When read as a stream, produces RFC 2046 compliant boundary-delimited content.
 *
 * Accepts any MessageInterface implementation (Guzzle responses, PSR-7 messages, etc.)
 * without requiring special wrapper classes.
 *
 * Usage:
 * ```php
 * $message = new MultipartMessage(MultipartType::Mixed,
 *     new Message('text/plain', 'Hello'),
 *     new Message('text/html', '<h1>Hello</h1>'),
 *     $guzzleResponse  // Any MessageInterface works
 * );
 *
 * // Access parts
 * $part = $message->getPart(0);
 * $count = count($message);
 * foreach ($message as $part) { ... }
 *
 * // Modify (immutable)
 * $message = $message->withAddedPart($attachment);
 * $message = $message->withoutPart(2);
 *
 * // Filter
 * $htmlParts = $message->findParts(fn($p) =>
 *     str_starts_with($p->getHeaderLine('Content-Type'), 'text/html')
 * );
 *
 * // Stream the body
 * $body = $message->getBody();
 * while (!$body->eof()) {
 *     echo $body->read(8192);
 * }
 * ```
 *
 * @see https://datatracker.ietf.org/doc/html/rfc2046#section-5
 */
class MultipartMessage implements MessageInterface, Countable, IteratorAggregate, ArrayAccess
{
    protected string $protocolVersion = '1.1';
    protected array $headers = [];
    protected array $headerCases = [];

    /** @var MessageInterface[] */
    protected array $parts = [];

    protected string $boundary;

    /**
     * Create a multipart message
     *
     * @param MultipartType|string $type Multipart type
     * @param MessageInterface ...$parts Initial parts
     */
    public function __construct(
        MultipartType|string $type = MultipartType::Mixed,
        MessageInterface ...$parts
    ) {
        $this->boundary = $this->generateBoundary();
        $this->parts = $parts;

        $subtype = $type instanceof MultipartType ? $type->value : $type;
        $contentType = "multipart/{$subtype}; boundary=\"{$this->boundary}\"";
        $this->headers['content-type'] = [$contentType];
        $this->headerCases['content-type'] = 'Content-Type';
    }

    /**
     * Generate a unique boundary string
     */
    protected function generateBoundary(): string
    {
        return '=_Part_' . bin2hex(random_bytes(16));
    }

    // =========================================================================
    // Parts API (modeled after headers API)
    // =========================================================================

    /**
     * Get all parts
     *
     * @return MessageInterface[]
     */
    public function getParts(): array
    {
        return $this->parts;
    }

    /**
     * Get a specific part by index
     *
     * @param int $index Zero-based index
     * @return MessageInterface|null The part, or null if index out of bounds
     */
    public function getPart(int $index): ?MessageInterface
    {
        return $this->parts[$index] ?? null;
    }

    /**
     * Check if a part exists at the given index
     *
     * @param int $index Zero-based index
     * @return bool
     */
    public function hasPart(int $index): bool
    {
        return isset($this->parts[$index]);
    }

    /**
     * Return instance with part replaced at the specified index
     *
     * @param int $index Zero-based index
     * @param MessageInterface $part The replacement part
     * @return static
     * @throws \OutOfBoundsException If index is out of bounds
     */
    public function withPart(int $index, MessageInterface $part): static
    {
        if (!isset($this->parts[$index])) {
            throw new \OutOfBoundsException("Part index {$index} does not exist");
        }

        $clone = clone $this;
        $clone->parts[$index] = $part;
        return $clone;
    }

    /**
     * Return instance with an additional part appended
     *
     * @param MessageInterface $part The part to add
     * @return static
     */
    public function withAddedPart(MessageInterface $part): static
    {
        $clone = clone $this;
        $clone->parts[] = $part;
        return $clone;
    }

    /**
     * Return instance without the part at the specified index
     *
     * Remaining parts are re-indexed.
     *
     * @param int $index Zero-based index
     * @return static
     */
    public function withoutPart(int $index): static
    {
        $clone = clone $this;
        unset($clone->parts[$index]);
        $clone->parts = array_values($clone->parts); // Re-index
        return $clone;
    }

    // =========================================================================
    // Parts filtering and searching
    // =========================================================================

    /**
     * Find the first part matching the predicate
     *
     * @param callable(MessageInterface, int): bool $predicate
     * @return MessageInterface|null
     */
    public function findPart(callable $predicate): ?MessageInterface
    {
        foreach ($this->parts as $index => $part) {
            if ($predicate($part, $index)) {
                return $part;
            }
        }
        return null;
    }

    /**
     * Find all parts matching the predicate
     *
     * @param callable(MessageInterface, int): bool $predicate
     * @return MessageInterface[]
     */
    public function findParts(callable $predicate): array
    {
        $result = [];
        foreach ($this->parts as $index => $part) {
            if ($predicate($part, $index)) {
                $result[] = $part;
            }
        }
        return $result;
    }

    /**
     * Return instance with only parts matching the predicate
     *
     * @param callable(MessageInterface, int): bool $predicate
     * @return static
     */
    public function withParts(callable $predicate): static
    {
        $clone = clone $this;
        $clone->parts = array_values(array_filter(
            $this->parts,
            $predicate,
            ARRAY_FILTER_USE_BOTH
        ));
        return $clone;
    }

    /**
     * Return instance without parts matching the predicate
     *
     * @param callable(MessageInterface, int): bool $predicate
     * @return static
     */
    public function withoutParts(callable $predicate): static
    {
        return $this->withParts(fn($part, $index) => !$predicate($part, $index));
    }

    // =========================================================================
    // Multipart-specific accessors
    // =========================================================================

    /**
     * Get the boundary string
     *
     * @return string
     */
    public function getBoundary(): string
    {
        return $this->boundary;
    }

    /**
     * Get the multipart subtype (mixed, alternative, related, etc.)
     *
     * @return string
     */
    public function getMultipartType(): string
    {
        $contentType = $this->getHeaderLine('Content-Type');
        if (preg_match('#^multipart/([^;\s]+)#i', $contentType, $matches)) {
            return strtolower($matches[1]);
        }
        return 'mixed';
    }

    /**
     * Return instance with a different boundary
     *
     * @param string $boundary
     * @return static
     */
    public function withBoundary(string $boundary): static
    {
        $clone = clone $this;
        $clone->boundary = $boundary;
        $subtype = $clone->getMultipartType();
        $clone->headers['content-type'] = ["multipart/{$subtype}; boundary=\"{$boundary}\""];
        return $clone;
    }

    /**
     * Return instance with a different multipart type
     *
     * @param MultipartType|string $type
     * @return static
     */
    public function withMultipartType(MultipartType|string $type): static
    {
        $subtype = $type instanceof MultipartType ? $type->value : $type;
        if (!preg_match('/^[a-z0-9-]+$/i', $subtype)) {
            throw new \InvalidArgumentException("Invalid multipart subtype: {$subtype}");
        }

        $clone = clone $this;
        $clone->headers['content-type'] = ["multipart/{$subtype}; boundary=\"{$clone->boundary}\""];
        return $clone;
    }

    // =========================================================================
    // Countable & IteratorAggregate
    // =========================================================================

    public function count(): int
    {
        return count($this->parts);
    }

    public function getIterator(): Traversable
    {
        return new ArrayIterator($this->parts);
    }

    // =========================================================================
    // ArrayAccess implementation (read-only)
    // =========================================================================

    /**
     * @param int $offset
     */
    public function offsetExists(mixed $offset): bool
    {
        return isset($this->parts[$offset]);
    }

    /**
     * @param int $offset
     */
    public function offsetGet(mixed $offset): ?MessageInterface
    {
        return $this->parts[$offset] ?? null;
    }

    /**
     * @throws \RuntimeException Always - MultipartMessage is immutable
     */
    public function offsetSet(mixed $offset, mixed $value): void
    {
        throw new \RuntimeException(
            'MultipartMessage is immutable. Use withPart() or withAddedPart() instead.'
        );
    }

    /**
     * @throws \RuntimeException Always - MultipartMessage is immutable
     */
    public function offsetUnset(mixed $offset): void
    {
        throw new \RuntimeException(
            'MultipartMessage is immutable. Use withoutPart() instead.'
        );
    }

    // =========================================================================
    // PSR-7 MessageInterface implementation
    // =========================================================================

    public function getProtocolVersion(): string
    {
        return $this->protocolVersion;
    }

    public function withProtocolVersion(string $version): static
    {
        $clone = clone $this;
        $clone->protocolVersion = $version;
        return $clone;
    }

    public function getHeaders(): array
    {
        $result = [];
        foreach ($this->headers as $key => $values) {
            $result[$this->headerCases[$key]] = $values;
        }
        return $result;
    }

    public function hasHeader(string $name): bool
    {
        return isset($this->headers[strtolower($name)]);
    }

    public function getHeader(string $name): array
    {
        return $this->headers[strtolower($name)] ?? [];
    }

    public function getHeaderLine(string $name): string
    {
        return implode(', ', $this->getHeader($name));
    }

    public function withHeader(string $name, $value): static
    {
        $clone = clone $this;
        $key = strtolower($name);
        $clone->headers[$key] = is_array($value) ? $value : [(string) $value];
        $clone->headerCases[$key] = $name;
        return $clone;
    }

    public function withAddedHeader(string $name, $value): static
    {
        $clone = clone $this;
        $key = strtolower($name);
        if (!isset($clone->headers[$key])) {
            $clone->headers[$key] = [];
            $clone->headerCases[$key] = $name;
        }
        if (is_array($value)) {
            array_push($clone->headers[$key], ...$value);
        } else {
            $clone->headers[$key][] = (string) $value;
        }
        return $clone;
    }

    public function withoutHeader(string $name): static
    {
        $clone = clone $this;
        $key = strtolower($name);
        unset($clone->headers[$key], $clone->headerCases[$key]);
        return $clone;
    }

    /**
     * Get the message body
     *
     * Returns a stream that produces RFC 2046 compliant multipart content.
     * The stream reads through child parts without buffering entire bodies.
     *
     * @return StreamInterface
     */
    public function getBody(): StreamInterface
    {
        return new MultipartMessageStream($this->parts, $this->boundary);
    }

    /**
     * Return instance with the specified body
     *
     * For multipart messages, if the body is a MessageInterface, it replaces
     * all parts with that single part. Otherwise throws.
     *
     * @param StreamInterface $body
     * @return static
     */
    public function withBody(StreamInterface $body): static
    {
        // This is awkward for multipart - you'd typically use withPart/withAddedPart
        // But to satisfy the interface, we can wrap a stream as a single part
        throw new \RuntimeException(
            'Use withPart() or withAddedPart() to modify MultipartMessage contents'
        );
    }

    /**
     * Clone handler
     */
    public function __clone(): void
    {
        // Note: We don't deep clone parts - they're MessageInterface and should be immutable
        // If someone needs deep cloning, they should do it explicitly
    }
}