. 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; /** * 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; } public function getContentType() : string { return $this->contentType; } 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
" 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; } /** * Pre-send preparations * * @param string $mailer Mailer tool * * @return bool * * @since 1.0.0 */ public function preSend(string $mailer) : bool { $this->header = ''; $this->mailer = $mailer; if (empty($this->to) && empty($this->cc) && empty($this->bcc)) { return false; } 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; } 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 * * @return 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; /* @noinspection PhpMissingBreakStatementInspection */ case 'comment': $matchcount = \preg_match_all('/[()"]/', $str, $matches); 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 ("` =?
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('/(? 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)
&& \strpos($url, 'cid:') !== 0
&& !\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
* @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
*
* @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);
}
}