phpOMS/Message/Mail/Email.php
2024-03-20 07:21:26 +00:00

2463 lines
73 KiB
PHP
Executable File

<?php
/**
* Jingga
*
* PHP Version 8.2
*
* @package phpOMS\Message\Mail
* @copyright Dennis Eichhorn
* @license OMS License 2.0
* @version 1.0.0
* @link https://jingga.app
*
* Extended based on:
* GLGPL 2.1 License
* (c) 2012 - 2015 Marcus Bointon, 2010 - 2012 Jim Jagielski, 2004 - 2009 Andy Prevost
* (c) PHPMailer
*/
declare(strict_types=1);
namespace phpOMS\Message\Mail;
use phpOMS\System\CharsetType;
use phpOMS\System\File\FileUtils;
use phpOMS\System\MimeType;
use phpOMS\System\SystemUtils;
use phpOMS\Utils\MbStringUtils;
use phpOMS\Validation\Network\Email as EmailValidator;
/**
* Mail class.
*
* @package phpOMS\Message\Mail
* @license OMS License 2.0
* @link https://jingga.app
* @since 1.0.0
*/
class Email
{
/**
* Mailer name.
*
* @var string
* @since 1.0.0
*/
public const XMAILER = 'phpOMS';
/**
* The maximum line length supported by mail().
*
* @var int
* @since 1.0.0
*/
public const MAIL_MAX_LINE_LENGTH = 63;
/**
* The maximum line length allowed by RFC 2822 section 2.1.1.
*
* @var int
* @since 1.0.0
*/
public const MAX_LINE_LENGTH = 998;
/**
* The lower maximum line length allowed by RFC 2822 section 2.1.1.
*
* @var int
* @since 1.0.0
*/
public const STD_LINE_LENGTH = 76;
/**
* Folding White Space.
*
* @var string
* @since 1.0.0
*/
public const FWS = ' ';
/**
* SMTP RFC standard line ending
*
* @var string
* @since 1.0.0
*/
protected static string $LE = "\r\n";
/**
* Message id.
*
* Format <id@domain>. If empty this is automatically generated.
*
* @var string
* @since 1.0.0
*/
public string $messageId = '';
/**
* Unique ID used for message ID and boundaries.
*
* @var string
* @since 1.0.0
*/
public string $uniqueid = '';
/**
* Hostname coming from the mail handler.
*
* @var string
* @since 1.0.0
*/
public string $hostname = '';
/**
* Mailer for sending message
*
* @var string
* @since 1.0.0
*/
public string $mailer = SubmitType::MAIL;
/**
* Template strings
*
* @var array<string, string>
* @since 1.0.0
*/
public array $template = [];
/**
* Mail from.
*
* @var array
* @since 1.0.0
*/
public array $from = [];
/**
* Return path/bounce address
*
* @var string
* @since 1.0.0
*/
public string $sender = '';
/**
* Confirm address.
*
* @var string
*/
public string $confirmationAddress = '';
/**
* Mail to.
*
* @var array
* @since 1.0.0
*/
public array $to = [];
/**
* Mail subject.
*
* @var string
* @since 1.0.0
*/
public string $subject = '';
/**
* Mail cc.
*
* @var array
* @since 1.0.0
*/
public array $cc = [];
/**
* Mail bcc.
*
* @var array
* @since 1.0.0
*/
public array $bcc = [];
/**
* The array of reply-to names and addresses.
*
* @var array
* @since 1.0.0
*/
public array $replyTo = [];
/**
* Mail attachments.
*
* @var array
* @since 1.0.0
*/
protected array $attachment = [];
/**
* Mail body.
*
* @var string
* @since 1.0.0
*/
public string $body = '';
/**
* Mail alt.
*
* @var string
* @since 1.0.0
*/
public string $bodyAlt = '';
/**
* Ical body.
*
* @var string
* @since 1.0.0
*/
public string $ical = '';
/**
* Mail mime.
*
* @var string
* @since 1.0.0
*/
public string $bodyMime = '';
/**
* The array of MIME boundary strings.
*
* @var array
* @since 1.0.0
*/
protected array $boundary = [];
/**
* Mail header.
*
* @var string
* @since 1.0.0
*/
protected string $header = '';
/**
* Mail header.
*
* @var string
* @since 1.0.0
*/
public string $headerMime = '';
/**
* The array of custom headers.
*
* @var array
* @since 1.0.0
*/
protected array $customHeader = [];
/**
* Word wrap.
*
* @var int
* @since 1.0.0
*/
protected int $wordWrap = 72;
/**
* Encoding.
*
* @var string
* @since 1.0.0
*/
protected string $encoding = EncodingType::E_8BIT;
/**
* Mail content type.
*
* @var string
* @since 1.0.0
*/
protected string $contentType = MimeType::M_TXT;
/**
* Character set
*
* @var string
* @since 1.0.0
*/
public string $charset = CharsetType::ISO_8859_1;
/**
* Mail message type.
*
* @var string
* @since 1.0.0
*/
protected string $messageType = '';
/**
* Mail from.
*
* @var null|\DateTime
* @since 1.0.0
*/
public ?\DateTimeImmutable $messageDate = null;
/**
* Priority
*
* @var int
* @since 1.0.0
*/
public int $priority = 0;
/**
* The S/MIME certificate file path.
*
* @var string
* @since 1.0.0
*/
protected string $signCertFile = '';
/**
* The S/MIME key file path.
*
* @var string
* @since 1.0.0
*/
protected string $signKeyFile = '';
/**
* The optional S/MIME extra certificates ("CA Chain") file path.
*
* @var string
* @since 1.0.0
*/
protected string $signExtracertFiles = '';
/**
* The S/MIME password for the key.
* Used only if the key is encrypted.
*
* @var string
* @since 1.0.0
*/
protected string $signKeyPass = '';
/**
* DKIM selector.
*
* @var string
* @since 1.0.0
*/
public string $dkimSelector = '';
/**
* DKIM Identity.
* Usually the email address used as the source of the email.
*
* @var string
* @since 1.0.0
*/
public string $dkimIdentity = '';
/**
* DKIM passphrase.
* Used if your key is encrypted.
*
* @var string
* @since 1.0.0
*/
public string $dkimPass = '';
/**
* DKIM signing domain name.
*
* @var string
* @since 1.0.0
*/
public string $dkimDomain = '';
/**
* DKIM Copy header field values for diagnostic use.
*
* @var bool
* @since 1.0.0
*/
public bool $dkimCopyHeader = true;
/**
* DKIM Extra signing headers.
*
* @example ['List-Unsubscribe', 'List-Help']
*
* @var array
* @since 1.0.0
*/
public array $dkimHeaders = [];
/**
* DKIM private key file path.
*
* @var string
* @since 1.0.0
*/
public string $dkimPrivatePath = '';
/**
* DKIM private key string.
*
* If set, takes precedence over `$dkimPrivatePath`.
*
* @var string
* @since 1.0.0
*/
public string $dkimPrivateKey = '';
/**
* Set the From and FromName.
*
* @param string $address Email address
* @param string $name Name
*
* @return bool
*
* @since 1.0.0
*/
public function setFrom(string $address, string $name = '') : bool
{
$address = \trim($address);
$name = \trim(\preg_replace('/[\r\n]+/', '', $name));
if (!EmailValidator::isValid($address)) {
return false;
}
$this->from = [$address, $name];
if (empty($this->sender)) {
$this->sender = $address;
}
return true;
}
/**
* Sets message type to html or plain.
*
* @param bool $isHtml Html mode
*
* @return void
*
* @since 1.0.0
*/
public function setHtml(bool $isHtml = true) : void
{
$this->contentType = $isHtml ? MimeType::M_HTML : MimeType::M_TEXT;
}
/**
* Get content type
*
* @return string
*
* @since 1.0.0
*/
public function getContentType() : string
{
return $this->contentType;
}
/**
* Is html content type?
*
* @return bool
*
* @since 1.0.0
*/
public function isHtml() : bool
{
return $this->contentType === MimeType::M_HTML;
}
/**
* Add a "To" address.
*
* @param string $address Email address
* @param string $name Name
*
* @return bool
*
* @since 1.0.0
*/
public function addTo(string $address, string $name = '') : bool
{
if (!EmailValidator::isValid($address)) {
return false;
}
$this->to[$address] = [$address, $name];
return true;
}
/**
* Add a "CC" address.
*
* @param string $address Email address
* @param string $name Name
*
* @return bool
*
* @since 1.0.0
*/
public function addCC(string $address, string $name = '') : bool
{
if (!EmailValidator::isValid($address)) {
return false;
}
$this->cc[$address] = [$address, $name];
return true;
}
/**
* Add a "BCC" address.
*
* @param string $address Email address
* @param string $name Name
*
* @return bool
*
* @since 1.0.0
*/
public function addBCC(string $address, string $name = '') : bool
{
if (!EmailValidator::isValid($address)) {
return false;
}
$this->bcc[$address] = [$address, $name];
return true;
}
/**
* Add a "Reply-To" address.
*
* @param string $address Email address
* @param string $name Name
*
* @return bool
*
* @since 1.0.0
*/
public function addReplyTo(string $address, string $name = '') : bool
{
if (!EmailValidator::isValid($address)) {
return false;
}
$this->replyTo[$address] = [$address, $name];
return true;
}
/**
* Parse and validate a string containing one or more RFC822-style comma-separated email addresses
* of the form "display name <address>" into an array of name/address pairs.
*
* @param string $addrstr Address line
* @param bool $useImap Use imap for parsing
* @param string $charset Charset for email
*
* @return array
*
* @since 1.0.0
*/
public static function parseAddresses(string $addrstr, bool $useImap = true, string $charset = CharsetType::ISO_8859_1) : array
{
$addresses = [];
if ($useImap && \function_exists('imap_rfc822_parse_adrlist')) {
$list = \imap_rfc822_parse_adrlist($addrstr, '');
foreach ($list as $address) {
if (($address->host !== '.SYNTAX-ERROR.')
&& EmailValidator::isValid($address->mailbox . '@' . $address->host)
) {
if (\property_exists($address, 'personal')
&& \preg_match('/^=\?.*\?=$/s', $address->personal)
) {
$origCharset = \mb_internal_encoding();
\mb_internal_encoding($charset);
$address->personal = \str_replace('_', '=20', $address->personal);
$address->personal = \mb_decode_mimeheader($address->personal);
\mb_internal_encoding($origCharset);
}
$addresses[] = [
'name' => (\property_exists($address, 'personal') ? $address->personal : ''),
'address' => $address->mailbox . '@' . $address->host,
];
}
}
return $addresses;
}
$list = \explode(',', $addrstr);
foreach ($list as $address) {
$address = \trim($address);
if (\strpos($address, '<') === false) {
if (EmailValidator::isValid($address)) {
$addresses[] = [
'name' => '',
'address' => $address,
];
}
} else {
$addr = \explode('<', $address);
$email = \trim(\str_replace('>', '', $addr[1]));
if (EmailValidator::isValid($email)) {
$addresses[] = [
'name' => \trim($addr[0], '\'" '),
'address' => $email,
];
}
}
}
return $addresses;
}
/**
* Parse email template.
*
* Replaces placeholders with content
*
* @return void
*
* @since 1.0.0
*/
public function parseTemplate() : void
{
if (empty($this->template)) {
return;
}
$keys = \array_keys($this->template);
$values = \array_values($this->template);
$this->subject = \str_replace($keys, $values, $this->subject);
$this->body = \str_replace($keys, $values, $this->body);
$this->bodyAlt = \str_replace($keys, $values, $this->bodyAlt);
}
/**
* Pre-send preparations
*
* @param string $mailer Mailer tool
*
* @return bool
*
* @since 1.0.0
*/
public function preSend(string $mailer) : bool
{
if (empty($this->from)
|| (empty($this->to) && empty($this->cc) && empty($this->bcc))
) {
return false;
}
$this->header = '';
$this->mailer = $mailer;
$tempSubject = $this->subject;
$tempBody = $this->body;
$tempBodyAlt = $this->bodyAlt;
$this->parseTemplate();
if (!empty($this->bodyAlt)) {
$this->contentType = MimeType::M_ALT;
}
$this->setMessageType();
$this->headerMime = '';
$this->bodyMime = $this->createBody();
$tempHeaders = $this->headerMime;
$this->headerMime = $this->createHeader();
$this->headerMime .= $tempHeaders;
if ($this->mailer === SubmitType::MAIL) {
$this->header .= empty($this->to)
? 'Subject: undisclosed-recipients:;' . self::$LE
: $this->createAddressList('To', $this->to);
$this->header .= 'Subject: ' . $this->encodeHeader(\trim(\str_replace(["\r", "\n"], '', $this->subject))) . self::$LE;
}
// Sign with DKIM if enabled
if (!empty($this->dkimDomain)
&& !empty($this->dkimSelector)
&& (!empty($this->dkimPrivateKey)
|| (!empty($this->dkimPrivatePath)
&& FileUtils::isPermittedPath($this->dkimPrivatePath)
&& \is_file($this->dkimPrivatePath)
)
)
) {
$headerDkim = $this->dkimAdd(
$this->headerMime . $this->header,
$this->encodeHeader(\trim(\str_replace(["\r", "\n"], '', $this->subject))),
$this->bodyMime
);
$this->headerMime = \rtrim($this->headerMime, " \r\n\t") . self::$LE .
self::normalizeBreaks($headerDkim, self::$LE) . self::$LE;
}
$this->subject = $tempSubject;
$this->body = $tempBody;
$this->bodyAlt = $tempBodyAlt;
return true;
}
/**
* Assemble message headers.
*
* @return string The assembled headers
*
* @since 1.0.0
*/
private function createHeader() : string
{
$result = 'Date : ' . ($this->messageDate === null
? (new \DateTime('now'))->format('D, j M Y H:i:s O')
: $this->messageDate->format('D, j M Y H:i:s O'))
. self::$LE;
if ($this->mailer !== SubmitType::MAIL) {
$result .= empty($this->to)
? 'To: undisclosed-recipients:;' . self::$LE
: $this->addrAppend('To', $this->to);
}
$result .= $this->addrAppend('From', [$this->from]);
// sendmail and mail() extract Cc from the header before sending
if (!empty($this->cc)) {
$result .= $this->addrAppend('Cc', $this->cc);
}
// sendmail and mail() extract Bcc from the header before sending
if (($this->mailer === SubmitType::MAIL
|| $this->mailer === SubmitType::SENDMAIL
|| $this->mailer === SubmitType::QMAIL)
&& !empty($this->bcc)
) {
$result .= $this->addrAppend('Bcc', $this->bcc);
}
if (!empty($this->replyTo)) {
$result .= $this->addrAppend('Reply-To', $this->replyTo);
}
// mail() sets the subject itself
if ($this->mailer !== SubmitType::MAIL) {
$result .= 'Subject: ' . $this->encodeHeader(\trim(\str_replace(["\r", "\n"], '', $this->subject))) . self::$LE;
}
$this->hostname = empty($this->hostname) ? SystemUtils::getHostname() : $this->hostname;
// Only allow a custom message Id if it conforms to RFC 5322 section 3.6.4
// https://tools.ietf.org/html/rfc5322#section-3.6.4
$this->messageId = $this->messageId !== ''
&& \preg_match('/^<((([a-z\d!#$%&\'*+\/=?^_`{|}~-]+(\.[a-z\d!#$%&\'*+\/=?^_`{|}~-]+)*)' .
'|("(([\x01-\x08\x0B\x0C\x0E-\x1F\x7F]|[\x21\x23-\x5B\x5D-\x7E])' .
'|(\\[\x01-\x09\x0B\x0C\x0E-\x7F]))*"))@(([a-z\d!#$%&\'*+\/=?^_`{|}~-]+' .
'(\.[a-z\d!#$%&\'*+\/=?^_`{|}~-]+)*)|(\[(([\x01-\x08\x0B\x0C\x0E-\x1F\x7F]' .
'|[\x21-\x5A\x5E-\x7E])|(\\[\x01-\x09\x0B\x0C\x0E-\x7F]))*\])))>$/Di', $this->messageId)
? $this->messageId
: \sprintf('<%s@%s>', $this->uniqueid, $this->hostname);
$result .= 'Message-ID: ' . $this->messageId . self::$LE;
if ($this->priority > 0) {
$result .= 'X-Priority: ' . $this->priority . self::$LE;
}
$result .= 'X-Mailer: ' . self::XMAILER . self::$LE;
if ($this->confirmationAddress !== '') {
$result .= 'Disposition-Notification-To: <' . $this->confirmationAddress . '>' . self::$LE;
}
// Add custom headers
foreach ($this->customHeader as $header) {
$result .= \trim($header[0]) . ': ' . $this->encodeHeader(\trim($header[1])) . self::$LE;
}
if (empty($this->signKeyFile)) {
$result .= 'MIME-Version: 1.0' . self::$LE;
$result .= $this->getMailMime();
}
return $result;
}
/**
* Create recipient headers.
*
* @param string $type Address type
* @param array $addr Address 0 = address, 1 = name ['joe@example.com', 'Joe User']
*
* @return string
*
* @since 1.0.0
*/
private function addrAppend(string $type, array $addr) : string
{
$addresses = [];
foreach ($addr as $address) {
$addresses[] = $this->addrFormat($address);
}
return $type . ': ' . \implode(', ', $addresses) . self::$LE;
}
/**
* Format an address for use in a message header.
*
* @param array $addr Address 0 = address, 1 = name ['joe@example.com', 'Joe User']
*
* @return string
*
* @since 1.0.0
*/
public function addrFormat(array $addr) : string
{
if (empty($addr[1])) {
return \trim(\str_replace(["\r", "\n"], '', $addr[0]));
}
return $this->encodeHeader(\trim(\str_replace(["\r", "\n"], '', $addr[1])), 'phrase') .
' <' . \trim(\str_replace(["\r", "\n"], '', $addr[0])) . '>';
}
/**
* Get the message MIME type headers.
*
* @return string
*
* @since 1.0.0
*/
private function getMailMime() : string
{
$result = '';
$isMultipart = true;
switch ($this->messageType) {
case 'inline':
$result .= 'Content-Type: ' . MimeType::M_RELATED . ';' . self::$LE;
$result .= ' boundary="' . $this->boundary[1] . '"' . self::$LE;
break;
case 'attach':
case 'inline_attach':
case 'alt_attach':
case 'alt_inline_attach':
$result .= 'Content-Type: ' . MimeType::M_MIXED . ';' . self::$LE;
$result .= ' boundary="' . $this->boundary[1] . '"' . self::$LE;
break;
case 'alt':
case 'alt_inline':
$result .= 'Content-Type: ' . MimeType::M_ALT . ';' . self::$LE;
$result .= ' boundary="' . $this->boundary[1] . '"' . self::$LE;
break;
default:
// Catches case 'plain': and case '':
$result .= 'Content-Type: ' . $this->contentType . '; charset=' . $this->charset . self::$LE;
$isMultipart = false;
break;
}
// RFC1341 part 5 says 7bit is assumed if not specified
if ($this->encoding === EncodingType::E_7BIT) {
return $result;
}
// RFC 2045 section 6.4 says multipart MIME parts may only use 7bit, 8bit or binary CTE
if ($isMultipart) {
if ($this->encoding === EncodingType::E_8BIT) {
$result .= 'Content-Transfer-Encoding: ' . EncodingType::E_8BIT . self::$LE;
}
// quoted-printable and base64 are 7bit compatible
} else {
$result .= 'Content-Transfer-Encoding: ' . $this->encoding . self::$LE;
}
return $result;
}
/**
* Converts IDN in given email address to its ASCII form
*
* @param string $charset Charset
* @param string $address Email address
*
* @return string The encoded address in ASCII form
*
* @since 1.0.0
*/
private function punyencodeAddress(string $charset, string $address) : string
{
if (empty($charset) || !EmailValidator::isValid($address)) {
return $address;
}
$pos = \strrpos($address, '@');
$domain = \substr($address, ++$pos);
if (!((bool) \preg_match('/[\x80-\xFF]/', $domain)) || !\mb_check_encoding($domain, $charset)) {
return $address;
}
$domain = \mb_convert_encoding($domain, 'UTF-8', $charset);
$errorcode = 0;
if (\defined('INTL_IDNA_VARIANT_UTS46')) {
$punycode = \idn_to_ascii(
$domain,
\IDNA_DEFAULT | \IDNA_USE_STD3_RULES | \IDNA_CHECK_BIDI | \IDNA_CHECK_CONTEXTJ | \IDNA_NONTRANSITIONAL_TO_ASCII,
\INTL_IDNA_VARIANT_UTS46);
} else {
$punycode = \idn_to_ascii($domain, $errorcode);
}
if ($punycode !== false) {
return \substr($address, 0, $pos) . $punycode;
}
return $address;
}
/**
* Create a unique ID to use for boundaries.
*
* @return string
*
* @since 1.0.0
*/
protected function generateId() : string
{
$len = 32; //32 bytes = 256 bits
$bytes = '';
$bytes = \random_bytes($len);
if ($bytes === '') {
$bytes = \hash('sha256', \uniqid((string) \mt_rand(), true), true); // @codeCoverageIgnore
}
return \str_replace(['=', '+', '/'], '', \base64_encode(\hash('sha256', $bytes, true)));
}
/**
* Assemble the message body.
*
* @return string Empty on failure
*
* @since 1.0.0
*/
public function createBody() : string
{
$body = '';
$this->uniqueid = $this->generateId();
$this->boundary[1] = 'b1=_' . $this->uniqueid;
$this->boundary[2] = 'b2=_' . $this->uniqueid;
$this->boundary[3] = 'b3=_' . $this->uniqueid;
if (!empty($this->signKeyFile)) {
$body .= $this->getMailMime() . self::$LE;
}
$this->setWordWrap();
$bodyEncoding = $this->encoding;
$bodyCharSet = $this->charset;
// Can we do a 7-bit downgrade?
if ($bodyEncoding === EncodingType::E_8BIT && !((bool) \preg_match('/[\x80-\xFF]/', $this->body))) {
$bodyEncoding = EncodingType::E_7BIT;
//All ISO 8859, Windows codepage and UTF-8 charsets are ascii compatible up to 7-bit
$bodyCharSet = CharsetType::ASCII;
}
// If lines are too long, and we're not already using an encoding that will shorten them,
// change to quoted-printable transfer encoding for the body part only
if ($this->encoding !== EncodingType::E_BASE64
&& ((bool) \preg_match('/^(.{' . (self::MAX_LINE_LENGTH + \strlen(self::$LE)) . ',})/m', $this->body))
) {
$bodyEncoding = EncodingType::E_QUOTED;
}
$altBodyEncoding = $this->encoding;
$altBodyCharSet = $this->charset;
//Can we do a 7-bit downgrade?
if ($altBodyEncoding === EncodingType::E_8BIT && !((bool) \preg_match('/[\x80-\xFF]/', $this->bodyAlt))) {
$altBodyEncoding = EncodingType::E_7BIT;
//All ISO 8859, Windows codepage and UTF-8 charsets are ascii compatible up to 7-bit
$altBodyCharSet = CharsetType::ASCII;
}
//If lines are too long, and we're not already using an encoding that will shorten them,
//change to quoted-printable transfer encoding for the alt body part only
if ($altBodyEncoding !== EncodingType::E_BASE64
&& ((bool) \preg_match('/^(.{' . (self::MAX_LINE_LENGTH + \strlen(self::$LE)) . ',})/m', $this->bodyAlt))
) {
$altBodyEncoding = EncodingType::E_QUOTED;
}
//Use this as a preamble in all multipart message types
$mimePre = '';
switch ($this->messageType) {
case 'inline':
$body .= $mimePre;
$body .= $this->getBoundary($this->boundary[1], $bodyCharSet, '', $bodyEncoding);
$body .= $this->encodeString($this->body, $bodyEncoding) . self::$LE;
$body .= $this->attachAll('inline', $this->boundary[1]);
break;
case 'attach':
$body .= $mimePre;
$body .= $this->getBoundary($this->boundary[1], $bodyCharSet, '', $bodyEncoding);
$body .= $this->encodeString($this->body, $bodyEncoding) . self::$LE;
$body .= $this->attachAll('attachment', $this->boundary[1]);
break;
case 'inline_attach':
$body .= $mimePre;
$body .= '--' . $this->boundary[1] . self::$LE;
$body .= 'Content-Type: ' . MimeType::M_RELATED . ';' . self::$LE;
$body .= ' boundary="' . $this->boundary[2] . '";' . self::$LE;
$body .= ' type="' . MimeType::M_HTML . '"' . self::$LE . self::$LE;
$body .= $this->getBoundary($this->boundary[2], $bodyCharSet, '', $bodyEncoding);
$body .= $this->encodeString($this->body, $bodyEncoding) . self::$LE;
$body .= $this->attachAll('inline', $this->boundary[2]) . self::$LE;
$body .= $this->attachAll('attachment', $this->boundary[1]);
break;
case 'alt':
$body .= $mimePre;
$body .= $this->getBoundary($this->boundary[1], $altBodyCharSet, MimeType::M_TEXT, $altBodyEncoding);
$body .= $this->encodeString($this->bodyAlt, $altBodyEncoding) . self::$LE;
$body .= $this->getBoundary($this->boundary[1], $bodyCharSet, MimeType::M_HTML, $bodyEncoding);
$body .= $this->encodeString($this->body, $bodyEncoding) . self::$LE;
if (!empty($this->ical)) {
$method = ICALMethodType::REQUEST;
$methods = ICALMethodType::getConstants();
foreach ($methods as $imethod) {
if (\stripos($this->ical, 'METHOD:' . $imethod) !== false) {
$method = $imethod;
break;
}
}
$body .= $this->getBoundary($this->boundary[1], '', MimeType::M_ICS . '; method=' . $method, '');
$body .= $this->encodeString($this->ical, $this->encoding) . self::$LE;
}
$body .= self::$LE . '--' . $this->boundary[1] . '--' . self::$LE;
break;
case 'alt_inline':
$body .= $mimePre;
$body .= $this->getBoundary($this->boundary[1], $altBodyCharSet, MimeType::M_TEXT, $altBodyEncoding);
$body .= $this->encodeString($this->bodyAlt, $altBodyEncoding) . self::$LE;
$body .= '--' . $this->boundary[1] . self::$LE;
$body .= 'Content-Type: ' . MimeType::M_RELATED . ';' . self::$LE;
$body .= ' boundary="' . $this->boundary[2] . '";' . self::$LE;
$body .= ' type="' . MimeType::M_HTML . '"' . self::$LE . self::$LE;
$body .= $this->getBoundary($this->boundary[2], $bodyCharSet, MimeType::M_HTML, $bodyEncoding);
$body .= $this->encodeString($this->body, $bodyEncoding) . self::$LE;
$body .= $this->attachAll('inline', $this->boundary[2]) . self::$LE;
$body .= self::$LE . '--' . $this->boundary[1] . '--' . self::$LE;
break;
case 'alt_attach':
$body .= $mimePre;
$body .= '--' . $this->boundary[1] . self::$LE;
$body .= 'Content-Type: ' . MimeType::M_ALT . ';' . self::$LE;
$body .= ' boundary="' . $this->boundary[2] . '"' . self::$LE . self::$LE;
$body .= $this->getBoundary($this->boundary[2], $altBodyCharSet, MimeType::M_TEXT, $altBodyEncoding);
$body .= $this->encodeString($this->bodyAlt, $altBodyEncoding) . self::$LE;
$body .= $this->getBoundary($this->boundary[2], $bodyCharSet, MimeType::M_HTML, $bodyEncoding);
$body .= $this->encodeString($this->body, $bodyEncoding) . self::$LE;
if (!empty($this->ical)) {
$method = ICALMethodType::REQUEST;
$methods = ICALMethodType::getConstants();
foreach ($methods as $imethod) {
if (\stripos($this->ical, 'METHOD:' . $imethod) !== false) {
$method = $imethod;
break;
}
}
$body .= $this->getBoundary($this->boundary[2], '', MimeType::M_ICS . '; method=' . $method, '');
$body .= $this->encodeString($this->ical, $this->encoding);
}
$body .= self::$LE . '--' . $this->boundary[2] . '--' . self::$LE . self::$LE;
$body .= $this->attachAll('attachment', $this->boundary[1]);
break;
case 'alt_inline_attach':
$body .= $mimePre;
$body .= '--' . $this->boundary[1] . self::$LE;
$body .= 'Content-Type: ' . MimeType::M_ALT . ';' . self::$LE;
$body .= ' boundary="' . $this->boundary[2] . '"' . self::$LE;
$body .= $this->getBoundary($this->boundary[2], $altBodyCharSet, MimeType::M_TEXT, $altBodyEncoding);
$body .= $this->encodeString($this->bodyAlt, $altBodyEncoding) . self::$LE;
$body .= '--' . $this->boundary[2] . self::$LE;
$body .= 'Content-Type: ' . MimeType::M_RELATED . ';' . self::$LE;
$body .= ' boundary="' . $this->boundary[3] . '";' . self::$LE;
$body .= ' type="' . MimeType::M_HTML . '"' . self::$LE . self::$LE;
$body .= $this->getBoundary($this->boundary[3], $bodyCharSet, MimeType::M_HTML, $bodyEncoding);
$body .= $this->encodeString($this->body, $bodyEncoding) . self::$LE;
$body .= $this->attachAll('inline', $this->boundary[3]) . self::$LE;
$body .= self::$LE . '--' . $this->boundary[2] . '--' . self::$LE . self::$LE;
$body .= $this->attachAll('attachment', $this->boundary[1]);
break;
default:
// Catch case 'plain' and case '', applies to simple `text/plain` and `text/html` body content types
$this->encoding = $bodyEncoding;
$body .= $this->encodeString($this->body, $this->encoding);
break;
}
if (!empty($this->signKeyFile)) {
if (!\defined('PKCS7_TEXT')) {
return '';
}
$file = \tempnam($tmpDir = \sys_get_temp_dir(), 'srcsign');
$signed = \tempnam($tmpDir, 'mailsign');
\file_put_contents($file, $body);
try {
// Workaround for PHP bug https://bugs.php.net/bug.php?id=69197
$sign = empty($this->signExtracertFiles)
? \openssl_pkcs7_sign(\realpath($file), $signed,
'file://' . \realpath($this->signCertFile),
['file://' . \realpath($this->signKeyFile), $this->signKeyPass],
[],
)
: \openssl_pkcs7_sign(\realpath($file), $signed,
'file://' . \realpath($this->signCertFile),
['file://' . \realpath($this->signKeyFile), $this->signKeyPass],
[],
\PKCS7_DETACHED,
$this->signExtracertFiles
);
} catch (\Throwable $_) {
$sign = false;
}
\unlink($file);
if ($sign === false) {
\unlink($signed);
return '';
}
$body = \file_get_contents($signed);
\unlink($signed);
//The message returned by openssl contains both headers and body, so need to split them up
$parts = \explode("\n\n", $body, 2);
$this->headerMime .= $parts[0] . self::$LE . self::$LE;
$body = $parts[1];
}
return $body;
}
/**
* Return the start of a message boundary.
*
* @param string $boundary Boundary
* @param string $charset Charset
* @param string $contentType Content type
* @param string $encoding Concoding
*
* @return string
*
* @since 1.0.0
*/
protected function getBoundary(string $boundary, string $charset, string $contentType, string $encoding) : string
{
$result = '';
if ($charset === '') {
$charset = $this->charset;
}
if ($contentType === '') {
$contentType = $this->contentType;
}
if ($encoding === '') {
$encoding = $this->encoding;
}
$result .= '--' . $boundary . self::$LE;
$result .= \sprintf('Content-Type: %s; charset=%s', $contentType, $charset);
$result .= self::$LE;
// RFC1341 part 5 says 7bit is assumed if not specified
if ($encoding !== EncodingType::E_7BIT) {
$result .= 'Content-Transfer-Encoding: ' . $encoding . self::$LE;
}
return $result . self::$LE;
}
/**
* Attach all file, string, and binary attachments to the message.
*
* @param string $dispositionType Disposition type
* @param string $boundary Boundary string
*
* @return string
*
* @since 1.0.0
*/
protected function attachAll(string $dispositionType, string $boundary) : string
{
$mime = [];
$cidUniq = [];
$incl = [];
$attachments = $this->getAttachments();
foreach ($attachments as $attachment) {
if ($attachment[6] !== $dispositionType) {
continue;
}
$bString = $attachment[5];
$string = $bString ? $attachment[0] : '';
$path = $bString ? '' : $attachment[0];
$inclHash = \hash('sha256', \serialize($attachment));
if (\in_array($inclHash, $incl, true)) {
continue;
}
$incl[] = $inclHash;
$name = $attachment[2];
$encoding = $attachment[3];
$type = $attachment[4];
$disposition = $attachment[6];
$cid = $attachment[7];
if ($disposition === 'inline' && isset($cidUniq[$cid])) {
continue;
}
$cidUniq[$cid] = true;
$mime[] = \sprintf('--%s%s', $boundary, self::$LE);
//Only include a filename property if we have one
$mime[] = empty($name)
? \sprintf('Content-Type: %s%s',
$type,
self::$LE
)
: \sprintf('Content-Type: %s; name=%s%s',
$type,
self::quotedString($this->encodeHeader(\trim(\str_replace(["\r", "\n"], '', $name)))),
self::$LE
);
// RFC1341 part 5 says 7bit is assumed if not specified
if ($encoding !== EncodingType::E_7BIT) {
$mime[] = \sprintf('Content-Transfer-Encoding: %s%s', $encoding, self::$LE);
}
//Only set Content-IDs on inline attachments
if ((string) $cid !== '' && $disposition === 'inline') {
$mime[] = 'Content-ID: <' . $this->encodeHeader(\trim(\str_replace(["\r", "\n"], '', $cid))) . '>' . self::$LE;
}
// Allow for bypassing the Content-Disposition header
if (!empty($disposition)) {
$encodedName = $this->encodeHeader(\trim(\str_replace(["\r", "\n"], '', $name)));
$mime[] = empty($encodedName)
? \sprintf('Content-Disposition: %s%s', $disposition, self::$LE . self::$LE)
: \sprintf('Content-Disposition: %s; filename=%s%s',
$disposition,
self::quotedString($encodedName),
self::$LE . self::$LE
);
} else {
$mime[] = self::$LE;
}
// Encode as string attachment
$mime[] = $bString
? $this->encodeString($string, $encoding)
: $this->encodeFile($path, $encoding);
$mime[] = self::$LE;
}
$mime[] = \sprintf('--%s--%s', $boundary, self::$LE);
return \implode('', $mime);
}
/**
* If a string contains any "special" characters, double-quote the name,
* and escape any double quotes with a backslash.
*
* @param string $str String to quote
*
* @return string
*
* @since 1.0.0
*/
private static function quotedString(string $str) : string
{
if (\preg_match('/[ ()<>@,;:"\/\[\]?=]/', $str)) {
return '"' . \str_replace('"', '\\"', $str) . '"';
}
return $str;
}
/**
* Encode a file attachment in requested format.
*
* @param string $path Path
* @param string $encoding Encoding
*
* @return string
*
* @since 1.0.0
*/
private function encodeFile(string $path, string $encoding = EncodingType::E_BASE64) : string
{
if (!FileUtils::isAccessible($path)) {
return '';
}
$fileBuffer = \file_get_contents($path);
return $fileBuffer === false ? '' : $this->encodeString($fileBuffer, $encoding);
}
/**
* Encode a string in requested format.
*
* @param string $str The text to encode
* @param string $encoding Encoding
*
* @return string
*
* @since 1.0.0
*/
private function encodeString(string $str, string $encoding = EncodingType::E_BASE64) : string
{
$encoded = '';
switch (\strtolower($encoding)) {
case EncodingType::E_BASE64:
$encoded = \chunk_split(\base64_encode($str), self::STD_LINE_LENGTH, self::$LE);
break;
case EncodingType::E_7BIT:
case EncodingType::E_8BIT:
$encoded = self::normalizeBreaks($str, self::$LE);
if (\substr($encoded, -(\strlen(self::$LE))) !== self::$LE) {
$encoded .= self::$LE;
}
break;
case EncodingType::E_BINARY:
$encoded = $str;
break;
case EncodingType::E_QUOTED:
$encoded = self::normalizeBreaks(\quoted_printable_encode($str), self::$LE);
break;
default:
return '';
}
return $encoded;
}
/**
* Set message type based on content
*
* @return void
*
* @since 1.0.0
*/
protected function setMessageType() : void
{
$type = [];
if (!empty($this->bodyAlt)) {
$type[] = 'alt';
}
if ($this->hasInlineImage()) {
$type[] = 'inline';
}
if ($this->hasAttachment()) {
$type[] = 'attach';
}
$this->messageType = \implode('_', $type);
if ($this->messageType === '') {
$this->messageType = 'plain';
}
}
/**
* Mail has inline image
*
* @return bool
*
* @since 1.0.0
*/
public function hasInlineImage() : bool
{
foreach ($this->attachment as $attachment) {
if ($attachment[6] === 'inline') {
return true;
}
}
return false;
}
/**
* Mail has attachment
*
* @return bool
*
* @since 1.0.0
*/
public function hasAttachment() : bool
{
foreach ($this->attachment as $attachment) {
if ($attachment[6] === 'attachment') {
return true;
}
}
return false;
}
/**
* Create address list
*
* @param string $type Address type
* @param array $addr Addresses
*
* @return string
*
* @since 1.0.0
*/
public function createAddressList(string $type, array $addr) : string
{
$addresses = [];
foreach ($addr as $address) {
$addresses[] = $this->addrFormat($address);
}
return $type . ': ' . \implode(', ', $addresses) . static::$LE;
}
/**
* Apply word wrapping
*
* @return void
*
* @since 1.0.0
*/
public function setWordWrap() : void
{
if ($this->wordWrap < 1) {
return;
}
switch ($this->messageType) {
case 'alt':
case 'alt_inline':
case 'alt_attach':
case 'alt_inline_attach':
$this->bodyAlt = $this->wrapText($this->bodyAlt, $this->wordWrap);
break;
default:
$this->body = $this->wrapText($this->body, $this->wordWrap);
break;
}
}
/**
* Word-wrap message.
* Original written by philippe.
*
* @param string $message The message to wrap
* @param int $length The line length to wrap to
* @param bool $qpMode Use Quoted-Printable mode
*
* @return string
*
* @since 1.0.0
*/
private function wrapText(string $message, int $length, bool $qpMode = false) : string
{
$softBreak = $qpMode ? \sprintf(' =%s', self::$LE) : self::$LE;
// Don't split multibyte characters
$isUtf8 = \strtolower($this->charset) === CharsetType::UTF_8;
$leLen = \strlen(self::$LE);
$crlfLen = \strlen(self::$LE);
$message = self::normalizeBreaks($message, self::$LE);
//Remove a trailing line break
if (\substr($message, -$leLen) === self::$LE) {
$message = \substr($message, 0, -$leLen);
}
//Split message into lines
$lines = \explode(self::$LE, $message);
$message = '';
foreach ($lines as $line) {
$words = \explode(' ', $line);
$buf = '';
$firstword = true;
foreach ($words as $word) {
if ($qpMode && \strlen($word) > $length) {
$spaceLeft = $length - \strlen($buf) - $crlfLen;
if (!$firstword) {
if ($spaceLeft > 20) {
$len = $spaceLeft;
if ($isUtf8) {
$len = MbStringUtils::utf8CharBoundary($word, $len);
} elseif (\substr($word, $len - 1, 1) === '=') {
--$len;
} elseif (\substr($word, $len - 2, 1) === '=') {
$len -= 2;
}
$part = \substr($word, 0, $len);
$word = \substr($word, $len);
$buf .= ' ' . $part;
$message .= $buf . \sprintf('=%s', self::$LE);
} else {
$message .= $buf . $softBreak;
}
$buf = '';
}
while ($word !== '') {
if ($length <= 0) {
break;
}
$len = $length;
if ($isUtf8) {
$len = MbStringUtils::utf8CharBoundary($word, $len);
} elseif (\substr($word, $len - 1, 1) === '=') {
--$len;
} elseif (\substr($word, $len - 2, 1) === '=') {
$len -= 2;
}
$part = \substr($word, 0, $len);
$word = (string) \substr($word, $len);
if ($word !== '') {
$message .= $part . \sprintf('=%s', self::$LE);
} else {
$buf = $part;
}
}
} else {
$bufO = $buf;
if (!$firstword) {
$buf .= ' ';
}
$buf .= $word;
if ($bufO !== '' && \strlen($buf) > $length) {
$message .= $bufO . $softBreak;
$buf = $word;
}
}
$firstword = false;
}
$message .= $buf . self::$LE;
}
return $message;
}
/**
* Encode a header value (not including its label) optimally.
* Picks shortest of Q, B, or none. Result includes folding if needed.
*
* @param string $str Header value
* @param string $position Context
*
* @return string
*
* @since 1.0.0
*/
public function encodeHeader(string $str, string $position = 'text') : string
{
$matchcount = 0;
switch (\strtolower($position)) {
case 'phrase':
if (!\preg_match('/[\200-\377]/', $str)) {
$encoded = \addcslashes($str, "\0..\37\177\\\"");
return $str === $encoded && !\preg_match('/[^A-Za-z0-9!#$%&\'*+\/=?^_`{|}~ -]/', $str)
? $encoded
: '"' . $encoded . '"';
}
$matchcount = \preg_match_all('/[^\040\041\043-\133\135-\176]/', $str, $matches);
break;
case 'comment':
$matchcount = \preg_match_all('/[()"]/', $str, $matches);
// no break
case 'text':
default:
$matchcount += \preg_match_all('/[\000-\010\013\014\016-\037\177-\377]/', $str, $matches);
break;
}
$charset = ((bool) \preg_match('/[\x80-\xFF]/', $str)) ? $this->charset : CharsetType::ASCII;
// Q/B encoding adds 8 chars and the charset ("` =?<charset>?[QB]?<content>?=`").
$overhead = 8 + \strlen($charset);
$maxLen = $this->mailer === SubmitType::MAIL
? self::MAIL_MAX_LINE_LENGTH - $overhead
: self::MAX_LINE_LENGTH - $overhead;
// Select the encoding that produces the shortest output and/or prevents corruption.
if ($matchcount > \strlen($str) / 3) {
// More than 1/3 of the content needs encoding, use B-encode.
$encoding = 'B';
} elseif ($matchcount > 0) {
// Less than 1/3 of the content needs encoding, use Q-encode.
$encoding = 'Q';
} elseif (\strlen($str) > $maxLen) {
// No encoding needed, but value exceeds max line length, use Q-encode to prevent corruption.
$encoding = 'Q';
} else {
// No reformatting needed
$encoding = '';
}
switch ($encoding) {
case 'B':
if (\strlen($str) > \mb_strlen($str, $this->charset)) {
$encoded = $this->base64EncodeWrapMB($str, "\n");
} else {
$encoded = \base64_encode($str);
$maxLen -= $maxLen % 4;
$encoded = \trim(\chunk_split($encoded, $maxLen, "\n"));
}
$encoded = \preg_replace('/^(.*)$/m', ' =?' . $charset . '?' . $encoding . '?\\1?=', $encoded);
break;
case 'Q':
$encoded = $this->encodeQ($str, $position);
$encoded = $this->wrapText($encoded, $maxLen, true);
$encoded = \str_replace('=' . self::$LE, "\n", \trim($encoded));
$encoded = \preg_replace('/^(.*)$/m', ' =?' . $charset . '?' . $encoding . '?\\1?=', $encoded);
break;
default:
return $str;
}
return \trim(self::normalizeBreaks($encoded, self::$LE));
}
/**
* Encode a string using Q encoding.
*
* @param string $str Text to encode
* @param string $position Where the text is going to be used, see the RFC for what that means
*
* @return string
*
* @since 1.0.0
*/
private function encodeQ(string $str, string $position = 'text') : string
{
$pattern = '';
$encoded = \str_replace(["\r", "\n"], '', $str);
switch (\strtolower($position)) {
case 'phrase':
$pattern = '^A-Za-z0-9!*+\/ -';
break;
case 'comment':
$pattern = '\(\)"';
// no break
case 'text':
default:
// Replace every high ascii, control, =, ? and _ characters
$pattern = '\000-\011\013\014\016-\037\075\077\137\177-\377' . $pattern;
break;
}
if (\preg_match_all("/[{$pattern}]/", $encoded, $matches) !== false) {
return \strtr($encoded, ' ', '_');
}
$matches = [];
// If the string contains an '=', make sure it's the first thing we replace
// so as to avoid double-encoding
$eqkey = \array_search('=', $matches[0], true);
if ($eqkey !== false) {
unset($matches[0][$eqkey]);
\array_unshift($matches[0], '=');
}
$unique = \array_unique($matches[0]);
foreach ($unique as $char) {
$encoded = \str_replace($char, '=' . \sprintf('%02X', \ord($char)), $encoded);
}
// Replace spaces with _ (more readable than =20)
// RFC 2047 section 4.2(2)
return \strtr($encoded, ' ', '_');
}
/**
* Encode and wrap long multibyte strings for mail headers
*
* @param string $str Multi-byte text to wrap encode
* @param string $linebreak string to use as linefeed/end-of-line
*
* @return string
*
* @since 1.0.0
*/
private function base64EncodeWrapMB(string $str, string $linebreak) : string
{
$start = '=?' . $this->charset . '?B?';
$end = '?=';
$encoded = '';
$mbLength = \mb_strlen($str, $this->charset);
$length = 75 - \strlen($start) - \strlen($end);
$ratio = $mbLength / \strlen($str);
$avgLength = \floor($length * $ratio * .75);
$offset = 0;
for ($i = 0; $i < $mbLength; $i += $offset) {
$lookBack = 0;
do {
$offset = $avgLength - $lookBack;
$chunk = \mb_substr($str, $i, $offset, $this->charset);
$chunk = \base64_encode($chunk);
++$lookBack;
} while (\strlen($chunk) > $length);
$encoded .= $chunk . $linebreak;
}
return \substr($encoded, 0, -\strlen($linebreak));
}
/**
* Add an attachment from a path on the filesystem.
*
* @param string $path Path
* @param string $name Overrides the attachment name
* @param string $encoding File encoding
* @param string $type Mime type; determined automatically from $path if not specified
* @param string $disposition Disposition to use
*
* @return bool
*
* @since 1.0.0
*/
public function addAttachment(
string $path,
string $name = '',
string $encoding = EncodingType::E_BASE64,
string $type = '',
string $disposition = 'attachment'
) : bool {
if (!FileUtils::isAccessible($path)) {
return false;
}
// Mime from file
if ($type === '') {
$type = MimeType::extensionToMime(FileUtils::mb_pathinfo($path, \PATHINFO_EXTENSION));
}
$filename = FileUtils::mb_pathinfo($path, \PATHINFO_BASENAME);
if ($name === '') {
$name = $filename;
}
$this->attachment[] = [
0 => $path,
1 => $filename,
2 => $name,
3 => $encoding,
4 => $type,
5 => false, // isStringAttachment
6 => $disposition,
7 => $name,
];
return true;
}
/**
* Return the array of attachments.
*
* @return array
*/
public function getAttachments()
{
return $this->attachment;
}
/**
* Add a string or binary attachment (non-filesystem).
*
* @param string $string String attachment data
* @param string $filename Name of the attachment
* @param string $encoding File encoding (see $encoding)
* @param string $type File extension (MIME) type
* @param string $disposition Disposition to use
*
* @return bool
*
* @since 1.0.0
*/
public function addStringAttachment(
string $string,
string $filename,
string $encoding = EncodingType::E_BASE64,
string $type = '',
string $disposition = 'attachment'
) : bool {
// Mime from file
if ($type === '') {
$type = MimeType::extensionToMime(FileUtils::mb_pathinfo($filename, \PATHINFO_EXTENSION));
}
$this->attachment[] = [
0 => $string,
1 => $filename,
2 => FileUtils::mb_pathinfo($filename, \PATHINFO_BASENAME),
3 => $encoding,
4 => $type,
5 => true, // isStringAttachment
6 => $disposition,
7 => 0,
];
return true;
}
/**
* Add an embedded (inline) attachment from a file.
* This can include images, sounds, and just about any other document type.
*
* @param string $path Path to the attachment
* @param string $cid Content ID of the attachment
* @param string $name Overrides the attachment name
* @param string $encoding File encoding (see $encoding)
* @param string $type File MIME type
* @param string $disposition Disposition to use
*
* @return bool
*
* @since 1.0.0
*/
public function addEmbeddedImage(
string $path,
string $cid,
string $name = '',
string $encoding = EncodingType::E_BASE64,
string $type = '',
string $disposition = 'inline'
) : bool {
if (!FileUtils::isAccessible($path)) {
return false;
}
// Mime from file
if ($type === '') {
$type = MimeType::extensionToMime(FileUtils::mb_pathinfo($path, \PATHINFO_EXTENSION));
}
$filename = FileUtils::mb_pathinfo($path, \PATHINFO_BASENAME);
if ($name === '') {
$name = $filename;
}
// Append to $attachment array
$this->attachment[] = [
0 => $path,
1 => $filename,
2 => $name,
3 => $encoding,
4 => $type,
5 => false, // isStringAttachment
6 => $disposition,
7 => $cid,
];
return true;
}
/**
* Add an embedded stringified attachment.
* This can include images, sounds, and just about any other document type.
*
* @param string $string The attachment binary data
* @param string $cid Content ID of the attachment
* @param string $name A filename for the attachment. Should use extension.
* @param string $encoding File encoding (see $encoding), defaults to 'base64'
* @param string $type MIME type - will be used in preference to any automatically derived type
* @param string $disposition Disposition to use
*
* @return bool
*
* @since 1.0.0
*/
public function addStringEmbeddedImage(
string $string,
string $cid,
string $name = '',
string $encoding = EncodingType::E_BASE64,
string $type = '',
string $disposition = 'inline'
) : bool {
// Mime from file
if ($type === '' && !empty($name)) {
$type = MimeType::extensionToMime(FileUtils::mb_pathinfo($name, \PATHINFO_EXTENSION));
}
// Append to $attachment array
$this->attachment[] = [
0 => $string,
1 => $name,
2 => $name,
3 => $encoding,
4 => $type,
5 => true, // isStringAttachment
6 => $disposition,
7 => $cid,
];
return true;
}
/**
* Check if an embedded attachment is present with this cid.
*
* @param string $cid Cid
*
* @return bool
*
* @since 1.0.0
*/
protected function cidExists(string $cid) : bool
{
foreach ($this->attachment as $attachment) {
if ($attachment[6] === 'inline' && $cid === $attachment[7]) {
return true;
}
}
return false;
}
/**
* Add a custom header.
*
* @param string $name Name
* @param null|string $value Value
*
* @return bool
*
* @since 1.0.0
*/
public function addCustomHeader(string $name, ?string $value = null) : bool
{
$name = \trim($name);
$value = \trim($value);
if (empty($name) || \strpbrk($name . $value, "\r\n") !== false) {
return false;
}
$this->customHeader[] = [$name, $value];
return true;
}
/**
* Returns all custom headers.
*
* @return array
*
* @since 1.0.0
*/
public function getCustomHeaders() : array
{
return $this->customHeader;
}
/**
* Create a message body from an HTML string.
*
* $basedir is prepended when handling relative URLs, e.g. <img src="/images/a.png"> and must not be empty
* will look for an image file in $basedir/images/a.png and convert it to inline.
* If you don't provide a $basedir, relative paths will be left untouched (and thus probably break in email)
* Converts data-uri images into embedded attachments.
*
* If you don't want to apply these transformations to your HTML, just set Body and AltBody directly.
*
* @param string $message HTML message string
* @param string $basedir Absolute path to a base directory to prepend to relative paths to images
* @param null|\Closure $advanced Internal or external text to html converter
*
* @return string
*
* @since 1.0.0
*/
public function msgHTML(string $message, string $basedir = '', ?\Closure $advanced = null)
{
\preg_match_all('/(?<!-)(src|background)=["\'](.*)["\']/Ui', $message, $images);
if (isset($images[2])) {
if (\strlen($basedir) > 1 && \substr($basedir, -1) !== '/') {
$basedir .= '/';
}
foreach ($images[2] as $imgindex => $url) {
// Convert data URIs into embedded images
$match = [];
if (\preg_match('#^data:(image/(?:jpe?g|gif|png));?(base64)?,(.+)#', $url, $match)) {
if (\count($match) === 4 && $match[2] === EncodingType::E_BASE64) {
$data = \base64_decode($match[3]);
} elseif ($match[2] === '') {
$data = \rawurldecode($match[3]);
} else {
continue;
}
$cid = \substr(\hash('sha256', $data), 0, 32) . '@phpoms.0'; // RFC2392 S 2
if (!$this->cidExists($cid)) {
$this->addStringEmbeddedImage($data, $cid, 'embed' . $imgindex, EncodingType::E_BASE64, $match[1]);
}
$message = \str_replace($images[0][$imgindex], $images[1][$imgindex] . '="cid:' . $cid . '"', $message);
continue;
}
if (!empty($basedir)
&& (\strpos($url, '..') === false)
&& !\str_starts_with($url, 'cid:')
&& !\preg_match('#^[a-z][a-z0-9+.-]*:?//#i', $url)
) {
$filename = FileUtils::mb_pathinfo($url, \PATHINFO_BASENAME);
$directory = \dirname($url);
if ($directory === '.') {
$directory = '';
}
// RFC2392 S 2
$cid = \substr(\hash('sha256', $url), 0, 32) . '@phpoms.0';
if (\strlen($basedir) > 1 && \substr($basedir, -1) !== '/') {
$basedir .= '/';
}
if (\strlen($directory) > 1 && \substr($directory, -1) !== '/') {
$directory .= '/';
}
if ($this->addEmbeddedImage(
$basedir . $directory . $filename,
$cid,
$filename,
EncodingType::E_BASE64,
MimeType::extensionToMime((string) FileUtils::mb_pathinfo($filename, \PATHINFO_EXTENSION))
)
) {
$message = \preg_replace(
'/' . $images[1][$imgindex] . '=["\']' . \preg_quote($url, '/') . '["\']/Ui',
$images[1][$imgindex] . '="cid:' . $cid . '"',
$message
);
}
}
}
}
$this->contentType = MimeType::M_HTML;
$this->body = self::normalizeBreaks($message, self::$LE);
$this->bodyAlt = self::normalizeBreaks($this->html2text($message, $advanced), self::$LE);
if (empty($this->bodyAlt)) {
$this->bodyAlt = 'This is an HTML-only message. To view it, activate HTML in your email application.' . self::$LE;
}
return $this->body;
}
/**
* Normalize line breaks in a string.
*
* @param string $text Text to normalize
* @param string $breaktype What kind of line break to use; defaults to self::$LE
*
* @return string
*
* @since 1.0.0
*/
private static function normalizeBreaks(string $text, string $breaktype) : string
{
$text = \str_replace(["\r\n", "\r"], "\n", $text);
if ($breaktype !== "\n") {
$text = \str_replace("\n", $breaktype, $text);
}
return $text;
}
/**
* Convert an HTML string into plain text.
*
* @param string $html The HTML text to convert
* @param null|\Closure $advanced Internal or external text to html converter
*
* @return string
*
* @since 1.0.0
*/
private function html2text(string $html, ?\Closure $advanced = null) : string
{
if ($advanced !== null) {
return $advanced($html);
}
return \html_entity_decode(
\trim(\strip_tags(\preg_replace('/<(head|title|style|script)[^>]*>.*?<\/\\1>/si', '', $html))),
\ENT_QUOTES,
$this->charset
);
}
/**
* Set the public and private key files and password for S/MIME signing.
*
* @param string $certFile Certification file
* @param string $keyFile Key file
* @param string $keyPass Password for private key
* @param string $extracertsFile Optional path to chain certificate
*
* @return void
*
* @since 1.0.0
*/
public function sign($certFile, $keyFile, $keyPass, $extracertsFile = '') : void
{
$this->signCertFile = $certFile;
$this->signKeyFile = $keyFile;
$this->signKeyPass = $keyPass;
$this->signExtracertFiles = $extracertsFile;
}
/**
* Quoted-Printable-encode a DKIM header.
*
* @param string $txt Text
*
* @return string
*
* @since 1.0.0
*/
public function dkimQP(string $txt) : string
{
$line = '';
$len = \strlen($txt);
for ($i = 0; $i < $len; ++$i) {
$ord = \ord($txt[$i]);
$line .= (($ord >= 0x21) && ($ord <= 0x3A)) || $ord === 0x3C || (($ord >= 0x3E) && ($ord <= 0x7E))
? $txt[$i]
: '=' . \sprintf('%02X', $ord);
}
return $line;
}
/**
* Generate a DKIM signature.
*
* @param string $signHeader Sign header
*
* @return string The DKIM signature value
*
* @since 1.0.0
*/
public function dkimSign(string $signHeader) : string
{
if (!\defined('PKCS7_TEXT')) {
return '';
}
$privKeyStr = empty($this->dkimPrivateKey)
? \file_get_contents($this->dkimPrivatePath)
: $this->dkimPrivateKey;
$privKey = $this->dkimPass === ''
? \openssl_pkey_get_private($privKeyStr)
: \openssl_pkey_get_private($privKeyStr, $this->dkimPass);
return \openssl_sign($signHeader, $signature, $privKey, 'sha256WithRSAEncryption')
? \base64_encode($signature)
: '';
}
/**
* Generate a DKIM canonicalization header.
*
* @param string $signHeader Header
*
* @return string
*
* @since 1.0.0
*/
public function dkimHeaderC(string $signHeader) : string
{
$signHeader = self::normalizeBreaks($signHeader, "\r\n");
$signHeader = \preg_replace('/\r\n[ \t]+/', ' ', $signHeader);
$lines = \explode("\r\n", $signHeader);
foreach ($lines as $key => $line) {
if (\strpos($line, ':') === false) {
continue;
}
list($heading, $value) = \explode(':', $line, 2);
$heading = \strtolower($heading);
$value = \preg_replace('/[ \t]+/', ' ', $value);
$lines[$key] = \trim($heading, " \t") . ':' . \trim($value, " \t");
}
return \implode("\r\n", $lines);
}
/**
* Generate a DKIM canonicalization body.
*
* @param string $body Message Body
*
* @return string
*
* @since 1.0.0
*/
public function dkimBodyC(string $body) : string
{
if (empty($body)) {
return "\r\n";
}
$body = self::normalizeBreaks($body, "\r\n");
return \rtrim($body, " \r\n\t") . "\r\n";
}
/**
* Create the DKIM header and body in a new message header.
*
* @param string $headersLine Header lines
* @param string $subject Subject
* @param string $body Body
*
* @return string
*
* @since 1.0.0
*/
public function dkimAdd(string $headersLine, string $subject, string $body) : string
{
$DKIMsignatureType = 'rsa-sha256';
$DKIMcanonicalization = 'relaxed/simple';
$DKIMquery = 'dns/txt';
$DKIMtime = \time();
$autoSignHeaders = [
'from',
'to',
'cc',
'date',
'subject',
'reply-to',
'message-id',
'Content-Type',
'mime-version',
'x-mailer',
];
if (\stripos($headersLine, 'Subject') === false) {
$headersLine .= 'Subject: ' . $subject . self::$LE;
}
$headerLines = \explode(self::$LE, $headersLine);
$currentHeaderLabel = '';
$currentHeaderValue = '';
$parsedHeaders = [];
$headerLineIndex = 0;
$headerLineCount = \count($headerLines);
foreach ($headerLines as $headerLine) {
$matches = [];
if (\preg_match('/^([^ \t]*?)(?::[ \t]*)(.*)$/', $headerLine, $matches)) {
if ($currentHeaderLabel !== '') {
$parsedHeaders[] = ['label' => $currentHeaderLabel, 'value' => $currentHeaderValue];
}
$currentHeaderLabel = $matches[1];
$currentHeaderValue = $matches[2];
} elseif (\preg_match('/^[ \t]+(.*)$/', $headerLine, $matches)) {
$currentHeaderValue .= ' ' . $matches[1];
}
++$headerLineIndex;
if ($headerLineIndex >= $headerLineCount) {
$parsedHeaders[] = ['label' => $currentHeaderLabel, 'value' => $currentHeaderValue];
}
}
$copiedHeaders = [];
$headersToSignKeys = [];
$headersToSign = [];
foreach ($parsedHeaders as $header) {
if (\in_array(\strtolower($header['label']), $autoSignHeaders, true)) {
$headersToSignKeys[] = $header['label'];
$headersToSign[] = $header['label'] . ': ' . $header['value'];
if ($this->dkimCopyHeader) {
$copiedHeaders[] = $header['label'] . ':'
. \str_replace('|', '=7C', $this->dkimQP($header['value']));
}
continue;
}
if (\in_array($header['label'], $this->dkimHeaders, true)) {
foreach ($this->customHeader as $customHeader) {
if ($customHeader[0] === $header['label']) {
$headersToSignKeys[] = $header['label'];
$headersToSign[] = $header['label'] . ': ' . $header['value'];
if ($this->dkimCopyHeader) {
$copiedHeaders[] = $header['label'] . ':'
. \str_replace('|', '=7C', $this->dkimQP($header['value']));
}
continue 2;
}
}
}
}
$copiedHeaderFields = '';
if ($this->dkimCopyHeader && !empty($copiedHeaders)) {
$copiedHeaderFields = ' z=';
$first = true;
foreach ($copiedHeaders as $copiedHeader) {
if (!$first) {
$copiedHeaderFields .= self::$LE . ' |';
}
$copiedHeaderFields .= \strlen($copiedHeader) > self::STD_LINE_LENGTH - 3
? \substr(
\chunk_split($copiedHeader, self::STD_LINE_LENGTH - 3, self::$LE . self::FWS),
0,
-\strlen(self::$LE . self::FWS)
)
: $copiedHeader;
$first = false;
}
$copiedHeaderFields .= ';' . self::$LE;
}
$headerKeys = ' h=' . \implode(':', $headersToSignKeys) . ';' . self::$LE;
$headerValues = \implode(self::$LE, $headersToSign);
$body = $this->dkimBodyC($body);
$DKIMb64 = \base64_encode(\pack('H*', \hash('sha256', $body)));
$ident = '';
if ($this->dkimIdentity !== '') {
$ident = ' i=' . $this->dkimIdentity . ';' . self::$LE;
}
$dkimSignatureHeader = 'DKIM-Signature: v=1;'
. ' d=' . $this->dkimDomain . ';'
. ' s=' . $this->dkimSelector . ';' . self::$LE
. ' a=' . $DKIMsignatureType . ';'
. ' q=' . $DKIMquery . ';'
. ' t=' . $DKIMtime . ';'
. ' c=' . $DKIMcanonicalization . ';' . self::$LE
. $headerKeys . $ident . $copiedHeaderFields
. ' bh=' . $DKIMb64 . ';' . self::$LE
. ' b=';
$canonicalizedHeaders = $this->dkimHeaderC(
$headerValues . self::$LE . $dkimSignatureHeader
);
$signature = $this->dkimSign($canonicalizedHeaders);
$signature = \trim(\chunk_split($signature, self::STD_LINE_LENGTH - 3, self::$LE . self::FWS));
return self::normalizeBreaks($dkimSignatureHeader . $signature, self::$LE);
}
}