MediaWiki  master
UserMailer.php
Go to the documentation of this file.
1 <?php
2 
35 
39 class UserMailer {
40  private static $mErrorString;
41 
52  protected static function sendWithPear( $mailer, $dest, $headers, $body ) {
53  $mailResult = $mailer->send( $dest, $headers, $body );
54 
55  // Based on the result return an error string,
56  if ( PEAR::isError( $mailResult ) ) {
57  wfDebug( "PEAR::Mail failed: " . $mailResult->getMessage() );
58  return Status::newFatal( 'pear-mail-error', $mailResult->getMessage() );
59  } else {
60  return Status::newGood();
61  }
62  }
63 
69  private static function makeMsgId() {
70  $smtp = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::SMTP );
71  $server = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::Server );
72  $domainId = WikiMap::getCurrentWikiDbDomain()->getId();
73  $msgid = uniqid( $domainId . ".", true );
74  if ( is_array( $smtp ) && isset( $smtp['IDHost'] ) && $smtp['IDHost'] ) {
75  $domain = $smtp['IDHost'];
76  } else {
77  $url = wfParseUrl( $server );
78  $domain = $url['host'];
79  }
80  return "<$msgid@$domain>";
81  }
82 
99  public static function send( $to, $from, $subject, $body, $options = [] ) {
100  $services = MediaWikiServices::getInstance();
101  $allowHTMLEmail = $services->getMainConfig()->get(
102  MainConfigNames::AllowHTMLEmail );
103 
104  if ( !isset( $options['contentType'] ) ) {
105  $options['contentType'] = 'text/plain; charset=UTF-8';
106  }
107 
108  if ( !is_array( $to ) ) {
109  $to = [ $to ];
110  }
111 
112  // mail body must have some content
113  $minBodyLen = 10;
114  // arbitrary but longer than Array or Object to detect casting error
115 
116  // body must either be a string or an array with text and body
117  if (
118  !(
119  !is_array( $body ) &&
120  strlen( $body ) >= $minBodyLen
121  )
122  &&
123  !(
124  is_array( $body ) &&
125  isset( $body['text'] ) &&
126  isset( $body['html'] ) &&
127  strlen( $body['text'] ) >= $minBodyLen &&
128  strlen( $body['html'] ) >= $minBodyLen
129  )
130  ) {
131  // if it is neither we have a problem
132  return Status::newFatal( 'user-mail-no-body' );
133  }
134 
135  if ( !$allowHTMLEmail && is_array( $body ) ) {
136  // HTML not wanted. Dump it.
137  $body = $body['text'];
138  }
139 
140  wfDebug( __METHOD__ . ': sending mail to ' . implode( ', ', $to ) );
141 
142  // Make sure we have at least one address
143  $has_address = false;
144  foreach ( $to as $u ) {
145  if ( $u->address ) {
146  $has_address = true;
147  break;
148  }
149  }
150  if ( !$has_address ) {
151  return Status::newFatal( 'user-mail-no-addy' );
152  }
153 
154  // give a chance to UserMailerTransformContents subscribers who need to deal with each
155  // target differently to split up the address list
156  if ( count( $to ) > 1 ) {
157  $oldTo = $to;
158  ( new HookRunner( $services->getHookContainer() ) )->onUserMailerSplitTo( $to );
159  if ( $oldTo != $to ) {
160  $splitTo = array_diff( $oldTo, $to );
161  $to = array_diff( $oldTo, $splitTo ); // ignore new addresses added in the hook
162  // first send to non-split address list, then to split addresses one by one
163  $status = Status::newGood();
164  if ( $to ) {
165  $status->merge( self::sendInternal(
166  $to, $from, $subject, $body, $options ) );
167  }
168  foreach ( $splitTo as $newTo ) {
169  $status->merge( self::sendInternal(
170  [ $newTo ], $from, $subject, $body, $options ) );
171  }
172  return $status;
173  }
174  }
175 
176  return self::sendInternal( $to, $from, $subject, $body, $options );
177  }
178 
192  protected static function sendInternal(
193  array $to,
194  MailAddress $from,
195  $subject,
196  $body,
197  $options = []
198  ) {
199  $services = MediaWikiServices::getInstance();
200  $mainConfig = $services->getMainConfig();
201  $smtp = $mainConfig->get( MainConfigNames::SMTP );
202  $enotifMaxRecips = $mainConfig->get( MainConfigNames::EnotifMaxRecips );
203  $additionalMailParams = $mainConfig->get( MainConfigNames::AdditionalMailParams );
204 
205  $replyto = $options['replyTo'] ?? null;
206  $contentType = $options['contentType'] ?? 'text/plain; charset=UTF-8';
207  $headers = $options['headers'] ?? [];
208 
209  $hookRunner = new HookRunner( $services->getHookContainer() );
210  // Allow transformation of content, such as encrypting/signing
211  $error = false;
212  // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args
213  if ( !$hookRunner->onUserMailerTransformContent( $to, $from, $body, $error ) ) {
214  if ( $error ) {
215  return Status::newFatal( 'php-mail-error', $error );
216  } else {
217  return Status::newFatal( 'php-mail-error-unknown' );
218  }
219  }
220 
250  $headers['From'] = $from->toString();
251  $returnPath = $from->address;
252  $extraParams = $additionalMailParams;
253 
254  // Hook to generate custom VERP address for 'Return-Path'
255  $hookRunner->onUserMailerChangeReturnPath( $to, $returnPath );
256  // Add the envelope sender address using the -f command line option when PHP mail() is used.
257  // Will default to the $from->address when the UserMailerChangeReturnPath hook fails and the
258  // generated VERP address when the hook runs effectively.
259 
260  // PHP runs this through escapeshellcmd(). However that's not sufficient
261  // escaping (e.g. due to spaces). MediaWiki's email sanitizer should generally
262  // be good enough, but just in case, put in double quotes, and remove any
263  // double quotes present (" is not allowed in emails, so should have no
264  // effect, although this might cause apostrophes to be double escaped)
265  $returnPathCLI = '"' . str_replace( '"', '', $returnPath ) . '"';
266  $extraParams .= ' -f ' . $returnPathCLI;
267 
268  $headers['Return-Path'] = $returnPath;
269 
270  if ( $replyto ) {
271  $headers['Reply-To'] = $replyto->toString();
272  }
273 
274  $headers['Date'] = MWTimestamp::getLocalInstance()->format( 'r' );
275  $headers['Message-ID'] = self::makeMsgId();
276  $headers['X-Mailer'] = 'MediaWiki mailer';
277  $headers['List-Unsubscribe'] = '<' . SpecialPage::getTitleFor( 'Preferences' )
278  ->getFullURL( '', false, PROTO_CANONICAL ) . '>';
279 
280  // Line endings need to be different on Unix and Windows due to
281  // the bug described at https://core.trac.wordpress.org/ticket/2603
282  $endl = PHP_EOL;
283 
284  if ( is_array( $body ) ) {
285  // we are sending a multipart message
286  wfDebug( "Assembling multipart mime email" );
287  if ( wfIsWindows() ) {
288  $body['text'] = str_replace( "\n", "\r\n", $body['text'] );
289  $body['html'] = str_replace( "\n", "\r\n", $body['html'] );
290  }
291  $mime = new Mail_mime( [
292  'eol' => $endl,
293  'text_charset' => 'UTF-8',
294  'html_charset' => 'UTF-8'
295  ] );
296  $mime->setTXTBody( $body['text'] );
297  $mime->setHTMLBody( $body['html'] );
298  $body = $mime->get(); // must call get() before headers()
299  $headers = $mime->headers( $headers );
300  } else {
301  // sending text only
302  if ( wfIsWindows() ) {
303  $body = str_replace( "\n", "\r\n", $body );
304  }
305  $headers['MIME-Version'] = '1.0';
306  $headers['Content-type'] = $contentType;
307  $headers['Content-transfer-encoding'] = '8bit';
308  }
309 
310  // allow transformation of MIME-encoded message
311  if ( !$hookRunner->onUserMailerTransformMessage(
312  $to, $from, $subject, $headers, $body, $error )
313  ) {
314  if ( $error ) {
315  return Status::newFatal( 'php-mail-error', $error );
316  } else {
317  return Status::newFatal( 'php-mail-error-unknown' );
318  }
319  }
320 
321  $ret = $hookRunner->onAlternateUserMailer( $headers, $to, $from, $subject, $body );
322  if ( $ret === false ) {
323  // the hook implementation will return false to skip regular mail sending
324  return Status::newGood();
325  } elseif ( $ret !== true ) {
326  // the hook implementation will return a string to pass an error message
327  return Status::newFatal( 'php-mail-error', $ret );
328  }
329 
330  if ( is_array( $smtp ) ) {
331  $recips = array_map( 'strval', $to );
332 
333  // Create the mail object using the Mail::factory method
334  $mail_object = Mail::factory( 'smtp', $smtp );
335  if ( PEAR::isError( $mail_object ) ) {
336  wfDebug( "PEAR::Mail factory failed: " . $mail_object->getMessage() );
337  return Status::newFatal( 'pear-mail-error', $mail_object->getMessage() );
338  }
339  '@phan-var Mail_smtp $mail_object';
340 
341  wfDebug( "Sending mail via PEAR::Mail" );
342 
343  $headers['Subject'] = self::quotedPrintable( $subject );
344 
345  // When sending only to one recipient, shows it its email using To:
346  if ( count( $recips ) == 1 ) {
347  $headers['To'] = $recips[0];
348  }
349 
350  // Split jobs since SMTP servers tends to limit the maximum
351  // number of possible recipients.
352  $chunks = array_chunk( $recips, $enotifMaxRecips );
353  foreach ( $chunks as $chunk ) {
354  $status = self::sendWithPear( $mail_object, $chunk, $headers, $body );
355  // FIXME : some chunks might be sent while others are not!
356  if ( !$status->isOK() ) {
357  return $status;
358  }
359  }
360  return Status::newGood();
361  } else {
362  // PHP mail()
363  if ( count( $to ) > 1 ) {
364  $headers['To'] = 'undisclosed-recipients:;';
365  }
366 
367  wfDebug( "Sending mail via internal mail() function" );
368 
369  self::$mErrorString = '';
370  $html_errors = ini_get( 'html_errors' );
371  ini_set( 'html_errors', '0' );
372  set_error_handler( [ self::class, 'errorHandler' ] );
373 
374  try {
375  foreach ( $to as $recip ) {
376  $sent = mail(
377  $recip->toString(),
378  self::quotedPrintable( $subject ),
379  $body,
380  $headers,
381  $extraParams
382  );
383  }
384  } catch ( Exception $e ) {
385  restore_error_handler();
386  throw $e;
387  }
388 
389  restore_error_handler();
390  ini_set( 'html_errors', $html_errors );
391 
392  if ( self::$mErrorString ) {
393  wfDebug( "Error sending mail: " . self::$mErrorString );
394  return Status::newFatal( 'php-mail-error', self::$mErrorString );
395  } elseif ( !$sent ) {
396  // @phan-suppress-previous-line PhanPossiblyUndeclaredVariable sent set on success
397  // mail function only tells if there's an error
398  wfDebug( "Unknown error sending mail" );
399  return Status::newFatal( 'php-mail-error-unknown' );
400  } else {
401  return Status::newGood();
402  }
403  }
404  }
405 
412  private static function errorHandler( $code, $string ) {
413  self::$mErrorString = preg_replace( '/^mail\‍(\‍)(\s*\[.*?\])?: /', '', $string );
414  }
415 
421  public static function sanitizeHeaderValue( $val ) {
422  return strtr( $val, [ "\r" => '', "\n" => '' ] );
423  }
424 
430  public static function rfc822Phrase( $phrase ) {
431  // Remove line breaks
432  $phrase = self::sanitizeHeaderValue( $phrase );
433  // Remove quotes
434  $phrase = str_replace( '"', '', $phrase );
435  return '"' . $phrase . '"';
436  }
437 
451  public static function quotedPrintable( $string, $charset = '' ) {
452  // Probably incomplete; see RFC 2045
453  if ( !$charset ) {
454  $charset = 'UTF-8';
455  }
456  $charset = strtoupper( $charset );
457  $charset = str_replace( 'ISO-8859', 'ISO8859', $charset ); // ?
458 
459  $illegal = '\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\xff=';
460  $replace = $illegal . '\t ?_';
461  if ( !preg_match( "/[$illegal]/", $string ) ) {
462  return $string;
463  }
464  $out = "=?$charset?Q?";
465  $out .= preg_replace_callback( "/([$replace])/",
466  static function ( $matches ) {
467  return sprintf( "=%02X", ord( $matches[1] ) );
468  },
469  $string
470  );
471  $out .= '?=';
472  return $out;
473  }
474 }
wfIsWindows()
Check if the operating system is Windows.
const PROTO_CANONICAL
Definition: Defines.php:197
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
wfParseUrl( $url)
parse_url() work-alike, but non-broken.
$matches
Stores a single person's name and email address.
Definition: MailAddress.php:36
toString()
Return formatted and quoted address to insert into SMTP headers.
Definition: MailAddress.php:91
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Definition: HookRunner.php:568
A class containing constants representing the names of configuration variables.
Service locator for MediaWiki core services.
Parent class for all special pages.
Definition: SpecialPage.php:66
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition: Status.php:58
Library for creating and parsing MW-style timestamps.
Definition: MWTimestamp.php:48
Tools for dealing with other locally-hosted wikis.
Definition: WikiMap.php:31
Collection of static functions for sending mail.
Definition: UserMailer.php:39
static rfc822Phrase( $phrase)
Converts a string into a valid RFC 822 "phrase", such as is used for the sender name.
Definition: UserMailer.php:430
static sanitizeHeaderValue( $val)
Strips bad characters from a header value to prevent PHP mail header injection attacks.
Definition: UserMailer.php:421
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:99
static sendWithPear( $mailer, $dest, $headers, $body)
Send mail using a PEAR mailer.
Definition: UserMailer.php:52
static quotedPrintable( $string, $charset='')
Converts a string into quoted-printable format.
Definition: UserMailer.php:451
static sendInternal(array $to, MailAddress $from, $subject, $body, $options=[])
Helper function fo UserMailer::send() which does the actual sending.
Definition: UserMailer.php:192
$mime
Definition: router.php:60