498 lines
16 KiB
PHP
498 lines
16 KiB
PHP
<?php
|
|
/**
|
|
* @copyright Copyright (c) 2016, ownCloud, Inc.
|
|
*
|
|
* @author Joas Schilling <coding@schilljs.com>
|
|
* @author Lukas Reschke <lukas@statuscode.ch>
|
|
* @author Thomas Citharel <nextcloud@tcit.fr>
|
|
*
|
|
* @license AGPL-3.0
|
|
*
|
|
* This code is free software: you can redistribute it and/or modify
|
|
* it under the terms of the GNU Affero General Public License, version 3,
|
|
* as published by the Free Software Foundation.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU Affero General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU Affero General Public License, version 3,
|
|
* along with this program. If not, see <http://www.gnu.org/licenses/>
|
|
*
|
|
*/
|
|
|
|
namespace OCA\Activity;
|
|
|
|
use OCP\Activity\IEvent;
|
|
use OCP\Activity\IManager;
|
|
use OCP\DB\QueryBuilder\IQueryBuilder;
|
|
use OCP\Defaults;
|
|
use OCP\IConfig;
|
|
use OCP\IDateTimeFormatter;
|
|
use OCP\IDBConnection;
|
|
use OCP\IURLGenerator;
|
|
use OCP\IUser;
|
|
use OCP\IUserManager;
|
|
use OCP\L10N\IFactory;
|
|
use OCP\Mail\Headers\AutoSubmitted;
|
|
use OCP\Mail\IMailer;
|
|
use OCP\RichObjectStrings\InvalidObjectExeption;
|
|
use OCP\RichObjectStrings\IValidator;
|
|
use OCP\Util;
|
|
use Psr\Log\LoggerInterface;
|
|
|
|
/**
|
|
* Class MailQueueHandler
|
|
* Gets the users from the database and
|
|
*
|
|
* @package OCA\Activity
|
|
*/
|
|
class MailQueueHandler {
|
|
public const CLI_EMAIL_BATCH_SIZE = 500;
|
|
|
|
public const WEB_EMAIL_BATCH_SIZE = 25;
|
|
|
|
/** Number of entries we want to list in the email */
|
|
public const ENTRY_LIMIT = 200;
|
|
|
|
/** @var array */
|
|
protected $languages;
|
|
|
|
/** @var string */
|
|
protected $senderAddress;
|
|
|
|
/** @var string */
|
|
protected $senderName;
|
|
|
|
/** @var IDateTimeFormatter */
|
|
protected $dateFormatter;
|
|
|
|
public function __construct(IDateTimeFormatter $dateFormatter,
|
|
protected IDBConnection $connection,
|
|
protected IMailer $mailer,
|
|
protected IURLGenerator $urlGenerator,
|
|
protected IUserManager $userManager,
|
|
protected IFactory $lFactory,
|
|
protected IManager $activityManager,
|
|
protected IValidator $richObjectValidator,
|
|
protected IConfig $config,
|
|
protected LoggerInterface $logger) {
|
|
$this->dateFormatter = $dateFormatter;
|
|
}
|
|
|
|
/**
|
|
* Send an email to {$limit} users
|
|
*
|
|
* @param int $limit Number of users we want to send an email to
|
|
* @param int $sendTime The latest send time
|
|
* @param bool $forceSending Ignores latest send and just sends all emails
|
|
* @param null|int $restrictEmails null or one of UserSettings::EMAIL_SEND_*
|
|
* @return int Number of users we sent an email to
|
|
*/
|
|
public function sendEmails($limit, $sendTime, $forceSending = false, $restrictEmails = null) {
|
|
// Get all users which should receive an email
|
|
$affectedUsers = $this->getAffectedUsers($limit, $sendTime, $forceSending, $restrictEmails);
|
|
if (empty($affectedUsers)) {
|
|
// No users found to notify, mission abort
|
|
return 0;
|
|
}
|
|
|
|
$userLanguages = $this->config->getUserValueForUsers('core', 'lang', $affectedUsers);
|
|
$userTimezones = $this->config->getUserValueForUsers('core', 'timezone', $affectedUsers);
|
|
$userEnabled = $this->config->getUserValueForUsers('core', 'enabled', $affectedUsers);
|
|
|
|
// Send Email
|
|
$default_lang = $this->config->getSystemValue('default_language', 'en');
|
|
$defaultTimeZone = date_default_timezone_get();
|
|
|
|
$deleteItemsForUsers = [];
|
|
$this->activityManager->setRequirePNG(true);
|
|
foreach ($affectedUsers as $user) {
|
|
if (isset($userEnabled[$user]) && $userEnabled[$user] === 'false') {
|
|
$deleteItemsForUsers[] = $user;
|
|
continue;
|
|
}
|
|
|
|
$userObject = $this->userManager->get($user);
|
|
$email = $userObject ? $userObject->getEMailAddress() : '';
|
|
if (empty($email)) {
|
|
// The user did not setup an email address
|
|
// So we will not send an email :(
|
|
$this->logger->debug("Couldn't send notification email to user '{user}' (email address isn't set for that user)", ['user' => $user, 'app' => 'activity']);
|
|
$deleteItemsForUsers[] = $user;
|
|
continue;
|
|
}
|
|
|
|
$language = (!empty($userLanguages[$user])) ? $userLanguages[$user] : $default_lang;
|
|
$timezone = (!empty($userTimezones[$user])) ? $userTimezones[$user] : $defaultTimeZone;
|
|
try {
|
|
if ($this->sendEmailToUser($user, $email, $language, $timezone, $sendTime)) {
|
|
$deleteItemsForUsers[] = $user;
|
|
} else {
|
|
$this->logger->warning("Failed sending activity email to user '{user}'.", ['user' => $user, 'app' => 'activity']);
|
|
}
|
|
} catch (\Exception $e) {
|
|
$this->logger->error('Failed creating activity email for user "{user}"', [
|
|
'exception' => $e,
|
|
'user' => $user,
|
|
'app' => 'activity',
|
|
]);
|
|
// continue;
|
|
}
|
|
}
|
|
$this->activityManager->setRequirePNG(false);
|
|
|
|
// Delete all entries we dealt with
|
|
$this->deleteSentItems($deleteItemsForUsers, $sendTime);
|
|
|
|
return count($affectedUsers);
|
|
}
|
|
|
|
/**
|
|
* Get the users we want to send an email to
|
|
*
|
|
* @param int|null $limit
|
|
* @param int $latestSend
|
|
* @param bool $forceSending
|
|
* @param int|null $restrictEmails
|
|
* @return array
|
|
*/
|
|
protected function getAffectedUsers($limit, $latestSend, $forceSending, $restrictEmails) {
|
|
$query = $this->connection->getQueryBuilder();
|
|
$query->select('amq_affecteduser')
|
|
->selectAlias($query->createFunction('MIN(' . $query->getColumnName('amq_latest_send') . ')'), 'amq_trigger_time')
|
|
->from('activity_mq')
|
|
->groupBy('amq_affecteduser')
|
|
->orderBy('amq_trigger_time', 'ASC');
|
|
|
|
if ($limit > 0) {
|
|
$query->setMaxResults($limit);
|
|
}
|
|
|
|
if ($forceSending) {
|
|
$query->where($query->expr()->lt('amq_timestamp', $query->createNamedParameter($latestSend)));
|
|
} else {
|
|
$query->where($query->expr()->lt('amq_latest_send', $query->createNamedParameter($latestSend)));
|
|
}
|
|
|
|
if ($restrictEmails !== null) {
|
|
if ($restrictEmails === UserSettings::EMAIL_SEND_HOURLY) {
|
|
$query->where($query->expr()->eq('amq_timestamp', $query->func()->subtract('amq_latest_send', $query->expr()->literal(3600))));
|
|
} elseif ($restrictEmails === UserSettings::EMAIL_SEND_DAILY) {
|
|
$query->where($query->expr()->eq('amq_timestamp', $query->func()->subtract('amq_latest_send', $query->expr()->literal(3600 * 24))));
|
|
} elseif ($restrictEmails === UserSettings::EMAIL_SEND_WEEKLY) {
|
|
$query->where($query->expr()->eq('amq_timestamp', $query->func()->subtract('amq_latest_send', $query->expr()->literal(3600 * 24 * 7))));
|
|
} elseif ($restrictEmails === UserSettings::EMAIL_SEND_ASAP) {
|
|
$query->where($query->expr()->eq('amq_timestamp', 'amq_latest_send'));
|
|
}
|
|
}
|
|
|
|
$result = $query->execute();
|
|
|
|
$affectedUsers = [];
|
|
while ($row = $result->fetch()) {
|
|
$affectedUsers[] = $row['amq_affecteduser'];
|
|
}
|
|
$result->closeCursor();
|
|
|
|
return $affectedUsers;
|
|
}
|
|
|
|
/**
|
|
* Get all items for the user we want to send an email to
|
|
*
|
|
* @param string $affectedUser
|
|
* @param int $maxTime
|
|
* @param int $maxNumItems
|
|
* @return array [data of the first max. 200 entries, total number of entries]
|
|
*/
|
|
protected function getItemsForUser($affectedUser, $maxTime, $maxNumItems = self::ENTRY_LIMIT) {
|
|
$query = $this->connection->getQueryBuilder();
|
|
$query->select('*')
|
|
->from('activity_mq')
|
|
->where($query->expr()->lte('amq_timestamp', $query->createNamedParameter($maxTime)))
|
|
->andWhere($query->expr()->eq('amq_affecteduser', $query->createNamedParameter($affectedUser)))
|
|
->orderBy('amq_timestamp', 'ASC')
|
|
->setMaxResults($maxNumItems);
|
|
$result = $query->execute();
|
|
|
|
$activities = [];
|
|
while ($row = $result->fetch()) {
|
|
$activities[] = $row;
|
|
}
|
|
$result->closeCursor();
|
|
|
|
if (isset($activities[$maxNumItems - 1])) {
|
|
// Reached the limit, run a query to get the actual count.
|
|
$query = $this->connection->getQueryBuilder();
|
|
$query->selectAlias($query->func()->count('*'), 'actual_count')
|
|
->from('activity_mq')
|
|
->where($query->expr()->lte('amq_timestamp', $query->createNamedParameter($maxTime)))
|
|
->andWhere($query->expr()->eq('amq_affecteduser', $query->createNamedParameter($affectedUser)));
|
|
$result = $query->execute();
|
|
$row = $result->fetch();
|
|
$result->closeCursor();
|
|
|
|
return [$activities, $row['actual_count'] - $maxNumItems];
|
|
}
|
|
|
|
return [$activities, 0];
|
|
}
|
|
|
|
public function purgeItemsForUser(string $affectedUser): void {
|
|
$queryBuilder = $this->connection->getQueryBuilder();
|
|
|
|
$queryBuilder->delete('activity_mq')
|
|
->where($queryBuilder->expr()->eq('amq_affecteduser', $queryBuilder->createNamedParameter($affectedUser)));
|
|
$queryBuilder->executeStatement();
|
|
}
|
|
|
|
/**
|
|
* Get a language object for a specific language
|
|
*
|
|
* @param string $lang Language identifier
|
|
* @return \OCP\IL10N Language object of $lang
|
|
*/
|
|
protected function getLanguage($lang) {
|
|
if (!isset($this->languages[$lang])) {
|
|
$this->languages[$lang] = $this->lFactory->get('activity', $lang);
|
|
}
|
|
|
|
return $this->languages[$lang];
|
|
}
|
|
|
|
/**
|
|
* Get the sender data
|
|
* @param string $setting Either `email` or `name`
|
|
* @return string
|
|
*/
|
|
protected function getSenderData($setting) {
|
|
if (empty($this->senderAddress)) {
|
|
$this->senderAddress = Util::getDefaultEmailAddress('no-reply');
|
|
}
|
|
if (empty($this->senderName)) {
|
|
$defaults = new Defaults();
|
|
$this->senderName = $defaults->getName();
|
|
}
|
|
|
|
if ($setting === 'email') {
|
|
return $this->senderAddress;
|
|
}
|
|
return $this->senderName;
|
|
}
|
|
|
|
/**
|
|
* Send a notification to one user
|
|
*
|
|
* @param string $userName Username of the recipient
|
|
* @param string $email Email address of the recipient
|
|
* @param string $lang Selected language of the recipient
|
|
* @param string $timezone Selected timezone of the recipient
|
|
* @param int $maxTime
|
|
* @return bool True if the entries should be removed, false otherwise
|
|
* @throws \UnexpectedValueException
|
|
*/
|
|
protected function sendEmailToUser($userName, $email, $lang, $timezone, $maxTime) {
|
|
$user = $this->userManager->get($userName);
|
|
if (!$user instanceof IUser) {
|
|
return true;
|
|
}
|
|
|
|
if (!$this->mailer->validateMailAddress($email)) {
|
|
$this->logger->warning('Notification for user "{user}" not sent because the email address "{email}" is invalid.', ['user' => $userName, 'email' => $email]);
|
|
return true;
|
|
}
|
|
|
|
[$mailData, $skippedCount] = $this->getItemsForUser($userName, $maxTime);
|
|
|
|
$l = $this->getLanguage($lang);
|
|
$this->activityManager->setCurrentUserId($userName);
|
|
|
|
$activityEvents = [];
|
|
foreach ($mailData as $activity) {
|
|
$event = $this->activityManager->generateEvent();
|
|
try {
|
|
$event->setApp((string) $activity['amq_appid'])
|
|
->setType((string) $activity['amq_type'])
|
|
->setAffectedUser((string) $activity['amq_affecteduser'])
|
|
->setTimestamp((int) $activity['amq_timestamp'])
|
|
->setSubject((string) $activity['amq_subject'], (array) json_decode($activity['amq_subjectparams'], true))
|
|
->setObject((string) $activity['object_type'], (int) $activity['object_id']);
|
|
} catch (\InvalidArgumentException $e) {
|
|
continue;
|
|
}
|
|
|
|
$relativeDateTime = $this->dateFormatter->formatDateTimeRelativeDay(
|
|
(int) $activity['amq_timestamp'],
|
|
'long', 'short',
|
|
new \DateTimeZone($timezone), $l
|
|
);
|
|
|
|
try {
|
|
$event = $this->parseEvent($lang, $event);
|
|
} catch (\InvalidArgumentException $e) {
|
|
continue;
|
|
}
|
|
|
|
$activityEvents[] = [
|
|
'event' => $event,
|
|
'relativeDateTime' => $relativeDateTime
|
|
];
|
|
}
|
|
|
|
$template = $this->mailer->createEMailTemplate('activity.Notification', [
|
|
'displayname' => $user->getDisplayName(),
|
|
'url' => $this->urlGenerator->getAbsoluteURL('/'),
|
|
'activityEvents' => $activityEvents,
|
|
'skippedCount' => $skippedCount,
|
|
]);
|
|
$template->setSubject($l->t('Activity at %s', $this->getSenderData('name')));
|
|
$template->addHeader();
|
|
$template->addHeading($l->t('Hello %s', [$user->getDisplayName()]), $l->t('Hello %s,', [$user->getDisplayName()]));
|
|
|
|
$homeLink = '<a href="' . $this->urlGenerator->getAbsoluteURL('/') . '">' . htmlspecialchars($this->getSenderData('name')) . '</a>';
|
|
$template->addBodyText(
|
|
$l->t('There was some activity at %s', [$homeLink]),
|
|
$l->t('There was some activity at %s', [$this->urlGenerator->getAbsoluteURL('/')])
|
|
);
|
|
|
|
foreach ($activityEvents as $activity) {
|
|
/** @var IEvent $event */
|
|
$event = $activity['event'];
|
|
$relativeDateTime = $activity['relativeDateTime'];
|
|
|
|
$template->addBodyListItem($this->getHTMLSubject($event), $relativeDateTime, $event->getIcon(), $event->getParsedSubject());
|
|
}
|
|
|
|
if ($skippedCount) {
|
|
$template->addBodyListItem($l->n('and %n more ', 'and %n more ', $skippedCount));
|
|
}
|
|
|
|
$template->addFooter('', $lang);
|
|
|
|
$message = $this->mailer->createMessage();
|
|
$message->setTo([$email => $user->getDisplayName()]);
|
|
$message->useTemplate($template);
|
|
$message->setFrom([$this->getSenderData('email') => $this->getSenderData('name')]);
|
|
|
|
// We don't want auto generated responses to autogenerated activity notifications
|
|
$message->setAutoSubmitted(AutoSubmitted::VALUE_AUTO_GENERATED);
|
|
|
|
try {
|
|
$this->mailer->send($message);
|
|
} catch (\Exception $e) {
|
|
$this->logger->error('Failed sending activity email to user "{user}"', [
|
|
'exception' => $e,
|
|
'user' => $userName,
|
|
'app' => 'activity',
|
|
]);
|
|
return false;
|
|
}
|
|
|
|
$this->activityManager->setCurrentUserId(null);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* @param IEvent $event
|
|
* @return string
|
|
*/
|
|
protected function getHTMLSubject(IEvent $event): string {
|
|
if ($event->getRichSubject() === '') {
|
|
return htmlspecialchars($event->getParsedSubject());
|
|
}
|
|
|
|
$placeholders = $replacements = [];
|
|
foreach ($event->getRichSubjectParameters() as $placeholder => $parameter) {
|
|
$placeholders[] = '{' . $placeholder . '}';
|
|
|
|
if ($parameter['type'] === 'file') {
|
|
$replacement = (string) $parameter['path'];
|
|
} else {
|
|
$replacement = (string) $parameter['name'];
|
|
}
|
|
|
|
if (isset($parameter['link'])) {
|
|
$replacements[] = '<a href="' . $parameter['link'] . '">' . htmlspecialchars($replacement) . '</a>';
|
|
} else {
|
|
$replacements[] = '<strong>' . htmlspecialchars($replacement) . '</strong>';
|
|
}
|
|
}
|
|
|
|
return str_replace($placeholders, $replacements, $event->getRichSubject());
|
|
}
|
|
|
|
/**
|
|
* @param string $lang
|
|
* @param IEvent $event
|
|
* @return IEvent
|
|
* @throws \InvalidArgumentException when the event could not be parsed
|
|
*/
|
|
protected function parseEvent($lang, IEvent $event) {
|
|
$this->activityManager->setFormattingObject($event->getObjectType(), $event->getObjectId());
|
|
foreach ($this->activityManager->getProviders() as $provider) {
|
|
try {
|
|
$event = $provider->parse($lang, $event);
|
|
} catch (\InvalidArgumentException $e) {
|
|
}
|
|
}
|
|
$this->activityManager->setFormattingObject('', 0);
|
|
|
|
try {
|
|
$this->richObjectValidator->validate($event->getRichSubject(), $event->getRichSubjectParameters());
|
|
} catch (InvalidObjectExeption $e) {
|
|
$this->logger->error(
|
|
$e->getMessage(),
|
|
[
|
|
'app' => 'activity',
|
|
'exception' => $e
|
|
],
|
|
);
|
|
$event->setRichSubject('Rich subject or a parameter for "' . $event->getRichSubject() . '" is malformed', []);
|
|
$event->setParsedSubject('Rich subject or a parameter for "' . $event->getRichSubject() . '" is malformed');
|
|
}
|
|
|
|
if ($event->getRichMessage()) {
|
|
try {
|
|
$this->richObjectValidator->validate($event->getRichMessage(), $event->getRichMessageParameters());
|
|
} catch (InvalidObjectExeption $e) {
|
|
$this->logger->error(
|
|
$e->getMessage(),
|
|
[
|
|
'app' => 'activity',
|
|
'exception' => $e
|
|
],
|
|
);
|
|
$event->setRichMessage('Rich message or a parameter is malformed', []);
|
|
$event->setParsedMessage('Rich message or a parameter is malformed');
|
|
}
|
|
}
|
|
|
|
if (!$event->getParsedSubject()) {
|
|
$this->logger->debug('Activity "' . $event->getRichSubject() . '" was not parsed by any provider');
|
|
throw new \InvalidArgumentException('Activity "' . $event->getRichSubject() . '" was not parsed by any provider');
|
|
}
|
|
|
|
return $event;
|
|
}
|
|
|
|
/**
|
|
* Delete all entries we dealt with
|
|
*
|
|
* @param array $affectedUsers
|
|
* @param int $maxTime
|
|
*/
|
|
protected function deleteSentItems(array $affectedUsers, $maxTime) {
|
|
if (empty($affectedUsers)) {
|
|
return;
|
|
}
|
|
|
|
$query = $this->connection->getQueryBuilder();
|
|
$query->delete('activity_mq')
|
|
->where($query->expr()->lte('amq_timestamp', $query->createNamedParameter($maxTime, IQueryBuilder::PARAM_INT)))
|
|
->andWhere($query->expr()->in('amq_affecteduser', $query->createNamedParameter($affectedUsers, IQueryBuilder::PARAM_STR_ARRAY), IQueryBuilder::PARAM_STR));
|
|
$query->execute();
|
|
}
|
|
}
|