MediaWiki master
UserMailer.php
Go to the documentation of this file.
1<?php
33
46 private static $mErrorString;
47
58 protected static function sendWithPear( $mailer, $dest, $headers, $body ) {
59 $mailResult = $mailer->send( $dest, $headers, $body );
60
61 // Based on the result return an error string,
62 if ( PEAR::isError( $mailResult ) ) {
63 wfDebug( "PEAR::Mail failed: " . $mailResult->getMessage() );
64 return Status::newFatal( 'pear-mail-error', $mailResult->getMessage() );
65 } else {
66 return Status::newGood();
67 }
68 }
69
75 private static function makeMsgId() {
76 $services = MediaWikiServices::getInstance();
77
78 $smtp = $services->getMainConfig()->get( MainConfigNames::SMTP );
79 $server = $services->getMainConfig()->get( MainConfigNames::Server );
80 $domainId = WikiMap::getCurrentWikiDbDomain()->getId();
81 $msgid = uniqid( $domainId . ".", true );
82
83 if ( is_array( $smtp ) && isset( $smtp['IDHost'] ) && $smtp['IDHost'] ) {
84 $domain = $smtp['IDHost'];
85 } else {
86 $domain = parse_url( $server, PHP_URL_HOST ) ?? '';
87 }
88 return "<$msgid@$domain>";
89 }
90
112 public static function send( $to, $from, $subject, $body, $options = [] ) {
113 $services = MediaWikiServices::getInstance();
114 $allowHTMLEmail = $services->getMainConfig()->get(
115 MainConfigNames::AllowHTMLEmail );
116
117 if ( !isset( $options['contentType'] ) ) {
118 $options['contentType'] = 'text/plain; charset=UTF-8';
119 }
120
121 if ( !is_array( $to ) ) {
122 $to = [ $to ];
123 }
124
125 // mail body must have some content
126 $minBodyLen = 10;
127 // arbitrary but longer than Array or Object to detect casting error
128
129 // body must either be a string or an array with text and body
130 if (
131 !(
132 !is_array( $body ) &&
133 strlen( $body ) >= $minBodyLen
134 )
135 &&
136 !(
137 is_array( $body ) &&
138 isset( $body['text'] ) &&
139 isset( $body['html'] ) &&
140 strlen( $body['text'] ) >= $minBodyLen &&
141 strlen( $body['html'] ) >= $minBodyLen
142 )
143 ) {
144 // if it is neither we have a problem
145 return Status::newFatal( 'user-mail-no-body' );
146 }
147
148 if ( !$allowHTMLEmail && is_array( $body ) ) {
149 // HTML not wanted. Dump it.
150 $body = $body['text'];
151 }
152
153 wfDebug( __METHOD__ . ': sending mail to ' . implode( ', ', $to ) );
154
155 // Make sure we have at least one address
156 $has_address = false;
157 foreach ( $to as $u ) {
158 if ( $u->address ) {
159 $has_address = true;
160 break;
161 }
162 }
163 if ( !$has_address ) {
164 return Status::newFatal( 'user-mail-no-addy' );
165 }
166
167 // give a chance to UserMailerTransformContents subscribers who need to deal with each
168 // target differently to split up the address list
169 if ( count( $to ) > 1 ) {
170 $oldTo = $to;
171 ( new HookRunner( $services->getHookContainer() ) )->onUserMailerSplitTo( $to );
172 if ( $oldTo != $to ) {
173 $splitTo = array_diff( $oldTo, $to );
174 $to = array_diff( $oldTo, $splitTo ); // ignore new addresses added in the hook
175 // first send to non-split address list, then to split addresses one by one
176 $status = Status::newGood();
177 if ( $to ) {
178 $status->merge( self::sendInternal(
179 $to, $from, $subject, $body, $options ) );
180 }
181 foreach ( $splitTo as $newTo ) {
182 $status->merge( self::sendInternal(
183 [ $newTo ], $from, $subject, $body, $options ) );
184 }
185 return $status;
186 }
187 }
188
189 return self::sendInternal( $to, $from, $subject, $body, $options );
190 }
191
205 protected static function sendInternal(
206 array $to,
207 MailAddress $from,
208 $subject,
209 $body,
210 $options = []
211 ) {
212 $services = MediaWikiServices::getInstance();
213 $mainConfig = $services->getMainConfig();
214 $smtp = $mainConfig->get( MainConfigNames::SMTP );
215 $enotifMaxRecips = $mainConfig->get( MainConfigNames::EnotifMaxRecips );
216 $additionalMailParams = $mainConfig->get( MainConfigNames::AdditionalMailParams );
217
218 $replyto = $options['replyTo'] ?? null;
219 $contentType = $options['contentType'] ?? 'text/plain; charset=UTF-8';
220 $headers = $options['headers'] ?? [];
221
222 $hookRunner = new HookRunner( $services->getHookContainer() );
223 // Allow transformation of content, such as encrypting/signing
224 $error = false;
225 // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args
226 if ( !$hookRunner->onUserMailerTransformContent( $to, $from, $body, $error ) ) {
227 if ( $error ) {
228 return Status::newFatal( 'php-mail-error', $error );
229 } else {
230 return Status::newFatal( 'php-mail-error-unknown' );
231 }
232 }
233
263 $headers['From'] = $from->toString();
264 $returnPath = $from->address;
265 $extraParams = $additionalMailParams;
266
267 // Hook to generate custom VERP address for 'Return-Path'
268 $hookRunner->onUserMailerChangeReturnPath( $to, $returnPath );
269 // Add the envelope sender address using the -f command line option when PHP mail() is used.
270 // Will default to the $from->address when the UserMailerChangeReturnPath hook fails and the
271 // generated VERP address when the hook runs effectively.
272
273 // PHP runs this through escapeshellcmd(). However that's not sufficient
274 // escaping (e.g. due to spaces). MediaWiki's email sanitizer should generally
275 // be good enough, but just in case, put in double quotes, and remove any
276 // double quotes present (" is not allowed in emails, so should have no
277 // effect, although this might cause apostrophes to be double escaped)
278 $returnPathCLI = '"' . str_replace( '"', '', $returnPath ) . '"';
279 $extraParams .= ' -f ' . $returnPathCLI;
280
281 $headers['Return-Path'] = $returnPath;
282
283 if ( $replyto ) {
284 $headers['Reply-To'] = $replyto->toString();
285 }
286
287 $headers['Date'] = MWTimestamp::getLocalInstance()->format( 'r' );
288 $headers['Message-ID'] = self::makeMsgId();
289 $headers['X-Mailer'] = 'MediaWiki mailer';
290 $headers['List-Unsubscribe'] = '<' . SpecialPage::getTitleFor( 'Preferences' )
291 ->getFullURL( '', false, PROTO_CANONICAL ) . '>';
292
293 // Line endings need to be different on Unix and Windows due to
294 // the bug described at https://core.trac.wordpress.org/ticket/2603
295 $endl = PHP_EOL;
296
297 if ( is_array( $body ) ) {
298 // we are sending a multipart message
299 wfDebug( "Assembling multipart mime email" );
300 if ( wfIsWindows() ) {
301 $body['text'] = str_replace( "\n", "\r\n", $body['text'] );
302 $body['html'] = str_replace( "\n", "\r\n", $body['html'] );
303 }
304 $mime = new Mail_mime( [
305 'eol' => $endl,
306 'text_charset' => 'UTF-8',
307 'html_charset' => 'UTF-8'
308 ] );
309 $mime->setTXTBody( $body['text'] );
310 $mime->setHTMLBody( $body['html'] );
311 $body = $mime->get(); // must call get() before headers()
312 $headers = $mime->headers( $headers );
313 } else {
314 // sending text only
315 if ( wfIsWindows() ) {
316 $body = str_replace( "\n", "\r\n", $body );
317 }
318 $headers['MIME-Version'] = '1.0';
319 $headers['Content-type'] = $contentType;
320 $headers['Content-transfer-encoding'] = '8bit';
321 }
322
323 // allow transformation of MIME-encoded message
324 if ( !$hookRunner->onUserMailerTransformMessage(
325 $to, $from, $subject, $headers, $body, $error )
326 ) {
327 if ( $error ) {
328 return Status::newFatal( 'php-mail-error', $error );
329 } else {
330 return Status::newFatal( 'php-mail-error-unknown' );
331 }
332 }
333
334 $ret = $hookRunner->onAlternateUserMailer( $headers, $to, $from, $subject, $body );
335 if ( $ret === false ) {
336 // the hook implementation will return false to skip regular mail sending
337 LoggerFactory::getInstance( 'usermailer' )->info(
338 "Email to {to} from {from} with subject {subject} handled by AlternateUserMailer",
339 [
340 'to' => $to[0]->toString(),
341 'allto' => implode( ', ', array_map( 'strval', $to ) ),
342 'from' => $from->toString(),
343 'subject' => $subject,
344 ]
345 );
346 return Status::newGood();
347 } elseif ( $ret !== true ) {
348 // the hook implementation will return a string to pass an error message
349 return Status::newFatal( 'php-mail-error', $ret );
350 }
351
352 if ( is_array( $smtp ) ) {
353 $recips = array_map( 'strval', $to );
354
355 // Create the mail object using the Mail::factory method
356 $mail_object = Mail::factory( 'smtp', $smtp );
357 if ( PEAR::isError( $mail_object ) ) {
358 wfDebug( "PEAR::Mail factory failed: " . $mail_object->getMessage() );
359 return Status::newFatal( 'pear-mail-error', $mail_object->getMessage() );
360 }
361 '@phan-var Mail_smtp $mail_object';
362
363 wfDebug( "Sending mail via PEAR::Mail" );
364
365 $headers['Subject'] = self::quotedPrintable( $subject );
366
367 // When sending only to one recipient, shows it its email using To:
368 if ( count( $recips ) == 1 ) {
369 $headers['To'] = $recips[0];
370 }
371
372 // Split jobs since SMTP servers tends to limit the maximum
373 // number of possible recipients.
374 $chunks = array_chunk( $recips, $enotifMaxRecips );
375 foreach ( $chunks as $chunk ) {
376 $status = self::sendWithPear( $mail_object, $chunk, $headers, $body );
377 // FIXME : some chunks might be sent while others are not!
378 if ( !$status->isOK() ) {
379 return $status;
380 }
381 }
382 return Status::newGood();
383 } else {
384 // PHP mail()
385 if ( count( $to ) > 1 ) {
386 $headers['To'] = 'undisclosed-recipients:;';
387 }
388
389 wfDebug( "Sending mail via internal mail() function" );
390
391 self::$mErrorString = '';
392 $html_errors = ini_get( 'html_errors' );
393 ini_set( 'html_errors', '0' );
394 set_error_handler( [ self::class, 'errorHandler' ] );
395
396 try {
397 foreach ( $to as $recip ) {
398 $sent = mail(
399 $recip->toString(),
400 self::quotedPrintable( $subject ),
401 $body,
402 $headers,
403 $extraParams
404 );
405 }
406 } catch ( Exception $e ) {
407 restore_error_handler();
408 throw $e;
409 }
410
411 restore_error_handler();
412 ini_set( 'html_errors', $html_errors );
413
414 if ( self::$mErrorString ) {
415 wfDebug( "Error sending mail: " . self::$mErrorString );
416 return Status::newFatal( 'php-mail-error', self::$mErrorString );
417 } elseif ( !$sent ) {
418 // @phan-suppress-previous-line PhanPossiblyUndeclaredVariable sent set on success
419 // mail function only tells if there's an error
420 wfDebug( "Unknown error sending mail" );
421 return Status::newFatal( 'php-mail-error-unknown' );
422 } else {
423 LoggerFactory::getInstance( 'usermailer' )->info(
424 "Email sent to {to} from {from} with subject {subject}",
425 [
426 'to' => $to[0]->toString(),
427 'allto' => implode( ', ', array_map( 'strval', $to ) ),
428 'from' => $from->toString(),
429 'subject' => $subject,
430 ]
431 );
432 return Status::newGood();
433 }
434 }
435 }
436
443 private static function errorHandler( $code, $string ) {
444 self::$mErrorString = preg_replace( '/^mail\‍(\‍)(\s*\[.*?\])?: /', '', $string );
445 }
446
452 public static function sanitizeHeaderValue( $val ) {
453 return strtr( $val, [ "\r" => '', "\n" => '' ] );
454 }
455
469 public static function quotedPrintable( $string, $charset = '' ) {
470 // Probably incomplete; see RFC 2045
471 if ( !$charset ) {
472 $charset = 'UTF-8';
473 }
474 $charset = strtoupper( $charset );
475 $charset = str_replace( 'ISO-8859', 'ISO8859', $charset ); // ?
476
477 $illegal = '\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\xff=';
478 if ( !preg_match( "/[$illegal]/", $string ) ) {
479 return $string;
480 }
481
482 // T344912: Add period '.' char
483 $replace = $illegal . '.\t ?_';
484
485 $out = "=?$charset?Q?";
486 $out .= preg_replace_callback( "/[$replace]/",
487 static fn ( $m ) => sprintf( "=%02X", ord( $m[0] ) ),
488 $string
489 );
490 $out .= '?=';
491 return $out;
492 }
493}
wfIsWindows()
Check if the operating system is Windows.
const PROTO_CANONICAL
Definition Defines.php:216
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
Represent and format a single name and email address pair for SMTP.
toString()
Format and quote address for insertion in SMTP headers.
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
Create PSR-3 logger objects.
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:54
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 sanitizeHeaderValue( $val)
Strips bad characters from a header value to prevent PHP mail header injection attacks.
static send( $to, $from, $subject, $body, $options=[])
Send a raw email via SMTP (if $wgSMTP is set) or otherwise via PHP mail().
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.