From 7c159f9c1a8f044f2f432efc1acff9d147df555c Mon Sep 17 00:00:00 2001 From: Ryan Cramer Date: Thu, 31 May 2018 13:15:32 -0400 Subject: [PATCH] Some reworking of and improvements to the WireMail class, though no change to external interface (and should be no changes required for classes extending it). --- wire/core/WireMail.php | 307 +++++++++++++++++++++++++++++++---------- 1 file changed, 231 insertions(+), 76 deletions(-) diff --git a/wire/core/WireMail.php b/wire/core/WireMail.php index 5a6a9321..75dbf59c 100644 --- a/wire/core/WireMail.php +++ b/wire/core/WireMail.php @@ -51,6 +51,7 @@ * #pw-body * * @method int send() Send email. + * @method string htmlToText($html) Convert HTML email body to TEXT email body. * * @property array $to To email address. * @property array $toName Optional person’s name to accompany “to” email address @@ -65,6 +66,7 @@ * @property array $headers Alias of $header * @property array $param Associative array of aditional params (likely not applicable to most WireMail modules). * @property array $attachments Array of file attachments (if populated and where supported) #pw-advanced + * @property string $newline Newline character, populated only if different from CRLF. #pw-advanced * */ @@ -487,99 +489,63 @@ class WireMail extends WireData implements WireMailInterface { return $this; } + /** + * Get the multipart boundary string for this email + * + * @param string|bool $prefix Specify optional boundary prefix or boolean true to clear any existing stored boundary + * @return string + * + */ + protected function multipartBoundary($prefix = '') { + $boundary = parent::get('_multipartBoundary'); + if(empty($boundary) || $prefix === true) { + $boundary = "==Multipart_Boundary_x" . md5(time()) . "x"; + parent::set('_multipartBoundary', $boundary); + } + if(is_string($prefix) && !empty($prefix)) { + $boundary = str_replace("_Boundary_x", "_Boundary_{$prefix}_x", $boundary); + } + return $boundary; + } + /** * Send the email * * Call this method only after you have specified at least the `subject`, `to` and `body`. - * + * * #pw-notes This is the primary method that modules extending this class would want to replace. * - * @return int Returns a positive number (indicating number of addresses emailed) or 0 on failure. + * @return int Returns a positive number (indicating number of addresses emailed) or 0 on failure. * */ public function ___send() { - $from = $this->from; - if(!strlen($from)) $from = $this->wire('config')->adminEmail; - if(!strlen($from)) $from = 'processwire@' . $this->wire('config')->httpHost; - - $header = "From: " . ($this->fromName ? $this->bundleEmailAndName($from, $this->fromName) : $from); - - foreach($this->header as $key => $value) $header .= "\r\n$key: $value"; + // prep header and body + $this->multipartBoundary(true); + $header = $this->renderMailHeader(); + $body = $this->renderMailBody(); + // adjust for the cases where people want to change RFC standard \r\n to just \n + $newline = parent::get('newline'); + if(is_string($newline) && strlen($newline) && $newline !== "\r\n") { + $body = str_replace("\r\n", $newline, $body); + $header = str_replace("\r\n", $newline, $header); + } + + // prep any additional PHP mail params $param = $this->wire('config')->phpMailAdditionalParameters; if(is_null($param)) $param = ''; - foreach($this->param as $value) $param .= " $value"; - - $header = trim($header); - $param = trim($param); - $text = $this->body; - $html = $this->bodyHTML; - - if($this->bodyHTML || count($this->attachments)) { - if(!strlen($text)) $text = strip_tags($html); - $contentType = count($this->attachments) ? 'multipart/mixed' : 'multipart/alternative'; - $boundary = "==Multipart_Boundary_x" . md5(time()) . "x"; - $header .= "\r\nMIME-Version: 1.0"; - $header .= "\r\nContent-Type: $contentType;\r\n boundary=\"$boundary\""; - - // Plain Text - $body = "This is a multi-part message in MIME format.\r\n\r\n" . - "--$boundary\r\n"; - - $textbody = "Content-Type: text/plain; charset=\"utf-8\"\r\n" . - "Content-Transfer-Encoding: quoted-printable\r\n\r\n" . - quoted_printable_encode($text) . "\r\n\r\n"; - - // HTML - if($this->bodyHTML){ - $htmlbody = "Content-Type: text/html; charset=\"utf-8\"\r\n" . - "Content-Transfer-Encoding: quoted-printable\r\n\r\n" . - quoted_printable_encode($html) . "\r\n\r\n"; - - if(count($this->attachments)) { - $subboundary = "==Multipart_Boundary_alt_x" . md5(time()) . "x"; - - $body .= "Content-Type: multipart/alternative;\r\n boundary=\"$subboundary\"\r\n\r\n" . - "--$subboundary\r\n" . - $textbody . - "--$subboundary\r\n" . - $htmlbody . - "--$subboundary--\r\n\r\n"; - } else { - $body .= $textbody . - "--$boundary\r\n" . - $htmlbody; - } - } else { - $body .= $textbody; - } - - // Attachments - foreach($this->attachments as $filename => $file) { - $content = file_get_contents($file); - $content = chunk_split(base64_encode($content)); - - $body .= "--$boundary\r\n" . - "Content-Type: application/octet-stream; name=\"$filename\"\r\n" . - "Content-Transfer-Encoding: base64\r\n" . - "Content-Disposition: attachment; filename=\"$filename\"\r\n\r\n" . - "$content\r\n\r\n"; - } - - $body .= "--$boundary--\r\n"; - - } else { - $header .= "\r\nContent-Type: text/plain; charset=UTF-8\r\n" . - "Content-Transfer-Encoding: quoted-printable"; - $body = quoted_printable_encode($text); + foreach($this->param as $value) { + $param .= " $value"; } + // send email(s) $numSent = 0; + $subject = $this->encodeSubject($this->subject); + foreach($this->to as $to) { - $toName = $this->mail['toName'][$to]; + $toName = isset($this->mail['toName'][$to]) ? $this->mail['toName'][$to] : ''; if($toName) $to = $this->bundleEmailAndName($to, $toName); // bundle to "User Name encodeSubject($this->subject); if($param) { if(@mail($to, $subject, $body, $header, $param)) $numSent++; } else { @@ -587,7 +553,193 @@ class WireMail extends WireData implements WireMailInterface { } } - return $numSent; + return $numSent; + } + + /** + * Render email header string + * + * @return string + * + */ + protected function renderMailHeader() { + + $from = $this->from; + if(!strlen($from)) $from = $this->wire('config')->adminEmail; + if(!strlen($from)) $from = 'processwire@' . $this->wire('config')->httpHost; + + $header = "From: " . ($this->fromName ? $this->bundleEmailAndName($from, $this->fromName) : $from); + + foreach($this->header as $key => $value) { + $header .= "\r\n$key: $value"; + } + + $boundary = $this->multipartBoundary(); + $header = trim($this->strReplace($header, $boundary)); + + if($this->bodyHTML || count($this->attachments)) { + $contentType = count($this->attachments) ? 'multipart/mixed' : 'multipart/alternative'; + $header .= + "\r\nMIME-Version: 1.0" . + "\r\nContent-Type: $contentType;\r\n boundary=\"$boundary\""; + } else { + $header .= + "\r\nContent-Type: text/plain; charset=UTF-8" . + "\r\nContent-Transfer-Encoding: quoted-printable"; + } + + return $header; + } + + /** + * Render mail body + * + * @return string + * + */ + protected function renderMailBody() { + + $boundary = $this->multipartBoundary(); + $subboundary = $this->multipartBoundary('alt'); + + // don’t allow boundary to appear in visible portions of email + $text = $this->strReplace($this->body, array($boundary, $subboundary)); + $html = $this->strReplace($this->bodyHTML, array($boundary, $subboundary)); + + // if plain text only, return now + if(empty($html) && !count($this->attachments)) return quoted_printable_encode($text); + + // if only HTML provided, generate text version from HTML + if(!strlen($text) && strlen($html)) $text = $this->htmlToText($html); + + $body = + "This is a multi-part message in MIME format.\r\n\r\n" . + "--$boundary\r\n"; + + // Plain Text + $textbody = + "Content-Type: text/plain; charset=\"utf-8\"\r\n" . + "Content-Transfer-Encoding: quoted-printable\r\n\r\n" . + quoted_printable_encode($text) . "\r\n\r\n"; + + if($this->bodyHTML) { + // HTML + $htmlbody = + "Content-Type: text/html; charset=\"utf-8\"\r\n" . + "Content-Transfer-Encoding: quoted-printable\r\n\r\n" . + quoted_printable_encode($html) . "\r\n\r\n"; + + if(count($this->attachments)) { + // file attachments + $textbody = $this->strReplace($textbody, $subboundary); + $htmlbody = $this->strReplace($htmlbody, $subboundary); + + $body .= + "Content-Type: multipart/alternative;\r\n boundary=\"$subboundary\"\r\n\r\n" . + "--$subboundary\r\n" . + $textbody . + "--$subboundary\r\n" . + $htmlbody . + "--$subboundary--\r\n\r\n"; + + } else { + // no file attachments + $body .= + $textbody . + "--$boundary\r\n" . + $htmlbody; + } + + } else { + // plain text + $body .= $textbody; + } + + if(count($this->attachments)) { + $body .= $this->renderMailAttachments(); + } + + $body .= "--$boundary--\r\n"; + + return $body; + } + + /** + * Render mail attachments string for placement in body + * + * @return string + * + */ + protected function renderMailAttachments() { + $body = ''; + $boundary = $this->multipartBoundary(); + + foreach($this->attachments as $filename => $file) { + + $filename = $this->wire('sanitizer')->text($filename, array( + 'maxLength' => 512, + 'truncateTail' => false, + 'stripSpace' => '-', + 'stripQuotes' => true + )); + + if(stripos($filename, $boundary) !== false) continue; + + $content = file_get_contents($file); + $content = chunk_split(base64_encode($content)); + + if(stripos($content, $boundary) !== false) continue; + + $body .= + "--$boundary\r\n" . + "Content-Type: application/octet-stream; name=\"$filename\"\r\n" . + "Content-Transfer-Encoding: base64\r\n" . + "Content-Disposition: attachment; filename=\"$filename\"\r\n\r\n" . + "$content\r\n\r\n"; + } + + return $body; + } + + /** + * Recursive string replacement + * + * This is better than using str_replace() because it handles cases where replacement + * results in the construction of a new $find that was not present in original $str. + * Note: this function ignores case. + * + * @param string $str + * @param string|array $find + * @param string $replace + * @return string + * + */ + protected function strReplace($str, $find, $replace = '') { + if(!is_array($find)) $find = array($find); + if(!is_string($str)) $str = (string) $str; + foreach($find as $findStr) { + if(is_array($findStr)) continue; + while(stripos($str, $findStr) !== false) { + $str = str_ireplace($findStr, $replace, $str); + } + } + return $str; + } + + /** + * Convert HTML mail body to TEXT mail body + * + * @param string $html + * @return string + * + */ + protected function ___htmlToText($html) { + $textTools = new WireTextTools(); + $this->wire($textTools); + $text = $textTools->markupToText($html); + $text = str_replace("\n", "\r\n", $text); + $text = $this->strReplace($text, $this->multipartBoundary()); + return $text; } /** @@ -601,6 +753,9 @@ class WireMail extends WireData implements WireMailInterface { */ public function encodeSubject($subject) { + $boundary = $this->multipartBoundary(); + $subject = $this->strReplace($subject, $boundary); + if(extension_loaded("mbstring")) { // Need to pass in the header name and subtract it afterwards, // otherwise the first line would grow too long