Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
81.65% covered (warning)
81.65%
129 / 158
42.86% covered (danger)
42.86%
3 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
EmailUser
81.65% covered (warning)
81.65%
129 / 158
42.86% covered (danger)
42.86%
3 / 7
55.97
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
23.86
 getFromAndReplyTo
77.78% covered (warning)
77.78%
7 / 9
0.00% covered (danger)
0.00%
0 / 1
2.04
 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 StatusValue;
36use UnexpectedValueException;
37use Wikimedia\Message\IMessageFormatterFactory;
38use Wikimedia\Message\ITextFormatter;
39use Wikimedia\Message\MessageSpecifier;
40use 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 */
52class 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}