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