From 3fd6a4f9c629c67b5a8d9211ce19c4ec4a3b6f91 Mon Sep 17 00:00:00 2001 From: klaussalminenq5 Date: Sun, 11 Oct 2026 19:05:34 +0200 Subject: [PATCH 1/5] fix: embed inline logo in guest invitation emails Use IMessage::useTemplate() so inline images from EMailTemplate (the cid:logo theming logo since Nextcloud 34) are attached. Signed-off-by:Klaus Salminen klaus@andqfive.com Signed-off-by: klaussalminenq5 --- lib/Mail.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/Mail.php b/lib/Mail.php index ead0be5b3..19cfd1940 100644 --- a/lib/Mail.php +++ b/lib/Mail.php @@ -70,8 +70,8 @@ public function sendGuestInviteMail(string $uid, string $guest, string $token, s $message = $this->mailer->createMessage(); $message->setTo([$guestEmail => $targetUser->getDisplayName()]); $message->setSubject($subject); - $message->setHtmlBody($emailTemplate->renderHtml()); - $message->setPlainBody($emailTemplate->renderText()); + $message->useTemplate($emailTemplate); + $message->setSubject($subject); $message->setFrom([ Util::getDefaultEmailAddress('sharing-noreply') => $l10n->t('%s via %s', [ From 0aa348fa2e29752aaaa7db4125f064740255c469 Mon Sep 17 00:00:00 2001 From: klaussalminenq5 Date: Sun, 11 Oct 2026 19:07:47 +0200 Subject: [PATCH 2/5] Update Mail.php Signed-off-by: klaussalminenq5 --- lib/Mail.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Mail.php b/lib/Mail.php index 19cfd1940..e76c9d1bd 100644 --- a/lib/Mail.php +++ b/lib/Mail.php @@ -71,7 +71,7 @@ public function sendGuestInviteMail(string $uid, string $guest, string $token, s $message->setTo([$guestEmail => $targetUser->getDisplayName()]); $message->setSubject($subject); $message->useTemplate($emailTemplate); - $message->setSubject($subject); + $message->setSubject($subject); $message->setFrom([ Util::getDefaultEmailAddress('sharing-noreply') => $l10n->t('%s via %s', [ From bbdc35dc57b102aef85cc9b0b181a7ac1d213579 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Molakvo=C3=A6?= <14975046+skjnldsv@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:26:24 +0200 Subject: [PATCH 3/5] fix(mail): set the guest invitation subject on the template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useTemplate() overwrites the message subject with the template one. Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: John Molakvoæ <14975046+skjnldsv@users.noreply.github.com> --- lib/Mail.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/Mail.php b/lib/Mail.php index e76c9d1bd..e75d052ae 100644 --- a/lib/Mail.php +++ b/lib/Mail.php @@ -69,9 +69,8 @@ public function sendGuestInviteMail(string $uid, string $guest, string $token, s try { $message = $this->mailer->createMessage(); $message->setTo([$guestEmail => $targetUser->getDisplayName()]); - $message->setSubject($subject); + $emailTemplate->setSubject($subject); $message->useTemplate($emailTemplate); - $message->setSubject($subject); $message->setFrom([ Util::getDefaultEmailAddress('sharing-noreply') => $l10n->t('%s via %s', [ From 772f1fd159c70fa2e7343b7f395fe15faf6d1e47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Molakvo=C3=A6?= <14975046+skjnldsv@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:26:58 +0200 Subject: [PATCH 4/5] feat(mail): use the new email template blocks for guest invitations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: John Molakvoæ <14975046+skjnldsv@users.noreply.github.com> --- appinfo/info.xml | 2 +- lib/Mail.php | 97 +++++++++++------------------- tests/unit/MailTest.php | 130 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 166 insertions(+), 63 deletions(-) create mode 100644 tests/unit/MailTest.php diff --git a/appinfo/info.xml b/appinfo/info.xml index 596abe233..fb7adfe3c 100755 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -31,7 +31,7 @@ Guests users can only access files shared to them and cannot create any files ou https://raw.githubusercontent.com/nextcloud/guests/master/screenshots/settings.png https://raw.githubusercontent.com/nextcloud/guests/master/screenshots/dropdown.png - + diff --git a/lib/Mail.php b/lib/Mail.php index e75d052ae..190720f00 100644 --- a/lib/Mail.php +++ b/lib/Mail.php @@ -16,6 +16,7 @@ use OCP\IUserManager; use OCP\IUserSession; use OCP\L10N\IFactory; +use OCP\Mail\EMailDetails; use OCP\Mail\IEMailTemplate; use OCP\Mail\IMailer; use OCP\Share\IShare; @@ -61,15 +62,14 @@ public function sendGuestInviteMail(string $uid, string $guest, string $token, s $senderDisplayName = $this->userSession->getUser()->getDisplayName(); if (!$share instanceof IShare) { - [ $subject, $emailTemplate ] = $this->composeInviteMessage($senderDisplayName, $guestEmail, $passwordLink, $l10n); + $emailTemplate = $this->composeInviteMessage($senderDisplayName, $replyTo, $guestEmail, $passwordLink, $l10n); } else { - [ $subject, $emailTemplate ] = $this->composeShareMessage($share, $senderDisplayName, $guestEmail, $passwordLink, $l10n); + $emailTemplate = $this->composeShareMessage($share, $senderDisplayName, $replyTo, $guestEmail, $passwordLink, $l10n); } try { $message = $this->mailer->createMessage(); $message->setTo([$guestEmail => $targetUser->getDisplayName()]); - $emailTemplate->setSubject($subject); $message->useTemplate($emailTemplate); $message->setFrom([ Util::getDefaultEmailAddress('sharing-noreply') @@ -91,87 +91,60 @@ public function sendGuestInviteMail(string $uid, string $guest, string $token, s } } - /** - * @return array{string, IEMailTemplate} - */ - private function composeShareMessage(IShare $share, string $senderDisplayName, string $guestEmail, string $passwordLink, IL10N $l10n): array { + private function composeShareMessage(IShare $share, string $senderDisplayName, ?string $senderEmail, string $guestEmail, string $passwordLink, IL10N $l10n): IEMailTemplate { $filename = trim($share->getTarget(), '/'); - $subject = $l10n->t('%s shared a file with you', [$senderDisplayName]); $expiration = $share->getExpirationDate(); + $note = $share->getNote(); $link = $this->urlGenerator->linkToRouteAbsolute( 'files.viewcontroller.showFile', ['fileid' => $share->getNodeId(), 'direct' => 1] ); $emailTemplate = $this->mailer->createEMailTemplate('guest.share'); + $emailTemplate->setLanguage($l10n->getLanguageCode()); + $emailTemplate->setSubject($l10n->t('%1$s shared %2$s with you', [$senderDisplayName, $filename])); $emailTemplate->addHeader(); - $emailTemplate->addHeading($l10n->t('%s shared a file with you', [$senderDisplayName])); - - $emailTemplate->addBodyText( - $l10n->t('Hey there,') - ); - - $emailTemplate->addBodyText( - $l10n->t('%1$s just invited you and shared »%2$s« with you.', [$senderDisplayName, $filename]) - ); + $emailTemplate->addBodySender($senderDisplayName, $senderEmail ?? ''); + $emailTemplate->addHeading($l10n->t('%1$s shared %2$s with you', [$senderDisplayName, $filename])); - $emailTemplate->addBodyText( - $l10n->t('You can access the shared file by activating your guest account.') - ); - $emailTemplate->addBodyText( - $l10n->t('After your account is activated you can view the share by logging in with %s.', [$guestEmail]) - ); - - if ($expiration) { - $formattedDate = $l10n->l('date', $expiration); - $emailTemplate->addBodyText( - $l10n->t('The share will expire at %s.', [$formattedDate]) - ); + if ($note !== '') { + $emailTemplate->addBodyNote($note, $l10n->t('Note')); } - $emailTemplate->addBodyButtonGroup( - $l10n->t('Activate account'), - $passwordLink, - $l10n->t('View share'), - $link - ); + $details = new EMailDetails($filename); + if ($expiration !== null) { + $details->addRow($l10n->t('Valid until'))->text((string)$l10n->l('date', $expiration, ['width' => 'medium'])); + } + $details->addRow($l10n->t('Your login'))->text($guestEmail); + $emailTemplate->addBodyDetails($details); + + $emailTemplate->addBodyText($l10n->t('Activate your guest account to open it.')); + $emailTemplate->addBodyButtons([ + ['text' => $l10n->t('Activate account'), 'url' => $passwordLink], + ['text' => $l10n->t('View share'), 'url' => $link], + ]); $emailTemplate->addFooter(); - return [ $subject, $emailTemplate ]; + return $emailTemplate; } - /** - * @return array{string, IEMailTemplate} - */ - private function composeInviteMessage(string $senderDisplayName, string $guestEmail, string $passwordLink, IL10N $l10n): array { - $subject = $l10n->t('%s invited you as a guest', [$senderDisplayName]); - + private function composeInviteMessage(string $senderDisplayName, ?string $senderEmail, string $guestEmail, string $passwordLink, IL10N $l10n): IEMailTemplate { $emailTemplate = $this->mailer->createEMailTemplate('guest.invite'); + $emailTemplate->setLanguage($l10n->getLanguageCode()); + $emailTemplate->setSubject($l10n->t('%s invited you as a guest', [$senderDisplayName])); $emailTemplate->addHeader(); - $emailTemplate->addHeading($l10n->t('You have been invited')); - - $emailTemplate->addBodyText( - $l10n->t('Hey there,') - ); + $emailTemplate->addBodySender($senderDisplayName, $senderEmail ?? ''); + $emailTemplate->addHeading($l10n->t('%s invited you as a guest', [$senderDisplayName])); - $emailTemplate->addBodyText( - $l10n->t('%s just invited you.', [$senderDisplayName]) - ); + $details = new EMailDetails($this->defaults->getName()); + $details->addRow($l10n->t('Your login'))->text($guestEmail); + $emailTemplate->addBodyDetails($details); - $emailTemplate->addBodyText( - $l10n->t('You can activate your guest account with the button below.') - ); - $emailTemplate->addBodyText( - $l10n->t('After your account is activated you can log in with %s.', [$guestEmail]) - ); - - $emailTemplate->addBodyButton( - $l10n->t('Activate account'), - $passwordLink - ); + $emailTemplate->addBodyText($l10n->t('Activate your guest account to get started.')); + $emailTemplate->addBodyButton($l10n->t('Activate account'), $passwordLink); $emailTemplate->addFooter(); - return [ $subject, $emailTemplate ]; + return $emailTemplate; } } diff --git a/tests/unit/MailTest.php b/tests/unit/MailTest.php new file mode 100644 index 000000000..fb5f1bb9e --- /dev/null +++ b/tests/unit/MailTest.php @@ -0,0 +1,130 @@ +mailer = $this->createMock(IMailer::class); + $this->mailer->method('createEMailTemplate') + ->willReturnCallback(fn (string $emailId) => Server::get(IMailer::class)->createEMailTemplate($emailId)); + $message = $this->createMock(IMessage::class); + $message->method('useTemplate') + ->willReturnCallback(function (IEMailTemplate $template) use ($message) { + $this->sentTemplate = $template; + return $message; + }); + $this->mailer->method('createMessage')->willReturn($message); + + $sender = $this->createMock(IUser::class); + $sender->method('getDisplayName')->willReturn('Alice Martin'); + $sender->method('getEMailAddress')->willReturn('alice@example.com'); + $guest = $this->createMock(IUser::class); + $guest->method('getDisplayName')->willReturn('Jordan'); + $guest->method('getEMailAddress')->willReturn('jordan@partner.example'); + + $this->userManager = $this->createMock(IUserManager::class); + $this->userManager->method('get')->willReturnMap([ + ['alice', $sender], + ['jordan@partner.example', $guest], + ]); + $userSession = $this->createMock(IUserSession::class); + $userSession->method('getUser')->willReturn($sender); + + $defaults = $this->createMock(Defaults::class); + $defaults->method('getName')->willReturn('Nextcloud'); + + $urlGenerator = $this->createMock(IURLGenerator::class); + $urlGenerator->method('linkToRouteAbsolute')->willReturnMap([ + ['core.lost.resetform', ['userId' => 'jordan@partner.example', 'token' => 'token'], 'https://cloud.example.com/reset'], + ['files.viewcontroller.showFile', ['fileid' => 42, 'direct' => 1], 'https://cloud.example.com/f/42'], + ]); + + $this->mail = new Mail( + $userSession, + $this->mailer, + $defaults, + Server::get(IFactory::class), + $this->userManager, + $urlGenerator, + ); + } + + private function createShare(string $note, ?\DateTime $expiration): IShare&MockObject { + $share = $this->createMock(IShare::class); + $share->method('getTarget')->willReturn('/Budget.xlsx'); + $share->method('getNodeId')->willReturn(42); + $share->method('getNote')->willReturn($note); + $share->method('getExpirationDate')->willReturn($expiration); + return $share; + } + + public function testShareMail(): void { + $share = $this->createShare("Can you check\nthe travel line?", new \DateTime('2026-11-15')); + $this->mail->sendGuestInviteMail('alice', 'jordan@partner.example', 'token', 'en', $share); + + $this->assertSame('Alice Martin shared Budget.xlsx with you', $this->sentTemplate->renderSubject()); + $text = $this->sentTemplate->renderText(); + $this->assertStringContainsString('Alice Martin (alice@example.com)', $text); + $this->assertStringContainsString("> Can you check\n> the travel line?", $text); + $this->assertStringContainsString('Valid until: Nov 15, 2026', $text); + $this->assertStringContainsString('Your login: jordan@partner.example', $text); + $this->assertStringContainsString('Activate account: https://cloud.example.com/reset', $text); + $this->assertStringContainsString('View share: https://cloud.example.com/f/42', $text); + } + + public function testShareMailWithoutNoteAndExpiration(): void { + $share = $this->createShare('', null); + $this->mail->sendGuestInviteMail('alice', 'jordan@partner.example', 'token', 'en', $share); + + $text = $this->sentTemplate->renderText(); + $this->assertStringNotContainsString('Note', $text); + $this->assertStringNotContainsString('Valid until', $text); + $this->assertStringContainsString('Your login: jordan@partner.example', $text); + } + + public function testInviteMail(): void { + $this->mail->sendGuestInviteMail('alice', 'jordan@partner.example', 'token', 'en'); + + $this->assertSame('Alice Martin invited you as a guest', $this->sentTemplate->renderSubject()); + $text = $this->sentTemplate->renderText(); + $this->assertStringContainsString('Alice Martin (alice@example.com)', $text); + $this->assertStringContainsString('Your login: jordan@partner.example', $text); + $this->assertStringContainsString('Activate account: https://cloud.example.com/reset', $text); + $this->assertStringNotContainsString('View share', $text); + } + + public function testRightToLeftLanguage(): void { + $this->mail->sendGuestInviteMail('alice', 'jordan@partner.example', 'token', 'ar'); + + $this->assertStringContainsString('lang="ar" xml:lang="ar" dir="rtl"', $this->sentTemplate->renderHtml()); + } +} From 78bcdd3d26c8c05f2ed500c37227a829d3d259d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?John=20Molakvo=C3=A6?= <14975046+skjnldsv@users.noreply.github.com> Date: Sun, 11 Oct 2026 22:36:09 +0200 Subject: [PATCH 5/5] chore(psalm): raise PHP version to 8.3 for Nextcloud 36 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: ClaudeCode:claude-opus-5-5 Signed-off-by: John Molakvoæ <14975046+skjnldsv@users.noreply.github.com> --- psalm.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/psalm.xml b/psalm.xml index 16f7e79cb..e6a608e56 100644 --- a/psalm.xml +++ b/psalm.xml @@ -12,7 +12,7 @@ findUnusedIssueHandlerSuppression="false" findUnusedBaselineEntry="false" findUnusedCode="false" - phpVersion="8.1" + phpVersion="8.3" resolveFromConfigFile="true" totallyTyped="true" >