mail.inc 21.1 KB
Newer Older
1
2
3
<?php
// $Id$

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

9
10
/**
 * Auto-detect appropriate line endings for e-mails.
11
 *
12
13
14
15
 * $conf['mail_line_endings'] will override this setting.
 */
define('MAIL_LINE_ENDINGS', isset($_SERVER['WINDIR']) || strpos($_SERVER['SERVER_SOFTWARE'], 'Win32') !== FALSE ? "\r\n" : "\n");

16
/**
17
18
19
20
21
22
23
 * Compose and optionally send an e-mail message.
 *
 * 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().
24
 * Finally drupal_mail_system()->mail() sends the e-mail, which can
25
26
 * be reused if the exact same composed e-mail is to be sent to multiple
 * recipients.
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
 *
 * 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
 * use user_preferred_language(). If you send email based on form values
 * 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
 * used to generate the page ($language global variable) 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).
 *
 * 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.
51
 *       drupal_mail('example', 'notice', $account->mail, user_preferred_language($account), $params);
52
53
 *     }
 *   }
Dries's avatar
Dries committed
54
 *
55
56
57
58
59
60
 *   function example_mail($key, &$message, $params) {
 *     $language = $message['language'];
 *     $variables = user_mail_tokens($params['account'], $language);
 *     switch($key) {
 *       case 'notice':
 *         $message['subject'] = t('Notification from !site', $variables, $language->language);
61
 *         $message['body'][] = t("Dear !username\n\nThere is new content available on the site.", $variables, $language->language);
62
63
64
65
 *         break;
 *     }
 *   }
 * @endcode
66
 *
67
68
69
70
71
72
73
 * @param $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 $key
 *   A key to identify the e-mail sent. The final e-mail id for e-mail altering
 *   will be {$module}_{$key}.
74
 * @param $to
75
 *   The e-mail address or addresses where the message will be sent to. The
76
 *   formatting of this string must comply with RFC 2822. Some examples are:
77
78
79
80
 *   - user@example.com
 *   - user@example.com, anotheruser@example.com
 *   - User <user@example.com>
 *   - User <user@example.com>, Another User <anotheruser@example.com>
81
82
83
84
 * @param $language
 *   Language object to use to compose the e-mail.
 * @param $params
 *   Optional parameters to build the e-mail.
85
 * @param $from
86
 *   Sets From to this value, if given.
87
 * @param $send
88
89
 *   Send the message directly, without calling drupal_mail_system()->mail()
 *   manually.
90
 *
91
92
93
 * @return
 *   The $message array structure containing all details of the
 *   message. If already sent ($send = TRUE), then the 'result' element
94
95
96
 *   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.)
97
 */
98
99
function drupal_mail($module, $key, $to, $language, $params = array(), $from = NULL, $send = TRUE) {
  $default_from = variable_get('site_mail', ini_get('sendmail_from'));
Dries's avatar
Dries committed
100

101
102
  // Bundle up the variables into a structured array for altering.
  $message = array(
103
    'id'       => $module . '_' . $key,
104
105
    'module'   => $module,
    'key'      => $key,
Dries's avatar
Dries committed
106
    'to'       => $to,
107
108
109
110
111
112
113
114
115
116
117
    'from'     => isset($from) ? $from : $default_from,
    'language' => $language,
    'params'   => $params,
    'subject'  => '',
    'body'     => array()
  );

  // Build the default headers
  $headers = array(
    'MIME-Version'              => '1.0',
    'Content-Type'              => 'text/plain; charset=UTF-8; format=flowed; delsp=yes',
118
    'Content-Transfer-Encoding' => '8Bit',
119
    'X-Mailer'                  => 'Drupal'
120
121
  );
  if ($default_from) {
122
123
124
    // 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. Errors-To is redundant, but shouldn't hurt.
125
    $headers['From'] = $headers['Sender'] = $headers['Return-Path'] = $headers['Errors-To'] = $default_from;
126
127
  }
  if ($from) {
128
    $headers['From'] = $from;
129
  }
130
  $message['headers'] = $headers;
Dries's avatar
Dries committed
131

132
133
134
  // Build the e-mail (get subject and body, allow additional headers) by
  // invoking hook_mail() on this module. We cannot use module_invoke() as
  // we need to have $message by reference in hook_mail().
135
  if (function_exists($function = $module . '_mail')) {
136
137
    $function($key, $message, $params);
  }
Dries's avatar
Dries committed
138

139
  // Invoke hook_mail_alter() to allow all modules to alter the resulting e-mail.
140
141
  drupal_alter('mail', $message);

142
143
144
145
146
  // Retrieve the responsible implementation for this message.
  $system = drupal_mail_system($module, $key);

  // Format the message body.
  $message = $system->format($message);
147
148
149

  // Optionally send e-mail.
  if ($send) {
150
    $message['result'] = $system->mail($message);
151
152
153
154

    // Log errors
    if (!$message['result']) {
      watchdog('mail', 'Error sending e-mail (from %from to %to).', array('%from' => $message['from'], '%to' => $message['to']), WATCHDOG_ERROR);
155
      drupal_set_message(t('Unable to send e-mail. Contact the site administrator if the problem persists.'), 'error');
156
    }
157
158
159
160
161
162
  }

  return $message;
}

/**
163
164
 * Returns an object that implements the MailSystemInterface.
 *
165
 * Allows for one or more custom mail backends to format and send mail messages
166
167
 * composed using drupal_mail().
 *
168
169
170
171
172
173
174
175
176
177
178
179
 * An implementation needs to implement the following methods:
 * - format: Allows to preprocess, format, and postprocess a mail
 *   message before it is passed to the sending system. By default, all messages
 *   may contain HTML and are converted to plain-text by the DefaultMailSystem
 *   implementation. For example, an alternative implementation could override
 *   the default implementation and additionally sanitize the HTML for usage in
 *   a MIME-encoded e-mail, but still invoking the DefaultMailSystem
 *   implementation to generate an alternate plain-text version for sending.
 * - mail: Sends a message through a custom mail sending engine.
 *   By default, all messages are sent via PHP's mail() function by the
 *   DefaultMailSystem implementation.
 *
180
 * The selection of a particular implementation is controlled via the variable
181
 * 'mail_system', which is a keyed array.  The default implementation
182
183
184
185
186
 * is the class whose name is the value of 'default-system' key. A more specific
 * match first to key and then to module will be used in preference to the
 * default. To specificy a different class for all mail sent by one module, set
 * the class name as the value for the key corresponding to the module name. To
 * specificy a class for a particular message sent by one module, set the class
187
 * name as the value for the array key that is the message id, which is
188
189
190
191
192
193
194
 * "${module}_${key}".
 *
 * For example to debug all mail sent by the user module by logging it to a
 * file, you might set the variable as something like:
 *
 * @code
 * array(
195
 *   'default-system' => 'DefaultMailSystem',
196
197
198
199
200
201
202
203
204
 *   'user' => 'DevelMailLog',
 * );
 * @endcode
 *
 * Finally, a different system can be specified for a specific e-mail ID (see
 * the $key param), such as one of the keys used by the contact module:
 *
 * @code
 * array(
205
 *   'default-system' => 'DefaultMailSystem',
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
 *   'user' => 'DevelMailLog',
 *   'contact_page_autoreply' => 'DrupalDevNullMailSend',
 * );
 * @endcode
 *
 * Other possible uses for system include a mail-sending class that actually
 * sends (or duplicates) each message to SMS, Twitter, instant message, etc, or
 * a class that queues up a large number of messages for more efficient bulk
 * sending or for sending via a remote gateway so as to reduce the load
 * on the local server.
 *
 * @param $module
 *   The module name which was used by drupal_mail() to invoke hook_mail().
 * @param $key
 *   A key to identify the e-mail sent. The final e-mail ID for the e-mail
 *   alter hook in drupal_mail() would have been {$module}_{$key}.
222
 *
223
 * @return MailSystemInterface
224
 */
225
function drupal_mail_system($module, $key) {
226
227
228
  $instances = &drupal_static(__FUNCTION__, array());

  $id = $module . '_' . $key;
229
230

  $configuration = variable_get('mail_system', array('default-system' => 'DefaultMailSystem'));
231
232
233
234
235
236
237
238

  // Look for overrides for the default class, starting from the most specific
  // id, and falling back to the module name.
  if (isset($configuration[$id])) {
    $class = $configuration[$id];
  }
  elseif (isset($configuration[$module])) {
    $class = $configuration[$module];
239
240
  }
  else {
241
242
243
244
245
246
    $class = $configuration['default-system'];
  }

  if (empty($instances[$class])) {
    $interfaces = class_implements($class);
    if (isset($interfaces['MailSystemInterface'])) {
247
      $instances[$class] = new $class();
248
249
250
    }
    else {
      throw new Exception(t('Class %class does not implement interface %interface', array('%class' => $class, '%interface' => 'MailSystemInterface')));
251
252
    }
  }
253
254
255
256
257
258
259
260
  return $instances[$class];
}

/**
 * An interface for pluggable mail back-ends.
 */
interface MailSystemInterface {
  /**
261
262
263
264
265
266
267
268
269
270
271
272
   * Format a message composed by drupal_mail() prior sending.
   *
   * @param $message
   *   A message array, as described in hook_mail_alter().
   *
   * @return
   *   The formatted $message.
   */
   public function format(array $message);

  /**
   * Send a message composed by drupal_mail().
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
   *
   * @param $message
   *   Message array with at least the following elements:
   *   - id: A unique identifier of the e-mail type. Examples: 'contact_user_copy',
   *     'user_password_reset'.
   *   - to: The mail address or addresses where the message will be sent to.
   *     The formatting of this string must comply with RFC 2822. Some examples:
   *     - user@example.com
   *     - user@example.com, anotheruser@example.com
   *     - User <user@example.com>
   *     - User <user@example.com>, Another User <anotheruser@example.com>
   *    - subject: Subject of the e-mail to be sent. This must not contain any
   *      newline characters, or the mail may not be sent properly.
   *    - body: Message to be sent. Accepts both CRLF and LF line-endings.
   *      E-mail bodies must be wrapped. You can use drupal_wrap_mail() for
   *      smart plain text wrapping.
   *    - headers: Associative array containing all additional mail headers not
   *      defined by one of the other parameters.  PHP's mail() looks for Cc
   *      and Bcc headers and sends the mail to addresses in these headers too.
292
   *
293
294
295
296
   * @return
   *   TRUE if the mail was successfully accepted for delivery, otherwise FALSE.
   */
   public function mail(array $message);
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
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
}

/**
 * Perform format=flowed soft wrapping for mail (RFC 3676).
 *
 * 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.
 */
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.
    $text = preg_replace('/ +\n/m', "\n", $text);
    // 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;
}

/**
 * Transform an HTML string into plain text, preserving the structure of the
344
 * markup. Useful for preparing the body of a node to be sent by e-mail.
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
 *
 * 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.
360
 *
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
 * @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.
  $string = _filter_htmlcorrector(filter_xss($string, $allowed_tags));

  // Apply inline styles.
378
379
  $string = preg_replace('!</?(em|i)((?> +)[^>]*)?>!i', '/', $string);
  $string = preg_replace('!</?(strong|b)((?> +)[^>]*)?>!i', '*', $string);
380
381
382
383

  // 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.
384
  _drupal_html_to_mail_urls(NULL, TRUE);
385
  $pattern = '@(<a[^>]+?href="([^"]*)"[^>]*?>(.+?)</a>)@i';
386
387
388
389
390
391
  $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++) {
392
      $footnotes .= '[' . ($i + 1) . '] ' . $urls[$i] . "\n";
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
    }
  }

  // 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':
432
          $indent[] = is_numeric($lists[0]) ? ' ' . $lists[0]++ . ') ' : ' * ';
433
434
435
436
437
438
439
440
441
442
443
444
445
          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).
446
            $output = rtrim($output, "> \n") . "\"\n";
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
            $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.
483
          $output .= drupal_wrap_mail('', implode('', $indent)) . "\n";
484
485
486
487
488
489
490
491
492
493
494
495
          $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 {
496
497
498
499
      // 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)) {
500
501
502
503
504
505
506
507
508
509
510
        $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);
      }
      // Format it and apply the current indentation.
511
      $output .= drupal_wrap_mail($chunk, implode('', $indent));
512
513
514
      // Remove non-quotation markers from indentation.
      $indent = array_map('_drupal_html_to_text_clean', $indent);
    }
515

516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
    $tag = !$tag;
  }

  return $output . $footnotes;
}

/**
 * Helper function for array_walk in drupal_wrap_mail().
 *
 * Wraps words on a single line.
 */
function _drupal_wrap_mail_line(&$line, $key, $values) {
  // 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");
}

/**
 * Helper function for drupal_html_to_text().
 *
 * Keeps track of URLs and replaces them with placeholder tokens.
 */
539
function _drupal_html_to_mail_urls($match = NULL, $reset = FALSE) {
540
541
  global $base_url, $base_path;
  static $urls = array(), $regexp;
542

543
544
545
  if ($reset) {
    // Reset internal URL list.
    $urls = array();
546
  }
547
548
  else {
    if (empty($regexp)) {
549
      $regexp = '@^' . preg_quote($base_path, '@') . '@';
550
551
552
553
    }
    if ($match) {
      list(, , $url, $label) = $match;
      // Ensure all URLs are absolute.
554
555
      $urls[] = strpos($url, '://') ? $url : preg_replace($regexp, $base_url . '/', $url);
      return $label . ' [' . count($urls) . ']';
556
    }
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
  }
  return $urls;
}

/**
 * Helper function for drupal_wrap_mail() and drupal_html_to_text().
 *
 * Replace all non-quotation markers from a given piece of indentation with spaces.
 */
function _drupal_html_to_text_clean($indent) {
  return preg_replace('/[^>]/', ' ', $indent);
}

/**
 * Helper function for drupal_html_to_text().
 *
 * Pad the last line with the given character.
 */
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;
  }
582
  $n = max(0, 79 - (strlen($text) - $p) - strlen($prefix));
583
  // Add prefix and padding, and restore linebreak.
584
  return $text . $prefix . str_repeat($pad, $n) . "\n";
585
}