diff --git a/apps/sharebymail/lib/ShareByMailProvider.php b/apps/sharebymail/lib/ShareByMailProvider.php index f604cebc17a5a..6fa726e1457df 100644 --- a/apps/sharebymail/lib/ShareByMailProvider.php +++ b/apps/sharebymail/lib/ShareByMailProvider.php @@ -19,6 +19,7 @@ use OCP\Files\IRootFolder; use OCP\Files\Node; use OCP\HintException; +use OCP\IAppConfig; use OCP\IConfig; use OCP\IDBConnection; use OCP\IL10N; @@ -27,6 +28,7 @@ use OCP\IUserManager; use OCP\Mail\IEmailValidator; use OCP\Mail\IMailer; +use OCP\Mail\Provider\Address; use OCP\Security\Events\GenerateSecurePasswordEvent; use OCP\Security\IHasher; use OCP\Security\ISecureRandom; @@ -39,6 +41,8 @@ use OCP\Share\IShareProviderWithNotification; use OCP\Util; use Psr\Log\LoggerInterface; +use OCP\Mail\Provider\IManager as IMailManager; +use OCP\Mail\Provider\IMessageSend; /** * Class ShareByMail @@ -56,6 +60,7 @@ public function identifier(): string { } public function __construct( + private IAppConfig $appConfig, private IConfig $config, private IDBConnection $dbConnection, private ISecureRandom $secureRandom, @@ -63,7 +68,8 @@ public function __construct( private IRootFolder $rootFolder, private IL10N $l, private LoggerInterface $logger, - private IMailer $mailer, + private IMailer $systemMailer, + private IMailManager $mailManager, private IURLGenerator $urlGenerator, private IManager $activityManager, private SettingsManager $settingsManager, @@ -325,9 +331,9 @@ protected function sendEmail(IShare $share, array $emails): void { $initiatorUser = $this->userManager->get($initiator); $initiatorDisplayName = ($initiatorUser instanceof IUser) ? $initiatorUser->getDisplayName() : $initiator; - $message = $this->mailer->createMessage(); + $initiatorEmail = ($initiatorUser instanceof IUser) ? $initiatorUser->getEMailAddress() : null; - $emailTemplate = $this->mailer->createEMailTemplate('sharebymail.RecipientNotification', [ + $emailTemplate = $this->systemMailer->createEMailTemplate('sharebymail.RecipientNotification', [ 'filename' => $filename, 'link' => $link, 'initiator' => $initiatorDisplayName, @@ -363,47 +369,71 @@ protected function sendEmail(IShare $share, array $emails): void { $link ); - // If multiple recipients are given, we send the mail to all of them - if (count($emails) > 1) { - // We do not want to expose the email addresses of the other recipients - $message->setBcc($emails); + $instanceName = $this->defaults->getName(); + + // Add footer - adjust "Do not reply" text if reply-to will be set + if ($initiatorUser && $this->settingsManager->replyToInitiator() && $initiatorEmail !== null) { + $emailTemplate->addFooter($instanceName . ($this->defaults->getSlogan() !== '' ? ' - ' . $this->defaults->getSlogan() : '')); } else { - $message->setTo($emails); + $emailTemplate->addFooter(); } - // The "From" contains the sharers name - $instanceName = $this->defaults->getName(); - $senderName = $instanceName; - if ($this->settingsManager->replyToInitiator()) { - $senderName = $this->l->t( - '%1$s via %2$s', - [ - $initiatorDisplayName, - $instanceName - ] - ); + // Try to send via the user's personal mail service + $mailService = null; + if ($this->appConfig->getValueBool('core', 'mail_providers_enabled', true) + && $initiatorUser instanceof IUser + && $initiatorEmail !== null) { + $mailService = $this->mailManager->findServiceByAddress($initiatorUser->getUID(), $initiatorEmail); } - $message->setFrom([Util::getDefaultEmailAddress($instanceName) => $senderName]); - // The "Reply-To" is set to the sharer if an mail address is configured - // also the default footer contains a "Do not reply" which needs to be adjusted. - if ($initiatorUser && $this->settingsManager->replyToInitiator()) { - $initiatorEmail = $initiatorUser->getEMailAddress(); - if ($initiatorEmail !== null) { - $message->setReplyTo([$initiatorEmail => $initiatorDisplayName]); - $emailTemplate->addFooter($instanceName . ($this->defaults->getSlogan() !== '' ? ' - ' . $this->defaults->getSlogan() : '')); - } else { - $emailTemplate->addFooter(); + // use personal mail service if available + if ($mailService instanceof IMessageSend) { + foreach ($emails as $email) { + $message = $mailService->initiateMessage(); + $message->setFrom( + new Address($initiatorEmail, $initiatorDisplayName) + ); + $message->setTo(new Address($email)); + $message->setSubject($emailTemplate->renderSubject()); + $message->setBodyPlain($emailTemplate->renderText()); + $message->setBodyHtml($emailTemplate->renderHtml()); + $mailService->sendMessage($message); } - } else { - $emailTemplate->addFooter(); } + // Fall back to system mailer + else { + $senderName = $instanceName; + if ($this->settingsManager->replyToInitiator()) { + $senderName = $this->l->t( + '%1$s via %2$s', + [ + $initiatorDisplayName, + $instanceName + ] + ); + } - $message->useTemplate($emailTemplate); - $failedRecipients = $this->mailer->send($message); - if (!empty($failedRecipients)) { - $this->logger->error('Share notification mail could not be sent to: ' . implode(', ', $failedRecipients)); - return; + $message = $this->systemMailer->createMessage(); + + if (count($emails) > 1) { + $message->setBcc($emails); + } else { + $message->setTo($emails); + } + + $message->setFrom([Util::getDefaultEmailAddress($instanceName) => $senderName]); + + if ($initiatorUser && $this->settingsManager->replyToInitiator() && $initiatorEmail !== null) { + $message->setReplyTo([$initiatorEmail => $initiatorDisplayName]); + } + + $message->useTemplate($emailTemplate); + $failed = $this->systemMailer->send($message); + + if (!empty($failed)) { + $this->logger->error('Share notification mail could not be sent to: ' . implode(', ', $failed)); + return; + } } } @@ -435,9 +465,9 @@ protected function sendPassword(IShare $share, string $password, array $emails): $plainBodyPart = $this->l->t('%1$s shared %2$s with you. You should have already received a separate mail with a link to access it.', [$initiatorDisplayName, $filename]); $htmlBodyPart = $this->l->t('%1$s shared %2$s with you. You should have already received a separate mail with a link to access it.', [$initiatorDisplayName, $filename]); - $message = $this->mailer->createMessage(); + $message = $this->systemMailer->createMessage(); - $emailTemplate = $this->mailer->createEMailTemplate('sharebymail.RecipientPasswordNotification', [ + $emailTemplate = $this->systemMailer->createEMailTemplate('sharebymail.RecipientPasswordNotification', [ 'filename' => $filename, 'password' => $password, 'initiator' => $initiatorDisplayName, @@ -496,7 +526,7 @@ protected function sendPassword(IShare $share, string $password, array $emails): } $message->useTemplate($emailTemplate); - $failedRecipients = $this->mailer->send($message); + $failedRecipients = $this->systemMailer->send($message); if (!empty($failedRecipients)) { $this->logger->error('Share password mail could not be sent to: ' . implode(', ', $failedRecipients)); return false; @@ -521,9 +551,9 @@ protected function sendNote(IShare $share): void { $plainHeading = $this->l->t('%1$s shared %2$s with you and wants to add:', [$initiatorDisplayName, $filename]); $htmlHeading = $this->l->t('%1$s shared %2$s with you and wants to add', [$initiatorDisplayName, $filename]); - $message = $this->mailer->createMessage(); + $message = $this->systemMailer->createMessage(); - $emailTemplate = $this->mailer->createEMailTemplate('shareByMail.sendNote'); + $emailTemplate = $this->systemMailer->createEMailTemplate('shareByMail.sendNote'); $emailTemplate->setSubject($this->l->t('%s added a note to a file shared with you', [$initiatorDisplayName])); $emailTemplate->addHeader(); @@ -559,7 +589,7 @@ protected function sendNote(IShare $share): void { $message->setTo([$recipient]); $message->useTemplate($emailTemplate); - $this->mailer->send($message); + $this->systemMailer->send($message); } /** @@ -583,8 +613,8 @@ protected function sendPasswordToOwner(IShare $share, string $password): bool { $bodyPart = $this->l->t('You just shared %1$s with %2$s. The share was already sent to the recipient. Due to the security policies defined by the administrator of %3$s each share needs to be protected by password and it is not allowed to send the password directly to the recipient. Therefore you need to forward the password manually to the recipient.', [$filename, $shareWith, $this->defaults->getName()]); - $message = $this->mailer->createMessage(); - $emailTemplate = $this->mailer->createEMailTemplate('sharebymail.OwnerPasswordNotification', [ + $message = $this->systemMailer->createMessage(); + $emailTemplate = $this->systemMailer->createEMailTemplate('sharebymail.OwnerPasswordNotification', [ 'filename' => $filename, 'password' => $password, 'initiator' => $initiatorDisplayName, @@ -621,7 +651,7 @@ protected function sendPasswordToOwner(IShare $share, string $password): bool { $message->setFrom([Util::getDefaultEmailAddress($instanceName) => $senderName]); $message->setTo([$initiatorEMailAddress => $initiatorDisplayName]); $message->useTemplate($emailTemplate); - $this->mailer->send($message); + $this->systemMailer->send($message); $this->createPasswordSendActivity($share, $shareWith, true); diff --git a/apps/sharebymail/tests/ShareByMailProviderTest.php b/apps/sharebymail/tests/ShareByMailProviderTest.php index af1ab888f71df..58d1910e7ab11 100644 --- a/apps/sharebymail/tests/ShareByMailProviderTest.php +++ b/apps/sharebymail/tests/ShareByMailProviderTest.php @@ -19,6 +19,7 @@ use OCP\Files\File; use OCP\Files\IRootFolder; use OCP\Files\Node; +use OCP\IAppConfig; use OCP\IConfig; use OCP\IDBConnection; use OCP\IL10N; @@ -28,6 +29,11 @@ use OCP\Mail\IEMailTemplate; use OCP\Mail\IMailer; use OCP\Mail\IMessage; +use OCP\Mail\Provider\Address; +use OCP\Mail\Provider\IManager as IMailManager; +use OCP\Mail\Provider\IMessage as IProviderMessage; +use OCP\Mail\Provider\IMessageSend; +use OCP\Mail\Provider\IService; use OCP\Security\Events\GenerateSecurePasswordEvent; use OCP\Security\IHasher; use OCP\Security\ISecureRandom; @@ -56,8 +62,10 @@ class ShareByMailProviderTest extends TestCase { private IL10N&MockObject $l; private IShare&MockObject $share; + private IAppConfig&MockObject $appConfig; private IConfig&MockObject $config; private IMailer&MockObject $mailer; + private IMailManager&MockObject $mailManager; private IHasher&MockObject $hasher; private Defaults&MockObject $defaults; private IManager&MockObject $shareManager; @@ -80,12 +88,14 @@ protected function setUp(): void { ->willReturnCallback(function ($text, $parameters = []) { return vsprintf($text, $parameters); }); + $this->appConfig = $this->createMock(IAppConfig::class); $this->config = $this->createMock(IConfig::class); $this->logger = $this->createMock(LoggerInterface::class); $this->rootFolder = $this->createMock('OCP\Files\IRootFolder'); $this->userManager = $this->createMock(IUserManager::class); $this->secureRandom = $this->createMock('\OCP\Security\ISecureRandom'); $this->mailer = $this->createMock('\OCP\Mail\IMailer'); + $this->mailManager = $this->createMock(IMailManager::class); $this->urlGenerator = $this->createMock(IURLGenerator::class); $this->share = $this->createMock(IShare::class); $this->activityManager = $this->createMock('OCP\Activity\IManager'); @@ -109,6 +119,7 @@ private function getInstance(array $mockedMethods = []) { if (!empty($mockedMethods)) { return $this->getMockBuilder(ShareByMailProvider::class) ->setConstructorArgs([ + $this->appConfig, $this->config, $this->connection, $this->secureRandom, @@ -117,6 +128,7 @@ private function getInstance(array $mockedMethods = []) { $this->l, $this->logger, $this->mailer, + $this->mailManager, $this->urlGenerator, $this->activityManager, $this->settingsManager, @@ -131,6 +143,7 @@ private function getInstance(array $mockedMethods = []) { } return new ShareByMailProvider( + $this->appConfig, $this->config, $this->connection, $this->secureRandom, @@ -139,6 +152,7 @@ private function getInstance(array $mockedMethods = []) { $this->l, $this->logger, $this->mailer, + $this->mailManager, $this->urlGenerator, $this->activityManager, $this->settingsManager, @@ -1765,10 +1779,12 @@ public function testSendMailNotificationWithSameUserAndUserEmailAndReplyToDesact ->with([ Util::getDefaultEmailAddress('UnitTestCloud') => 'UnitTestCloud' ]); - // Since replyToInitiator is false, we never get the initiator email address + // Since replyToInitiator is false, getEMailAddress is still called + // (for mail provider lookup) but setReplyTo is never called $user - ->expects($this->never()) - ->method('getEMailAddress'); + ->expects($this->once()) + ->method('getEMailAddress') + ->willReturn('owner@example.com'); $message ->expects($this->never()) ->method('setReplyTo'); @@ -1905,4 +1921,332 @@ public function testSendMailNotificationWithDifferentUserAndNoUserEmailAndReplyT [$share] ); } + + public function testSendMailNotificationViaMailProvider(): void { + $provider = $this->getInstance(); + + $user = $this->createMock(IUser::class); + $this->userManager + ->expects($this->once()) + ->method('get') + ->with('OwnerUser') + ->willReturn($user); + $user + ->expects($this->once()) + ->method('getDisplayName') + ->willReturn('Mrs. Owner User'); + $user + ->expects($this->once()) + ->method('getEMailAddress') + ->willReturn('owner@example.com'); + $user + ->method('getUID') + ->willReturn('OwnerUser'); + + $template = $this->createMock(IEMailTemplate::class); + $this->mailer + ->expects($this->once()) + ->method('createEMailTemplate') + ->willReturn($template); + $template->expects($this->once())->method('addHeader'); + $template->expects($this->once())->method('addHeading') + ->with('Mrs. Owner User shared file.txt with you'); + $template->expects($this->once())->method('addBodyButton') + ->with('Open file.txt', 'https://example.com/file.txt'); + $template->expects($this->once())->method('setSubject') + ->with('Mrs. Owner User shared file.txt with you'); + $template->method('renderSubject')->willReturn('Mrs. Owner User shared file.txt with you'); + $template->method('renderText')->willReturn('plain text body'); + $template->method('renderHtml')->willReturn('body'); + + $this->settingsManager->expects($this->any())->method('replyToInitiator')->willReturn(true); + $this->defaults->expects($this->any())->method('getName')->willReturn('UnitTestCloud'); + $this->defaults->expects($this->any())->method('getSlogan')->willReturn('Testing like 1990'); + $template->expects($this->once())->method('addFooter') + ->with('UnitTestCloud - Testing like 1990'); + + // Enable mail providers and return a service that implements IMessageSend + $this->appConfig + ->expects($this->once()) + ->method('getValueBool') + ->with('core', 'mail_providers_enabled', true) + ->willReturn(true); + + $providerMessage = $this->createMock(IProviderMessage::class); + $mailService = $this->createMailServiceMock($providerMessage); + + $this->mailManager + ->expects($this->once()) + ->method('findServiceByAddress') + ->with('OwnerUser', 'owner@example.com') + ->willReturn($mailService); + + // System mailer should NOT be used at all + $this->mailer->expects($this->never())->method('createMessage'); + $this->mailer->expects($this->never())->method('send'); + + $this->urlGenerator->expects($this->once())->method('linkToRouteAbsolute') + ->with('files_sharing.sharecontroller.showShare', ['token' => 'token']) + ->willReturn('https://example.com/file.txt'); + + $node = $this->createMock(File::class); + $node->expects($this->any())->method('getName')->willReturn('file.txt'); + + $share = $this->createMock(IShare::class); + $share->expects($this->any())->method('getSharedBy')->willReturn('OwnerUser'); + $share->expects($this->any())->method('getSharedWith')->willReturn('john@doe.com'); + $share->expects($this->any())->method('getNode')->willReturn($node); + $share->expects($this->any())->method('getId')->willReturn('42'); + $share->expects($this->any())->method('getNote')->willReturn(''); + $share->expects($this->any())->method('getToken')->willReturn('token'); + + self::invokePrivate( + $provider, + 'sendMailNotification', + [$share] + ); + + // Verify one message was sent via the mail provider + $this->assertCount(1, $mailService->sentMessages); + } + + public function testSendMailNotificationViaMailProviderMultipleRecipients(): void { + $provider = $this->getInstance(); + + $user = $this->createMock(IUser::class); + $this->userManager + ->expects($this->once()) + ->method('get') + ->with('OwnerUser') + ->willReturn($user); + $user + ->expects($this->once()) + ->method('getDisplayName') + ->willReturn('Mrs. Owner User'); + $user + ->expects($this->once()) + ->method('getEMailAddress') + ->willReturn('owner@example.com'); + $user + ->method('getUID') + ->willReturn('OwnerUser'); + + $template = $this->createMock(IEMailTemplate::class); + $this->mailer + ->expects($this->once()) + ->method('createEMailTemplate') + ->willReturn($template); + $template->expects($this->once())->method('addHeader'); + $template->expects($this->once())->method('setSubject'); + $template->method('renderSubject')->willReturn('subject'); + $template->method('renderText')->willReturn('plain'); + $template->method('renderHtml')->willReturn('html'); + + $this->settingsManager->expects($this->any())->method('replyToInitiator')->willReturn(true); + $this->defaults->expects($this->any())->method('getName')->willReturn('UnitTestCloud'); + $this->defaults->expects($this->any())->method('getSlogan')->willReturn(''); + + $this->appConfig + ->expects($this->once()) + ->method('getValueBool') + ->with('core', 'mail_providers_enabled', true) + ->willReturn(true); + + $providerMessage = $this->createMock(IProviderMessage::class); + $mailService = $this->createMailServiceMock($providerMessage); + + $this->mailManager + ->expects($this->once()) + ->method('findServiceByAddress') + ->with('OwnerUser', 'owner@example.com') + ->willReturn($mailService); + + // System mailer should NOT be used + $this->mailer->expects($this->never())->method('createMessage'); + $this->mailer->expects($this->never())->method('send'); + + $this->urlGenerator->expects($this->once())->method('linkToRouteAbsolute') + ->with('files_sharing.sharecontroller.showShare', ['token' => 'token']) + ->willReturn('https://example.com/file.txt'); + + $node = $this->createMock(File::class); + $node->expects($this->any())->method('getName')->willReturn('file.txt'); + + $attributes = $this->createMock(IAttributes::class); + $attributes->expects($this->once())->method('getAttribute') + ->with('shareWith', 'emails') + ->willReturn(['john@doe.com', 'jane@doe.com', 'bob@test.com']); + + $share = $this->createMock(IShare::class); + $share->expects($this->any())->method('getSharedBy')->willReturn('OwnerUser'); + $share->expects($this->any())->method('getSharedWith')->willReturn('john@doe.com'); + $share->expects($this->any())->method('getAttributes')->willReturn($attributes); + $share->expects($this->any())->method('getNode')->willReturn($node); + $share->expects($this->any())->method('getId')->willReturn('42'); + $share->expects($this->any())->method('getNote')->willReturn(''); + $share->expects($this->any())->method('getToken')->willReturn('token'); + + self::invokePrivate( + $provider, + 'sendMailNotification', + [$share] + ); + + // Verify one message was sent per recipient + $this->assertCount(3, $mailService->sentMessages); + } + + public function testSendMailNotificationFallsBackToSystemMailerWhenNoMailProvider(): void { + $provider = $this->getInstance(); + + $user = $this->createMock(IUser::class); + $this->userManager + ->expects($this->once()) + ->method('get') + ->with('OwnerUser') + ->willReturn($user); + $user + ->expects($this->once()) + ->method('getDisplayName') + ->willReturn('Mrs. Owner User'); + $user + ->expects($this->once()) + ->method('getEMailAddress') + ->willReturn('owner@example.com'); + $user + ->method('getUID') + ->willReturn('OwnerUser'); + + $template = $this->createMock(IEMailTemplate::class); + $this->mailer + ->expects($this->once()) + ->method('createEMailTemplate') + ->willReturn($template); + $template->expects($this->once())->method('addHeader'); + $template->expects($this->once())->method('setSubject'); + + $this->settingsManager->expects($this->any())->method('replyToInitiator')->willReturn(true); + $this->defaults->expects($this->any())->method('getName')->willReturn('UnitTestCloud'); + $this->defaults->expects($this->any())->method('getSlogan')->willReturn(''); + + // Mail providers enabled but no service found for user + $this->appConfig + ->expects($this->once()) + ->method('getValueBool') + ->with('core', 'mail_providers_enabled', true) + ->willReturn(true); + + $this->mailManager + ->expects($this->once()) + ->method('findServiceByAddress') + ->with('OwnerUser', 'owner@example.com') + ->willReturn(null); + + // System mailer IS used as fallback + $message = $this->createMock(Message::class); + $this->mailer + ->expects($this->once()) + ->method('createMessage') + ->willReturn($message); + $message + ->expects($this->once()) + ->method('setTo') + ->with(['john@doe.com']); + $message + ->expects($this->once()) + ->method('setFrom') + ->with([Util::getDefaultEmailAddress('UnitTestCloud') => 'Mrs. Owner User via UnitTestCloud']); + $message + ->expects($this->once()) + ->method('setReplyTo') + ->with(['owner@example.com' => 'Mrs. Owner User']); + $message + ->expects($this->once()) + ->method('useTemplate') + ->with($template); + $this->mailer + ->expects($this->once()) + ->method('send') + ->with($message); + + $this->urlGenerator->expects($this->once())->method('linkToRouteAbsolute') + ->with('files_sharing.sharecontroller.showShare', ['token' => 'token']) + ->willReturn('https://example.com/file.txt'); + + $node = $this->createMock(File::class); + $node->expects($this->any())->method('getName')->willReturn('file.txt'); + + $share = $this->createMock(IShare::class); + $share->expects($this->any())->method('getSharedBy')->willReturn('OwnerUser'); + $share->expects($this->any())->method('getSharedWith')->willReturn('john@doe.com'); + $share->expects($this->any())->method('getNode')->willReturn($node); + $share->expects($this->any())->method('getId')->willReturn('42'); + $share->expects($this->any())->method('getNote')->willReturn(''); + $share->expects($this->any())->method('getToken')->willReturn('token'); + + self::invokePrivate( + $provider, + 'sendMailNotification', + [$share] + ); + } + + /** + * Create a test mail service that implements both IService and IMessageSend. + * Tracks sent messages in the public $sentMessages array. + */ + private function createMailServiceMock(IProviderMessage $providerMessage): IService&IMessageSend { + return new class ($providerMessage) implements IService, IMessageSend { + private IProviderMessage $providerMessage; + public array $sentMessages = []; + + public function __construct(IProviderMessage $providerMessage) { + $this->providerMessage = $providerMessage; + } + + public function id(): string { + return 'test-service'; + } + + public function capable(string $value): bool { + return false; + } + + public function capabilities(): array { + return []; + } + + public function getLabel(): string { + return 'Test Service'; + } + + public function setLabel(string $value): self { + return $this; + } + + public function getPrimaryAddress(): \OCP\Mail\Provider\IAddress { + return new Address('test@test.com'); + } + + public function setPrimaryAddress(\OCP\Mail\Provider\IAddress $value): self { + return $this; + } + + public function getSecondaryAddresses(): array { + return []; + } + + public function setSecondaryAddresses(\OCP\Mail\Provider\IAddress ...$value): self { + return $this; + } + + public function initiateMessage(): IProviderMessage { + return $this->providerMessage; + } + + public function sendMessage(IProviderMessage $message, array $options = []): void { + $this->sentMessages[] = $message; + } + }; + } }