mail.inc 18.9 KB
Newer Older
1
2
<?php

3
4
5
6
7
/**
 * @file
 * API functions for processing and sending e-mail.
 */

8
use Drupal\Component\Utility\Html;
9
use Drupal\Component\Utility\Settings;
10
11
use Drupal\Component\Utility\Xss;

12
/**
13
 * Composes and optionally sends an e-mail message.
14
15
16
17
18
19
 *
 * Sending an e-mail works with defining an e-mail template (subject, text
 * and possibly e-mail headers) and the replacement values to use in the
 * appropriate places in the template. Processed e-mail templates are
 * requested from hook_mail() from the module sending the e-mail. Any module
 * can modify the composed e-mail message array using hook_mail_alter().
20
 * Finally drupal_mail_system()->mail() sends the e-mail, which can
21
22
 * be reused if the exact same composed e-mail is to be sent to multiple
 * recipients.
23
24
25
 *
 * Finding out what language to send the e-mail with needs some consideration.
 * If you send e-mail to a user, her preferred language should be fine, so
26
 * use user_preferred_langcode(). If you send email based on form values
27
28
 * filled on the page, there are two additional choices if you are not
 * sending the e-mail to a user on the site. You can either use the language
29
30
31
32
 * used to generate the page or the site default language. See
 * language_default(). The former is good if sending e-mail to the person
 * filling the form, the later is good if you send e-mail to an address
 * previously set up (like contact addresses in a contact form).
33
34
35
36
37
38
39
40
41
42
43
44
45
46
 *
 * Taking care of always using the proper language is even more important
 * when sending e-mails 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 e-mail 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_mail() parameter.
47
 *       drupal_mail('example', 'notice', $account->mail, user_preferred_langcode($account), $params);
48
49
 *     }
 *   }
Dries's avatar
Dries committed
50
 *
51
 *   function example_mail($key, &$message, $params) {
52
 *     $data['user'] = $params['account'];
53
 *     $options['langcode'] = $message['langcode'];
54
 *     user_mail_tokens($variables, $data, $options);
55
56
 *     switch($key) {
 *       case 'notice':
57
58
59
60
61
62
 *         // 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;
 *         }
63
64
 *         $message['subject'] = t('Notification from !site', $variables, $options);
 *         $message['body'][] = t("Dear !username\n\nThere is new content available on the site.", $variables, $options);
65
66
67
68
 *         break;
 *     }
 *   }
 * @endcode
69
 *
70
71
72
73
74
75
 * Another example, which uses drupal_mail() to format a message for sending
 * later:
 *
 * @code
 *   $params = array('current_conditions' => $data);
 *   $to = 'user@example.com';
76
 *   $message = drupal_mail('example', 'notice', $to, $langcode, $params, FALSE);
77
78
79
80
81
82
 *   // Only add to the spool if sending was not canceled.
 *   if ($message['send']) {
 *     example_spool_message($message);
 *   }
 * @endcode
 *
83
 * @param string $module
84
85
86
 *   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.
87
 * @param string $key
88
 *   A key to identify the e-mail sent. The final message ID for e-mail altering
89
 *   will be {$module}_{$key}.
90
 * @param string $to
91
 *   The e-mail address or addresses where the message will be sent to. The
92
93
94
 *   formatting of this string will be validated with the
 *   @link http://php.net/manual/filter.filters.validate.php PHP e-mail validation filter. @endlink
 *   Some examples are:
95
96
97
98
 *   - user@example.com
 *   - user@example.com, anotheruser@example.com
 *   - User <user@example.com>
 *   - User <user@example.com>, Another User <anotheruser@example.com>
99
 * @param string $langcode
100
 *   Language code to use to compose the e-mail.
101
102
 * @param array $params
 *   (optional) Parameters to build the e-mail.
103
104
 * @param string|null $reply
 *   Optional e-mail address to be used to answer.
105
 * @param bool $send
106
107
108
109
 *   If TRUE, drupal_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.
110
 *
111
112
113
 * @return
 *   The $message array structure containing all details of the
 *   message. If already sent ($send = TRUE), then the 'result' element
114
115
116
 *   will contain the success indicator of the e-mail, 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.)
117
 */
118
function drupal_mail($module, $key, $to, $langcode, $params = array(), $reply = NULL, $send = TRUE) {
119
  $site_config = \Drupal::config('system.site');
120
  $site_mail = $site_config->get('mail');
121
122
123
  if (empty($site_mail)) {
    $site_mail = ini_get('sendmail_from');
  }
Dries's avatar
Dries committed
124

125
126
  // Bundle up the variables into a structured array for altering.
  $message = array(
127
    'id'       => $module . '_' . $key,
128
129
    'module'   => $module,
    'key'      => $key,
Dries's avatar
Dries committed
130
    'to'       => $to,
131
132
    'from'     => $site_mail,
    'reply-to' => $reply,
133
    'langcode' => $langcode,
134
    'params'   => $params,
135
    'send'     => TRUE,
136
137
138
139
140
141
142
143
    'subject'  => '',
    'body'     => array()
  );

  // Build the default headers
  $headers = array(
    'MIME-Version'              => '1.0',
    'Content-Type'              => 'text/plain; charset=UTF-8; format=flowed; delsp=yes',
144
    'Content-Transfer-Encoding' => '8Bit',
145
    'X-Mailer'                  => 'Drupal'
146
  );
147
148
149
150
151
152
153
  // To prevent e-mail 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;
154
  }
155
  $message['headers'] = $headers;
Dries's avatar
Dries committed
156

157
  // Build the e-mail (get subject and body, allow additional headers) by
158
159
160
  // invoking hook_mail() on this module. We cannot use
  // moduleHandler()->invoke() as we need to have $message by reference in
  // hook_mail().
161
  if (function_exists($function = $module . '_mail')) {
162
163
    $function($key, $message, $params);
  }
Dries's avatar
Dries committed
164

165
  // Invoke hook_mail_alter() to allow all modules to alter the resulting e-mail.
166
  \Drupal::moduleHandler()->alter('mail', $message);
167

168
169
170
171
172
  // Retrieve the responsible implementation for this message.
  $system = drupal_mail_system($module, $key);

  // Format the message body.
  $message = $system->format($message);
173
174
175

  // Optionally send e-mail.
  if ($send) {
176
177
178
179
180
181
182
183
184
185
186
    // 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']) {
187
        watchdog('mail', 'Error sending e-mail (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')), WATCHDOG_ERROR);
188
189
        drupal_set_message(t('Unable to send e-mail. Contact the site administrator if the problem persists.'), 'error');
      }
190
    }
191
192
193
194
195
196
  }

  return $message;
}

/**
197
 * Returns an instance of the mail plugin to use for a given message ID.
198
 *
199
 * @param string $module
200
 *   The module name which was used by drupal_mail() to invoke hook_mail().
201
 * @param string $key
202
 *   A key to identify the e-mail sent. The final message ID for the e-mail
203
 *   alter hook in drupal_mail() would have been {$module}_{$key}.
204
 *
205
 * @return \Drupal\Core\Mail\MailInterface
206
 *   A mail plugin instance.
207
 *
208
 * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
209
 *
210
 * @see \Drupal\Core\Mail\MailManager::getInstance()
211
 */
212
function drupal_mail_system($module, $key) {
213
  return \Drupal::service('plugin.manager.mail')->getInstance(array('module' => $module, 'key' => $key));
214
215
}

216
/**
217
 * Performs format=flowed soft wrapping for mail (RFC 3676).
218
219
220
221
222
223
224
225
226
227
228
 *
 * We use delsp=yes wrapping, but only break non-spaced languages when
 * absolutely necessary to avoid compatibility issues.
 *
 * We deliberately use LF rather than CRLF, see drupal_mail().
 *
 * @param $text
 *   The plain text to process.
 * @param $indent (optional)
 *   A string to indent the text with. Only '>' characters are repeated on
 *   subsequent wrapped lines. Others are replaced by spaces.
229
230
231
 *
 * @return
 *   The content of the email as a string with formatting applied.
232
233
234
235
236
237
238
239
240
 */
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) {
241
242
243
    // Remove trailing spaces to make existing breaks hard, but leave signature
    // marker untouched (RFC 3676, Section 4.3).
    $text = preg_replace('/(?(?<!^--) +\n|  +\n)/m', "\n", $text);
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
    // Wrap each line at the needed width.
    $lines = explode("\n", $text);
    array_walk($lines, '_drupal_wrap_mail_line', array('soft' => $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;
}

/**
264
 * Transforms an HTML string into plain text, preserving its structure.
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
 *
 * The output will be suitable for use as 'format=flowed; delsp=yes' text
 * (RFC 3676) and can be passed directly to drupal_mail() for sending.
 *
 * We deliberately use LF rather than CRLF, see drupal_mail().
 *
 * This function provides suitable alternatives for the following tags:
 * <a> <em> <i> <strong> <b> <br> <p> <blockquote> <ul> <ol> <li> <dl> <dt>
 * <dd> <h1> <h2> <h3> <h4> <h5> <h6> <hr>
 *
 * @param $string
 *   The string to be transformed.
 * @param $allowed_tags (optional)
 *   If supplied, a list of tags that will be transformed. If omitted, all
 *   all supported tags are transformed.
280
 *
281
282
283
284
285
286
287
288
289
290
291
292
293
294
 * @return
 *   The transformed string.
 */
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.
295
  $string = Html::normalize(Xss::filter($string, $allowed_tags));
296
297

  // Apply inline styles.
298
299
  $string = preg_replace('!</?(em|i)((?> +)[^>]*)?>!i', '/', $string);
  $string = preg_replace('!</?(strong|b)((?> +)[^>]*)?>!i', '*', $string);
300
301
302
303

  // Replace inline <a> tags with the text of link and a footnote.
  // 'See <a href="http://drupal.org">the Drupal site</a>' becomes
  // 'See the Drupal site [1]' with the URL included as a footnote.
304
  _drupal_html_to_mail_urls(NULL, TRUE);
305
  $pattern = '@(<a[^>]+?href="([^"]*)"[^>]*?>(.+?)</a>)@i';
306
307
308
309
310
311
  $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++) {
312
      $footnotes .= '[' . ($i + 1) . '] ' . $urls[$i] . "\n";
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
    }
  }

  // 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':
352
          $indent[] = isset($lists[0]) && is_numeric($lists[0]) ? ' ' . $lists[0]++ . ') ' : ' * ';
353
354
355
356
357
358
359
360
361
362
363
364
365
          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).
366
            $output = rtrim($output, "> \n") . "\"\n";
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
            $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.
403
          $output .= drupal_wrap_mail('', implode('', $indent)) . "\n";
404
405
406
407
408
409
410
411
412
413
414
415
          $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 {
416
417
418
419
      // 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)) {
420
421
422
423
424
425
426
427
428
429
        $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);
      }
430
      $line_endings = Settings::get('mail_line_endings', PHP_EOL);
431
      // Format it and apply the current indentation.
432
      $output .= drupal_wrap_mail($chunk, implode('', $indent)) . $line_endings;
433
434
435
      // Remove non-quotation markers from indentation.
      $indent = array_map('_drupal_html_to_text_clean', $indent);
    }
436

437
438
439
440
441
442
443
444
    $tag = !$tag;
  }

  return $output . $footnotes;
}

/**
 * Wraps words on a single line.
445
446
 *
 * Callback for array_walk() winthin drupal_wrap_mail().
447
448
449
450
451
452
453
454
455
 *
 * 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 e-mail 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"
456
457
 */
function _drupal_wrap_mail_line(&$line, $key, $values) {
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
  $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.
475
    $line = wordwrap($line, 77 - $values['length'], $values['soft'] ? " \n" : "\n");
476
  }
477
478
479
480
481
482
  // 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.
483
484
 *
 * Callback for preg_replace_callback() within drupal_html_to_text().
485
 */
486
function _drupal_html_to_mail_urls($match = NULL, $reset = FALSE) {
487
488
  global $base_url, $base_path;
  static $urls = array(), $regexp;
489

490
491
492
  if ($reset) {
    // Reset internal URL list.
    $urls = array();
493
  }
494
495
  else {
    if (empty($regexp)) {
496
      $regexp = '@^' . preg_quote($base_path, '@') . '@';
497
498
499
500
    }
    if ($match) {
      list(, , $url, $label) = $match;
      // Ensure all URLs are absolute.
501
502
      $urls[] = strpos($url, '://') ? $url : preg_replace($regexp, $base_url . '/', $url);
      return $label . ' [' . count($urls) . ']';
503
    }
504
505
506
507
508
  }
  return $urls;
}

/**
509
 * Replaces non-quotation markers from a given piece of indentation with spaces.
510
 *
511
 * Callback for array_map() within drupal_html_to_text().
512
513
514
515
516
517
 */
function _drupal_html_to_text_clean($indent) {
  return preg_replace('/[^>]/', ' ', $indent);
}

/**
518
 * Pads the last line with the given character.
519
 *
520
 * @see drupal_html_to_text()
521
522
523
524
525
526
527
528
 */
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;
  }
529
  $n = max(0, 79 - (strlen($text) - $p) - strlen($prefix));
530
  // Add prefix and padding, and restore linebreak.
531
  return $text . $prefix . str_repeat($pad, $n) . "\n";
532
}