Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
36.59% |
45 / 123 |
|
14.29% |
1 / 7 |
CRAP | |
0.00% |
0 / 1 |
CampaignsUserMailer | |
36.59% |
45 / 123 |
|
14.29% |
1 / 7 |
212.91 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 | |||
sendEmail | |
91.30% |
21 / 23 |
|
0.00% |
0 / 1 |
6.02 | |||
getMessageWithFooter | |
0.00% |
0 / 27 |
|
0.00% |
0 / 1 |
12 | |||
createEmailJob | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
2 | |||
validateTarget | |
33.33% |
9 / 27 |
|
0.00% |
0 / 1 |
33.00 | |||
validateSender | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
12 | |||
getFromAndReplyTo | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
12 |
1 | <?php |
2 | declare( strict_types=1 ); |
3 | namespace MediaWiki\Extension\CampaignEvents\Messaging; |
4 | |
5 | use JobQueueGroup; |
6 | use MailAddress; |
7 | use MediaWiki\Config\ServiceOptions; |
8 | use MediaWiki\Context\RequestContext; |
9 | use MediaWiki\Extension\CampaignEvents\Event\ExistingEventRegistration; |
10 | use MediaWiki\Extension\CampaignEvents\MWEntity\CampaignsCentralUserLookup; |
11 | use MediaWiki\Extension\CampaignEvents\MWEntity\PageURLResolver; |
12 | use MediaWiki\Extension\CampaignEvents\Participants\Participant; |
13 | use MediaWiki\Extension\CampaignEvents\Permissions\PermissionChecker; |
14 | use MediaWiki\Extension\CampaignEvents\Special\SpecialAllEvents; |
15 | use MediaWiki\Mail\EmailUserFactory; |
16 | use MediaWiki\MainConfigNames; |
17 | use MediaWiki\Permissions\Authority; |
18 | use MediaWiki\Preferences\MultiUsernameFilter; |
19 | use MediaWiki\SpecialPage\SpecialPage; |
20 | use MediaWiki\User\CentralId\CentralIdLookup; |
21 | use MediaWiki\User\Options\UserOptionsLookup; |
22 | use MediaWiki\User\User; |
23 | use MediaWiki\User\UserFactory; |
24 | use StatusValue; |
25 | use Wikimedia\Message\ITextFormatter; |
26 | use 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 | */ |
32 | class 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 | } |