MediaWiki 1.41.2
UserMailer.php
Go to the documentation of this file.
1<?php
2
35
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 if ( !preg_match( "/[$illegal]/", $string ) ) {
461 return $string;
462 }
463
464 // T344912: Add period '.' char
465 $replace = $illegal . '.\t ?_';
466
467 $out = "=?$charset?Q?";
468 $out .= preg_replace_callback( "/([$replace])/",
469 static function ( $matches ) {
470 return sprintf( "=%02X", ord( $matches[1] ) );
471 },
472 $string
473 );
474 $out .= '?=';
475 return $out;
476 }
477}
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.
Stores a single person's name and email address.
toString()
Return formatted and quoted address to insert into SMTP headers.
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
A class containing constants representing the names of configuration variables.
Service locator for MediaWiki core services.
Parent class for all special pages.
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.
Tools for dealing with other locally-hosted wikis.
Definition WikiMap.php:31
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