Completed
Branch master (939199)
by
unknown
39:35
created

includes/mail/UserMailer.php (1 issue)

Upgrade to new PHP Analysis Engine

These results are based on our legacy PHP analysis, consider migrating to our new PHP analysis engine instead. Learn more

1
<?php
2
/**
3
 * Classes used to send e-mails
4
 *
5
 * This program is free software; you can redistribute it and/or modify
6
 * it under the terms of the GNU General Public License as published by
7
 * the Free Software Foundation; either version 2 of the License, or
8
 * (at your option) any later version.
9
 *
10
 * This program is distributed in the hope that it will be useful,
11
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13
 * GNU General Public License for more details.
14
 *
15
 * You should have received a copy of the GNU General Public License along
16
 * with this program; if not, write to the Free Software Foundation, Inc.,
17
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18
 * http://www.gnu.org/copyleft/gpl.html
19
 *
20
 * @file
21
 * @author <[email protected]>
22
 * @author <[email protected]>
23
 * @author Tim Starling
24
 * @author Luke Welling [email protected]
25
 */
26
27
/**
28
 * Collection of static functions for sending mail
29
 */
30
class UserMailer {
31
	private static $mErrorString;
32
33
	/**
34
	 * Send mail using a PEAR mailer
35
	 *
36
	 * @param Mail_smtp $mailer
37
	 * @param string $dest
38
	 * @param string $headers
39
	 * @param string $body
40
	 *
41
	 * @return Status
42
	 */
43
	protected static function sendWithPear( $mailer, $dest, $headers, $body ) {
44
		$mailResult = $mailer->send( $dest, $headers, $body );
45
46
		// Based on the result return an error string,
47 View Code Duplication
		if ( PEAR::isError( $mailResult ) ) {
48
			wfDebug( "PEAR::Mail failed: " . $mailResult->getMessage() . "\n" );
49
			return Status::newFatal( 'pear-mail-error', $mailResult->getMessage() );
50
		} else {
51
			return Status::newGood();
52
		}
53
	}
54
55
	/**
56
	 * Creates a single string from an associative array
57
	 *
58
	 * @param array $headers Associative Array: keys are header field names,
59
	 *                 values are ... values.
60
	 * @param string $endl The end of line character.  Defaults to "\n"
61
	 *
62
	 * Note RFC2822 says newlines must be CRLF (\r\n)
63
	 * but php mail naively "corrects" it and requires \n for the "correction" to work
64
	 *
65
	 * @return string
66
	 */
67
	static function arrayToHeaderString( $headers, $endl = PHP_EOL ) {
68
		$strings = [];
69
		foreach ( $headers as $name => $value ) {
70
			// Prevent header injection by stripping newlines from value
71
			$value = self::sanitizeHeaderValue( $value );
72
			$strings[] = "$name: $value";
73
		}
74
		return implode( $endl, $strings );
75
	}
76
77
	/**
78
	 * Create a value suitable for the MessageId Header
79
	 *
80
	 * @return string
81
	 */
82
	static function makeMsgId() {
83
		global $wgSMTP, $wgServer;
84
85
		$msgid = uniqid( wfWikiID() . ".", true ); /* true required for cygwin */
86
		if ( is_array( $wgSMTP ) && isset( $wgSMTP['IDHost'] ) && $wgSMTP['IDHost'] ) {
87
			$domain = $wgSMTP['IDHost'];
88
		} else {
89
			$url = wfParseUrl( $wgServer );
90
			$domain = $url['host'];
91
		}
92
		return "<$msgid@$domain>";
93
	}
94
95
	/**
96
	 * This function will perform a direct (authenticated) login to
97
	 * a SMTP Server to use for mail relaying if 'wgSMTP' specifies an
98
	 * array of parameters. It requires PEAR:Mail to do that.
99
	 * Otherwise it just uses the standard PHP 'mail' function.
100
	 *
101
	 * @param MailAddress|MailAddress[] $to Recipient's email (or an array of them)
102
	 * @param MailAddress $from Sender's email
103
	 * @param string $subject Email's subject.
104
	 * @param string $body Email's text or Array of two strings to be the text and html bodies
105
	 * @param array $options:
106
	 * 		'replyTo' MailAddress
107
	 * 		'contentType' string default 'text/plain; charset=UTF-8'
108
	 * 		'headers' array Extra headers to set
109
	 *
110
	 * @throws MWException
111
	 * @throws Exception
112
	 * @return Status
113
	 */
114
	public static function send( $to, $from, $subject, $body, $options = [] ) {
115
		global $wgAllowHTMLEmail;
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 ( !$wgAllowHTMLEmail && is_array( $body ) ) {
149
			// HTML not wanted.  Dump it.
150
			$body = $body['text'];
151
		}
152
153
		wfDebug( __METHOD__ . ': sending mail to ' . implode( ', ', $to ) . "\n" );
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
			Hooks::run( 'UserMailerSplitTo', [ &$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( UserMailer::sendInternal(
179
						$to, $from, $subject, $body, $options ) );
180
				}
181
				foreach ( $splitTo as $newTo ) {
182
					$status->merge( UserMailer::sendInternal(
183
						[ $newTo ], $from, $subject, $body, $options ) );
184
				}
185
				return $status;
186
			}
187
		}
188
189
		return UserMailer::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 $body Email's text or Array of two strings to be the text and html bodies
199
	 * @param array $options:
0 ignored issues
show
There is no parameter named $options:. Did you maybe mean $options?

This check looks for PHPDoc comments describing methods or function parameters that do not exist on the corresponding method or function. It has, however, found a similar but not annotated parameter which might be a good fit.

Consider the following example. The parameter $ireland is not defined by the method finale(...).

/**
 * @param array $germany
 * @param array $ireland
 */
function finale($germany, $island) {
    return "2:1";
}

The most likely cause is that the parameter was changed, but the annotation was not.

Loading history...
200
	 * 		'replyTo' MailAddress
201
	 * 		'contentType' string default 'text/plain; charset=UTF-8'
202
	 * 		'headers' array Extra headers to set
203
	 *
204
	 * @throws MWException
205
	 * @throws Exception
206
	 * @return Status
207
	 */
208
	protected static function sendInternal(
209
		array $to,
210
		MailAddress $from,
211
		$subject,
212
		$body,
213
		$options = []
214
	) {
215
		global $wgSMTP, $wgEnotifMaxRecips, $wgAdditionalMailParams;
216
		$mime = null;
217
218
		$replyto = isset( $options['replyTo'] ) ? $options['replyTo'] : null;
219
		$contentType = isset( $options['contentType'] ) ?
220
			$options['contentType'] : 'text/plain; charset=UTF-8';
221
		$headers = isset( $options['headers'] ) ? $options['headers'] : [];
222
223
		// Allow transformation of content, such as encrypting/signing
224
		$error = false;
225 View Code Duplication
		if ( !Hooks::run( 'UserMailerTransformContent', [ $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
233
		/**
234
		 * Forge email headers
235
		 * -------------------
236
		 *
237
		 * WARNING
238
		 *
239
		 * DO NOT add To: or Subject: headers at this step. They need to be
240
		 * handled differently depending upon the mailer we are going to use.
241
		 *
242
		 * To:
243
		 *  PHP mail() first argument is the mail receiver. The argument is
244
		 *  used as a recipient destination and as a To header.
245
		 *
246
		 *  PEAR mailer has a recipient argument which is only used to
247
		 *  send the mail. If no To header is given, PEAR will set it to
248
		 *  to 'undisclosed-recipients:'.
249
		 *
250
		 *  NOTE: To: is for presentation, the actual recipient is specified
251
		 *  by the mailer using the Rcpt-To: header.
252
		 *
253
		 * Subject:
254
		 *  PHP mail() second argument to pass the subject, passing a Subject
255
		 *  as an additional header will result in a duplicate header.
256
		 *
257
		 *  PEAR mailer should be passed a Subject header.
258
		 *
259
		 * -- hashar 20120218
260
		 */
261
262
		$headers['From'] = $from->toString();
263
		$returnPath = $from->address;
264
		$extraParams = $wgAdditionalMailParams;
265
266
		// Hook to generate custom VERP address for 'Return-Path'
267
		Hooks::run( 'UserMailerChangeReturnPath', [ $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
		$extraParams .= ' -f ' . $returnPath;
272
273
		$headers['Return-Path'] = $returnPath;
274
275
		if ( $replyto ) {
276
			$headers['Reply-To'] = $replyto->toString();
277
		}
278
279
		$headers['Date'] = MWTimestamp::getLocalInstance()->format( 'r' );
280
		$headers['Message-ID'] = self::makeMsgId();
281
		$headers['X-Mailer'] = 'MediaWiki mailer';
282
		$headers['List-Unsubscribe'] = '<' . SpecialPage::getTitleFor( 'Preferences' )
283
			->getFullURL( '', false, PROTO_CANONICAL ) . '>';
284
285
		// Line endings need to be different on Unix and Windows due to
286
		// the bug described at http://trac.wordpress.org/ticket/2603
287
		$endl = PHP_EOL;
288
289
		if ( is_array( $body ) ) {
290
			// we are sending a multipart message
291
			wfDebug( "Assembling multipart mime email\n" );
292
			if ( !stream_resolve_include_path( 'Mail/mime.php' ) ) {
293
				wfDebug( "PEAR Mail_Mime package is not installed. Falling back to text email.\n" );
294
				// remove the html body for text email fall back
295
				$body = $body['text'];
296
			} else {
297
				// Check if pear/mail_mime is already loaded (via composer)
298
				if ( !class_exists( 'Mail_mime' ) ) {
299
					require_once 'Mail/mime.php';
300
				}
301
				if ( wfIsWindows() ) {
302
					$body['text'] = str_replace( "\n", "\r\n", $body['text'] );
303
					$body['html'] = str_replace( "\n", "\r\n", $body['html'] );
304
				}
305
				$mime = new Mail_mime( [
306
					'eol' => $endl,
307
					'text_charset' => 'UTF-8',
308
					'html_charset' => 'UTF-8'
309
				] );
310
				$mime->setTXTBody( $body['text'] );
311
				$mime->setHTMLBody( $body['html'] );
312
				$body = $mime->get(); // must call get() before headers()
313
				$headers = $mime->headers( $headers );
314
			}
315
		}
316
		if ( $mime === null ) {
317
			// sending text only, either deliberately or as a fallback
318
			if ( wfIsWindows() ) {
319
				$body = str_replace( "\n", "\r\n", $body );
320
			}
321
			$headers['MIME-Version'] = '1.0';
322
			$headers['Content-type'] = $contentType;
323
			$headers['Content-transfer-encoding'] = '8bit';
324
		}
325
326
		// allow transformation of MIME-encoded message
327 View Code Duplication
		if ( !Hooks::run( 'UserMailerTransformMessage',
328
			[ $to, $from, &$subject, &$headers, &$body, &$error ] )
329
		) {
330
			if ( $error ) {
331
				return Status::newFatal( 'php-mail-error', $error );
332
			} else {
333
				return Status::newFatal( 'php-mail-error-unknown' );
334
			}
335
		}
336
337
		$ret = Hooks::run( 'AlternateUserMailer', [ $headers, $to, $from, $subject, $body ] );
338
		if ( $ret === false ) {
339
			// the hook implementation will return false to skip regular mail sending
340
			return Status::newGood();
341
		} elseif ( $ret !== true ) {
342
			// the hook implementation will return a string to pass an error message
343
			return Status::newFatal( 'php-mail-error', $ret );
344
		}
345
346
		if ( is_array( $wgSMTP ) ) {
347
			// Check if pear/mail is already loaded (via composer)
348
			if ( !class_exists( 'Mail' ) ) {
349
				// PEAR MAILER
350
				if ( !stream_resolve_include_path( 'Mail.php' ) ) {
351
					throw new MWException( 'PEAR mail package is not installed' );
352
				}
353
				require_once 'Mail.php';
354
			}
355
356
			MediaWiki\suppressWarnings();
357
358
			// Create the mail object using the Mail::factory method
359
			$mail_object =& Mail::factory( 'smtp', $wgSMTP );
360 View Code Duplication
			if ( PEAR::isError( $mail_object ) ) {
361
				wfDebug( "PEAR::Mail factory failed: " . $mail_object->getMessage() . "\n" );
362
				MediaWiki\restoreWarnings();
363
				return Status::newFatal( 'pear-mail-error', $mail_object->getMessage() );
364
			}
365
366
			wfDebug( "Sending mail via PEAR::Mail\n" );
367
368
			$headers['Subject'] = self::quotedPrintable( $subject );
369
370
			// When sending only to one recipient, shows it its email using To:
371
			if ( count( $to ) == 1 ) {
372
				$headers['To'] = $to[0]->toString();
373
			}
374
375
			// Split jobs since SMTP servers tends to limit the maximum
376
			// number of possible recipients.
377
			$chunks = array_chunk( $to, $wgEnotifMaxRecips );
378
			foreach ( $chunks as $chunk ) {
379
				$status = self::sendWithPear( $mail_object, $chunk, $headers, $body );
380
				// FIXME : some chunks might be sent while others are not!
381
				if ( !$status->isOK() ) {
382
					MediaWiki\restoreWarnings();
383
					return $status;
384
				}
385
			}
386
			MediaWiki\restoreWarnings();
387
			return Status::newGood();
388
		} else {
389
			// PHP mail()
390
			if ( count( $to ) > 1 ) {
391
				$headers['To'] = 'undisclosed-recipients:;';
392
			}
393
			$headers = self::arrayToHeaderString( $headers, $endl );
394
395
			wfDebug( "Sending mail via internal mail() function\n" );
396
397
			self::$mErrorString = '';
398
			$html_errors = ini_get( 'html_errors' );
399
			ini_set( 'html_errors', '0' );
400
			set_error_handler( 'UserMailer::errorHandler' );
401
402
			try {
403
				foreach ( $to as $recip ) {
404
					$sent = mail(
405
						$recip,
406
						self::quotedPrintable( $subject ),
407
						$body,
408
						$headers,
409
						$extraParams
410
					);
411
				}
412
			} catch ( Exception $e ) {
413
				restore_error_handler();
414
				throw $e;
415
			}
416
417
			restore_error_handler();
418
			ini_set( 'html_errors', $html_errors );
419
420
			if ( self::$mErrorString ) {
421
				wfDebug( "Error sending mail: " . self::$mErrorString . "\n" );
422
				return Status::newFatal( 'php-mail-error', self::$mErrorString );
423
			} elseif ( !$sent ) {
424
				// mail function only tells if there's an error
425
				wfDebug( "Unknown error sending mail\n" );
426
				return Status::newFatal( 'php-mail-error-unknown' );
427
			} else {
428
				return Status::newGood();
429
			}
430
		}
431
	}
432
433
	/**
434
	 * Set the mail error message in self::$mErrorString
435
	 *
436
	 * @param int $code Error number
437
	 * @param string $string Error message
438
	 */
439
	static function errorHandler( $code, $string ) {
440
		self::$mErrorString = preg_replace( '/^mail\(\)(\s*\[.*?\])?: /', '', $string );
441
	}
442
443
	/**
444
	 * Strips bad characters from a header value to prevent PHP mail header injection attacks
445
	 * @param string $val String to be santizied
446
	 * @return string
447
	 */
448
	public static function sanitizeHeaderValue( $val ) {
449
		return strtr( $val, [ "\r" => '', "\n" => '' ] );
450
	}
451
452
	/**
453
	 * Converts a string into a valid RFC 822 "phrase", such as is used for the sender name
454
	 * @param string $phrase
455
	 * @return string
456
	 */
457
	public static function rfc822Phrase( $phrase ) {
458
		// Remove line breaks
459
		$phrase = self::sanitizeHeaderValue( $phrase );
460
		// Remove quotes
461
		$phrase = str_replace( '"', '', $phrase );
462
		return '"' . $phrase . '"';
463
	}
464
465
	/**
466
	 * Converts a string into quoted-printable format
467
	 * @since 1.17
468
	 *
469
	 * From PHP5.3 there is a built in function quoted_printable_encode()
470
	 * This method does not duplicate that.
471
	 * This method is doing Q encoding inside encoded-words as defined by RFC 2047
472
	 * This is for email headers.
473
	 * The built in quoted_printable_encode() is for email bodies
474
	 * @param string $string
475
	 * @param string $charset
476
	 * @return string
477
	 */
478
	public static function quotedPrintable( $string, $charset = '' ) {
479
		// Probably incomplete; see RFC 2045
480
		if ( empty( $charset ) ) {
481
			$charset = 'UTF-8';
482
		}
483
		$charset = strtoupper( $charset );
484
		$charset = str_replace( 'ISO-8859', 'ISO8859', $charset ); // ?
485
486
		$illegal = '\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\xff=';
487
		$replace = $illegal . '\t ?_';
488
		if ( !preg_match( "/[$illegal]/", $string ) ) {
489
			return $string;
490
		}
491
		$out = "=?$charset?Q?";
492
		$out .= preg_replace_callback( "/([$replace])/",
493
			function ( $matches ) {
494
				return sprintf( "=%02X", ord( $matches[1] ) );
495
			},
496
			$string
497
		);
498
		$out .= '?=';
499
		return $out;
500
	}
501
}
502