Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
36.59% covered (danger)
36.59%
45 / 123
14.29% covered (danger)
14.29%
1 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
CampaignsUserMailer
36.59% covered (danger)
36.59%
45 / 123
14.29% covered (danger)
14.29%
1 / 7
212.91
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
 sendEmail
91.30% covered (success)
91.30%
21 / 23
0.00% covered (danger)
0.00%
0 / 1
6.02
 getMessageWithFooter
0.00% covered (danger)
0.00%
0 / 27
0.00% covered (danger)
0.00%
0 / 1
12
 createEmailJob
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
2
 validateTarget
33.33% covered (danger)
33.33%
9 / 27
0.00% covered (danger)
0.00%
0 / 1
33.00
 validateSender
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
12
 getFromAndReplyTo
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
1<?php
2declare( strict_types=1 );
3namespace MediaWiki\Extension\CampaignEvents\Messaging;
4
5use JobQueueGroup;
6use MailAddress;
7use MediaWiki\Config\ServiceOptions;
8use MediaWiki\Context\RequestContext;
9use MediaWiki\Extension\CampaignEvents\Event\ExistingEventRegistration;
10use MediaWiki\Extension\CampaignEvents\MWEntity\CampaignsCentralUserLookup;
11use MediaWiki\Extension\CampaignEvents\MWEntity\PageURLResolver;
12use MediaWiki\Extension\CampaignEvents\Participants\Participant;
13use MediaWiki\Extension\CampaignEvents\Permissions\PermissionChecker;
14use MediaWiki\Extension\CampaignEvents\Special\SpecialAllEvents;
15use MediaWiki\Mail\EmailUserFactory;
16use MediaWiki\MainConfigNames;
17use MediaWiki\Permissions\Authority;
18use MediaWiki\Preferences\MultiUsernameFilter;
19use MediaWiki\SpecialPage\SpecialPage;
20use MediaWiki\User\CentralId\CentralIdLookup;
21use MediaWiki\User\Options\UserOptionsLookup;
22use MediaWiki\User\User;
23use MediaWiki\User\UserFactory;
24use StatusValue;
25use Wikimedia\Message\ITextFormatter;
26use Wikimedia\Message\MessageValue;
27
28/**
29 * This class uses a lot of MW classes as the core email code is not ideal and there aren't many alternatives.
30 * All of this should be refactored in future if possible.
31 */
32class CampaignsUserMailer {
33
34    public const SERVICE_NAME = 'CampaignEventsUserMailer';
35    public const CONSTRUCTOR_OPTIONS = [
36        MainConfigNames::PasswordSender,
37        MainConfigNames::EnableEmail,
38        MainConfigNames::EnableUserEmail,
39        MainConfigNames::UserEmailUseReplyTo,
40        MainConfigNames::EnableSpecialMute,
41    ];
42
43    private UserFactory $userFactory;
44    private JobQueueGroup $jobQueueGroup;
45    private ServiceOptions $options;
46    private CentralIdLookup $centralIdLookup;
47    private CampaignsCentralUserLookup $centralUserLookup;
48    private UserOptionsLookup $userOptionsLookup;
49    private ITextFormatter $contLangMsgFormatter;
50    private PageURLResolver $pageURLResolver;
51    private EmailUserFactory $emailUserFactory;
52
53    /**
54     * @param UserFactory $userFactory
55     * @param JobQueueGroup $jobQueueGroup
56     * @param ServiceOptions $options
57     * @param CentralIdLookup $centralIdLookup
58     * @param CampaignsCentralUserLookup $centralUserLookup
59     * @param UserOptionsLookup $userOptionsLookup
60     * @param ITextFormatter $contLangMsgFormatter
61     * @param PageURLResolver $pageURLResolver
62     * @param EmailUserFactory $emailUserFactory
63     */
64    public function __construct(
65        UserFactory $userFactory,
66        JobQueueGroup $jobQueueGroup,
67        ServiceOptions $options,
68        CentralIdLookup $centralIdLookup,
69        CampaignsCentralUserLookup $centralUserLookup,
70        UserOptionsLookup $userOptionsLookup,
71        ITextFormatter $contLangMsgFormatter,
72        PageURLResolver $pageURLResolver,
73        EmailUserFactory $emailUserFactory
74    ) {
75        $this->userFactory = $userFactory;
76        $this->jobQueueGroup = $jobQueueGroup;
77        $options->assertRequiredOptions( self::CONSTRUCTOR_OPTIONS );
78        $this->options = $options;
79        $this->centralIdLookup = $centralIdLookup;
80        $this->centralUserLookup = $centralUserLookup;
81        $this->userOptionsLookup = $userOptionsLookup;
82        $this->contLangMsgFormatter = $contLangMsgFormatter;
83        $this->pageURLResolver = $pageURLResolver;
84        $this->emailUserFactory = $emailUserFactory;
85    }
86
87    /**
88     * @param Authority $performer
89     * @param Participant[] $participants
90     * @param string $subject
91     * @param string $message
92     * @param ExistingEventRegistration $event
93     * @return StatusValue
94     */
95    public function sendEmail(
96        Authority $performer,
97        array $participants,
98        string $subject,
99        string $message,
100        ExistingEventRegistration $event
101    ): StatusValue {
102        $centralIdsMap = [];
103        foreach ( $participants as $participant ) {
104            $centralIdsMap[$participant->getUser()->getCentralID()] = null;
105        }
106
107        $recipients = $this->centralUserLookup->getNames( $centralIdsMap );
108
109        $validSend = 0;
110        $performerUser = $this->userFactory->newFromAuthority( $performer );
111        $status = $this->validateSender( $performerUser );
112        if ( !$status->isGood() ) {
113            return $status;
114        }
115        $jobs = [];
116        foreach ( $recipients as $recipientName ) {
117            $recipientUser = $this->userFactory->newFromName( $recipientName );
118            if ( $recipientUser === null ) {
119                continue;
120            }
121            $validTarget = $this->validateTarget( $recipientUser, $performerUser );
122            if ( $validTarget === null ) {
123                $validSend++;
124                $recipientAddress = MailAddress::newFromUser( $recipientUser );
125                $performerAddress = MailAddress::newFromUser( $performerUser );
126                $curMessage = $this->getMessageWithFooter( $message, $performerAddress, $recipientAddress, $event );
127                // @phan-suppress-next-line SecurityCheck-XSS Gets confused by HTML and text body being passed together
128                $jobs[] = $this->createEmailJob( $recipientAddress, $subject, $curMessage, $performerAddress );
129            }
130        }
131        $this->jobQueueGroup->push( $jobs );
132
133        return StatusValue::newGood( $validSend );
134    }
135
136    /**
137     * Add a predefined footer to the email body, similar to EmailUser::sendEmailUnsafe().
138     * @todo It might make sense to move this to the job, for performance. However, it should wait until
139     * T339821 is ready, as that will give us a better holistic view of how to refactor this code.
140     *
141     * @param string $body
142     * @param MailAddress $from
143     * @param MailAddress $to
144     * @param ExistingEventRegistration $event
145     * @return string
146     */
147    private function getMessageWithFooter(
148        string $body,
149        MailAddress $from,
150        MailAddress $to,
151        ExistingEventRegistration $event
152    ): string {
153        $body = rtrim( $body ) . "\n\n-- \n";
154        $eventPageURL = $this->pageURLResolver->getCanonicalUrl( $event->getPage() );
155        $body .= $this->contLangMsgFormatter->format(
156            MessageValue::new( 'campaignevents-email-footer', [ $from->name, $to->name, $eventPageURL ] )
157        );
158        if ( $this->options->get( MainConfigNames::EnableSpecialMute ) ) {
159            $body .= "\n" . $this->contLangMsgFormatter->format(
160                MessageValue::new(
161                    'specialmute-email-footer',
162                    [
163                        SpecialPage::getTitleFor( 'Mute', $from->name )->getCanonicalURL(),
164                        $from->name
165                    ]
166                )
167            );
168        }
169        if ( defined( 'MW_PHPUNIT_TEST' ) ) {
170            $collaborationListLink = '(Collaboration list link)';
171        } else {
172            $collaborationListLink = "\n\n" . $this->contLangMsgFormatter->format(
173                    MessageValue::new(
174                        'campaignevents-email-footer-collaboration-list-link',
175                        [
176                            SpecialPage::getTitleFor( SpecialAllEvents::PAGE_NAME )->getCanonicalURL()
177                        ]
178                    )
179                );
180        }
181        $body .= $collaborationListLink;
182
183        return $body;
184    }
185
186    /**
187     * @param MailAddress $to
188     * @param string $subject
189     * @param string $message
190     * @param MailAddress $from
191     * @return EmailUsersJob
192     */
193    private function createEmailJob(
194        MailAddress $to,
195        string $subject,
196        string $message,
197        MailAddress $from
198    ): EmailUsersJob {
199        [ $mailFrom, $replyTo ] = $this->getFromAndReplyTo( $from );
200
201        // TODO: This could be improved by making MailAddress JSON-serializable, see T346406
202        $toComponents = [ $to->address, $to->name, $to->realName ];
203        $fromComponents = [ $mailFrom->address, $mailFrom->name, $mailFrom->realName ];
204        $replyToComponents = $replyTo ? [ $replyTo->address, $replyTo->name, $replyTo->realName ] : null;
205
206        $params = [
207            'to' => $toComponents,
208            'from' => $fromComponents,
209            'subject' => $subject,
210            'message' => $message,
211            'replyTo' => $replyToComponents
212        ];
213
214        return new EmailUsersJob(
215            'sendCampaignEmail',
216            $params
217        );
218    }
219
220    /**
221     * Validate target User
222     * This code was copied from core EmailUser::validateTarget
223     * @param User $target Target user
224     * @param User $sender User sending the email
225     * @return null|string Null on success, string on error.
226     */
227    public function validateTarget( User $target, User $sender ): ?string {
228        if ( !$target->getId() ) {
229            wfDebug( "Target is invalid user." );
230
231            return 'notarget';
232        }
233
234        if ( !$target->isEmailConfirmed() ) {
235            wfDebug( "User has no valid email." );
236
237            return 'noemail';
238        }
239
240        if ( !$target->canReceiveEmail() ) {
241            wfDebug( "User does not allow user emails." );
242
243            return 'nowikiemail';
244        }
245
246        if ( !$this->userOptionsLookup->getOption(
247                $target,
248                'email-allow-new-users'
249            ) && $sender->isNewbie()
250        ) {
251            wfDebug( "User does not allow user emails from new users." );
252
253            return 'nowikiemail';
254        }
255
256        $muteList = $this->userOptionsLookup->getOption(
257            $target,
258            'email-blacklist',
259            ''
260        );
261        if ( $muteList ) {
262            $muteList = MultiUsernameFilter::splitIds( $muteList );
263            $senderId = $this->centralIdLookup->centralIdFromLocalUser( $sender );
264            if ( $senderId !== 0 && in_array( $senderId, $muteList, true ) ) {
265                wfDebug( "User does not allow user emails from this user." );
266
267                return 'nowikiemail';
268            }
269        }
270
271        return null;
272    }
273
274    /**
275     * Check whether a user is allowed to send email
276     * @param User $user
277     * @return StatusValue status indicating whether the user can send an email or not
278     */
279    private function validateSender( User $user ): StatusValue {
280        $status = $this->emailUserFactory
281            ->newEmailUser( $user )
282            ->canSend();
283        if ( !$status->isGood() ) {
284            return $status;
285        }
286
287        if ( $user->pingLimiter( PermissionChecker::SEND_EVENTS_EMAIL_RIGHT ) ) {
288            wfDebug( "Ping limiter triggered." );
289
290            return StatusValue::newFatal( 'actionthrottledtext' );
291        }
292
293        return StatusValue::newGood();
294    }
295
296    /**
297     * @param MailAddress $fromAddress
298     * @return array<MailAddress|null>
299     * @phan-return array{0:MailAddress,1:?MailAddress}
300     */
301    private function getFromAndReplyTo( MailAddress $fromAddress ): array {
302        if ( $this->options->get( MainConfigNames::UserEmailUseReplyTo ) ) {
303            /**
304             * Put the generic wiki autogenerated address in the From:
305             * header and reserve the user for Reply-To.
306             *
307             * This is a bit ugly, but will serve to differentiate
308             * wiki-borne mails from direct mails and protects against
309             * SPF and bounce problems with some mailers (see below).
310             */
311            if ( defined( 'MW_PHPUNIT_TEST' ) ) {
312                $emailSenderName = '(emailsender)';
313            } else {
314                $emailSenderName = RequestContext::getMain()->msg( 'emailsender' )->inContentLanguage()->text();
315            }
316            $mailFrom = new MailAddress(
317                $this->options->get( MainConfigNames::PasswordSender ),
318                $emailSenderName
319            );
320            $replyTo = $fromAddress;
321        } else {
322            /**
323             * Put the sending user's e-mail address in the From: header.
324             *
325             * This is clean-looking and convenient, but has issues.
326             * One is that it doesn't as clearly differentiate the wiki mail
327             * from "directly" sent mails.
328             *
329             * Another is that some mailers (like sSMTP) will use the From
330             * address as the envelope sender as well. For open sites this
331             * can cause mails to be flunked for SPF violations (since the
332             * wiki server isn't an authorized sender for various users'
333             * domains) as well as creating a privacy issue as bounces
334             * containing the recipient's e-mail address may get sent to
335             * the sending user.
336             */
337            $mailFrom = $fromAddress;
338            $replyTo = null;
339        }
340        return [ $mailFrom, $replyTo ];
341    }
342}