$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 { // The card description and the workflow's name are deliberately left // out — the mail only says what moved where. The card title itself is // the link to the card. $sentence = '„%1$s“ (Board „%2$s“) ist überfällig und wurde von „%3$s“ nach „%4$s“ verschoben.'; $url = $this->urlGenerator->getAbsoluteURL('/apps/deck/board/' . $workflow->getBoardId() . '/card/' . $card->getId()); // With both parts passed, addBodyText() escapes nothing: every title // is user input and has to be escaped here for the HTML part. $html = sprintf( $sentence, '' . self::escape($card->getTitle()) . '', self::escape($targets['board']), self::escape($targets['sourceStack']), self::escape($targets['targetStack']), ); $plain = sprintf( $sentence, $card->getTitle(), $targets['board'], $targets['sourceStack'], $targets['targetStack'], ) . PHP_EOL . $url; $template->addBodyText($html, $plain); } private static function escape(string $text): string { return htmlspecialchars($text, ENT_QUOTES, 'UTF-8'); } /** * Completes "… wurde ausgeschaltet, weil %s." — shared with * Notification\Notifier so mail and notification give the same reason. * * @param WorkflowRunner::TARGET_* $brokenTarget */ public static function describeBrokenTarget(string $brokenTarget): string { return 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', }; } /** * 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 = self::describeBrokenTarget($brokenTarget); 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, ]); } } }