MediaWiki master
UserMailer.php
Go to the documentation of this file.
1<?php
11namespace MediaWiki\Mail;
12
13use Exception;
14use Mail;
15use Mail_mime;
16use Mail_smtp;
25use PEAR;
26use RuntimeException;
27
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() {
71
72 $smtp = $services->getMainConfig()->get( MainConfigNames::SMTP );
73 $server = $services->getMainConfig()->get( MainConfigNames::Server );
74 $domainId = WikiMap::getCurrentWikiDbDomain()->getId();
75 $msgid = uniqid( $domainId . ".", true );
76
77 if ( is_array( $smtp ) && isset( $smtp['IDHost'] ) && $smtp['IDHost'] ) {
78 $domain = $smtp['IDHost'];
79 } else {
80 $domain = parse_url( $server, PHP_URL_HOST ) ?? '';
81 }
82 return "<$msgid@$domain>";
83 }
84
106 public static function send( $to, $from, $subject, $body, $options = [] ) {
107 $services = MediaWikiServices::getInstance();
108 $allowHTMLEmail = $services->getMainConfig()->get(
110
111 if ( !isset( $options['contentType'] ) ) {
112 $options['contentType'] = 'text/plain; charset=UTF-8';
113 }
114
115 if ( !is_array( $to ) ) {
116 $to = [ $to ];
117 }
118
119 // mail body must have some content
120 $minBodyLen = 10;
121 // arbitrary but longer than Array or Object to detect casting error
122
123 // body must either be a string or an array with text and body
124 if (
125 !(
126 !is_array( $body ) &&
127 strlen( $body ) >= $minBodyLen
128 )
129 &&
130 !(
131 is_array( $body ) &&
132 isset( $body['text'] ) &&
133 isset( $body['html'] ) &&
134 strlen( $body['text'] ) >= $minBodyLen &&
135 strlen( $body['html'] ) >= $minBodyLen
136 )
137 ) {
138 // if it is neither we have a problem
139 return Status::newFatal( 'user-mail-no-body' );
140 }
141
142 if ( !$allowHTMLEmail && is_array( $body ) ) {
143 // HTML not wanted. Dump it.
144 $body = $body['text'];
145 }
146
147 wfDebug( __METHOD__ . ': sending mail to ' . implode( ', ', $to ) );
148
149 // Make sure we have at least one address
150 $has_address = false;
151 foreach ( $to as $u ) {
152 if ( $u->address ) {
153 $has_address = true;
154 break;
155 }
156 }
157 if ( !$has_address ) {
158 return Status::newFatal( 'user-mail-no-addy' );
159 }
160
161 // give a chance to UserMailerTransformContents subscribers who need to deal with each
162 // target differently to split up the address list
163 if ( count( $to ) > 1 ) {
164 $oldTo = $to;
165 ( new HookRunner( $services->getHookContainer() ) )->onUserMailerSplitTo( $to );
166 if ( $oldTo != $to ) {
167 $splitTo = array_diff( $oldTo, $to );
168 $to = array_diff( $oldTo, $splitTo ); // ignore new addresses added in the hook
169 // first send to non-split address list, then to split addresses one by one
170 $status = Status::newGood();
171 if ( $to ) {
172 $status->merge( self::sendInternal(
173 $to, $from, $subject, $body, $options ) );
174 }
175 foreach ( $splitTo as $newTo ) {
176 $status->merge( self::sendInternal(
177 [ $newTo ], $from, $subject, $body, $options ) );
178 }
179 return $status;
180 }
181 }
182
183 return self::sendInternal( $to, $from, $subject, $body, $options );
184 }
185
199 protected static function sendInternal(
200 array $to,
201 MailAddress $from,
202 $subject,
203 $body,
204 $options = []
205 ) {
206 $services = MediaWikiServices::getInstance();
207 $mainConfig = $services->getMainConfig();
208 $smtp = $mainConfig->get( MainConfigNames::SMTP );
209 $additionalMailParams = $mainConfig->get( MainConfigNames::AdditionalMailParams );
210
211 $replyto = $options['replyTo'] ?? null;
212 $contentType = $options['contentType'] ?? 'text/plain; charset=UTF-8';
213 $headers = $options['headers'] ?? [];
214
215 $hookRunner = new HookRunner( $services->getHookContainer() );
216 // Allow transformation of content, such as encrypting/signing
217 $error = false;
218 // @phan-suppress-next-line PhanTypeMismatchArgument Type mismatch on pass-by-ref args
219 if ( !$hookRunner->onUserMailerTransformContent( $to, $from, $body, $error ) ) {
220 if ( $error ) {
221 return Status::newFatal( 'php-mail-error', $error );
222 } else {
223 return Status::newFatal( 'php-mail-error-unknown' );
224 }
225 }
226
256 $headers['From'] = $from->toString();
257 $returnPath = $from->address;
258 $extraParams = $additionalMailParams;
259
260 // Hook to generate custom VERP address for 'Return-Path'
261 $hookRunner->onUserMailerChangeReturnPath( $to, $returnPath );
262 // Add the envelope sender address using the -f command line option when PHP mail() is used.
263 // Will default to the $from->address when the UserMailerChangeReturnPath hook fails and the
264 // generated VERP address when the hook runs effectively.
265
266 // PHP runs this through escapeshellcmd(). However that's not sufficient
267 // escaping (e.g. due to spaces). MediaWiki's email sanitizer should generally
268 // be good enough, but just in case, put in double quotes, and remove any
269 // double quotes present (" is not allowed in emails, so should have no
270 // effect, although this might cause apostrophes to be double escaped)
271 $returnPathCLI = '"' . str_replace( '"', '', $returnPath ) . '"';
272 $extraParams .= ' -f ' . $returnPathCLI;
273
274 $headers['Return-Path'] = $returnPath;
275
276 if ( $replyto ) {
277 $headers['Reply-To'] = $replyto->toString();
278 }
279
280 $headers['Date'] = MWTimestamp::getLocalInstance()->format( 'r' );
281 $headers['Message-ID'] = self::makeMsgId();
282 $headers['X-Mailer'] = 'MediaWiki mailer';
283 $headers['List-Unsubscribe'] = '<' . SpecialPage::getTitleFor( 'Preferences' )
284 ->getFullURL( '', false, PROTO_CANONICAL ) . '>';
285
286 // Line endings need to be different on Unix and Windows due to
287 // the bug described at https://core.trac.wordpress.org/ticket/2603
288 $endl = PHP_EOL;
289
290 if ( is_array( $body ) ) {
291 // we are sending a multipart message
292 wfDebug( "Assembling multipart mime email" );
293 if ( wfIsWindows() ) {
294 $body['text'] = str_replace( "\n", "\r\n", $body['text'] );
295 $body['html'] = str_replace( "\n", "\r\n", $body['html'] );
296 }
297 $mime = new Mail_mime( [
298 'eol' => $endl,
299 'text_charset' => 'UTF-8',
300 'html_charset' => 'UTF-8'
301 ] );
302 $mime->setTXTBody( $body['text'] );
303 $mime->setHTMLBody( $body['html'] );
304 $body = $mime->get(); // must call get() before headers()
305 $headers = $mime->headers( $headers );
306 } else {
307 // sending text only
308 if ( wfIsWindows() ) {
309 $body = str_replace( "\n", "\r\n", $body );
310 }
311 $headers['MIME-Version'] = '1.0';
312 $headers['Content-type'] = $contentType;
313 $headers['Content-transfer-encoding'] = '8bit';
314 }
315
316 // allow transformation of MIME-encoded message
317 if ( !$hookRunner->onUserMailerTransformMessage(
318 $to, $from, $subject, $headers, $body, $error )
319 ) {
320 if ( $error ) {
321 return Status::newFatal( 'php-mail-error', $error );
322 } else {
323 return Status::newFatal( 'php-mail-error-unknown' );
324 }
325 }
326
327 $ret = $hookRunner->onAlternateUserMailer( $headers, $to, $from, $subject, $body );
328 if ( $ret === false ) {
329 // the hook implementation will return false to skip regular mail sending
330 LoggerFactory::getInstance( 'usermailer' )->info(
331 "Email to {to} from {from} with subject {subject} handled by AlternateUserMailer",
332 [
333 'to' => $to[0]->toString(),
334 'allto' => implode( ', ', array_map( 'strval', $to ) ),
335 'from' => $from->toString(),
336 'subject' => $subject,
337 ]
338 );
339 return Status::newGood();
340 } elseif ( $ret !== true ) {
341 // the hook implementation will return a string to pass an error message
342 return Status::newFatal( 'php-mail-error', $ret );
343 }
344
345 if ( is_array( $smtp ) ) {
346 $receips = array_map( 'strval', $to );
347
348 if ( count( $receips ) !== 1 ) {
349 throw new RuntimeException(
350 __METHOD__ . 'somehow called for multiple recipients, no longer supported.'
351 );
352 }
353 $recipient = $receips[0];
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 // Shows recipient its email using To:
368 $headers['To'] = $recipient;
369
370 $status = self::sendWithPear( $mail_object, $recipient, $headers, $body );
371 if ( !$status->isOK() ) {
372 return $status;
373 }
374 return Status::newGood();
375 } else {
376 // PHP mail()
377 if ( count( $to ) > 1 ) {
378 $headers['To'] = 'undisclosed-recipients:;';
379 }
380
381 wfDebug( "Sending mail via internal mail() function" );
382
383 self::$mErrorString = '';
384 $html_errors = ini_get( 'html_errors' );
385 ini_set( 'html_errors', '0' );
386 set_error_handler( self::errorHandler( ... ) );
387
388 try {
389 foreach ( $to as $recip ) {
390 $sent = mail(
391 $recip->toString(),
392 self::quotedPrintable( $subject ),
393 $body,
394 $headers,
395 $extraParams
396 );
397 }
398 } catch ( Exception $e ) {
399 restore_error_handler();
400 throw $e;
401 }
402
403 restore_error_handler();
404 ini_set( 'html_errors', $html_errors );
405
406 if ( self::$mErrorString ) {
407 wfDebug( "Error sending mail: " . self::$mErrorString );
408 return Status::newFatal( 'php-mail-error', self::$mErrorString );
409 } elseif ( !$sent ) {
410 // @phan-suppress-previous-line PhanPossiblyUndeclaredVariable sent set on success
411 // mail function only tells if there's an error
412 wfDebug( "Unknown error sending mail" );
413 return Status::newFatal( 'php-mail-error-unknown' );
414 } else {
415 LoggerFactory::getInstance( 'usermailer' )->info(
416 "Email sent to {to} from {from} with subject {subject}",
417 [
418 'to' => $to[0]->toString(),
419 'allto' => implode( ', ', array_map( 'strval', $to ) ),
420 'from' => $from->toString(),
421 'subject' => $subject,
422 ]
423 );
424 return Status::newGood();
425 }
426 }
427 }
428
435 private static function errorHandler( $code, $string ): bool {
436 if ( self::$mErrorString !== '' ) {
437 self::$mErrorString .= "\n";
438 }
439 self::$mErrorString .= preg_replace( '/^mail\‍(\‍)(\s*\[.*?\])?: /', '', $string );
440 return false;
441 }
442
456 public static function quotedPrintable( $string, $charset = '' ) {
457 // Probably incomplete; see RFC 2045
458 if ( !$charset ) {
459 $charset = 'UTF-8';
460 }
461 $charset = strtoupper( $charset );
462 $charset = str_replace( 'ISO-8859', 'ISO8859', $charset ); // ?
463
464 $illegal = '\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\xff=';
465 if ( !preg_match( "/[$illegal]/", $string ) ) {
466 return $string;
467 }
468
469 // T344912: Add period '.' char
470 // T385403: Add comma ',' char
471 $replace = $illegal . '.,\t ?_';
472
473 $out = "=?$charset?Q?";
474 $out .= preg_replace_callback( "/[$replace]/",
475 static fn ( $m ) => sprintf( "=%02X", ord( $m[0] ) ),
476 $string
477 );
478 $out .= '?=';
479 return $out;
480 }
481}
482
484class_alias( UserMailer::class, 'UserMailer' );
wfIsWindows()
Check if the operating system is Windows.
const PROTO_CANONICAL
Definition Defines.php:223
wfDebug( $text, $dest='all', array $context=[])
Sends a line to the debug log if enabled or, optionally, to a comment in output.
if(!defined('MW_SETUP_CALLBACK'))
Definition WebStart.php:68
This class provides an implementation of the core hook interfaces, forwarding hook calls to HookConta...
static factory( $command, $params=[])
Create the appropriate object to handle a specific job.
Definition Job.php:62
Create PSR-3 logger objects.
Represent and format a single name and email address pair for SMTP.
toString()
Format and quote address for insertion in SMTP headers.
Collection of static functions for sending mail.
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.
A class containing constants representing the names of configuration variables.
const Server
Name constant for the Server setting, for use with Config::get()
const AllowHTMLEmail
Name constant for the AllowHTMLEmail setting, for use with Config::get()
const AdditionalMailParams
Name constant for the AdditionalMailParams setting, for use with Config::get()
const SMTP
Name constant for the SMTP setting, for use with Config::get()
Service locator for MediaWiki core services.
static getInstance()
Returns the global default instance of the top level service locator.
Parent class for all special pages.
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,...
Generic operation result class Has warning/error list, boolean status and arbitrary value.
Definition Status.php:44
Library for creating and parsing MW-style timestamps.
Tools for dealing with other locally-hosted wikis.
Definition WikiMap.php:19