From 6f476b8a43ee90680b4728b4cdb0d5765809ed76 Mon Sep 17 00:00:00 2001 From: Alex Pott Date: Mon, 11 Aug 2014 09:25:38 -0500 Subject: [PATCH] Issue #2301393 by kim.pepper, larowlan: Deprecate all of mail.inc, move drupal_mail to method on Mail plugin manager. --- core/core.services.yml | 2 +- core/includes/mail.inc | 374 +---------------- .../lib/Drupal/Core/Mail/MailFormatHelper.php | 395 ++++++++++++++++++ core/lib/Drupal/Core/Mail/MailManager.php | 125 +++++- .../Drupal/Core/Mail/MailManagerInterface.php | 124 ++++++ .../Tests/Core/Mail/MailFormatHelperTest.php} | 22 +- .../Tests/Core/Mail/MailManagerTest.php | 19 +- 7 files changed, 684 insertions(+), 377 deletions(-) create mode 100644 core/lib/Drupal/Core/Mail/MailFormatHelper.php create mode 100644 core/lib/Drupal/Core/Mail/MailManagerInterface.php rename core/{modules/system/src/Tests/Mail/WrapMailUnitTest.php => tests/Drupal/Tests/Core/Mail/MailFormatHelperTest.php} (61%) diff --git a/core/core.services.yml b/core/core.services.yml index 8527b2773ab..200972e73b3 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -745,7 +745,7 @@ services: - { name: backend_overridable } plugin.manager.mail: class: Drupal\Core\Mail\MailManager - arguments: ['@container.namespaces', '@cache.discovery', '@module_handler', '@config.factory'] + arguments: ['@container.namespaces', '@cache.discovery', '@module_handler', '@config.factory', '@logger.factory', '@string_translation'] plugin.manager.condition: class: Drupal\Core\Condition\ConditionManager parent: default_plugin_manager diff --git a/core/includes/mail.inc b/core/includes/mail.inc index e62297c5e2e..c22d10acb57 100644 --- a/core/includes/mail.inc +++ b/core/includes/mail.inc @@ -5,9 +5,7 @@ * API functions for processing and sending email. */ -use Drupal\Component\Utility\Html; -use Drupal\Component\Utility\Xss; -use Drupal\Core\Site\Settings; +use Drupal\Core\Mail\MailFormatHelper; /** * Composes and optionally sends an email message. @@ -108,89 +106,20 @@ use Drupal\Core\Site\Settings; * implementing hook_mail_alter() may cancel sending by setting * $message['send'] to FALSE. * - * @return + * @return array * The $message array structure containing all details of the * message. If already sent ($send = TRUE), then the 'result' element * will contain the success indicator of the email, failure being already * written to the watchdog. (Success means nothing more than the message being * accepted at php-level, which still doesn't guarantee it to be delivered.) + * + * @deprecated in Drupal 8.x-dev, will be removed before Drupal 8.0. + * Use \Drupal::service('plugin.manager.mail')->mail() in procedural code. In + * Object-Oriented code inject the 'plugin.manager.mail' service and use the + * ::mail() method. */ function drupal_mail($module, $key, $to, $langcode, $params = array(), $reply = NULL, $send = TRUE) { - $site_config = \Drupal::config('system.site'); - $site_mail = $site_config->get('mail'); - if (empty($site_mail)) { - $site_mail = ini_get('sendmail_from'); - } - - // Bundle up the variables into a structured array for altering. - $message = array( - 'id' => $module . '_' . $key, - 'module' => $module, - 'key' => $key, - 'to' => $to, - 'from' => $site_mail, - 'reply-to' => $reply, - 'langcode' => $langcode, - 'params' => $params, - 'send' => TRUE, - 'subject' => '', - 'body' => array() - ); - - // Build the default headers - $headers = array( - 'MIME-Version' => '1.0', - 'Content-Type' => 'text/plain; charset=UTF-8; format=flowed; delsp=yes', - 'Content-Transfer-Encoding' => '8Bit', - 'X-Mailer' => 'Drupal' - ); - // To prevent email from looking like spam, the addresses in the Sender and - // Return-Path headers should have a domain authorized to use the - // originating SMTP server. - $headers['Sender'] = $headers['Return-Path'] = $site_mail; - $headers['From'] = $site_config->get('name') . ' <' . $site_mail . '>'; - if ($reply) { - $headers['Reply-to'] = $reply; - } - $message['headers'] = $headers; - - // Build the email (get subject and body, allow additional headers) by - // invoking hook_mail() on this module. We cannot use - // moduleHandler()->invoke() as we need to have $message by reference in - // hook_mail(). - if (function_exists($function = $module . '_mail')) { - $function($key, $message, $params); - } - - // Invoke hook_mail_alter() to allow all modules to alter the resulting email. - \Drupal::moduleHandler()->alter('mail', $message); - - // Retrieve the responsible implementation for this message. - $system = drupal_mail_system($module, $key); - - // Format the message body. - $message = $system->format($message); - - // Optionally send email. - if ($send) { - // The original caller requested sending. Sending was canceled by one or - // more hook_mail_alter() implementations. We set 'result' to NULL, because - // FALSE indicates an error in sending. - if (empty($message['send'])) { - $message['result'] = NULL; - } - // Sending was originally requested and was not canceled. - else { - $message['result'] = $system->mail($message); - // Log errors. - if (!$message['result']) { - \Drupal::logger('mail')->error('Error sending email (from %from to %to with reply-to %reply).', array('%from' => $message['from'], '%to' => $message['to'], '%reply' => $message['reply-to'] ? $message['reply-to'] : t('not set'))); - drupal_set_message(t('Unable to send email. Contact the site administrator if the problem persists.'), 'error'); - } - } - } - - return $message; + return \Drupal::service('plugin.manager.mail')->mail($module, $key, $to, $langcode, $params, $reply, $send); } /** @@ -208,6 +137,11 @@ function drupal_mail($module, $key, $to, $langcode, $params = array(), $reply = * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException * * @see \Drupal\Core\Mail\MailManager::getInstance() + * + * @deprecated in Drupal 8.x-dev, will be removed before Drupal 8.0. + * Use \Drupal::service('plugin.manager.mail')->getInstance() in procedural + * code. In Object-Oriented code inject the 'plugin.manager.mail' service and + * use the ::getInstance() method. */ function drupal_mail_system($module, $key) { return \Drupal::service('plugin.manager.mail')->getInstance(array('module' => $module, 'key' => $key)); @@ -229,35 +163,12 @@ function drupal_mail_system($module, $key) { * * @return * The content of the email as a string with formatting applied. + * + * @deprecated in Drupal 8.x-dev, will be removed before Drupal 8.0. + * Use \Drupal\Core\Utility\Mail::wrapMail(). */ function drupal_wrap_mail($text, $indent = '') { - // Convert CRLF into LF. - $text = str_replace("\r", '', $text); - // See if soft-wrapping is allowed. - $clean_indent = _drupal_html_to_text_clean($indent); - $soft = strpos($clean_indent, ' ') === FALSE; - // Check if the string has line breaks. - if (strpos($text, "\n") !== FALSE) { - // Remove trailing spaces to make existing breaks hard, but leave signature - // marker untouched (RFC 3676, Section 4.3). - $text = preg_replace('/(?(? $soft, 'length' => strlen($indent))); - $text = implode("\n", $lines); - } - else { - // Wrap this line. - _drupal_wrap_mail_line($text, 0, array('soft' => $soft, 'length' => strlen($indent))); - } - // Empty lines with nothing but spaces. - $text = preg_replace('/^ +\n/m', "\n", $text); - // Space-stuff special lines. - $text = preg_replace('/^(>| |From)/m', ' $1', $text); - // Apply indentation. We only include non-'>' indentation on the first line. - $text = $indent . substr(preg_replace('/^/m', $clean_indent, $text), strlen($indent)); - - return $text; + return MailFormatHelper::wrapMail($text, $indent); } /** @@ -280,253 +191,10 @@ function drupal_wrap_mail($text, $indent = '') { * * @return * The transformed string. + * + * @deprecated in Drupal 8.x-dev, will be removed before Drupal 8.0. + * Use \Drupal\Core\Utility\Mail::htmlToText(). */ function drupal_html_to_text($string, $allowed_tags = NULL) { - // Cache list of supported tags. - static $supported_tags; - if (empty($supported_tags)) { - $supported_tags = array('a', 'em', 'i', 'strong', 'b', 'br', 'p', 'blockquote', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr'); - } - - // Make sure only supported tags are kept. - $allowed_tags = isset($allowed_tags) ? array_intersect($supported_tags, $allowed_tags) : $supported_tags; - - // Make sure tags, entities and attributes are well-formed and properly nested. - $string = Html::normalize(Xss::filter($string, $allowed_tags)); - - // Apply inline styles. - $string = preg_replace('! +)[^>]*)?>!i', '/', $string); - $string = preg_replace('! +)[^>]*)?>!i', '*', $string); - - // Replace inline tags with the text of link and a footnote. - // 'See the Drupal site' becomes - // 'See the Drupal site [1]' with the URL included as a footnote. - _drupal_html_to_mail_urls(NULL, TRUE); - $pattern = '@(]+?href="([^"]*)"[^>]*?>(.+?))@i'; - $string = preg_replace_callback($pattern, '_drupal_html_to_mail_urls', $string); - $urls = _drupal_html_to_mail_urls(); - $footnotes = ''; - if (count($urls)) { - $footnotes .= "\n"; - for ($i = 0, $max = count($urls); $i < $max; $i++) { - $footnotes .= '[' . ($i + 1) . '] ' . $urls[$i] . "\n"; - } - } - - // Split tags from text. - $split = preg_split('/<([^>]+?)>/', $string, -1, PREG_SPLIT_DELIM_CAPTURE); - // Note: PHP ensures the array consists of alternating delimiters and literals - // and begins and ends with a literal (inserting $null as required). - - $tag = FALSE; // Odd/even counter (tag or no tag) - $casing = NULL; // Case conversion function - $output = ''; - $indent = array(); // All current indentation string chunks - $lists = array(); // Array of counters for opened lists - foreach ($split as $value) { - $chunk = NULL; // Holds a string ready to be formatted and output. - - // Process HTML tags (but don't output any literally). - if ($tag) { - list($tagname) = explode(' ', strtolower($value), 2); - switch ($tagname) { - // List counters - case 'ul': - array_unshift($lists, '*'); - break; - case 'ol': - array_unshift($lists, 1); - break; - case '/ul': - case '/ol': - array_shift($lists); - $chunk = ''; // Ensure blank new-line. - break; - - // Quotation/list markers, non-fancy headers - case 'blockquote': - // Format=flowed indentation cannot be mixed with lists. - $indent[] = count($lists) ? ' "' : '>'; - break; - case 'li': - $indent[] = isset($lists[0]) && is_numeric($lists[0]) ? ' ' . $lists[0]++ . ') ' : ' * '; - break; - case 'dd': - $indent[] = ' '; - break; - case 'h3': - $indent[] = '.... '; - break; - case 'h4': - $indent[] = '.. '; - break; - case '/blockquote': - if (count($lists)) { - // Append closing quote for inline quotes (immediately). - $output = rtrim($output, "> \n") . "\"\n"; - $chunk = ''; // Ensure blank new-line. - } - // Fall-through - case '/li': - case '/dd': - array_pop($indent); - break; - case '/h3': - case '/h4': - array_pop($indent); - case '/h5': - case '/h6': - $chunk = ''; // Ensure blank new-line. - break; - - // Fancy headers - case 'h1': - $indent[] = '======== '; - $casing = 'drupal_strtoupper'; - break; - case 'h2': - $indent[] = '-------- '; - $casing = 'drupal_strtoupper'; - break; - case '/h1': - case '/h2': - $casing = NULL; - // Pad the line with dashes. - $output = _drupal_html_to_text_pad($output, ($tagname == '/h1') ? '=' : '-', ' '); - array_pop($indent); - $chunk = ''; // Ensure blank new-line. - break; - - // Horizontal rulers - case 'hr': - // Insert immediately. - $output .= drupal_wrap_mail('', implode('', $indent)) . "\n"; - $output = _drupal_html_to_text_pad($output, '-'); - break; - - // Paragraphs and definition lists - case '/p': - case '/dl': - $chunk = ''; // Ensure blank new-line. - break; - } - } - // Process blocks of text. - else { - // Convert inline HTML text to plain text; not removing line-breaks or - // white-space, since that breaks newlines when sanitizing plain-text. - $value = trim(decode_entities($value)); - if (drupal_strlen($value)) { - $chunk = $value; - } - } - - // See if there is something waiting to be output. - if (isset($chunk)) { - // Apply any necessary case conversion. - if (isset($casing)) { - $chunk = $casing($chunk); - } - $line_endings = Settings::get('mail_line_endings', PHP_EOL); - // Format it and apply the current indentation. - $output .= drupal_wrap_mail($chunk, implode('', $indent)) . $line_endings; - // Remove non-quotation markers from indentation. - $indent = array_map('_drupal_html_to_text_clean', $indent); - } - - $tag = !$tag; - } - - return $output . $footnotes; -} - -/** - * Wraps words on a single line. - * - * Callback for array_walk() winthin drupal_wrap_mail(). - * - * Note that we are skipping MIME content header lines, because attached files, - * especially applications, could have long MIME types or long filenames which - * result in line length longer than the 77 characters limit and wrapping that - * line will break the email format. E.g., the attached file hello_drupal.docx - * will produce the following Content-Type: - * Content-Type: - * application/vnd.openxmlformats-officedocument.wordprocessingml.document; - * name="hello_drupal.docx" - */ -function _drupal_wrap_mail_line(&$line, $key, $values) { - $line_is_mime_header = FALSE; - $mime_headers = array( - 'Content-Type', - 'Content-Transfer-Encoding', - 'Content-Disposition', - 'Content-Description', - ); - - // Do not break MIME headers which could be longer than 77 characters. - foreach ($mime_headers as $header) { - if (strpos($line, $header . ': ') === 0) { - $line_is_mime_header = TRUE; - break; - } - } - if (!$line_is_mime_header) { - // Use soft-breaks only for purely quoted or unindented text. - $line = wordwrap($line, 77 - $values['length'], $values['soft'] ? " \n" : "\n"); - } - // Break really long words at the maximum width allowed. - $line = wordwrap($line, 996 - $values['length'], $values['soft'] ? " \n" : "\n"); -} - -/** - * Keeps track of URLs and replaces them with placeholder tokens. - * - * Callback for preg_replace_callback() within drupal_html_to_text(). - */ -function _drupal_html_to_mail_urls($match = NULL, $reset = FALSE) { - global $base_url, $base_path; - static $urls = array(), $regexp; - - if ($reset) { - // Reset internal URL list. - $urls = array(); - } - else { - if (empty($regexp)) { - $regexp = '@^' . preg_quote($base_path, '@') . '@'; - } - if ($match) { - list(, , $url, $label) = $match; - // Ensure all URLs are absolute. - $urls[] = strpos($url, '://') ? $url : preg_replace($regexp, $base_url . '/', $url); - return $label . ' [' . count($urls) . ']'; - } - } - return $urls; -} - -/** - * Replaces non-quotation markers from a given piece of indentation with spaces. - * - * Callback for array_map() within drupal_html_to_text(). - */ -function _drupal_html_to_text_clean($indent) { - return preg_replace('/[^>]/', ' ', $indent); -} - -/** - * Pads the last line with the given character. - * - * @see drupal_html_to_text() - */ -function _drupal_html_to_text_pad($text, $pad, $prefix = '') { - // Remove last line break. - $text = substr($text, 0, -1); - // Calculate needed padding space and add it. - if (($p = strrpos($text, "\n")) === FALSE) { - $p = -1; - } - $n = max(0, 79 - (strlen($text) - $p) - strlen($prefix)); - // Add prefix and padding, and restore linebreak. - return $text . $prefix . str_repeat($pad, $n) . "\n"; + return MailFormatHelper::htmlToText($string, $allowed_tags); } diff --git a/core/lib/Drupal/Core/Mail/MailFormatHelper.php b/core/lib/Drupal/Core/Mail/MailFormatHelper.php new file mode 100644 index 00000000000..3f180ccf434 --- /dev/null +++ b/core/lib/Drupal/Core/Mail/MailFormatHelper.php @@ -0,0 +1,395 @@ +' characters are + * repeated on subsequent wrapped lines. Others are replaced by spaces. + * + * @return string + * The content of the email as a string with formatting applied. + */ + public static function wrapMail($text, $indent = '') { + // Convert CRLF into LF. + $text = str_replace("\r", '', $text); + // See if soft-wrapping is allowed. + $clean_indent = static::htmlToTextClean($indent); + $soft = strpos($clean_indent, ' ') === FALSE; + // Check if the string has line breaks. + if (strpos($text, "\n") !== FALSE) { + // Remove trailing spaces to make existing breaks hard, but leave + // signature marker untouched (RFC 3676, Section 4.3). + $text = preg_replace('/(?(? $soft, 'length' => strlen($indent))); + $text = implode("\n", $lines); + } + else { + // Wrap this line. + static::wrapMailLine($text, 0, array('soft' => $soft, 'length' => strlen($indent))); + } + // Empty lines with nothing but spaces. + $text = preg_replace('/^ +\n/m', "\n", $text); + // Space-stuff special lines. + $text = preg_replace('/^(>| |From)/m', ' $1', $text); + // Apply indentation. We only include non-'>' indentation on the first line. + $text = $indent . substr(preg_replace('/^/m', $clean_indent, $text), strlen($indent)); + + return $text; + } + + /** + * Transforms an HTML string into plain text, preserving its structure. + * + * The output will be suitable for use as 'format=flowed; delsp=yes' text + * (RFC 3676) and can be passed directly to MailManagerInterface::mail() for sending. + * + * We deliberately use LF rather than CRLF, see MailManagerInterface::mail(). + * + * This function provides suitable alternatives for the following tags: + *

    1. + *


      + * + * @param string $string + * The string to be transformed. + * @param array $allowed_tags + * (optional) If supplied, a list of tags that will be transformed. If + * omitted, all supported tags are transformed. + * + * @return string + * The transformed string. + */ + public static function htmlToText($string, $allowed_tags = NULL) { + // Cache list of supported tags. + if (empty(static::$supportedTags)) { + static::$supportedTags = array('a', 'em', 'i', 'strong', 'b', 'br', 'p', + 'blockquote', 'ul', 'ol', 'li', 'dl', 'dt', 'dd', 'h1', 'h2', 'h3', + 'h4', 'h5', 'h6', 'hr'); + } + + // Make sure only supported tags are kept. + $allowed_tags = isset($allowed_tags) ? array_intersect(static::$supportedTags, $allowed_tags) : static::$supportedTags; + + // Make sure tags, entities and attributes are well-formed and properly + // nested. + $string = Html::normalize(Xss::filter($string, $allowed_tags)); + + // Apply inline styles. + $string = preg_replace('! +)[^>]*)?>!i', '/', $string); + $string = preg_replace('! +)[^>]*)?>!i', '*', $string); + + // Replace inline
      tags with the text of link and a footnote. + // 'See the Drupal site' becomes + // 'See the Drupal site [1]' with the URL included as a footnote. + static::htmlToMailUrls(NULL, TRUE); + $pattern = '@(]+?href="([^"]*)"[^>]*?>(.+?))@i'; + $string = preg_replace_callback($pattern, 'static::htmlToMailUrls', $string); + $urls = static::htmlToMailUrls(); + $footnotes = ''; + if (count($urls)) { + $footnotes .= "\n"; + for ($i = 0, $max = count($urls); $i < $max; $i++) { + $footnotes .= '[' . ($i + 1) . '] ' . $urls[$i] . "\n"; + } + } + + // Split tags from text. + $split = preg_split('/<([^>]+?)>/', $string, -1, PREG_SPLIT_DELIM_CAPTURE); + // Note: PHP ensures the array consists of alternating delimiters and + // literals and begins and ends with a literal (inserting $null as + // required). + // Odd/even counter (tag or no tag). + $tag = FALSE; + // Case conversion function. + $casing = NULL; + $output = ''; + // All current indentation string chunks. + $indent = array(); + // Array of counters for opened lists. + $lists = array(); + foreach ($split as $value) { + // Holds a string ready to be formatted and output. + $chunk = NULL; + + // Process HTML tags (but don't output any literally). + if ($tag) { + list($tagname) = explode(' ', strtolower($value), 2); + switch ($tagname) { + // List counters. + case 'ul': + array_unshift($lists, '*'); + break; + + case 'ol': + array_unshift($lists, 1); + break; + + case '/ul': + case '/ol': + array_shift($lists); + // Ensure blank new-line. + $chunk = ''; + break; + + // Quotation/list markers, non-fancy headers. + case 'blockquote': + // Format=flowed indentation cannot be mixed with lists. + $indent[] = count($lists) ? ' "' : '>'; + break; + + case 'li': + $indent[] = isset($lists[0]) && is_numeric($lists[0]) ? ' ' . $lists[0]++ . ') ' : ' * '; + break; + + case 'dd': + $indent[] = ' '; + break; + + case 'h3': + $indent[] = '.... '; + break; + + case 'h4': + $indent[] = '.. '; + break; + + case '/blockquote': + if (count($lists)) { + // Append closing quote for inline quotes (immediately). + $output = rtrim($output, "> \n") . "\"\n"; + // Ensure blank new-line. + $chunk = ''; + } + + // Fall-through. + case '/li': + case '/dd': + array_pop($indent); + break; + + case '/h3': + case '/h4': + array_pop($indent); + case '/h5': + case '/h6': + // Ensure blank new-line. + $chunk = ''; + break; + + // Fancy headers. + case 'h1': + $indent[] = '======== '; + $casing = 'drupal_strtoupper'; + break; + + case 'h2': + $indent[] = '-------- '; + $casing = 'drupal_strtoupper'; + break; + + case '/h1': + case '/h2': + $casing = NULL; + // Pad the line with dashes. + $output = static::htmlToTextPad($output, ($tagname == '/h1') ? '=' : '-', ' '); + array_pop($indent); + // Ensure blank new-line. + $chunk = ''; + break; + + // Horizontal rulers. + case 'hr': + // Insert immediately. + $output .= static::wrapMail('', implode('', $indent)) . "\n"; + $output = static::htmlToTextPad($output, '-'); + break; + + // Paragraphs and definition lists. + case '/p': + case '/dl': + // Ensure blank new-line. + $chunk = ''; + break; + } + } + // Process blocks of text. + else { + // Convert inline HTML text to plain text; not removing line-breaks or + // white-space, since that breaks newlines when sanitizing plain-text. + $value = trim(decode_entities($value)); + if (drupal_strlen($value)) { + $chunk = $value; + } + } + + // See if there is something waiting to be output. + if (isset($chunk)) { + // Apply any necessary case conversion. + if (isset($casing)) { + $chunk = $casing($chunk); + } + $line_endings = Settings::get('mail_line_endings', PHP_EOL); + // Format it and apply the current indentation. + $output .= static::wrapMail($chunk, implode('', $indent)) . $line_endings; + // Remove non-quotation markers from indentation. + $indent = array_map('\Drupal\Core\Mail\MailFormatHelper::htmlToTextClean', $indent); + } + + $tag = !$tag; + } + + return $output . $footnotes; + } + + /** + * Wraps words on a single line. + * + * Callback for array_walk() within + * \Drupal\Core\Mail\MailFormatHelper::wrapMail(). + * + * Note that we are skipping MIME content header lines, because attached + * files, especially applications, could have long MIME types or long + * filenames which result in line length longer than the 77 characters limit + * and wrapping that line will break the email format. E.g., the attached file + * hello_drupal.docx will produce the following Content-Type: + * @code + * Content-Type: + * application/vnd.openxmlformats-officedocument.wordprocessingml.document; + * name="hello_drupal.docx" + * @endcode + */ + protected static function wrapMailLine(&$line, $key, $values) { + $line_is_mime_header = FALSE; + $mime_headers = array( + 'Content-Type', + 'Content-Transfer-Encoding', + 'Content-Disposition', + 'Content-Description', + ); + + // Do not break MIME headers which could be longer than 77 characters. + foreach ($mime_headers as $header) { + if (strpos($line, $header . ': ') === 0) { + $line_is_mime_header = TRUE; + break; + } + } + if (!$line_is_mime_header) { + // Use soft-breaks only for purely quoted or unindented text. + $line = wordwrap($line, 77 - $values['length'], $values['soft'] ? " \n" : "\n"); + } + // Break really long words at the maximum width allowed. + $line = wordwrap($line, 996 - $values['length'], $values['soft'] ? " \n" : "\n"); + } + + /** + * Keeps track of URLs and replaces them with placeholder tokens. + * + * Callback for preg_replace_callback() within + * \Drupal\Core\Mail\MailFormatHelper::htmlToText(). + */ + protected static function htmlToMailUrls($match = NULL, $reset = FALSE) { + // @todo Use request context instead. + global $base_url, $base_path; + + if ($reset) { + // Reset internal URL list. + static::$urls = array(); + } + else { + if (empty(static::$regexp)) { + static::$regexp = '@^' . preg_quote($base_path, '@') . '@'; + } + if ($match) { + list(, , $url, $label) = $match; + // Ensure all URLs are absolute. + static::$urls[] = strpos($url, '://') ? $url : preg_replace(static::$regexp, $base_url . '/', $url); + return $label . ' [' . count(static::$urls) . ']'; + } + } + return static::$urls; + } + + /** + * Replaces non-quotation markers from a piece of indentation with spaces. + * + * Callback for array_map() within + * \Drupal\Core\Mail\MailFormatHelper::htmlToText(). + */ + protected static function htmlToTextClean($indent) { + return preg_replace('/[^>]/', ' ', $indent); + } + + /** + * Pads the last line with the given character. + * + * @param string $text + * The text to pad. + * @param string $pad + * The character to pad the end of the string with. + * @param string $prefix + * (optional) Prefix to add to the string. + * + * @return string + * The padded string. + * + * @see \Drupal\Core\Mail\MailFormatHelper::htmlToText() + */ + protected static function htmlToTextPad($text, $pad, $prefix = '') { + // Remove last line break. + $text = substr($text, 0, -1); + // Calculate needed padding space and add it. + if (($p = strrpos($text, "\n")) === FALSE) { + $p = -1; + } + $n = max(0, 79 - (strlen($text) - $p) - strlen($prefix)); + // Add prefix and padding, and restore linebreak. + return $text . $prefix . str_repeat($pad, $n) . "\n"; + } +} diff --git a/core/lib/Drupal/Core/Mail/MailManager.php b/core/lib/Drupal/Core/Mail/MailManager.php index 0daaf3a742a..48e53cfbb44 100644 --- a/core/lib/Drupal/Core/Mail/MailManager.php +++ b/core/lib/Drupal/Core/Mail/MailManager.php @@ -7,12 +7,15 @@ namespace Drupal\Core\Mail; +use Drupal\Core\Logger\LoggerChannelFactoryInterface; use Drupal\Core\Plugin\DefaultPluginManager; use Drupal\Core\Cache\CacheBackendInterface; use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Component\Utility\String; use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\StringTranslation\TranslationInterface; /** * Provides a Mail plugin manager. @@ -21,14 +24,23 @@ use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException; * @see \Drupal\Core\Mail\MailInterface * @see plugin_api */ -class MailManager extends DefaultPluginManager { +class MailManager extends DefaultPluginManager implements MailManagerInterface { + + use StringTranslationTrait; /** - * Config object for mail system configurations. + * The config factory. * - * @var \Drupal\Core\Config\Config + * @var \Drupal\Core\Config\ConfigFactoryInterface */ - protected $mailConfig; + protected $configFactory; + + /** + * The logger factory. + * + * @var \Drupal\Core\Logger\LoggerChannelFactoryInterface + */ + protected $loggerFactory; /** * List of already instantiated mail plugins. @@ -49,12 +61,18 @@ class MailManager extends DefaultPluginManager { * The module handler to invoke the alter hook with. * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory * The configuration factory. + * @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger_factory + * The logger channel factory. + * @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation + * The string translation service. */ - public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler, ConfigFactoryInterface $config_factory) { + public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler, ConfigFactoryInterface $config_factory, LoggerChannelFactoryInterface $logger_factory, TranslationInterface $string_translation) { parent::__construct('Plugin/Mail', $namespaces, $module_handler, 'Drupal\Core\Annotation\Mail'); $this->alterInfo('mail_backend_info'); $this->setCacheBackend($cache_backend, 'mail_backend_plugins'); - $this->mailConfig = $config_factory->get('system.mail'); + $this->configFactory = $config_factory; + $this->loggerFactory = $logger_factory; + $this->stringTranslation = $string_translation; } /** @@ -116,7 +134,7 @@ class MailManager extends DefaultPluginManager { $key = $options['key']; $message_id = $module . '_' . $key; - $configuration = $this->mailConfig->get('interface'); + $configuration = $this->configFactory->get('system.mail')->get('interface'); // Look for overrides for the default mail plugin, starting from the most // specific message_id, and falling back to the module name. @@ -136,9 +154,100 @@ class MailManager extends DefaultPluginManager { $this->instances[$plugin_id] = $plugin; } else { - throw new InvalidPluginDefinitionException($plugin_id, String::format('Class %class does not implement interface %interface', array('%class' => get_class($plugin), '%interface' => 'Drupal\Core\Mail\MailInterface'))); + throw new InvalidPluginDefinitionException($plugin_id, String::format('Class %class does not implement interface %interface', array( + '%class' => get_class($plugin), + '%interface' => 'Drupal\Core\Mail\MailInterface', + ))); } } return $this->instances[$plugin_id]; } + + /** + * {@inheritdoc} + */ + public function mail($module, $key, $to, $langcode, $params = array(), $reply = NULL, $send = TRUE) { + $site_config = $this->configFactory->get('system.site'); + $site_mail = $site_config->get('mail'); + if (empty($site_mail)) { + $site_mail = ini_get('sendmail_from'); + } + + // Bundle up the variables into a structured array for altering. + $message = array( + 'id' => $module . '_' . $key, + 'module' => $module, + 'key' => $key, + 'to' => $to, + 'from' => $site_mail, + 'reply-to' => $reply, + 'langcode' => $langcode, + 'params' => $params, + 'send' => TRUE, + 'subject' => '', + 'body' => array(), + ); + + // Build the default headers. + $headers = array( + 'MIME-Version' => '1.0', + 'Content-Type' => 'text/plain; charset=UTF-8; format=flowed; delsp=yes', + 'Content-Transfer-Encoding' => '8Bit', + 'X-Mailer' => 'Drupal', + ); + // To prevent email from looking like spam, the addresses in the Sender and + // Return-Path headers should have a domain authorized to use the + // originating SMTP server. + $headers['Sender'] = $headers['Return-Path'] = $site_mail; + $headers['From'] = $site_config->get('name') . ' <' . $site_mail . '>'; + if ($reply) { + $headers['Reply-to'] = $reply; + } + $message['headers'] = $headers; + + // Build the email (get subject and body, allow additional headers) by + // invoking hook_mail() on this module. We cannot use + // moduleHandler()->invoke() as we need to have $message by reference in + // hook_mail(). + if (function_exists($function = $module . '_mail')) { + $function($key, $message, $params); + } + + // Invoke hook_mail_alter() to allow all modules to alter the resulting + // email. + $this->moduleHandler->alter('mail', $message); + + // Retrieve the responsible implementation for this message. + $system = $this->getInstance(array('module' => $module, 'key' => $key)); + + // Format the message body. + $message = $system->format($message); + + // Optionally send email. + if ($send) { + // The original caller requested sending. Sending was canceled by one or + // more hook_mail_alter() implementations. We set 'result' to NULL, + // because FALSE indicates an error in sending. + if (empty($message['send'])) { + $message['result'] = NULL; + } + // Sending was originally requested and was not canceled. + else { + $message['result'] = $system->mail($message); + // Log errors. + if (!$message['result']) { + $this->loggerFactory->get('mail') + ->error('Error sending email (from %from to %to with reply-to %reply).', array( + '%from' => $message['from'], + '%to' => $message['to'], + '%reply' => $message['reply-to'] ? $message['reply-to'] : $this->t('not set'), + )); + drupal_set_message($this->t('Unable to send email. Contact the site administrator if the problem persists.'), 'error'); + } + } + } + + return $message; + } + } diff --git a/core/lib/Drupal/Core/Mail/MailManagerInterface.php b/core/lib/Drupal/Core/Mail/MailManagerInterface.php new file mode 100644 index 00000000000..89916c1d614 --- /dev/null +++ b/core/lib/Drupal/Core/Mail/MailManagerInterface.php @@ -0,0 +1,124 @@ +mail() sends the email, which can be reused if the + * exact same composed email is to be sent to multiple recipients. + * + * Finding out what language to send the email with needs some consideration. + * If you send email to a user, her preferred language should be fine, so use + * user_preferred_langcode(). If you send email based on form values filled on + * the page, there are two additional choices if you are not sending the email + * to a user on the site. You can either use the language used to generate the + * page or the site default language. See language_default(). The former is + * good if sending email to the person filling the form, the later is good if + * you send email to an address previously set up (like contact addresses in a + * contact form). + * + * Taking care of always using the proper language is even more important when + * sending emails in a row to multiple users. Hook_mail() abstracts whether + * the mail text comes from an administrator setting or is static in the + * source code. It should also deal with common mail tokens, only receiving + * $params which are unique to the actual email at hand. + * + * An example: + * + * @code + * function example_notify($accounts) { + * foreach ($accounts as $account) { + * $params['account'] = $account; + * // example_mail() will be called based on the first \Drupal::service('plugin.manager.mail')->mail() parameter. + * \Drupal::service('plugin.manager.mail')->mail('example', 'notice', $account->mail, user_preferred_langcode($account), $params); + * } + * } + * + * function example_mail($key, &$message, $params) { + * $data['user'] = $params['account']; + * $options['langcode'] = $message['langcode']; + * user_mail_tokens($variables, $data, $options); + * switch($key) { + * case 'notice': + * // If the recipient can receive such notices by instant-message, do + * // not send by email. + * if (example_im_send($key, $message, $params)) { + * $message['send'] = FALSE; + * break; + * } + * $message['subject'] = t('Notification from !site', $variables, $options); + * $message['body'][] = t("Dear !username\n\nThere is new content available on the site.", $variables, $options); + * break; + * } + * } + * @endcode + * + * Another example, which uses \Drupal::service('plugin.manager.mail')->mail() + * to format a message for sending later: + * + * @code + * $params = array('current_conditions' => $data); + * $to = 'user@example.com'; + * $message = \Drupal::service('plugin.manager.mail')->mail('example', 'notice', $to, $langcode, $params, FALSE); + * // Only add to the spool if sending was not canceled. + * if ($message['send']) { + * example_spool_message($message); + * } + * @endcode + * + * @param string $module + * A module name to invoke hook_mail() on. The {$module}_mail() hook will be + * called to complete the $message structure which will already contain + * common defaults. + * @param string $key + * A key to identify the email sent. The final message ID for email altering + * will be {$module}_{$key}. + * @param string $to + * The email address or addresses where the message will be sent to. The + * formatting of this string will be validated with the + * @link http://php.net/manual/filter.filters.validate.php PHP email validation filter. @endlink + * Some examples are: + * - user@example.com + * - user@example.com, anotheruser@example.com + * - User + * - User , Another User + * @param string $langcode + * Language code to use to compose the email. + * @param array $params + * (optional) Parameters to build the email. + * @param string|null $reply + * Optional email address to be used to answer. + * @param bool $send + * If TRUE, \Drupal::service('plugin.manager.mail')->mail() will call + * drupal_mail_system()->mail() to deliver the message, and store the result + * in $message['result']. Modules implementing hook_mail_alter() may cancel + * sending by setting $message['send'] to FALSE. + * + * @return string + * The $message array structure containing all details of the message. If + * already sent ($send = TRUE), then the 'result' element will contain the + * success indicator of the email, failure being already written to the + * watchdog. (Success means nothing more than the message being accepted at + * php-level, which still doesn't guarantee it to be delivered.) + */ + public function mail($module, $key, $to, $langcode, $params = array(), $reply = NULL, $send = TRUE); + +} diff --git a/core/modules/system/src/Tests/Mail/WrapMailUnitTest.php b/core/tests/Drupal/Tests/Core/Mail/MailFormatHelperTest.php similarity index 61% rename from core/modules/system/src/Tests/Mail/WrapMailUnitTest.php rename to core/tests/Drupal/Tests/Core/Mail/MailFormatHelperTest.php index 08c3debffa9..f1f42585023 100644 --- a/core/modules/system/src/Tests/Mail/WrapMailUnitTest.php +++ b/core/tests/Drupal/Tests/Core/Mail/MailFormatHelperTest.php @@ -2,24 +2,24 @@ /** * @file - * Definition of Drupal\system\Tests\Mail\WrapMailUnitTest. + * Contains Drupal\Tests\Core\Mail\MailFormatHelperTest. */ -namespace Drupal\system\Tests\Mail; +namespace Drupal\Tests\Core\Mail; -use Drupal\simpletest\UnitTestBase; +use Drupal\Core\Mail\MailFormatHelper; +use Drupal\Tests\UnitTestCase; /** - * Tests drupal_wrap_mail(). - * + * @coversDefaultClass \Drupal\Core\Mail\MailFormatHelper * @group Mail */ -class WrapMailUnitTest extends UnitTestBase { +class MailFormatHelperTest extends UnitTestCase { /** * Makes sure that drupal_wrap_mail() wraps the correct types of lines. */ - function testDrupalWrapMail() { + public function testWrapMail() { $delimiter = "End of header\n"; $long_file_name = $this->randomMachineName(64) . '.docx'; $headers_in_body = 'Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document; name="' . $long_file_name . "\"\n"; @@ -27,14 +27,14 @@ class WrapMailUnitTest extends UnitTestBase { $headers_in_body .= 'Content-Disposition: attachment; filename="' . $long_file_name . "\"\n"; $headers_in_body .= 'Content-Description: ' . $this->randomMachineName(64); $body = $this->randomMachineName(76) . ' ' . $this->randomMachineName(6); - $wrapped_text = drupal_wrap_mail($headers_in_body . $delimiter . $body); + $wrapped_text = MailFormatHelper::wrapMail($headers_in_body . $delimiter . $body); list($processed_headers, $processed_body) = explode($delimiter, $wrapped_text); // Check that the body headers were not wrapped even though some exceeded // 77 characters. - $this->assertEqual($headers_in_body, $processed_headers, 'Headers in the body are not wrapped.'); + $this->assertEquals($headers_in_body, $processed_headers, 'Headers in the body are not wrapped.'); // Check that the body text is wrapped. - $this->assertEqual(wordwrap($body, 77, " \n"), $processed_body, 'Body text is wrapped.'); + $this->assertEquals(wordwrap($body, 77, " \n"), $processed_body, 'Body text is wrapped.'); } -} +} diff --git a/core/tests/Drupal/Tests/Core/Mail/MailManagerTest.php b/core/tests/Drupal/Tests/Core/Mail/MailManagerTest.php index b40db1dcee4..7d19b8a0a48 100644 --- a/core/tests/Drupal/Tests/Core/Mail/MailManagerTest.php +++ b/core/tests/Drupal/Tests/Core/Mail/MailManagerTest.php @@ -44,6 +44,13 @@ class MailManagerTest extends UnitTestCase { */ protected $discovery; + /** + * The mail manager under test. + * + * @var \Drupal\Tests\Core\Mail\TestMailManager + */ + protected $mailManager; + /** * A list of mail plugin definitions. * @@ -82,11 +89,15 @@ class MailManagerTest extends UnitTestCase { */ protected function setUpMailManager($interface = array()) { // Use the provided config for system.mail.interface settings. - $this->configFactory = $this->getConfigFactoryStub(array('system.mail' => array( - 'interface' => $interface, - ))); + $this->configFactory = $this->getConfigFactoryStub(array( + 'system.mail' => array( + 'interface' => $interface, + ), + )); + $logger_factory = $this->getMock('\Drupal\Core\Logger\LoggerChannelFactoryInterface'); + $string_translation = $this->getStringTranslationStub(); // Construct the manager object and override its discovery. - $this->mailManager = new TestMailManager(new \ArrayObject(), $this->cache, $this->moduleHandler, $this->configFactory); + $this->mailManager = new TestMailManager(new \ArrayObject(), $this->cache, $this->moduleHandler, $this->configFactory, $logger_factory, $string_translation); $this->mailManager->setDiscovery($this->discovery); }