From 5b71bfc21486bc7b2478fd39d2c1e1b27a7c1abc Mon Sep 17 00:00:00 2001 From: SebastianKrupinski Date: Wed, 2 Oct 2024 20:14:09 -0400 Subject: [PATCH] feat: Internet Message Events Support Signed-off-by: SebastianKrupinski --- appinfo/routes.php | 5 + lib/Controller/ImeController.php | 115 ++++++++++ lib/Db/MailAccountMapper.php | 21 ++ lib/Service/AccountService.php | 17 ++ lib/Service/Ime/ImeService.php | 243 ++++++++++++++++++++++ lib/Settings/AdminSettings.php | 16 +- src/components/settings/AdminSettings.vue | 26 +++ 7 files changed, 442 insertions(+), 1 deletion(-) create mode 100644 lib/Controller/ImeController.php create mode 100644 lib/Service/Ime/ImeService.php diff --git a/appinfo/routes.php b/appinfo/routes.php index a7f988e4aa..82332aafad 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -485,6 +485,11 @@ 'url' => '/api/follow-up/check-message-ids', 'verb' => 'POST', ], + [ + 'name' => 'ime#index', + 'url' => '/ime', + 'verb' => 'POST', + ], ], 'resources' => [ 'accounts' => ['url' => '/api/accounts'], diff --git a/lib/Controller/ImeController.php b/lib/Controller/ImeController.php new file mode 100644 index 0000000000..aaeb239003 --- /dev/null +++ b/lib/Controller/ImeController.php @@ -0,0 +1,115 @@ +ImeService->getEnabled() || !$this->authorize()) { + return JsonResponse::fail(); + } + + $this->ImeService->handle($events); + + return JsonResponse::success(); + + } + + /** + * authorize ime request + * + * @return bool + */ + public function authorize(): bool { + + $restriction = $this->ImeService->getRestrictions(); + $source = $this->request->__get('server')['REMOTE_ADDR']; + + // evaluate, if id address restriction is set + if (!empty($restriction)) { + $addresses = explode(' ', $restriction); + foreach ($addresses as $entry) { + // evaluate, if ip address matches + if ($this->ipInCidr($source, $entry)) { + return true; + } + } + } + + return false; + + } + + protected function ipInCidr(string $ip, string $cidr): bool { + + if (str_contains($cidr, '/')) { + // split cidr and convert to parameters + list($cidr_net, $cidr_mask) = explode('/', $cidr); + // convert ip address and cidr network to binary + $ip = inet_pton($ip); + $cidr_net = inet_pton($cidr_net); + // evaluate, if ip is valid + if ($ip === false) { + throw new InvalidArgumentException('Invalid IP Address'); + } + // evaluate, if cidr network is valid + if ($cidr_net === false) { + throw new InvalidArgumentException('Invalid CIDR Network'); + } + // evaluate, if ip and network are the same version + if (strlen($ip) != strlen($cidr_net)) { + throw new InvalidArgumentException('IP Address and CIDR Network version do not match'); + } + + // determain the amount of full bit bytes and add them + $mask = str_repeat(chr(255), (int) floor($cidr_mask / 8)); + // determain, if any bits are remaing + if ((strlen($mask) * 8) < $cidr_mask) { + $mask .= chr(1 << (8 - ($cidr_mask - (strlen($mask) * 8)))); + } + // determain, the amount of empty bit bytes and add them + $mask = str_pad($mask, strlen($cidr_net), chr(0)); + + // Compare the mask + return ($ip & $mask) === ($cidr_net & $mask); + + } + else { + // return comparison + return inet_pton($ip) === inet_pton($cidr); + } + + } + +} diff --git a/lib/Db/MailAccountMapper.php b/lib/Db/MailAccountMapper.php index 0e41bbe976..e0b09fb552 100644 --- a/lib/Db/MailAccountMapper.php +++ b/lib/Db/MailAccountMapper.php @@ -104,6 +104,27 @@ public function findByUserIdAndAddress(string $userId, string $address): array { return $this->findEntities($query); } + /** + * Finds a mail account(s) by inbound user identity + * + * @since 5.0.0 + * + * @param string $userId remote system inbound user identity + * + * @return MailAccount[] + */ + public function findByInboundUserId(string $value): array { + + $qb = $this->db->getQueryBuilder(); + $query = $qb + ->select('*') + ->from($this->getTableName()) + ->where($qb->expr()->eq('inbound_user', $qb->createNamedParameter($value))); + + return $this->findEntities($query); + + } + /** * @throws DoesNotExistException * @throws MultipleObjectsReturnedException diff --git a/lib/Service/AccountService.php b/lib/Service/AccountService.php index dea1e33985..69c924097f 100644 --- a/lib/Service/AccountService.php +++ b/lib/Service/AccountService.php @@ -107,6 +107,23 @@ public function findByUserIdAndAddress(string $userId, string $address): array { }, $this->mapper->findByUserIdAndAddress($userId, $address)); } + /** + * Finds a mail account by inbound user identity + * + * @since 5.0.0 + * + * @param string $value remote system inbound user identity + * + * @return Account[] + */ + public function findByInboundUserId(string $value): array { + + return array_map(static function ($a) { + return new Account($a); + }, $this->mapper->findByInboundUserId($value)); + + } + /** * @param string $userId * @param int $id diff --git a/lib/Service/Ime/ImeService.php b/lib/Service/Ime/ImeService.php new file mode 100644 index 0000000000..bba13905df --- /dev/null +++ b/lib/Service/Ime/ImeService.php @@ -0,0 +1,243 @@ +config->getValueBool(Application::APP_ID, 'ime_enabled', false); + } + + public function setEnabled(bool $value): void { + $this->config->setValueBool(Application::APP_ID, 'ime_enabled', $value); + } + + public function getRestrictions(): string { + return $this->config->getValueString(Application::APP_ID, 'ime_restrict_ip', ''); + } + + public function setRestrictions(string $value): void { + $this->config->setValueString(Application::APP_ID, 'ime_restrict_ip', $value); + } + + public function handle(array $events): void { + + foreach ($events as $event) { + + // determine if basic required informaiton is present + if (!isset($event['type'])) { + continue; + } + if (!isset($event['user'])) { + continue; + } + // determine if user account exists + $accounts = $this->accountService->findByInboundUserId($event['user']); + if (count($accounts) === 0) { + continue; + } + + $result = match ($event['type']) { + 'MailboxCreate' => $this->handleMailboxCreate($accounts, $event), + 'MailboxDelete' => $this->handleMailboxDelete($accounts, $event), + 'MailboxRename' => $this->handleMailboxRename($accounts, $event), + 'MessageAppend' => $this->handleMessageAppend($accounts, $event), + 'MessageExpunge' => $this->handleMessageExpunge($accounts, $event), + default => "Unknown event type" + }; + } + + } + + public function handleMailboxCreate($accounts, array $event): void { + + // determine if basic required informaiton is present + if (!isset($event['folder'])) { + return; + } + // + foreach ($accounts as $account) { + + // retieve mailbox with the new name + // this should error as this mailbox should not exist yet + // if mailbox name already exists ignore the event as two mailboxes with the same name are not permitted. + try { + $mailbox = $this->mailboxMapper->find($account, $event['folder']); + } catch (DoesNotExistException $e) { + } + if (isset($mailbox)) { + continue; + } + + $mailbox = new Mailbox(); + $mailbox->setAccountId($account->getId()); + $mailbox->setName($event['folder']); + $mailbox->setNameHash(md5($event['folder'])); + $mailbox->setSelectable(true); + $mailbox->setDelimiter('/'); + $mailbox->setMessages(is_numeric($event['messages']) ? (int)$event['messages'] : 0); + $mailbox->setUnseen(is_numeric($event['unseen']) ? (int)$event['unseen'] : 0); + $mailbox = $this->mailboxMapper->insert($mailbox); + + } + + } + + public function handleMailboxDelete($accounts, array $event): void { + + // determine if basic required informaiton is present + if (!isset($event['folder'])) { + return; + } + // + foreach ($accounts as $account) { + + // retieve mailbox with the name + // if the mailbox does not exist ignore the event as this might be a lagging or duplicate event + // or the folder hierarchy has been updated in another way + try { + $mailbox = $this->mailboxMapper->find($account, $event['folder']); + } catch (DoesNotExistException $e) { + continue; + } + + $this->messageMapper->deleteAll($mailbox); + $this->mailboxMapper->delete($mailbox); + + } + + + } + + public function handleMailboxRename($accounts, array $event): void { + + // determine if basic required informaiton is present + if (!isset($event['folder_from'])) { + return; + } + if (!isset($event['folder_to'])) { + return; + } + // + foreach ($accounts as $account) { + + // retieve mailbox with the new name + // this should error as this mailbox should not exist yet + // if mailbox name already exists ignore the event as two mailboxes with the same name are not permitted. + try { + $mailbox = $this->mailboxMapper->find($account, $event['folder_to']); + } catch (DoesNotExistException $e) { + } + if (isset($mailbox)) { + continue; + } + // retieve mailbox with the current name + // if the mailbox does not exist ignore the event as this might be a lagging or duplicate event + // or the folder hierarchy has been updated in another way + try { + $mailbox = $this->mailboxMapper->find($account, $event['folder_from']); + } catch (DoesNotExistException $e) { + continue; + } + // if we got this far its safe to update the folder name + $mailbox->setName($event['folder_to']); + $mailbox->setNameHash(md5($event['folder_to'])); + if (is_numeric($event['messages'])) { $mailbox->setMessages((int)$event['messages']); } + if (is_numeric($event['unseen'])) { $mailbox->setUnseen((int)$event['unseen']); } + $this->mailboxMapper->update($mailbox); + + } + + } + + public function handleMessageAppend($accounts, array $event): void { + + // determine if basic required informaiton is present + if (!isset($event['folder_id'])) { + return; + } + if (!is_numeric($event['uid'])) { + return; + } + // + foreach ($accounts as $account) { + + // retieve mailbox + // if the mailbox does not exist ignore the event as this might be a lagging event + // or the folder hierarchy has been updated in another way + try { + $mailbox = $this->mailboxMapper->find($account, $event['folder_id']); + } catch (DoesNotExistException $e) { + continue; + } + // update mailbox + if (is_numeric($event['messages'])) { $mailbox->setMessages((int)$event['messages']); } + if (is_numeric($event['unseen'])) { $mailbox->setUnseen((int)$event['unseen']); } + $this->mailboxMapper->update($mailbox); + // create message + $message = new Message(); + $message->setMailboxId($mailbox->getId()); + $message->setUid((int)$event['uid']); + $message->setSentAt(is_numeric($event['date']) ? (int)$event['date'] : 0); + $message->setSubject($event['subject']); + $message->setPreviewText($event['snippet']); + $this->messageMapper->insert($message); + + } + + } + + public function handleMessageExpunge($accounts, array $event): void { + + // determine if basic required informaiton is present + if (!isset($event['folder_id'])) { + return; + } + if (!is_numeric($event['uid'])) { + return; + } + // + foreach ($accounts as $account) { + + // retieve mailbox + // if the mailbox does not exist ignore the event as this might be a lagging event + // or the folder hierarchy has been updated in another way + try { + $mailbox = $this->mailboxMapper->find($account, $event['folder_id']); + } catch (DoesNotExistException $e) { + continue; + } + // update mailbox + if (is_numeric($event['messages'])) { $mailbox->setMessages((int)$event['messages']); } + if (is_numeric($event['unseen'])) { $mailbox->setUnseen((int)$event['unseen']); } + $this->mailboxMapper->update($mailbox); + // delete message + $this->messageMapper->deleteByUid($mailbox, (int)$event['uid']); + + } + + } +} diff --git a/lib/Settings/AdminSettings.php b/lib/Settings/AdminSettings.php index 1b42644b8c..6f45708eb6 100644 --- a/lib/Settings/AdminSettings.php +++ b/lib/Settings/AdminSettings.php @@ -15,6 +15,7 @@ use OCA\Mail\Service\AiIntegrations\AiIntegrationsService; use OCA\Mail\Service\AntiSpamService; use OCA\Mail\Service\Classification\ClassificationSettingsService; +use OCA\Mail\Service\Ime\ImeService; use OCA\Mail\Service\Provisioning\Manager as ProvisioningManager; use OCP\AppFramework\Http\TemplateResponse; use OCP\IConfig; @@ -47,7 +48,8 @@ public function __construct(IInitialStateService $initialStateService, MicrosoftIntegration $microsoftIntegration, IConfig $config, AiIntegrationsService $aiIntegrationsService, - ClassificationSettingsService $classificationSettingsService) { + ClassificationSettingsService $classificationSettingsService, + private ImeService $imeService) { $this->initialStateService = $initialStateService; $this->provisioningManager = $provisioningManager; $this->antiSpamService = $antiSpamService; @@ -98,6 +100,18 @@ public function getForm() { $this->aiIntegrationsService->isLlmAvailable(SummaryTaskType::class) ); + $this->initialStateService->provideInitialState( + Application::APP_ID, + 'ime_enabled', + $this->imeService->getEnabled() + ); + + $this->initialStateService->provideInitialState( + Application::APP_ID, + 'ime_restrictions', + $this->imeService->getRestrictions() + ); + $this->initialStateService->provideLazyInitialState( Application::APP_ID, 'ldap_aliases_integration', diff --git a/src/components/settings/AdminSettings.vue b/src/components/settings/AdminSettings.vue index 7748099a7e..9acd83627b 100644 --- a/src/components/settings/AdminSettings.vue +++ b/src/components/settings/AdminSettings.vue @@ -161,6 +161,28 @@

+
+

{{ t('mail', 'Internet Message Events Service') }}

+
+

+ {{ t('mail', 'The Internet Message Events Service allows compatible mail servers to push notify the mail app of folder and message changes preformed by other mail clients, allow the mail app to stay in sync with the mail server in real time.') }} +

+

+ + {{ t('mail', 'Enable IME Service') }} + +

+

+ + +

+
+

{{ @@ -269,6 +291,7 @@ import IconAdd from 'vue-material-design-icons/Plus.vue' import IconSettings from 'vue-material-design-icons/Cog.vue' import SettingsSection from '@nextcloud/vue/dist/Components/NcSettingsSection.js' import NcCheckboxRadioSwitch from '@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch.js' +import NcInputField from '@nextcloud/vue/dist/Components/NcInputField.js' import { disableProvisioning, createProvisioningSettings, @@ -298,6 +321,7 @@ export default { IconAdd, IconSettings, NcCheckboxRadioSwitch, + NcInputField, }, props: { provisioningSettings: { @@ -342,6 +366,8 @@ export default { isLlmFreePromptConfigured: loadState('mail', 'enabled_llm_free_prompt_backend'), isClassificationEnabledByDefault: loadState('mail', 'llm_processing', true), isImportanceClassificationEnabledByDefault: loadState('mail', 'importance_classification_default', true), + imeEnabled: loadState('mail', 'ime_enabled', true), + imeRestrictions: loadState('mail', 'ime_restrictions', ''), } }, methods: {