Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
113 / 113
100.00% covered (success)
100.00%
5 / 5
CRAP
100.00% covered (success)
100.00%
1 / 1
ReportIncidentMailer
100.00% covered (success)
100.00%
113 / 113
100.00% covered (success)
100.00%
5 / 5
14
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 sendEmail
100.00% covered (success)
100.00%
53 / 53
100.00% covered (success)
100.00%
1 / 1
4
 validateConfig
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
1 / 1
4
 getLinkToReportedContent
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 actuallySendEmail
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace MediaWiki\Extension\ReportIncident\Services;
4
5use MailAddress;
6use MediaWiki\Config\ServiceOptions;
7use MediaWiki\Extension\ReportIncident\IncidentReport;
8use MediaWiki\Extension\ReportIncident\IncidentReportEmailStatus;
9use MediaWiki\Mail\IEmailer;
10use MediaWiki\Title\TitleFactory;
11use MediaWiki\Utils\UrlUtils;
12use Psr\Log\LoggerInterface;
13use StatusValue;
14use Wikimedia\Message\ITextFormatter;
15use Wikimedia\Message\MessageValue;
16
17/**
18 * Handles emailing incident reports.
19 */
20class ReportIncidentMailer {
21
22    private ServiceOptions $options;
23    private TitleFactory $titleFactory;
24    private ITextFormatter $textFormatter;
25    private IEmailer $emailer;
26    private LoggerInterface $logger;
27    private UrlUtils $urlUtils;
28    public const CONSTRUCTOR_OPTIONS = [
29        'ReportIncidentRecipientEmails',
30        'ReportIncidentEmailFromAddress',
31    ];
32
33    /**
34     * @param ServiceOptions $options
35     * @param UrlUtils $urlUtils
36     * @param TitleFactory $titleFactory
37     * @param ITextFormatter $textFormatter
38     * @param IEmailer $emailer
39     * @param LoggerInterface $logger
40     */
41    public function __construct(
42        ServiceOptions $options,
43        UrlUtils $urlUtils,
44        TitleFactory $titleFactory,
45        ITextFormatter $textFormatter,
46        IEmailer $emailer,
47        LoggerInterface $logger
48    ) {
49        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
50        $this->options = $options;
51        $this->urlUtils = $urlUtils;
52        $this->titleFactory = $titleFactory;
53        $this->textFormatter = $textFormatter;
54        $this->emailer = $emailer;
55        $this->logger = $logger;
56    }
57
58    /**
59     * Sends an email to the administrators using the
60     * provided IncidentReport object as the data to
61     * send.
62     *
63     * @param IncidentReport $incidentReport The IncidentReport object containing the data to send in the email.
64     * @return IncidentReportEmailStatus The result of attempting to email the administrators. A good status indicates
65     *   an email was sent.
66     */
67    public function sendEmail( IncidentReport $incidentReport ): StatusValue {
68        $reportIncidentEmailStatus = $this->validateConfig();
69        if ( !$reportIncidentEmailStatus->isGood() ) {
70            // Return early if the config was not valid.
71            return $reportIncidentEmailStatus;
72        }
73        // Get MailAddress objects for the to emails and from email.
74        $to = array_map( static function ( $address ) {
75            return new MailAddress( $address );
76        }, $this->options->get( 'ReportIncidentRecipientEmails' ) );
77        $from = new MailAddress(
78            $this->options->get( 'ReportIncidentEmailFromAddress' ),
79            $this->textFormatter->format(
80                new MessageValue( 'emailsender' )
81            )
82        );
83        $reportingUserPage = $this->titleFactory->newFromText(
84            $incidentReport->getReportingUser()->getName(), NS_USER
85        );
86        $subject = $this->textFormatter->format(
87            new MessageValue(
88                'reportincident-email-subject',
89                [ $reportingUserPage->getPrefixedDBkey() ]
90            )
91        );
92
93        [ $linkPrefixText, $linkToPageAtRevision ] = $this->getLinkToReportedContent( $incidentReport );
94
95        $reportedUserPage = $this->titleFactory->newFromText(
96            $incidentReport->getReportedUser()->getName(), NS_USER
97        );
98        // Get the behaviors and substitute the 'something-else' behavior
99        // with the text submitted in the Something else textbox.
100        $behaviors = $incidentReport->getBehaviors();
101        if ( $incidentReport->getSomethingElseDetails() ) {
102            $somethingElseIndex = array_search( 'something-else', $behaviors );
103            if ( $somethingElseIndex !== false ) {
104                $behaviors[$somethingElseIndex] = $this->textFormatter->format(
105                    new MessageValue(
106                        'reportincident-email-something-else',
107                        [ $incidentReport->getSomethingElseDetails() ]
108                    )
109                );
110            }
111        }
112        $emailUrl = $this->titleFactory->newFromText( 'Special:EmailUser' )
113            ->getSubpage( $reportingUserPage->getDBkey() )
114            ->getFullURL();
115
116        $body = $this->textFormatter->format(
117            new MessageValue(
118                'reportincident-email-body',
119                [
120                    $reportingUserPage->getDBkey(),
121                    $reportedUserPage->getDBkey(),
122                    $linkPrefixText,
123                    $linkToPageAtRevision,
124                    implode( ', ', $behaviors ),
125                    $incidentReport->getDetails(),
126                    $emailUrl
127                ]
128            )
129        );
130        return $this->actuallySendEmail( $to, $from, $subject, $body, $reportIncidentEmailStatus );
131    }
132
133    /**
134     * Validates the configuration settings wgReportIncidentRecipientEmails
135     * and wgReportIncidentEmailFromAddress. If invalid this method returns
136     * a fatal status. Otherwise a good status is returned.
137     *
138     *
139     * @return IncidentReportEmailStatus
140     */
141    private function validateConfig(): IncidentReportEmailStatus {
142        $recipientEmails = $this->options->get( 'ReportIncidentRecipientEmails' );
143        if ( !$recipientEmails || !is_array( $recipientEmails ) ) {
144            $this->logger->error(
145                'ReportIncidentRecipientEmails configuration is empty or not an array, not sending an email.'
146            );
147            return IncidentReportEmailStatus::newFatal(
148                'rawmessage',
149                'ReportIncidentRecipientEmails configuration is empty or not an array, not sending an email.'
150            );
151        }
152        if ( !$this->options->get( 'ReportIncidentEmailFromAddress' ) ) {
153            $this->logger->error( 'ReportIncidentEmailFromAddress configuration is empty, not sending an email.' );
154            return IncidentReportEmailStatus::newFatal(
155                'rawmessage',
156                'ReportIncidentEmailFromAddress configuration is empty, not sending an email.'
157            );
158        }
159        return IncidentReportEmailStatus::newGood();
160    }
161
162    /**
163     * Gets a link to the reported content. This returns a link to
164     * the permalink of the page, and if the report entry point was
165     * via DiscussionTools the link includes an anchor to the topic
166     * or comment that was reported.
167     *
168     * @param IncidentReport $incidentReport The IncidentReport object provided to ::sendEmail
169     * @return array First item being the prefix text for the link to be used in the email
170     *   and the second item being the URL to the reported content.
171     */
172    private function getLinkToReportedContent( IncidentReport $incidentReport ): array {
173        $revision = $incidentReport->getRevisionRecord();
174        // In theory UrlUtils::expand() could return null, this seems pretty unlikely in practice;
175        // cast to string to make Phan happy.
176        $entrypointUrl = (string)$this->urlUtils->expand( wfScript() );
177        $linkToPageAtRevision = wfAppendQuery(
178            $entrypointUrl,
179            [ 'oldid' => $revision->getId() ]
180        );
181        $threadId = $incidentReport->getThreadId();
182        if ( $threadId ) {
183            // If a thread ID is defined, then add it to the link to the page at revision
184            // as an anchor in the URL. Currently this is only provided by DiscussionTools.
185            $linkToPageAtRevision .= '#' . urlencode( $threadId );
186            // If the threadId starts with 'h-', then the threadId refers to a topic/section header
187            // and as such the link prefix text should indicate this instead of saying it is a comment.
188            if ( substr( $threadId, 0, 2 ) === 'h-' ) {
189                $linkPrefixText = new MessageValue( 'reportincident-email-link-to-topic-prefix' );
190            } else {
191                $linkPrefixText = new MessageValue( 'reportincident-email-link-to-comment-prefix' );
192            }
193        } else {
194            $linkPrefixText = new MessageValue( 'reportincident-email-link-to-page-prefix' );
195        }
196        return [ $linkPrefixText, $linkToPageAtRevision ];
197    }
198
199    /**
200     * Actually sends the email when provided with the
201     * 'to' email address, 'from' email addresses, the subject,
202     * and the body of the email.
203     *
204     * @param MailAddress[] $to
205     * @param MailAddress $from
206     * @param string $subject
207     * @param string $body
208     * @param IncidentReportEmailStatus $incidentReportEmailStatus Should be a status with no errors.
209     * @return IncidentReportEmailStatus
210     */
211    private function actuallySendEmail(
212        array $to,
213        MailAddress $from,
214        string $subject,
215        string $body,
216        IncidentReportEmailStatus $incidentReportEmailStatus
217    ) {
218        // Call IEmailer::send and merge the status returned with the
219        // existing $incidentReportEmailStatus status.
220        $incidentReportEmailStatus->merge(
221            $this->emailer->send(
222                $to,
223                $from,
224                $subject,
225                $body
226            ),
227            true
228        );
229        // Add the email contents to $incidentReportEmailStatus
230        $incidentReportEmailStatus->emailContents = [
231            'to' => $to,
232            'from' => $from,
233            'subject' => $subject,
234            'body' => $body,
235        ];
236        if ( !$incidentReportEmailStatus->isGood() ) {
237            // Log an error if the IEmailer::send method returns a fatal status.
238            $this->logger->error(
239                'Unable to send report incident email. IEmailer::send returned the following: {emailer-message}',
240                [
241                    'emailer-message' => $incidentReportEmailStatus->getMessage( false, false, 'en' ),
242                ]
243            );
244        }
245        return $incidentReportEmailStatus;
246    }
247}