$entries * targets are the titles resolved by DeckIntegrationService::describeTargets() */ public function sendCardsMovedNotification(IUser $recipient, array $entries): void { $email = $recipient->getEMailAddress(); if ($email === null || $email === '') { $this->logger->info('Skipping moved-cards mail: user ' . $recipient->getUID() . ' has no email address', [ 'app' => self::APP_ID, ]); return; } $count = count($entries); $l10n = $this->l10nFor($recipient); try { $template = $this->mailer->createEMailTemplate(self::APP_ID . '.CardsMoved', [ 'count' => $count, 'cardTitles' => array_map(static fn (array $entry) => $entry['card']->getTitle(), $entries), ]); $template->setSubject($count === 1 ? $l10n->t('Deck card moved: %s', [$entries[0]['card']->getTitle()]) : $l10n->n('%n Deck card moved', '%n Deck cards moved', $count)); $template->addHeader(); $template->addHeading($l10n->n('A card was moved automatically', '%n cards were moved automatically', $count), false); foreach ($entries as $entry) { $this->addCard($template, $l10n, $entry['workflow'], $entry['card'], $entry['targets']); } $template->addFooter(); $message = $this->mailer->createMessage(); $message->setTo([$email => $recipient->getDisplayName()]); $message->setFrom([Util::getDefaultEmailAddress('noreply') => $l10n->t('Deck Workflow Automation')]); $message->useTemplate($template); $failedRecipients = $this->mailer->send($message); if (!empty($failedRecipients)) { $this->logger->error('Moved-cards mail failed for: ' . implode(', ', $failedRecipients), [ 'app' => self::APP_ID, ]); } } catch (Throwable $e) { $this->logger->error('Could not send moved-cards mail to ' . $recipient->getUID() . ': ' . $e->getMessage(), [ 'app' => self::APP_ID, 'exception' => $e, ]); } } /** * @param array{board: string, sourceStack: string, targetStack: string} $targets */ private function addCard(IEMailTemplate $template, IL10N $l10n, Workflow $workflow, Card $card, array $targets): void { $template->addBodyText($l10n->t( 'The card "%1$s" was moved because it is overdue (workflow "%2$s").', [$card->getTitle(), $workflow->getTitle()], )); // Single-argument addBodyText() runs htmlspecialchars() itself, so // these board- and stack-titles (user input) are safe as-is — // unlike the description below, which passes both parts. $template->addBodyText($l10n->t( 'Board "%1$s": moved from "%2$s" to "%3$s".', [$targets['board'], $targets['sourceStack'], $targets['targetStack']], )); $this->addCardDescription($template, $l10n, $card); $template->addBodyButton( $l10n->t('Open card'), $this->urlGenerator->getAbsoluteURL('/apps/deck/board/' . $workflow->getBoardId() . '/card/' . $card->getId()), ); } /** * One-off notice that a workflow was switched off because the board or * stack it points at is gone. * * @param WorkflowRunner::TARGET_* $brokenTarget */ public function sendWorkflowDisabledNotification(IUser $user, Workflow $workflow, string $brokenTarget): void { $email = $user->getEMailAddress(); if ($email === null || $email === '') { $this->logger->info('Cannot notify about disabled workflow ' . $workflow->getId() . ': user ' . $user->getUID() . ' has no email address', [ 'app' => self::APP_ID, ]); return; } $l10n = $this->l10nFor($user); $reason = match ($brokenTarget) { WorkflowRunner::TARGET_SOURCE_STACK => $l10n->t('its source stack no longer exists'), WorkflowRunner::TARGET_TARGET_STACK => $l10n->t('its target stack no longer exists'), default => $l10n->t('its board no longer exists or is no longer available to you'), }; try { $settingsLink = $this->urlGenerator->getAbsoluteURL('/settings/user/' . self::APP_ID); $template = $this->mailer->createEMailTemplate(self::APP_ID . '.WorkflowDisabled', [ 'workflowTitle' => $workflow->getTitle(), ]); $template->setSubject($l10n->t('Deck workflow deactivated: %s', [$workflow->getTitle()])); $template->addHeader(); $template->addHeading($l10n->t('A workflow was deactivated'), false); $template->addBodyText($l10n->t( 'The workflow "%1$s" was switched off automatically because %2$s. No cards are being moved by it any more.', [$workflow->getTitle(), $reason], )); $template->addBodyText($l10n->t( 'Delete the workflow or point it at an existing board and stack to reactivate it. You will not be reminded about this workflow again.', )); $template->addBodyButton($l10n->t('Open settings'), $settingsLink); $template->addFooter(); $message = $this->mailer->createMessage(); $message->setTo([$email => $user->getDisplayName()]); $message->setFrom([Util::getDefaultEmailAddress('noreply') => $l10n->t('Deck Workflow Automation')]); $message->useTemplate($template); $failedRecipients = $this->mailer->send($message); if (!empty($failedRecipients)) { $this->logger->error('Deactivation mail for workflow ' . $workflow->getId() . ' failed for: ' . implode(', ', $failedRecipients), [ 'app' => self::APP_ID, ]); } } catch (Throwable $e) { $this->logger->error('Could not send deactivation mail for workflow ' . $workflow->getId() . ': ' . $e->getMessage(), [ 'app' => self::APP_ID, 'exception' => $e, ]); } } /** * The recipient's own language. Cron has no request to take one from, so * an injected IL10N would always speak the instance default — wrong as * soon as a mail goes to someone other than whoever set that default. */ private function l10nFor(IUser $user): IL10N { return $this->l10nFactory->get(self::APP_ID, $this->l10nFactory->getUserLanguage($user)); } /** * Appends the card's description, if it has one. * * Deck stores the description as Markdown. It is sent as-is rather than * rendered: pulling a Markdown parser in just for the mail would be a * lot of machinery, and unrendered Markdown still reads fine. */ private function addCardDescription(IEMailTemplate $template, IL10N $l10n, Card $card): void { $description = trim((string)$card->getDescription()); if ($description === '') { return; } if (mb_strlen($description) > self::MAX_DESCRIPTION_LENGTH) { $description = mb_substr($description, 0, self::MAX_DESCRIPTION_LENGTH) . ' […]'; } $template->addBodyText($l10n->t('Card content:')); // addBodyText() only runs htmlspecialchars() when it has to build the // plain-text part itself. Passing both parts means escaping is on us // -- and it has to be, because a card description is user input that // would otherwise land unescaped in the HTML mail. $template->addBodyText( nl2br(htmlspecialchars($description, ENT_QUOTES, 'UTF-8'), false), $description, ); } }