Email.php
PHP
Path: src/Mail/Email.php
<?php
namespace mini\Mail;
use Psr\Http\Message\MessageInterface;
use Psr\Http\Message\StreamInterface;
/**
* Email - High-level email composition with lazy MIME compilation
*
* Provides a declarative API for building emails. The MIME structure is
* compiled lazily when getBody() is called, and cached until mutation.
*
* Headers are the single source of truth - address methods like getFrom()
* parse the header values on demand.
*
* @see EmailInterface for full API documentation and usage examples
*/
class Email implements EmailInterface
{
protected string $protocolVersion = '1.1';
/** @var array<string, string[]> Headers (lowercase key => values) */
protected array $headers = [];
/** @var array<string, string> Header case preservation */
protected array $headerCases = [];
protected ?string $textBody = null;
protected ?string $htmlBody = null;
/** @var array<string, MessageInterface> Content-ID => Message */
protected array $inlines = [];
/** @var MessageInterface[] */
protected array $attachments = [];
/** @var MessageInterface|null Cached compiled message */
protected ?MessageInterface $compiled = null;
// =========================================================================
// Address Headers
// =========================================================================
public function getFrom(): array
{
return $this->parseMailboxHeader('From');
}
public function withFrom(MailboxInterface|string ...$mailboxes): static
{
return $this->withMailboxHeader('From', $mailboxes);
}
public function getTo(): array
{
return $this->parseMailboxHeader('To');
}
public function withTo(MailboxInterface|string ...$mailboxes): static
{
return $this->withMailboxHeader('To', $mailboxes);
}
public function withAddedTo(MailboxInterface|string ...$mailboxes): static
{
return $this->withAddedMailboxHeader('To', $mailboxes);
}
public function getCc(): array
{
return $this->parseMailboxHeader('Cc');
}
public function withCc(MailboxInterface|string ...$mailboxes): static
{
return $this->withMailboxHeader('Cc', $mailboxes);
}
public function withAddedCc(MailboxInterface|string ...$mailboxes): static
{
return $this->withAddedMailboxHeader('Cc', $mailboxes);
}
public function getBcc(): array
{
return $this->parseMailboxHeader('Bcc');
}
public function withBcc(MailboxInterface|string ...$mailboxes): static
{
return $this->withMailboxHeader('Bcc', $mailboxes);
}
public function withAddedBcc(MailboxInterface|string ...$mailboxes): static
{
return $this->withAddedMailboxHeader('Bcc', $mailboxes);
}
public function getReplyTo(): array
{
return $this->parseMailboxHeader('Reply-To');
}
public function withReplyTo(MailboxInterface|string ...$mailboxes): static
{
return $this->withMailboxHeader('Reply-To', $mailboxes);
}
public function withAddedReplyTo(MailboxInterface|string ...$mailboxes): static
{
return $this->withAddedMailboxHeader('Reply-To', $mailboxes);
}
/**
* Parse a header into MailboxInterface array
*
* @return MailboxInterface[]
*/
protected function parseMailboxHeader(string $name): array
{
$key = strtolower($name);
if (!isset($this->headers[$key])) {
return [];
}
$result = [];
foreach ($this->headers[$key] as $value) {
// Split by comma, but respect quoted strings and angle brackets
foreach ($this->splitMailboxList($value) as $mailboxStr) {
$mailboxStr = trim($mailboxStr);
if ($mailboxStr !== '') {
$result[] = Mailbox::fromString($mailboxStr);
}
}
}
return $result;
}
/**
* Split a comma-separated mailbox list, respecting quotes and brackets
*
* @return string[]
*/
protected function splitMailboxList(string $value): array
{
$result = [];
$current = '';
$inQuotes = false;
$inBrackets = false;
$len = strlen($value);
for ($i = 0; $i < $len; $i++) {
$char = $value[$i];
if ($char === '"' && ($i === 0 || $value[$i - 1] !== '\\')) {
$inQuotes = !$inQuotes;
$current .= $char;
} elseif ($char === '<' && !$inQuotes) {
$inBrackets = true;
$current .= $char;
} elseif ($char === '>' && !$inQuotes) {
$inBrackets = false;
$current .= $char;
} elseif ($char === ',' && !$inQuotes && !$inBrackets) {
$result[] = $current;
$current = '';
} else {
$current .= $char;
}
}
if ($current !== '') {
$result[] = $current;
}
return $result;
}
/**
* Set a mailbox header (replaces existing)
*/
protected function withMailboxHeader(string $name, array $mailboxes): static
{
if (empty($mailboxes)) {
return $this->withoutHeader($name);
}
$formatted = [];
foreach ($mailboxes as $mailbox) {
$formatted[] = $this->formatMailbox($mailbox);
}
return $this->withHeader($name, implode(', ', $formatted));
}
/**
* Add to a mailbox header
*/
protected function withAddedMailboxHeader(string $name, array $mailboxes): static
{
if (empty($mailboxes)) {
return $this;
}
$formatted = [];
foreach ($mailboxes as $mailbox) {
$formatted[] = $this->formatMailbox($mailbox);
}
$key = strtolower($name);
$clone = clone $this;
if (!isset($clone->headers[$key])) {
$clone->headers[$key] = [];
$clone->headerCases[$key] = $name;
}
// Append to existing value or add new
$newValue = implode(', ', $formatted);
if (!empty($clone->headers[$key])) {
$clone->headers[$key][0] .= ', ' . $newValue;
} else {
$clone->headers[$key][] = $newValue;
}
$clone->invalidateCache();
return $clone;
}
/**
* Format a mailbox for header storage
*/
protected function formatMailbox(MailboxInterface|string $mailbox): string
{
if (is_string($mailbox)) {
$mailbox = Mailbox::fromString($mailbox);
}
$displayName = $mailbox->getDisplayName();
$addrSpec = $mailbox->getAddrSpec();
if ($displayName === null) {
return $addrSpec;
}
// Check if display name needs quoting
if (preg_match('/[()<>@,;:\\\\".\[\]]/', $displayName)) {
$escaped = addcslashes($displayName, '"\\');
return "\"{$escaped}\" <{$addrSpec}>";
}
return "{$displayName} <{$addrSpec}>";
}
// =========================================================================
// Subject and Date
// =========================================================================
public function getSubject(): ?string
{
$key = strtolower('Subject');
if (!isset($this->headers[$key])) {
return null;
}
return $this->headers[$key][0] ?? null;
}
public function withSubject(string $subject): static
{
// Sanitize to prevent header injection
$subject = preg_replace("/\r\n|\r|\n/", ' ', $subject);
return $this->withHeader('Subject', $subject);
}
public function getDate(): ?string
{
$key = strtolower('Date');
if (!isset($this->headers[$key])) {
return null;
}
return $this->headers[$key][0] ?? null;
}
public function withDate(string|\DateTimeInterface $date): static
{
if ($date instanceof \DateTimeInterface) {
$date = $date->format(\DateTimeInterface::RFC2822);
}
return $this->withHeader('Date', $date);
}
// =========================================================================
// Body Content
// =========================================================================
public function getTextBody(): ?string
{
return $this->textBody;
}
public function withTextBody(string $text): static
{
$clone = clone $this;
$clone->textBody = $text;
$clone->invalidateCache();
return $clone;
}
public function getHtmlBody(): ?string
{
return $this->htmlBody;
}
public function withHtmlBody(string $html, array $inlines = []): static
{
$clone = clone $this;
$clone->htmlBody = $html;
$clone->inlines = [];
foreach ($inlines as $contentId => $inline) {
if (is_string($inline)) {
$clone->inlines[$contentId] = Message::fromFile($inline);
} elseif ($inline instanceof MessageInterface) {
$clone->inlines[$contentId] = $inline;
} else {
throw new \InvalidArgumentException(
"Inline '$contentId' must be a file path or MessageInterface"
);
}
}
$clone->invalidateCache();
return $clone;
}
public function getInlines(): array
{
return $this->inlines;
}
// =========================================================================
// Attachments
// =========================================================================
public function getAttachments(): array
{
return $this->attachments;
}
public function withAttachments(array $attachments): static
{
$clone = clone $this;
$clone->attachments = [];
foreach ($attachments as $key => $attachment) {
$filename = is_string($key) ? $key : null;
if (is_string($attachment)) {
$message = Message::fromFile($attachment);
if ($filename === null) {
$filename = basename($attachment);
}
} elseif ($attachment instanceof MessageInterface) {
$message = $attachment;
if ($filename === null) {
$storedFilename = $attachment->getHeader('X-Mini-Filename');
if (!empty($storedFilename)) {
$filename = $storedFilename[0];
}
}
} else {
throw new \InvalidArgumentException(
'Attachment must be a file path or MessageInterface'
);
}
if ($message->hasHeader('X-Mini-Filename')) {
$message = $message->withoutHeader('X-Mini-Filename');
}
$disposition = 'attachment';
if ($filename !== null) {
if (preg_match('/[^\x20-\x7E]|["\\\\\x00-\x1f]/', $filename)) {
$encoded = rawurlencode($filename);
$disposition .= "; filename*=UTF-8''{$encoded}";
} else {
$disposition .= "; filename=\"{$filename}\"";
}
}
$message = $message->withHeader('Content-Disposition', $disposition);
$clone->attachments[] = $message;
}
$clone->invalidateCache();
return $clone;
}
// =========================================================================
// PSR-7 MessageInterface
// =========================================================================
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 = [];
// Add Date if not set
if (!isset($this->headers['date'])) {
$result['Date'] = [date(\DateTimeInterface::RFC2822)];
}
// Add Message-ID if not set
if (!isset($this->headers['message-id'])) {
$result['Message-ID'] = [$this->generateMessageId()];
}
// MIME-Version
$result['MIME-Version'] = ['1.0'];
// Content-Type and Content-Transfer-Encoding from compiled body
$compiled = $this->compile();
$result['Content-Type'] = $compiled->getHeader('Content-Type');
if ($compiled->hasHeader('Content-Transfer-Encoding')) {
$result['Content-Transfer-Encoding'] = $compiled->getHeader('Content-Transfer-Encoding');
}
// All stored headers (with proper casing and encoding)
foreach ($this->headers as $key => $values) {
$name = $this->headerCases[$key] ?? $key;
// Encode headers that need it
if ($this->isAddressHeader($key)) {
// Address headers - encode display names if needed
$result[$name] = array_map([$this, 'encodeAddressHeader'], $values);
} elseif ($key === 'subject') {
// Subject - encode if non-ASCII
$result[$name] = array_map([$this, 'encodeHeader'], $values);
} else {
$result[$name] = $values;
}
}
return $result;
}
/**
* Check if header is an address header
*/
protected function isAddressHeader(string $key): bool
{
return in_array($key, ['from', 'to', 'cc', 'bcc', 'reply-to']);
}
/**
* Encode address header value (RFC 2047 for display names)
*/
protected function encodeAddressHeader(string $value): string
{
// Parse and re-encode each mailbox
$result = [];
foreach ($this->splitMailboxList($value) as $mailboxStr) {
$mailboxStr = trim($mailboxStr);
if ($mailboxStr === '') {
continue;
}
$mailbox = Mailbox::fromString($mailboxStr);
$displayName = $mailbox->getDisplayName();
$addrSpec = $mailbox->getAddrSpec();
if ($displayName === null) {
$result[] = $addrSpec;
} elseif (preg_match('/[\x80-\xFF]/', $displayName)) {
$encoded = $this->encodeRfc2047($displayName);
$result[] = "{$encoded} <{$addrSpec}>";
} elseif (preg_match('/[()<>@,;:\\\\".\[\]]/', $displayName)) {
$escaped = addcslashes($displayName, '"\\');
$result[] = "\"{$escaped}\" <{$addrSpec}>";
} else {
$result[] = "{$displayName} <{$addrSpec}>";
}
}
return implode(', ', $result);
}
public function hasHeader(string $name): bool
{
$key = strtolower($name);
// Built-in headers that are always present
if (in_array($key, ['date', 'message-id', 'mime-version', 'content-type'])) {
return true;
}
return isset($this->headers[$key]);
}
public function getHeader(string $name): array
{
$headers = $this->getHeaders();
foreach ($headers as $headerName => $values) {
if (strtolower($headerName) === strtolower($name)) {
return $values;
}
}
return [];
}
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;
$clone->invalidateCache();
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;
}
$clone->invalidateCache();
return $clone;
}
public function withoutHeader(string $name): static
{
$clone = clone $this;
$key = strtolower($name);
unset($clone->headers[$key], $clone->headerCases[$key]);
$clone->invalidateCache();
return $clone;
}
public function getBody(): StreamInterface
{
return $this->compile()->getBody();
}
public function withBody(StreamInterface $body): static
{
throw new \RuntimeException(
'Email body cannot be set directly. Use withTextBody(), withHtmlBody(), or withAttachments().'
);
}
// =========================================================================
// Compilation
// =========================================================================
protected function compile(): MessageInterface
{
if ($this->compiled !== null) {
return $this->compiled;
}
$this->compiled = $this->buildMimeStructure();
return $this->compiled;
}
protected function invalidateCache(): void
{
$this->compiled = null;
}
protected function buildMimeStructure(): MessageInterface
{
$textPart = null;
$htmlPart = null;
$contentPart = null;
if ($this->textBody !== null) {
$textPart = $this->createTextPart($this->textBody, 'text/plain');
}
if ($this->htmlBody !== null) {
$htmlPart = $this->createTextPart($this->htmlBody, 'text/html');
if (!empty($this->inlines)) {
$parts = [$htmlPart];
foreach ($this->inlines as $contentId => $inline) {
$parts[] = $this->prepareInline($inline, $contentId);
}
$htmlPart = new MultipartMessage(MultipartType::Related, ...$parts);
}
}
if ($textPart !== null && $htmlPart !== null) {
$contentPart = new MultipartMessage(MultipartType::Alternative, $textPart, $htmlPart);
} elseif ($textPart !== null) {
$contentPart = $textPart;
} elseif ($htmlPart !== null) {
$contentPart = $htmlPart;
} else {
$contentPart = new Message('text/plain', '');
}
if (!empty($this->attachments)) {
$parts = [$contentPart];
foreach ($this->attachments as $attachment) {
$parts[] = $this->prepareAttachment($attachment);
}
return new MultipartMessage(MultipartType::Mixed, ...$parts);
}
return $contentPart;
}
protected function createTextPart(string $content, string $mimeType): Message
{
$content = str_replace(["\r\n", "\r", "\n"], "\r\n", $content);
$needsEncoding = $this->needsEncoding($content);
if ($needsEncoding) {
$encoded = quoted_printable_encode($content);
return new Message(
"{$mimeType}; charset=UTF-8",
$encoded,
['Content-Transfer-Encoding' => 'quoted-printable']
);
}
return new Message("{$mimeType}; charset=UTF-8", $content);
}
protected function needsEncoding(string $content): bool
{
if (preg_match('/[\x80-\xFF]/', $content)) {
return true;
}
$lines = explode("\r\n", $content);
foreach ($lines as $line) {
if (strlen($line) > 998) {
return true;
}
}
return false;
}
protected function prepareInline(MessageInterface $inline, string $contentId): MessageInterface
{
if ($inline->hasHeader('X-Mini-Filename')) {
$inline = $inline->withoutHeader('X-Mini-Filename');
}
$inline = $inline
->withHeader('Content-Disposition', 'inline')
->withHeader('Content-ID', "<{$contentId}>");
return $this->prepareAttachment($inline);
}
protected function prepareAttachment(MessageInterface $attachment): MessageInterface
{
if ($attachment->hasHeader('Content-Transfer-Encoding')) {
return $attachment;
}
$contentType = $attachment->getHeaderLine('Content-Type');
$isBinary = !str_starts_with($contentType, 'text/');
if ($isBinary) {
// Wrap body in Base64Stream for lazy streaming encoding
$body = new Base64Stream($attachment->getBody());
return new Message(
$contentType,
$body,
array_merge(
$this->extractHeaders($attachment),
['Content-Transfer-Encoding' => 'base64']
)
);
}
// For text content, wrap in QuotedPrintableStream for lazy encoding
$body = new QuotedPrintableStream($attachment->getBody());
return new Message(
$contentType,
$body,
array_merge(
$this->extractHeaders($attachment),
['Content-Transfer-Encoding' => 'quoted-printable']
)
);
}
protected function extractHeaders(MessageInterface $message): array
{
$result = [];
foreach ($message->getHeaders() as $name => $values) {
if (strtolower($name) !== 'content-type') {
$result[$name] = $values;
}
}
return $result;
}
// =========================================================================
// Encoding Helpers
// =========================================================================
protected function encodeHeader(string $value): string
{
if (preg_match('/[\x80-\xFF]/', $value)) {
return $this->encodeRfc2047($value);
}
return $value;
}
protected function encodeRfc2047(string $value): string
{
$encoded = base64_encode($value);
return "=?UTF-8?B?{$encoded}?=";
}
protected function generateMessageId(): string
{
$random = bin2hex(random_bytes(16));
$domain = 'localhost';
// Try to get domain from From address
$from = $this->getFrom();
if (!empty($from)) {
$addrSpec = $from[0]->getAddrSpec();
if (preg_match('/@(.+)$/', $addrSpec, $matches)) {
$domain = $matches[1];
}
}
return "<{$random}@{$domain}>";
}
// =========================================================================
// StreamInterface implementation
// =========================================================================
/** @var StreamInterface|null Cached stream for reading */
protected ?StreamInterface $stream = null;
/**
* Build the complete email stream (headers + CRLF + body)
*/
protected function buildStream(): StreamInterface
{
if ($this->stream !== null) {
return $this->stream;
}
// Build headers string
$headerLines = '';
foreach ($this->getHeaders() as $name => $values) {
foreach ($values as $value) {
$headerLines .= "{$name}: {$value}\r\n";
}
}
$headerLines .= "\r\n"; // Blank line between headers and body
// Create a composite stream: headers + body
$this->stream = new class($headerLines, $this->getBody()) implements StreamInterface {
private string $headers;
private int $headerPos = 0;
private StreamInterface $body;
private bool $headersDone = false;
private bool $detached = false;
public function __construct(string $headers, StreamInterface $body)
{
$this->headers = $headers;
$this->body = $body;
}
public function read(int $length): string
{
if ($this->detached) {
throw new \RuntimeException('Stream is detached');
}
$result = '';
$remaining = $length;
// First, read from headers
if (!$this->headersDone) {
$headerRemaining = strlen($this->headers) - $this->headerPos;
$toRead = min($remaining, $headerRemaining);
$result .= substr($this->headers, $this->headerPos, $toRead);
$this->headerPos += $toRead;
$remaining -= $toRead;
if ($this->headerPos >= strlen($this->headers)) {
$this->headersDone = true;
}
}
// Then read from body
if ($remaining > 0 && $this->headersDone && !$this->body->eof()) {
$result .= $this->body->read($remaining);
}
return $result;
}
public function getContents(): string
{
if ($this->detached) {
throw new \RuntimeException('Stream is detached');
}
$contents = '';
while (!$this->eof()) {
$contents .= $this->read(8192);
}
return $contents;
}
public function __toString(): string
{
try {
$this->rewind();
return $this->getContents();
} catch (\Throwable $e) {
return '';
}
}
public function eof(): bool
{
return $this->detached || ($this->headersDone && $this->body->eof());
}
public function tell(): int
{
throw new \RuntimeException('Email stream does not support tell()');
}
public function seek(int $offset, int $whence = SEEK_SET): void
{
throw new \RuntimeException('Email stream is not seekable');
}
public function rewind(): void
{
if ($this->detached) {
throw new \RuntimeException('Stream is detached');
}
$this->headerPos = 0;
$this->headersDone = false;
if ($this->body->isSeekable()) {
$this->body->rewind();
}
}
public function isSeekable(): bool
{
return false;
}
public function isWritable(): bool
{
return false;
}
public function write(string $string): int
{
throw new \RuntimeException('Email stream is not writable');
}
public function isReadable(): bool
{
return !$this->detached;
}
public function getSize(): ?int
{
return null;
}
public function getMetadata(?string $key = null): mixed
{
$meta = ['seekable' => false, 'eof' => $this->eof()];
return $key === null ? $meta : ($meta[$key] ?? null);
}
public function close(): void
{
$this->detached = true;
}
public function detach()
{
$this->detached = true;
return null;
}
};
return $this->stream;
}
public function __toString(): string
{
try {
return (string) $this->buildStream();
} catch (\Throwable $e) {
return '';
}
}
public function close(): void
{
$this->buildStream()->close();
}
public function detach()
{
return $this->buildStream()->detach();
}
public function getSize(): ?int
{
return null;
}
public function tell(): int
{
return $this->buildStream()->tell();
}
public function eof(): bool
{
return $this->buildStream()->eof();
}
public function isSeekable(): bool
{
return false;
}
public function seek(int $offset, int $whence = SEEK_SET): void
{
$this->buildStream()->seek($offset, $whence);
}
public function rewind(): void
{
$this->buildStream()->rewind();
}
public function isWritable(): bool
{
return false;
}
public function write(string $string): int
{
throw new \RuntimeException('Email stream is not writable');
}
public function isReadable(): bool
{
return true;
}
public function read(int $length): string
{
return $this->buildStream()->read($length);
}
public function getContents(): string
{
return $this->buildStream()->getContents();
}
public function getMetadata(?string $key = null): mixed
{
return $this->buildStream()->getMetadata($key);
}
// =========================================================================
// Clone
// =========================================================================
public function __clone(): void
{
$this->compiled = null;
$this->stream = null;
}
}