From dd4460ee1310d84db6a6e0d4aa6cdb492f6b8287 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sat, 25 Apr 2026 14:59:01 -0300 Subject: [PATCH 1/3] fix: normalize mail subjects to ASCII Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/MailService.php | 32 ++++++++++-- tests/php/Unit/Service/MailServiceTest.php | 59 ++++++++++++++++++++++ 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/lib/Service/MailService.php b/lib/Service/MailService.php index 5f24ff1d04..cf6d0c3a2a 100644 --- a/lib/Service/MailService.php +++ b/lib/Service/MailService.php @@ -21,6 +21,7 @@ class MailService { /** @var array */ private $files = []; + private const FALLBACK_MAIL_SUBJECT = 'LibreSign: Code to sign file'; public function __construct( private LoggerInterface $logger, @@ -42,13 +43,34 @@ private function getFileById(int $fileId): File { return $this->files[$fileId]; } + private function normalizeMailSubject(string $subject): string { + if (preg_match('/^[\x20-\x7E]+$/', $subject) === 1) { + return $subject; + } + + $normalized = $subject; + if (class_exists(\Normalizer::class)) { + $normalized = \Normalizer::normalize($normalized, \Normalizer::FORM_KD) ?: $normalized; + $normalized = preg_replace('/\p{Mn}+/u', '', $normalized) ?: $normalized; + } + + $ascii = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized); + if (!is_string($ascii) || trim($ascii) === '') { + return self::FALLBACK_MAIL_SUBJECT; + } + + $ascii = preg_replace('/[^\x20-\x7E]/', '', $ascii) ?: ''; + $ascii = trim(preg_replace('/\s+/', ' ', $ascii) ?: ''); + return $ascii !== '' ? $ascii : self::FALLBACK_MAIL_SUBJECT; + } + /** * @psalm-suppress MixedMethodCall */ public function notifySignDataUpdated(SignRequest $data, string $email, ?string $description = null): void { $emailTemplate = $this->mailer->createEMailTemplate('settings.TestEmail'); // TRANSLATORS The subject of the email that is sent after changes are made to the signature request that may affect something for the signer who will sign the document. Some possible reasons: URL for signature changed (when the URL expires), the person who requested the signature sent a notification - $emailTemplate->setSubject($this->l10n->t('LibreSign: Changes into a file for you to sign')); + $emailTemplate->setSubject($this->normalizeMailSubject($this->l10n->t('LibreSign: Changes into a file for you to sign'))); $emailTemplate->addHeader(); $emailTemplate->addHeading($this->l10n->t('File to sign'), false); @@ -84,7 +106,7 @@ public function notifySignDataUpdated(SignRequest $data, string $email, ?string */ public function notifyUnsignedUser(SignRequest $data, string $email, ?string $description = null): void { $emailTemplate = $this->mailer->createEMailTemplate('settings.TestEmail'); - $emailTemplate->setSubject($this->l10n->t('LibreSign: There is a file for you to sign')); + $emailTemplate->setSubject($this->normalizeMailSubject($this->l10n->t('LibreSign: There is a file for you to sign'))); $emailTemplate->addHeader(); $emailTemplate->addHeading($this->l10n->t('File to sign'), false); @@ -118,7 +140,7 @@ public function notifyUnsignedUser(SignRequest $data, string $email, ?string $de public function notifySignedUser(SignRequest $signRequest, string $email, File $libreSignFile, string $displayName): void { $emailTemplate = $this->mailer->createEMailTemplate('settings.TestEmail'); // TRANSLATORS The subject of the email that is sent after a document has been signed by a user. This email is sent to the person who requested the signature. - $emailTemplate->setSubject($this->l10n->t('LibreSign: A file has been signed')); + $emailTemplate->setSubject($this->normalizeMailSubject($this->l10n->t('LibreSign: A file has been signed'))); $emailTemplate->addHeader(); $emailTemplate->addHeading($this->l10n->t('File signed'), false); // TRANSLATORS The text in the email that is sent after a document has been signed by a user. %s will be replaced with the name of the user who signed the document. @@ -147,7 +169,7 @@ public function notifySignedUser(SignRequest $signRequest, string $email, File $ public function notifyCanceledRequest(SignRequest $signRequest, string $email, File $libreSignFile): void { $emailTemplate = $this->mailer->createEMailTemplate('settings.TestEmail'); // TRANSLATORS The subject of the email that is sent when a signature request has been canceled. - $emailTemplate->setSubject($this->l10n->t('LibreSign: A signature request has been canceled')); + $emailTemplate->setSubject($this->normalizeMailSubject($this->l10n->t('LibreSign: A signature request has been canceled'))); $emailTemplate->addHeader(); $emailTemplate->addHeading($this->l10n->t('Signature request canceled'), false); // TRANSLATORS The text in the email that is sent when a signature request has been canceled. %s will be replaced with the name of the file. @@ -169,7 +191,7 @@ public function notifyCanceledRequest(SignRequest $signRequest, string $email, F public function sendCodeToSign(string $email, string $name, string $code): void { $emailTemplate = $this->mailer->createEMailTemplate('settings.TestEmail'); - $emailTemplate->setSubject($this->l10n->t('LibreSign: Code to sign file')); + $emailTemplate->setSubject($this->normalizeMailSubject($this->l10n->t('LibreSign: Code to sign file'))); $emailTemplate->addHeader(); $emailTemplate->addBodyText($this->l10n->t('Use this code to sign the document:')); $emailTemplate->addBodyText($code); diff --git a/tests/php/Unit/Service/MailServiceTest.php b/tests/php/Unit/Service/MailServiceTest.php index 45a4d69105..ca70ac70b1 100644 --- a/tests/php/Unit/Service/MailServiceTest.php +++ b/tests/php/Unit/Service/MailServiceTest.php @@ -15,7 +15,9 @@ use OCP\IAppConfig; use OCP\IL10N; use OCP\IURLGenerator; +use OCP\Mail\IEMailTemplate; use OCP\Mail\IMailer; +use OCP\Mail\IMessage; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; @@ -113,4 +115,61 @@ public function testFailToSendMailToUnsignedUser():void { $actual = $this->service->notifyUnsignedUser($signRequest, 'a@b.coop'); $this->assertNull($actual); } + + public function testSendCodeToSignNormalizesAccentedSubjectToAscii(): void { + $l10n = $this->createMock(IL10N::class); + $l10n + ->method('t') + ->willReturnCallback(static fn (string $text): string + => $text === 'LibreSign: Code to sign file' + ? 'LibreSign : Code nécessaire à la signature du fichier' + : $text + ); + + $service = new MailService( + $this->logger, + $this->mailer, + $this->fileMapper, + $l10n, + $this->urlGenerator, + $this->appConfig + ); + + $emailTemplate = $this->createMock(IEMailTemplate::class); + $emailTemplate + ->expects($this->once()) + ->method('setSubject') + ->with('LibreSign : Code necessaire a la signature du fichier'); + $emailTemplate + ->expects($this->once()) + ->method('addHeader'); + $emailTemplate + ->expects($this->exactly(2)) + ->method('addBodyText'); + + $message = $this->createMock(IMessage::class); + $message + ->method('setTo') + ->willReturnSelf(); + $message + ->method('useTemplate') + ->willReturnSelf(); + + $this->mailer + ->expects($this->once()) + ->method('createEMailTemplate') + ->with('settings.TestEmail') + ->willReturn($emailTemplate); + $this->mailer + ->expects($this->once()) + ->method('createMessage') + ->willReturn($message); + $this->mailer + ->expects($this->once()) + ->method('send') + ->with($message) + ->willReturn([]); + + $service->sendCodeToSign('a@b.coop', 'John Doe', '123456'); + } } From 262aa335d774f37065408d38bc85a92aaed8bc06 Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:00:33 -0300 Subject: [PATCH 2/3] fix: encode localized mail subjects as MIME headers Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/MailService.php | 40 +++++++++++++--------- tests/php/Unit/Service/MailServiceTest.php | 9 +++-- 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/lib/Service/MailService.php b/lib/Service/MailService.php index cf6d0c3a2a..3e48d15415 100644 --- a/lib/Service/MailService.php +++ b/lib/Service/MailService.php @@ -21,7 +21,6 @@ class MailService { /** @var array */ private $files = []; - private const FALLBACK_MAIL_SUBJECT = 'LibreSign: Code to sign file'; public function __construct( private LoggerInterface $logger, @@ -43,25 +42,32 @@ private function getFileById(int $fileId): File { return $this->files[$fileId]; } - private function normalizeMailSubject(string $subject): string { + private function encodeMailSubject(string $subject): string { if (preg_match('/^[\x20-\x7E]+$/', $subject) === 1) { return $subject; } - $normalized = $subject; - if (class_exists(\Normalizer::class)) { - $normalized = \Normalizer::normalize($normalized, \Normalizer::FORM_KD) ?: $normalized; - $normalized = preg_replace('/\p{Mn}+/u', '', $normalized) ?: $normalized; + if (function_exists('mb_encode_mimeheader')) { + $encoded = mb_encode_mimeheader($subject, 'UTF-8', 'B', "\r\n"); + if (is_string($encoded) && $encoded !== '') { + return str_replace(["\r", "\n"], '', $encoded); + } } - $ascii = iconv('UTF-8', 'ASCII//TRANSLIT//IGNORE', $normalized); - if (!is_string($ascii) || trim($ascii) === '') { - return self::FALLBACK_MAIL_SUBJECT; + if (function_exists('iconv_mime_encode')) { + $encoded = iconv_mime_encode('Subject', $subject, [ + 'scheme' => 'B', + 'input-charset' => 'UTF-8', + 'output-charset' => 'UTF-8', + 'line-length' => 76, + 'line-break-chars' => "\r\n", + ]); + if (is_string($encoded) && str_starts_with($encoded, 'Subject: ')) { + return str_replace(["\r", "\n"], '', substr($encoded, 9)); + } } - $ascii = preg_replace('/[^\x20-\x7E]/', '', $ascii) ?: ''; - $ascii = trim(preg_replace('/\s+/', ' ', $ascii) ?: ''); - return $ascii !== '' ? $ascii : self::FALLBACK_MAIL_SUBJECT; + return $subject; } /** @@ -70,7 +76,7 @@ private function normalizeMailSubject(string $subject): string { public function notifySignDataUpdated(SignRequest $data, string $email, ?string $description = null): void { $emailTemplate = $this->mailer->createEMailTemplate('settings.TestEmail'); // TRANSLATORS The subject of the email that is sent after changes are made to the signature request that may affect something for the signer who will sign the document. Some possible reasons: URL for signature changed (when the URL expires), the person who requested the signature sent a notification - $emailTemplate->setSubject($this->normalizeMailSubject($this->l10n->t('LibreSign: Changes into a file for you to sign'))); + $emailTemplate->setSubject($this->encodeMailSubject($this->l10n->t('LibreSign: Changes into a file for you to sign'))); $emailTemplate->addHeader(); $emailTemplate->addHeading($this->l10n->t('File to sign'), false); @@ -106,7 +112,7 @@ public function notifySignDataUpdated(SignRequest $data, string $email, ?string */ public function notifyUnsignedUser(SignRequest $data, string $email, ?string $description = null): void { $emailTemplate = $this->mailer->createEMailTemplate('settings.TestEmail'); - $emailTemplate->setSubject($this->normalizeMailSubject($this->l10n->t('LibreSign: There is a file for you to sign'))); + $emailTemplate->setSubject($this->encodeMailSubject($this->l10n->t('LibreSign: There is a file for you to sign'))); $emailTemplate->addHeader(); $emailTemplate->addHeading($this->l10n->t('File to sign'), false); @@ -140,7 +146,7 @@ public function notifyUnsignedUser(SignRequest $data, string $email, ?string $de public function notifySignedUser(SignRequest $signRequest, string $email, File $libreSignFile, string $displayName): void { $emailTemplate = $this->mailer->createEMailTemplate('settings.TestEmail'); // TRANSLATORS The subject of the email that is sent after a document has been signed by a user. This email is sent to the person who requested the signature. - $emailTemplate->setSubject($this->normalizeMailSubject($this->l10n->t('LibreSign: A file has been signed'))); + $emailTemplate->setSubject($this->encodeMailSubject($this->l10n->t('LibreSign: A file has been signed'))); $emailTemplate->addHeader(); $emailTemplate->addHeading($this->l10n->t('File signed'), false); // TRANSLATORS The text in the email that is sent after a document has been signed by a user. %s will be replaced with the name of the user who signed the document. @@ -169,7 +175,7 @@ public function notifySignedUser(SignRequest $signRequest, string $email, File $ public function notifyCanceledRequest(SignRequest $signRequest, string $email, File $libreSignFile): void { $emailTemplate = $this->mailer->createEMailTemplate('settings.TestEmail'); // TRANSLATORS The subject of the email that is sent when a signature request has been canceled. - $emailTemplate->setSubject($this->normalizeMailSubject($this->l10n->t('LibreSign: A signature request has been canceled'))); + $emailTemplate->setSubject($this->encodeMailSubject($this->l10n->t('LibreSign: A signature request has been canceled'))); $emailTemplate->addHeader(); $emailTemplate->addHeading($this->l10n->t('Signature request canceled'), false); // TRANSLATORS The text in the email that is sent when a signature request has been canceled. %s will be replaced with the name of the file. @@ -191,7 +197,7 @@ public function notifyCanceledRequest(SignRequest $signRequest, string $email, F public function sendCodeToSign(string $email, string $name, string $code): void { $emailTemplate = $this->mailer->createEMailTemplate('settings.TestEmail'); - $emailTemplate->setSubject($this->normalizeMailSubject($this->l10n->t('LibreSign: Code to sign file'))); + $emailTemplate->setSubject($this->encodeMailSubject($this->l10n->t('LibreSign: Code to sign file'))); $emailTemplate->addHeader(); $emailTemplate->addBodyText($this->l10n->t('Use this code to sign the document:')); $emailTemplate->addBodyText($code); diff --git a/tests/php/Unit/Service/MailServiceTest.php b/tests/php/Unit/Service/MailServiceTest.php index ca70ac70b1..8eee01ca28 100644 --- a/tests/php/Unit/Service/MailServiceTest.php +++ b/tests/php/Unit/Service/MailServiceTest.php @@ -116,7 +116,7 @@ public function testFailToSendMailToUnsignedUser():void { $this->assertNull($actual); } - public function testSendCodeToSignNormalizesAccentedSubjectToAscii(): void { + public function testSendCodeToSignEncodesAccentedSubjectAsMimeHeader(): void { $l10n = $this->createMock(IL10N::class); $l10n ->method('t') @@ -139,7 +139,12 @@ public function testSendCodeToSignNormalizesAccentedSubjectToAscii(): void { $emailTemplate ->expects($this->once()) ->method('setSubject') - ->with('LibreSign : Code necessaire a la signature du fichier'); + ->with($this->callback(static function (string $subject): bool { + if (preg_match('/^[\x20-\x7E]+$/', $subject) !== 1) { + return false; + } + return str_contains($subject, '=?UTF-8?B?') || str_contains($subject, '=?UTF-8?Q?'); + })); $emailTemplate ->expects($this->once()) ->method('addHeader'); From cc0a8bf9abe09ef43602888ac27ec9cb7a15b40f Mon Sep 17 00:00:00 2001 From: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> Date: Sat, 25 Apr 2026 15:03:24 -0300 Subject: [PATCH 3/3] fix: keep localized mail subject unchanged Signed-off-by: Vitor Mattos <1079143+vitormattos@users.noreply.github.com> --- lib/Service/MailService.php | 38 +++------------------- tests/php/Unit/Service/MailServiceTest.php | 9 ++--- 2 files changed, 7 insertions(+), 40 deletions(-) diff --git a/lib/Service/MailService.php b/lib/Service/MailService.php index 3e48d15415..5f24ff1d04 100644 --- a/lib/Service/MailService.php +++ b/lib/Service/MailService.php @@ -42,41 +42,13 @@ private function getFileById(int $fileId): File { return $this->files[$fileId]; } - private function encodeMailSubject(string $subject): string { - if (preg_match('/^[\x20-\x7E]+$/', $subject) === 1) { - return $subject; - } - - if (function_exists('mb_encode_mimeheader')) { - $encoded = mb_encode_mimeheader($subject, 'UTF-8', 'B', "\r\n"); - if (is_string($encoded) && $encoded !== '') { - return str_replace(["\r", "\n"], '', $encoded); - } - } - - if (function_exists('iconv_mime_encode')) { - $encoded = iconv_mime_encode('Subject', $subject, [ - 'scheme' => 'B', - 'input-charset' => 'UTF-8', - 'output-charset' => 'UTF-8', - 'line-length' => 76, - 'line-break-chars' => "\r\n", - ]); - if (is_string($encoded) && str_starts_with($encoded, 'Subject: ')) { - return str_replace(["\r", "\n"], '', substr($encoded, 9)); - } - } - - return $subject; - } - /** * @psalm-suppress MixedMethodCall */ public function notifySignDataUpdated(SignRequest $data, string $email, ?string $description = null): void { $emailTemplate = $this->mailer->createEMailTemplate('settings.TestEmail'); // TRANSLATORS The subject of the email that is sent after changes are made to the signature request that may affect something for the signer who will sign the document. Some possible reasons: URL for signature changed (when the URL expires), the person who requested the signature sent a notification - $emailTemplate->setSubject($this->encodeMailSubject($this->l10n->t('LibreSign: Changes into a file for you to sign'))); + $emailTemplate->setSubject($this->l10n->t('LibreSign: Changes into a file for you to sign')); $emailTemplate->addHeader(); $emailTemplate->addHeading($this->l10n->t('File to sign'), false); @@ -112,7 +84,7 @@ public function notifySignDataUpdated(SignRequest $data, string $email, ?string */ public function notifyUnsignedUser(SignRequest $data, string $email, ?string $description = null): void { $emailTemplate = $this->mailer->createEMailTemplate('settings.TestEmail'); - $emailTemplate->setSubject($this->encodeMailSubject($this->l10n->t('LibreSign: There is a file for you to sign'))); + $emailTemplate->setSubject($this->l10n->t('LibreSign: There is a file for you to sign')); $emailTemplate->addHeader(); $emailTemplate->addHeading($this->l10n->t('File to sign'), false); @@ -146,7 +118,7 @@ public function notifyUnsignedUser(SignRequest $data, string $email, ?string $de public function notifySignedUser(SignRequest $signRequest, string $email, File $libreSignFile, string $displayName): void { $emailTemplate = $this->mailer->createEMailTemplate('settings.TestEmail'); // TRANSLATORS The subject of the email that is sent after a document has been signed by a user. This email is sent to the person who requested the signature. - $emailTemplate->setSubject($this->encodeMailSubject($this->l10n->t('LibreSign: A file has been signed'))); + $emailTemplate->setSubject($this->l10n->t('LibreSign: A file has been signed')); $emailTemplate->addHeader(); $emailTemplate->addHeading($this->l10n->t('File signed'), false); // TRANSLATORS The text in the email that is sent after a document has been signed by a user. %s will be replaced with the name of the user who signed the document. @@ -175,7 +147,7 @@ public function notifySignedUser(SignRequest $signRequest, string $email, File $ public function notifyCanceledRequest(SignRequest $signRequest, string $email, File $libreSignFile): void { $emailTemplate = $this->mailer->createEMailTemplate('settings.TestEmail'); // TRANSLATORS The subject of the email that is sent when a signature request has been canceled. - $emailTemplate->setSubject($this->encodeMailSubject($this->l10n->t('LibreSign: A signature request has been canceled'))); + $emailTemplate->setSubject($this->l10n->t('LibreSign: A signature request has been canceled')); $emailTemplate->addHeader(); $emailTemplate->addHeading($this->l10n->t('Signature request canceled'), false); // TRANSLATORS The text in the email that is sent when a signature request has been canceled. %s will be replaced with the name of the file. @@ -197,7 +169,7 @@ public function notifyCanceledRequest(SignRequest $signRequest, string $email, F public function sendCodeToSign(string $email, string $name, string $code): void { $emailTemplate = $this->mailer->createEMailTemplate('settings.TestEmail'); - $emailTemplate->setSubject($this->encodeMailSubject($this->l10n->t('LibreSign: Code to sign file'))); + $emailTemplate->setSubject($this->l10n->t('LibreSign: Code to sign file')); $emailTemplate->addHeader(); $emailTemplate->addBodyText($this->l10n->t('Use this code to sign the document:')); $emailTemplate->addBodyText($code); diff --git a/tests/php/Unit/Service/MailServiceTest.php b/tests/php/Unit/Service/MailServiceTest.php index 8eee01ca28..41bf4a3c14 100644 --- a/tests/php/Unit/Service/MailServiceTest.php +++ b/tests/php/Unit/Service/MailServiceTest.php @@ -116,7 +116,7 @@ public function testFailToSendMailToUnsignedUser():void { $this->assertNull($actual); } - public function testSendCodeToSignEncodesAccentedSubjectAsMimeHeader(): void { + public function testSendCodeToSignUsesLocalizedSubjectWithoutMutation(): void { $l10n = $this->createMock(IL10N::class); $l10n ->method('t') @@ -139,12 +139,7 @@ public function testSendCodeToSignEncodesAccentedSubjectAsMimeHeader(): void { $emailTemplate ->expects($this->once()) ->method('setSubject') - ->with($this->callback(static function (string $subject): bool { - if (preg_match('/^[\x20-\x7E]+$/', $subject) !== 1) { - return false; - } - return str_contains($subject, '=?UTF-8?B?') || str_contains($subject, '=?UTF-8?Q?'); - })); + ->with('LibreSign : Code nécessaire à la signature du fichier'); $emailTemplate ->expects($this->once()) ->method('addHeader');