diff --git a/appinfo/info.xml b/appinfo/info.xml index 01b372f99b..03af0e9d64 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -99,6 +99,8 @@ Learn more about the Nextcloud Ethical AI Rating [in our blog](https://nextcloud OCA\Mail\Settings\AdminSettings + OCA\Mail\Settings\ProviderAccountOverviewSettings + OCA\Mail\Settings\Section\MailProviderAccountsSection diff --git a/appinfo/routes.php b/appinfo/routes.php index addac33608..c54fee7967 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -135,6 +135,21 @@ 'url' => '/api/providers/{providerId}/password', 'verb' => 'POST' ], + [ + 'name' => 'externalAccounts#indexMailboxes', + 'url' => '/api/providers/{providerId}/mailboxes', + 'verb' => 'GET' + ], + [ + 'name' => 'externalAccounts#updateMailbox', + 'url' => '/api/providers/{providerId}/mailboxes/{userId}', + 'verb' => 'PUT' + ], + [ + 'name' => 'externalAccounts#destroyMailbox', + 'url' => '/api/providers/{providerId}/mailboxes/{userId}', + 'verb' => 'DELETE' + ], [ 'name' => 'tags#update', 'url' => '/api/tags/{id}', diff --git a/composer.json b/composer.json index 03c8fabdc5..1235d8e2d8 100644 --- a/composer.json +++ b/composer.json @@ -54,7 +54,7 @@ "source": { "type": "git", "url": "https://github.com/ionos-productivity/ionos-mail-configuration-api-client.git", - "reference": "2.0.0-20251208083401" + "reference": "2.0.0-20260210132735" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index d99348d985..5822e59de5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "fb553591efe3fd5dbaed693076de60c6", + "content-hash": "be0c6c53c9b00d2b30f96797ecdc80e2", "packages": [ { "name": "amphp/amp", @@ -1860,7 +1860,7 @@ "source": { "type": "git", "url": "https://github.com/ionos-productivity/ionos-mail-configuration-api-client.git", - "reference": "2.0.0-20251208083401" + "reference": "2.0.0-20260210132735" }, "type": "library", "autoload": { diff --git a/lib/Controller/ExternalAccountsController.php b/lib/Controller/ExternalAccountsController.php index b2e968ee50..4782106979 100644 --- a/lib/Controller/ExternalAccountsController.php +++ b/lib/Controller/ExternalAccountsController.php @@ -248,6 +248,190 @@ public function generatePassword(string $providerId): JSONResponse { } } + /** + * List all mailboxes for a specific provider + * + * @NoAdminRequired + * + * @param string $providerId The provider ID + * @return JSONResponse + */ + #[TrapError] + public function indexMailboxes(string $providerId): JSONResponse { + try { + $userId = $this->getUserIdOrFail(); + + $this->logger->debug('Listing mailboxes for provider', [ + 'providerId' => $providerId, + 'userId' => $userId, + ]); + + $provider = $this->providerRegistry->getProvider($providerId); + if ($provider === null) { + return MailJsonResponse::fail([ + 'error' => self::ERR_PROVIDER_NOT_FOUND, + 'message' => 'Provider not found: ' . $providerId, + ], Http::STATUS_NOT_FOUND); + } + + $mailboxes = $provider->getMailboxes(); + + return MailJsonResponse::success(['mailboxes' => $mailboxes]); + } catch (\Exception $e) { + $this->logger->error('Error listing mailboxes', [ + 'providerId' => $providerId, + 'exception' => $e, + ]); + return MailJsonResponse::error('Could not list mailboxes'); + } + } + + /** + * Update a mailbox (e.g., change localpart) + * + * @NoAdminRequired + * + * @param string $providerId The provider ID + * @param string $userId The user ID whose mailbox to update + * @return JSONResponse + */ + #[TrapError] + public function updateMailbox(string $providerId, string $userId): JSONResponse { + try { + $currentUserId = $this->getUserIdOrFail(); + + // Get update data from request + $data = $this->request->getParams(); + unset($data['providerId']); + unset($data['userId']); + unset($data['_route']); + + // Validate localpart if provided + if (isset($data['localpart'])) { + $localpart = trim($data['localpart']); + if (empty($localpart)) { + return MailJsonResponse::fail([ + 'error' => self::ERR_INVALID_PARAMETERS, + 'message' => 'Localpart cannot be empty', + ], Http::STATUS_BAD_REQUEST); + } + // Basic validation: alphanumeric, dots, hyphens, underscores + if (!preg_match('/^[a-zA-Z0-9._-]+$/', $localpart)) { + return MailJsonResponse::fail([ + 'error' => self::ERR_INVALID_PARAMETERS, + 'message' => 'Localpart contains invalid characters', + ], Http::STATUS_BAD_REQUEST); + } + $data['localpart'] = $localpart; + } + + $this->logger->info('Updating mailbox', [ + 'providerId' => $providerId, + 'userId' => $userId, + 'currentUserId' => $currentUserId, + 'data' => array_keys($data), + ]); + + $provider = $this->providerRegistry->getProvider($providerId); + if ($provider === null) { + return MailJsonResponse::fail([ + 'error' => self::ERR_PROVIDER_NOT_FOUND, + 'message' => 'Provider not found: ' . $providerId, + ], Http::STATUS_NOT_FOUND); + } + + $mailbox = $provider->updateMailbox($userId, $data); + + $this->logger->info('Mailbox updated successfully', [ + 'userId' => $userId, + 'email' => $mailbox['email'] ?? null, + ]); + + return MailJsonResponse::success($mailbox); + } catch (\OCA\Mail\Exception\AccountAlreadyExistsException $e) { + $this->logger->warning('Email address already taken', [ + 'providerId' => $providerId, + 'userId' => $userId, + ]); + return MailJsonResponse::fail([ + 'error' => 'EMAIL_ALREADY_TAKEN', + 'message' => 'Email is already taken', + ], Http::STATUS_CONFLICT); + } catch (ServiceException $e) { + return $this->buildServiceErrorResponse($e, $providerId); + } catch (\InvalidArgumentException $e) { + $this->logger->error('Invalid parameters for mailbox update', [ + 'providerId' => $providerId, + 'userId' => $userId, + 'exception' => $e, + ]); + return MailJsonResponse::fail([ + 'error' => self::ERR_INVALID_PARAMETERS, + 'message' => $e->getMessage(), + ], Http::STATUS_BAD_REQUEST); + } catch (\Exception $e) { + $this->logger->error('Unexpected error updating mailbox', [ + 'providerId' => $providerId, + 'userId' => $userId, + 'exception' => $e, + ]); + return MailJsonResponse::error('Could not update mailbox'); + } + } + + /** + * Delete a mailbox + * + * @NoAdminRequired + * + * @param string $providerId The provider ID + * @param string $userId The user ID whose mailbox to delete + * @return JSONResponse + */ + #[TrapError] + public function destroyMailbox(string $providerId, string $userId): JSONResponse { + try { + $currentUserId = $this->getUserIdOrFail(); + + $this->logger->info('Deleting mailbox', [ + 'providerId' => $providerId, + 'userId' => $userId, + 'currentUserId' => $currentUserId, + ]); + + $provider = $this->providerRegistry->getProvider($providerId); + if ($provider === null) { + return MailJsonResponse::fail([ + 'error' => self::ERR_PROVIDER_NOT_FOUND, + 'message' => 'Provider not found: ' . $providerId, + ], Http::STATUS_NOT_FOUND); + } + + $success = $provider->deleteMailbox($userId); + + if ($success) { + $this->logger->info('Mailbox deleted successfully', [ + 'userId' => $userId, + ]); + return MailJsonResponse::success(['deleted' => true]); + } else { + return MailJsonResponse::fail([ + 'error' => self::ERR_SERVICE_ERROR, + 'message' => 'Failed to delete mailbox', + ], Http::STATUS_INTERNAL_SERVER_ERROR); + } + } catch (ServiceException $e) { + return $this->buildServiceErrorResponse($e, $providerId); + } catch (\Exception $e) { + $this->logger->error('Unexpected error deleting mailbox', [ + 'providerId' => $providerId, + 'userId' => $userId, + 'exception' => $e, + ]); + return MailJsonResponse::error('Could not delete mailbox'); + } + } + /** * Get the current user ID * diff --git a/lib/Exception/AccountAlreadyExistsException.php b/lib/Exception/AccountAlreadyExistsException.php new file mode 100644 index 0000000000..b3f45a81af --- /dev/null +++ b/lib/Exception/AccountAlreadyExistsException.php @@ -0,0 +1,17 @@ + List of mailbox information + * @throws \OCA\Mail\Exception\ServiceException If fetching mailboxes fails + */ + public function getMailboxes(): array; + + /** + * Update a mailbox (e.g., change localpart/username) + * + * @param string $userId The Nextcloud user ID + * @param array $data Update data (e.g., ['localpart' => 'newusername']) + * @return array{userId: string, email: string, name: string} Updated mailbox information + * @throws \OCA\Mail\Exception\AccountAlreadyExistsException If email is already taken + * @throws \OCA\Mail\Exception\ServiceException If update fails + */ + public function updateMailbox(string $userId, array $data): array; + + /** + * Delete a mailbox + * + * @param string $userId The Nextcloud user ID + * @return bool True if deletion was successful + * @throws \OCA\Mail\Exception\ServiceException If deletion fails + */ + public function deleteMailbox(string $userId): bool; } diff --git a/lib/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacade.php b/lib/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacade.php index 56023407a8..ddcbae2a44 100644 --- a/lib/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacade.php +++ b/lib/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacade.php @@ -222,4 +222,91 @@ public function generateAppPassword(string $userId): string { return $this->mutationService->resetAppPassword($userId, IonosConfigService::APP_PASSWORD_NAME_USER); } + + /** + * Get all mailboxes managed by this provider + * + * Returns a list of all mailboxes (email accounts) managed by this provider + * across all users. Used for administration/overview purposes. + * + * @return array List of mailbox information + */ + public function getMailboxes(): array { + $this->logger->debug('Getting all IONOS mailboxes'); + + try { + $accountResponses = $this->queryService->getAllMailAccountResponses(); + + $mailboxes = []; + foreach ($accountResponses as $response) { + // Extract user ID from the email or use a placeholder + // The API response contains the email but we need to derive the userId + $email = $response->getEmail(); + + // For IONOS, the email format is typically userId@domain + // Extract userId from the email localpart + $emailParts = explode('@', $email); + $userId = $emailParts[0] ?? ''; + + $mailboxes[] = [ + 'userId' => $userId, + 'email' => $email, + 'name' => $response->getName() ?? $userId, + ]; + } + + $this->logger->debug('Retrieved IONOS mailboxes', [ + 'count' => count($mailboxes), + ]); + + return $mailboxes; + } catch (\Exception $e) { + $this->logger->error('Error getting IONOS mailboxes', [ + 'exception' => $e, + ]); + return []; + } + } + + /** + * Update a mailbox (e.g., change localpart) + * + * @param string $userId The Nextcloud user ID + * @param array $data Update data + * @return array{userId: string, email: string, name: string} Updated mailbox information + * @throws \OCA\Mail\Exception\ServiceException If update fails + */ + public function updateMailbox(string $userId, array $data): array { + $this->logger->info('Updating IONOS mailbox via facade', [ + 'userId' => $userId, + 'data' => array_keys($data), + ]); + + $localpart = $data['localpart'] ?? null; + $name = $data['name'] ?? ''; + + if ($localpart === null || $localpart === '') { + throw new \InvalidArgumentException('localpart is required for mailbox update'); + } + + // Update the account using the creation service (which handles updates) + $account = $this->creationService->createOrUpdateAccount($userId, $localpart, $name); + + return [ + 'userId' => $userId, + 'email' => $account->getEmail(), + 'name' => $account->getName(), + ]; + } + + /** + * Delete a mailbox + * + * @param string $userId The Nextcloud user ID + * @return bool True if deletion was successful + * @throws \OCA\Mail\Exception\ServiceException If deletion fails + */ + public function deleteMailbox(string $userId): bool { + return $this->deleteAccount($userId); + } } diff --git a/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/Core/IonosAccountMutationService.php b/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/Core/IonosAccountMutationService.php index 475efca764..0f7d8d1204 100644 --- a/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/Core/IonosAccountMutationService.php +++ b/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/Core/IonosAccountMutationService.php @@ -10,11 +10,11 @@ namespace OCA\Mail\Provider\MailAccountProvider\Implementations\Ionos\Service\Core; use IONOS\MailConfigurationAPI\Client\ApiException; -use IONOS\MailConfigurationAPI\Client\Model\Imap; +use IONOS\MailConfigurationAPI\Client\Model\ImapConfig; use IONOS\MailConfigurationAPI\Client\Model\MailAccountCreatedResponse; use IONOS\MailConfigurationAPI\Client\Model\MailAddonErrorMessage; use IONOS\MailConfigurationAPI\Client\Model\MailCreateData; -use IONOS\MailConfigurationAPI\Client\Model\Smtp; +use IONOS\MailConfigurationAPI\Client\Model\SmtpConfig; use OCA\Mail\Exception\ServiceException; use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailAccountConfig; use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailServerConfig; @@ -356,13 +356,13 @@ private function buildSuccessResponse(MailAccountCreatedResponse $response): Mai * Creates a complete MailAccountConfig object by combining IMAP and SMTP server * configurations with email credentials. SSL modes are normalized to standard format. * - * @param Imap $imapServer IMAP server configuration object - * @param Smtp $smtpServer SMTP server configuration object + * @param ImapConfig $imapServer IMAP server configuration object + * @param SmtpConfig $smtpServer SMTP server configuration object * @param string $email Email address * @param string $password Account password * @return MailAccountConfig Complete mail account configuration */ - private function buildMailAccountConfig(Imap $imapServer, Smtp $smtpServer, string $email, string $password): MailAccountConfig { + private function buildMailAccountConfig(ImapConfig $imapServer, SmtpConfig $smtpServer, string $email, string $password): MailAccountConfig { $imapConfig = new MailServerConfig( host: $imapServer->getHost(), port: $imapServer->getPort(), diff --git a/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/Core/IonosAccountQueryService.php b/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/Core/IonosAccountQueryService.php index 219f27f818..9e782ea480 100644 --- a/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/Core/IonosAccountQueryService.php +++ b/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/Core/IonosAccountQueryService.php @@ -182,6 +182,45 @@ public function getMailDomain(): string { return $this->configService->getMailDomain(); } + /** + * Get all IONOS mail accounts + * + * @return array List of mail account responses + */ + public function getAllMailAccountResponses(): array { + try { + $this->logger->debug('Getting all IONOS mail accounts', [ + 'extRef' => $this->configService->getExternalReference(), + ]); + + $apiInstance = $this->createApiInstance(); + $result = $apiInstance->getAllFunctionalAccounts( + self::BRAND, + $this->configService->getExternalReference() + ); + + if (is_array($result)) { + $this->logger->debug('Retrieved IONOS mail accounts', [ + 'count' => count($result), + ]); + return $result; + } + + return []; + } catch (ApiException $e) { + $this->logger->error('API error getting all IONOS mail accounts', [ + 'statusCode' => $e->getCode(), + 'error' => $e->getMessage(), + ]); + return []; + } catch (\Exception $e) { + $this->logger->error('Unexpected error getting all IONOS mail accounts', [ + 'error' => $e->getMessage(), + ]); + return []; + } + } + /** * Get the current user ID from the session * diff --git a/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosMailService.php b/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosMailService.php index 69f31cd278..1a5d7a37d8 100644 --- a/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosMailService.php +++ b/lib/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosMailService.php @@ -11,12 +11,12 @@ use IONOS\MailConfigurationAPI\Client\Api\MailConfigurationAPIApi; use IONOS\MailConfigurationAPI\Client\ApiException; -use IONOS\MailConfigurationAPI\Client\Model\Imap; +use IONOS\MailConfigurationAPI\Client\Model\ImapConfig; use IONOS\MailConfigurationAPI\Client\Model\MailAccountCreatedResponse; use IONOS\MailConfigurationAPI\Client\Model\MailAccountResponse; use IONOS\MailConfigurationAPI\Client\Model\MailAddonErrorMessage; use IONOS\MailConfigurationAPI\Client\Model\MailCreateData; -use IONOS\MailConfigurationAPI\Client\Model\Smtp; +use IONOS\MailConfigurationAPI\Client\Model\SmtpConfig; use OCA\Mail\Exception\ServiceException; use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailAccountConfig; use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailServerConfig; @@ -333,13 +333,13 @@ private function buildSuccessResponse(MailAccountCreatedResponse $response): Mai /** * Build mail account configuration from server details * - * @param Imap $imapServer IMAP server configuration object - * @param Smtp $smtpServer SMTP server configuration object + * @param ImapConfig $imapServer IMAP server configuration object + * @param SmtpConfig $smtpServer SMTP server configuration object * @param string $email Email address * @param string $password Account password * @return MailAccountConfig Complete mail account configuration */ - private function buildMailAccountConfig(Imap $imapServer, Smtp $smtpServer, string $email, string $password): MailAccountConfig { + private function buildMailAccountConfig(ImapConfig $imapServer, SmtpConfig $smtpServer, string $email, string $password): MailAccountConfig { $imapConfig = new MailServerConfig( host: $imapServer->getHost(), port: $imapServer->getPort(), diff --git a/lib/Provider/MailAccountProvider/Implementations/IonosProvider.php b/lib/Provider/MailAccountProvider/Implementations/IonosProvider.php index 46e2b6f6b2..f2f9a839c7 100644 --- a/lib/Provider/MailAccountProvider/Implementations/IonosProvider.php +++ b/lib/Provider/MailAccountProvider/Implementations/IonosProvider.php @@ -156,4 +156,16 @@ public function generateAppPassword(string $userId): string { ); } } + + public function getMailboxes(): array { + return $this->facade->getMailboxes(); + } + + public function updateMailbox(string $userId, array $data): array { + return $this->facade->updateMailbox($userId, $data); + } + + public function deleteMailbox(string $userId): bool { + return $this->facade->deleteMailbox($userId); + } } diff --git a/lib/Settings/ProviderAccountOverviewSettings.php b/lib/Settings/ProviderAccountOverviewSettings.php new file mode 100644 index 0000000000..0e6253e742 --- /dev/null +++ b/lib/Settings/ProviderAccountOverviewSettings.php @@ -0,0 +1,44 @@ +l->t('Email Provider Accounts'); + } + + #[\Override] + public function getPriority(): int { + return 55; + } + + #[\Override] + public function getIcon(): string { + return $this->urlGenerator->imagePath('mail', 'mail.svg'); + } +} diff --git a/src/components/mailboxAdmin/MailboxAdministration.vue b/src/components/mailboxAdmin/MailboxAdministration.vue new file mode 100644 index 0000000000..cf4e2fe67e --- /dev/null +++ b/src/components/mailboxAdmin/MailboxAdministration.vue @@ -0,0 +1,225 @@ + + + + + + + diff --git a/src/components/mailboxAdmin/MailboxDeletionModal.vue b/src/components/mailboxAdmin/MailboxDeletionModal.vue new file mode 100644 index 0000000000..f3ae402990 --- /dev/null +++ b/src/components/mailboxAdmin/MailboxDeletionModal.vue @@ -0,0 +1,118 @@ + + + + + + + diff --git a/src/components/mailboxAdmin/MailboxListItem.vue b/src/components/mailboxAdmin/MailboxListItem.vue new file mode 100644 index 0000000000..f4b48b39ac --- /dev/null +++ b/src/components/mailboxAdmin/MailboxListItem.vue @@ -0,0 +1,114 @@ + + + + + + + diff --git a/src/main-provider-account-overview.js b/src/main-provider-account-overview.js new file mode 100644 index 0000000000..4ca7fc828e --- /dev/null +++ b/src/main-provider-account-overview.js @@ -0,0 +1,22 @@ +/** + * SPDX-FileCopyrightText: 2025 STRATO GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import { generateFilePath } from '@nextcloud/router' +import { getRequestToken } from '@nextcloud/auth' +import '@nextcloud/dialogs/style.css' +import Vue from 'vue' + +import MailboxAdministration from './components/mailboxAdmin/MailboxAdministration.vue' +import Nextcloud from './mixins/Nextcloud.js' + +// eslint-disable-next-line camelcase +__webpack_nonce__ = btoa(getRequestToken()) +// eslint-disable-next-line camelcase +__webpack_public_path__ = generateFilePath('mail', '', 'js/') + +Vue.mixin(Nextcloud) + +const View = Vue.extend(MailboxAdministration) +new View().$mount('#mail-provider-account-overview') diff --git a/src/service/MailboxAdminService.js b/src/service/MailboxAdminService.js new file mode 100644 index 0000000000..ce8d097e7b --- /dev/null +++ b/src/service/MailboxAdminService.js @@ -0,0 +1,61 @@ +/** + * SPDX-FileCopyrightText: 2025 STRATO GmbH + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import axios from '@nextcloud/axios' +import { generateUrl } from '@nextcloud/router' + +/** + * Get all mailboxes for a provider + * + * @param {string} providerId The provider ID + * @return {Promise} + */ +export const getMailboxes = (providerId) => { + const url = generateUrl('/apps/mail/api/providers/{providerId}/mailboxes', { + providerId, + }) + return axios.get(url).then((resp) => resp.data) +} + +/** + * Update a mailbox + * + * @param {string} providerId The provider ID + * @param {string} userId The user ID + * @param {object} data Update data (localpart, name, etc.) + * @return {Promise} + */ +export const updateMailbox = (providerId, userId, data) => { + const url = generateUrl('/apps/mail/api/providers/{providerId}/mailboxes/{userId}', { + providerId, + userId, + }) + return axios.put(url, data).then((resp) => resp.data) +} + +/** + * Delete a mailbox + * + * @param {string} providerId The provider ID + * @param {string} userId The user ID + * @return {Promise} + */ +export const deleteMailbox = (providerId, userId) => { + const url = generateUrl('/apps/mail/api/providers/{providerId}/mailboxes/{userId}', { + providerId, + userId, + }) + return axios.delete(url).then((resp) => resp.data) +} + +/** + * Get available providers + * + * @return {Promise} + */ +export const getProviders = () => { + const url = generateUrl('/apps/mail/api/providers') + return axios.get(url).then((resp) => resp.data) +} diff --git a/templates/settings-provider-account-overview.php b/templates/settings-provider-account-overview.php new file mode 100644 index 0000000000..4f3ab1919f --- /dev/null +++ b/templates/settings-provider-account-overview.php @@ -0,0 +1,12 @@ + +
+
diff --git a/tests/Unit/Controller/ExternalAccountsControllerTest.php b/tests/Unit/Controller/ExternalAccountsControllerTest.php index 3c29745ba4..72feea8299 100644 --- a/tests/Unit/Controller/ExternalAccountsControllerTest.php +++ b/tests/Unit/Controller/ExternalAccountsControllerTest.php @@ -599,4 +599,247 @@ public function testGeneratePasswordWithServiceException(): void { $this->assertEquals('fail', $data['status']); $this->assertEquals('SERVICE_ERROR', $data['data']['error']); } + + // Mailbox management tests + + public function testIndexMailboxesSuccess(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $mailboxes = [ + ['userId' => 'user1', 'email' => 'user1@example.com', 'name' => 'User One'], + ['userId' => 'user2', 'email' => 'user2@example.com', 'name' => 'User Two'], + ]; + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('getMailboxes') + ->willReturn($mailboxes); + + $this->providerRegistry->method('getProvider') + ->with('test-provider') + ->willReturn($provider); + + $response = $this->controller->indexMailboxes('test-provider'); + + $this->assertEquals(Http::STATUS_OK, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('success', $data['status']); + $this->assertEquals($mailboxes, $data['data']['mailboxes']); + } + + public function testIndexMailboxesWithProviderNotFound(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->providerRegistry->method('getProvider') + ->with('nonexistent') + ->willReturn(null); + + $response = $this->controller->indexMailboxes('nonexistent'); + + $this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + $this->assertEquals('PROVIDER_NOT_FOUND', $data['data']['error']); + } + + public function testUpdateMailboxSuccess(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParams') + ->willReturn([ + 'providerId' => 'test-provider', + 'userId' => 'targetuser', + '_route' => 'some-route', + 'localpart' => 'newusername', + 'name' => 'New Name', + ]); + + $updatedMailbox = [ + 'userId' => 'targetuser', + 'email' => 'newusername@example.com', + 'name' => 'New Name', + ]; + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('updateMailbox') + ->with('targetuser', ['localpart' => 'newusername', 'name' => 'New Name']) + ->willReturn($updatedMailbox); + + $this->providerRegistry->method('getProvider') + ->with('test-provider') + ->willReturn($provider); + + $response = $this->controller->updateMailbox('test-provider', 'targetuser'); + + $this->assertEquals(Http::STATUS_OK, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('success', $data['status']); + $this->assertEquals($updatedMailbox, $data['data']); + } + + public function testUpdateMailboxWithInvalidLocalpart(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParams') + ->willReturn([ + 'providerId' => 'test-provider', + 'userId' => 'targetuser', + '_route' => 'some-route', + 'localpart' => 'invalid@user', // Contains invalid character @ + ]); + + $this->providerRegistry->method('getProvider') + ->with('test-provider') + ->willReturn($this->createMock(IMailAccountProvider::class)); + + $response = $this->controller->updateMailbox('test-provider', 'targetuser'); + + $this->assertEquals(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + $this->assertEquals('INVALID_PARAMETERS', $data['data']['error']); + $this->assertStringContainsString('invalid characters', $data['data']['message']); + } + + public function testUpdateMailboxWithEmptyLocalpart(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParams') + ->willReturn([ + 'providerId' => 'test-provider', + 'userId' => 'targetuser', + '_route' => 'some-route', + 'localpart' => ' ', // Empty after trim + ]); + + $this->providerRegistry->method('getProvider') + ->with('test-provider') + ->willReturn($this->createMock(IMailAccountProvider::class)); + + $response = $this->controller->updateMailbox('test-provider', 'targetuser'); + + $this->assertEquals(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + $this->assertEquals('INVALID_PARAMETERS', $data['data']['error']); + $this->assertStringContainsString('cannot be empty', $data['data']['message']); + } + + public function testUpdateMailboxWithAccountAlreadyExistsException(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParams') + ->willReturn([ + 'providerId' => 'test-provider', + 'userId' => 'targetuser', + '_route' => 'some-route', + 'localpart' => 'existinguser', + ]); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('updateMailbox') + ->willThrowException(new \OCA\Mail\Exception\AccountAlreadyExistsException('Email already taken')); + + $this->providerRegistry->method('getProvider') + ->with('test-provider') + ->willReturn($provider); + + $response = $this->controller->updateMailbox('test-provider', 'targetuser'); + + $this->assertEquals(Http::STATUS_CONFLICT, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + $this->assertEquals('EMAIL_ALREADY_TAKEN', $data['data']['error']); + $this->assertEquals('Email is already taken', $data['data']['message']); + } + + public function testDestroyMailboxSuccess(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('deleteMailbox') + ->with('targetuser') + ->willReturn(true); + + $this->providerRegistry->method('getProvider') + ->with('test-provider') + ->willReturn($provider); + + $response = $this->controller->destroyMailbox('test-provider', 'targetuser'); + + $this->assertEquals(Http::STATUS_OK, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('success', $data['status']); + $this->assertTrue($data['data']['deleted']); + } + + public function testDestroyMailboxWithProviderNotFound(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->providerRegistry->method('getProvider') + ->with('nonexistent') + ->willReturn(null); + + $response = $this->controller->destroyMailbox('nonexistent', 'targetuser'); + + $this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + $this->assertEquals('PROVIDER_NOT_FOUND', $data['data']['error']); + } + + public function testDestroyMailboxFailed(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('deleteMailbox') + ->with('targetuser') + ->willReturn(false); + + $this->providerRegistry->method('getProvider') + ->with('test-provider') + ->willReturn($provider); + + $response = $this->controller->destroyMailbox('test-provider', 'targetuser'); + + $this->assertEquals(Http::STATUS_INTERNAL_SERVER_ERROR, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + $this->assertEquals('SERVICE_ERROR', $data['data']['error']); + } } diff --git a/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacadeTest.php b/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacadeTest.php index 2d064ae009..6d907a0df6 100644 --- a/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacadeTest.php +++ b/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacadeTest.php @@ -386,4 +386,89 @@ public function testGenerateAppPasswordThrowsException(): void { $this->facade->generateAppPassword($userId); } + + public function testGetMailboxesReturnsEmptyArrayWhenNoAccounts(): void { + $this->logger->expects($this->once()) + ->method('debug') + ->with('Getting all IONOS mailboxes'); + + $this->queryService->expects($this->once()) + ->method('getAllMailAccountResponses') + ->willReturn([]); + + $result = $this->facade->getMailboxes(); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + public function testGetMailboxesReturnsMailboxList(): void { + $mockResponse1 = $this->createMock(\IONOS\MailConfigurationAPI\Client\Model\MailAccountResponse::class); + $mockResponse1->method('getEmail')->willReturn('user1@ionos.com'); + $mockResponse1->method('getName')->willReturn('User One'); + + $mockResponse2 = $this->createMock(\IONOS\MailConfigurationAPI\Client\Model\MailAccountResponse::class); + $mockResponse2->method('getEmail')->willReturn('user2@ionos.com'); + $mockResponse2->method('getName')->willReturn('User Two'); + + $this->logger->expects($this->exactly(2)) + ->method('debug'); + + $this->queryService->expects($this->once()) + ->method('getAllMailAccountResponses') + ->willReturn([$mockResponse1, $mockResponse2]); + + $result = $this->facade->getMailboxes(); + + $this->assertIsArray($result); + $this->assertCount(2, $result); + + $this->assertEquals('user1', $result[0]['userId']); + $this->assertEquals('user1@ionos.com', $result[0]['email']); + $this->assertEquals('User One', $result[0]['name']); + + $this->assertEquals('user2', $result[1]['userId']); + $this->assertEquals('user2@ionos.com', $result[1]['email']); + $this->assertEquals('User Two', $result[1]['name']); + } + + public function testGetMailboxesHandlesException(): void { + $this->logger->expects($this->once()) + ->method('debug') + ->with('Getting all IONOS mailboxes'); + + $this->queryService->expects($this->once()) + ->method('getAllMailAccountResponses') + ->willThrowException(new \Exception('Service error')); + + $this->logger->expects($this->once()) + ->method('error') + ->with('Error getting IONOS mailboxes', $this->anything()); + + $result = $this->facade->getMailboxes(); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + public function testGetMailboxesHandlesEmailWithoutName(): void { + $mockResponse = $this->createMock(\IONOS\MailConfigurationAPI\Client\Model\MailAccountResponse::class); + $mockResponse->method('getEmail')->willReturn('user3@ionos.com'); + $mockResponse->method('getName')->willReturn(null); + + $this->logger->expects($this->exactly(2)) + ->method('debug'); + + $this->queryService->expects($this->once()) + ->method('getAllMailAccountResponses') + ->willReturn([$mockResponse]); + + $result = $this->facade->getMailboxes(); + + $this->assertIsArray($result); + $this->assertCount(1, $result); + $this->assertEquals('user3', $result[0]['userId']); + $this->assertEquals('user3@ionos.com', $result[0]['email']); + $this->assertEquals('user3', $result[0]['name']); // Falls back to userId + } } diff --git a/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/Service/Core/IonosAccountQueryServiceTest.php b/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/Service/Core/IonosAccountQueryServiceTest.php new file mode 100644 index 0000000000..b2b709bf82 --- /dev/null +++ b/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/Service/Core/IonosAccountQueryServiceTest.php @@ -0,0 +1,196 @@ +apiClientService = $this->createMock(ApiMailConfigClientService::class); + $this->configService = $this->createMock(IonosConfigService::class); + $this->userSession = $this->createMock(IUserSession::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->apiInstance = $this->createMock(MailConfigurationAPIApi::class); + + $this->service = new IonosAccountQueryService( + $this->apiClientService, + $this->configService, + $this->userSession, + $this->logger, + ); + } + + public function testGetAllMailAccountResponsesReturnsEmptyArrayOnApiException(): void { + $externalRef = 'test-ref'; + + $this->configService->expects($this->once()) + ->method('getExternalReference') + ->willReturn($externalRef); + + $this->configService->expects($this->once()) + ->method('getBasicAuthUser') + ->willReturn('user'); + + $this->configService->expects($this->once()) + ->method('getBasicAuthPassword') + ->willReturn('pass'); + + $this->configService->expects($this->once()) + ->method('getAllowInsecure') + ->willReturn(false); + + $this->configService->expects($this->once()) + ->method('getApiBaseUrl') + ->willReturn('https://api.example.com'); + + $this->apiClientService->expects($this->once()) + ->method('newClient') + ->willReturn($this->createMock(\GuzzleHttp\ClientInterface::class)); + + $this->apiClientService->expects($this->once()) + ->method('newMailConfigurationAPIApi') + ->willReturn($this->apiInstance); + + $this->apiInstance->expects($this->once()) + ->method('getAllFunctionalAccounts') + ->with('IONOS', $externalRef) + ->willThrowException(new ApiException('API error', 500)); + + $this->logger->expects($this->atLeastOnce()) + ->method('debug'); + + $this->logger->expects($this->once()) + ->method('error') + ->with('API error getting all IONOS mail accounts', $this->anything()); + + $result = $this->service->getAllMailAccountResponses(); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + public function testGetAllMailAccountResponsesReturnsAccountList(): void { + $externalRef = 'test-ref'; + $mockResponse1 = $this->createMock(MailAccountResponse::class); + $mockResponse2 = $this->createMock(MailAccountResponse::class); + $responses = [$mockResponse1, $mockResponse2]; + + $this->configService->expects($this->once()) + ->method('getExternalReference') + ->willReturn($externalRef); + + $this->configService->expects($this->once()) + ->method('getBasicAuthUser') + ->willReturn('user'); + + $this->configService->expects($this->once()) + ->method('getBasicAuthPassword') + ->willReturn('pass'); + + $this->configService->expects($this->once()) + ->method('getAllowInsecure') + ->willReturn(false); + + $this->configService->expects($this->once()) + ->method('getApiBaseUrl') + ->willReturn('https://api.example.com'); + + $this->apiClientService->expects($this->once()) + ->method('newClient') + ->willReturn($this->createMock(\GuzzleHttp\ClientInterface::class)); + + $this->apiClientService->expects($this->once()) + ->method('newMailConfigurationAPIApi') + ->willReturn($this->apiInstance); + + $this->apiInstance->expects($this->once()) + ->method('getAllFunctionalAccounts') + ->with('IONOS', $externalRef) + ->willReturn($responses); + + $this->logger->expects($this->atLeastOnce()) + ->method('debug'); + + $result = $this->service->getAllMailAccountResponses(); + + $this->assertIsArray($result); + $this->assertCount(2, $result); + $this->assertSame($mockResponse1, $result[0]); + $this->assertSame($mockResponse2, $result[1]); + } + + public function testGetAllMailAccountResponsesHandlesException(): void { + $externalRef = 'test-ref'; + + $this->configService->expects($this->once()) + ->method('getExternalReference') + ->willReturn($externalRef); + + $this->configService->expects($this->once()) + ->method('getBasicAuthUser') + ->willReturn('user'); + + $this->configService->expects($this->once()) + ->method('getBasicAuthPassword') + ->willReturn('pass'); + + $this->configService->expects($this->once()) + ->method('getAllowInsecure') + ->willReturn(false); + + $this->configService->expects($this->once()) + ->method('getApiBaseUrl') + ->willReturn('https://api.example.com'); + + $this->apiClientService->expects($this->once()) + ->method('newClient') + ->willReturn($this->createMock(\GuzzleHttp\ClientInterface::class)); + + $this->apiClientService->expects($this->once()) + ->method('newMailConfigurationAPIApi') + ->willReturn($this->apiInstance); + + $this->apiInstance->expects($this->once()) + ->method('getAllFunctionalAccounts') + ->with('IONOS', $externalRef) + ->willThrowException(new \Exception('Unexpected error')); + + $this->logger->expects($this->atLeastOnce()) + ->method('debug'); + + $this->logger->expects($this->once()) + ->method('error') + ->with('Unexpected error getting all IONOS mail accounts', $this->anything()); + + $result = $this->service->getAllMailAccountResponses(); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } +} diff --git a/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosMailServiceTest.php b/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosMailServiceTest.php index 667010a520..a2c6f093a4 100644 --- a/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosMailServiceTest.php +++ b/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/Service/IonosMailServiceTest.php @@ -12,12 +12,12 @@ use ChristophWurst\Nextcloud\Testing\TestCase; use GuzzleHttp\ClientInterface; use IONOS\MailConfigurationAPI\Client\Api\MailConfigurationAPIApi; -use IONOS\MailConfigurationAPI\Client\Model\Imap; +use IONOS\MailConfigurationAPI\Client\Model\ImapConfig; use IONOS\MailConfigurationAPI\Client\Model\MailAccountCreatedResponse; use IONOS\MailConfigurationAPI\Client\Model\MailAccountResponse; use IONOS\MailConfigurationAPI\Client\Model\MailAddonErrorMessage; use IONOS\MailConfigurationAPI\Client\Model\MailServer; -use IONOS\MailConfigurationAPI\Client\Model\Smtp; +use IONOS\MailConfigurationAPI\Client\Model\SmtpConfig; use OCA\Mail\Exception\ServiceException; use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailAccountConfig; use OCA\Mail\Provider\MailAccountProvider\Implementations\Ionos\Service\ApiMailConfigClientService; @@ -121,8 +121,8 @@ private function createMockImapServer( string $host = self::IMAP_HOST, int $port = self::IMAP_PORT, string $sslMode = 'ssl', - ): Imap&MockObject { - $imapServer = $this->getMockBuilder(Imap::class) + ): ImapConfig&MockObject { + $imapServer = $this->getMockBuilder(ImapConfig::class) ->disableOriginalConstructor() ->onlyMethods(['getHost', 'getPort', 'getSslMode']) ->getMock(); @@ -139,8 +139,8 @@ private function createMockSmtpServer( string $host = self::SMTP_HOST, int $port = self::SMTP_PORT, string $sslMode = 'tls', - ): Smtp&MockObject { - $smtpServer = $this->getMockBuilder(Smtp::class) + ): SmtpConfig&MockObject { + $smtpServer = $this->getMockBuilder(SmtpConfig::class) ->disableOriginalConstructor() ->onlyMethods(['getHost', 'getPort', 'getSslMode']) ->getMock(); diff --git a/tests/Unit/Settings/ProviderAccountOverviewSettingsTest.php b/tests/Unit/Settings/ProviderAccountOverviewSettingsTest.php new file mode 100644 index 0000000000..7f077bb855 --- /dev/null +++ b/tests/Unit/Settings/ProviderAccountOverviewSettingsTest.php @@ -0,0 +1,56 @@ +settings = new ProviderAccountOverviewSettings(); + } + + public function testGetForm(): void { + $result = $this->settings->getForm(); + + $this->assertInstanceOf(TemplateResponse::class, $result); + $this->assertEquals('mail', $result->getAppName()); + $this->assertEquals('settings-provider-account-overview', $result->getTemplateName()); + } + + public function testGetSection(): void { + $result = $this->settings->getSection(); + + $this->assertEquals('mail-provider-accounts', $result); + } + + public function testGetPriority(): void { + $result = $this->settings->getPriority(); + + $this->assertEquals(50, $result); + } + + public function testGetName(): void { + $result = $this->settings->getName(); + + $this->assertNull($result); + } + + public function testGetAuthorizedAppConfig(): void { + $result = $this->settings->getAuthorizedAppConfig(); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } +} diff --git a/tests/Unit/Settings/Section/MailProviderAccountsSectionTest.php b/tests/Unit/Settings/Section/MailProviderAccountsSectionTest.php new file mode 100644 index 0000000000..b0ccaa6df8 --- /dev/null +++ b/tests/Unit/Settings/Section/MailProviderAccountsSectionTest.php @@ -0,0 +1,68 @@ +l = $this->createMock(IL10N::class); + $this->urlGenerator = $this->createMock(IURLGenerator::class); + + $this->section = new MailProviderAccountsSection( + $this->l, + $this->urlGenerator, + ); + } + + public function testGetID(): void { + $result = $this->section->getID(); + + $this->assertEquals('mail-provider-accounts', $result); + } + + public function testGetName(): void { + $this->l->expects($this->once()) + ->method('t') + ->with('Email Provider Accounts') + ->willReturn('Email Provider Accounts'); + + $result = $this->section->getName(); + + $this->assertEquals('Email Provider Accounts', $result); + } + + public function testGetPriority(): void { + $result = $this->section->getPriority(); + + $this->assertEquals(55, $result); + } + + public function testGetIcon(): void { + $this->urlGenerator->expects($this->once()) + ->method('imagePath') + ->with('mail', 'mail.svg') + ->willReturn('/apps/mail/img/mail.svg'); + + $result = $this->section->getIcon(); + + $this->assertEquals('/apps/mail/img/mail.svg', $result); + } +} diff --git a/webpack.common.js b/webpack.common.js index b6db529241..b4fd9fcd32 100644 --- a/webpack.common.js +++ b/webpack.common.js @@ -53,6 +53,7 @@ module.exports = async () => ({ mail: path.join(__dirname, 'src/main.js'), oauthpopup: path.join(__dirname, 'src/main-oauth-popup.js'), settings: path.join(__dirname, 'src/main-settings'), + 'provider-account-overview': path.join(__dirname, 'src/main-provider-account-overview'), htmlresponse: path.join(__dirname, 'src/html-response.js'), }, output: {