diff --git a/apps-custom/nc_ionos_processes b/apps-custom/nc_ionos_processes index 5ee4b69ebed39..9501a5ee7224a 160000 --- a/apps-custom/nc_ionos_processes +++ b/apps-custom/nc_ionos_processes @@ -1 +1 @@ -Subproject commit 5ee4b69ebed39714358f29e42f63dd76831682b3 +Subproject commit 9501a5ee7224abf276913302e25e0684e54c560f diff --git a/apps/sharebymail/composer/composer/autoload_classmap.php b/apps/sharebymail/composer/composer/autoload_classmap.php index 38fec4de2788d..eb1defce86867 100644 --- a/apps/sharebymail/composer/composer/autoload_classmap.php +++ b/apps/sharebymail/composer/composer/autoload_classmap.php @@ -8,6 +8,10 @@ return array( 'Composer\\InstalledVersions' => $vendorDir . '/composer/InstalledVersions.php', 'OCA\\ShareByMail\\Activity' => $baseDir . '/../lib/Activity.php', + 'OCA\\ShareByMail\\Event\\AbstractBeforeShareMailSentEvent' => $baseDir . '/../lib/Event/AbstractBeforeShareMailSentEvent.php', + 'OCA\\ShareByMail\\Event\\BeforeShareMailSentEvent' => $baseDir . '/../lib/Event/BeforeShareMailSentEvent.php', + 'OCA\\ShareByMail\\Event\\BeforeShareNoteMailSentEvent' => $baseDir . '/../lib/Event/BeforeShareNoteMailSentEvent.php', + 'OCA\\ShareByMail\\Event\\BeforeSharePasswordMailSentEvent' => $baseDir . '/../lib/Event/BeforeSharePasswordMailSentEvent.php', 'OCA\\ShareByMail\\AppInfo\\Application' => $baseDir . '/../lib/AppInfo/Application.php', 'OCA\\ShareByMail\\Capabilities' => $baseDir . '/../lib/Capabilities.php', 'OCA\\ShareByMail\\Settings\\Admin' => $baseDir . '/../lib/Settings/Admin.php', diff --git a/apps/sharebymail/composer/composer/autoload_static.php b/apps/sharebymail/composer/composer/autoload_static.php index 16ce27764b6b7..ee32b6f1140db 100644 --- a/apps/sharebymail/composer/composer/autoload_static.php +++ b/apps/sharebymail/composer/composer/autoload_static.php @@ -24,6 +24,10 @@ class ComposerStaticInitShareByMail 'Composer\\InstalledVersions' => __DIR__ . '/..' . '/composer/InstalledVersions.php', 'OCA\\ShareByMail\\Activity' => __DIR__ . '/..' . '/../lib/Activity.php', 'OCA\\ShareByMail\\AppInfo\\Application' => __DIR__ . '/..' . '/../lib/AppInfo/Application.php', + 'OCA\\ShareByMail\\Event\\AbstractBeforeShareMailSentEvent' => __DIR__ . '/..' . '/../lib/Event/AbstractBeforeShareMailSentEvent.php', + 'OCA\\ShareByMail\\Event\\BeforeShareMailSentEvent' => __DIR__ . '/..' . '/../lib/Event/BeforeShareMailSentEvent.php', + 'OCA\\ShareByMail\\Event\\BeforeShareNoteMailSentEvent' => __DIR__ . '/..' . '/../lib/Event/BeforeShareNoteMailSentEvent.php', + 'OCA\\ShareByMail\\Event\\BeforeSharePasswordMailSentEvent' => __DIR__ . '/..' . '/../lib/Event/BeforeSharePasswordMailSentEvent.php', 'OCA\\ShareByMail\\Capabilities' => __DIR__ . '/..' . '/../lib/Capabilities.php', 'OCA\\ShareByMail\\Settings\\Admin' => __DIR__ . '/..' . '/../lib/Settings/Admin.php', 'OCA\\ShareByMail\\Settings\\SettingsManager' => __DIR__ . '/..' . '/../lib/Settings/SettingsManager.php', diff --git a/apps/sharebymail/lib/Event/AbstractBeforeShareMailSentEvent.php b/apps/sharebymail/lib/Event/AbstractBeforeShareMailSentEvent.php new file mode 100644 index 0000000000000..739f2dbe52a43 --- /dev/null +++ b/apps/sharebymail/lib/Event/AbstractBeforeShareMailSentEvent.php @@ -0,0 +1,62 @@ +send(). + */ +abstract class AbstractBeforeShareMailSentEvent extends Event { + private bool $mailHandled = false; + + /** + * @param string[] $resolvedEmails validated recipients + */ + public function __construct( + private readonly IShare $share, + private readonly array $resolvedEmails, + private readonly IMessage $message, + ) { + parent::__construct(); + } + + public function getShare(): IShare { + return $this->share; + } + + /** @return string[] */ + public function getResolvedEmails(): array { + return $this->resolvedEmails; + } + + public function getMessage(): IMessage { + return $this->message; + } + + /** + * Call to suppress the native mailer->send() for this message. + * Must be called before any send attempt — if the listener's own send + * throws, the exception propagates and the native send is also skipped. + */ + public function markMailHandled(): void { + $this->mailHandled = true; + } + + public function isMailHandled(): bool { + return $this->mailHandled; + } +} diff --git a/apps/sharebymail/lib/Event/BeforeShareMailSentEvent.php b/apps/sharebymail/lib/Event/BeforeShareMailSentEvent.php new file mode 100644 index 0000000000000..657429d2ac772 --- /dev/null +++ b/apps/sharebymail/lib/Event/BeforeShareMailSentEvent.php @@ -0,0 +1,72 @@ +send() call for share-link notifications to recipients. + * + * @psalm-type TemplateData = array{ + * senderUserId: string, + * filename: string, + * link: string, + * initiator: string, + * expiration: \DateTime|null, + * shareWith: string, + * note: string, + * } + * + * @psalm-api + */ +class BeforeShareMailSentEvent extends AbstractBeforeShareMailSentEvent { + /** + * @param string[] $resolvedEmails + * @param TemplateData $templateData + */ + public function __construct( + IShare $share, + array $resolvedEmails, + IMessage $message, + private readonly array $templateData, + ) { + parent::__construct($share, $resolvedEmails, $message); + } + + public function getSenderUserId(): string { + return $this->templateData['senderUserId']; + } + + public function getFileName(): string { + return $this->templateData['filename']; + } + + public function getResourceUrl(): string { + return $this->templateData['link']; + } + + public function getNote(): string { + return $this->templateData['note']; + } + + public function getShareWith(): string { + return $this->templateData['shareWith']; + } + + public function getInitiatorDisplayName(): string { + return $this->templateData['initiator']; + } + + public function getExpiration(): ?\DateTime { + return $this->templateData['expiration']; + } +} diff --git a/apps/sharebymail/lib/Event/BeforeShareNoteMailSentEvent.php b/apps/sharebymail/lib/Event/BeforeShareNoteMailSentEvent.php new file mode 100644 index 0000000000000..bbf1ec2dbe661 --- /dev/null +++ b/apps/sharebymail/lib/Event/BeforeShareNoteMailSentEvent.php @@ -0,0 +1,47 @@ +send() call for note-update notifications to recipients. + * + * @psalm-type TemplateData = array{ + * filename: string, + * note: string, + * } + * + * @psalm-api + */ +class BeforeShareNoteMailSentEvent extends AbstractBeforeShareMailSentEvent { + /** + * @param string[] $resolvedEmails + * @param TemplateData $templateData + */ + public function __construct( + IShare $share, + array $resolvedEmails, + IMessage $message, + private readonly array $templateData, + ) { + parent::__construct($share, $resolvedEmails, $message); + } + + public function getFileName(): string { + return $this->templateData['filename']; + } + + public function getNote(): string { + return $this->templateData['note']; + } +} diff --git a/apps/sharebymail/lib/Event/BeforeSharePasswordMailSentEvent.php b/apps/sharebymail/lib/Event/BeforeSharePasswordMailSentEvent.php new file mode 100644 index 0000000000000..cbfe0b831658c --- /dev/null +++ b/apps/sharebymail/lib/Event/BeforeSharePasswordMailSentEvent.php @@ -0,0 +1,66 @@ +send() call for password emails. + * + * For sendPassword(), initiatorEmail may be null when the initiator has no + * email address configured. For sendPasswordToOwner() it is always a non-null + * string (the call site throws earlier if the owner has no email address). + * + * @psalm-type TemplateData = array{ + * filename: string, + * password: string, + * initiator: string, + * initiatorEmail: string|null, + * shareWith: string, + * } + * + * @psalm-api + */ +class BeforeSharePasswordMailSentEvent extends AbstractBeforeShareMailSentEvent { + /** + * @param string[] $resolvedEmails + * @param TemplateData $templateData + */ + public function __construct( + IShare $share, + array $resolvedEmails, + IMessage $message, + private readonly array $templateData, + ) { + parent::__construct($share, $resolvedEmails, $message); + } + + public function getFileName(): string { + return $this->templateData['filename']; + } + + public function getPassword(): string { + return $this->templateData['password']; + } + + public function getInitiatorDisplayName(): string { + return $this->templateData['initiator']; + } + + public function getInitiatorEmail(): ?string { + return $this->templateData['initiatorEmail']; + } + + public function getShareWith(): string { + return $this->templateData['shareWith']; + } +} diff --git a/apps/sharebymail/lib/ShareByMailProvider.php b/apps/sharebymail/lib/ShareByMailProvider.php index ee815b31ac02b..9033024c5f3d3 100644 --- a/apps/sharebymail/lib/ShareByMailProvider.php +++ b/apps/sharebymail/lib/ShareByMailProvider.php @@ -9,6 +9,9 @@ use OC\Share20\Exception\InvalidShare; use OC\Share20\Share; use OC\User\NoUserException; +use OCA\ShareByMail\Event\BeforeShareMailSentEvent; +use OCA\ShareByMail\Event\BeforeShareNoteMailSentEvent; +use OCA\ShareByMail\Event\BeforeSharePasswordMailSentEvent; use OCA\ShareByMail\Settings\SettingsManager; use OCP\Activity\IManager; use OCP\DB\QueryBuilder\IQueryBuilder; @@ -319,14 +322,16 @@ protected function sendEmail(IShare $share, array $emails): void { $initiatorDisplayName = ($initiatorUser instanceof IUser) ? $initiatorUser->getDisplayName() : $initiator; $message = $this->mailer->createMessage(); - $emailTemplate = $this->mailer->createEMailTemplate('sharebymail.RecipientNotification', [ + $templateData = [ 'filename' => $filename, 'link' => $link, 'initiator' => $initiatorDisplayName, + 'senderUserId' => $initiator, 'expiration' => $expiration, 'shareWith' => $shareWith, - 'note' => $note - ]); + 'note' => $note, + ]; + $emailTemplate = $this->mailer->createEMailTemplate('sharebymail.RecipientNotification', $templateData); $emailTemplate->setSubject($this->l->t('%1$s shared %2$s with you', [$initiatorDisplayName, $filename])); $emailTemplate->addHeader(); @@ -392,6 +397,11 @@ protected function sendEmail(IShare $share, array $emails): void { } $message->useTemplate($emailTemplate); + $event = new BeforeShareMailSentEvent($share, $emails, $message, $templateData); + $this->eventDispatcher->dispatchTyped($event); + if ($event->isMailHandled()) { + return; + } $failedRecipients = $this->mailer->send($message); if (!empty($failedRecipients)) { $this->logger->error('Share notification mail could not be sent to: ' . implode(', ', $failedRecipients)); @@ -429,13 +439,14 @@ protected function sendPassword(IShare $share, string $password, array $emails): $message = $this->mailer->createMessage(); - $emailTemplate = $this->mailer->createEMailTemplate('sharebymail.RecipientPasswordNotification', [ + $templateData = [ 'filename' => $filename, 'password' => $password, 'initiator' => $initiatorDisplayName, 'initiatorEmail' => $initiatorEmailAddress, 'shareWith' => $shareWith, - ]); + ]; + $emailTemplate = $this->mailer->createEMailTemplate('sharebymail.RecipientPasswordNotification', $templateData); $emailTemplate->setSubject($this->l->t('Password to access %1$s shared to you by %2$s', [$filename, $initiatorDisplayName])); $emailTemplate->addHeader(); @@ -488,6 +499,12 @@ protected function sendPassword(IShare $share, string $password, array $emails): } $message->useTemplate($emailTemplate); + $event = new BeforeSharePasswordMailSentEvent($share, $emails, $message, $templateData); + $this->eventDispatcher->dispatchTyped($event); + if ($event->isMailHandled()) { + return true; + } + $failedRecipients = $this->mailer->send($message); if (!empty($failedRecipients)) { $this->logger->error('Share password mail could not be sent to: ' . implode(', ', $failedRecipients)); @@ -515,7 +532,11 @@ protected function sendNote(IShare $share): void { $message = $this->mailer->createMessage(); - $emailTemplate = $this->mailer->createEMailTemplate('shareByMail.sendNote'); + $templateData = [ + 'filename' => $filename, + 'note' => $note, + ]; + $emailTemplate = $this->mailer->createEMailTemplate('shareByMail.sendNote', $templateData); $emailTemplate->setSubject($this->l->t('%s added a note to a file shared with you', [$initiatorDisplayName])); $emailTemplate->addHeader(); @@ -551,6 +572,11 @@ protected function sendNote(IShare $share): void { $message->setTo([$recipient]); $message->useTemplate($emailTemplate); + $event = new BeforeShareNoteMailSentEvent($share, [$recipient], $message, $templateData); + $this->eventDispatcher->dispatchTyped($event); + if ($event->isMailHandled()) { + return; + } $this->mailer->send($message); } @@ -576,13 +602,14 @@ 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', [ + $templateData = [ 'filename' => $filename, 'password' => $password, 'initiator' => $initiatorDisplayName, 'initiatorEmail' => $initiatorEMailAddress, 'shareWith' => $shareWith, - ]); + ]; + $emailTemplate = $this->mailer->createEMailTemplate('sharebymail.OwnerPasswordNotification', $templateData); $emailTemplate->setSubject($this->l->t('Password to access %1$s shared by you with %2$s', [$filename, $shareWith])); $emailTemplate->addHeader(); @@ -613,8 +640,13 @@ protected function sendPasswordToOwner(IShare $share, string $password): bool { $message->setFrom([\OCP\Util::getDefaultEmailAddress($instanceName) => $senderName]); $message->setTo([$initiatorEMailAddress => $initiatorDisplayName]); $message->useTemplate($emailTemplate); - $this->mailer->send($message); + $event = new BeforeSharePasswordMailSentEvent($share, [$initiatorEMailAddress], $message, $templateData); + $this->eventDispatcher->dispatchTyped($event); + if ($event->isMailHandled()) { + return true; + } + $this->mailer->send($message); $this->createPasswordSendActivity($share, $shareWith, true); return true; diff --git a/apps/sharebymail/tests/ShareByMailProviderTest.php b/apps/sharebymail/tests/ShareByMailProviderTest.php index 18ca9574d6f05..8995a7500c403 100644 --- a/apps/sharebymail/tests/ShareByMailProviderTest.php +++ b/apps/sharebymail/tests/ShareByMailProviderTest.php @@ -22,6 +22,9 @@ use OCP\Mail\IEMailTemplate; use OCP\Mail\IMailer; use OCP\Mail\IMessage; +use OCA\ShareByMail\Event\BeforeShareMailSentEvent; +use OCA\ShareByMail\Event\BeforeShareNoteMailSentEvent; +use OCA\ShareByMail\Event\BeforeSharePasswordMailSentEvent; use OCP\Security\Events\GenerateSecurePasswordEvent; use OCP\Security\IHasher; use OCP\Security\ISecureRandom; @@ -359,9 +362,13 @@ public function testCreateSendPasswordByMailWithEnforcedPasswordProtectionWithPe ->method('generate') ->with(8, ISecureRandom::CHAR_HUMAN_READABLE) ->willReturn('autogeneratedPassword'); - $this->eventDispatcher->expects($this->once()) + $this->eventDispatcher->expects($this->exactly(3)) ->method('dispatchTyped') - ->with(new GenerateSecurePasswordEvent()); + ->withConsecutive( + [new GenerateSecurePasswordEvent()], + [$this->isInstanceOf(BeforeShareMailSentEvent::class)], + [$this->isInstanceOf(BeforeSharePasswordMailSentEvent::class)], + ); // Assume the mail address is valid. $this->mailer->expects($this->any())->method('validateMailAddress')->willReturn(true); @@ -393,6 +400,7 @@ public function testCreateSendPasswordByMailWithEnforcedPasswordProtectionWithPe 'filename' => 'filename', 'link' => 'https://example.com/file.txt', 'initiator' => 'owner', + 'senderUserId' => 'owner', 'expiration' => null, 'shareWith' => 'receiver@example.com', 'note' => '' @@ -467,6 +475,7 @@ public function testCreateSendPasswordByMailWithPasswordAndWithEnforcedPasswordP 'filename' => 'filename', 'link' => 'https://example.com/file.txt', 'initiator' => 'owner', + 'senderUserId' => 'owner', 'expiration' => null, 'shareWith' => 'receiver@example.com', 'note' => '' @@ -546,6 +555,7 @@ public function testCreateSendPasswordByTalkWithEnforcedPasswordProtectionWithPe 'filename' => 'filename', 'link' => 'https://example.com/file.txt', 'initiator' => 'owner', + 'senderUserId' => 'owner', 'expiration' => null, 'shareWith' => 'receiver@example.com', 'note' => '' @@ -1922,4 +1932,116 @@ public function testSendMailNotificationWithDifferentUserAndNoUserEmailAndReplyT [$share] ); } + + public function testSendNoteDispatchesBeforeShareNoteMailSentEvent(): void { + $provider = $this->getInstance(); + $user = $this->createMock(IUser::class); + $user->method('getDisplayName')->willReturn('Mrs. Owner User'); + $user->method('getEMailAddress')->willReturn('owner@example.com'); + + $this->settingsManager->method('replyToInitiator')->willReturn(true); + $this->userManager->method('get')->with('OwnerUser')->willReturn($user); + + $message = $this->createMock(Message::class); + $this->mailer->method('createMessage')->willReturn($message); + $template = $this->createMock(IEMailTemplate::class); + $this->mailer->method('createEMailTemplate')->willReturn($template); + + $this->urlGenerator->method('linkToRouteAbsolute') + ->with('files_sharing.sharecontroller.showShare', ['token' => 'token']) + ->willReturn('https://example.com/file.txt'); + $this->defaults->method('getName')->willReturn('UnitTestCloud'); + $this->defaults->method('getSlogan')->willReturn('Testing like 1990'); + + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->with($this->isInstanceOf(BeforeShareNoteMailSentEvent::class)); + $this->mailer->expects($this->once())->method('send')->with($message); + + $node = $this->getMockBuilder(File::class)->getMock(); + $node->method('getName')->willReturn('file.txt'); + + $share = $this->getMockBuilder(IShare::class)->getMock(); + $share->method('getSharedWith')->willReturn('john@doe.com'); + $share->method('getSharedBy')->willReturn('OwnerUser'); + $share->method('getNode')->willReturn($node); + $share->method('getNote')->willReturn('This is a note to the recipient'); + $share->method('getToken')->willReturn('token'); + + self::invokePrivate($provider, 'sendNote', [$share]); + } + + public function testSendPasswordDoesNotCreateActivityWhenMailHandledByListener(): void { + $provider = $this->getInstance(['createPasswordSendActivity']); + + $node = $this->createMock(File::class); + $node->method('getName')->willReturn('file.txt'); + + $share = $this->createMock(IShare::class); + $share->method('getSharedWith')->willReturn('recipient@example.com'); + $share->method('getSharedBy')->willReturn('sender'); + $share->method('getNode')->willReturn($node); + $share->method('getSendPasswordByTalk')->willReturn(false); + + $this->settingsManager->method('sendPasswordByMail')->willReturn(true); + $this->defaults->method('getName')->willReturn('TestCloud'); + $this->defaults->method('getSlogan')->willReturn(''); + + $message = $this->createMock(Message::class); + $this->mailer->method('createMessage')->willReturn($message); + $template = $this->createMock(IEMailTemplate::class); + $this->mailer->method('createEMailTemplate')->willReturn($template); + + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->willReturnCallback(function ($event): void { + if ($event instanceof BeforeSharePasswordMailSentEvent) { + $event->markMailHandled(); + } + }); + + $provider->expects($this->never())->method('createPasswordSendActivity'); + $this->mailer->expects($this->never())->method('send'); + + self::invokePrivate($provider, 'sendPassword', [$share, 'secret', ['recipient@example.com']]); + } + + public function testSendPasswordToOwnerDoesNotCreateActivityWhenMailHandledByListener(): void { + $provider = $this->getInstance(['createPasswordSendActivity']); + + $node = $this->createMock(File::class); + $node->method('getName')->willReturn('file.txt'); + + $initiatorUser = $this->createMock(IUser::class); + $initiatorUser->method('getDisplayName')->willReturn('Sender User'); + $initiatorUser->method('getEMailAddress')->willReturn('sender@example.com'); + + $share = $this->createMock(IShare::class); + $share->method('getSharedWith')->willReturn('recipient@example.com'); + $share->method('getSharedBy')->willReturn('sender'); + $share->method('getNode')->willReturn($node); + + $this->userManager->method('get')->with('sender')->willReturn($initiatorUser); + $this->defaults->method('getName')->willReturn('TestCloud'); + $this->defaults->method('getSlogan')->willReturn(''); + $this->settingsManager->method('replyToInitiator')->willReturn(false); + + $message = $this->createMock(Message::class); + $this->mailer->method('createMessage')->willReturn($message); + $template = $this->createMock(IEMailTemplate::class); + $this->mailer->method('createEMailTemplate')->willReturn($template); + + $this->eventDispatcher->expects($this->once()) + ->method('dispatchTyped') + ->willReturnCallback(function ($event): void { + if ($event instanceof BeforeSharePasswordMailSentEvent) { + $event->markMailHandled(); + } + }); + + $provider->expects($this->never())->method('createPasswordSendActivity'); + $this->mailer->expects($this->never())->method('send'); + + self::invokePrivate($provider, 'sendPasswordToOwner', [$share, 'secret']); + } }