diff --git a/apps/sharebymail/lib/Settings/Admin.php b/apps/sharebymail/lib/Settings/Admin.php index 74242c9e35641..fef33723e2542 100644 --- a/apps/sharebymail/lib/Settings/Admin.php +++ b/apps/sharebymail/lib/Settings/Admin.php @@ -30,6 +30,7 @@ public function __construct( public function getForm() { $this->initialState->provideInitialState('sendPasswordMail', $this->settingsManager->sendPasswordByMail()); $this->initialState->provideInitialState('replyToInitiator', $this->settingsManager->replyToInitiator()); + $this->initialState->provideInitialState('useUserEmail', $this->settingsManager->useUserEmail()); Util::addStyle('sharebymail', 'admin-settings'); Util::addScript('sharebymail', 'admin-settings'); @@ -67,6 +68,7 @@ public function getAuthorizedAppConfig(): array { 'sharebymail' => [ 'sendpasswordmail', 'replyToInitiator', + 'useUserEmail', ], ]; } diff --git a/apps/sharebymail/lib/Settings/SettingsManager.php b/apps/sharebymail/lib/Settings/SettingsManager.php index 00480380d2b8c..04127d47512b9 100644 --- a/apps/sharebymail/lib/Settings/SettingsManager.php +++ b/apps/sharebymail/lib/Settings/SettingsManager.php @@ -17,6 +17,8 @@ class SettingsManager { private $replyToInitiatorDefault = 'yes'; + private $useUserEmailDefault = 'no'; + public function __construct( private IConfig $config, ) { @@ -41,4 +43,20 @@ public function replyToInitiator(): bool { $replyToInitiator = $this->config->getAppValue('sharebymail', 'replyToInitiator', $this->replyToInitiatorDefault); return $replyToInitiator === 'yes'; } + + /** + * Should share-by-mail emails be sent using the user's personal email + * address (via Mail Provider) instead of the global system email address. + * + * When enabled and a Mail Provider is available for the user, share + * notification emails will be sent from the user's own address, similar + * to how calendar invitations work. + * + * @return bool + * @psalm-suppress DeprecatedMethod + */ + public function useUserEmail(): bool { + $useUserEmail = $this->config->getAppValue('sharebymail', 'useUserEmail', $this->useUserEmailDefault); + return $useUserEmail === 'yes'; + } } diff --git a/apps/sharebymail/lib/ShareByMailProvider.php b/apps/sharebymail/lib/ShareByMailProvider.php index b96a61288517f..ebc0f6264bac9 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,10 @@ use OCP\IUserManager; use OCP\Mail\IEmailValidator; use OCP\Mail\IMailer; +use OCP\Mail\Provider\Address; +use OCP\Mail\Provider\IManager as IMailManager; +use OCP\Mail\Provider\IMessageSend; +use OCP\Mail\Provider\IService; use OCP\Security\Events\GenerateSecurePasswordEvent; use OCP\Security\IHasher; use OCP\Security\ISecureRandom; @@ -74,6 +79,8 @@ public function __construct( private IEventDispatcher $eventDispatcher, private IShareManager $shareManager, private IEmailValidator $emailValidator, + private IMailManager $mailManager, + private IAppConfig $appConfig, ) { } @@ -328,6 +335,111 @@ private function trySendPasswordToOwner(IShare $share): void { 'exception' => $e, ]); } + + } + + /** + * Try to find a Mail Provider service for the given user that can send mail. + * + * This follows the same pattern as IMipPlugin for calendar invitations: + * if mail providers are enabled globally and the admin has enabled + * user-level sending for share emails, look up the user's mail service. + * + * @param ?string $userId The user ID of the share initiator + * @param ?string $userEmail The email address of the share initiator + * @return (IMessageSend&IService)|null A mail service that can send, or null to fall back to the system mailer + */ + protected function findMailService(?string $userId, ?string $userEmail): (IMessageSend&IService)|null { + if ($userId === null || $userEmail === null) { + return null; + } + if (!$this->settingsManager->useUserEmail()) { + return null; + } + + if (!$this->appConfig->getValueBool('core', 'mail_providers_enabled', true)) { + return null; + } + + $mailService = $this->mailManager->findServiceByAddress($userId, $userEmail); + if ($mailService instanceof IMessageSend) { + return $mailService; + } + + return null; + } + + /** + * Send the share notification email via Mail Provider if available, + * otherwise fall back to the system mailer. + * + * @param IMessageSend&IService $mailService The mail provider service + * @param string $senderEmail The sender's email address + * @param string $senderName The sender's display name + * @param array $recipientEmails The recipient email addresses + * @param \OCP\Mail\IEMailTemplate $emailTemplate The email template + */ + /** + * Send the share email via Mail Provider if available, + * otherwise return false to fall back to the system mailer. + * + * @param string $initiator The user ID of the share initiator + * @param ?string $initiatorEmail The email address of the share initiator + * @param string $initiatorDisplayName The display name of the share initiator + * @param array $recipientEmails The recipient email addresses + * @param callable $templateFactory A factory function that returns an \OCP\Mail\IEMailTemplate + * @return bool True if successfully sent via Mail Provider, false if falling back + */ + protected function sendViaMailProviderWithFallback( + string $initiator, + ?string $initiatorEmail, + string $initiatorDisplayName, + array $recipientEmails, + callable $templateFactory, + ): bool { + $mailService = $this->findMailService($initiator, $initiatorEmail); + if ($mailService === null || $initiatorEmail === null) { + return false; + } + + $emailTemplate = $templateFactory(); + $instanceName = $this->defaults->getName(); + $emailTemplate->addFooter($instanceName . ($this->defaults->getSlogan() !== '' ? ' - ' . $this->defaults->getSlogan() : '')); + + try { + $this->sendViaMailProvider($mailService, $initiatorEmail, $initiatorDisplayName, $recipientEmails, $emailTemplate); + return true; + } catch (\Exception $e) { + $this->logger->warning('Failed to send share email via Mail Provider, falling back to system mailer.', [ + 'app' => 'sharebymail', + 'exception' => $e, + ]); + return false; + } + } + + protected function sendViaMailProvider( + IMessageSend&IService $mailService, + string $senderEmail, + string $senderName, + array $recipientEmails, + \OCP\Mail\IEMailTemplate $emailTemplate, + ): void { + $message = $mailService->initiateMessage(); + $message->setFrom(new Address($senderEmail, $senderName)); + + $recipients = array_map(fn (string $email) => new Address($email), $recipientEmails); + if (count($recipients) > 1) { + $message->setBcc(...$recipients); + } else { + $message->setTo(...$recipients); + } + + $message->setSubject($emailTemplate->renderSubject()); + $message->setBodyPlain($emailTemplate->renderText()); + $message->setBodyHtml($emailTemplate->renderHtml()); + + $mailService->sendMessage($message); } /** @@ -347,43 +459,59 @@ protected function sendEmail(IShare $share, array $emails): void { $initiatorUser = $this->userManager->get($initiator); $initiatorDisplayName = ($initiatorUser instanceof IUser) ? $initiatorUser->getDisplayName() : $initiator; - $message = $this->mailer->createMessage(); - $emailTemplate = $this->mailer->createEMailTemplate('sharebymail.RecipientNotification', [ - 'filename' => $filename, - 'link' => $link, - 'initiator' => $initiatorDisplayName, - 'expiration' => $expiration, - 'shareWith' => $shareWith, - 'note' => $note - ]); + $templateFactory = function () use ($filename, $link, $initiatorDisplayName, $expiration, $shareWith, $note): \OCP\Mail\IEMailTemplate { + $emailTemplate = $this->mailer->createEMailTemplate('sharebymail.RecipientNotification', [ + 'filename' => $filename, + 'link' => $link, + 'initiator' => $initiatorDisplayName, + 'expiration' => $expiration, + 'shareWith' => $shareWith, + 'note' => $note + ]); - $emailTemplate->setSubject($this->l->t('%1$s shared %2$s with you', [$initiatorDisplayName, $filename])); - $emailTemplate->addHeader(); - $emailTemplate->addHeading($this->l->t('%1$s shared %2$s with you', [$initiatorDisplayName, $filename]), false); - - if ($note !== '') { - $emailTemplate->addBodyListItem( - htmlspecialchars($note), - $this->l->t('Note:'), - $this->getAbsoluteImagePath('caldav/description.png'), - $note - ); - } + $emailTemplate->setSubject($this->l->t('%1$s shared %2$s with you', [$initiatorDisplayName, $filename])); + $emailTemplate->addHeader(); + $emailTemplate->addHeading($this->l->t('%1$s shared %2$s with you', [$initiatorDisplayName, $filename]), false); - if ($expiration !== null) { - $dateString = (string)$this->l->l('date', $expiration, ['width' => 'medium']); - $emailTemplate->addBodyListItem( - $this->l->t('This share is valid until %s at midnight', [$dateString]), - $this->l->t('Expiration:'), - $this->getAbsoluteImagePath('caldav/time.png'), + if ($note !== '') { + $emailTemplate->addBodyListItem( + htmlspecialchars($note), + $this->l->t('Note:'), + $this->getAbsoluteImagePath('caldav/description.png'), + $note + ); + } + + if ($expiration !== null) { + $dateString = (string)$this->l->l('date', $expiration, ['width' => 'medium']); + $emailTemplate->addBodyListItem( + $this->l->t('This share is valid until %s at midnight', [$dateString]), + $this->l->t('Expiration:'), + $this->getAbsoluteImagePath('caldav/time.png'), + ); + } + + $emailTemplate->addBodyButton( + $this->l->t('Open shared item'), + $link ); + + return $emailTemplate; + }; + + $instanceName = $this->defaults->getName(); + + // Try to send via the user's Mail Provider + $initiatorEmail = ($initiatorUser instanceof IUser) ? $initiatorUser->getEMailAddress() : null; + if ($this->sendViaMailProviderWithFallback($initiator, $initiatorEmail, $initiatorDisplayName, $emails, $templateFactory)) { + return; } - $emailTemplate->addBodyButton( - $this->l->t('Open shared item'), - $link - ); + $emailTemplate = $templateFactory(); + + // Fall back to the system mailer + $message = $this->mailer->createMessage(); // If multiple recipients are given, we send the mail to all of them if (count($emails) > 1) { @@ -394,7 +522,6 @@ protected function sendEmail(IShare $share, array $emails): void { } // The "From" contains the sharers name - $instanceName = $this->defaults->getName(); $senderName = $instanceName; if ($this->settingsManager->replyToInitiator()) { $senderName = $this->l->t( @@ -457,30 +584,44 @@ 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(); + $templateFactory = function () use ($filename, $password, $initiatorDisplayName, $initiatorEmailAddress, $shareWith, $htmlBodyPart, $plainBodyPart): \OCP\Mail\IEMailTemplate { + $emailTemplate = $this->mailer->createEMailTemplate('sharebymail.RecipientPasswordNotification', [ + 'filename' => $filename, + 'password' => $password, + 'initiator' => $initiatorDisplayName, + 'initiatorEmail' => $initiatorEmailAddress, + 'shareWith' => $shareWith, + ]); - $emailTemplate = $this->mailer->createEMailTemplate('sharebymail.RecipientPasswordNotification', [ - 'filename' => $filename, - 'password' => $password, - 'initiator' => $initiatorDisplayName, - 'initiatorEmail' => $initiatorEmailAddress, - 'shareWith' => $shareWith, - ]); + $emailTemplate->setSubject($this->l->t('Password to access %1$s shared to you by %2$s', [$filename, $initiatorDisplayName])); + $emailTemplate->addHeader(); + $emailTemplate->addHeading($this->l->t('Password to access %s', [$filename]), false); + $emailTemplate->addBodyText(htmlspecialchars($htmlBodyPart), $plainBodyPart); + $emailTemplate->addBodyText($this->l->t('It is protected with the following password:')); + $emailTemplate->addBodyText($password); + + if ($this->config->getSystemValue('sharing.enable_mail_link_password_expiration', false) === true) { + $expirationTime = new \DateTime(); + $expirationInterval = $this->config->getSystemValue('sharing.mail_link_password_expiration_interval', 3600); + $expirationTime = $expirationTime->add(new \DateInterval('PT' . $expirationInterval . 'S')); + $emailTemplate->addBodyText($this->l->t('This password will expire at %s', [$expirationTime->format('r')])); + } - $emailTemplate->setSubject($this->l->t('Password to access %1$s shared to you by %2$s', [$filename, $initiatorDisplayName])); - $emailTemplate->addHeader(); - $emailTemplate->addHeading($this->l->t('Password to access %s', [$filename]), false); - $emailTemplate->addBodyText(htmlspecialchars($htmlBodyPart), $plainBodyPart); - $emailTemplate->addBodyText($this->l->t('It is protected with the following password:')); - $emailTemplate->addBodyText($password); + return $emailTemplate; + }; - if ($this->config->getSystemValue('sharing.enable_mail_link_password_expiration', false) === true) { - $expirationTime = new \DateTime(); - $expirationInterval = $this->config->getSystemValue('sharing.mail_link_password_expiration_interval', 3600); - $expirationTime = $expirationTime->add(new \DateInterval('PT' . $expirationInterval . 'S')); - $emailTemplate->addBodyText($this->l->t('This password will expire at %s', [$expirationTime->format('r')])); + // Try to send via the user's Mail Provider + if ($this->sendViaMailProviderWithFallback($initiator, $initiatorEmailAddress, $initiatorDisplayName, $emails, $templateFactory)) { + $this->createPasswordSendActivity($share, $shareWith, false); + return true; } + $instanceName = $this->defaults->getName(); + + // Fall back to the system mailer + $emailTemplate = $templateFactory(); + $message = $this->mailer->createMessage(); + // 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 @@ -490,7 +631,6 @@ protected function sendPassword(IShare $share, string $password, array $emails): } // The "From" contains the sharers name - $instanceName = $this->defaults->getName(); $senderName = $instanceName; if ($this->settingsManager->replyToInitiator()) { $senderName = $this->l->t( @@ -542,24 +682,39 @@ 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(); + $templateFactory = function () use ($initiatorDisplayName, $htmlHeading, $plainHeading, $note, $share): \OCP\Mail\IEMailTemplate { + $emailTemplate = $this->mailer->createEMailTemplate('shareByMail.sendNote'); - $emailTemplate = $this->mailer->createEMailTemplate('shareByMail.sendNote'); + $emailTemplate->setSubject($this->l->t('%s added a note to a file shared with you', [$initiatorDisplayName])); + $emailTemplate->addHeader(); + $emailTemplate->addHeading(htmlspecialchars($htmlHeading), $plainHeading); + $emailTemplate->addBodyText(htmlspecialchars($note), $note); - $emailTemplate->setSubject($this->l->t('%s added a note to a file shared with you', [$initiatorDisplayName])); - $emailTemplate->addHeader(); - $emailTemplate->addHeading(htmlspecialchars($htmlHeading), $plainHeading); - $emailTemplate->addBodyText(htmlspecialchars($note), $note); + $link = $this->urlGenerator->linkToRouteAbsolute('files_sharing.sharecontroller.showShare', + ['token' => $share->getToken()]); + $emailTemplate->addBodyButton( + $this->l->t('Open shared item'), + $link + ); + + return $emailTemplate; + }; + + // Try to send via the user's Mail Provider + if ($this->sendViaMailProviderWithFallback($initiator, $initiatorEmailAddress, $initiatorDisplayName, [$recipient], $templateFactory)) { + return; + } + + $instanceName = $this->defaults->getName(); $link = $this->urlGenerator->linkToRouteAbsolute('files_sharing.sharecontroller.showShare', ['token' => $share->getToken()]); - $emailTemplate->addBodyButton( - $this->l->t('Open shared item'), - $link - ); + + // Fall back to the system mailer + $emailTemplate = $templateFactory(); + $message = $this->mailer->createMessage(); // The "From" contains the sharers name - $instanceName = $this->defaults->getName(); $senderName = $instanceName; if ($this->settingsManager->replyToInitiator()) { $senderName = $this->l->t( diff --git a/apps/sharebymail/src/components/AdminSettings.vue b/apps/sharebymail/src/components/AdminSettings.vue index 24832d9fc76f1..aa0a6e227c3f3 100644 --- a/apps/sharebymail/src/components/AdminSettings.vue +++ b/apps/sharebymail/src/components/AdminSettings.vue @@ -14,6 +14,13 @@ {{ t('sharebymail', 'Reply to initiator') }} + + + {{ t('sharebymail', 'Send share emails from user\'s email address') }} + +

+ {{ t('sharebymail', 'When enabled, share notification emails will be sent from the user\'s personal email address via their Mail Provider (e.g. Nextcloud Mail), similar to calendar invitations. Falls back to the system email if no Mail Provider is available.') }} +

@@ -43,6 +50,7 @@ export default { return { sendPasswordMail: loadState('sharebymail', 'sendPasswordMail'), replyToInitiator: loadState('sharebymail', 'replyToInitiator'), + useUserEmail: loadState('sharebymail', 'useUserEmail'), } }, @@ -54,6 +62,10 @@ export default { replyToInitiator(newValue) { this.update('replyToInitiator', newValue) }, + + useUserEmail(newValue) { + this.update('useUserEmail', newValue) + }, }, methods: { @@ -88,3 +100,12 @@ export default { }, } + + diff --git a/apps/sharebymail/tests/ShareByMailProviderTest.php b/apps/sharebymail/tests/ShareByMailProviderTest.php index c87748f6657ce..0ae210940f355 100644 --- a/apps/sharebymail/tests/ShareByMailProviderTest.php +++ b/apps/sharebymail/tests/ShareByMailProviderTest.php @@ -20,6 +20,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; @@ -29,6 +30,7 @@ use OCP\Mail\IEMailTemplate; use OCP\Mail\IMailer; use OCP\Mail\IMessage; +use OCP\Mail\Provider\IManager as IMailManager; use OCP\Security\Events\GenerateSecurePasswordEvent; use OCP\Security\IHasher; use OCP\Security\ISecureRandom; @@ -70,6 +72,8 @@ class ShareByMailProviderTest extends TestCase { private SettingsManager&MockObject $settingsManager; private IActivityManager&MockObject $activityManager; private IEventDispatcher&MockObject $eventDispatcher; + private IMailManager&MockObject $mailManager; + private IAppConfig&MockObject $appConfig; protected function setUp(): void { parent::setUp(); @@ -91,10 +95,14 @@ protected function setUp(): void { $this->share = $this->createMock(IShare::class); $this->activityManager = $this->createMock('OCP\Activity\IManager'); $this->settingsManager = $this->createMock(SettingsManager::class); + $this->settingsManager->expects($this->any())->method('useUserEmail')->willReturn(true); $this->defaults = $this->createMock(Defaults::class); $this->hasher = $this->createMock(IHasher::class); $this->eventDispatcher = $this->createMock(IEventDispatcher::class); $this->shareManager = $this->createMock(IManager::class); + $this->mailManager = $this->createMock(IMailManager::class); + $this->appConfig = $this->createMock(IAppConfig::class); + $this->appConfig->expects($this->any())->method('getValueBool')->willReturn(true); $this->userManager->expects($this->any())->method('userExists')->willReturn(true); $this->config->expects($this->any())->method('getAppValue')->with('core', 'enforce_strict_email_check')->willReturn('yes'); @@ -126,6 +134,8 @@ private function getInstance(array $mockedMethods = []) { $this->eventDispatcher, $this->shareManager, $this->getEmailValidatorWithStrictEmailCheck(), + $this->mailManager, + $this->appConfig, ]) ->onlyMethods($mockedMethods) ->getMock(); @@ -148,6 +158,8 @@ private function getInstance(array $mockedMethods = []) { $this->eventDispatcher, $this->shareManager, $this->getEmailValidatorWithStrictEmailCheck(), + $this->mailManager, + $this->appConfig, ); } diff --git a/tests/lib/Share20/ShareByMailProviderTest.php b/tests/lib/Share20/ShareByMailProviderTest.php index 50ed5a53445ee..b14ccd86bce44 100644 --- a/tests/lib/Share20/ShareByMailProviderTest.php +++ b/tests/lib/Share20/ShareByMailProviderTest.php @@ -16,12 +16,14 @@ use OCP\Defaults; use OCP\EventDispatcher\IEventDispatcher; use OCP\Files\IRootFolder; +use OCP\IAppConfig; use OCP\IConfig; use OCP\IDBConnection; use OCP\IL10N; use OCP\IURLGenerator; use OCP\IUserManager; use OCP\Mail\IMailer; +use OCP\Mail\Provider\IManager as IMailManager; use OCP\Security\IHasher; use OCP\Security\ISecureRandom; use OCP\Server; @@ -88,6 +90,12 @@ class ShareByMailProviderTest extends TestCase { /** @var SettingsManager|MockObject */ private $settingsManager; + /** @var IMailManager|MockObject */ + private $mailManager; + + /** @var IAppConfig|MockObject */ + private $appConfig; + #[\Override] protected function setUp(): void { parent::setUp(); @@ -102,11 +110,15 @@ protected function setUp(): void { $this->logger = $this->createMock(LoggerInterface::class); $this->activityManager = $this->createMock(\OCP\Activity\IManager::class); $this->settingsManager = $this->createMock(SettingsManager::class); + $this->settingsManager->expects($this->any())->method('useUserEmail')->willReturn(true); $this->hasher = $this->createMock(IHasher::class); $this->eventDispatcher = $this->createMock(IEventDispatcher::class); $this->shareManager = $this->createMock(\OCP\Share\IManager::class); $this->secureRandom = $this->createMock(ISecureRandom::class); $this->config = $this->createMock(IConfig::class); + $this->mailManager = $this->createMock(IMailManager::class); + $this->appConfig = $this->createMock(IAppConfig::class); + $this->appConfig->expects($this->any())->method('getValueBool')->willReturn(true); // Empty share table $this->dbConn->getQueryBuilder()->delete('share')->executeStatement(); @@ -128,6 +140,8 @@ protected function setUp(): void { $this->eventDispatcher, $this->shareManager, $this->getEmailValidatorWithStrictEmailCheck(), + $this->mailManager, + $this->appConfig, ); }