MediaWiki REL1_39
UserMailer.php
Go to the documentation of this file.
1<?php
2
30use Wikimedia\AtEase\AtEase;
31
36 private static $mErrorString;
37
48 protected static function sendWithPear( $mailer, $dest, $headers, $body ) {
49 $mailResult = $mailer->send( $dest, $headers, $body );
50
51 // Based on the result return an error string,
52 if ( PEAR::isError( $mailResult ) ) {
53 wfDebug( "PEAR::Mail failed: " . $mailResult->getMessage() );
54 return Status::newFatal( 'pear-mail-error', $mailResult->getMessage() );
55 } else {
56 return Status::newGood();
57 }
58 }
59
65 private static function makeMsgId() {
66 $smtp = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::SMTP );
67 $server = MediaWikiServices::getInstance()->getMainConfig()->get( MainConfigNames::Server );
68 $domainId = WikiMap::getCurrentWikiDbDomain()->getId();
69 $msgid = uniqid( $domainId . ".", true );
70 if ( is_array( $smtp ) && isset( $smtp['IDHost'] ) && $smtp['IDHost'] ) {
71 $domain = $smtp['IDHost'];
72 } else {
73 $url = wfParseUrl( $server );
74 $domain = $url['host'];
75 }
76 return "<$msgid@$domain>";
77 }
78
98 public static function send( $to, $from, $subject, $body, $options = [] ) {
99 $allowHTMLEmail = MediaWikiServices::getInstance()->getMainConfig()->get(
100 MainConfigNames::AllowHTMLEmail );
101
102 if ( !isset( $options['contentType'] ) ) {
103 $options['contentType'] = 'text/plain; charset=UTF-8';
104 }
105
106 if ( !is_array( $to ) ) {
107 $to = [ $to ];
108 }
109
110 // mail body must have some content
111 $minBodyLen = 10;
112 // arbitrary but longer than Array or Object to detect casting error
113
114 // body must either be a string or an array with text and body
115 if (
116 !(
117 !is_array( $body ) &&
118 strlen( $body ) >= $minBodyLen
119 )
120 &&
121 !(
122 is_array( $body ) &&
123 isset( $body['text'] ) &&
124 isset( $body['html'] ) &&
125 strlen( $body['text'] ) >= $minBodyLen &&
126 strlen( $body['html'] ) >= $minBodyLen
127 )
128 ) {
129 // if it is neither we have a problem
130 return Status::newFatal( 'user-mail-no-body' );
131 }
132
133 if ( !$allowHTMLEmail && is_array( $body ) ) {
134 // HTML not wanted. Dump it.
135 $body = $body['text'];
136 }
137
138 wfDebug( __METHOD__ . ': sending mail to ' . implode( ', ', $to ) );
139
140 // Make sure we have at least one address
141 $has_address = false;
142 foreach ( $to as $u ) {
143 if ( $u->address ) {
144 $has_address = true;
145 break;
146 }
147 }
148 if ( !$has_address ) {
149 return Status::newFatal( 'user-mail-no-addy' );
150 }
151
152 // give a chance to UserMailerTransformContents subscribers who need to deal with each
153 // target differently to split up the address list
154 if ( count( $to ) > 1 ) {
155 $oldTo = $to;
156 Hooks::runner()->onUserMailerSplitTo( $to );
157 if ( $oldTo != $to ) {
158 $splitTo = array_diff( $oldTo, $to );
159 $to = array_diff( $oldTo, $splitTo ); // ignore new addresses added in the hook
160 // first send to non-split address list, then to split addresses one by one
161 $status = Status::newGood();
162 if ( $to ) {
163 $status->merge( self::sendInternal(
164 $to, $from, $subject, $body, $options ) );
165 }
166 foreach ( $splitTo as $newTo ) {
167 $status->merge( self::sendInternal(
168 [ $newTo ], $from, $subject, $body, $options ) );
169 }
170 return $status;
171 }
172 }
173
174 return self::sendInternal( $to, $from, $subject, $body, $options );
175 }
176
183 private static function isMailMimeUsable() {
184 static $usable = null;
185 if ( $usable === null ) {
186 $usable = class_exists( Mail_mime::class );
187 }
188 return $usable;
189 }
190
197 private static function isMailUsable() {
198 static $usable = null;
199 if ( $usable === null ) {
200 $usable = class_exists( Mail::class );
201 }
202
203 return $usable;
204 }
205
222 protected static function sendInternal(
223 array $to,
224 MailAddress $from,
225 $subject,
226 $body,
227 $options = []
228 ) {
229 $mainConfig = MediaWikiServices::getInstance()->getMainConfig();
230 $smtp = $mainConfig->get( MainConfigNames::SMTP );
231 $enotifMaxRecips = $mainConfig->get( MainConfigNames::EnotifMaxRecips );
232 $additionalMailParams = $mainConfig->get( MainConfigNames::AdditionalMailParams );
233 $mime = null;
234
235 $replyto = $options['replyTo'] ?? null;
236 $contentType = $options['contentType'] ?? 'text/plain; charset=UTF-8';
237 $headers = $options['headers'] ?? [];
238
239 // Allow transformation of content, such as encrypting/signing
240 $error = false;
241 // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args
242 if ( !Hooks::runner()->onUserMailerTransformContent( $to, $from, $body, $error ) ) {
243 if ( $error ) {
244 return Status::newFatal( 'php-mail-error', $error );
245 } else {
246 return Status::newFatal( 'php-mail-error-unknown' );
247 }
248 }
249
279 $headers['From'] = $from->toString();
280 $returnPath = $from->address;
281 $extraParams = $additionalMailParams;
282
283 // Hook to generate custom VERP address for 'Return-Path'
284 Hooks::runner()->onUserMailerChangeReturnPath( $to, $returnPath );
285 // Add the envelope sender address using the -f command line option when PHP mail() is used.
286 // Will default to the $from->address when the UserMailerChangeReturnPath hook fails and the
287 // generated VERP address when the hook runs effectively.
288
289 // PHP runs this through escapeshellcmd(). However that's not sufficient
290 // escaping (e.g. due to spaces). MediaWiki's email sanitizer should generally
291 // be good enough, but just in case, put in double quotes, and remove any
292 // double quotes present (" is not allowed in emails, so should have no
293 // effect, although this might cause apostrophes to be double escaped)
294 $returnPathCLI = '"' . str_replace( '"', '', $returnPath ) . '"';
295 $extraParams .= ' -f ' . $returnPathCLI;
296
297 $headers['Return-Path'] = $returnPath;
298
299 if ( $replyto ) {
300 $headers['Reply-To'] = $replyto->toString();
301 }
302
303 $headers['Date'] = MWTimestamp::getLocalInstance()->format( 'r' );
304 $headers['Message-ID'] = self::makeMsgId();
305 $headers['X-Mailer'] = 'MediaWiki mailer';
306 $headers['List-Unsubscribe'] = '<' . SpecialPage::getTitleFor( 'Preferences' )
307 ->getFullURL( '', false, PROTO_CANONICAL ) . '>';
308
309 // Line endings need to be different on Unix and Windows due to
310 // the bug described at https://core.trac.wordpress.org/ticket/2603
311 $endl = PHP_EOL;
312
313 if ( is_array( $body ) ) {
314 // we are sending a multipart message
315 wfDebug( "Assembling multipart mime email" );
316 if ( !self::isMailMimeUsable() ) {
317 wfDebug( "PEAR Mail_Mime package is not installed. Falling back to text email." );
318 // remove the html body for text email fall back
319 $body = $body['text'];
320 } else {
321 // pear/mail_mime is already loaded by this point
322 if ( wfIsWindows() ) {
323 $body['text'] = str_replace( "\n", "\r\n", $body['text'] );
324 $body['html'] = str_replace( "\n", "\r\n", $body['html'] );
325 }
326 $mime = new Mail_mime( [
327 'eol' => $endl,
328 'text_charset' => 'UTF-8',
329 'html_charset' => 'UTF-8'
330 ] );
331 $mime->setTXTBody( $body['text'] );
332 $mime->setHTMLBody( $body['html'] );
333 $body = $mime->get(); // must call get() before headers()
334 $headers = $mime->headers( $headers );
335 }
336 }
337 if ( $mime === null ) {
338 // sending text only, either deliberately or as a fallback
339 if ( wfIsWindows() ) {
340 $body = str_replace( "\n", "\r\n", $body );
341 }
342 $headers['MIME-Version'] = '1.0';
343 $headers['Content-type'] = $contentType;
344 $headers['Content-transfer-encoding'] = '8bit';
345 }
346
347 // allow transformation of MIME-encoded message
348 if ( !Hooks::runner()->onUserMailerTransformMessage(
349 $to, $from, $subject, $headers, $body, $error )
350 ) {
351 if ( $error ) {
352 return Status::newFatal( 'php-mail-error', $error );
353 } else {
354 return Status::newFatal( 'php-mail-error-unknown' );
355 }
356 }
357
358 $ret = Hooks::runner()->onAlternateUserMailer( $headers, $to, $from, $subject, $body );
359 if ( $ret === false ) {
360 // the hook implementation will return false to skip regular mail sending
361 return Status::newGood();
362 } elseif ( $ret !== true ) {
363 // the hook implementation will return a string to pass an error message
364 return Status::newFatal( 'php-mail-error', $ret );
365 }
366
367 if ( is_array( $smtp ) ) {
368 // Check if pear/mail is already loaded (via composer)
369 if ( !self::isMailUsable() ) {
370 throw new MWException( 'PEAR mail package is not installed' );
371 }
372
373 $recips = array_map( 'strval', $to );
374
375 AtEase::suppressWarnings();
376
377 // Create the mail object using the Mail::factory method
378 $mail_object = Mail::factory( 'smtp', $smtp );
379 if ( PEAR::isError( $mail_object ) ) {
380 wfDebug( "PEAR::Mail factory failed: " . $mail_object->getMessage() );
381 AtEase::restoreWarnings();
382 return Status::newFatal( 'pear-mail-error', $mail_object->getMessage() );
383 }
384 '@phan-var Mail_smtp $mail_object';
385
386 wfDebug( "Sending mail via PEAR::Mail" );
387
388 $headers['Subject'] = self::quotedPrintable( $subject );
389
390 // When sending only to one recipient, shows it its email using To:
391 if ( count( $recips ) == 1 ) {
392 $headers['To'] = $recips[0];
393 }
394
395 // Split jobs since SMTP servers tends to limit the maximum
396 // number of possible recipients.
397 $chunks = array_chunk( $recips, $enotifMaxRecips );
398 foreach ( $chunks as $chunk ) {
399 $status = self::sendWithPear( $mail_object, $chunk, $headers, $body );
400 // FIXME : some chunks might be sent while others are not!
401 if ( !$status->isOK() ) {
402 AtEase::restoreWarnings();
403 return $status;
404 }
405 }
406 AtEase::restoreWarnings();
407 return Status::newGood();
408 } else {
409 // PHP mail()
410 if ( count( $to ) > 1 ) {
411 $headers['To'] = 'undisclosed-recipients:;';
412 }
413
414 wfDebug( "Sending mail via internal mail() function" );
415
416 self::$mErrorString = '';
417 $html_errors = ini_get( 'html_errors' );
418 ini_set( 'html_errors', '0' );
419 set_error_handler( 'UserMailer::errorHandler' );
420
421 try {
422 foreach ( $to as $recip ) {
423 $sent = mail(
424 $recip->toString(),
425 self::quotedPrintable( $subject ),
426 $body,
427 $headers,
428 $extraParams
429 );
430 }
431 } catch ( Exception $e ) {
432 restore_error_handler();
433 throw $e;
434 }
435
436 restore_error_handler();
437 ini_set( 'html_errors', $html_errors );
438
439 if ( self::$mErrorString ) {
440 wfDebug( "Error sending mail: " . self::$mErrorString );
441 return Status::newFatal( 'php-mail-error', self::$mErrorString );
442 } elseif ( !$sent ) {
443 // @phan-suppress-previous-line PhanPossiblyUndeclaredVariable sent set on success
444 // mail function only tells if there's an error
445 wfDebug( "Unknown error sending mail" );
446 return Status::newFatal( 'php-mail-error-unknown' );
447 } else {
448 return Status::newGood();
449 }
450 }
451 }
452
459 private static function errorHandler( $code, $string ) {
460 self::$mErrorString = preg_replace( '/^mail\‍(\‍)(\s*\[.*?\])?: /', '', $string );
461 }
462
468 public static function sanitizeHeaderValue( $val ) {
469 return strtr( $val, [ "\r" => '', "\n" => '' ] );
470 }
471
477 public static function rfc822Phrase( $phrase ) {
478 // Remove line breaks
479 $phrase = self::sanitizeHeaderValue( $phrase );
480 // Remove quotes
481 $phrase = str_replace( '"', '', $phrase );
482 return '"' . $phrase . '"';
483 }
484
498 public static function quotedPrintable( $string, $charset = '' ) {
499 // Probably incomplete; see RFC 2045
500 if ( empty( $charset ) ) {
501 $charset = 'UTF-8';
502 }
503 $charset = strtoupper( $charset );
504 $charset = str_replace( 'ISO-8859', 'ISO8859', $charset ); // ?
505
506 $illegal = '\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\xff=';
507 if ( !preg_match( "/[$illegal]/", $string ) ) {
508 return $string;
509 }
510
511 // T344912: Add period '.' char
512 $replace = $illegal . '.\t ?_';
513
514 $out = "=?$charset?Q?";
515 $out .= preg_replace_callback( "/([$replace])/",
516 static function ( $matches ) {
517 return sprintf( "=%02X", ord( $matches[1] ) );
518 },
519 $string
520 );
521 $out .= '?=';
522 return $out;
523 }
524}
const PROTO_CANONICAL
Definition Defines.php:199
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.
wfIsWindows()
Check if the operating system is Windows.
MediaWiki exception.
Stores a single person's name and email address.
toString()
Return formatted and quoted address to insert into SMTP headers.
A class containing constants representing the names of configuration variables.
Service locator for MediaWiki core services.
static getTitleFor( $name, $subpage=false, $fragment='')
Get a localised Title object for a specified special page name If you don't need a full Title object,...
Collection of static functions for sending mail.
static rfc822Phrase( $phrase)
Converts a string into a valid RFC 822 "phrase", such as is used for the sender name.
static sanitizeHeaderValue( $val)
Strips bad characters from a header value to prevent PHP mail header injection attacks.
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...
static sendWithPear( $mailer, $dest, $headers, $body)
Send mail using a PEAR mailer.
static quotedPrintable( $string, $charset='')
Converts a string into quoted-printable format.
static sendInternal(array $to, MailAddress $from, $subject, $body, $options=[])
Helper function fo UserMailer::send() which does the actual sending.
$mime
Definition router.php:60