Files
nextcloud-workflow-deck-aut…/lib/Service/NotificationMailer.php
T
Patrick Niebeling 1ffc0765ea Switch the app to German as its only language
The app is used internally only and its settings page was German
already. Mails, controller error messages (shown in the form), the
settings section name and the mail sender are now written in German
directly instead of going through IL10N without any translation files.
Nextcloud's own mail footer is requested in German as well.
2026-09-30 10:39:12 +02:00

217 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\IURLGenerator;
use OCP\IUser;
use OCP\Mail\IEMailTemplate;
use OCP\Mail\IMailer;
use OCP\Util;
use Psr\Log\LoggerInterface;
use Throwable;
/**
* The app is used internally only and speaks German, like its settings
* page: texts are written in German directly instead of going through
* IL10N with English source strings and a translation file.
*/
class NotificationMailer {
private const APP_ID = 'workflow_deck_automation';
private const SENDER_NAME = 'Deck Workflow-Automatisierung';
/**
* Language of Nextcloud's own footer text ("automatically sent email,
* please do not reply"), which would otherwise follow the instance
* default — in cron there is no request to take a language from.
*/
private const FOOTER_LANGUAGE = 'de';
/**
* 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 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);
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
? sprintf('Deck-Karte verschoben: %s', $entries[0]['card']->getTitle())
: sprintf('%d Deck-Karten verschoben', $count));
$template->addHeader();
$template->addHeading($count === 1
? 'Eine Karte wurde automatisch verschoben'
: sprintf('%d Karten wurden automatisch verschoben', $count), false);
foreach ($entries as $entry) {
$this->addCard($template, $entry['workflow'], $entry['card'], $entry['targets']);
}
$template->addFooter('', self::FOOTER_LANGUAGE);
$message = $this->mailer->createMessage();
$message->setTo([$email => $recipient->getDisplayName()]);
$message->setFrom([Util::getDefaultEmailAddress('noreply') => self::SENDER_NAME]);
$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, Workflow $workflow, Card $card, array $targets): void {
// Single-argument addBodyText() runs htmlspecialchars() itself, so
// these card-, workflow-, board- and stack-titles (user input) are
// safe as-is — unlike the description below, which passes both parts.
$template->addBodyText(sprintf(
'Die Karte „%1$s“ wurde verschoben, weil sie überfällig ist (Workflow „%2$s“).',
$card->getTitle(),
$workflow->getTitle(),
));
$template->addBodyText(sprintf(
'Board „%1$s“: von „%2$s“ nach „%3$s“ verschoben.',
$targets['board'],
$targets['sourceStack'],
$targets['targetStack'],
));
$this->addCardDescription($template, $card);
$template->addBodyButton(
'Karte öffnen',
$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;
}
$reason = match ($brokenTarget) {
WorkflowRunner::TARGET_SOURCE_STACK => 'sein Quell-Stapel nicht mehr existiert',
WorkflowRunner::TARGET_TARGET_STACK => 'sein Ziel-Stapel nicht mehr existiert',
default => 'sein Board nicht mehr existiert oder für dich nicht mehr verfügbar ist',
};
try {
$settingsLink = $this->urlGenerator->getAbsoluteURL('/settings/user/' . self::APP_ID);
$template = $this->mailer->createEMailTemplate(self::APP_ID . '.WorkflowDisabled', [
'workflowTitle' => $workflow->getTitle(),
]);
$template->setSubject(sprintf('Deck-Workflow deaktiviert: %s', $workflow->getTitle()));
$template->addHeader();
$template->addHeading('Ein Workflow wurde deaktiviert', false);
$template->addBodyText(sprintf(
'Der Workflow „%1$s“ wurde automatisch ausgeschaltet, weil %2$s. Er verschiebt keine Karten mehr.',
$workflow->getTitle(),
$reason,
));
$template->addBodyText(
'Lösche den Workflow oder stelle ein vorhandenes Board und vorhandene Stapel ein, um ihn wieder zu aktivieren. An diesen Workflow wirst du nicht noch einmal erinnert.',
);
$template->addBodyButton('Einstellungen öffnen', $settingsLink);
$template->addFooter('', self::FOOTER_LANGUAGE);
$message = $this->mailer->createMessage();
$message->setTo([$email => $user->getDisplayName()]);
$message->setFrom([Util::getDefaultEmailAddress('noreply') => self::SENDER_NAME]);
$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,
]);
}
}
/**
* 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, 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('Karteninhalt:');
// 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,
);
}
}