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