summaryrefslogtreecommitdiff
blob: 8440a712a3ebfb35515e30945cd25df50697684f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
<?php

use MediaWiki\Extension\Notifications\Formatters\EchoHtmlDigestEmailFormatter;
use MediaWiki\Extension\Notifications\Formatters\EchoPlainTextDigestEmailFormatter;
use MediaWiki\MediaWikiServices;
use MediaWiki\User\UserOptionsManager;
use Wikimedia\Rdbms\IResultWrapper;

/**
 * Handle user email batch ( daily/ weekly )
 */
class MWEchoEmailBatch {

	/**
	 * @var User the user to be notified
	 */
	protected $mUser;

	/**
	 * @var Language
	 */
	protected $language;

	/**
	 * @var UserOptionsManager
	 */
	protected $userOptionsManager;

	/**
	 * @var EchoEvent[] events included in this email
	 */
	protected $events = [];

	/**
	 * @var EchoEvent the last notification event of this batch
	 */
	protected $lastEvent;

	/**
	 * @var int the event count, this count is supported up to self::$displaySize + 1
	 */
	protected $count = 0;

	/**
	 * @var int number of bundle events to include in an email,
	 * we cannot include all events in a batch email
	 */
	protected static $displaySize = 20;

	/**
	 * @param User $user
	 * @param UserOptionsManager $userOptionsManager
	 */
	public function __construct( User $user, UserOptionsManager $userOptionsManager ) {
		$this->mUser = $user;
		$this->language = Language::factory(
			$userOptionsManager->getOption( $user, 'language' )
		);
		$this->userOptionsManager = $userOptionsManager;
	}

	/**
	 * Factory method to determine whether to create a batch instance for this
	 * user based on the user setting, this assumes the following value for
	 * member setting for echo-email-frequency
	 * -1 - no email
	 *  0 - instant
	 *  1 - once everyday
	 *  7 - once every 7 days
	 * @param int $userId
	 * @param bool $enforceFrequency Whether or not email sending frequency should
	 *  be enforced.
	 *
	 *  When true, today's notifications won't be returned if they are
	 *  configured to go out tonight or at the end of the week.
	 *
	 *  When false, all pending notifications will be returned.
	 * @return MWEchoEmailBatch|false
	 */
	public static function newFromUserId( $userId, $enforceFrequency = true ) {
		$user = User::newFromId( (int)$userId );
		$userOptionsManager = MediaWikiServices::getInstance()->getUserOptionsManager();

		$userEmailSetting = (int)$userOptionsManager->getOption( $user, 'echo-email-frequency' );

		// clear all existing events if user decides not to receive emails
		if ( $userEmailSetting == -1 ) {
			$emailBatch = new self( $user, $userOptionsManager );
			$emailBatch->clearProcessedEvent();

			return false;
		}

		// @Todo - There may be some items idling in the queue, eg, a bundle job is lost
		// and there is not never another message with the same hash or a user switches from
		// digest to instant.  We should check the first item in the queue, if it doesn't
		// have either web or email bundling or created long ago, then clear it, this will
		// prevent idling item queuing up.

		// user has instant email delivery
		if ( $userEmailSetting == 0 ) {
			return false;
		}

		$userLastBatch = $userOptionsManager->getOption( $user, 'echo-email-last-batch' );

		// send email batch, if
		// 1. it has been long enough since last email batch based on frequency
		// 2. there is no last batch timestamp recorded for the user
		// 3. user has switched from batch to instant email, send events left in the queue
		if ( $userLastBatch ) {
			// use 20 as hours per day to get estimate
			$nextBatch = (int)wfTimestamp( TS_UNIX, $userLastBatch ) + $userEmailSetting * 20 * 60 * 60;
			if ( $enforceFrequency && wfTimestamp( TS_MW, $nextBatch ) > wfTimestampNow() ) {
				return false;
			}
		}

		return new self( $user, $userOptionsManager );
	}

	/**
	 * Wrapper function that calls other functions required to process email batch
	 */
	public function process() {
		// if there is no event for this user, exist the process
		if ( !$this->setLastEvent() ) {
			return;
		}

		// get valid events
		$events = $this->getEvents();

		if ( $events ) {
			foreach ( $events as $row ) {
				$this->count++;
				if ( $this->count > self::$displaySize ) {
					break;
				}
				$event = EchoEvent::newFromRow( $row );
				if ( !$event ) {
					continue;
				}
				$event->setBundleHash( $row->eeb_event_hash );
				$this->events[] = $event;
			}

			$bundler = new Bundler();
			$this->events = $bundler->bundle( $this->events );

			$this->sendEmail();
		}

		$this->clearProcessedEvent();
		$this->updateUserLastBatchTimestamp();
	}

	/**
	 * Set the last event of this batch, this is a cutoff point for clearing
	 * processed/invalid events
	 *
	 * @return bool true if event exists false otherwise
	 */
	protected function setLastEvent() {
		$dbr = MWEchoDbFactory::newFromDefault()->getEchoDb( DB_REPLICA );
		$res = $dbr->selectField(
			[ 'echo_email_batch' ],
			'MAX( eeb_event_id )',
			[ 'eeb_user_id' => $this->mUser->getId() ],
			__METHOD__
		);

		if ( $res ) {
			$this->lastEvent = $res;

			return true;
		}

		return false;
	}

	/**
	 * Update the user's last batch timestamp after a successful batch
	 */
	protected function updateUserLastBatchTimestamp() {
		$this->userOptionsManager->setOption(
			$this->mUser,
			'echo-email-last-batch',
			wfTimestampNow()
		);
		$this->mUser->saveSettings();
		$this->mUser->invalidateCache();
	}

	/**
	 * Get the events queued for the current user
	 * @return stdClass[]
	 */
	protected function getEvents() {
		global $wgEchoNotifications;

		$events = [];

		$validEvents = array_keys( $wgEchoNotifications );

		// Per the tech discussion in the design meeting (03/22/2013), since this is
		// processed by a cron job, it's okay to use GROUP BY over more complex
		// composite index, favor insert performance, storage space over read
		// performance in this case
		if ( $validEvents ) {
			$dbr = MWEchoDbFactory::newFromDefault()->getEchoDb( DB_REPLICA );

			$conds = [
				'eeb_user_id' => $this->mUser->getId(),
				'event_id = eeb_event_id',
				'event_type' => $validEvents
			];

			$tables = [ 'echo_email_batch', 'echo_event' ];
			if ( $this->userOptionsManager->getOption(
				$this->mUser, 'echo-dont-email-read-notifications'
			) ) {
				$conds = array_merge(
					$conds,
					[
						'notification_event = event_id',
						'notification_read_timestamp IS NULL',
					]
				);
				$tables[] = 'echo_notification';
			}

			// See setLastEvent() for more detail for this variable
			if ( $this->lastEvent ) {
				$conds[] = 'eeb_event_id <= ' . (int)$this->lastEvent;
			}
			$fields = array_merge( EchoEvent::selectFields(), [
				'eeb_id',
				'eeb_user_id',
				'eeb_event_priority',
				'eeb_event_id',
				'eeb_event_hash',
			] );

			$res = $dbr->select(
				$tables,
				$fields,
				$conds,
				__METHOD__,
				[
					'ORDER BY' => 'eeb_event_priority',
					'LIMIT' => self::$displaySize + 1,
				]
			);

			foreach ( $res as $row ) {
				// records in the queue inserted before email bundling code
				// have no hash, in this case, we just ignore them
				if ( $row->eeb_event_hash ) {
					$events[$row->eeb_id] = $row;
				}
			}
		}

		return $events;
	}

	/**
	 * Clear "processed" events in the queue,
	 * processed could be: email sent, invalid, users do not want to receive emails
	 */
	public function clearProcessedEvent() {
		global $wgUpdateRowsPerQuery;
		$eventMapper = new EchoEventMapper();
		$dbFactory = MWEchoDbFactory::newFromDefault();
		$dbw = $dbFactory->getEchoDb( DB_PRIMARY );
		$dbr = $dbFactory->getEchoDb( DB_REPLICA );
		$lbFactory = MediaWikiServices::getInstance()->getDBLoadBalancerFactory();
		$ticket = $lbFactory->getEmptyTransactionTicket( __METHOD__ );
		$domainId = $dbw->getDomainID();

		$iterator = new BatchRowIterator( $dbr, 'echo_email_batch', 'eeb_event_id', $wgUpdateRowsPerQuery );
		$iterator->addConditions( [ 'eeb_user_id' => $this->mUser->getId() ] );
		if ( $this->lastEvent ) {
			// There is a processed cutoff point
			$iterator->addConditions( [ 'eeb_event_id <= ' . (int)$this->lastEvent ] );
		}
		$iterator->setCaller( __METHOD__ );

		foreach ( $iterator as $batch ) {
			$eventIds = [];
			foreach ( $batch as $row ) {
				$eventIds[] = $row->eeb_event_id;
			}
			$dbw->delete( 'echo_email_batch', [
				'eeb_user_id' => $this->mUser->getId(),
				'eeb_event_id' => $eventIds
			], __METHOD__ );

			// Find out which events are now orphaned, i.e. no longer referenced in echo_email_batch
			// (besides the rows we just deleted) or in echo_notification, and delete them
			$eventMapper->deleteOrphanedEvents( $eventIds, $this->mUser->getId(), 'echo_email_batch' );

			$lbFactory->commitAndWaitForReplication(
				__METHOD__, $ticket, [ 'domain' => $domainId ] );
		}
	}

	/**
	 * Send the batch email
	 */
	public function sendEmail() {
		global $wgPasswordSender, $wgNoReplyAddress;

		if ( $this->userOptionsManager->getOption( $this->mUser, 'echo-email-frequency' )
			== EchoEmailFrequency::WEEKLY_DIGEST
		) {
			$frequency = 'weekly';
			$emailDeliveryMode = 'weekly_digest';
		} else {
			$frequency = 'daily';
			$emailDeliveryMode = 'daily_digest';
		}

		$textEmailDigestFormatter = new EchoPlainTextDigestEmailFormatter( $this->mUser, $this->language, $frequency );
		$content = $textEmailDigestFormatter->format( $this->events, 'email' );

		if ( !$content ) {
			// no event could be formatted
			return;
		}

		$format = MWEchoNotifUser::newFromUser( $this->mUser )->getEmailFormat();
		if ( $format == EchoEmailFormat::HTML ) {
			$htmlEmailDigestFormatter = new EchoHtmlDigestEmailFormatter( $this->mUser, $this->language, $frequency );
			$htmlContent = $htmlEmailDigestFormatter->format( $this->events, 'email' );

			$content = [
				'body' => [
					'text' => $content['body'],
					'html' => $htmlContent['body'],
				],
				'subject' => $htmlContent['subject'],
			];
		}

		$toAddress = MailAddress::newFromUser( $this->mUser );
		$fromAddress = new MailAddress( $wgPasswordSender, wfMessage( 'emailsender' )->inContentLanguage()->text() );
		$replyTo = new MailAddress( $wgNoReplyAddress );

		// @Todo Push the email to job queue or just send it out directly?
		UserMailer::send( $toAddress, $fromAddress, $content['subject'], $content['body'], [ 'replyTo' => $replyTo ] );
		MWEchoEventLogging::logSchemaEchoMail( $this->mUser, $emailDeliveryMode );
	}

	/**
	 * Insert notification event into email queue
	 *
	 * @param int $userId
	 * @param int $eventId
	 * @param int $priority
	 * @param string $hash
	 */
	public static function addToQueue( $userId, $eventId, $priority, $hash ) {
		if ( !$userId || !$eventId ) {
			return;
		}

		$dbw = MWEchoDbFactory::newFromDefault()->getEchoDb( DB_PRIMARY );

		$row = [
			'eeb_user_id' => $userId,
			'eeb_event_id' => $eventId,
			'eeb_event_priority' => $priority,
			'eeb_event_hash' => $hash
		];

		$dbw->insert(
			'echo_email_batch',
			$row,
			__METHOD__,
			[ 'IGNORE' ]
		);
	}

	/**
	 * Get a list of users to be notified for the batch
	 *
	 * @param int $startUserId
	 * @param int $batchSize
	 *
	 * @return IResultWrapper|bool
	 */
	public static function getUsersToNotify( $startUserId, $batchSize ) {
		$dbr = MWEchoDbFactory::newFromDefault()->getEchoDb( DB_REPLICA );
		$res = $dbr->select(
			[ 'echo_email_batch' ],
			[ 'eeb_user_id' ],
			[ 'eeb_user_id > ' . (int)$startUserId ],
			__METHOD__,
			[ 'ORDER BY' => 'eeb_user_id', 'LIMIT' => $batchSize ]
		);

		return $res;
	}
}