* @author Lukas Reschke * @author Thomas Citharel * * @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 * */ 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\ILogger; use OCP\IURLGenerator; use OCP\IUser; use OCP\IUserManager; use OCP\L10N\IFactory; use OCP\Mail\IMailer; use OCP\RichObjectStrings\InvalidObjectExeption; use OCP\RichObjectStrings\IValidator; use OCP\Util; /** * 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; /** @var IDBConnection */ protected $connection; /** @var IMailer */ protected $mailer; /** @var IURLGenerator */ protected $urlGenerator; /** @var IUserManager */ protected $userManager; /** @var IFactory */ protected $lFactory; /** @var IManager */ protected $activityManager; /** @var IValidator */ protected $richObjectValidator; /** @var IConfig */ protected $config; /** @var ILogger */ protected $logger; public function __construct(IDateTimeFormatter $dateFormatter, IDBConnection $connection, IMailer $mailer, IURLGenerator $urlGenerator, IUserManager $userManager, IFactory $lFactory, IManager $activityManager, IValidator $richObjectValidator, IConfig $config, ILogger $logger) { $this->dateFormatter = $dateFormatter; $this->connection = $connection; $this->mailer = $mailer; $this->urlGenerator = $urlGenerator; $this->userManager = $userManager; $this->lFactory = $lFactory; $this->activityManager = $activityManager; $this->richObjectValidator = $richObjectValidator; $this->config = $config; $this->logger = $logger; } /** * 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->logException($e, [ 'message' => 'Failed creating activity email for user "{user}"', '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 = '' . htmlspecialchars($this->getSenderData('name')) . ''; $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')]); try { $this->mailer->send($message); } catch (\Exception $e) { $this->logger->logException($e, [ 'message' => 'Failed sending activity email to user "{user}"', '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[] = '' . htmlspecialchars($replacement) . ''; } else { $replacements[] = '' . htmlspecialchars($replacement) . ''; } } 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->logException($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->logException($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(); } }