Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
0.00% |
0 / 171 |
|
0.00% |
0 / 10 |
CRAP | |
0.00% |
0 / 1 |
EmailBatch | |
0.00% |
0 / 171 |
|
0.00% |
0 / 10 |
1190 | |
0.00% |
0 / 1 |
__construct | |
0.00% |
0 / 5 |
|
0.00% |
0 / 1 |
2 | |||
newFromUserId | |
0.00% |
0 / 17 |
|
0.00% |
0 / 1 |
42 | |||
process | |
0.00% |
0 / 18 |
|
0.00% |
0 / 1 |
42 | |||
setLastEvent | |
0.00% |
0 / 11 |
|
0.00% |
0 / 1 |
6 | |||
updateUserLastBatchTimestamp | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
2 | |||
getEvents | |
0.00% |
0 / 35 |
|
0.00% |
0 / 1 |
42 | |||
clearProcessedEvent | |
0.00% |
0 / 28 |
|
0.00% |
0 / 1 |
20 | |||
sendEmail | |
0.00% |
0 / 26 |
|
0.00% |
0 / 1 |
20 | |||
addToQueue | |
0.00% |
0 / 15 |
|
0.00% |
0 / 1 |
12 | |||
getUsersToNotify | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Extension\Notifications; |
4 | |
5 | use BatchRowIterator; |
6 | use Language; |
7 | use MailAddress; |
8 | use MediaWiki\Extension\Notifications\Formatters\EchoHtmlDigestEmailFormatter; |
9 | use MediaWiki\Extension\Notifications\Formatters\EchoPlainTextDigestEmailFormatter; |
10 | use MediaWiki\Extension\Notifications\Mapper\EventMapper; |
11 | use MediaWiki\Extension\Notifications\Model\Event; |
12 | use MediaWiki\Languages\LanguageFactory; |
13 | use MediaWiki\MediaWikiServices; |
14 | use MediaWiki\User\Options\UserOptionsManager; |
15 | use MediaWiki\User\User; |
16 | use stdClass; |
17 | use UserMailer; |
18 | use Wikimedia\Rdbms\IResultWrapper; |
19 | |
20 | /** |
21 | * Handle user email batch ( daily/ weekly ) |
22 | */ |
23 | class EmailBatch { |
24 | |
25 | /** |
26 | * @var User the user to be notified |
27 | */ |
28 | protected $mUser; |
29 | |
30 | /** |
31 | * @var Language |
32 | */ |
33 | protected $language; |
34 | |
35 | /** |
36 | * @var UserOptionsManager |
37 | */ |
38 | protected $userOptionsManager; |
39 | |
40 | /** |
41 | * @var Event[] events included in this email |
42 | */ |
43 | protected $events = []; |
44 | |
45 | /** |
46 | * @var Event the last notification event of this batch |
47 | */ |
48 | protected $lastEvent; |
49 | |
50 | /** |
51 | * @var int the event count, this count is supported up to self::$displaySize + 1 |
52 | */ |
53 | protected $count = 0; |
54 | |
55 | /** |
56 | * @var int number of bundle events to include in an email, |
57 | * we cannot include all events in a batch email |
58 | */ |
59 | protected static $displaySize = 20; |
60 | |
61 | /** |
62 | * @param User $user |
63 | * @param UserOptionsManager $userOptionsManager |
64 | * @param LanguageFactory $languageFactory |
65 | */ |
66 | public function __construct( |
67 | User $user, |
68 | UserOptionsManager $userOptionsManager, |
69 | LanguageFactory $languageFactory |
70 | ) { |
71 | $this->mUser = $user; |
72 | $this->language = $languageFactory->getLanguage( |
73 | $userOptionsManager->getOption( $user, 'language' ) |
74 | ); |
75 | $this->userOptionsManager = $userOptionsManager; |
76 | } |
77 | |
78 | /** |
79 | * Factory method to determine whether to create a batch instance for this |
80 | * user based on the user setting, this assumes the following value for |
81 | * member setting for echo-email-frequency |
82 | * -1 - no email |
83 | * 0 - instant |
84 | * 1 - once everyday |
85 | * 7 - once every 7 days |
86 | * @param int $userId |
87 | * @param bool $enforceFrequency Whether email sending frequency should |
88 | * be enforced. |
89 | * |
90 | * When true, today's notifications won't be returned if they are |
91 | * configured to go out tonight or at the end of the week. |
92 | * |
93 | * When false, all pending notifications will be returned. |
94 | * @return EmailBatch|false |
95 | */ |
96 | public static function newFromUserId( $userId, $enforceFrequency = true ) { |
97 | $user = User::newFromId( (int)$userId ); |
98 | $services = MediaWikiServices::getInstance(); |
99 | $userOptionsManager = $services->getUserOptionsManager(); |
100 | $languageFactory = $services->getLanguageFactory(); |
101 | |
102 | $userEmailSetting = (int)$userOptionsManager->getOption( $user, 'echo-email-frequency' ); |
103 | |
104 | // clear all existing events if user decides not to receive emails |
105 | if ( $userEmailSetting == -1 ) { |
106 | $emailBatch = new self( $user, $userOptionsManager, $languageFactory ); |
107 | $emailBatch->clearProcessedEvent(); |
108 | |
109 | return false; |
110 | } |
111 | |
112 | // @Todo - There may be some items idling in the queue, eg, a bundle job is lost |
113 | // and there is not never another message with the same hash or a user switches from |
114 | // digest to instant. We should check the first item in the queue, if it doesn't |
115 | // have either web or email bundling or created long ago, then clear it, this will |
116 | // prevent idling item queuing up. |
117 | |
118 | // user has instant email delivery |
119 | if ( $userEmailSetting == 0 ) { |
120 | return false; |
121 | } |
122 | |
123 | $userLastBatch = $userOptionsManager->getOption( $user, 'echo-email-last-batch' ); |
124 | |
125 | // send email batch, if |
126 | // 1. it has been long enough since last email batch based on frequency |
127 | // 2. there is no last batch timestamp recorded for the user |
128 | // 3. user has switched from batch to instant email, send events left in the queue |
129 | if ( $userLastBatch ) { |
130 | // use 20 as hours per day to get estimate |
131 | $nextBatch = (int)wfTimestamp( TS_UNIX, $userLastBatch ) + $userEmailSetting * 20 * 60 * 60; |
132 | if ( $enforceFrequency && wfTimestamp( TS_MW, $nextBatch ) > wfTimestampNow() ) { |
133 | return false; |
134 | } |
135 | } |
136 | |
137 | return new self( $user, $userOptionsManager, $languageFactory ); |
138 | } |
139 | |
140 | /** |
141 | * Wrapper function that calls other functions required to process email batch |
142 | */ |
143 | public function process() { |
144 | // if there is no event for this user, exist the process |
145 | if ( !$this->setLastEvent() ) { |
146 | return; |
147 | } |
148 | |
149 | // get valid events |
150 | $events = $this->getEvents(); |
151 | |
152 | if ( $events ) { |
153 | foreach ( $events as $row ) { |
154 | $this->count++; |
155 | if ( $this->count > self::$displaySize ) { |
156 | break; |
157 | } |
158 | $event = Event::newFromRow( $row ); |
159 | if ( !$event ) { |
160 | continue; |
161 | } |
162 | $event->setBundleHash( $row->eeb_event_hash ); |
163 | $this->events[] = $event; |
164 | } |
165 | |
166 | $bundler = new Bundler(); |
167 | $this->events = $bundler->bundle( $this->events ); |
168 | |
169 | $this->sendEmail(); |
170 | } |
171 | |
172 | $this->clearProcessedEvent(); |
173 | $this->updateUserLastBatchTimestamp(); |
174 | } |
175 | |
176 | /** |
177 | * Set the last event of this batch, this is a cutoff point for clearing |
178 | * processed/invalid events |
179 | * |
180 | * @return bool true if event exists false otherwise |
181 | */ |
182 | protected function setLastEvent() { |
183 | $dbr = DbFactory::newFromDefault()->getEchoDb( DB_REPLICA ); |
184 | $res = $dbr->newSelectQueryBuilder() |
185 | ->select( 'MAX( eeb_event_id )' ) |
186 | ->from( 'echo_email_batch' ) |
187 | ->where( [ 'eeb_user_id' => $this->mUser->getId() ] ) |
188 | ->caller( __METHOD__ ) |
189 | ->fetchField(); |
190 | |
191 | if ( $res ) { |
192 | $this->lastEvent = $res; |
193 | |
194 | return true; |
195 | } |
196 | |
197 | return false; |
198 | } |
199 | |
200 | /** |
201 | * Update the user's last batch timestamp after a successful batch |
202 | */ |
203 | protected function updateUserLastBatchTimestamp() { |
204 | $this->userOptionsManager->setOption( |
205 | $this->mUser, |
206 | 'echo-email-last-batch', |
207 | wfTimestampNow() |
208 | ); |
209 | $this->mUser->saveSettings(); |
210 | $this->mUser->invalidateCache(); |
211 | } |
212 | |
213 | /** |
214 | * Get the events queued for the current user |
215 | * @return stdClass[] |
216 | */ |
217 | protected function getEvents() { |
218 | global $wgEchoNotifications; |
219 | |
220 | $events = []; |
221 | |
222 | $validEvents = array_keys( $wgEchoNotifications ); |
223 | |
224 | // Per the tech discussion in the design meeting (03/22/2013), since this is |
225 | // processed by a cron job, it's okay to use GROUP BY over more complex |
226 | // composite index, favor insert performance, storage space over read |
227 | // performance in this case |
228 | if ( $validEvents ) { |
229 | $dbr = DbFactory::newFromDefault()->getEchoDb( DB_REPLICA ); |
230 | $queryBuilder = $dbr->newSelectQueryBuilder() |
231 | ->select( array_merge( Event::selectFields(), [ |
232 | 'eeb_id', |
233 | 'eeb_user_id', |
234 | 'eeb_event_priority', |
235 | 'eeb_event_id', |
236 | 'eeb_event_hash', |
237 | ] ) ) |
238 | ->from( 'echo_email_batch' ) |
239 | ->join( 'echo_event', null, 'event_id = eeb_event_id' ) |
240 | ->where( [ |
241 | 'eeb_user_id' => $this->mUser->getId(), |
242 | 'event_type' => $validEvents |
243 | ] ) |
244 | ->orderBy( 'eeb_event_priority' ) |
245 | ->limit( self::$displaySize + 1 ) |
246 | ->caller( __METHOD__ ); |
247 | |
248 | if ( $this->userOptionsManager->getOption( |
249 | $this->mUser, 'echo-dont-email-read-notifications' |
250 | ) ) { |
251 | $queryBuilder |
252 | ->join( 'echo_notification', null, 'notification_event = event_id' ) |
253 | ->andWhere( [ 'notification_read_timestamp' => null ] ); |
254 | } |
255 | |
256 | // See setLastEvent() for more detail for this variable |
257 | if ( $this->lastEvent ) { |
258 | $queryBuilder->andWhere( $dbr->expr( 'eeb_event_id', '<=', (int)$this->lastEvent ) ); |
259 | } |
260 | |
261 | $res = $queryBuilder->fetchResultSet(); |
262 | |
263 | foreach ( $res as $row ) { |
264 | // records in the queue inserted before email bundling code |
265 | // have no hash, in this case, we just ignore them |
266 | if ( $row->eeb_event_hash ) { |
267 | $events[$row->eeb_id] = $row; |
268 | } |
269 | } |
270 | } |
271 | |
272 | return $events; |
273 | } |
274 | |
275 | /** |
276 | * Clear "processed" events in the queue, |
277 | * processed could be: email sent, invalid, users do not want to receive emails |
278 | */ |
279 | public function clearProcessedEvent() { |
280 | global $wgUpdateRowsPerQuery; |
281 | $eventMapper = new EventMapper(); |
282 | $dbFactory = DbFactory::newFromDefault(); |
283 | $dbw = $dbFactory->getEchoDb( DB_PRIMARY ); |
284 | $dbr = $dbFactory->getEchoDb( DB_REPLICA ); |
285 | $lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory(); |
286 | $ticket = $lbFactory->getEmptyTransactionTicket( __METHOD__ ); |
287 | $domainId = $dbw->getDomainID(); |
288 | |
289 | $iterator = new BatchRowIterator( $dbr, 'echo_email_batch', 'eeb_event_id', $wgUpdateRowsPerQuery ); |
290 | $iterator->addConditions( [ 'eeb_user_id' => $this->mUser->getId() ] ); |
291 | if ( $this->lastEvent ) { |
292 | // There is a processed cutoff point |
293 | $iterator->addConditions( [ 'eeb_event_id <= ' . (int)$this->lastEvent ] ); |
294 | } |
295 | $iterator->setCaller( __METHOD__ ); |
296 | |
297 | foreach ( $iterator as $batch ) { |
298 | $eventIds = []; |
299 | foreach ( $batch as $row ) { |
300 | $eventIds[] = $row->eeb_event_id; |
301 | } |
302 | $dbw->newDeleteQueryBuilder() |
303 | ->deleteFrom( 'echo_email_batch' ) |
304 | ->where( [ |
305 | 'eeb_user_id' => $this->mUser->getId(), |
306 | 'eeb_event_id' => $eventIds |
307 | ] ) |
308 | ->caller( __METHOD__ ) |
309 | ->execute(); |
310 | |
311 | // Find out which events are now orphaned, i.e. no longer referenced in echo_email_batch |
312 | // (besides the rows we just deleted) or in echo_notification, and delete them |
313 | $eventMapper->deleteOrphanedEvents( $eventIds, $this->mUser->getId(), 'echo_email_batch' ); |
314 | |
315 | $lbFactory->commitAndWaitForReplication( |
316 | __METHOD__, $ticket, [ 'domain' => $domainId ] ); |
317 | } |
318 | } |
319 | |
320 | /** |
321 | * Send the batch email |
322 | */ |
323 | public function sendEmail() { |
324 | global $wgPasswordSender, $wgNoReplyAddress; |
325 | |
326 | if ( $this->userOptionsManager->getOption( $this->mUser, 'echo-email-frequency' ) |
327 | == EmailFrequency::WEEKLY_DIGEST |
328 | ) { |
329 | $frequency = 'weekly'; |
330 | $emailDeliveryMode = 'weekly_digest'; |
331 | } else { |
332 | $frequency = 'daily'; |
333 | $emailDeliveryMode = 'daily_digest'; |
334 | } |
335 | |
336 | $textEmailDigestFormatter = new EchoPlainTextDigestEmailFormatter( $this->mUser, $this->language, $frequency ); |
337 | $content = $textEmailDigestFormatter->format( $this->events, 'email' ); |
338 | |
339 | if ( !$content ) { |
340 | // no event could be formatted |
341 | return; |
342 | } |
343 | |
344 | $format = NotifUser::newFromUser( $this->mUser )->getEmailFormat(); |
345 | if ( $format == EmailFormat::HTML ) { |
346 | $htmlEmailDigestFormatter = new EchoHtmlDigestEmailFormatter( $this->mUser, $this->language, $frequency ); |
347 | $htmlContent = $htmlEmailDigestFormatter->format( $this->events, 'email' ); |
348 | |
349 | $content = [ |
350 | 'body' => [ |
351 | 'text' => $content['body'], |
352 | 'html' => $htmlContent['body'], |
353 | ], |
354 | 'subject' => $htmlContent['subject'], |
355 | ]; |
356 | } |
357 | |
358 | $toAddress = MailAddress::newFromUser( $this->mUser ); |
359 | $fromAddress = new MailAddress( $wgPasswordSender, wfMessage( 'emailsender' )->inContentLanguage()->text() ); |
360 | $replyTo = new MailAddress( $wgNoReplyAddress ); |
361 | |
362 | // @Todo Push the email to job queue or just send it out directly? |
363 | UserMailer::send( $toAddress, $fromAddress, $content['subject'], $content['body'], [ 'replyTo' => $replyTo ] ); |
364 | } |
365 | |
366 | /** |
367 | * Insert notification event into email queue |
368 | * |
369 | * @param int $userId |
370 | * @param int $eventId |
371 | * @param int $priority |
372 | * @param string $hash |
373 | */ |
374 | public static function addToQueue( $userId, $eventId, $priority, $hash ) { |
375 | if ( !$userId || !$eventId ) { |
376 | return; |
377 | } |
378 | |
379 | $dbw = DbFactory::newFromDefault()->getEchoDb( DB_PRIMARY ); |
380 | |
381 | $row = [ |
382 | 'eeb_user_id' => $userId, |
383 | 'eeb_event_id' => $eventId, |
384 | 'eeb_event_priority' => $priority, |
385 | 'eeb_event_hash' => $hash |
386 | ]; |
387 | |
388 | $dbw->newInsertQueryBuilder() |
389 | ->insertInto( 'echo_email_batch' ) |
390 | ->ignore() |
391 | ->row( $row ) |
392 | ->caller( __METHOD__ ) |
393 | ->execute(); |
394 | } |
395 | |
396 | /** |
397 | * Get a list of users to be notified for the batch |
398 | * |
399 | * @param int $startUserId |
400 | * @param int $batchSize |
401 | * |
402 | * @return IResultWrapper |
403 | */ |
404 | public static function getUsersToNotify( $startUserId, $batchSize ) { |
405 | $dbr = DbFactory::newFromDefault()->getEchoDb( DB_REPLICA ); |
406 | return $dbr->newSelectQueryBuilder() |
407 | ->select( 'eeb_user_id' ) |
408 | ->from( 'echo_email_batch' ) |
409 | ->where( $dbr->expr( 'eeb_user_id', '>', (int)$startUserId ) ) |
410 | ->orderBy( 'eeb_user_id' ) |
411 | ->limit( $batchSize ) |
412 | ->caller( __METHOD__ ) |
413 | ->fetchResultSet(); |
414 | } |
415 | } |