Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
21.83% |
43 / 197 |
|
0.00% |
0 / 8 |
CRAP | |
0.00% |
0 / 1 |
UserMailer | |
21.83% |
43 / 197 |
|
0.00% |
0 / 8 |
1343.73 | |
0.00% |
0 / 1 |
sendWithPear | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
makeMsgId | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
20 | |||
send | |
67.44% |
29 / 43 |
|
0.00% |
0 / 1 |
31.46 | |||
sendInternal | |
0.00% |
0 / 119 |
|
0.00% |
0 / 1 |
462 | |||
errorHandler | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
sanitizeHeaderValue | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
rfc822Phrase | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
2 | |||
quotedPrintable | |
93.33% |
14 / 15 |
|
0.00% |
0 / 1 |
3.00 |
1 | <?php |
2 | |
3 | /** |
4 | * Classes used to send e-mails |
5 | * |
6 | * This program is free software; you can redistribute it and/or modify |
7 | * it under the terms of the GNU General Public License as published by |
8 | * the Free Software Foundation; either version 2 of the License, or |
9 | * (at your option) any later version. |
10 | * |
11 | * This program is distributed in the hope that it will be useful, |
12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
14 | * GNU General Public License for more details. |
15 | * |
16 | * You should have received a copy of the GNU General Public License along |
17 | * with this program; if not, write to the Free Software Foundation, Inc., |
18 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
19 | * http://www.gnu.org/copyleft/gpl.html |
20 | * |
21 | * @file |
22 | * @author Brooke Vibber |
23 | * @author <mail@tgries.de> |
24 | * @author Tim Starling |
25 | * @author Luke Welling lwelling@wikimedia.org |
26 | */ |
27 | |
28 | use MediaWiki\HookContainer\HookRunner; |
29 | use MediaWiki\Logger\LoggerFactory; |
30 | use MediaWiki\MainConfigNames; |
31 | use MediaWiki\MediaWikiServices; |
32 | use MediaWiki\SpecialPage\SpecialPage; |
33 | use MediaWiki\Status\Status; |
34 | use MediaWiki\Utils\MWTimestamp; |
35 | use MediaWiki\WikiMap\WikiMap; |
36 | |
37 | /** |
38 | * Collection of static functions for sending mail |
39 | */ |
40 | class UserMailer { |
41 | private static $mErrorString; |
42 | |
43 | /** |
44 | * Send mail using a PEAR mailer |
45 | * |
46 | * @param Mail_smtp $mailer |
47 | * @param string[]|string $dest |
48 | * @param array $headers |
49 | * @param string $body |
50 | * |
51 | * @return Status |
52 | */ |
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 | |
65 | /** |
66 | * Create a value suitable for the MessageId Header |
67 | * |
68 | * @return string |
69 | */ |
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 /** for cygwin */ ); |
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 | |
86 | /** |
87 | * This function will perform a direct (authenticated) login to |
88 | * a SMTP Server to use for mail relaying if 'wgSMTP' specifies an |
89 | * array of parameters. It requires PEAR:Mail to do that. |
90 | * Otherwise it just uses the standard PHP 'mail' function. |
91 | * |
92 | * @param MailAddress|MailAddress[] $to Recipient's email (or an array of them) |
93 | * @param MailAddress $from Sender's email |
94 | * @param string $subject Email's subject. |
95 | * @param string|string[] $body Email's text or Array of two strings to be the text and html bodies |
96 | * @param array $options Keys: |
97 | * 'replyTo' MailAddress |
98 | * 'contentType' string default 'text/plain; charset=UTF-8' |
99 | * 'headers' array Extra headers to set |
100 | * @return Status |
101 | */ |
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 | |
182 | /** |
183 | * Helper function fo UserMailer::send() which does the actual sending. It expects a $to |
184 | * list which the UserMailerSplitTo hook would not split further. |
185 | * @param MailAddress[] $to Array of recipients' email addresses |
186 | * @param MailAddress $from Sender's email |
187 | * @param string $subject Email's subject. |
188 | * @param string|string[] $body Email's text or Array of two strings to be the text and html bodies |
189 | * @param array $options Keys: |
190 | * 'replyTo' MailAddress |
191 | * 'contentType' string default 'text/plain; charset=UTF-8' |
192 | * 'headers' array Extra headers to set |
193 | * @return Status |
194 | */ |
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 | |
224 | /** |
225 | * Forge email headers |
226 | * ------------------- |
227 | * |
228 | * WARNING |
229 | * |
230 | * DO NOT add To: or Subject: headers at this step. They need to be |
231 | * handled differently depending upon the mailer we are going to use. |
232 | * |
233 | * To: |
234 | * PHP mail() first argument is the mail receiver. The argument is |
235 | * used as a recipient destination and as a To header. |
236 | * |
237 | * PEAR mailer has a recipient argument which is only used to |
238 | * send the mail. If no To header is given, PEAR will set it to |
239 | * to 'undisclosed-recipients:'. |
240 | * |
241 | * NOTE: To: is for presentation, the actual recipient is specified |
242 | * by the mailer using the Rcpt-To: header. |
243 | * |
244 | * Subject: |
245 | * PHP mail() second argument to pass the subject, passing a Subject |
246 | * as an additional header will result in a duplicate header. |
247 | * |
248 | * PEAR mailer should be passed a Subject header. |
249 | * |
250 | * -- hashar 20120218 |
251 | */ |
252 | |
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 | |
427 | /** |
428 | * Set the mail error message in self::$mErrorString |
429 | * |
430 | * @param int $code Error number |
431 | * @param string $string Error message |
432 | */ |
433 | private static function errorHandler( $code, $string ) { |
434 | self::$mErrorString = preg_replace( '/^mail\(\)(\s*\[.*?\])?: /', '', $string ); |
435 | } |
436 | |
437 | /** |
438 | * Strips bad characters from a header value to prevent PHP mail header injection attacks |
439 | * @param string $val String to be sanitized |
440 | * @return string |
441 | */ |
442 | public static function sanitizeHeaderValue( $val ) { |
443 | return strtr( $val, [ "\r" => '', "\n" => '' ] ); |
444 | } |
445 | |
446 | /** |
447 | * Converts a string into a valid RFC 822 "phrase", such as is used for the sender name |
448 | * @deprecated 1.38 This method has not been used by anything |
449 | * @param string $phrase |
450 | * @return string |
451 | */ |
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 | |
461 | /** |
462 | * Converts a string into quoted-printable format |
463 | * @since 1.17 |
464 | * |
465 | * From PHP5.3 there is a built in function quoted_printable_encode() |
466 | * This method does not duplicate that. |
467 | * This method is doing Q encoding inside encoded-words as defined by RFC 2047 |
468 | * This is for email headers. |
469 | * The built in quoted_printable_encode() is for email bodies |
470 | * @param string $string |
471 | * @param string $charset |
472 | * @return string |
473 | */ |
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 | } |