Mailbox.php
PHP
Path: src/Mail/Mailbox.php
<?php
namespace mini\Mail;
/**
* Mailbox - RFC 5322 email address with optional display name
*
* Represents a mailbox which is a display name + addr-spec combination.
* Immutable value object with validation.
*
* Usage:
* ```php
* // Simple email
* $mailbox = new Mailbox('frode@ennerd.com');
*
* // With display name
* $mailbox = new Mailbox('frode@ennerd.com', 'Frode Børli');
*
* // Parse from string
* $mailbox = Mailbox::fromString('Frode Børli <frode@ennerd.com>');
* $mailbox = Mailbox::fromString('frode@ennerd.com');
*
* // Modify (immutable)
* $mailbox = $mailbox->withDisplayName('New Name');
*
* // Use in string context
* echo $mailbox; // "Frode Børli <frode@ennerd.com>"
* ```
*
* @see https://datatracker.ietf.org/doc/html/rfc5322#section-3.4
*/
class Mailbox implements MailboxInterface
{
private string $addrSpec;
private ?string $displayName;
/**
* Create a mailbox
*
* @param string $addrSpec The email address (local@domain)
* @param string|null $displayName Optional display name
* @throws \InvalidArgumentException If the addr-spec is invalid
*/
public function __construct(string $addrSpec, ?string $displayName = null)
{
$addrSpec = trim($addrSpec);
if (!self::isValidAddrSpec($addrSpec)) {
throw new \InvalidArgumentException("Invalid email address: {$addrSpec}");
}
$this->addrSpec = $addrSpec;
$this->displayName = $displayName !== null ? trim($displayName) : null;
if ($this->displayName === '') {
$this->displayName = null;
}
}
/**
* Parse a mailbox from a string
*
* Accepts formats:
* - `email@domain.com`
* - `Display Name <email@domain.com>`
* - `"Display Name" <email@domain.com>`
*
* @param string $string The mailbox string to parse
* @return static
* @throws \InvalidArgumentException If the string cannot be parsed
*/
public static function fromString(string $string): static
{
$string = trim($string);
if ($string === '') {
throw new \InvalidArgumentException('Mailbox string cannot be empty');
}
// Try to match "Display Name <email>" or <email> format
if (preg_match('/^(.+?)\s*<([^>]+)>$/', $string, $matches)) {
$displayName = trim($matches[1]);
$addrSpec = trim($matches[2]);
// Remove surrounding quotes from display name if present
if (preg_match('/^"(.+)"$/', $displayName, $quotedMatches)) {
$displayName = $quotedMatches[1];
// Unescape quoted pairs
$displayName = stripslashes($displayName);
}
return new static($addrSpec, $displayName ?: null);
}
// Just an email address
return new static($string);
}
/**
* Validate an addr-spec (email address)
*
* Uses a simplified validation that covers the vast majority of valid emails.
* Full RFC 5322 validation is complex; this covers practical cases.
*
* @param string $addrSpec
* @return bool
*/
private static function isValidAddrSpec(string $addrSpec): bool
{
if ($addrSpec === '') {
return false;
}
// Use PHP's built-in filter for basic validation
if (filter_var($addrSpec, FILTER_VALIDATE_EMAIL) === false) {
return false;
}
return true;
}
/**
* {@inheritdoc}
*/
public function getAddrSpec(): string
{
return $this->addrSpec;
}
/**
* {@inheritdoc}
*/
public function getDisplayName(): ?string
{
return $this->displayName;
}
/**
* {@inheritdoc}
*/
public function withAddrSpec(string $addrSpec): static
{
$addrSpec = trim($addrSpec);
if (!self::isValidAddrSpec($addrSpec)) {
throw new \InvalidArgumentException("Invalid email address: {$addrSpec}");
}
$clone = clone $this;
$clone->addrSpec = $addrSpec;
return $clone;
}
/**
* {@inheritdoc}
*/
public function withDisplayName(?string $displayName): static
{
$clone = clone $this;
$clone->displayName = $displayName !== null ? trim($displayName) : null;
if ($clone->displayName === '') {
$clone->displayName = null;
}
return $clone;
}
/**
* {@inheritdoc}
*/
public function __toString(): string
{
if ($this->displayName === null) {
return $this->addrSpec;
}
// Check if display name needs quoting
// Quote if it contains specials: ()<>@,;:\".[]
if (preg_match('/[()<>@,;:\\\\".\[\]]/', $this->displayName)) {
// Escape quotes and backslashes, wrap in quotes
$escaped = addcslashes($this->displayName, '"\\');
return "\"{$escaped}\" <{$this->addrSpec}>";
}
return "{$this->displayName} <{$this->addrSpec}>";
}
}