MediaWiki  master
UserMailer.php
Go to the documentation of this file.
1 <?php
30 class UserMailer {
31  private static $mErrorString;
32 
43  protected static function sendWithPear( $mailer, $dest, $headers, $body ) {
44  $mailResult = $mailer->send( $dest, $headers, $body );
45 
46  // Based on the result return an error string,
47  if ( PEAR::isError( $mailResult ) ) {
48  wfDebug( "PEAR::Mail failed: " . $mailResult->getMessage() . "\n" );
49  return Status::newFatal( 'pear-mail-error', $mailResult->getMessage() );
50  } else {
51  return Status::newGood();
52  }
53  }
54 
67  private static function arrayToHeaderString( $headers, $endl = PHP_EOL ) {
68  $strings = [];
69  foreach ( $headers as $name => $value ) {
70  // Prevent header injection by stripping newlines from value
71  $value = self::sanitizeHeaderValue( $value );
72  $strings[] = "$name: $value";
73  }
74  return implode( $endl, $strings );
75  }
76 
82  private static function makeMsgId() {
83  global $wgSMTP, $wgServer;
84 
85  $domainId = WikiMap::getCurrentWikiDbDomain()->getId();
86  $msgid = uniqid( $domainId . ".", true );
87  if ( is_array( $wgSMTP ) && isset( $wgSMTP['IDHost'] ) && $wgSMTP['IDHost'] ) {
88  $domain = $wgSMTP['IDHost'];
89  } else {
90  $url = wfParseUrl( $wgServer );
91  $domain = $url['host'];
92  }
93  return "<$msgid@$domain>";
94  }
95 
115  public static function send( $to, $from, $subject, $body, $options = [] ) {
116  global $wgAllowHTMLEmail;
117 
118  if ( !isset( $options['contentType'] ) ) {
119  $options['contentType'] = 'text/plain; charset=UTF-8';
120  }
121 
122  if ( !is_array( $to ) ) {
123  $to = [ $to ];
124  }
125 
126  // mail body must have some content
127  $minBodyLen = 10;
128  // arbitrary but longer than Array or Object to detect casting error
129 
130  // body must either be a string or an array with text and body
131  if (
132  !(
133  !is_array( $body ) &&
134  strlen( $body ) >= $minBodyLen
135  )
136  &&
137  !(
138  is_array( $body ) &&
139  isset( $body['text'] ) &&
140  isset( $body['html'] ) &&
141  strlen( $body['text'] ) >= $minBodyLen &&
142  strlen( $body['html'] ) >= $minBodyLen
143  )
144  ) {
145  // if it is neither we have a problem
146  return Status::newFatal( 'user-mail-no-body' );
147  }
148 
149  if ( !$wgAllowHTMLEmail && is_array( $body ) ) {
150  // HTML not wanted. Dump it.
151  $body = $body['text'];
152  }
153 
154  wfDebug( __METHOD__ . ': sending mail to ' . implode( ', ', $to ) . "\n" );
155 
156  // Make sure we have at least one address
157  $has_address = false;
158  foreach ( $to as $u ) {
159  if ( $u->address ) {
160  $has_address = true;
161  break;
162  }
163  }
164  if ( !$has_address ) {
165  return Status::newFatal( 'user-mail-no-addy' );
166  }
167 
168  // give a chance to UserMailerTransformContents subscribers who need to deal with each
169  // target differently to split up the address list
170  if ( count( $to ) > 1 ) {
171  $oldTo = $to;
172  Hooks::run( 'UserMailerSplitTo', [ &$to ] );
173  if ( $oldTo != $to ) {
174  $splitTo = array_diff( $oldTo, $to );
175  $to = array_diff( $oldTo, $splitTo ); // ignore new addresses added in the hook
176  // first send to non-split address list, then to split addresses one by one
177  $status = Status::newGood();
178  if ( $to ) {
179  $status->merge( self::sendInternal(
180  $to, $from, $subject, $body, $options ) );
181  }
182  foreach ( $splitTo as $newTo ) {
183  $status->merge( self::sendInternal(
184  [ $newTo ], $from, $subject, $body, $options ) );
185  }
186  return $status;
187  }
188  }
189 
190  return self::sendInternal( $to, $from, $subject, $body, $options );
191  }
192 
199  private static function isMailMimeUsable() {
200  static $usable = null;
201  if ( $usable === null ) {
202  $usable = class_exists( 'Mail_mime' );
203  }
204  return $usable;
205  }
206 
213  private static function isMailUsable() {
214  static $usable = null;
215  if ( $usable === null ) {
216  $usable = class_exists( 'Mail' );
217  }
218 
219  return $usable;
220  }
221 
238  protected static function sendInternal(
239  array $to,
240  MailAddress $from,
241  $subject,
242  $body,
243  $options = []
244  ) {
246  $mime = null;
247 
248  $replyto = $options['replyTo'] ?? null;
249  $contentType = $options['contentType'] ?? 'text/plain; charset=UTF-8';
250  $headers = $options['headers'] ?? [];
251 
252  // Allow transformation of content, such as encrypting/signing
253  $error = false;
254  if ( !Hooks::run( 'UserMailerTransformContent', [ $to, $from, &$body, &$error ] ) ) {
255  if ( $error ) {
256  return Status::newFatal( 'php-mail-error', $error );
257  } else {
258  return Status::newFatal( 'php-mail-error-unknown' );
259  }
260  }
261 
291  $headers['From'] = $from->toString();
292  $returnPath = $from->address;
293  $extraParams = $wgAdditionalMailParams;
294 
295  // Hook to generate custom VERP address for 'Return-Path'
296  Hooks::run( 'UserMailerChangeReturnPath', [ $to, &$returnPath ] );
297  // Add the envelope sender address using the -f command line option when PHP mail() is used.
298  // Will default to the $from->address when the UserMailerChangeReturnPath hook fails and the
299  // generated VERP address when the hook runs effectively.
300 
301  // PHP runs this through escapeshellcmd(). However that's not sufficient
302  // escaping (e.g. due to spaces). MediaWiki's email sanitizer should generally
303  // be good enough, but just in case, put in double quotes, and remove any
304  // double quotes present (" is not allowed in emails, so should have no
305  // effect, although this might cause apostrophees to be double escaped)
306  $returnPathCLI = '"' . str_replace( '"', '', $returnPath ) . '"';
307  $extraParams .= ' -f ' . $returnPathCLI;
308 
309  $headers['Return-Path'] = $returnPath;
310 
311  if ( $replyto ) {
312  $headers['Reply-To'] = $replyto->toString();
313  }
314 
315  $headers['Date'] = MWTimestamp::getLocalInstance()->format( 'r' );
316  $headers['Message-ID'] = self::makeMsgId();
317  $headers['X-Mailer'] = 'MediaWiki mailer';
318  $headers['List-Unsubscribe'] = '<' . SpecialPage::getTitleFor( 'Preferences' )
319  ->getFullURL( '', false, PROTO_CANONICAL ) . '>';
320 
321  // Line endings need to be different on Unix and Windows due to
322  // the bug described at https://core.trac.wordpress.org/ticket/2603
323  $endl = PHP_EOL;
324 
325  if ( is_array( $body ) ) {
326  // we are sending a multipart message
327  wfDebug( "Assembling multipart mime email\n" );
328  if ( !self::isMailMimeUsable() ) {
329  wfDebug( "PEAR Mail_Mime package is not installed. Falling back to text email.\n" );
330  // remove the html body for text email fall back
331  $body = $body['text'];
332  } else {
333  // pear/mail_mime is already loaded by this point
334  if ( wfIsWindows() ) {
335  $body['text'] = str_replace( "\n", "\r\n", $body['text'] );
336  $body['html'] = str_replace( "\n", "\r\n", $body['html'] );
337  }
338  $mime = new Mail_mime( [
339  'eol' => $endl,
340  'text_charset' => 'UTF-8',
341  'html_charset' => 'UTF-8'
342  ] );
343  $mime->setTXTBody( $body['text'] );
344  $mime->setHTMLBody( $body['html'] );
345  $body = $mime->get(); // must call get() before headers()
346  $headers = $mime->headers( $headers );
347  }
348  }
349  if ( $mime === null ) {
350  // sending text only, either deliberately or as a fallback
351  if ( wfIsWindows() ) {
352  $body = str_replace( "\n", "\r\n", $body );
353  }
354  $headers['MIME-Version'] = '1.0';
355  $headers['Content-type'] = $contentType;
356  $headers['Content-transfer-encoding'] = '8bit';
357  }
358 
359  // allow transformation of MIME-encoded message
360  if ( !Hooks::run( 'UserMailerTransformMessage',
361  [ $to, $from, &$subject, &$headers, &$body, &$error ] )
362  ) {
363  if ( $error ) {
364  return Status::newFatal( 'php-mail-error', $error );
365  } else {
366  return Status::newFatal( 'php-mail-error-unknown' );
367  }
368  }
369 
370  $ret = Hooks::run( 'AlternateUserMailer', [ $headers, $to, $from, $subject, $body ] );
371  if ( $ret === false ) {
372  // the hook implementation will return false to skip regular mail sending
373  return Status::newGood();
374  } elseif ( $ret !== true ) {
375  // the hook implementation will return a string to pass an error message
376  return Status::newFatal( 'php-mail-error', $ret );
377  }
378 
379  if ( is_array( $wgSMTP ) ) {
380  // Check if pear/mail is already loaded (via composer)
381  if ( !self::isMailUsable() ) {
382  throw new MWException( 'PEAR mail package is not installed' );
383  }
384 
385  $recips = array_map( 'strval', $to );
386 
387  Wikimedia\suppressWarnings();
388 
389  // Create the mail object using the Mail::factory method
390  $mail_object = Mail::factory( 'smtp', $wgSMTP );
391  if ( PEAR::isError( $mail_object ) ) {
392  wfDebug( "PEAR::Mail factory failed: " . $mail_object->getMessage() . "\n" );
393  Wikimedia\restoreWarnings();
394  return Status::newFatal( 'pear-mail-error', $mail_object->getMessage() );
395  }
396  '@phan-var Mail_smtp $mail_object';
397 
398  wfDebug( "Sending mail via PEAR::Mail\n" );
399 
400  $headers['Subject'] = self::quotedPrintable( $subject );
401 
402  // When sending only to one recipient, shows it its email using To:
403  if ( count( $recips ) == 1 ) {
404  $headers['To'] = $recips[0];
405  }
406 
407  // Split jobs since SMTP servers tends to limit the maximum
408  // number of possible recipients.
409  $chunks = array_chunk( $recips, $wgEnotifMaxRecips );
410  foreach ( $chunks as $chunk ) {
411  $status = self::sendWithPear( $mail_object, $chunk, $headers, $body );
412  // FIXME : some chunks might be sent while others are not!
413  if ( !$status->isOK() ) {
414  Wikimedia\restoreWarnings();
415  return $status;
416  }
417  }
418  Wikimedia\restoreWarnings();
419  return Status::newGood();
420  } else {
421  // PHP mail()
422  if ( count( $to ) > 1 ) {
423  $headers['To'] = 'undisclosed-recipients:;';
424  }
425  $headers = self::arrayToHeaderString( $headers, $endl );
426 
427  wfDebug( "Sending mail via internal mail() function\n" );
428 
429  self::$mErrorString = '';
430  $html_errors = ini_get( 'html_errors' );
431  ini_set( 'html_errors', '0' );
432  set_error_handler( 'UserMailer::errorHandler' );
433 
434  try {
435  foreach ( $to as $recip ) {
436  $sent = mail(
437  $recip->toString(),
438  self::quotedPrintable( $subject ),
439  $body,
440  $headers,
441  $extraParams
442  );
443  }
444  } catch ( Exception $e ) {
445  restore_error_handler();
446  throw $e;
447  }
448 
449  restore_error_handler();
450  ini_set( 'html_errors', $html_errors );
451 
452  if ( self::$mErrorString ) {
453  wfDebug( "Error sending mail: " . self::$mErrorString . "\n" );
454  return Status::newFatal( 'php-mail-error', self::$mErrorString );
455  } elseif ( !$sent ) {
456  // mail function only tells if there's an error
457  wfDebug( "Unknown error sending mail\n" );
458  return Status::newFatal( 'php-mail-error-unknown' );
459  } else {
460  return Status::newGood();
461  }
462  }
463  }
464 
471  private static function errorHandler( $code, $string ) {
472  self::$mErrorString = preg_replace( '/^mail\(\)(\s*\[.*?\])?: /', '', $string );
473  }
474 
480  public static function sanitizeHeaderValue( $val ) {
481  return strtr( $val, [ "\r" => '', "\n" => '' ] );
482  }
483 
489  public static function rfc822Phrase( $phrase ) {
490  // Remove line breaks
491  $phrase = self::sanitizeHeaderValue( $phrase );
492  // Remove quotes
493  $phrase = str_replace( '"', '', $phrase );
494  return '"' . $phrase . '"';
495  }
496 
510  public static function quotedPrintable( $string, $charset = '' ) {
511  // Probably incomplete; see RFC 2045
512  if ( empty( $charset ) ) {
513  $charset = 'UTF-8';
514  }
515  $charset = strtoupper( $charset );
516  $charset = str_replace( 'ISO-8859', 'ISO8859', $charset ); // ?
517 
518  $illegal = '\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\xff=';
519  $replace = $illegal . '\t ?_';
520  if ( !preg_match( "/[$illegal]/", $string ) ) {
521  return $string;
522  }
523  $out = "=?$charset?Q?";
524  $out .= preg_replace_callback( "/([$replace])/",
525  function ( $matches ) {
526  return sprintf( "=%02X", ord( $matches[1] ) );
527  },
528  $string
529  );
530  $out .= '?=';
531  return $out;
532  }
533 }
static newFatal( $message,... $parameters)
Factory function for fatal errors.
Definition: StatusValue.php:69
Collection of static functions for sending mail.
Definition: UserMailer.php:30
static getLocalInstance( $ts=false)
Get a timestamp instance in the server local timezone ($wgLocaltimezone)
static send( $to, $from, $subject, $body, $options=[])
This function will perform a direct (authenticated) login to a SMTP Server to use for mail relaying i...
Definition: UserMailer.php:115
wfParseUrl( $url)
parse_url() work-alike, but non-broken.
$wgAdditionalMailParams
Additional email parameters, will be passed as the last argument to mail() call.
static sendWithPear( $mailer, $dest, $headers, $body)
Send mail using a PEAR mailer.
Definition: UserMailer.php:43
wfIsWindows()
Check if the operating system is Windows.
$wgSMTP
SMTP Mode.
Stores a single person&#39;s name and email address.
Definition: MailAddress.php:32
static newGood( $value=null)
Factory function for good results.
Definition: StatusValue.php:81
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
static arrayToHeaderString( $headers, $endl=PHP_EOL)
Creates a single string from an associative array.
Definition: UserMailer.php:67
toString()
Return formatted and quoted address to insert into SMTP headers.
Definition: MailAddress.php:74
static getTitleFor( $name, $subpage=false, $fragment='')
Get a localised Title object for a specified special page name If you don&#39;t need a full Title object...
Definition: SpecialPage.php:83
static makeMsgId()
Create a value suitable for the MessageId Header.
Definition: UserMailer.php:82
static getCurrentWikiDbDomain()
Definition: WikiMap.php:293
static sendInternal(array $to, MailAddress $from, $subject, $body, $options=[])
Helper function fo UserMailer::send() which does the actual sending.
Definition: UserMailer.php:238
const PROTO_CANONICAL
Definition: Defines.php:203
$wgEnotifMaxRecips
Maximum number of users to mail at once when using impersonal mail.
static rfc822Phrase( $phrase)
Converts a string into a valid RFC 822 "phrase", such as is used for the sender name.
Definition: UserMailer.php:489
static sanitizeHeaderValue( $val)
Strips bad characters from a header value to prevent PHP mail header injection attacks.
Definition: UserMailer.php:480
$wgAllowHTMLEmail
For parts of the system that have been updated to provide HTML email content, send both text and HTML...
static isMailUsable()
Whether the PEAR Mail library is usable.
Definition: UserMailer.php:213
$wgServer
URL of the server.
static isMailMimeUsable()
Whether the PEAR Mail_mime library is usable.
Definition: UserMailer.php:199
static errorHandler( $code, $string)
Set the mail error message in self::$mErrorString.
Definition: UserMailer.php:471
static $mErrorString
Definition: UserMailer.php:31
static quotedPrintable( $string, $charset='')
Converts a string into quoted-printable format.
Definition: UserMailer.php:510
static run( $event, array $args=[], $deprecatedVersion=null)
Call hook functions defined in Hooks::register and $wgHooks.
Definition: Hooks.php:200
$matches