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