Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 171
0.00% covered (danger)
0.00%
0 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
EmailBatch
0.00% covered (danger)
0.00%
0 / 171
0.00% covered (danger)
0.00%
0 / 10
1190
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 newFromUserId
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
42
 process
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
42
 setLastEvent
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
6
 updateUserLastBatchTimestamp
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 getEvents
0.00% covered (danger)
0.00%
0 / 35
0.00% covered (danger)
0.00%
0 / 1
42
 clearProcessedEvent
0.00% covered (danger)
0.00%
0 / 28
0.00% covered (danger)
0.00%
0 / 1
20
 sendEmail
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
20
 addToQueue
0.00% covered (danger)
0.00%
0 / 15
0.00% covered (danger)
0.00%
0 / 1
12
 getUsersToNotify
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Extension\Notifications;
4
5use BatchRowIterator;
6use Language;
7use MailAddress;
8use MediaWiki\Extension\Notifications\Formatters\EchoHtmlDigestEmailFormatter;
9use MediaWiki\Extension\Notifications\Formatters\EchoPlainTextDigestEmailFormatter;
10use MediaWiki\Extension\Notifications\Mapper\EventMapper;
11use MediaWiki\Extension\Notifications\Model\Event;
12use MediaWiki\Languages\LanguageFactory;
13use MediaWiki\MediaWikiServices;
14use MediaWiki\User\Options\UserOptionsManager;
15use MediaWiki\User\User;
16use stdClass;
17use UserMailer;
18use Wikimedia\Rdbms\IResultWrapper;
19
20/**
21 * Handle user email batch ( daily/ weekly )
22 */
23class 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}