Message.php

PHP

Path: src/Mail/Message.php

<?php

namespace mini\Mail;

use mini\Http\Message\Stream;
use Psr\Http\Message\MessageInterface;
use Psr\Http\Message\StreamInterface;

/**
 * MIME Message - Single-part message (text, HTML, attachment, etc.)
 *
 * Implements PSR-7 MessageInterface with a StreamInterface body.
 *
 * Usage:
 * ```php
 * // Simple text message
 * $msg = new Message('text/plain', 'Hello, World!');
 *
 * // HTML message
 * $msg = new Message('text/html', '<h1>Hello</h1>');
 *
 * // From file (auto-detects MIME type)
 * $msg = Message::fromFile('document.pdf');
 *
 * // From stream resource
 * $msg = new Message('image/png', fopen('image.png', 'rb'));
 *
 * // From existing StreamInterface (e.g., Base64Stream)
 * $msg = new Message('image/png', new Base64Stream($rawStream));
 * ```
 *
 * @see https://datatracker.ietf.org/doc/html/rfc2045
 * @see https://datatracker.ietf.org/doc/html/rfc2046
 */
class Message implements MessageInterface
{
    /**
     * Common MIME types by extension (subset for when config not available)
     */
    private const MIME_TYPES = [
        'txt' => 'text/plain',
        'html' => 'text/html',
        'htm' => 'text/html',
        'css' => 'text/css',
        'js' => 'application/javascript',
        'json' => 'application/json',
        'xml' => 'application/xml',
        'pdf' => 'application/pdf',
        'zip' => 'application/zip',
        'gz' => 'application/gzip',
        'tar' => 'application/x-tar',
        'gif' => 'image/gif',
        'jpg' => 'image/jpeg',
        'jpeg' => 'image/jpeg',
        'png' => 'image/png',
        'webp' => 'image/webp',
        'svg' => 'image/svg+xml',
        'ico' => 'image/x-icon',
        'mp3' => 'audio/mpeg',
        'wav' => 'audio/wav',
        'mp4' => 'video/mp4',
        'webm' => 'video/webm',
        'woff' => 'font/woff',
        'woff2' => 'font/woff2',
        'ttf' => 'font/ttf',
        'otf' => 'font/otf',
        'csv' => 'text/csv',
        'doc' => 'application/msword',
        'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
        'xls' => 'application/vnd.ms-excel',
        'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
    ];

    /**
     * Create a MIME message from a file
     *
     * Automatically detects MIME type from extension. Stores the original
     * filename in X-Mini-Filename header for later use (e.g., by withAttachment).
     * Does not set Content-Disposition - that's the caller's responsibility.
     *
     * @param string $path Path to the file
     * @param string|null $mimeType Override MIME type (auto-detected if null)
     * @return static
     * @throws \InvalidArgumentException If file doesn't exist or isn't readable
     */
    public static function fromFile(string $path, ?string $mimeType = null): static
    {
        if (!file_exists($path)) {
            throw new \InvalidArgumentException("File not found: {$path}");
        }
        if (!is_readable($path)) {
            throw new \InvalidArgumentException("File not readable: {$path}");
        }

        // Detect MIME type
        if ($mimeType === null) {
            $mimeType = self::detectMimeType($path);
        }

        // Open file as stream
        $stream = fopen($path, 'rb');
        if ($stream === false) {
            throw new \InvalidArgumentException("Failed to open file: {$path}");
        }

        $message = new static($mimeType, $stream);

        // Store original filename for later use (e.g., withAttachment)
        $filename = basename($path);
        return $message->withHeader('X-Mini-Filename', $filename);
    }

    /**
     * Detect MIME type from file path
     *
     * Uses extension-based detection (reliable for known types).
     * Falls back to application/octet-stream for unknown extensions.
     *
     * @param string $path File path
     * @return string MIME type
     */
    protected static function detectMimeType(string $path): string
    {
        $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION));

        if ($extension === '') {
            return 'application/octet-stream';
        }

        // Try framework config first (if Mini is bootstrapped)
        if (class_exists(\mini\Mini::class) && isset(\mini\Mini::$mini)) {
            try {
                $mimeTypes = \mini\Mini::$mini->loadConfig('mimeTypes.php');
                if (isset($mimeTypes[$extension])) {
                    return $mimeTypes[$extension];
                }
            } catch (\Throwable $e) {
                // Config not available, fall through
            }
        }

        // Use built-in mapping
        return self::MIME_TYPES[$extension] ?? 'application/octet-stream';
    }

    protected string $protocolVersion = '1.0';
    protected array $headers = [];
    protected array $headerCases = [];
    protected StreamInterface $body;

    /**
     * Create a MIME message
     *
     * @param string $contentType MIME type (e.g., 'text/plain', 'text/html', 'application/pdf')
     * @param StreamInterface|resource|string|null $content Message content
     * @param array<string, string|string[]> $headers Additional headers
     */
    public function __construct(
        string $contentType = 'text/plain',
        mixed $content = null,
        array $headers = []
    ) {
        // Use Stream::cast() to normalize content to StreamInterface
        $this->body = Stream::cast($content ?? '');

        // Set Content-Type header first, then merge in additional headers
        $headers = array_merge(['Content-Type' => $contentType], $headers);

        // Initialize headers
        foreach ($headers as $name => $values) {
            $key = strtolower($name);
            $this->headerCases[$key] = $name;
            $this->headers[$key] = is_array($values) ? $values : [$values];
        }
    }

    // =========================================================================
    // 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
     *
     * @return StreamInterface
     */
    public function getBody(): StreamInterface
    {
        return $this->body;
    }

    /**
     * Return an instance with the specified message body
     *
     * @param StreamInterface $body New body content
     * @return static
     */
    public function withBody(StreamInterface $body): static
    {
        $clone = clone $this;
        $clone->body = $body;
        return $clone;
    }

    // =========================================================================
    // MIME-specific methods
    // =========================================================================

    /**
     * Get the Content-Type header value
     *
     * @return string The MIME type (e.g., 'text/plain; charset=utf-8')
     */
    public function getContentType(): string
    {
        return $this->getHeaderLine('Content-Type') ?: 'application/octet-stream';
    }

    /**
     * Return instance with specified Content-Type
     *
     * @param string $contentType MIME type
     * @param array<string, string> $params Additional parameters (e.g., ['charset' => 'utf-8'])
     * @return static
     */
    public function withContentType(string $contentType, array $params = []): static
    {
        if (!empty($params)) {
            $paramStr = '';
            foreach ($params as $key => $value) {
                // Quote values containing special characters
                if (preg_match('/[()<>@,;:\\"\/\[\]?=\s]/', $value)) {
                    $value = '"' . addslashes($value) . '"';
                }
                $paramStr .= "; {$key}={$value}";
            }
            $contentType .= $paramStr;
        }
        return $this->withHeader('Content-Type', $contentType);
    }
}