MediaWiki fundraising/REL1_35
UserMailer.php
Go to the documentation of this file.
1<?php
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() );
49 return Status::newFatal( 'pear-mail-error', $mailResult->getMessage() );
50 } else {
51 return Status::newGood();
52 }
53 }
54
60 private static function makeMsgId() {
61 global $wgSMTP, $wgServer;
62
63 $domainId = WikiMap::getCurrentWikiDbDomain()->getId();
64 $msgid = uniqid( $domainId . ".", true );
65 if ( is_array( $wgSMTP ) && isset( $wgSMTP['IDHost'] ) && $wgSMTP['IDHost'] ) {
66 $domain = $wgSMTP['IDHost'];
67 } else {
68 $url = wfParseUrl( $wgServer );
69 $domain = $url['host'];
70 }
71 return "<$msgid@$domain>";
72 }
73
93 public static function send( $to, $from, $subject, $body, $options = [] ) {
94 global $wgAllowHTMLEmail;
95
96 if ( !isset( $options['contentType'] ) ) {
97 $options['contentType'] = 'text/plain; charset=UTF-8';
98 }
99
100 if ( !is_array( $to ) ) {
101 $to = [ $to ];
102 }
103
104 // mail body must have some content
105 $minBodyLen = 10;
106 // arbitrary but longer than Array or Object to detect casting error
107
108 // body must either be a string or an array with text and body
109 if (
110 !(
111 !is_array( $body ) &&
112 strlen( $body ) >= $minBodyLen
113 )
114 &&
115 !(
116 is_array( $body ) &&
117 isset( $body['text'] ) &&
118 isset( $body['html'] ) &&
119 strlen( $body['text'] ) >= $minBodyLen &&
120 strlen( $body['html'] ) >= $minBodyLen
121 )
122 ) {
123 // if it is neither we have a problem
124 return Status::newFatal( 'user-mail-no-body' );
125 }
126
127 if ( !$wgAllowHTMLEmail && is_array( $body ) ) {
128 // HTML not wanted. Dump it.
129 $body = $body['text'];
130 }
131
132 wfDebug( __METHOD__ . ': sending mail to ' . implode( ', ', $to ) );
133
134 // Make sure we have at least one address
135 $has_address = false;
136 foreach ( $to as $u ) {
137 if ( $u->address ) {
138 $has_address = true;
139 break;
140 }
141 }
142 if ( !$has_address ) {
143 return Status::newFatal( 'user-mail-no-addy' );
144 }
145
146 // give a chance to UserMailerTransformContents subscribers who need to deal with each
147 // target differently to split up the address list
148 if ( count( $to ) > 1 ) {
149 $oldTo = $to;
150 Hooks::runner()->onUserMailerSplitTo( $to );
151 if ( $oldTo != $to ) {
152 $splitTo = array_diff( $oldTo, $to );
153 $to = array_diff( $oldTo, $splitTo ); // ignore new addresses added in the hook
154 // first send to non-split address list, then to split addresses one by one
155 $status = Status::newGood();
156 if ( $to ) {
157 $status->merge( self::sendInternal(
158 $to, $from, $subject, $body, $options ) );
159 }
160 foreach ( $splitTo as $newTo ) {
161 $status->merge( self::sendInternal(
162 [ $newTo ], $from, $subject, $body, $options ) );
163 }
164 return $status;
165 }
166 }
167
168 return self::sendInternal( $to, $from, $subject, $body, $options );
169 }
170
177 private static function isMailMimeUsable() {
178 static $usable = null;
179 if ( $usable === null ) {
180 $usable = class_exists( 'Mail_mime' );
181 }
182 return $usable;
183 }
184
191 private static function isMailUsable() {
192 static $usable = null;
193 if ( $usable === null ) {
194 $usable = class_exists( 'Mail' );
195 }
196
197 return $usable;
198 }
199
216 protected static function sendInternal(
217 array $to,
218 MailAddress $from,
219 $subject,
220 $body,
221 $options = []
222 ) {
224 $mime = null;
225
226 $replyto = $options['replyTo'] ?? null;
227 $contentType = $options['contentType'] ?? 'text/plain; charset=UTF-8';
228 $headers = $options['headers'] ?? [];
229
230 // Allow transformation of content, such as encrypting/signing
231 $error = false;
232 if ( !Hooks::runner()->onUserMailerTransformContent( $to, $from, $body, $error ) ) {
233 if ( $error ) {
234 return Status::newFatal( 'php-mail-error', $error );
235 } else {
236 return Status::newFatal( 'php-mail-error-unknown' );
237 }
238 }
239
269 $headers['From'] = $from->toString();
270 $returnPath = $from->address;
271 $extraParams = $wgAdditionalMailParams;
272
273 // Hook to generate custom VERP address for 'Return-Path'
274 Hooks::runner()->onUserMailerChangeReturnPath( $to, $returnPath );
275 // Add the envelope sender address using the -f command line option when PHP mail() is used.
276 // Will default to the $from->address when the UserMailerChangeReturnPath hook fails and the
277 // generated VERP address when the hook runs effectively.
278
279 // PHP runs this through escapeshellcmd(). However that's not sufficient
280 // escaping (e.g. due to spaces). MediaWiki's email sanitizer should generally
281 // be good enough, but just in case, put in double quotes, and remove any
282 // double quotes present (" is not allowed in emails, so should have no
283 // effect, although this might cause apostrophees to be double escaped)
284 $returnPathCLI = '"' . str_replace( '"', '', $returnPath ) . '"';
285 $extraParams .= ' -f ' . $returnPathCLI;
286
287 $headers['Return-Path'] = $returnPath;
288
289 if ( $replyto ) {
290 $headers['Reply-To'] = $replyto->toString();
291 }
292
293 $headers['Date'] = MWTimestamp::getLocalInstance()->format( 'r' );
294 $headers['Message-ID'] = self::makeMsgId();
295 $headers['X-Mailer'] = 'MediaWiki mailer';
296 $headers['List-Unsubscribe'] = '<' . SpecialPage::getTitleFor( 'Preferences' )
297 ->getFullURL( '', false, PROTO_CANONICAL ) . '>';
298
299 // Line endings need to be different on Unix and Windows due to
300 // the bug described at https://core.trac.wordpress.org/ticket/2603
301 $endl = PHP_EOL;
302
303 if ( is_array( $body ) ) {
304 // we are sending a multipart message
305 wfDebug( "Assembling multipart mime email" );
306 if ( !self::isMailMimeUsable() ) {
307 wfDebug( "PEAR Mail_Mime package is not installed. Falling back to text email." );
308 // remove the html body for text email fall back
309 $body = $body['text'];
310 } else {
311 // pear/mail_mime is already loaded by this point
312 if ( wfIsWindows() ) {
313 $body['text'] = str_replace( "\n", "\r\n", $body['text'] );
314 $body['html'] = str_replace( "\n", "\r\n", $body['html'] );
315 }
316 $mime = new Mail_mime( [
317 'eol' => $endl,
318 'text_charset' => 'UTF-8',
319 'html_charset' => 'UTF-8'
320 ] );
321 $mime->setTXTBody( $body['text'] );
322 $mime->setHTMLBody( $body['html'] );
323 $body = $mime->get(); // must call get() before headers()
324 $headers = $mime->headers( $headers );
325 }
326 }
327 if ( $mime === null ) {
328 // sending text only, either deliberately or as a fallback
329 if ( wfIsWindows() ) {
330 $body = str_replace( "\n", "\r\n", $body );
331 }
332 $headers['MIME-Version'] = '1.0';
333 $headers['Content-type'] = $contentType;
334 $headers['Content-transfer-encoding'] = '8bit';
335 }
336
337 // allow transformation of MIME-encoded message
338 if ( !Hooks::runner()->onUserMailerTransformMessage(
339 $to, $from, $subject, $headers, $body, $error )
340 ) {
341 if ( $error ) {
342 return Status::newFatal( 'php-mail-error', $error );
343 } else {
344 return Status::newFatal( 'php-mail-error-unknown' );
345 }
346 }
347
348 $ret = Hooks::runner()->onAlternateUserMailer( $headers, $to, $from, $subject, $body );
349 if ( $ret === false ) {
350 // the hook implementation will return false to skip regular mail sending
351 return Status::newGood();
352 } elseif ( $ret !== true ) {
353 // the hook implementation will return a string to pass an error message
354 // @phan-suppress-next-line PhanTypeVoidArgument
355 return Status::newFatal( 'php-mail-error', $ret );
356 }
357
358 if ( is_array( $wgSMTP ) ) {
359 // Check if pear/mail is already loaded (via composer)
360 if ( !self::isMailUsable() ) {
361 throw new MWException( 'PEAR mail package is not installed' );
362 }
363
364 $recips = array_map( 'strval', $to );
365
366 Wikimedia\suppressWarnings();
367
368 // Create the mail object using the Mail::factory method
369 $mail_object = Mail::factory( 'smtp', $wgSMTP );
370 if ( PEAR::isError( $mail_object ) ) {
371 wfDebug( "PEAR::Mail factory failed: " . $mail_object->getMessage() );
372 Wikimedia\restoreWarnings();
373 return Status::newFatal( 'pear-mail-error', $mail_object->getMessage() );
374 }
375 '@phan-var Mail_smtp $mail_object';
376
377 wfDebug( "Sending mail via PEAR::Mail" );
378
379 $headers['Subject'] = self::quotedPrintable( $subject );
380
381 // When sending only to one recipient, shows it its email using To:
382 if ( count( $recips ) == 1 ) {
383 $headers['To'] = $recips[0];
384 }
385
386 // Split jobs since SMTP servers tends to limit the maximum
387 // number of possible recipients.
388 $chunks = array_chunk( $recips, $wgEnotifMaxRecips );
389 foreach ( $chunks as $chunk ) {
390 $status = self::sendWithPear( $mail_object, $chunk, $headers, $body );
391 // FIXME : some chunks might be sent while others are not!
392 if ( !$status->isOK() ) {
393 Wikimedia\restoreWarnings();
394 return $status;
395 }
396 }
397 Wikimedia\restoreWarnings();
398 return Status::newGood();
399 } else {
400 // PHP mail()
401 if ( count( $to ) > 1 ) {
402 $headers['To'] = 'undisclosed-recipients:;';
403 }
404
405 wfDebug( "Sending mail via internal mail() function" );
406
407 self::$mErrorString = '';
408 $html_errors = ini_get( 'html_errors' );
409 ini_set( 'html_errors', '0' );
410 set_error_handler( 'UserMailer::errorHandler' );
411
412 try {
413 foreach ( $to as $recip ) {
414 $sent = mail(
415 $recip->toString(),
416 self::quotedPrintable( $subject ),
417 $body,
418 $headers,
419 $extraParams
420 );
421 }
422 } catch ( Exception $e ) {
423 restore_error_handler();
424 throw $e;
425 }
426
427 restore_error_handler();
428 ini_set( 'html_errors', $html_errors );
429
430 if ( self::$mErrorString ) {
431 wfDebug( "Error sending mail: " . self::$mErrorString );
432 return Status::newFatal( 'php-mail-error', self::$mErrorString );
433 } elseif ( !$sent ) {
434 // mail function only tells if there's an error
435 wfDebug( "Unknown error sending mail" );
436 return Status::newFatal( 'php-mail-error-unknown' );
437 } else {
438 return Status::newGood();
439 }
440 }
441 }
442
449 private static function errorHandler( $code, $string ) {
450 self::$mErrorString = preg_replace( '/^mail\‍(\‍)(\s*\[.*?\])?: /', '', $string );
451 }
452
458 public static function sanitizeHeaderValue( $val ) {
459 return strtr( $val, [ "\r" => '', "\n" => '' ] );
460 }
461
467 public static function rfc822Phrase( $phrase ) {
468 // Remove line breaks
469 $phrase = self::sanitizeHeaderValue( $phrase );
470 // Remove quotes
471 $phrase = str_replace( '"', '', $phrase );
472 return '"' . $phrase . '"';
473 }
474
488 public static function quotedPrintable( $string, $charset = '' ) {
489 // Probably incomplete; see RFC 2045
490 if ( empty( $charset ) ) {
491 $charset = 'UTF-8';
492 }
493 $charset = strtoupper( $charset );
494 $charset = str_replace( 'ISO-8859', 'ISO8859', $charset ); // ?
495
496 $illegal = '\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\xff=';
497 $replace = $illegal . '\t ?_';
498 if ( !preg_match( "/[$illegal]/", $string ) ) {
499 return $string;
500 }
501 $out = "=?$charset?Q?";
502 $out .= preg_replace_callback( "/([$replace])/",
503 function ( $matches ) {
504 return sprintf( "=%02X", ord( $matches[1] ) );
505 },
506 $string
507 );
508 $out .= '?=';
509 return $out;
510 }
511}
$wgEnotifMaxRecips
Maximum number of users to mail at once when using impersonal mail.
$wgAdditionalMailParams
Additional email parameters, will be passed as the last argument to mail() call.
$wgAllowHTMLEmail
For parts of the system that have been updated to provide HTML email content, send both text and HTML...
$wgSMTP
SMTP Mode.
$wgServer
URL of the server.
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.
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 errorHandler( $code, $string)
Set the mail error message in self::$mErrorString.
static isMailUsable()
Whether the PEAR Mail library is usable.
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 $mErrorString
static sendWithPear( $mailer, $dest, $headers, $body)
Send mail using a PEAR mailer.
static quotedPrintable( $string, $charset='')
Converts a string into quoted-printable format.
static isMailMimeUsable()
Whether the PEAR Mail_mime library is usable.
static sendInternal(array $to, MailAddress $from, $subject, $body, $options=[])
Helper function fo UserMailer::send() which does the actual sending.
static makeMsgId()
Create a value suitable for the MessageId Header.
const PROTO_CANONICAL
Definition Defines.php:213
$mime
Definition router.php:60