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