From 221745f7220d5e143d30c78d5d7c7fd3274e50d2 Mon Sep 17 00:00:00 2001 From: SebastianKrupinski Date: Tue, 9 Jul 2024 11:08:24 -0400 Subject: [PATCH] feat: mail provider backend Signed-off-by: SebastianKrupinski --- lib/AppInfo/Application.php | 9 + lib/Db/MailAccountMapper.php | 23 +++ lib/Provider/Command/MessageSend.php | 87 ++++++++ lib/Provider/MailProvider.php | 238 +++++++++++++++++++++ lib/Provider/MailService.php | 250 +++++++++++++++++++++++ lib/Provider/MailServiceIdentity.php | 28 +++ lib/Provider/MailServiceLocation.php | 28 +++ lib/Service/AccountService.php | 40 ++++ tests/Unit/Provider/MailProviderTest.php | 233 +++++++++++++++++++++ tests/Unit/Provider/MailServiceTest.php | 112 ++++++++++ 10 files changed, 1048 insertions(+) create mode 100644 lib/Provider/Command/MessageSend.php create mode 100644 lib/Provider/MailProvider.php create mode 100644 lib/Provider/MailService.php create mode 100644 lib/Provider/MailServiceIdentity.php create mode 100644 lib/Provider/MailServiceLocation.php create mode 100644 tests/Unit/Provider/MailProviderTest.php create mode 100644 tests/Unit/Provider/MailServiceTest.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 6e4ae2dfde..085a0679cb 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -55,6 +55,7 @@ use OCA\Mail\Listener\SpamReportListener; use OCA\Mail\Listener\UserDeletedListener; use OCA\Mail\Notification\Notifier; +use OCA\Mail\Provider\MailProvider; use OCA\Mail\Search\FilteringProvider; use OCA\Mail\Search\Provider; use OCA\Mail\Service\Attachment\AttachmentService; @@ -164,6 +165,14 @@ public function register(IRegistrationContext $context): void { $context->registerSearchProvider(Provider::class); } + + // TODO: drop condition if nextcloud < 30 is not supported anymore + // evaluate, if mail provider registration is possible + if (method_exists($context, 'registerMailProvider')) { + // register mail provider + $context->registerMailProvider(MailProvider::class); + } + $context->registerNotifierService(Notifier::class); // bypass Horde Translation system diff --git a/lib/Db/MailAccountMapper.php b/lib/Db/MailAccountMapper.php index b42c1908c4..6797a5df6b 100644 --- a/lib/Db/MailAccountMapper.php +++ b/lib/Db/MailAccountMapper.php @@ -83,6 +83,29 @@ public function findByUserId(string $userId): array { return $this->findEntities($query); } + /** + * Finds a mail account(s) by user id and mail address + * + * @since 2024.05.25 + * + * @param string $userId system user id + * @param string $address mail address (e.g. test@example.com) + * + * @return MailAccount[] + * + * @throws DoesNotExistException + */ + public function findByUserIdAndAddress(string $userId, string $address): array { + $qb = $this->db->getQueryBuilder(); + $query = $qb + ->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('user_id', $qb->createNamedParameter($userId))) + ->andWhere($qb->expr()->eq('email', $qb->createNamedParameter($address))); + + return $this->findEntities($query); + } + /** * @throws DoesNotExistException * @throws MultipleObjectsReturnedException diff --git a/lib/Provider/Command/MessageSend.php b/lib/Provider/Command/MessageSend.php new file mode 100644 index 0000000000..ad6cfca519 --- /dev/null +++ b/lib/Provider/Command/MessageSend.php @@ -0,0 +1,87 @@ +accountService->find($userId, (int) $serviceId); + // convert mail provider message to local message + $lm = new LocalMessage(); + $lm->setType($lm::TYPE_OUTGOING); + $lm->setAccountId($account->getId()); + $lm->setSubject($message->getSubject()); + $lm->setBody($message->getBody()); + $lm->setHtml(true); + $lm->setSendAt(time()); + + // convert all mail provider attachments to local attachments + $attachments = []; + if (count($message->getAttachments()) > 0) { + // iterate attachments and save them + foreach ($message->getAttachments() as $entry) { + $attachments[] = $this->attachmentService->addFileFromString( + $userId, + $entry->getName(), + $entry->getType(), + $entry->getContents() + )->jsonSerialize(); + } + } + // convert recipiant addresses + $to = $this->convertAddressArray($message->getTo()); + $cc = $this->convertAddressArray($message->getCc()); + $bcc = $this->convertAddressArray($message->getBcc()); + // save message for sending + $lm = $this->outboxService->saveMessage( + $account, + $lm, + $to, + $cc, + $bcc, + $attachments + ); + + // evaluate if job scheduler is NOT cron, send message right away otherwise let cron job handle it + if ($this->config->getAppValue('core', 'backgroundjobs_mode', 'ajax') !== 'cron') { + $lm = $this->outboxService->sendMessage($lm, $account); + } + + } + + protected function convertAddressArray(array|null $in) { + // construct place holder + $out = []; + // convert format + foreach ($in as $entry) { + $out[] = (!empty($entry->getLabel())) ? ['email' => $entry->getAddress(), 'label' => $entry->getLabel()] : ['email' => $entry->getAddress()]; + } + // return converted addressess + return $out; + } + +} diff --git a/lib/Provider/MailProvider.php b/lib/Provider/MailProvider.php new file mode 100644 index 0000000000..b7d9c145ef --- /dev/null +++ b/lib/Provider/MailProvider.php @@ -0,0 +1,238 @@ +listServices($userId)) > 0); + + } + + /** + * retrieve collection of services for a specific user + * + * @since 2024.05.25 + * + * @param string $userId user id + * + * @return array collection of service id and object ['1' => IServiceObject] + */ + public function listServices(string $userId): array { + + try { + // retrieve service(s) details from data store + $accounts = $this->AccountService->findByUserId($userId); + } catch (\Throwable $th) { + return []; + } + // construct temporary collection + $services = []; + // add services to collection + foreach ($accounts as $entry) { + // extract values + $serviceId = (string) $entry->getId(); + $label = $entry->getName(); + $address = new MailAddress($entry->getEmail(), $entry->getName()); + $identity = new MailServiceIdentity(); + $location = new MailServiceLocation(); + // add service to collection + $services[$serviceId] = new MailService($this->container, $userId, $serviceId, $label, $address, $identity, $location); + } + // return list of services for user + return $services; + + } + + /** + * retrieve a service with a specific id + * + * @since 2024.05.25 + * + * @param string $userId user id + * @param string $serviceId service id + * + * @return IService|null returns service object or null if none found + */ + public function findServiceById(string $userId, string $serviceId): IService | null { + + // evaluate if id is a number + if (is_numeric($serviceId)) { + try { + // retrieve service details from data store + $account = $this->AccountService->find($userId, (int) $serviceId); + } catch(\Throwable $th) { + return null; + } + } + // evaliate if service details where found + if ($account instanceof Account) { + // extract values + $serviceId = (string) $account->getId(); + $label = $account->getName(); + $address = new MailAddress($account->getEmail(), $account->getName()); + $identity = new MailServiceIdentity(); + $location = new MailServiceLocation(); + // return mail service object + return (new MailService($this->container, $userId, $serviceId, $label, $address, $identity, $location)); + } + + return null; + + } + + /** + * retrieve a service for a specific mail address + * + * @since 2024.05.25 + * + * @param string $userId user id + * @param string $address mail address (e.g. test@example.com) + * + * @return IService returns service object or null if none found + */ + public function findServiceByAddress(string $userId, string $address): IService | null { + + try { + // retrieve service details from data store + $accounts = $this->AccountService->findByUserIdAndAddress($userId, $address); + } catch(\Throwable $th) { + return null; + } + // evaliate if service details where found + if (is_array($accounts) && count($accounts) > 0 && $accounts[0] instanceof Account) { + // extract values + $serviceId = (string) $accounts[0]->getId(); + $label = $accounts[0]->getName(); + $address = new MailAddress($accounts[0]->getEmail(), $accounts[0]->getName()); + $identity = new MailServiceIdentity(); + $location = new MailServiceLocation(); + // return mail service object + return (new MailService($this->container, $userId, $serviceId, $label, $address, $identity, $location)); + } + + return null; + + } + + /** + * construct a new empty service object + * + * @since 30.0.0 + * + * @return IService blank service object + */ + public function initiateService(): IService { + + return (new MailService($this->container, $userId, '', '', '')); + + } + + /** + * create a service configuration for a specific user + * + * @since 2024.05.25 + * + * @param string $userId user id + * @param IService $service service object + * + * @return string id of created service + */ + public function createService(string $userId, IService $service): string { + + return ''; + + } + + /** + * modify a service configuration for a specific user + * + * @since 2024.05.25 + * + * @param string $userId user id + * @param IService $service service object + * + * @return string id of modifided service + */ + public function modifyService(string $userId, IService $service): string { + + return ''; + + } + + /** + * delete a service configuration for a specific user + * + * @since 2024.05.25 + * + * @param string $userId user id + * @param IService $service service object + * + * @return bool status of delete action + */ + public function deleteService(string $userId, IService $service): bool { + + return false; + + } + +} diff --git a/lib/Provider/MailService.php b/lib/Provider/MailService.php new file mode 100644 index 0000000000..ea9571a6ed --- /dev/null +++ b/lib/Provider/MailService.php @@ -0,0 +1,250 @@ +serviceId; + + } + + /** + * checks or retrieves what capabilites the service has + * + * @since 2024.05.25 + * + * @param string $ability required ability e.g. 'MessageSend' + * + * @return bool|array true/false if ability is supplied, collection of abilities otherwise + */ + public function capable(?string $ability = null): bool | array { + + // define all abilities + $abilities = [ + 'MessageSend' => true, + ]; + // evaluate if required ability was specified + if (isset($ability)) { + return (isset($abilities[$ability]) ? (bool) $abilities[$ability] : false); + } else { + return $abilities; + } + + } + + /** + * gets the localized human frendly name of this service + * + * @since 2024.05.25 + * + * @return string label/name of service (e.g. ACME Company Mail Service) + */ + public function getLabel(): string { + + return $this->serviceLabel; + + } + + /** + * sets the localized human frendly name of this service + * + * @since 2024.05.25 + * + * @param string $value label/name of service (e.g. ACME Company Mail Service) + * + * @return self return this object for command chaining + */ + public function setLabel(string $value): self { + + $this->serviceLabel = $value; + return $this; + + } + + /** + * gets service itentity + * + * @since 2024.05.25 + * + * @return IServiceIdentity service identity object + */ + public function getIdentity(): IServiceIdentity | null { + + return $this->serviceIdentity; + + } + + /** + * sets service identity + * + * @since 2024.05.25 + * + * @param IServiceIdentity $identity service identity object + * + * @return self return this object for command chaining + */ + public function setIdentity(IServiceIdentity $value): self { + + $this->serviceIdentity = $value; + return $this; + } + + /** + * gets service location + * + * @since 2024.05.25 + * + * @return IServiceLocation service location object + */ + public function getLocation(): IServiceLocation | null { + + return $this->serviceLocation; + + } + + /** + * sets service location + * + * @since 2024.05.25 + * + * @param IServiceLocation $location service location object + * + * @return self return this object for command chaining + */ + public function setLocation(IServiceLocation $value): self { + + $this->serviceLocation = $value; + return $this; + + } + + /** + * gets the primary mailing address for this service + * + * @since 2024.05.25 + * + * @return IAddress mail address object + */ + public function getPrimaryAddress(): IAddress { + + // retrieve and return primary service address + return $this->servicePrimaryAddress; + + } + + /** + * sets the primary mailing address for this service + * + * @since 2024.05.25 + * + * @param IAddress $value mail address object + * + * @return self return this object for command chaining + */ + public function setPrimaryAddress(IAddress $value): self { + + $this->servicePrimaryAddress = $value; + return $this; + + } + + /** + * gets the secondary mailing addresses (aliases) collection for this service + * + * @since 2024.05.25 + * + * @return array collection of mail address object [IAddress, IAddress] + */ + public function getSecondaryAddresses(): array { + + // retrieve and return secondary service addressess (aliases) collection + return $this->serviceSecondaryAddresses; + + } + + /** + * sets the secondary mailing addresses (aliases) for this service + * + * @since 2024.05.25 + * + * @param IAddress ...$value collection of one or more mail address object + * + * @return self return this object for command chaining + */ + public function setSecondaryAddresses(IAddress ...$value): self { + + $this->serviceSecondaryAddresses = $value; + return $this; + + } + + /** + * construct a new empty message object + * + * @since 30.0.0 + * + * @return IMessage blank message object + */ + public function initiateMessage(): IMessage { + + return (new Message()); + + } + + /** + * sends an outbound message + * + * @since 2024.05.25 + * + * @param IMessage $message mail message object with all required parameters to send a message + * + * @param array $options array of options reserved for future use + */ + public function sendMessage(IMessage $message, array $option = []): void { + + // load action + $cmd = $this->container->get(\OCA\Mail\Provider\Command\MessageSend::class); + // perform action + $cmd->perform($this->userId, $this->serviceId, $message, $option); + + } + +} diff --git a/lib/Provider/MailServiceIdentity.php b/lib/Provider/MailServiceIdentity.php new file mode 100644 index 0000000000..8f4816cd05 --- /dev/null +++ b/lib/Provider/MailServiceIdentity.php @@ -0,0 +1,28 @@ +mapper->findById($id)); } + /** + * Finds a mail account by user id and mail address + * + * @since 2024.05.25 + * + * @param string $userId system user id + * @param string $address mail address (e.g. test@example.com) + * + * @return Account[] + * + * @throws ClientException + */ + public function findByUserIdAndAddress(string $userId, string $address): array { + // evaluate if cached accounts collection already exists + if (isset($this->accounts[$userId])) { + // initialize tempory collection + $list = []; + // iterate through accounts and find accounts matching mail address + foreach ($this->accounts[$userId] as $account) { + if ($account->getEmail() === $address) { + $list[] = $account; + } + } + // evaluate if any accounts where found and return them + if (count($list) > 0) { + return $list; + } + // if no accounts where found thrown an error + throw new ClientException("Account with address $address does not exist or you don\'t have permission to access it"); + } + // if cached accounts collection did not exist retrieve account details directly from the data store + try { + return array_map(static function ($a) { + return new Account($a); + }, $this->mapper->findByUserIdAndAddress($userId, $address)); + } catch (DoesNotExistException $e) { + throw new ClientException("Account with address $address does not exist or you don\'t have permission to access it"); + } + } + /** * @param string $userId * @param int $id diff --git a/tests/Unit/Provider/MailProviderTest.php b/tests/Unit/Provider/MailProviderTest.php new file mode 100644 index 0000000000..4b245aa32a --- /dev/null +++ b/tests/Unit/Provider/MailProviderTest.php @@ -0,0 +1,233 @@ +containerInterface = $this->createMock(ContainerInterface::class); + $this->accountService = $this->createMock(AccountService::class); + + } + + public function testId(): void { + + // construct mail provider + $mailProvider = new MailProvider($this->containerInterface, $this->accountService); + // test set by constructor + $this->assertEquals('mail-application', $mailProvider->id()); + + } + + public function testLabel(): void { + + // construct mail provider + $mailProvider = new MailProvider($this->containerInterface, $this->accountService); + // test set by constructor + $this->assertEquals('Mail Application', $mailProvider->label()); + + } + + public function testHasServices(): void { + + // construct dummy mail account + $mailAccount = new Account(new MailAccount([ + 'accountId' => 100, + 'accountName' => 'User One', + 'emailAddress' => 'user1@testing.com', + 'imapHost' => '', + 'imapPort' => '', + 'imapSslMode' => false, + 'imapUser' => '', + 'smtpHost' => '', + 'smtpPort' => '', + 'smtpSslMode' => false, + 'smtpUser' => '', + ])); + // define account services find + $this->accountService + ->expects($this->any()) + ->method('findByUserId') + ->will( + $this->returnValueMap( + [ + ['user0', []], + ['user1', [100 => $mailAccount]] + ] + ) + ); + // construct mail provider + $mailProvider = new MailProvider($this->containerInterface, $this->accountService); + // test result with no services found + $this->assertFalse($mailProvider->hasServices('user0')); + // test result with services found + $this->assertTrue($mailProvider->hasServices('user1')); + + } + + public function testListServices(): void { + + // construct dummy mail account + $mailAccount = new Account(new MailAccount([ + 'accountId' => 100, + 'accountName' => 'User One', + 'emailAddress' => 'user1@testing.com', + 'imapHost' => '', + 'imapPort' => '', + 'imapSslMode' => false, + 'imapUser' => '', + 'smtpHost' => '', + 'smtpPort' => '', + 'smtpSslMode' => false, + 'smtpUser' => '', + ])); + // construct dummy mail service + $mailService = new MailService( + $this->containerInterface, + 'user1', + '100', + 'User One', + new MailAddress('user1@testing.com', 'User One'), + new MailServiceIdentity(), + new MailServiceLocation() + ); + // define account services find + $this->accountService + ->expects($this->any()) + ->method('findByUserId') + ->will( + $this->returnValueMap( + [ + ['user0', []], + ['user1', [$mailAccount]] + ] + ) + ); + // construct mail provider + $mailProvider = new MailProvider($this->containerInterface, $this->accountService); + // test result with no services found + $this->assertEquals([], $mailProvider->listServices('user0')); + // test result with services found + $this->assertEquals([100 => $mailService], $mailProvider->listServices('user1')); + + } + + public function testFindServiceById(): void { + + // construct dummy mail account + $mailAccount = new Account(new MailAccount([ + 'accountId' => 100, + 'accountName' => 'User One', + 'emailAddress' => 'user1@testing.com', + 'imapHost' => '', + 'imapPort' => '', + 'imapSslMode' => false, + 'imapUser' => '', + 'smtpHost' => '', + 'smtpPort' => '', + 'smtpSslMode' => false, + 'smtpUser' => '', + ])); + // construct dummy mail service + $mailService = new MailService( + $this->containerInterface, + 'user1', + '100', + 'User One', + new MailAddress('user1@testing.com', 'User One'), + new MailServiceIdentity(), + new MailServiceLocation() + ); + // define account services find + $this->accountService + ->expects($this->any()) + ->method('find') + ->will( + $this->returnValueMap( + [ + ['user0', 100, $this->throwException(new ClientException())], + ['user1', 100, $mailAccount] + ] + ) + ); + // construct mail provider + $mailProvider = new MailProvider($this->containerInterface, $this->accountService); + // test result with no services found + $this->assertEquals(null, $mailProvider->findServiceById('user0', '100')); + // test result with services found + $this->assertEquals($mailService, $mailProvider->findServiceById('user1', '100')); + + } + + public function testFindServiceByAddress(): void { + + // construct dummy mail account + $mailAccount = new Account(new MailAccount([ + 'accountId' => 100, + 'accountName' => 'User One', + 'emailAddress' => 'user1@testing.com', + 'imapHost' => '', + 'imapPort' => '', + 'imapSslMode' => false, + 'imapUser' => '', + 'smtpHost' => '', + 'smtpPort' => '', + 'smtpSslMode' => false, + 'smtpUser' => '', + ])); + // construct dummy mail service + $mailService = new MailService( + $this->containerInterface, + 'user1', + '100', + 'User One', + new MailAddress('user1@testing.com', 'User One'), + new MailServiceIdentity(), + new MailServiceLocation() + ); + // define account services find + $this->accountService + ->expects($this->any()) + ->method('findByUserIdAndAddress') + ->will( + $this->returnValueMap( + [ + ['user0', 'user0@testing.com', $this->throwException(new ClientException())], + ['user1', 'user1@testing.com', [$mailAccount]] + ] + ) + ); + // construct mail provider + $mailProvider = new MailProvider($this->containerInterface, $this->accountService); + // test result with no services found + $this->assertEquals(null, $mailProvider->findServiceByAddress('user0', 'user0@testing.com')); + // test result with services found + $this->assertEquals($mailService, $mailProvider->findServiceByAddress('user1', 'user1@testing.com')); + + } + +} diff --git a/tests/Unit/Provider/MailServiceTest.php b/tests/Unit/Provider/MailServiceTest.php new file mode 100644 index 0000000000..ed249165d1 --- /dev/null +++ b/tests/Unit/Provider/MailServiceTest.php @@ -0,0 +1,112 @@ +createMock(ContainerInterface::class); + + $this->primaryAddress = new Address('test@testing.com', 'Tester'); + $this->mailServiceIdentity = new MailServiceIdentity(); + $this->mailServiceLocation = new MailServiceLocation(); + + $this->mailService = new MailService( + $container, + 'user1', + 'service1', + 'Mail Service', + $this->primaryAddress, + $this->mailServiceIdentity, + $this->mailServiceLocation + ); + } + + public function testId(): void { + + $this->assertEquals('service1', $this->mailService->id()); + + } + + public function testCapable(): void { + + // test matched result + $this->assertEquals(true, $this->mailService->capable('MessageSend')); + // test not matched result + $this->assertEquals(false, $this->mailService->capable('NoMatch')); + // test collection result + $this->assertEquals([ + 'MessageSend' => true, + ], $this->mailService->capable()); + + } + + public function testLabel(): void { + + // test set by constructor + $this->assertEquals('Mail Service', $this->mailService->getLabel()); + // test set by setter + $this->mailService->setLabel('Mail Service 2'); + $this->assertEquals('Mail Service 2', $this->mailService->getLabel()); + + } + + public function testIdentity(): void { + + // test set by constructor + $this->assertEquals($this->mailServiceIdentity, $this->mailService->getIdentity()); + + } + + public function testLocation(): void { + + // test set by constructor + $this->assertEquals($this->mailServiceLocation, $this->mailService->getLocation()); + + } + + public function testPrimaryAddress(): void { + + // test set by constructor + $this->assertEquals($this->primaryAddress, $this->mailService->getPrimaryAddress()); + // test set by setter + $address = new Address('tester@testing.com'); + $this->mailService->setPrimaryAddress($address); + $this->assertEquals($address, $this->mailService->getPrimaryAddress()); + + } + + public function testSecondaryAddresses(): void { + + // test set by setter + $address1 = new Address('test1@testing.com'); + $address2 = new Address('test2@testing.com'); + $this->mailService->setSecondaryAddresses($address1, $address2); + $this->assertEquals([$address1, $address2], $this->mailService->getSecondaryAddresses()); + + } + +}