Files
nextcloud-workflow-deck-aut…/lib/Service/NotificationMailer.php
T
Patrick Niebeling 578b235f08
Build package / unit-tests (push) Failing after 20s
Build package / xml-lint (push) Successful in 46s
Build package / php-lint (8.4) (push) Successful in 1m30s
Build package / php-lint (8.5) (push) Successful in 1m36s
Build package / package (push) Skipped
Notify board members about moved cards according to their Deck setting
Replaces the yes/no notify_email flag with notify_mode:
- off:   no mail
- owner: only the board owner, as before (migrated from notify_email=1)
- board: everyone with access to the board, filtered by their own Deck
         "Notifications" setting for it (board:<id>:notify-due: off /
         assigned / all), mirroring Deck's NotificationHelper rules

Recipients are resolved via PermissionService::findUsers() (owner, ACL,
groups and circles expanded), AssignmentMapper::isUserAssigned() and
IConfig, none of which reads Deck's frozen $userId. If they cannot be
resolved, no mail is sent rather than one to the wrong people.

Moved cards are collected over the whole run and each recipient gets a
single digest mail afterwards, in their own language.

Bumps to 35.2.0 so the migrations run.
2026-09-30 10:31:45 +02:00

211 lines
7.7 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\WorkflowDeckAutomation\Service;
use OCA\Deck\Db\Card;
use OCA\WorkflowDeckAutomation\Db\Workflow;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\IUser;
use OCP\L10N\IFactory;
use OCP\Mail\IEMailTemplate;
use OCP\Mail\IMailer;
use OCP\Util;
use Psr\Log\LoggerInterface;
use Throwable;
class NotificationMailer {
private const APP_ID = 'workflow_deck_automation';
/**
* Descriptions are unbounded in Deck; keep the mail readable and well
* clear of any MTA size limits.
*/
private const MAX_DESCRIPTION_LENGTH = 2000;
public function __construct(
private IMailer $mailer,
private IURLGenerator $urlGenerator,
private IFactory $l10nFactory,
private LoggerInterface $logger,
) {
}
/**
* One mail listing every card moved for $recipient in this run — they
* may come from several workflows and boards.
*
* @param non-empty-list<array{workflow: Workflow, card: Card, targets: array{board: string, sourceStack: string, targetStack: string}}> $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,
);
}
}