Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.22% covered (warning)
77.22%
122 / 158
42.86% covered (danger)
42.86%
3 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
EmailUser
77.22% covered (warning)
77.22%
122 / 158
42.86% covered (danger)
42.86%
3 / 7
68.95
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 validateTarget
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
9
 canSend
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
7
 authorizeSend
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
5.03
 sendEmailUnsafe
71.26% covered (warning)
71.26%
62 / 87
0.00% covered (danger)
0.00%
0 / 1
25.69
 getFromAndReplyTo
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 getSpecialMuteCanonicalURL
n/a
0 / 0
n/a
0 / 0
2
 setEditToken
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
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
21namespace MediaWiki\Mail;
22
23use MailAddress;
24use MediaWiki\Config\ServiceOptions;
25use MediaWiki\HookContainer\HookContainer;
26use MediaWiki\HookContainer\HookRunner;
27use MediaWiki\MainConfigNames;
28use MediaWiki\Permissions\Authority;
29use MediaWiki\Permissions\PermissionStatus;
30use MediaWiki\Preferences\MultiUsernameFilter;
31use MediaWiki\SpecialPage\SpecialPage;
32use MediaWiki\User\CentralId\CentralIdLookup;
33use MediaWiki\User\Options\UserOptionsLookup;
34use MediaWiki\User\UserFactory;
35use MessageSpecifier;
36use StatusValue;
37use UnexpectedValueException;
38use Wikimedia\Message\IMessageFormatterFactory;
39use Wikimedia\Message\ITextFormatter;
40use 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 */
47class 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}