Completed
Push — master ( c2fce1...4aafd7 )
by Joas
03:29
created

MailQueueHandler::getHTMLSubject()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 14
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 4.125

Importance

Changes 0
Metric Value
dl 0
loc 14
ccs 4
cts 8
cp 0.5
rs 9.4285
c 0
b 0
f 0
cc 3
eloc 9
nc 3
nop 1
crap 4.125
1
<?php
2
/**
3
 * @copyright Copyright (c) 2016, ownCloud, Inc.
4
 *
5
 * @author Joas Schilling <[email protected]>
6
 * @author Lukas Reschke <[email protected]>
7
 *
8
 * @license AGPL-3.0
9
 *
10
 * This code is free software: you can redistribute it and/or modify
11
 * it under the terms of the GNU Affero General Public License, version 3,
12
 * as published by the Free Software Foundation.
13
 *
14
 * This program is distributed in the hope that it will be useful,
15
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
16
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17
 * GNU Affero General Public License for more details.
18
 *
19
 * You should have received a copy of the GNU Affero General Public License, version 3,
20
 * along with this program.  If not, see <http://www.gnu.org/licenses/>
21
 *
22
 */
23
24
namespace OCA\Activity;
25
26
use OCA\Activity\Extension\LegacyParser;
27
use OCP\Activity\IEvent;
28
use OCP\Activity\IManager;
29
use OCP\DB\QueryBuilder\IQueryBuilder;
30
use OCP\Defaults;
31
use OCP\IConfig;
32
use OCP\IDateTimeFormatter;
33
use OCP\IDBConnection;
34
use OCP\ILogger;
35
use OCP\IURLGenerator;
36
use OCP\IUser;
37
use OCP\IUserManager;
38
use OCP\L10N\IFactory;
39
use OCP\Mail\IMailer;
40
use OCP\Util;
41
42
/**
43
 * Class MailQueueHandler
44
 * Gets the users from the database and
45
 *
46
 * @package OCA\Activity
47
 */
48
class MailQueueHandler {
49
50
	const CLI_EMAIL_BATCH_SIZE = 500;
51
52
	const WEB_EMAIL_BATCH_SIZE = 25;
53
54
	/** Number of entries we want to list in the email */
55
	const ENTRY_LIMIT = 200;
56
57
	/** @var array */
58
	protected $languages;
59
60
	/** @var string */
61
	protected $senderAddress;
62
63
	/** @var string */
64
	protected $senderName;
65
66
	/** @var IDateTimeFormatter */
67
	protected $dateFormatter;
68
69
	/** @var DataHelper */
70
	protected $dataHelper;
71
72
	/** @var IDBConnection */
73
	protected $connection;
74
75
	/** @var IMailer */
76
	protected $mailer;
77
78
	/** @var IURLGenerator */
79
	protected $urlGenerator;
80
81
	/** @var IUserManager */
82
	protected $userManager;
83
84
	/** @var IFactory */
85
	protected $lFactory;
86
87
	/** @var IManager */
88
	protected $activityManager;
89
90
	/** @var LegacyParser */
91
	protected $legacyParser;
92
93
	/** @var IConfig */
94
	protected $config;
95
96
	/** @var ILogger */
97
	protected $logger;
98
99
	/**
100
	 * Constructor
101
	 *
102
	 * @param IDateTimeFormatter $dateFormatter
103
	 * @param IDBConnection $connection
104
	 * @param DataHelper $dataHelper
105
	 * @param IMailer $mailer
106
	 * @param IURLGenerator $urlGenerator
107
	 * @param IUserManager $userManager
108
	 * @param IFactory $lFactory
109
	 * @param IManager $activityManager
110
	 * @param LegacyParser $legacyParser
111
	 * @param IConfig $config
112
	 * @param ILogger $logger
113
	 */
114 10
	public function __construct(IDateTimeFormatter $dateFormatter,
115
								IDBConnection $connection,
116
								DataHelper $dataHelper,
117
								IMailer $mailer,
118
								IURLGenerator $urlGenerator,
119
								IUserManager $userManager,
120
								IFactory $lFactory,
121
								IManager $activityManager,
122
								LegacyParser $legacyParser,
123
								IConfig $config,
124
								ILogger $logger) {
125 10
		$this->dateFormatter = $dateFormatter;
126 10
		$this->connection = $connection;
127 10
		$this->dataHelper = $dataHelper;
128 10
		$this->mailer = $mailer;
129 10
		$this->urlGenerator = $urlGenerator;
130 10
		$this->userManager = $userManager;
131 10
		$this->lFactory = $lFactory;
132 10
		$this->activityManager = $activityManager;
133 10
		$this->legacyParser = $legacyParser;
134 10
		$this->config = $config;
135 10
		$this->logger = $logger;
136 10
	}
137
138
	/**
139
	 * Send an email to {$limit} users
140
	 *
141
	 * @param int $limit Number of users we want to send an email to
142
	 * @param int $sendTime The latest send time
143
	 * @param bool $forceSending Ignores latest send and just sends all emails
144
	 * @param null|int $restrictEmails null or one of UserSettings::EMAIL_SEND_*
145
	 * @return int Number of users we sent an email to
146
	 */
147
	public function sendEmails($limit, $sendTime, $forceSending = false, $restrictEmails = null) {
148
		// Get all users which should receive an email
149
		$affectedUsers = $this->getAffectedUsers($limit, $sendTime, $forceSending, $restrictEmails);
150
		if (empty($affectedUsers)) {
151
			// No users found to notify, mission abort
152
			return 0;
153
		}
154
155
		$userLanguages = $this->config->getUserValueForUsers('core', 'lang', $affectedUsers);
156
		$userTimezones = $this->config->getUserValueForUsers('core', 'timezone', $affectedUsers);
157
		$userEmails = $this->config->getUserValueForUsers('settings', 'email', $affectedUsers);
158
		$userEnabled = $this->config->getUserValueForUsers('core', 'enabled', $affectedUsers);
159
160
		// Send Email
161
		$default_lang = $this->config->getSystemValue('default_language', 'en');
162
		$defaultTimeZone = date_default_timezone_get();
163
164
		$deleteItemsForUsers = [];
165
		$this->activityManager->setRequirePNG(true);
166
		foreach ($affectedUsers as $user) {
167
			if (isset($userEnabled[$user]) && $userEnabled[$user] === 'false') {
168
				$deleteItemsForUsers[] = $user;
169
				continue;
170
			}
171
172
			if (empty($userEmails[$user])) {
173
				// The user did not setup an email address
174
				// So we will not send an email :(
175
				$this->logger->debug("Couldn't send notification email to user '{user}' (email address isn't set for that user)", ['user' => $user, 'app' => 'activity']);
176
				$deleteItemsForUsers[] = $user;
177
				continue;
178
			}
179
180
			$language = (!empty($userLanguages[$user])) ? $userLanguages[$user] : $default_lang;
181
			$timezone = (!empty($userTimezones[$user])) ? $userTimezones[$user] : $defaultTimeZone;
182
			try {
183
				if ($this->sendEmailToUser($user, $userEmails[$user], $language, $timezone, $sendTime)) {
184
					$deleteItemsForUsers[] = $user;
185
				} else {
186
					$this->logger->debug("Failed sending activity email to user '{user}'.", ['user' => $user, 'app' => 'activity']);
187
				}
188
			} catch (\Exception $e) {
189
				$this->logger->logException($e, [
190
					'message' => 'Failed sending activity email to user "{user}"',
191
					'user' => $user,
192
					'app' => 'activity',
193
				]);
194
				// continue;
195
			}
196
		}
197
		$this->activityManager->setRequirePNG(false);
198
199
		// Delete all entries we dealt with
200
		$this->deleteSentItems($deleteItemsForUsers, $sendTime);
201
202
		return count($affectedUsers);
203
	}
204
205
	/**
206
	 * Get the users we want to send an email to
207
	 *
208
	 * @param int|null $limit
209
	 * @param int $latestSend
210
	 * @param bool $forceSending
211
	 * @param int|null $restrictEmails
212
	 * @return array
213
	 */
214 6
	protected function getAffectedUsers($limit, $latestSend, $forceSending, $restrictEmails) {
215 6
		$query = $this->connection->getQueryBuilder();
216 6
		$query->select('amq_affecteduser')
217 6
			->selectAlias($query->createFunction('MIN(' . $query->getColumnName('amq_latest_send') . ')'), 'amq_trigger_time')
218 6
			->from('activity_mq')
219 6
			->groupBy('amq_affecteduser')
220 6
			->orderBy('amq_trigger_time', 'ASC');
221
222 6
		if ($limit > 0) {
223 5
			$query->setMaxResults($limit);
224
		}
225
226 6
		if ($forceSending) {
227
			$query->where($query->expr()->lt('amq_timestamp', $query->createNamedParameter($latestSend)));
228
		} else {
229 6
			$query->where($query->expr()->lt('amq_latest_send', $query->createNamedParameter($latestSend)));
230
		}
231
232 6
		if ($restrictEmails !== null) {
233
			if ($restrictEmails === UserSettings::EMAIL_SEND_HOURLY) {
234
				$query->where($query->expr()->lte('amq_timestamp', $query->createFunction($query->getColumnName('amq_latest_send') . ' + ' . 3600)));
235
			} else if ($restrictEmails === UserSettings::EMAIL_SEND_DAILY) {
236
				$query->where($query->expr()->eq('amq_timestamp', $query->createFunction($query->getColumnName('amq_latest_send') . ' + ' . 3600 * 24)));
237
			} else if ($restrictEmails === UserSettings::EMAIL_SEND_WEEKLY) {
238
				$query->where($query->expr()->eq('amq_timestamp', $query->createFunction($query->getColumnName('amq_latest_send') . ' + ' . 3600 * 24 * 7)));
239
			} else if ($restrictEmails === UserSettings::EMAIL_SEND_ASAP) {
240
				$query->where($query->expr()->eq('amq_timestamp', 'amq_latest_send'));
241
			}
242
		}
243
244 6
		$result = $query->execute();
245
246 6
		$affectedUsers = array();
247 6
		while ($row = $result->fetch()) {
248 6
			$affectedUsers[] = $row['amq_affecteduser'];
249
		}
250 6
		$result->closeCursor();
251
252 6
		return $affectedUsers;
253
	}
254
255
	/**
256
	 * Get all items for the user we want to send an email to
257
	 *
258
	 * @param string $affectedUser
259
	 * @param int $maxTime
260
	 * @param int $maxNumItems
261
	 * @return array [data of the first max. 200 entries, total number of entries]
262
	 */
263 7
	protected function getItemsForUser($affectedUser, $maxTime, $maxNumItems = self::ENTRY_LIMIT) {
264 7
		$query = $this->connection->prepare(
265
			'SELECT * '
266
			. ' FROM `*PREFIX*activity_mq` '
267
			. ' WHERE `amq_timestamp` <= ? '
268
			. ' AND `amq_affecteduser` = ? '
269 7
			. ' ORDER BY `amq_timestamp` ASC',
270 7
			$maxNumItems
271
		);
272 7
		$query->execute([(int) $maxTime, $affectedUser]);
273
274 7
		$activities = array();
275 7
		while ($row = $query->fetch()) {
276 7
			$activities[] = $row;
277
		}
278
279 7
		if (isset($activities[$maxNumItems - 1])) {
280
			// Reached the limit, run a query to get the actual count.
281 1
			$query = $this->connection->prepare(
282
				'SELECT COUNT(*) AS `actual_count`'
283
				. ' FROM `*PREFIX*activity_mq` '
284
				. ' WHERE `amq_timestamp` <= ? '
285 1
				. ' AND `amq_affecteduser` = ?'
286
			);
287 1
			$query->execute([(int) $maxTime, $affectedUser]);
288
289 1
			$row = $query->fetch();
290 1
			return [$activities, $row['actual_count'] - $maxNumItems];
291
		} else {
292 7
			return [$activities, 0];
293
		}
294
	}
295
296
	/**
297
	 * Get a language object for a specific language
298
	 *
299
	 * @param string $lang Language identifier
300
	 * @return \OCP\IL10N Language object of $lang
301
	 */
302 1
	protected function getLanguage($lang) {
303 1
		if (!isset($this->languages[$lang])) {
304 1
			$this->languages[$lang] = $this->lFactory->get('activity', $lang);
305
		}
306
307 1
		return $this->languages[$lang];
308
	}
309
310
	/**
311
	 * Get the sender data
312
	 * @param string $setting Either `email` or `name`
313
	 * @return string
314
	 */
315 1
	protected function getSenderData($setting) {
316 1
		if (empty($this->senderAddress)) {
317 1
			$this->senderAddress = Util::getDefaultEmailAddress('no-reply');
318
		}
319 1
		if (empty($this->senderName)) {
320 1
			$defaults = new Defaults();
321 1
			$this->senderName = $defaults->getName();
322
		}
323
324 1
		if ($setting === 'email') {
325 1
			return $this->senderAddress;
326
		}
327 1
		return $this->senderName;
328
	}
329
330
	/**
331
	 * Send a notification to one user
332
	 *
333
	 * @param string $userName Username of the recipient
334
	 * @param string $email Email address of the recipient
335
	 * @param string $lang Selected language of the recipient
336
	 * @param string $timezone Selected timezone of the recipient
337
	 * @param int $maxTime
338
	 * @return bool True if the entries should be removed, false otherwise
339
	 * @throws \UnexpectedValueException
340
	 */
341 1
	protected function sendEmailToUser($userName, $email, $lang, $timezone, $maxTime) {
342 1
		$user = $this->userManager->get($userName);
343 1
		if (!$user instanceof IUser) {
0 ignored issues
show
Bug introduced by
The class OCP\IUser does not exist. Did you forget a USE statement, or did you not list all dependencies?

This error could be the result of:

1. Missing dependencies

PHP Analyzer uses your composer.json file (if available) to determine the dependencies of your project and to determine all the available classes and functions. It expects the composer.json to be in the root folder of your repository.

Are you sure this class is defined by one of your dependencies, or did you maybe not list a dependency in either the require or require-dev section?

2. Missing use statement

PHP does not complain about undefined classes in ìnstanceof checks. For example, the following PHP code will work perfectly fine:

if ($x instanceof DoesNotExist) {
    // Do something.
}

If you have not tested against this specific condition, such errors might go unnoticed.

Loading history...
344 1
			return true;
345
		}
346
347 1
		list($mailData, $skippedCount) = $this->getItemsForUser($userName, $maxTime);
348
349 1
		$l = $this->getLanguage($lang);
350 1
		$this->dataHelper->setUser($userName);
351 1
		$this->dataHelper->setL10n($l);
352 1
		$this->activityManager->setCurrentUserId($userName);
353
354 1
		$activityEvents = [];
355 1
		foreach ($mailData as $activity) {
356 1
			$event = $this->activityManager->generateEvent();
357
			try {
358 1
				$event->setApp($activity['amq_appid'])
359 1
					->setType($activity['amq_type'])
360 1
					->setTimestamp((int) $activity['amq_timestamp'])
361 1
					->setSubject($activity['amq_subject'], json_decode($activity['amq_subjectparams'], true));
362
			} catch (\InvalidArgumentException $e) {
363
				continue;
364
			}
365
366 1
			$relativeDateTime = $this->dateFormatter->formatDateTimeRelativeDay(
367 1
				$activity['amq_timestamp'],
368 1
				'long', 'short',
369 1
				new \DateTimeZone($timezone), $l
370
			);
371
372
			try {
373 1
				$event = $this->parseEvent($lang, $event);
374
			} catch (\InvalidArgumentException $e) {
375
				continue;
376
			}
377
378 1
			$activityEvents[] = [
379 1
				'event' => $event,
380 1
				'relativeDateTime' => $relativeDateTime
381
			];
382
		}
383
384 1
		$template = $this->mailer->createEMailTemplate('activity.Notification', [
385 1
			'displayname' => $user->getDisplayName(),
386 1
			'url' => $this->urlGenerator->getAbsoluteURL('/'),
387 1
			'activityEvents' => $activityEvents,
388 1
			'skippedCount' => $skippedCount,
389
		]);
390 1
		$template->setSubject($l->t('Activity notification for %s', $this->getSenderData('name')));
391 1
		$template->addHeader();
392 1
		$template->addHeading($l->t('Hello %s',[$user->getDisplayName()]), $l->t('Hello %s,',[$user->getDisplayName()]));
393
394 1
		$homeLink = '<a href="' . $this->urlGenerator->getAbsoluteURL('/') . '">' . htmlspecialchars($this->getSenderData('name')) . '</a>';
395 1
		$template->addBodyText(
396 1
			$l->t('There was some activity at %s', [$homeLink]),
397 1
			$l->t('There was some activity at %s', [$this->urlGenerator->getAbsoluteURL('/')])
398
		);
399
400 1
		foreach ($activityEvents as $activity) {
401
			/** @var IEvent $event */
402 1
			$event = $activity['event'];
403 1
			$relativeDateTime = $activity['relativeDateTime'];
404
405 1
			$template->addBodyListItem($this->getHTMLSubject($event), $relativeDateTime, $event->getIcon(), $event->getParsedSubject());
406
		}
407
408 1
		if ($skippedCount) {
409
			$template->addBodyListItem($l->n('and %n more ', 'and %n more ', $skippedCount));
410
		}
411
412 1
		$template->addFooter();
413
414 1
		$message = $this->mailer->createMessage();
415 1
		$message->setTo([$email => $user->getDisplayName()]);
416 1
		$message->useTemplate($template);
417 1
		$message->setFrom([$this->getSenderData('email') => $this->getSenderData('name')]);
418
419
		try {
420 1
			$this->mailer->send($message);
421
		} catch (\Exception $e) {
422
			return false;
423
		}
424
425 1
		$this->activityManager->setCurrentUserId(null);
426 1
		return true;
427
	}
428
429
	/**
430
	 * @param IEvent $event
431
	 * @return string
432
	 */
433 1
	protected function getHTMLSubject(IEvent $event): string {
434 1
		$placeholders = $replacements = [];
435 1
		foreach ($event->getRichSubjectParameters() as $placeholder => $parameter) {
436
			$placeholders[] = '{' . $placeholder . '}';
437
438
			if (isset($parameter['link'])) {
439
				$replacements[] = '<a href="' . $parameter['link'] . '">' . htmlspecialchars($parameter['name']) . '</a>';
440
			} else {
441
				$replacements[] = '<strong>' . htmlspecialchars($parameter['name']) . '</strong>';
442
			}
443
		}
444
445 1
		return str_replace($placeholders, $replacements, $event->getRichSubject());
446
	}
447
448
	/**
449
	 * @param string $lang
450
	 * @param IEvent $event
451
	 * @return IEvent
452
	 * @throws \InvalidArgumentException when the event could not be parsed
453
	 */
454 1
	protected function parseEvent($lang, IEvent $event) {
455 1
		foreach ($this->activityManager->getProviders() as $provider) {
456
			try {
457
				$this->activityManager->setFormattingObject($event->getObjectType(), $event->getObjectId());
458
				$event = $provider->parse($lang, $event);
459
				$this->activityManager->setFormattingObject('', 0);
460
			} catch (\InvalidArgumentException $e) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
461
			}
462
		}
463
464 1
		if (!$event->getParsedSubject()) {
465 1
			$this->activityManager->setFormattingObject($event->getObjectType(), $event->getObjectId());
466 1
			$event = $this->legacyParser->parse($lang, $event);
467 1
			$this->activityManager->setFormattingObject('', 0);
468
		}
469
470 1
		return $event;
471
	}
472
473
	/**
474
	 * Delete all entries we dealt with
475
	 *
476
	 * @param array $affectedUsers
477
	 * @param int $maxTime
478
	 */
479 5
	protected function deleteSentItems(array $affectedUsers, $maxTime) {
480 5
		if (empty($affectedUsers)) {
481
			return;
482
		}
483
484 5
		$query = $this->connection->getQueryBuilder();
485 5
		$query->delete('activity_mq')
486 5
			->where($query->expr()->lte('amq_timestamp', $query->createNamedParameter($maxTime, IQueryBuilder::PARAM_INT)))
487 5
			->andWhere($query->expr()->in('amq_affecteduser', $query->createNamedParameter($affectedUsers, IQueryBuilder::PARAM_STR_ARRAY), IQueryBuilder::PARAM_STR));
488 5
		$query->execute();
489 5
	}
490
}
491