Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
49.74% |
96 / 193 |
|
14.29% |
1 / 7 |
CRAP | |
0.00% |
0 / 1 |
UserMailer | |
49.74% |
96 / 193 |
|
14.29% |
1 / 7 |
381.20 | |
0.00% |
0 / 1 |
sendWithPear | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
6 | |||
makeMsgId | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
4.02 | |||
send | |
67.44% |
29 / 43 |
|
0.00% |
0 / 1 |
31.46 | |||
sendInternal | |
36.97% |
44 / 119 |
|
0.00% |
0 / 1 |
131.40 | |||
errorHandler | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
sanitizeHeaderValue | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
quotedPrintable | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
3 |
1 | <?php |
2 | /** |
3 | * This program is free software; you can redistribute it and/or modify |
4 | * it under the terms of the GNU General Public License as published by |
5 | * the Free Software Foundation; either version 2 of the License, or |
6 | * (at your option) any later version. |
7 | * |
8 | * This program is distributed in the hope that it will be useful, |
9 | * but WITHOUT ANY WARRANTY; without even the implied warranty of |
10 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
11 | * GNU General Public License for more details. |
12 | * |
13 | * You should have received a copy of the GNU General Public License along |
14 | * with this program; if not, write to the Free Software Foundation, Inc., |
15 | * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. |
16 | * http://www.gnu.org/copyleft/gpl.html |
17 | * |
18 | * @file |
19 | * @author Brooke Vibber |
20 | * @author <mail@tgries.de> |
21 | * @author Tim Starling |
22 | * @author Luke Welling lwelling@wikimedia.org |
23 | */ |
24 | |
25 | use MediaWiki\HookContainer\HookRunner; |
26 | use MediaWiki\Logger\LoggerFactory; |
27 | use MediaWiki\MainConfigNames; |
28 | use MediaWiki\MediaWikiServices; |
29 | use MediaWiki\SpecialPage\SpecialPage; |
30 | use MediaWiki\Status\Status; |
31 | use MediaWiki\Utils\MWTimestamp; |
32 | use MediaWiki\WikiMap\WikiMap; |
33 | |
34 | /** |
35 | * @defgroup Mail Mail |
36 | */ |
37 | |
38 | /** |
39 | * Collection of static functions for sending mail |
40 | * |
41 | * @since 1.12 |
42 | * @ingroup Mail |
43 | */ |
44 | class UserMailer { |
45 | /** @var string */ |
46 | private static $mErrorString; |
47 | |
48 | /** |
49 | * Send mail using a PEAR mailer |
50 | * |
51 | * @param Mail_smtp $mailer |
52 | * @param string[]|string $dest |
53 | * @param array $headers |
54 | * @param string $body |
55 | * |
56 | * @return Status |
57 | */ |
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 | |
70 | /** |
71 | * Create a value suitable for the MessageId Header |
72 | * |
73 | * @return string |
74 | */ |
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 /** for cygwin */ ); |
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 | |
91 | /** |
92 | * Send a raw email via SMTP (if $wgSMTP is set) or otherwise via PHP mail(). |
93 | * |
94 | * This function perform a direct (authenticated) login to a SMTP server, |
95 | * to use for mail relaying, if 'wgSMTP' specifies an array of parameters. |
96 | * This uses the pear/mail package. |
97 | * |
98 | * Otherwise it uses the standard PHP 'mail' function, which in turn relies |
99 | * on the server's sendmail configuration. |
100 | * |
101 | * @since 1.12 |
102 | * @param MailAddress|MailAddress[] $to Recipient's email (or an array of them) |
103 | * @param MailAddress $from Sender's email |
104 | * @param string $subject Email's subject. |
105 | * @param string|string[] $body Email's text or Array of two strings to be the text and html bodies |
106 | * @param array $options Keys: |
107 | * 'replyTo' MailAddress |
108 | * 'contentType' string default 'text/plain; charset=UTF-8' |
109 | * 'headers' array Extra headers to set |
110 | * @return Status |
111 | */ |
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 | |
192 | /** |
193 | * Helper function fo UserMailer::send() which does the actual sending. It expects a $to |
194 | * list which the UserMailerSplitTo hook would not split further. |
195 | * @param MailAddress[] $to Array of recipients' email addresses |
196 | * @param MailAddress $from Sender's email |
197 | * @param string $subject Email's subject. |
198 | * @param string|string[] $body Email's text or Array of two strings to be the text and html bodies |
199 | * @param array $options Keys: |
200 | * 'replyTo' MailAddress |
201 | * 'contentType' string default 'text/plain; charset=UTF-8' |
202 | * 'headers' array Extra headers to set |
203 | * @return Status |
204 | */ |
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 | |
234 | /** |
235 | * Forge email headers |
236 | * ------------------- |
237 | * |
238 | * WARNING |
239 | * |
240 | * DO NOT add To: or Subject: headers at this step. They need to be |
241 | * handled differently depending upon the mailer we are going to use. |
242 | * |
243 | * To: |
244 | * PHP mail() first argument is the mail receiver. The argument is |
245 | * used as a recipient destination and as a To header. |
246 | * |
247 | * PEAR mailer has a recipient argument which is only used to |
248 | * send the mail. If no To header is given, PEAR will set it to |
249 | * to 'undisclosed-recipients:'. |
250 | * |
251 | * NOTE: To: is for presentation, the actual recipient is specified |
252 | * by the mailer using the Rcpt-To: header. |
253 | * |
254 | * Subject: |
255 | * PHP mail() second argument to pass the subject, passing a Subject |
256 | * as an additional header will result in a duplicate header. |
257 | * |
258 | * PEAR mailer should be passed a Subject header. |
259 | * |
260 | * -- hashar 20120218 |
261 | */ |
262 | |
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 | |
437 | /** |
438 | * Set the mail error message in self::$mErrorString |
439 | * |
440 | * @param int $code Error number |
441 | * @param string $string Error message |
442 | */ |
443 | private static function errorHandler( $code, $string ) { |
444 | self::$mErrorString = preg_replace( '/^mail\(\)(\s*\[.*?\])?: /', '', $string ); |
445 | } |
446 | |
447 | /** |
448 | * Strips bad characters from a header value to prevent PHP mail header injection attacks |
449 | * @param string $val String to be sanitized |
450 | * @return string |
451 | */ |
452 | public static function sanitizeHeaderValue( $val ) { |
453 | return strtr( $val, [ "\r" => '', "\n" => '' ] ); |
454 | } |
455 | |
456 | /** |
457 | * Converts a string into quoted-printable format |
458 | * @since 1.17 |
459 | * |
460 | * From PHP5.3 there is a built in function quoted_printable_encode() |
461 | * This method does not duplicate that. |
462 | * This method is doing Q encoding inside encoded-words as defined by RFC 2047 |
463 | * This is for email headers. |
464 | * The built in quoted_printable_encode() is for email bodies |
465 | * @param string $string |
466 | * @param string $charset |
467 | * @return string |
468 | */ |
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 | } |