Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
16.37% covered (danger)
16.37%
28 / 171
15.38% covered (danger)
15.38%
2 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialEmailUser
16.47% covered (danger)
16.47%
28 / 170
15.38% covered (danger)
15.38%
2 / 13
707.71
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 doesWrites
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getDescription
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFormFields
0.00% covered (danger)
0.00%
0 / 47
0.00% covered (danger)
0.00%
0 / 1
2
 handleCanSendEmailStatus
54.55% covered (warning)
54.55%
6 / 11
0.00% covered (danger)
0.00%
0 / 1
9.38
 execute
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
3.01
 getTarget
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
20
 userForm
0.00% covered (danger)
0.00%
0 / 33
0.00% covered (danger)
0.00%
0 / 1
30
 sendEmailForm
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
30
 onFormSubmit
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
12
 prefixSearchSubpages
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
 isListed
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getGroupName
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2/**
3 * @license GPL-2.0-or-later
4 * @file
5 */
6
7namespace MediaWiki\Specials;
8
9use MediaWiki\Exception\ErrorPageError;
10use MediaWiki\HTMLForm\HTMLForm;
11use MediaWiki\Mail\EmailUserFactory;
12use MediaWiki\MainConfigNames;
13use MediaWiki\MediaWikiServices;
14use MediaWiki\Permissions\PermissionStatus;
15use MediaWiki\SpecialPage\SpecialPage;
16use MediaWiki\Status\Status;
17use MediaWiki\User\Options\UserOptionsLookup;
18use MediaWiki\User\User;
19use MediaWiki\User\UserFactory;
20use MediaWiki\User\UserNamePrefixSearch;
21use MediaWiki\User\UserNameUtils;
22use StatusValue;
23
24/**
25 * Send an e-mail from one user to another.
26 *
27 * This is discoverable via the sidebar on any user's User namespace page.
28 *
29 * @ingroup SpecialPage
30 * @ingroup Mail
31 */
32class SpecialEmailUser extends SpecialPage {
33
34    private UserNameUtils $userNameUtils;
35    private UserNamePrefixSearch $userNamePrefixSearch;
36    private UserOptionsLookup $userOptionsLookup;
37    private EmailUserFactory $emailUserFactory;
38    private UserFactory $userFactory;
39
40    public function __construct(
41        UserNameUtils $userNameUtils,
42        UserNamePrefixSearch $userNamePrefixSearch,
43        UserOptionsLookup $userOptionsLookup,
44        EmailUserFactory $emailUserFactory,
45        UserFactory $userFactory
46    ) {
47        parent::__construct( 'Emailuser' );
48        $this->userNameUtils = $userNameUtils;
49        $this->userNamePrefixSearch = $userNamePrefixSearch;
50        $this->userOptionsLookup = $userOptionsLookup;
51        $this->emailUserFactory = $emailUserFactory;
52        $this->userFactory = $userFactory;
53    }
54
55    /** @inheritDoc */
56    public function doesWrites() {
57        return true;
58    }
59
60    /** @inheritDoc */
61    public function getDescription() {
62        return $this->msg( 'emailuser-title-notarget' );
63    }
64
65    protected function getFormFields( User $target ): array {
66        $linkRenderer = $this->getLinkRenderer();
67        $user = $this->getUser();
68        return [
69            'From' => [
70                'type' => 'info',
71                'raw' => 1,
72                'default' => $linkRenderer->makeLink(
73                    $user->getUserPage(),
74                    $user->getName()
75                ),
76                'label-message' => 'emailfrom',
77                'id' => 'mw-emailuser-sender',
78            ],
79            'To' => [
80                'type' => 'info',
81                'raw' => 1,
82                'default' => $linkRenderer->makeLink(
83                    $target->getUserPage(),
84                    $target->getName()
85                ),
86                'label-message' => 'emailto',
87                'id' => 'mw-emailuser-recipient',
88            ],
89            'Target' => [
90                'type' => 'hidden',
91                'default' => $target->getName(),
92            ],
93            'Subject' => [
94                'type' => 'text',
95                'default' => $this->msg( 'defemailsubject', $user->getName() )->inContentLanguage()->text(),
96                'label-message' => 'emailsubject',
97                'maxlength' => 200,
98                'size' => 60,
99                'required' => true,
100            ],
101            'Text' => [
102                'type' => 'textarea',
103                'rows' => 20,
104                'label-message' => 'emailmessage',
105                'required' => true,
106            ],
107            'CCMe' => [
108                'type' => 'check',
109                'label-message' => 'emailccme',
110                'default' => $this->userOptionsLookup->getBoolOption( $user, 'ccmeonemails' ),
111            ],
112        ];
113    }
114
115    /**
116     * Handles a {@link StatusValue} from {@link EmailUser::canSend}.
117     */
118    private function handleCanSendEmailStatus( StatusValue $status ): void {
119        if ( !$status->isGood() ) {
120            if ( $status instanceof PermissionStatus ) {
121                $status->throwErrorPageError();
122            } elseif ( $status->hasMessage( 'mailnologin' ) ) {
123                throw new ErrorPageError( 'mailnologin', 'mailnologintext' );
124            } elseif ( $status->hasMessage( 'usermaildisabled' ) ) {
125                throw new ErrorPageError( 'usermaildisabled', 'usermaildisabledtext' );
126            } elseif ( $status->getValue() !== null ) {
127                // BC for deprecated hook errors
128                // (to be removed when UserCanSendEmail and EmailUserPermissionsErrors are removed)
129                $msg = $status->getMessages()[0];
130                throw new ErrorPageError( $status->getValue(), $msg );
131            } else {
132                // Fallback in case new error types are added in EmailUser
133                throw new ErrorPageError( $this->getDescription(), Status::wrap( $status )->getMessage() );
134            }
135        }
136    }
137
138    /** @inheritDoc */
139    public function execute( $par ) {
140        $this->setHeaders();
141        $this->outputHeader();
142
143        $out = $this->getOutput();
144        $request = $this->getRequest();
145        $out->addModuleStyles( 'mediawiki.special' );
146
147        // Check if the user can send emails without authorizing the action.
148        $emailUser = $this->emailUserFactory->newEmailUserBC(
149            $this->getUser(),
150            $this->getConfig()
151        );
152        $emailUser->setEditToken( (string)$request->getVal( 'wpEditToken' ) );
153        $status = $emailUser->canSend();
154
155        // If user emailing is disabled, then prioritise this error over anything else (including the
156        // ::requireNamedUser check). This is because a user would still be unable access the form if they were to
157        // log in or create account.
158        if ( !$status->isGood() && $status->hasMessage( 'usermaildisabled' ) ) {
159            $this->handleCanSendEmailStatus( $status );
160        }
161
162        // If the user is not logged into a named account, then display this error. This should redirect to
163        // Special:UserLogin or Special:CreateAccount to not interrupt the flow.
164        $this->requireNamedUser( 'mailnologintext', 'mailnologin' );
165
166        $this->handleCanSendEmailStatus( $status );
167
168        // Always go through the userform, it will do validations on the target
169        // and display the emailform for us.
170        $target = $par ?? $request->getVal( 'wpTarget', $request->getVal( 'target', '' ) );
171        // @phan-suppress-next-line PhanTypeMismatchArgumentNullable Defaults to empty string
172        $this->userForm( $target );
173    }
174
175    /**
176     * Validate target User
177     *
178     * @param string $target Target user name
179     * @param User $sender User sending the email
180     * @return User|string User object on success or a string on error
181     * @deprecated since 1.42 Use UserFactory::newFromName() and EmailUser::validateTarget()
182     */
183    public static function getTarget( $target, User $sender ) {
184        $targetObject = MediaWikiServices::getInstance()->getUserFactory()->newFromName( $target );
185        if ( !$targetObject instanceof User ) {
186            return 'notarget';
187        }
188
189        $status = MediaWikiServices::getInstance()->getEmailUserFactory()
190            ->newEmailUser( $sender )
191            ->validateTarget( $targetObject );
192        if ( !$status->isGood() ) {
193            $msg = $status->getMessages()[0]->getKey();
194            $ret = $msg === 'emailnotarget' ? 'notarget' : preg_replace( '/text$/', '', $msg );
195        } else {
196            $ret = $targetObject;
197        }
198        return $ret;
199    }
200
201    /**
202     * Form to ask for target user name.
203     *
204     * @param string $name User name submitted.
205     */
206    protected function userForm( $name ) {
207        $htmlForm = HTMLForm::factory( 'ooui', [
208            'Target' => [
209                'type' => 'user',
210                'exists' => true,
211                'required' => true,
212                'label-message' => 'emailusername',
213                'id' => 'emailusertarget',
214                'autofocus' => true,
215                // Exclude temporary accounts from the autocomplete, as they cannot have email addresses.
216                'excludetemp' => true,
217                // Skip validation when visit directly without subpage (T347854)
218                'default' => '',
219                // Prefill for subpage syntax and old target param.
220                'filter-callback' => static function ( $value ) use ( $name ) {
221                    return str_replace( '_', ' ',
222                        ( $value !== '' && $value !== false && $value !== null ) ? $value : $name );
223                },
224                'validation-callback' => function ( $value ) {
225                    // HTMLForm checked that this is a valid user name
226                    $target = $this->userFactory->newFromName( $value );
227                    $statusValue = $this->emailUserFactory
228                        // @phan-suppress-next-line PhanTypeMismatchArgumentNullable
229                        ->newEmailUser( $this->getUser() )->validateTarget( $target );
230                    if ( !$statusValue->isGood() ) {
231                        // TODO: Return Status instead of StatusValue from validateTarget() method?
232                        return Status::wrap( $statusValue )->getMessage();
233                    }
234                    return true;
235                }
236            ]
237        ], $this->getContext() );
238
239        $htmlForm
240            ->setMethod( 'GET' )
241            ->setTitle( $this->getPageTitle() ) // Remove subpage
242            ->setSubmitCallback( $this->sendEmailForm( ... ) )
243            ->setId( 'askusername' )
244            ->setWrapperLegendMsg( 'emailtarget' )
245            ->setSubmitTextMsg( 'emailusernamesubmit' )
246            ->show();
247    }
248
249    /**
250     * @param array $data
251     * @return bool
252     */
253    private function sendEmailForm( array $data ) {
254        $out = $this->getOutput();
255
256        // HTMLForm checked that this is a valid user name, the return value can never be null.
257        $target = $this->userFactory->newFromName( $data['Target'] );
258        // @phan-suppress-next-line PhanTypeMismatchArgumentNullable
259        $htmlForm = HTMLForm::factory( 'ooui', $this->getFormFields( $target ), $this->getContext() );
260        $htmlForm
261            ->setTitle( $this->getPageTitle() ) // Remove subpage
262            ->addPreHtml( $this->msg( 'emailpagetext', $target->getName() )->parse() )
263            ->setSubmitTextMsg( 'emailsend' )
264            ->setSubmitCallback( $this->onFormSubmit( ... ) )
265            ->setWrapperLegendMsg( 'email-legend' )
266            ->prepareForm();
267
268        if ( !$this->getHookRunner()->onEmailUserForm( $htmlForm ) ) {
269            return false;
270        }
271
272        $result = $htmlForm->show();
273
274        if ( $result === true || ( $result instanceof Status && $result->isGood() ) ) {
275            $out->setPageTitleMsg( $this->msg( 'emailsent' ) );
276            $out->addWikiMsg( 'emailsenttext', $target->getName() );
277            $out->returnToMain( false, $target->getUserPage() );
278        } else {
279            $out->setPageTitleMsg( $this->msg( 'emailuser-title-target', $target->getName() ) );
280        }
281        return true;
282    }
283
284    /**
285     * @param array $data
286     * @return StatusValue|false
287     */
288    private function onFormSubmit( array $data ) {
289        // HTMLForm checked that this is a valid user name, the return value can never be null.
290        $target = $this->userFactory->newFromName( $data['Target'] );
291
292        $emailUser = $this->emailUserFactory->newEmailUser( $this->getAuthority() );
293        $emailUser->setEditToken( $this->getRequest()->getVal( 'wpEditToken' ) );
294
295        // Fully authorize on sending emails.
296        $status = $emailUser->authorizeSend();
297
298        if ( !$status->isOK() ) {
299            return $status;
300        }
301
302        // @phan-suppress-next-next-line PhanTypeMismatchArgumentNullable
303        $res = $emailUser->sendEmailUnsafe(
304            $target,
305            $data['Subject'],
306            $data['Text'],
307            $data['CCMe'],
308            $this->getLanguage()->getCode()
309        );
310        if ( $res->hasMessage( 'hookaborted' ) ) {
311            // BC: The method could previously return false if the EmailUser hook set the error to false. Preserve
312            // that behaviour until we replace the hook.
313            $res = false;
314        } else {
315            $res = Status::wrap( $res );
316        }
317        return $res;
318    }
319
320    /**
321     * Return an array of subpages beginning with $search that this special page will accept.
322     *
323     * @param string $search Prefix to search for
324     * @param int $limit Maximum number of results to return (usually 10)
325     * @param int $offset Number of results to skip (usually 0)
326     * @return string[] Matching subpages
327     */
328    public function prefixSearchSubpages( $search, $limit, $offset ) {
329        $search = $this->userNameUtils->getCanonical( $search );
330        if ( !$search ) {
331            // No prefix suggestion for invalid user
332            return [];
333        }
334        // Autocomplete subpage as user list - public to allow caching
335        return $this->userNamePrefixSearch
336            ->search( UserNamePrefixSearch::AUDIENCE_PUBLIC, $search, $limit, $offset );
337    }
338
339    /**
340     * @return bool
341     */
342    public function isListed() {
343        return $this->getConfig()->get( MainConfigNames::EnableUserEmail );
344    }
345
346    /** @inheritDoc */
347    protected function getGroupName() {
348        return 'users';
349    }
350}
351
352/** @deprecated class alias since 1.41 */
353class_alias( SpecialEmailUser::class, 'SpecialEmailUser' );