From 9e39985130d6b3cd96547a65db33c9a1e50ac9f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Molakvo=C3=A6?= <14975046+skjnldsv@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:37:15 +0200 Subject: [PATCH] feat(mail): add sender, note, details and buttons blocks to email template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds addBodySender, addBodyNote, addBodyDetails and addBodyButtons to IEMailTemplate, with the EMailDetails and EMailDetailsRow value classes for the details card. All values are escaped by the template, each block also renders a plain text version. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: John Molakvoæ <14975046+skjnldsv@users.noreply.github.com> --- lib/composer/composer/autoload_classmap.php | 2 + lib/composer/composer/autoload_static.php | 2 + lib/private/Mail/EMailTemplate.php | 339 ++++++++++++++++++++ lib/public/Mail/EMailDetails.php | 129 ++++++++ lib/public/Mail/EMailDetailsRow.php | 85 +++++ lib/public/Mail/IEMailTemplate.php | 59 ++++ tests/data/REUSE.toml | 6 + tests/data/emails/blocks-email.html | 217 +++++++++++++ tests/data/emails/blocks-email.txt | 27 ++ tests/lib/Mail/EMailDetailsTest.php | 59 ++++ tests/lib/Mail/EMailTemplateTest.php | 115 +++++++ 11 files changed, 1040 insertions(+) create mode 100644 lib/public/Mail/EMailDetails.php create mode 100644 lib/public/Mail/EMailDetailsRow.php create mode 100644 tests/data/emails/blocks-email.html create mode 100644 tests/data/emails/blocks-email.txt create mode 100644 tests/lib/Mail/EMailDetailsTest.php diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index a641ddddb350b..0e61963e20e29 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -765,6 +765,8 @@ 'OCP\\Log\\ILogFactory' => $baseDir . '/lib/public/Log/ILogFactory.php', 'OCP\\Log\\IWriter' => $baseDir . '/lib/public/Log/IWriter.php', 'OCP\\Log\\RotationTrait' => $baseDir . '/lib/public/Log/RotationTrait.php', + 'OCP\\Mail\\EMailDetails' => $baseDir . '/lib/public/Mail/EMailDetails.php', + 'OCP\\Mail\\EMailDetailsRow' => $baseDir . '/lib/public/Mail/EMailDetailsRow.php', 'OCP\\Mail\\Events\\BeforeMessageSent' => $baseDir . '/lib/public/Mail/Events/BeforeMessageSent.php', 'OCP\\Mail\\Headers\\AutoSubmitted' => $baseDir . '/lib/public/Mail/Headers/AutoSubmitted.php', 'OCP\\Mail\\IAttachment' => $baseDir . '/lib/public/Mail/IAttachment.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 1931ad3bff8a3..6438d80271814 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -811,6 +811,8 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Log\\ILogFactory' => __DIR__ . '/../../..' . '/lib/public/Log/ILogFactory.php', 'OCP\\Log\\IWriter' => __DIR__ . '/../../..' . '/lib/public/Log/IWriter.php', 'OCP\\Log\\RotationTrait' => __DIR__ . '/../../..' . '/lib/public/Log/RotationTrait.php', + 'OCP\\Mail\\EMailDetails' => __DIR__ . '/../../..' . '/lib/public/Mail/EMailDetails.php', + 'OCP\\Mail\\EMailDetailsRow' => __DIR__ . '/../../..' . '/lib/public/Mail/EMailDetailsRow.php', 'OCP\\Mail\\Events\\BeforeMessageSent' => __DIR__ . '/../../..' . '/lib/public/Mail/Events/BeforeMessageSent.php', 'OCP\\Mail\\Headers\\AutoSubmitted' => __DIR__ . '/../../..' . '/lib/public/Mail/Headers/AutoSubmitted.php', 'OCP\\Mail\\IAttachment' => __DIR__ . '/../../..' . '/lib/public/Mail/IAttachment.php', diff --git a/lib/private/Mail/EMailTemplate.php b/lib/private/Mail/EMailTemplate.php index 46dbbab2c1490..b4da9f46f8128 100644 --- a/lib/private/Mail/EMailTemplate.php +++ b/lib/private/Mail/EMailTemplate.php @@ -12,6 +12,8 @@ use OCP\Defaults; use OCP\IURLGenerator; use OCP\L10N\IFactory; +use OCP\Mail\EMailDetails; +use OCP\Mail\EMailDetailsRow; use OCP\Mail\IEMailTemplate; /** @@ -319,6 +321,167 @@ class EMailTemplate implements IEMailTemplate { +EOF; + + protected string $sender = << + + + + + + + + +
%1\$s%2\$s%3\$s
+ + + + +EOF; + + protected string $senderSubline = <<%s +EOF; + + protected string $initials = <<%3\$s +EOF; + + protected string $note = << + + + + + + + +
%3\$s
+ + + + +EOF; + + protected string $noteLabel = <<%s
+EOF; + + /** @var array */ + protected array $noteColors = [ + IEMailTemplate::NOTE_NEUTRAL => ['background' => '#f5f5f5', 'border' => '#ccc'], + IEMailTemplate::NOTE_INFO => ['background' => '#e5f0f5', 'border' => '#0071ad'], + IEMailTemplate::NOTE_WARNING => ['background' => '#fdf6e3', 'border' => '#a37200'], + IEMailTemplate::NOTE_ERROR => ['background' => '#fbe5e5', 'border' => '#db0606'], + ]; + + protected string $detailsBegin = << + + + + + + + +EOF; + + protected string $detailsLeading = <<%s +EOF; + + protected string $detailsDateBadge = << + + +
+ + + %2\$s + +
%3\$s%4\$s
+
%3\$s
%4\$s
+EOF; + + protected string $detailsRow = << + %2\$s + %3\$s + +EOF; + + protected string $detailsEnd = << + + + + +EOF; + + protected string $buttonsBegin = << + + +   + + + + + + + + + +
+
+ %s + +EOF; + + protected string $buttonsLabel = <<%s

+EOF; + + protected string $buttonsPrimary = << +
+ %4\$s +
+EOF; + + protected string $buttonsSecondary = << + + + %2\$s + + + +EOF; + + protected string $buttonsSeparator = << + + + +EOF; + + protected string $buttonsEnd = << + + + + + + + + + EOF; public function __construct( @@ -605,6 +768,182 @@ public function addBodyButton(string $text, string $url, $plainText = ''): void $this->plainBody .= $url . PHP_EOL; } + /** + * @param non-empty-list $buttons + */ + #[\Override] + public function addBodyButtons(array $buttons, string $label = ''): void { + if ($this->footerAdded) { + return; + } + + $this->ensureBodyIsOpened(); + $this->ensureBodyListClosed(); + + $color = $this->themingDefaults->getDefaultColorPrimary(); + $textColor = $this->themingDefaults->getDefaultTextColorPrimary(); + + $htmlButtons = []; + foreach ($buttons as $index => $button) { + $url = htmlspecialchars($button['url']); + $text = htmlspecialchars($button['text']); + $htmlButtons[] = $index === 0 + ? vsprintf($this->buttonsPrimary, [$color, $url, $textColor, $text]) + : vsprintf($this->buttonsSecondary, [$url, $text]); + } + + $htmlLabel = $label !== '' ? vsprintf($this->buttonsLabel, [htmlspecialchars($label)]) : ''; + $this->htmlBody .= vsprintf($this->buttonsBegin, [$htmlLabel]); + $this->htmlBody .= implode($this->buttonsSeparator, $htmlButtons); + $this->htmlBody .= $this->buttonsEnd; + + if ($label !== '') { + $this->plainBody .= $label . PHP_EOL; + } + foreach ($buttons as $button) { + $this->plainBody .= $button['text'] . ': ' . $button['url'] . PHP_EOL; + } + $this->plainBody .= PHP_EOL; + } + + #[\Override] + public function addBodySender(string $displayName, string $subline = ''): void { + if ($this->footerAdded) { + return; + } + + $this->ensureBodyListClosed(); + + $htmlSubline = $subline !== '' ? vsprintf($this->senderSubline, [htmlspecialchars($subline)]) : ''; + $this->htmlBody .= vsprintf($this->sender, [$this->renderInitials($displayName), htmlspecialchars($displayName), $htmlSubline]); + + $this->plainBody .= $displayName; + if ($subline !== '') { + $this->plainBody .= ' (' . $subline . ')'; + } + $this->plainBody .= PHP_EOL . PHP_EOL; + } + + #[\Override] + public function addBodyNote(string $text, string $label = '', string $type = IEMailTemplate::NOTE_NEUTRAL): void { + if ($this->footerAdded) { + return; + } + $colors = $this->noteColors[$type] ?? $this->noteColors[IEMailTemplate::NOTE_NEUTRAL]; + + $this->ensureBodyIsOpened(); + $this->ensureBodyListClosed(); + + $htmlText = str_replace("\n", '
', htmlspecialchars($text)); + if ($label !== '') { + $htmlLabel = htmlspecialchars($label); + $htmlText = $type === IEMailTemplate::NOTE_NEUTRAL + ? vsprintf($this->noteLabel, [$htmlLabel]) . $htmlText + : '' . $htmlLabel . ' ' . $htmlText; + } + $this->htmlBody .= vsprintf($this->note, [$colors['background'], $colors['border'], $htmlText]); + + if ($type === IEMailTemplate::NOTE_NEUTRAL) { + if ($label !== '') { + $this->plainBody .= $label . PHP_EOL; + } + $this->plainBody .= '> ' . str_replace("\n", PHP_EOL . '> ', $text); + } else { + $this->plainBody .= $label !== '' ? $label . ' ' . $text : $text; + } + $this->plainBody .= PHP_EOL . PHP_EOL; + } + + #[\Override] + public function addBodyDetails(EMailDetails $details): void { + if ($this->footerAdded) { + return; + } + + $this->ensureBodyIsOpened(); + $this->ensureBodyListClosed(); + + $leading = ''; + $initialsName = $details->getInitialsName(); + $dateBadge = $details->getDateBadge(); + if ($initialsName !== null) { + $leading = vsprintf($this->detailsLeading, [$this->renderInitials($initialsName)]); + } elseif ($dateBadge !== null) { + $leading = vsprintf($this->detailsLeading, [vsprintf($this->detailsDateBadge, [ + $this->themingDefaults->getDefaultColorPrimary(), + $this->themingDefaults->getDefaultTextColorPrimary(), + htmlspecialchars($dateBadge['month']), + htmlspecialchars($dateBadge['day']), + ])]); + } + + $subtitle = $details->getSubtitle(); + $htmlSubtitle = $subtitle !== '' ? vsprintf($this->senderSubline, [htmlspecialchars($subtitle)]) : ''; + $rows = $details->getRows(); + $separator = 'border-bottom:1px solid #ddd;'; + + $this->htmlBody .= vsprintf($this->detailsBegin, [$rows !== [] ? $separator : '', $leading, htmlspecialchars($details->getTitle()), $htmlSubtitle]); + $this->plainBody .= $details->getTitle() . PHP_EOL; + if ($subtitle !== '') { + $this->plainBody .= $subtitle . PHP_EOL; + } + + $linkColor = $this->themingDefaults->getDefaultColorPrimary(); + foreach ($rows as $index => $row) { + $htmlParts = []; + $plainParts = []; + foreach ($row->getParts() as $part) { + $text = htmlspecialchars($part['text']); + switch ($part['type']) { + case EMailDetailsRow::PART_LINK: + $htmlParts[] = '' . $text . ''; + $plainParts[] = $part['text'] === $part['url'] ? $part['url'] : $part['text'] . ' (' . $part['url'] . ')'; + break; + case EMailDetailsRow::PART_MUTED: + $htmlParts[] = '' . $text . ''; + $plainParts[] = $part['text']; + break; + default: + $htmlParts[] = $text; + $plainParts[] = $part['text']; + } + } + + $this->htmlBody .= vsprintf($this->detailsRow, [ + $index > 0 ? 'border-top:1px solid #eee;' : '', + htmlspecialchars($row->getLabel()), + implode('
', $htmlParts), + ]); + + $plainLabel = ' ' . $row->getLabel() . ': '; + $indent = PHP_EOL . str_repeat(' ', mb_strlen($plainLabel)); + $this->plainBody .= $plainLabel . implode($indent, $plainParts) . PHP_EOL; + } + + $this->htmlBody .= $this->detailsEnd; + $this->plainBody .= PHP_EOL; + } + + /** + * Initials circle in the theming colors, same letters as the generated avatars + */ + protected function renderInitials(string $name): string { + $name = trim($name); + $initials = '?'; + if ($name !== '') { + $initials = implode('', array_map( + static fn (string $namePart): string => mb_strtoupper(mb_substr(trim($namePart), 0, 1, 'UTF-8'), 'UTF-8'), + explode(' ', $name, 2), + )); + } + + return vsprintf($this->initials, [ + $this->themingDefaults->getDefaultColorPrimary(), + $this->themingDefaults->getDefaultTextColorPrimary(), + htmlspecialchars($initials), + ]); + } + /** * Close the HTML body when it is open */ diff --git a/lib/public/Mail/EMailDetails.php b/lib/public/Mail/EMailDetails.php new file mode 100644 index 0000000000000..d3390d346d787 --- /dev/null +++ b/lib/public/Mail/EMailDetails.php @@ -0,0 +1,129 @@ +setSubtitle('Thursday, October 15, 2026') + * ->setDateBadge('Oct', '15'); + * $details->addRow('Where') + * ->text('Meeting room 2') + * ->link('Join in Nextcloud Talk', 'https://cloud.example.com/call/abc'); + * $emailTemplate->addBodyDetails($details); + * + * @since 36.0.0 + */ +#[Consumable(since: '36.0.0')] +final class EMailDetails { + private string $subtitle = ''; + private ?string $initialsName = null; + /** @var array{month: string, day: string}|null */ + private ?array $dateBadge = null; + /** @var list */ + private array $rows = []; + + /** + * @since 36.0.0 + */ + public function __construct( + private string $title, + ) { + } + + /** + * @since 36.0.0 + */ + public function getTitle(): string { + return $this->title; + } + + /** + * @since 36.0.0 + */ + public function setSubtitle(string $subtitle): self { + $this->subtitle = $subtitle; + return $this; + } + + /** + * @since 36.0.0 + */ + public function getSubtitle(): string { + return $this->subtitle; + } + + /** + * Show an initials circle next to the title, replaces any date badge + * + * @param string $name Name the initials are computed from + * @since 36.0.0 + */ + public function setInitials(string $name): self { + $this->initialsName = $name; + $this->dateBadge = null; + return $this; + } + + /** + * @since 36.0.0 + */ + public function getInitialsName(): ?string { + return $this->initialsName; + } + + /** + * Show a calendar badge next to the title, replaces any initials circle + * + * @param string $month Localized short month name, e.g. "Oct" + * @param string $day Day of the month, e.g. "15" + * @since 36.0.0 + */ + public function setDateBadge(string $month, string $day): self { + $this->dateBadge = ['month' => $month, 'day' => $day]; + $this->initialsName = null; + return $this; + } + + /** + * @return array{month: string, day: string}|null + * @since 36.0.0 + */ + public function getDateBadge(): ?array { + return $this->dateBadge; + } + + /** + * Add a labelled row, fill it through the returned row + * + * @since 36.0.0 + */ + public function addRow(string $label): EMailDetailsRow { + $row = new EMailDetailsRow($label); + $this->rows[] = $row; + return $row; + } + + /** + * @return list + * @since 36.0.0 + */ + public function getRows(): array { + return $this->rows; + } +} diff --git a/lib/public/Mail/EMailDetailsRow.php b/lib/public/Mail/EMailDetailsRow.php new file mode 100644 index 0000000000000..2e7afeca1bb55 --- /dev/null +++ b/lib/public/Mail/EMailDetailsRow.php @@ -0,0 +1,85 @@ + */ + private array $parts = []; + + /** + * @since 36.0.0 + */ + public function __construct( + private string $label, + ) { + } + + /** + * @since 36.0.0 + */ + public function getLabel(): string { + return $this->label; + } + + /** + * @since 36.0.0 + */ + public function text(string $text): self { + $this->parts[] = ['type' => self::PART_TEXT, 'text' => $text, 'url' => '']; + return $this; + } + + /** + * @since 36.0.0 + */ + public function link(string $text, string $url): self { + $this->parts[] = ['type' => self::PART_LINK, 'text' => $text, 'url' => $url]; + return $this; + } + + /** + * Secondary information, rendered in a lighter color + * + * @since 36.0.0 + */ + public function muted(string $text): self { + $this->parts[] = ['type' => self::PART_MUTED, 'text' => $text, 'url' => '']; + return $this; + } + + /** + * @return list + * @since 36.0.0 + */ + public function getParts(): array { + return $this->parts; + } +} diff --git a/lib/public/Mail/IEMailTemplate.php b/lib/public/Mail/IEMailTemplate.php index de75c97730317..a36a3c8aa01c1 100644 --- a/lib/public/Mail/IEMailTemplate.php +++ b/lib/public/Mail/IEMailTemplate.php @@ -37,6 +37,25 @@ * @since 12.0.0 */ interface IEMailTemplate { + /** + * Note for content written by a user, e.g. a share note + * + * @since 36.0.0 + */ + public const NOTE_NEUTRAL = 'neutral'; + /** + * @since 36.0.0 + */ + public const NOTE_INFO = 'info'; + /** + * @since 36.0.0 + */ + public const NOTE_WARNING = 'warning'; + /** + * @since 36.0.0 + */ + public const NOTE_ERROR = 'error'; + /** * Sets the subject of the email * @@ -116,6 +135,46 @@ public function addBodyButtonGroup(string $textLeft, string $urlLeft, string $te */ public function addBodyButton(string $text, string $url, $plainText = ''); + /** + * Adds a row of buttons to the body of the email, the first one is the primary action + * + * @param non-empty-list $buttons + * @param string $label Optional text shown above the buttons, e.g. "Will you attend?" + * + * @since 36.0.0 + */ + public function addBodyButtons(array $buttons, string $label = ''): void; + + /** + * Adds the person an email is about (sharer, organizer...), shown with an initials circle + * + * @param string $subline Optional second line, e.g. the email address + * + * @since 36.0.0 + */ + public function addBodySender(string $displayName, string $subline = ''): void; + + /** + * Adds a highlighted box to the body of the email + * + * Use {@see self::NOTE_NEUTRAL} for content written by a user (share note, + * event description), the other types for messages from the server itself. + * Line breaks in $text are kept. + * + * @param string $label Label above the text for neutral notes, bold title before it for the other types + * @param self::NOTE_* $type + * + * @since 36.0.0 + */ + public function addBodyNote(string $text, string $label = '', string $type = self::NOTE_NEUTRAL): void; + + /** + * Adds a card with a title and labelled rows to the body of the email + * + * @since 36.0.0 + */ + public function addBodyDetails(EMailDetails $details): void; + /** * Adds a logo and a text to the footer.
in the text will be replaced by new lines in the plain text email * diff --git a/tests/data/REUSE.toml b/tests/data/REUSE.toml index 0b6124646cac6..ca8efc4e59779 100644 --- a/tests/data/REUSE.toml +++ b/tests/data/REUSE.toml @@ -87,6 +87,12 @@ precedence = "aggregate" SPDX-FileCopyrightText = "2017 Nextcloud GmbH and Nextcloud contributors" SPDX-License-Identifier = "AGPL-3.0-or-later" +[[annotations]] +path = ["emails/blocks-email.html", "emails/blocks-email.txt"] +precedence = "aggregate" +SPDX-FileCopyrightText = "2026 Nextcloud GmbH and Nextcloud contributors" +SPDX-License-Identifier = "AGPL-3.0-or-later" + [[annotations]] path = ["testimage.heic"] precedence = "aggregate" diff --git a/tests/data/emails/blocks-email.html b/tests/data/emails/blocks-email.html new file mode 100644 index 0000000000000..70c2178a1de10 --- /dev/null +++ b/tests/data/emails/blocks-email.html @@ -0,0 +1,217 @@ + + + + + + + + + + + + + + +
+
+ + + +
+ + + + + + +
+ + + +
+ +
+ + +
+
+
+ + + + + + +
 
+ + + + + +
+ + + + + +
AM
Alice Martin
alice.martin@example.org
+
+ + + + + +
+

Alice Martin invited you to an event

+
+ + + + + + +
 
+ + + +
+ + + + + + +
+ + + + + +
+ + + + + + + + + + + + +
+ + + + +
+ + +
Oct
15
Q3 board review
Thursday, October 15, 2026
+
When14:00 - 15:30 (Europe/Berlin)
WhereMeeting room 2
Join in Nextcloud Talk
AttendeesAlice Martin (organizer)
You
and 3 others
+
+ + + + + +
+ + + + +
Description
We'll go through the Q3 numbers.
Please review the report beforehand.
+
+ + + + + +
+ + + + +
This share is password protected. The password is sent in a separate email.
+
+ + + + + +
 
+ + + + + + +
+
+

Will you attend?

+ + + + +
+ Accept +
+ + + +
+ Maybe +
+ + + +
+ Decline +
+
+
+
+
+ + + + + +
 
+ + + + +
+
+ +
                                                           
+ + diff --git a/tests/data/emails/blocks-email.txt b/tests/data/emails/blocks-email.txt new file mode 100644 index 0000000000000..b00fc75ea0644 --- /dev/null +++ b/tests/data/emails/blocks-email.txt @@ -0,0 +1,27 @@ +Alice Martin (alice.martin@example.org) + +Alice Martin invited you to an event + +Q3 board review +Thursday, October 15, 2026 + When: 14:00 - 15:30 (Europe/Berlin) + Where: Meeting room 2 + Join in Nextcloud Talk (https://example.org/call/abc?x=1&y=2) + Attendees: Alice Martin (organizer) + You + and 3 others + +Description +> We'll go through the Q3 numbers. +> Please review the report beforehand. + +This share is password protected. The password is sent in a separate email. + +Will you attend? +Accept: https://example.org/invitation/accept +Maybe: https://example.org/invitation/tentative +Decline: https://example.org/invitation/decline + + +-- +TestCloud - A safe home for your data \ No newline at end of file diff --git a/tests/lib/Mail/EMailDetailsTest.php b/tests/lib/Mail/EMailDetailsTest.php new file mode 100644 index 0000000000000..091b7d7246e77 --- /dev/null +++ b/tests/lib/Mail/EMailDetailsTest.php @@ -0,0 +1,59 @@ +assertSame('Title', $details->getTitle()); + $this->assertSame('', $details->getSubtitle()); + $this->assertNull($details->getInitialsName()); + $this->assertNull($details->getDateBadge()); + $this->assertSame([], $details->getRows()); + } + + public function testLeadingVisualReplacesTheOther(): void { + $details = (new EMailDetails('Title')) + ->setInitials('Board prep') + ->setDateBadge('Oct', '15'); + + $this->assertNull($details->getInitialsName()); + $this->assertSame(['month' => 'Oct', 'day' => '15'], $details->getDateBadge()); + + $details->setInitials('Board prep'); + + $this->assertSame('Board prep', $details->getInitialsName()); + $this->assertNull($details->getDateBadge()); + } + + public function testRowsKeepOrder(): void { + $details = new EMailDetails('Title'); + $details->addRow('Where') + ->text('Room 2') + ->link('Join', 'https://example.org') + ->muted('Optional'); + $details->addRow('When')->text('Today'); + + $rows = $details->getRows(); + $this->assertCount(2, $rows); + $this->assertSame('Where', $rows[0]->getLabel()); + $this->assertSame([ + ['type' => EMailDetailsRow::PART_TEXT, 'text' => 'Room 2', 'url' => ''], + ['type' => EMailDetailsRow::PART_LINK, 'text' => 'Join', 'url' => 'https://example.org'], + ['type' => EMailDetailsRow::PART_MUTED, 'text' => 'Optional', 'url' => ''], + ], $rows[0]->getParts()); + $this->assertSame('When', $rows[1]->getLabel()); + } +} diff --git a/tests/lib/Mail/EMailTemplateTest.php b/tests/lib/Mail/EMailTemplateTest.php index 8bfa8670ea255..7fbc6d314ea59 100644 --- a/tests/lib/Mail/EMailTemplateTest.php +++ b/tests/lib/Mail/EMailTemplateTest.php @@ -12,6 +12,8 @@ use OCP\IL10N; use OCP\IURLGenerator; use OCP\L10N\IFactory; +use OCP\Mail\EMailDetails; +use OCP\Mail\IEMailTemplate; use Test\TestCase; class EMailTemplateTest extends TestCase { @@ -233,4 +235,117 @@ public function testEMailTemplateEmbedsLogo(): void { $this->emailTemplate->getInlineImages() ); } + + private function mockThemingColors(): void { + $this->defaults->method('getDefaultColorPrimary')->willReturn('#0082c9'); + $this->defaults->method('getDefaultTextColorPrimary')->willReturn('#ffffff'); + $this->defaults->method('getName')->willReturn('TestCloud'); + $this->defaults->method('getLogo')->willReturn('/img/logo-mail-header.png'); + $this->urlGenerator->method('getAbsoluteURL') + ->willReturn('https://example.org/img/logo-mail-header.png'); + } + + public function testEMailTemplateBlocks(): void { + $this->mockThemingColors(); + + $details = new EMailDetails('Q3 board review'); + $details->setSubtitle('Thursday, October 15, 2026') + ->setDateBadge('Oct', '15'); + $details->addRow('When') + ->text('14:00 - 15:30 (Europe/Berlin)'); + $details->addRow('Where') + ->text('Meeting room 2') + ->link('Join in Nextcloud Talk', 'https://example.org/call/abc?x=1&y=2'); + $details->addRow('Attendees') + ->text('Alice Martin (organizer)') + ->text('You') + ->muted('and 3 others'); + + $this->emailTemplate->addHeader(); + $this->emailTemplate->addBodySender('Alice Martin', 'alice.martin@example.org'); + $this->emailTemplate->addHeading('Alice Martin invited you to an event'); + $this->emailTemplate->addBodyDetails($details); + $this->emailTemplate->addBodyNote("We'll go through the Q3 numbers.\nPlease review the report beforehand.", 'Description'); + $this->emailTemplate->addBodyNote('The password is sent in a separate email.', 'This share is password protected.', IEMailTemplate::NOTE_WARNING); + $this->emailTemplate->addBodyButtons([ + ['text' => 'Accept', 'url' => 'https://example.org/invitation/accept'], + ['text' => 'Maybe', 'url' => 'https://example.org/invitation/tentative'], + ['text' => 'Decline', 'url' => 'https://example.org/invitation/decline'], + ], 'Will you attend?'); + $this->emailTemplate->addFooter('TestCloud - A safe home for your data'); + + $expectedHTML = file_get_contents(\OC::$SERVERROOT . '/tests/data/emails/blocks-email.html'); + $this->assertSame($expectedHTML, $this->emailTemplate->renderHtml()); + $expectedTXT = file_get_contents(\OC::$SERVERROOT . '/tests/data/emails/blocks-email.txt'); + $this->assertSame($expectedTXT, $this->emailTemplate->renderText()); + } + + public function testEMailTemplateBlocksEscapeHtml(): void { + $this->mockThemingColors(); + + $details = (new EMailDetails('title')) + ->setSubtitle('subtitle') + ->setInitials('