Files
nextcloud-server-mirror/tests/lib/Mail/EMailTemplateTest.php
John Molakvoæ 9629175507 feat(mail): support right-to-left languages in emails
IEMailTemplate::setLanguage() sets the lang and dir attributes of the
email, mirrors the layout for right-to-left languages and becomes the
default language of the footer. Every email sent by server now passes
the language it is written in.

Assisted-by: ClaudeCode:claude-opus-5-5
Signed-off-by: John Molakvoæ <14975046+skjnldsv@users.noreply.github.com>
2026-10-08 21:20:07 +02:00

462 lines
19 KiB
PHP

<?php
/**
* SPDX-FileCopyrightText: 2017 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace Test\Mail;
use OC\Mail\EMailTemplate;
use OCP\Defaults;
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 {
/** @var Defaults|\PHPUnit\Framework\MockObject\MockObject */
private $defaults;
/** @var IURLGenerator|\PHPUnit\Framework\MockObject\MockObject */
private $urlGenerator;
/** @var IFactory|\PHPUnit\Framework\MockObject\MockObject */
private $l10n;
/** @var EMailTemplate */
private $emailTemplate;
#[\Override]
protected function setUp(): void {
parent::setUp();
$this->defaults = $this->createMock(Defaults::class);
$this->urlGenerator = $this->createMock(IURLGenerator::class);
$this->l10n = $this->createMock(IFactory::class);
$this->l10n->method('get')
->with('lib', '')
->willReturn($this->createMock(IL10N::class));
$this->emailTemplate = new EMailTemplate(
$this->defaults,
$this->urlGenerator,
$this->l10n,
252,
120,
'test.TestTemplate',
[]
);
}
public function testEMailTemplateCustomFooter(): void {
$this->defaults
->expects($this->atLeastOnce())
->method('getDefaultColorPrimary')
->willReturn('#0082c9');
$this->defaults
->expects($this->any())
->method('getLogo')
->willReturn('/img/logo-mail-header.png');
$this->defaults
->expects($this->any())
->method('getName')
->willReturn('TestCloud');
$this->defaults
->expects($this->atLeastOnce())
->method('getDefaultTextColorPrimary')
->willReturn('#ffffff');
$this->urlGenerator
->expects($this->once())
->method('getAbsoluteURL')
->with('/img/logo-mail-header.png')
->willReturn('https://example.org/img/logo-mail-header.png');
$this->emailTemplate->addHeader();
$this->emailTemplate->addHeading('Welcome aboard');
$this->emailTemplate->addBodyText('Welcome to your Nextcloud account, you can add, protect, and share your data.');
$this->emailTemplate->addBodyText('Your username is: abc');
$this->emailTemplate->addBodyButtonGroup(
'Set your password', 'https://example.org/resetPassword/123',
'Install Client', 'https://nextcloud.com/install/#install-clients'
);
$this->emailTemplate->addFooter(
'TestCloud - A safe home for your data<br>This is an automatically sent email, please do not reply.'
);
$expectedHTML = file_get_contents(\OC::$SERVERROOT . '/tests/data/emails/new-account-email.html');
$this->assertSame($expectedHTML, $this->emailTemplate->renderHtml());
$expectedTXT = file_get_contents(\OC::$SERVERROOT . '/tests/data/emails/new-account-email.txt');
$this->assertSame($expectedTXT, $this->emailTemplate->renderText());
}
public function testEMailTemplateDefaultFooter(): void {
$this->defaults
->expects($this->atLeastOnce())
->method('getDefaultColorPrimary')
->willReturn('#0082c9');
$this->defaults
->expects($this->any())
->method('getName')
->willReturn('TestCloud');
$this->defaults
->expects($this->any())
->method('getSlogan')
->willReturn('A safe home for your data');
$this->defaults
->expects($this->any())
->method('getLogo')
->willReturn('/img/logo-mail-header.png');
$this->defaults
->expects($this->atLeastOnce())
->method('getDefaultTextColorPrimary')
->willReturn('#ffffff');
$this->urlGenerator
->expects($this->once())
->method('getAbsoluteURL')
->with('/img/logo-mail-header.png')
->willReturn('https://example.org/img/logo-mail-header.png');
$this->emailTemplate->addHeader();
$this->emailTemplate->addHeading('Welcome aboard');
$this->emailTemplate->addBodyText('Welcome to your Nextcloud account, you can add, protect, and share your data.');
$this->emailTemplate->addBodyText('Your username is: abc');
$this->emailTemplate->addBodyButtonGroup(
'Set your password', 'https://example.org/resetPassword/123',
'Install Client', 'https://nextcloud.com/install/#install-clients'
);
$this->emailTemplate->addFooter();
$expectedHTML = file_get_contents(\OC::$SERVERROOT . '/tests/data/emails/new-account-email-custom.html');
$this->assertSame($expectedHTML, $this->emailTemplate->renderHtml());
$expectedTXT = file_get_contents(\OC::$SERVERROOT . '/tests/data/emails/new-account-email-custom.txt');
$this->assertSame($expectedTXT, $this->emailTemplate->renderText());
}
public function testEMailTemplateSingleButton(): void {
$this->defaults
->expects($this->atLeastOnce())
->method('getDefaultColorPrimary')
->willReturn('#0082c9');
$this->defaults
->expects($this->any())
->method('getName')
->willReturn('TestCloud');
$this->defaults
->expects($this->any())
->method('getSlogan')
->willReturn('A safe home for your data');
$this->defaults
->expects($this->any())
->method('getLogo')
->willReturn('/img/logo-mail-header.png');
$this->defaults
->expects($this->atLeastOnce())
->method('getDefaultTextColorPrimary')
->willReturn('#ffffff');
$this->urlGenerator
->expects($this->once())
->method('getAbsoluteURL')
->with('/img/logo-mail-header.png')
->willReturn('https://example.org/img/logo-mail-header.png');
$this->emailTemplate->addHeader();
$this->emailTemplate->addHeading('Welcome aboard');
$this->emailTemplate->addBodyText('Welcome to your Nextcloud account, you can add, protect, and share your data.');
$this->emailTemplate->addBodyText('Your username is: abc');
$this->emailTemplate->addBodyButton(
'Set your password', 'https://example.org/resetPassword/123',
false
);
$this->emailTemplate->addFooter();
$expectedHTML = file_get_contents(\OC::$SERVERROOT . '/tests/data/emails/new-account-email-single-button.html');
$this->assertSame($expectedHTML, $this->emailTemplate->renderHtml());
$expectedTXT = file_get_contents(\OC::$SERVERROOT . '/tests/data/emails/new-account-email-single-button.txt');
$this->assertSame($expectedTXT, $this->emailTemplate->renderText());
}
public function testEMailTemplateAlternativePlainTexts(): void {
$this->defaults
->expects($this->atLeastOnce())
->method('getDefaultColorPrimary')
->willReturn('#0082c9');
$this->defaults
->expects($this->any())
->method('getName')
->willReturn('TestCloud');
$this->defaults
->expects($this->any())
->method('getSlogan')
->willReturn('A safe home for your data');
$this->defaults
->expects($this->any())
->method('getLogo')
->willReturn('/img/logo-mail-header.png');
$this->defaults
->expects($this->atLeastOnce())
->method('getDefaultTextColorPrimary')
->willReturn('#ffffff');
$this->urlGenerator
->expects($this->once())
->method('getAbsoluteURL')
->with('/img/logo-mail-header.png')
->willReturn('https://example.org/img/logo-mail-header.png');
$this->emailTemplate->addHeader();
$this->emailTemplate->addHeading('Welcome aboard', 'Welcome aboard - text');
$this->emailTemplate->addBodyText('Welcome to your Nextcloud account, you can add, protect, and share your data.', 'Welcome to your Nextcloud account, you can add, protect, and share your data. - text');
$this->emailTemplate->addBodyText('Your username is: abc');
$this->emailTemplate->addBodyButtonGroup(
'Set your password', 'https://example.org/resetPassword/123',
'Install Client', 'https://nextcloud.com/install/#install-clients',
'Set your password - text', 'Install Client - text'
);
$this->emailTemplate->addFooter();
$expectedHTML = file_get_contents(\OC::$SERVERROOT . '/tests/data/emails/new-account-email-custom.html');
$this->assertSame($expectedHTML, $this->emailTemplate->renderHtml());
$expectedTXT = file_get_contents(\OC::$SERVERROOT . '/tests/data/emails/new-account-email-custom-text-alternative.txt');
$this->assertSame($expectedTXT, $this->emailTemplate->renderText());
}
public function testEMailTemplateEmbedsLogo(): void {
$this->defaults->method('getDefaultColorPrimary')->willReturn('#0082c9');
$this->defaults->method('getName')->willReturn('TestCloud');
$this->defaults->method('getLogoImage')
->willReturn(['content' => 'PNGDATA', 'mimeType' => 'image/png']);
$this->urlGenerator->expects($this->never())->method('getAbsoluteURL');
$this->emailTemplate->addHeader();
$this->assertStringContainsString('src="cid:logo"', $this->emailTemplate->renderHtml());
$this->assertSame(
[['name' => 'logo', 'content' => 'PNGDATA', 'mimeType' => 'image/png']],
$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('<b>title</b>'))
->setSubtitle('<i>subtitle</i>')
->setInitials('<script>');
$details->addRow('<u>label</u>')
->text('<img src=x>')
->link('<em>link</em>', 'https://example.org/"onmouseover="alert(1)')
->muted('<s>muted</s>');
$this->emailTemplate->addBodySender('<b>Eve</b>', '<i>eve@example.org</i>');
$this->emailTemplate->addBodyDetails($details);
$this->emailTemplate->addBodyNote('<p>note</p>', '<b>label</b>');
$this->emailTemplate->addBodyNote('<p>note</p>', '<b>title</b>', IEMailTemplate::NOTE_ERROR);
$this->emailTemplate->addBodyButtons([['text' => '<b>Go</b>', 'url' => 'https://example.org/"><script>']], '<b>label</b>');
$html = $this->emailTemplate->renderHtml();
foreach (['<b>', '<i>', '<u>', '<s>', '<em>', '<img', '<script>', '<p>', '"onmouseover'] as $raw) {
$this->assertStringNotContainsString($raw, $html);
}
$this->assertStringContainsString('&lt;b&gt;Eve&lt;/b&gt;', $html);
$this->assertStringContainsString('https://example.org/&quot;onmouseover=&quot;alert(1)', $html);
$text = $this->emailTemplate->renderText();
$this->assertStringContainsString('<b>Eve</b> (<i>eve@example.org</i>)', $text);
$this->assertStringContainsString('<b>title</b> <p>note</p>', $text);
}
public function testEMailTemplateDetailsLeadingVisual(): void {
$this->mockThemingColors();
$details = (new EMailDetails('Board prep'))->setInitials('board prep');
$this->emailTemplate->addBodyDetails($details);
$html = $this->emailTemplate->renderHtml();
$this->assertStringContainsString('>BP</div>', $html);
$this->assertStringNotContainsString('text-transform:uppercase', $html);
// No row, so no separator under the title
$this->assertStringNotContainsString('border-bottom:1px solid #ddd;', $html);
$this->assertSame('Board prep' . PHP_EOL . PHP_EOL, $this->emailTemplate->renderText());
}
public function testEMailTemplateDetailsLinkWithUrlAsText(): void {
$this->mockThemingColors();
$details = new EMailDetails('Event');
$details->addRow('Link')->link('https://example.org/event', 'https://example.org/event');
$this->emailTemplate->addBodyDetails($details);
$this->assertSame('Event' . PHP_EOL . ' Link: https://example.org/event' . PHP_EOL . PHP_EOL, $this->emailTemplate->renderText());
}
public function testEMailTemplateBlocksIgnoredAfterFooter(): void {
$this->mockThemingColors();
$this->emailTemplate->addFooter('Footer');
$html = $this->emailTemplate->renderHtml();
$text = $this->emailTemplate->renderText();
$this->emailTemplate->addBodySender('Alice Martin');
$this->emailTemplate->addBodyNote('Note');
$this->emailTemplate->addBodyDetails(new EMailDetails('Details'));
$this->emailTemplate->addBodyButtons([['text' => 'Open', 'url' => 'https://example.org']]);
$this->assertSame($html, $this->emailTemplate->renderHtml());
$this->assertSame($text, $this->emailTemplate->renderText());
}
public function testEMailTemplateDefaultFooterOnBehalfOfSender(): void {
$this->defaults->method('getDefaultColorPrimary')->willReturn('#0082c9');
$this->defaults->method('getDefaultTextColorPrimary')->willReturn('#ffffff');
$this->defaults->method('getName')->willReturn('TestCloud');
$this->defaults->method('getSlogan')->willReturn('A safe home for your data');
$this->urlGenerator->method('getAbsoluteURL')->with('/')->willReturn('https://cloud.example.org/');
$l10n = $this->createMock(IL10N::class);
$l10n->method('t')->willReturnCallback(fn (string $text, array $parameters = []): string => vsprintf($text, $parameters));
$factory = $this->createMock(IFactory::class);
$factory->method('get')->willReturn($l10n);
$template = new EMailTemplate($this->defaults, $this->urlGenerator, $factory, 252, 120, 'test.TestTemplate', []);
$template->addBodySender('Tom & Jerry', 'tom@example.org');
$template->addFooter();
$html = $template->renderHtml();
$this->assertStringContainsString('This email was sent from <a class="nc-link" href="https://cloud.example.org/" style="color:inherit">cloud.example.org</a> on behalf of Tom &amp; Jerry.', $html);
$this->assertStringNotContainsString('please do not reply', $html);
$this->assertStringEndsWith(
'-- ' . PHP_EOL . 'TestCloud - A safe home for your data' . PHP_EOL . 'This email was sent from cloud.example.org on behalf of Tom & Jerry.',
$template->renderText(),
);
}
public function testEMailTemplateCustomFooterWinsOverSender(): void {
$this->mockThemingColors();
$this->emailTemplate->addBodySender('Alice Martin');
$this->emailTemplate->addFooter('Custom footer');
$this->assertStringNotContainsString('on behalf of', $this->emailTemplate->renderHtml());
$this->assertStringEndsWith('-- ' . PHP_EOL . 'Custom footer', $this->emailTemplate->renderText());
}
private function renderBlocks(EMailTemplate $template): void {
$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, left wing');
$template->addHeader();
$template->addBodySender('Alice Martin', 'alice.martin@example.org');
$template->addHeading('Alice Martin invited you to an event');
$template->addBodyNote('Turn left after the entrance.', 'Description');
$template->addBodyNote('The password is sent in a separate email.', 'This share is password protected.', IEMailTemplate::NOTE_WARNING);
$template->addBodyDetails($details);
$template->addBodyButtons([
['text' => 'Accept', 'url' => 'https://example.org/invitation/accept'],
['text' => 'Decline', 'url' => 'https://example.org/invitation/decline'],
]);
$template->addFooter('TestCloud - A safe home for your data');
}
public function testEMailTemplateLanguageLtr(): void {
$this->mockThemingColors();
$this->l10n->method('getLanguageDirection')->with('pt_BR')->willReturn('ltr');
$this->emailTemplate->setLanguage('pt_BR');
$this->renderBlocks($this->emailTemplate);
$html = $this->emailTemplate->renderHtml();
$this->assertStringContainsString('lang="pt-BR" xml:lang="pt-BR" dir="ltr"', $html);
$this->assertStringContainsString('border-left:4px solid', $html);
$this->assertStringNotContainsString('border-right:4px solid', $html);
}
public function testEMailTemplateLanguageRtl(): void {
$this->mockThemingColors();
$this->l10n->method('getLanguageDirection')->with('ar')->willReturn('rtl');
$this->emailTemplate->setLanguage('ar');
$this->renderBlocks($this->emailTemplate);
$expectedHTML = file_get_contents(\OC::$SERVERROOT . '/tests/data/emails/blocks-email-rtl.html');
$this->assertSame($expectedHTML, $this->emailTemplate->renderHtml());
}
public function testEMailTemplateRtlMirrorsOnlyStyles(): void {
$this->mockThemingColors();
$this->l10n->method('getLanguageDirection')->willReturn('rtl');
$this->emailTemplate->setLanguage('he');
$this->renderBlocks($this->emailTemplate);
$html = $this->emailTemplate->renderHtml();
$this->assertStringContainsString('lang="he" xml:lang="he" dir="rtl"', $html);
// Note bar, padding shorthands and the dark mode rules are mirrored
$this->assertStringContainsString('border-right:4px solid', $html);
$this->assertStringNotContainsString('border-left:4px solid', $html);
$this->assertStringContainsString('padding:12px 0 12px 20px', $html);
$this->assertStringContainsString('border-right-color:', $html);
// Text content is left alone
$this->assertStringContainsString('Turn left after the entrance.', $html);
$this->assertStringContainsString('Meeting room 2, left wing', $html);
$this->assertStringContainsString('Turn left after the entrance.', $this->emailTemplate->renderText());
}
public function testEMailTemplateDefaultFooterUsesLanguage(): void {
$this->mockThemingColors();
$factory = $this->createMock(IFactory::class);
$factory->expects($this->once())->method('get')->with('lib', 'ar')->willReturn($this->createMock(IL10N::class));
$factory->method('getLanguageDirection')->willReturn('rtl');
$template = new EMailTemplate($this->defaults, $this->urlGenerator, $factory, 252, 120, 'test.TestTemplate', []);
$template->setLanguage('ar');
$template->addFooter();
$template->renderHtml();
}
}