Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
39.82% |
45 / 113 |
|
14.29% |
1 / 7 |
CRAP | |
0.00% |
0 / 1 |
CampaignsUserMailer | |
39.82% |
45 / 113 |
|
14.29% |
1 / 7 |
173.31 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 | |||
sendEmail | |
91.30% |
21 / 23 |
|
0.00% |
0 / 1 |
6.02 | |||
getMessageWithFooter | |
0.00% |
0 / 16 |
|
0.00% |
0 / 1 |
6 | |||
createEmailJob | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
2 | |||
validateTarget | |
31.03% |
9 / 29 |
|
0.00% |
0 / 1 |
35.57 | |||
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\Extension\CampaignEvents\Event\ExistingEventRegistration; |
9 | use MediaWiki\Extension\CampaignEvents\MWEntity\CampaignsCentralUserLookup; |
10 | use MediaWiki\Extension\CampaignEvents\MWEntity\PageURLResolver; |
11 | use MediaWiki\Extension\CampaignEvents\Participants\Participant; |
12 | use MediaWiki\Extension\CampaignEvents\Permissions\PermissionChecker; |
13 | use MediaWiki\Mail\EmailUserFactory; |
14 | use MediaWiki\MainConfigNames; |
15 | use MediaWiki\MediaWikiServices; |
16 | use MediaWiki\Permissions\Authority; |
17 | use MediaWiki\Preferences\MultiUsernameFilter; |
18 | use MediaWiki\SpecialPage\SpecialPage; |
19 | use MediaWiki\User\Options\UserOptionsLookup; |
20 | use MediaWiki\User\User; |
21 | use MediaWiki\User\UserFactory; |
22 | use RequestContext; |
23 | use StatusValue; |
24 | use Wikimedia\Message\ITextFormatter; |
25 | use 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 | */ |
31 | class 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 | } |