Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
49.44% covered (danger)
49.44%
176 / 356
33.33% covered (danger)
33.33%
4 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecialContact
49.44% covered (danger)
49.44%
176 / 356
33.33% covered (danger)
33.33%
4 / 12
1474.33
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getDescription
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 getTypeConfig
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 getFormSpecificMessageKey
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
4
 execute
80.39% covered (warning)
80.39%
41 / 51
0.00% covered (danger)
0.00%
0 / 1
14.27
 checkFormErrors
92.59% covered (success)
92.59%
25 / 27
0.00% covered (danger)
0.00%
0 / 1
17.12
 getFormFields
92.63% covered (success)
92.63%
88 / 95
0.00% covered (danger)
0.00%
0 / 1
14.08
 processInput
0.00% covered (danger)
0.00%
0 / 142
0.00% covered (danger)
0.00%
0 / 1
1640
 getYesOrNoMsg
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
6
 useCaptcha
33.33% covered (danger)
33.33%
3 / 9
0.00% covered (danger)
0.00%
0 / 1
12.41
 getCaptcha
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
2
 getContactPageHookRunner
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * Speclial:Contact, a contact form for visitors.
4 * Based on SpecialEmailUser.php
5 *
6 * @file
7 * @ingroup SpecialPage
8 * @author Daniel Kinzler, brightbyte.de
9 * @copyright © 2007-2014 Daniel Kinzler, Sam Reed
10 * @license GPL-2.0-or-later
11 */
12
13namespace MediaWiki\Extension\ContactPage;
14
15use ErrorPageError;
16use MailAddress;
17use MediaWiki\Extension\ConfirmEdit\Hooks as ConfirmEditHooks;
18use MediaWiki\Extension\ContactPage\Hooks\HookRunner;
19use MediaWiki\Html\Html;
20use MediaWiki\HTMLForm\Field\HTMLCheckField;
21use MediaWiki\HTMLForm\Field\HTMLHiddenField;
22use MediaWiki\HTMLForm\HTMLForm;
23use MediaWiki\MainConfigNames;
24use MediaWiki\Parser\Sanitizer;
25use MediaWiki\Registration\ExtensionRegistry;
26use MediaWiki\Session\SessionManager;
27use MediaWiki\SpecialPage\UnlistedSpecialPage;
28use MediaWiki\Status\Status;
29use MediaWiki\User\Options\UserOptionsLookup;
30use MediaWiki\User\User;
31use MediaWiki\User\UserFactory;
32use UserBlockedError;
33use UserMailer;
34
35/**
36 * Provides the contact form
37 * @ingroup SpecialPage
38 */
39class SpecialContact extends UnlistedSpecialPage {
40    private UserOptionsLookup $userOptionsLookup;
41    private UserFactory $userFactory;
42    /** @var HookRunner|null */
43    private $contactPageHookRunner;
44
45    /** @var string|null */
46    private $recipientName = null;
47
48    /**
49     * @param UserOptionsLookup $userOptionsLookup
50     * @param UserFactory $userFactory
51     */
52    public function __construct( UserOptionsLookup $userOptionsLookup, UserFactory $userFactory ) {
53        parent::__construct( 'Contact' );
54        $this->userOptionsLookup = $userOptionsLookup;
55        $this->userFactory = $userFactory;
56    }
57
58    /**
59     * @inheritDoc
60     */
61    public function getDescription() {
62        return $this->msg( 'contactpage' );
63    }
64
65    /**
66     * @var string
67     */
68    protected $formType;
69
70    /**
71     * @return array
72     */
73    protected function getTypeConfig() {
74        $contactConfig = $this->getConfig()->get( 'ContactConfig' );
75
76        if ( $contactConfig['default']['SenderName'] === null ) {
77            $sitename = $this->getConfig()->get( 'Sitename' );
78            $contactConfig['default']['SenderName'] = "Contact Form on $sitename";
79        }
80
81        if ( isset( $contactConfig[$this->formType] ) ) {
82            return $contactConfig[$this->formType] + $contactConfig['default'];
83        }
84        return $contactConfig['default'];
85    }
86
87    /**
88     * Helper function that returns a form-specific message key if it is not
89     * disabled. Otherwise returns the generic message key. Used to make it
90     * possible for forms to have form-specific messages.
91     *
92     * @param string $genericMessageKey The message key that will be used if no form-specific one can be used
93     * @return string
94     */
95    protected function getFormSpecificMessageKey( string $genericMessageKey ): string {
96        $formSpecificMessageKey = $genericMessageKey . '-' . $this->formType;
97        if ( !str_starts_with( $genericMessageKey, 'contactpage' ) ) {
98            // If the generic message does not start with "contactpage" the form
99            //  specific one will have "contactpage-" prefixed on the generic message
100            //  name.
101            $formSpecificMessageKey = 'contactpage-' . $formSpecificMessageKey;
102        }
103        if ( $this->formType && !$this->msg( $formSpecificMessageKey )->isDisabled() ) {
104            // Return the form-specific message if the form type is not the empty string
105            //  and the message is defined.
106            return $formSpecificMessageKey;
107        }
108        return $genericMessageKey;
109    }
110
111    /**
112     * Main execution function
113     *
114     * @param string|null $par Parameters passed to the page
115     * @throws UserBlockedError
116     * @throws ErrorPageError
117     */
118    public function execute( $par ) {
119        if ( !$this->getConfig()->get( MainConfigNames::EnableEmail ) ) {
120            // From Special:EmailUser
121            throw new ErrorPageError( 'usermaildisabled', 'usermaildisabledtext' );
122        }
123
124        $request = $this->getRequest();
125        $this->formType = strtolower( $request->getText( 'formtype', $par ?? '' ) );
126
127        $config = $this->getTypeConfig();
128
129        if ( $config['Redirect'] ) {
130            $this->getOutput()->redirect( $config['Redirect'] );
131            return;
132        }
133
134        $user = $this->getUser();
135
136        $error = $this->checkFormErrors( $user, $config );
137
138        if ( $error ) {
139            $this->getOutput()->showErrorPage( ...$error );
140            return;
141        }
142
143        // Set page title now we're certain we will display the form
144        $this->getOutput()->setPageTitleMsg(
145            $this->msg( $this->getFormSpecificMessageKey( 'contactpage-title' ) )
146        );
147
148        $formItems = $this->getFormFields( $user, $config );
149
150        $form = HTMLForm::factory( 'ooui',
151            $formItems, $this->getContext(), "contactpage-{$this->formType}"
152        );
153        $form->setWrapperLegendMsg( 'contactpage-legend' );
154        $form->setSubmitTextMsg( $this->getFormSpecificMessageKey( 'emailsend' ) );
155        if ( $this->formType != '' ) {
156            $form->setId( "contactpage-{$this->formType}" );
157
158            $msg = $this->msg( "contactpage-legend-{$this->formType}" );
159            if ( !$msg->isDisabled() ) {
160                $form->setWrapperLegendMsg( $msg );
161            }
162
163            $msg = $this->msg( "contactpage-emailsend-{$this->formType}" );
164            if ( !$msg->isDisabled() ) {
165                $form->setSubmitTextMsg( $msg );
166            }
167        }
168        $form->setSubmitCallback( [ $this, 'processInput' ] );
169        $form->prepareForm();
170
171        // Stolen from Special:EmailUser
172        if ( !$this->getContactPageHookRunner()->onEmailUserForm( $form ) ) {
173            return;
174        }
175
176        $result = $form->show();
177
178        if ( $result === true || ( $result instanceof Status && $result->isGood() ) ) {
179            $output = $this->getOutput();
180            $output->setPageTitleMsg( $this->msg( $this->getFormSpecificMessageKey( 'emailsent' ) ) );
181            $output->addWikiMsg(
182                $this->getFormSpecificMessageKey( 'emailsenttext' ),
183                $this->recipientName
184            );
185
186            $output->returnToMain( false );
187        } else {
188            if ( $config['RLStyleModules'] ) {
189                $this->getOutput()->addModuleStyles( $config['RLStyleModules'] );
190            }
191            if ( $config['RLModules'] ) {
192                $this->getOutput()->addModules( $config['RLModules'] );
193            }
194            $formText = $this->msg(
195                $this->getFormSpecificMessageKey( 'contactpage-pagetext' )
196            )->parseAsBlock();
197            $this->getOutput()->prependHTML( trim( $formText ) );
198        }
199    }
200
201    /**
202     * Various permission and form misconfiguration checks
203     *
204     * When there's an error and the form should not displayed, the return value
205     * must be an array of exactly 2 string elements: message key for the error page title
206     * and message key for the actual error message.
207     *
208     * The method may also throw a subclass of ErrorPageError to halt displaying the form.
209     *
210     * false means there's no error and we should proceed to display the form.
211     *
212     * @return array|false
213     * @phan-return array{0:string,1:string}|false [ error title msg key, error text msg key ]
214     * @throws \UserNotLoggedIn
215     * @throws \UserBlockedError
216     */
217    private function checkFormErrors( User $user, array $config ) {
218        // Display error if user not logged in when config requires it
219        $requiresConfirmedEmail = $config['MustHaveEmail'] ?? false;
220        $requiresLogin = $config['MustBeLoggedIn'] ?? false;
221
222        if ( $requiresLogin ) {
223            // Uses the following message keys:
224            // * contactpage-mustbeloggedin
225            // * contactpage-mustbeloggedin-for-temp-user
226            $this->requireNamedUser( 'contactpage-mustbeloggedin' );
227        } elseif ( $requiresConfirmedEmail ) {
228            // MustHaveEmail must not be set without setting MustBeLoggedIn, as
229            // anon and temporary users do not have email addresses.
230            return [ 'contactpage-config-error-title', 'contactpage-config-error' ];
231        }
232
233        // Display error if sender has no confirmed email when config requires it
234        if ( $requiresConfirmedEmail && !$user->isEmailConfirmed() ) {
235            return [ 'contactpage-musthaveemail-error-title', 'contactpage-musthaveemail-error' ];
236        }
237
238        // Display error if no recipient specified in configuration
239        if ( !$config['RecipientUser'] && !$config['RecipientEmail'] ) {
240            return [ 'contactpage-config-error-title', 'contactpage-config-error' ];
241        }
242
243        // Display error if 'RecipientUser' is used, but they have email disabled
244        if ( $config['RecipientUser'] ) {
245            $recipient = $this->userFactory->newFromName( $config['RecipientUser'] );
246            if ( $recipient === null || !$recipient->canReceiveEmail() ) {
247                return [ 'noemailtitle', 'noemailtext' ];
248            }
249            $this->recipientName = $config['RecipientUser'];
250        } else {
251            $this->recipientName = $config['RecipientName'] ?? $this->getConfig()->get( 'Sitename' );
252        }
253
254        // Blocked users cannot use the contact form if they're disabled from sending email.
255        $block = $user->getBlock();
256        if ( $block && $block->appliesToRight( 'sendemail' ) ) {
257            $useCustomBlockMessage = $config['UseCustomBlockMessage'] ?? false;
258            if ( $useCustomBlockMessage ) {
259                return [ $this->getFormSpecificMessageKey( 'contactpage-title' ),
260                    $this->getFormSpecificMessageKey( 'contactpage-blocked-message' ) ];
261            }
262
263            throw new UserBlockedError( $block );
264        }
265
266        // Show error if the following are true as they are in combination invalid configuration:
267        // * The form doesn't require logging in
268        // * The form requires details
269        // * The email form is read only.
270        // This is because the email field will be empty for anon and temp users and must be filled
271        // for the form to be valid, but cannot be modified by the client.
272        $emailReadonly = $user->isNamed() && ( $config['EmailReadonly'] ?? false );
273        if ( !$requiresLogin && $emailReadonly && $config['RequireDetails'] ) {
274            return [ 'contactpage-config-error-title', 'contactpage-config-error' ];
275        }
276
277        return false;
278    }
279
280    private function getFormFields( User $user, array $config ): array {
281        # Check for type in [[Special:Contact/type]]: change pagetext and prefill form fields
282        $formSpecificSubjectMessageKey = $this->msg( [
283            'contactpage-defsubject-' . $this->formType,
284            'contactpage-subject-' . $this->formType
285        ] );
286
287        if ( $this->formType != '' && !$formSpecificSubjectMessageKey->isDisabled() ) {
288            $subject = trim( $formSpecificSubjectMessageKey->inContentLanguage()->plain() );
289        } else {
290            $subject = $this->msg( 'contactpage-defsubject' )->inContentLanguage()->text();
291        }
292
293        $fromAddress = '';
294        $fromName = '';
295        $nameReadonly = false;
296        $emailReadonly = false;
297        $subjectReadonly = $config['SubjectReadonly'] ?? false;
298
299        if ( $user->isRegistered() ) {
300            // See T335962#10283428 for an explanation for why
301            // we are including temporary accounts here.
302            $realName = $user->getRealName();
303            if ( $realName ) {
304                $fromName = $realName;
305            } else {
306                $fromName = $user->getName();
307            }
308            $fromAddress = $user->getEmail();
309            $nameReadonly = $config['NameReadonly'] ?? false;
310            $emailReadonly = $config['EmailReadonly'] ?? false;
311        }
312
313        $stockFields = [
314            'FromName' => [
315                'label-message' => $this->getFormSpecificMessageKey( 'contactpage-fromname' ),
316                'type' => 'text',
317                'required' => $config['RequireDetails'],
318                'default' => $fromName,
319                'disabled' => $nameReadonly,
320            ],
321            'FromAddress' => [
322                'label-message' => $this->getFormSpecificMessageKey( 'contactpage-fromaddress' ),
323                'type' => 'email',
324                'required' => $config['RequireDetails'],
325                'default' => $fromAddress,
326                'disabled' => $emailReadonly,
327            ],
328            'Subject' => [
329                'label-message' => $this->getFormSpecificMessageKey( 'emailsubject' ),
330                'type' => 'text',
331                'default' => $subject,
332                'disabled' => $subjectReadonly,
333            ],
334        ];
335
336        // Control fields cannot be repositioned or removed by FieldsMergeStrategy
337        // option so as to ensure better visual hierarchy and consistent form control.
338        $controlFields = [
339            'CCme' => [
340                'label-message' => $this->getFormSpecificMessageKey( 'emailccme' ),
341                'type' => 'check',
342                'default' => $this->userOptionsLookup->getBoolOption( $user, 'ccmeonemails' ),
343            ],
344            'FormType' => [
345                'class' => HTMLHiddenField::class,
346                'label' => 'Type',
347                'default' => $this->formType,
348            ]
349        ];
350
351        if ( $config['IncludeIP'] && $user->isRegistered() ) {
352            $controlFields['IncludeIP'] = [
353                'label-message' => $this->getFormSpecificMessageKey( 'contactpage-includeip' ),
354                'type' => 'check',
355            ];
356        }
357
358        if ( $this->useCaptcha() ) {
359            $controlFields['Captcha'] = [
360                'label-message' => 'captcha-label',
361                'type' => 'info',
362                'default' => $this->getCaptcha(),
363                'raw' => true,
364            ];
365        }
366
367        $additionalFields = $config['AdditionalFields'] ?? [];
368
369        if ( $additionalFields && $config['FieldsMergeStrategy'] === 'replace' ) {
370            // Merge fields and redefine stock fields with the same key.
371            $formFields = $additionalFields + $stockFields;
372
373            // Stock fields set to null should be removed from the form
374            $items = [
375                'FromName' => [ $fromName, $nameReadonly ],
376                'FromAddress' => [ $fromAddress, $emailReadonly ],
377                'Subject' => [ $subject, $subjectReadonly ],
378            ];
379
380            foreach ( $items as $field => [ $value, $disabled ] ) {
381                // Remove the field entirely if set to null
382                if ( $formFields[$field] === null ) {
383                    unset( $formFields[$field] );
384                } else {
385                    // if 'default' is null/unset, use computed default value
386                    $formFields[$field]['default'] ??= $value;
387                    // if 'disabled' is null/unset, set it from form config.
388                    $formFields[$field]['disabled'] ??= $disabled;
389                }
390
391            }
392        } else {
393            $formFields = $stockFields + $additionalFields;
394        }
395
396        // This field needs to be immediately after 'FromAddress' field. We have to
397        // do this here so as to check if that field exists and if we should add this one.
398        if ( isset( $formFields['FromAddress'] ) && !$config['RequireDetails'] ) {
399            $fromInfo = [
400                'FromInfo' => [
401                    'label' => '',
402                    'type' => 'info',
403                    'default' => Html::rawElement( 'small', [],
404                        $this->msg(
405                            $this->getFormSpecificMessageKey( 'contactpage-formfootnotes' )
406                        )->escaped()
407                    ),
408                    'raw' => true,
409                ]
410            ];
411
412            $formFields = wfArrayInsertAfter( $formFields, $fromInfo, 'FromAddress' );
413
414        }
415
416        // Form controls fields cannot be overriden. Use array_merge() to enforce that.
417        return array_merge( $formFields, $controlFields );
418    }
419
420    /**
421     * @param array $formData
422     * @return bool|string|array|Status
423     *     - Bool true or a good Status object indicates success,
424     *     - Bool false indicates no submission was attempted,
425     *     - Anything else indicates failure. The value may be a fatal Status
426     *       object, an HTML string, or an array of arrays (message keys and
427     *       params) or strings (message keys)
428     */
429    public function processInput( $formData ) {
430        $config = $this->getTypeConfig();
431
432        $request = $this->getRequest();
433        $user = $this->getUser();
434
435        if ( $this->useCaptcha() &&
436            !$this->getConfig()->get( 'Captcha' )->passCaptchaFromRequest( $request, $user )
437        ) {
438            return [ 'contactpage-captcha-error' ];
439        }
440
441        $senderIP = $request->getIP();
442
443        // Setup user that is going to receive the contact page response
444        if ( $config['RecipientUser'] ) {
445            $contactRecipientUser = $this->userFactory->newFromName( $config['RecipientUser'] );
446            '@phan-var \MediaWiki\User\User $contactRecipientUser';
447            $contactRecipientAddress = MailAddress::newFromUser( $contactRecipientUser );
448            $ccName = $contactRecipientUser->getName();
449        } else {
450            $ccName = $config['RecipientName'] ?? $this->getConfig()->get( 'Sitename' );
451            $contactRecipientAddress = new MailAddress( $config['RecipientEmail'] );
452        }
453
454        // Used when user hasn't set an email, when $wgUserEmailUseReplyTo is true,
455        // or when sending CC email to user
456        $siteAddress = new MailAddress(
457            $config['SenderEmail'] ?: $this->getConfig()->get( 'PasswordSender' ),
458            $config['SenderName']
459        );
460
461        // Initialize the sender to the site address
462        $senderAddress = $siteAddress;
463
464        $fromAddress = $formData['FromAddress'] ?? '';
465        $fromName = $formData['FromName'] ?? '';
466
467        $fromUserAddress = null;
468        $replyTo = null;
469
470        if ( $fromAddress ) {
471            // T232199 - If the email address is invalid, bail out.
472            // Don't allow it to fallback to basically @server.host.name
473            if ( !Sanitizer::validateEmail( $fromAddress ) ) {
474                return [ 'invalidemailaddress' ];
475            }
476
477            // Use user submitted details
478            $fromUserAddress = new MailAddress( $fromAddress, $fromName );
479
480            if ( $this->getConfig()->get( 'UserEmailUseReplyTo' ) ) {
481                // Define reply-to address
482                $replyTo = $fromUserAddress;
483            } else {
484                // Not using ReplyTo, so set the sender to $fromUserAddress
485                $senderAddress = $fromUserAddress;
486            }
487        }
488
489        $includeIP = isset( $config['IncludeIP'] ) && $config['IncludeIP']
490            && ( $user->isAnon() || $formData['IncludeIP'] );
491        $subject = $formData['Subject'] ?? '';
492
493        if ( $fromName !== '' ) {
494            if ( $includeIP ) {
495                $subject = $this->msg(
496                    'contactpage-subject-and-sender-withip',
497                    $subject,
498                    $fromName,
499                    $senderIP
500                )->inContentLanguage()->text();
501            } else {
502                $subject = $this->msg(
503                    'contactpage-subject-and-sender',
504                    $subject,
505                    $fromName
506                )->inContentLanguage()->text();
507            }
508        } elseif ( $fromAddress !== '' ) {
509            if ( $includeIP ) {
510                $subject = $this->msg(
511                    'contactpage-subject-and-sender-withip',
512                    $subject,
513                    $fromAddress,
514                    $senderIP
515                )->inContentLanguage()->text();
516            } else {
517                $subject = $this->msg(
518                    'contactpage-subject-and-sender',
519                    $subject,
520                    $fromAddress
521                )->inContentLanguage()->text();
522            }
523        } elseif ( $includeIP ) {
524            $subject = $this->msg(
525                'contactpage-subject-and-sender',
526                $subject,
527                $senderIP
528            )->inContentLanguage()->text();
529        }
530
531        $text = '';
532        foreach ( $config['AdditionalFields'] ?? [] as $name => $field ) {
533            if ( $field == null ) {
534                continue;
535            }
536
537            $class = HTMLForm::getClassFromDescriptor( $name, $field );
538
539            $value = '';
540            // TODO: Support selectandother/HTMLSelectAndOtherField
541            // options, options-messages and options-message
542            if ( isset( $field['options-messages'] ) ) {
543                // Multiple values!
544                if ( is_string( $formData[$name] ) ) {
545                    $optionValues = array_flip( $field['options-messages'] );
546                    if ( isset( $optionValues[$formData[$name]] ) ) {
547                        $value = $this->msg( $optionValues[$formData[$name]] )->inContentLanguage()->text();
548                    } else {
549                        $value = $formData[$name];
550                    }
551                } elseif ( count( $formData[$name] ) ) {
552                    $formValues = array_flip( $formData[$name] );
553                    $value .= "\n";
554                    foreach ( $field['options-messages'] as $msg => $optionValue ) {
555                        $msg = $this->msg( $msg )->inContentLanguage()->text();
556                        $optionValue = $this->getYesOrNoMsg( isset( $formValues[$optionValue] ) );
557                        $value .= "\t$msg$optionValue\n";
558                    }
559                }
560            } elseif ( isset( $field['options'] ) ) {
561                if ( is_string( $formData[$name] ) ) {
562                    $value = $formData[$name];
563                } elseif ( count( $formData[$name] ) ) {
564                    $formValues = array_flip( $formData[$name] );
565                    $value .= "\n";
566                    foreach ( $field['options'] as $msg => $optionValue ) {
567                        $optionValue = $this->getYesOrNoMsg( isset( $formValues[$optionValue] ) );
568                        $value .= "\t$msg$optionValue\n";
569                    }
570                }
571            } elseif ( $class === HTMLCheckField::class
572                // Checking old alias for compatibility with unchanged extensions
573                || $class === \HTMLCheckField::class
574            ) {
575                $value = $this->getYesOrNoMsg( $formData[$name] xor
576                    ( isset( $field['invert'] ) && $field['invert'] ) );
577            } elseif ( isset( $formData[$name] ) ) {
578                // HTMLTextField, HTMLTextAreaField
579                // HTMLFloatField, HTMLIntField
580
581                // Just dump the value if its wordy
582                $value = $formData[$name];
583            } else {
584                continue;
585            }
586
587            if ( isset( $field['contactpage-email-label'] ) ) {
588                $name = $field['contactpage-email-label'];
589            } elseif ( isset( $field['label-message'] ) ) {
590                $name = $this->msg( $field['label-message'] )->inContentLanguage()->text();
591            } else {
592                $name = $field['label'];
593            }
594
595            $text .= "{$name}$value\n";
596        }
597
598        $hookRunner = $this->getContactPageHookRunner();
599        if ( !$hookRunner->onContactForm( $contactRecipientAddress, $replyTo, $subject,
600            $text, $this->formType, $formData )
601        ) {
602            // TODO: Need to do some proper error handling here
603            return false;
604        }
605
606        wfDebug( __METHOD__ . ': sending mail from ' . $senderAddress->toString() .
607            ' to ' . $contactRecipientAddress->toString() .
608            ' replyto ' . ( $replyTo == null ? '-/-' : $replyTo->toString() ) . "\n"
609        );
610        // @phan-suppress-next-line SecurityCheck-XSS UserMailer::send defaults to text/plain if passed a string
611        $mailResult = UserMailer::send(
612            $contactRecipientAddress,
613            $senderAddress,
614            $subject,
615            $text,
616            [ 'replyTo' => $replyTo ]
617        );
618
619        $language = $this->getLanguage();
620        if ( !$mailResult->isOK() ) {
621            wfDebug( __METHOD__ . ': got error from UserMailer: ' .
622                $mailResult->getMessage( false, false, 'en' )->text() . "\n" );
623            return [ $mailResult->getMessage( 'contactpage-usermailererror', false, $language ) ];
624        }
625
626        // if the user requested a copy of this mail, do this now,
627        // unless they are emailing themselves, in which case one copy of the message is sufficient.
628        if ( $formData['CCme'] && $fromUserAddress ) {
629            $cc_subject = $this->msg( 'emailccsubject', $ccName, $subject )->text();
630            if ( $hookRunner->onContactForm(
631                $fromUserAddress, $senderAddress, $cc_subject, $text, $this->formType, $formData )
632            ) {
633                wfDebug( __METHOD__ . ': sending cc mail from ' . $senderAddress->toString() .
634                    ' to ' . $fromUserAddress->toString() . "\n"
635                );
636                // @phan-suppress-next-line SecurityCheck-XSS UserMailer::send defaults to text/plain if passed a string
637                $ccResult = UserMailer::send(
638                    $fromUserAddress,
639                    $senderAddress,
640                    $cc_subject,
641                    $text,
642                );
643                if ( !$ccResult->isOK() ) {
644                    // At this stage, the user's CC mail has failed, but their
645                    // original mail has succeeded. It's unlikely, but still, what to do?
646                    // We can either show them an error, or we can say everything was fine,
647                    // or we can say we sort of failed AND sort of succeeded. Of these options,
648                    // simply saying there was an error is probably best.
649                    return [ $ccResult->getMessage( 'contactpage-usermailererror', false, $language ) ];
650                }
651            }
652        }
653
654        $hookRunner->onContactFromComplete( $contactRecipientAddress, $replyTo, $subject, $text );
655
656        return true;
657    }
658
659    /**
660     * @param bool $value
661     * @return string
662     */
663    private function getYesOrNoMsg( $value ) {
664        return $this->msg( $value ? 'htmlform-yes' : 'htmlform-no' )->inContentLanguage()->text();
665    }
666
667    /**
668     * @return bool True if CAPTCHA should be used, false otherwise
669     */
670    private function useCaptcha() {
671        $extRegistry = ExtensionRegistry::getInstance();
672        if ( !$extRegistry->isLoaded( 'ConfirmEdit' ) ) {
673             return false;
674        }
675        $config = $this->getConfig();
676        $captchaTriggers = $config->get( 'CaptchaTriggers' );
677
678        return $config->get( 'CaptchaClass' )
679            && isset( $captchaTriggers['contactpage'] )
680            && $captchaTriggers['contactpage']
681            && !$this->getUser()->isAllowed( 'skipcaptcha' );
682    }
683
684    /**
685     * @return string CAPTCHA form HTML
686     */
687    private function getCaptcha() {
688        // NOTE: make sure we have a session. May be required for CAPTCHAs to work.
689        SessionManager::getGlobalSession()->persist();
690
691        $captcha = ConfirmEditHooks::getInstance();
692        $captcha->setTrigger( 'contactpage' );
693        $captcha->setAction( 'contact' );
694
695        $formInformation = $captcha->getFormInformation();
696        $formMetainfo = $formInformation;
697        unset( $formMetainfo['html'] );
698        $captcha->addFormInformationToOutput( $this->getOutput(), $formMetainfo );
699
700        return '<div class="captcha">' .
701            $formInformation['html'] .
702            "</div>\n";
703    }
704
705    /**
706     * @return HookRunner
707     */
708    private function getContactPageHookRunner() {
709        if ( !$this->contactPageHookRunner ) {
710            $this->contactPageHookRunner = new HookRunner( $this->getHookContainer() );
711        }
712        return $this->contactPageHookRunner;
713    }
714}