Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
77.22% |
122 / 158 |
|
42.86% |
3 / 7 |
CRAP | |
0.00% |
0 / 1 |
EmailUser | |
77.22% |
122 / 158 |
|
42.86% |
3 / 7 |
68.95 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
1 | |||
validateTarget | |
100.00% |
23 / 23 |
|
100.00% |
1 / 1 |
9 | |||
canSend | |
100.00% |
18 / 18 |
|
100.00% |
1 / 1 |
7 | |||
authorizeSend | |
90.00% |
9 / 10 |
|
0.00% |
0 / 1 |
5.03 | |||
sendEmailUnsafe | |
71.26% |
62 / 87 |
|
0.00% |
0 / 1 |
25.69 | |||
getFromAndReplyTo | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
6 | |||
getSpecialMuteCanonicalURL | n/a |
0 / 0 |
n/a |
0 / 0 |
2 | |||||
setEditToken | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 |
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 | */ |
20 | |
21 | namespace MediaWiki\Mail; |
22 | |
23 | use MailAddress; |
24 | use MediaWiki\Config\ServiceOptions; |
25 | use MediaWiki\HookContainer\HookContainer; |
26 | use MediaWiki\HookContainer\HookRunner; |
27 | use MediaWiki\MainConfigNames; |
28 | use MediaWiki\Permissions\Authority; |
29 | use MediaWiki\Permissions\PermissionStatus; |
30 | use MediaWiki\Preferences\MultiUsernameFilter; |
31 | use MediaWiki\SpecialPage\SpecialPage; |
32 | use MediaWiki\User\CentralId\CentralIdLookup; |
33 | use MediaWiki\User\Options\UserOptionsLookup; |
34 | use MediaWiki\User\UserFactory; |
35 | use MessageSpecifier; |
36 | use StatusValue; |
37 | use UnexpectedValueException; |
38 | use Wikimedia\Message\IMessageFormatterFactory; |
39 | use Wikimedia\Message\ITextFormatter; |
40 | use Wikimedia\Message\MessageValue; |
41 | |
42 | /** |
43 | * Command for sending emails to users. This class is stateless and can be used for multiple sends. |
44 | * |
45 | * @since 1.40 |
46 | */ |
47 | class EmailUser { |
48 | /** |
49 | * @internal For use by ServiceWiring |
50 | */ |
51 | public const CONSTRUCTOR_OPTIONS = [ |
52 | MainConfigNames::EnableEmail, |
53 | MainConfigNames::EnableUserEmail, |
54 | MainConfigNames::EnableSpecialMute, |
55 | MainConfigNames::PasswordSender, |
56 | MainConfigNames::UserEmailUseReplyTo, |
57 | ]; |
58 | |
59 | private ServiceOptions $options; |
60 | |
61 | private HookRunner $hookRunner; |
62 | |
63 | private UserOptionsLookup $userOptionsLookup; |
64 | |
65 | private CentralIdLookup $centralIdLookup; |
66 | |
67 | private UserFactory $userFactory; |
68 | |
69 | private IEmailer $emailer; |
70 | |
71 | private IMessageFormatterFactory $messageFormatterFactory; |
72 | |
73 | private ITextFormatter $contLangMsgFormatter; |
74 | |
75 | private Authority $sender; |
76 | |
77 | /** @var string Temporary property to support the deprecated EmailUserPermissionsErrors hook */ |
78 | private string $editToken = ''; |
79 | |
80 | /** |
81 | * @param ServiceOptions $options |
82 | * @param HookContainer $hookContainer |
83 | * @param UserOptionsLookup $userOptionsLookup |
84 | * @param CentralIdLookup $centralIdLookup |
85 | * @param UserFactory $userFactory |
86 | * @param IEmailer $emailer |
87 | * @param IMessageFormatterFactory $messageFormatterFactory |
88 | * @param ITextFormatter $contLangMsgFormatter |
89 | * @param Authority $sender |
90 | */ |
91 | public function __construct( |
92 | ServiceOptions $options, |
93 | HookContainer $hookContainer, |
94 | UserOptionsLookup $userOptionsLookup, |
95 | CentralIdLookup $centralIdLookup, |
96 | UserFactory $userFactory, |
97 | IEmailer $emailer, |
98 | IMessageFormatterFactory $messageFormatterFactory, |
99 | ITextFormatter $contLangMsgFormatter, |
100 | Authority $sender |
101 | ) { |
102 | $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS ); |
103 | $this->options = $options; |
104 | $this->hookRunner = new HookRunner( $hookContainer ); |
105 | $this->userOptionsLookup = $userOptionsLookup; |
106 | $this->centralIdLookup = $centralIdLookup; |
107 | $this->userFactory = $userFactory; |
108 | $this->emailer = $emailer; |
109 | $this->messageFormatterFactory = $messageFormatterFactory; |
110 | $this->contLangMsgFormatter = $contLangMsgFormatter; |
111 | |
112 | $this->sender = $sender; |
113 | } |
114 | |
115 | /** |
116 | * @internal |
117 | * @todo This method might perhaps be moved to a UserEmailContactLookup or something. |
118 | * |
119 | * @param UserEmailContact $target Target user |
120 | * @return StatusValue |
121 | */ |
122 | public function validateTarget( UserEmailContact $target ): StatusValue { |
123 | $targetIdentity = $target->getUser(); |
124 | |
125 | if ( !$targetIdentity->getId() ) { |
126 | return StatusValue::newFatal( 'emailnotarget' ); |
127 | } |
128 | |
129 | if ( !$target->isEmailConfirmed() ) { |
130 | return StatusValue::newFatal( 'noemailtext' ); |
131 | } |
132 | |
133 | $targetUser = $this->userFactory->newFromUserIdentity( $targetIdentity ); |
134 | if ( !$targetUser->canReceiveEmail() ) { |
135 | return StatusValue::newFatal( 'nowikiemailtext' ); |
136 | } |
137 | |
138 | $senderUser = $this->userFactory->newFromAuthority( $this->sender ); |
139 | if ( |
140 | !$this->userOptionsLookup->getOption( $targetIdentity, 'email-allow-new-users' ) && |
141 | $senderUser->isNewbie() |
142 | ) { |
143 | return StatusValue::newFatal( 'nowikiemailtext' ); |
144 | } |
145 | |
146 | $muteList = $this->userOptionsLookup->getOption( |
147 | $targetIdentity, |
148 | 'email-blacklist', |
149 | '' |
150 | ); |
151 | if ( $muteList ) { |
152 | $muteList = MultiUsernameFilter::splitIds( $muteList ); |
153 | $senderId = $this->centralIdLookup->centralIdFromLocalUser( $this->sender->getUser() ); |
154 | if ( $senderId !== 0 && in_array( $senderId, $muteList ) ) { |
155 | return StatusValue::newFatal( 'nowikiemailtext' ); |
156 | } |
157 | } |
158 | |
159 | return StatusValue::newGood(); |
160 | } |
161 | |
162 | /** |
163 | * Checks whether email sending is allowed. |
164 | * |
165 | * @return StatusValue For BC, the StatusValue's value can be set to a string representing |
166 | * a message key to use with ErrorPageError. Only SpecialEmailUser should rely on this. |
167 | */ |
168 | public function canSend(): StatusValue { |
169 | if ( |
170 | !$this->options->get( MainConfigNames::EnableEmail ) || |
171 | !$this->options->get( MainConfigNames::EnableUserEmail ) |
172 | ) { |
173 | return StatusValue::newFatal( 'usermaildisabled' ); |
174 | } |
175 | |
176 | $user = $this->userFactory->newFromAuthority( $this->sender ); |
177 | |
178 | // Run this before checking 'sendemail' permission |
179 | // to show appropriate message to anons (T160309) |
180 | if ( !$user->isEmailConfirmed() ) { |
181 | return StatusValue::newFatal( 'mailnologin' ); |
182 | } |
183 | |
184 | $status = PermissionStatus::newGood(); |
185 | if ( !$this->sender->isDefinitelyAllowed( 'sendemail', $status ) ) { |
186 | return $status; |
187 | } |
188 | |
189 | $hookErr = false; |
190 | |
191 | // TODO Remove deprecated hooks |
192 | $this->hookRunner->onUserCanSendEmail( $user, $hookErr ); |
193 | $this->hookRunner->onEmailUserPermissionsErrors( $user, $this->editToken, $hookErr ); |
194 | if ( is_array( $hookErr ) ) { |
195 | // SpamBlacklist uses null for the third element, and there might be more handlers not using an array. |
196 | $msgParamsArray = is_array( $hookErr[2] ) ? $hookErr[2] : []; |
197 | $ret = StatusValue::newFatal( $hookErr[1], ...$msgParamsArray ); |
198 | $ret->value = $hookErr[0]; |
199 | return $ret; |
200 | } |
201 | |
202 | return StatusValue::newGood(); |
203 | } |
204 | |
205 | /** |
206 | * Authorize the email sending, checking permissions etc. |
207 | * |
208 | * @return StatusValue For BC, the StatusValue's value can be set to a string representing |
209 | * a message key to use with ErrorPageError. Only SpecialEmailUser should rely on this. |
210 | */ |
211 | public function authorizeSend(): StatusValue { |
212 | $status = $this->canSend(); |
213 | if ( !$status->isOK() ) { |
214 | return $status; |
215 | } |
216 | |
217 | $status = PermissionStatus::newGood(); |
218 | if ( !$this->sender->authorizeAction( 'sendemail', $status ) ) { |
219 | return $status; |
220 | } |
221 | |
222 | $hookRes = $this->hookRunner->onEmailUserAuthorizeSend( $this->sender, $status ); |
223 | if ( !$hookRes && !$status->isGood() ) { |
224 | return $status; |
225 | } |
226 | |
227 | return StatusValue::newGood(); |
228 | } |
229 | |
230 | /** |
231 | * Really send a mail, without permission checks. |
232 | * |
233 | * @param UserEmailContact $target |
234 | * @param string $subject |
235 | * @param string $text |
236 | * @param bool $CCMe |
237 | * @param string $langCode Code of the language to be used for interface messages |
238 | * @return StatusValue |
239 | */ |
240 | public function sendEmailUnsafe( |
241 | UserEmailContact $target, |
242 | string $subject, |
243 | string $text, |
244 | bool $CCMe, |
245 | string $langCode |
246 | ): StatusValue { |
247 | $senderIdentity = $this->sender->getUser(); |
248 | $targetStatus = $this->validateTarget( $target ); |
249 | if ( !$targetStatus->isGood() ) { |
250 | return $targetStatus; |
251 | } |
252 | |
253 | $senderUser = $this->userFactory->newFromAuthority( $this->sender ); |
254 | |
255 | $toAddress = MailAddress::newFromUser( $target ); |
256 | $fromAddress = MailAddress::newFromUser( $senderUser ); |
257 | |
258 | // Add a standard footer and trim up trailing newlines |
259 | $text = rtrim( $text ) . "\n\n-- \n"; |
260 | $text .= $this->contLangMsgFormatter->format( |
261 | MessageValue::new( 'emailuserfooter', [ $fromAddress->name, $toAddress->name ] ) |
262 | ); |
263 | |
264 | if ( $this->options->get( MainConfigNames::EnableSpecialMute ) ) { |
265 | $text .= "\n" . $this->contLangMsgFormatter->format( |
266 | MessageValue::new( |
267 | 'specialmute-email-footer', |
268 | [ |
269 | $this->getSpecialMuteCanonicalURL( $senderIdentity->getName() ), |
270 | $senderIdentity->getName() |
271 | ] |
272 | ) |
273 | ); |
274 | } |
275 | |
276 | $error = false; |
277 | // TODO Remove deprecated ugly hook |
278 | if ( !$this->hookRunner->onEmailUser( $toAddress, $fromAddress, $subject, $text, $error ) ) { |
279 | if ( $error instanceof StatusValue ) { |
280 | return $error; |
281 | } elseif ( $error === false || $error === '' || $error === [] ) { |
282 | // Possibly to tell HTMLForm to pretend there was no submission? |
283 | return StatusValue::newFatal( 'hookaborted' ); |
284 | } elseif ( $error === true ) { |
285 | // Hook sent the mail itself and indicates success? |
286 | return StatusValue::newGood(); |
287 | } elseif ( is_array( $error ) ) { |
288 | $status = StatusValue::newGood(); |
289 | foreach ( $error as $e ) { |
290 | $status->fatal( $e ); |
291 | } |
292 | return $status; |
293 | } elseif ( $error instanceof MessageSpecifier ) { |
294 | return StatusValue::newFatal( $error ); |
295 | } else { |
296 | // Setting $error to something else was deprecated in 1.29 and |
297 | // removed in 1.36, and so an exception is now thrown |
298 | $type = is_object( $error ) ? get_class( $error ) : gettype( $error ); |
299 | throw new UnexpectedValueException( |
300 | 'EmailUser hook set $error to unsupported type ' . $type |
301 | ); |
302 | } |
303 | } |
304 | |
305 | $hookStatus = StatusValue::newGood(); |
306 | $hookRes = $this->hookRunner->onEmailUserSendEmail( |
307 | $this->sender, |
308 | $fromAddress, |
309 | $target, |
310 | $toAddress, |
311 | $subject, |
312 | $text, |
313 | $hookStatus |
314 | ); |
315 | if ( !$hookRes && !$hookStatus->isGood() ) { |
316 | return $hookStatus; |
317 | } |
318 | |
319 | [ $mailFrom, $replyTo ] = $this->getFromAndReplyTo( $fromAddress ); |
320 | |
321 | $status = $this->emailer->send( |
322 | $toAddress, |
323 | $mailFrom, |
324 | $subject, |
325 | $text, |
326 | null, |
327 | [ 'replyTo' => $replyTo ] |
328 | ); |
329 | |
330 | if ( !$status->isGood() ) { |
331 | return $status; |
332 | } |
333 | |
334 | // if the user requested a copy of this mail, do this now, |
335 | // unless they are emailing themselves, in which case one |
336 | // copy of the message is sufficient. |
337 | if ( $CCMe && !$toAddress->equals( $fromAddress ) ) { |
338 | $userMsgFormatter = $this->messageFormatterFactory->getTextFormatter( $langCode ); |
339 | $ccTo = $fromAddress; |
340 | $ccFrom = $fromAddress; |
341 | $ccSubject = $userMsgFormatter->format( |
342 | MessageValue::new( 'emailccsubject' )->plaintextParams( |
343 | $target->getUser()->getName(), |
344 | $subject |
345 | ) |
346 | ); |
347 | $ccText = $text; |
348 | |
349 | $this->hookRunner->onEmailUserCC( $ccTo, $ccFrom, $ccSubject, $ccText ); |
350 | |
351 | [ $mailFrom, $replyTo ] = $this->getFromAndReplyTo( $ccFrom ); |
352 | |
353 | $ccStatus = $this->emailer->send( |
354 | $ccTo, |
355 | $mailFrom, |
356 | $ccSubject, |
357 | $ccText, |
358 | null, |
359 | [ 'replyTo' => $replyTo ] |
360 | ); |
361 | $status->merge( $ccStatus ); |
362 | } |
363 | |
364 | $this->hookRunner->onEmailUserComplete( $toAddress, $fromAddress, $subject, $text ); |
365 | |
366 | return $status; |
367 | } |
368 | |
369 | /** |
370 | * @param MailAddress $fromAddress |
371 | * @return array |
372 | * @phan-return array{0:MailAddress,1:?MailAddress} |
373 | */ |
374 | private function getFromAndReplyTo( MailAddress $fromAddress ): array { |
375 | if ( $this->options->get( MainConfigNames::UserEmailUseReplyTo ) ) { |
376 | /** |
377 | * Put the generic wiki autogenerated address in the From: |
378 | * header and reserve the user for Reply-To. |
379 | * |
380 | * This is a bit ugly, but will serve to differentiate |
381 | * wiki-borne mails from direct mails and protects against |
382 | * SPF and bounce problems with some mailers (see below). |
383 | */ |
384 | $mailFrom = new MailAddress( |
385 | $this->options->get( MainConfigNames::PasswordSender ), |
386 | $this->contLangMsgFormatter->format( MessageValue::new( 'emailsender' ) ) |
387 | ); |
388 | $replyTo = $fromAddress; |
389 | } else { |
390 | /** |
391 | * Put the sending user's e-mail address in the From: header. |
392 | * |
393 | * This is clean-looking and convenient, but has issues. |
394 | * One is that it doesn't as clearly differentiate the wiki mail |
395 | * from "directly" sent mails. |
396 | * |
397 | * Another is that some mailers (like sSMTP) will use the From |
398 | * address as the envelope sender as well. For open sites this |
399 | * can cause mails to be flunked for SPF violations (since the |
400 | * wiki server isn't an authorized sender for various users' |
401 | * domains) as well as creating a privacy issue as bounces |
402 | * containing the recipient's e-mail address may get sent to |
403 | * the sending user. |
404 | */ |
405 | $mailFrom = $fromAddress; |
406 | $replyTo = null; |
407 | } |
408 | return [ $mailFrom, $replyTo ]; |
409 | } |
410 | |
411 | /** |
412 | * @param string $targetName |
413 | * @return string |
414 | * XXX This code is still heavily reliant on global state, so temporarily skip it in tests. |
415 | * @codeCoverageIgnore |
416 | */ |
417 | private function getSpecialMuteCanonicalURL( string $targetName ): string { |
418 | if ( defined( 'MW_PHPUNIT_TEST' ) ) { |
419 | return "Ceci n'est pas une URL"; |
420 | } |
421 | return SpecialPage::getTitleFor( 'Mute', $targetName )->getCanonicalURL(); |
422 | } |
423 | |
424 | /** |
425 | * @internal Only for BC with SpecialEmailUser |
426 | * @param string $token |
427 | */ |
428 | public function setEditToken( string $token ): void { |
429 | $this->editToken = $token; |
430 | } |
431 | |
432 | } |