diff --git a/appinfo/routes.php b/appinfo/routes.php index 506ed181f8..c62dc954c8 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -120,6 +120,11 @@ 'url' => '/api/tags', 'verb' => 'POST' ], + [ + 'name' => 'ionosAccounts#create', + 'url' => '/api/ionos/accounts', + 'verb' => 'POST' + ], [ 'name' => 'tags#update', 'url' => '/api/tags/{id}', diff --git a/composer.json b/composer.json index f9d9bab821..623c4ea384 100644 --- a/composer.json +++ b/composer.json @@ -30,6 +30,7 @@ "glenscott/url-normalizer": "^1.4", "gravatarphp/gravatar": "dev-master#6b9f6a45477ce48285738d9d0c3f0dbf97abe263", "hamza221/html2text": "^1.0", + "ionos-productivity/ionos-mail-configuration-api-client": "2.0.0", "jeremykendall/php-domain-parser": "^6.4.0", "nextcloud/horde-managesieve": "^1.0", "nextcloud/horde-smtp": "^1.0.2", @@ -44,6 +45,25 @@ "wamania/php-stemmer": "4.0 as 3.0", "youthweb/urllinker": "^2.1.0" }, + "repositories": [ + { + "type": "package", + "package": { + "name": "ionos-productivity/ionos-mail-configuration-api-client", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/ionos-productivity/ionos-mail-configuration-api-client.git", + "reference": "2.0.0-6ebd22a" + }, + "autoload": { + "psr-4": { + "IONOS\\MailConfigurationAPI\\Client\\" : "lib/" + } + } + } + } + ], "provide": { "psr/log": "^1.0.4|^2|^3" }, diff --git a/composer.lock b/composer.lock index f3259b22ae..7b932e7e7c 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": "430f364f5a83cafbfcc155b47694f0db", + "content-hash": "3e6f6d248a23842ef7813ed597bdb016", "packages": [ { "name": "amphp/amp", @@ -1854,6 +1854,21 @@ }, "time": "2023-08-16T11:30:50+00:00" }, + { + "name": "ionos-productivity/ionos-mail-configuration-api-client", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/ionos-productivity/ionos-mail-configuration-api-client.git", + "reference": "2.0.0-6ebd22a" + }, + "type": "library", + "autoload": { + "psr-4": { + "IONOS\\MailConfigurationAPI\\Client\\": "lib/" + } + } + }, { "name": "jeremykendall/php-domain-parser", "version": "6.4.0", @@ -3833,7 +3848,7 @@ "php": ">=8.1 <=8.4", "ext-openssl": "*" }, - "platform-dev": {}, + "platform-dev": [], "platform-overrides": { "php": "8.1" }, diff --git a/lib/Controller/IonosAccountsController.php b/lib/Controller/IonosAccountsController.php new file mode 100644 index 0000000000..9a1a5b700d --- /dev/null +++ b/lib/Controller/IonosAccountsController.php @@ -0,0 +1,95 @@ + false, 'message' => self::ERR_ALL_FIELDS_REQUIRED, 'error' => self::ERR_IONOS_API_ERROR], 400); + } + return null; + } + + /** + * @NoAdminRequired + */ + #[TrapError] + public function create(string $accountName, string $emailUser): JSONResponse { + if ($error = $this->validateInput($accountName, $emailUser)) { + return $error; + } + + try { + $this->logger->info('Starting IONOS email account creation', [ 'emailAddress' => $emailUser, 'accountName' => $accountName ]); + $ionosResponse = $this->ionosMailService->createEmailAccount($emailUser); + + $this->logger->info('IONOS email account created successfully', [ 'emailAddress' => $ionosResponse->getEmail() ]); + return $this->createNextcloudMailAccount($accountName, $ionosResponse); + } catch (ServiceException $e) { + $data = [ + 'error' => self::ERR_IONOS_API_ERROR, + 'statusCode' => $e->getCode(), + ]; + $this->logger->error('IONOS service error: ' . $e->getMessage(), $data); + + return MailJsonResponse::fail($data); + } catch (\Exception $e) { + return MailJsonResponse::error('Could not create account'); + } + } + + private function createNextcloudMailAccount(string $accountName, MailAccountConfig $mailConfig): JSONResponse { + $imap = $mailConfig->getImap(); + $smtp = $mailConfig->getSmtp(); + + return $this->accountsController->create( + $accountName, + $mailConfig->getEmail(), + $imap->getHost(), + $imap->getPort(), + $imap->getSecurity(), + $imap->getUsername(), + $imap->getPassword(), + $smtp->getHost(), + $smtp->getPort(), + $smtp->getSecurity(), + $smtp->getUsername(), + $smtp->getPassword(), + ); + } +} diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 4bc43085ce..2ea0f62352 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -21,6 +21,7 @@ use OCA\Mail\Service\AliasesService; use OCA\Mail\Service\Classification\ClassificationSettingsService; use OCA\Mail\Service\InternalAddressService; +use OCA\Mail\Service\IONOS\IonosConfigService; use OCA\Mail\Service\OutboxService; use OCA\Mail\Service\QuickActionsService; use OCA\Mail\Service\SmimeService; @@ -74,7 +75,8 @@ class PageController extends Controller { private InternalAddressService $internalAddressService; private QuickActionsService $quickActionsService; - public function __construct(string $appName, + public function __construct( + string $appName, IRequest $request, IURLGenerator $urlGenerator, IConfig $config, @@ -97,6 +99,7 @@ public function __construct(string $appName, InternalAddressService $internalAddressService, IAvailabilityCoordinator $availabilityCoordinator, QuickActionsService $quickActionsService, + private IonosConfigService $ionosConfigService, ) { parent::__construct($appName, $request); @@ -208,8 +211,12 @@ public function index(): TemplateResponse { $user = $this->userSession->getUser(); $response = new TemplateResponse($this->appName, 'index'); + + $this->initialStateService->provideInitialState('preferences', [ 'attachment-size-limit' => $this->config->getSystemValue('app.mail.attachment-size-limit', 0), + 'ionos-mailconfig-enabled' => $this->config->getAppValue('mail', 'ionos-mailconfig-enabled', 'no') === 'yes', + 'ionos-mailconfig-domain' => $this->ionosConfigService->getMailDomain(), 'app-version' => $this->config->getAppValue('mail', 'installed_version'), 'external-avatars' => $this->preferences->getPreference($this->currentUserId, 'external-avatars', 'true'), 'layout-mode' => $this->preferences->getPreference($this->currentUserId, 'layout-mode', 'vertical-split'), diff --git a/lib/Service/IONOS/ApiMailConfigClientService.php b/lib/Service/IONOS/ApiMailConfigClientService.php new file mode 100644 index 0000000000..a7fc40a9b6 --- /dev/null +++ b/lib/Service/IONOS/ApiMailConfigClientService.php @@ -0,0 +1,47 @@ +getConfig()->setHost($apiBaseUrl); + + return $apiClient; + } +} diff --git a/lib/Service/IONOS/Dto/MailAccountConfig.php b/lib/Service/IONOS/Dto/MailAccountConfig.php new file mode 100644 index 0000000000..2a4c3a5fa5 --- /dev/null +++ b/lib/Service/IONOS/Dto/MailAccountConfig.php @@ -0,0 +1,47 @@ +email; + } + + public function getImap(): MailServerConfig { + return $this->imap; + } + + public function getSmtp(): MailServerConfig { + return $this->smtp; + } + + /** + * Convert to array format for backwards compatibility + * + * @return array{email: string, imap: array, smtp: array} + */ + public function toArray(): array { + return [ + 'email' => $this->email, + 'imap' => $this->imap->toArray(), + 'smtp' => $this->smtp->toArray(), + ]; + } +} diff --git a/lib/Service/IONOS/Dto/MailServerConfig.php b/lib/Service/IONOS/Dto/MailServerConfig.php new file mode 100644 index 0000000000..2e06d9073d --- /dev/null +++ b/lib/Service/IONOS/Dto/MailServerConfig.php @@ -0,0 +1,59 @@ +host; + } + + public function getPort(): int { + return $this->port; + } + + public function getSecurity(): string { + return $this->security; + } + + public function getUsername(): string { + return $this->username; + } + + public function getPassword(): string { + return $this->password; + } + + /** + * Convert to array format for backwards compatibility + * + * @return array{host: string, port: int, security: string, username: string, password: string} + */ + public function toArray(): array { + return [ + 'host' => $this->host, + 'port' => $this->port, + 'security' => $this->security, + 'username' => $this->username, + 'password' => $this->password, + ]; + } +} diff --git a/lib/Service/IONOS/IonosConfigService.php b/lib/Service/IONOS/IonosConfigService.php new file mode 100644 index 0000000000..6606c510bb --- /dev/null +++ b/lib/Service/IONOS/IonosConfigService.php @@ -0,0 +1,176 @@ +config->getSystemValue('ncw.ext_ref'); + + if (empty($extRef)) { + $this->logger->error('No external reference is configured'); + throw new AppConfigException('No external reference configured'); + } + + return $extRef; + } + + /** + * Get the API base URL + * + * @throws AppConfigException + */ + public function getApiBaseUrl(): string { + $apiBaseUrl = $this->appConfig->getValueString( + Application::APP_ID, + 'ionos_mailconfig_api_base_url' + ); + + if (empty($apiBaseUrl)) { + $this->logger->error('No mailconfig service url is configured'); + throw new AppConfigException('No mailconfig service url configured'); + } + + return $apiBaseUrl; + } + + /** + * Get whether to allow insecure connections + */ + public function getAllowInsecure(): bool { + return $this->appConfig->getValueBool( + Application::APP_ID, + 'ionos_mailconfig_api_allow_insecure', + false + ); + } + + /** + * Get the basic auth username + * + * @throws AppConfigException + */ + public function getBasicAuthUser(): string { + $basicAuthUser = $this->appConfig->getValueString( + Application::APP_ID, + 'ionos_mailconfig_api_auth_user' + ); + + if (empty($basicAuthUser)) { + $this->logger->error('No mailconfig service user is configured'); + throw new AppConfigException('No mailconfig service user configured'); + } + + return $basicAuthUser; + } + + /** + * Get the basic auth password + * + * @throws AppConfigException + */ + public function getBasicAuthPassword(): string { + $basicAuthPass = $this->appConfig->getValueString( + Application::APP_ID, + 'ionos_mailconfig_api_auth_pass' + ); + + if (empty($basicAuthPass)) { + $this->logger->error('No mailconfig service password is configured'); + throw new AppConfigException('No mailconfig service password configured'); + } + + return $basicAuthPass; + } + + /** + * Validate and retrieve all API configuration + * + * @return array{extRef: string, apiBaseUrl: string, allowInsecure: bool, basicAuthUser: string, basicAuthPass: string} + * @throws AppConfigException + */ + public function getApiConfig(): array { + return [ + 'extRef' => $this->getExternalReference(), + 'apiBaseUrl' => $this->getApiBaseUrl(), + 'allowInsecure' => $this->getAllowInsecure(), + 'basicAuthUser' => $this->getBasicAuthUser(), + 'basicAuthPass' => $this->getBasicAuthPassword(), + ]; + } + + /** + * Get the mail domain from customer domain + * + * Extracts the registrable domain (mail domain) from the customer domain + * configured in system settings. + */ + public function getMailDomain(): string { + $customerDomain = $this->config->getSystemValue('ncw.customerDomain', ''); + return $this->extractMailDomain($customerDomain); + } + + /** + * Extract the registrable domain (mail domain) from a customer domain. + * + * Uses the Public Suffix List via Pdp library to properly extract the + * registrable domain, handling multi-level TLDs like .co.uk correctly. + * + * Examples: + * - foo.bar.lol -> bar.lol + * - mail.test.co.uk -> test.co.uk + * - sub.domain.example.com -> example.com + * + * @param string $customerDomain The full customer domain + * @return string The extracted mail domain, or empty string if input is empty + */ + private function extractMailDomain(string $customerDomain): string { + if (empty($customerDomain)) { + return ''; + } + + try { + $publicSuffixList = Rules::fromPath(__DIR__ . '/../../../resources/public_suffix_list.dat'); + $domain = Domain::fromIDNA2008($customerDomain); + $result = $publicSuffixList->resolve($domain); + return $result->registrableDomain()->toString(); + } catch (Throwable $e) { + // Fallback to simple extraction if Pdp fails + $parts = explode('.', $customerDomain); + if (count($parts) >= 2) { + return $parts[count($parts) - 2] . '.' . $parts[count($parts) - 1]; + } + return $customerDomain; + } + } +} diff --git a/lib/Service/IONOS/IonosMailService.php b/lib/Service/IONOS/IonosMailService.php new file mode 100644 index 0000000000..4e9e802a79 --- /dev/null +++ b/lib/Service/IONOS/IonosMailService.php @@ -0,0 +1,167 @@ +getCurrentUserId(); + $domain = $this->configService->getMailDomain(); + + $this->logger->debug('Sending request to mailconfig service', [ + 'extRef' => $this->configService->getExternalReference(), + 'userName' => $userName, + 'domain' => $domain, + 'apiBaseUrl' => $this->configService->getApiBaseUrl() + ]); + + $client = $this->apiClientService->newClient([ + 'auth' => [$this->configService->getBasicAuthUser(), $this->configService->getBasicAuthPassword()], + 'verify' => !$this->configService->getAllowInsecure(), + ]); + + $apiInstance = $this->apiClientService->newEventAPIApi($client, $this->configService->getApiBaseUrl()); + + $mailCreateData = new MailCreateData(); + $mailCreateData->setNextcloudUserId($userId); + $mailCreateData->setDomainPart($domain); + $mailCreateData->setLocalPart($userName); + + if (!$mailCreateData->valid()) { + $this->logger->error('Validate message to mailconfig service', ['data' => $mailCreateData->listInvalidProperties()]); + throw new ServiceException('Invalid mail configuration', 0); + } + + try { + $this->logger->debug('Send message to mailconfig service', ['data' => $mailCreateData]); + $result = $apiInstance->createMailbox(self::BRAND, $this->configService->getExternalReference(), $mailCreateData); + + if ($result instanceof ErrorMessage) { + $this->logger->error('Failed to create ionos mail', ['status code' => $result->getStatus(), 'message' => $result->getMessage()]); + throw new ServiceException('Failed to create ionos mail', $result->getStatus()); + } + if ($result instanceof MailAccountResponse) { + return $this->buildSuccessResponse($result); + } + + $this->logger->debug('Failed to create ionos mail: Unknown response type', ['data' => $result ]); + throw new ServiceException('Failed to create ionos mail', 0); + } catch (ApiException $e) { + $statusCode = $e->getCode(); + $this->logger->error('API Exception when calling MailConfigurationAPIApi->createMailbox', [ + 'statusCode' => $statusCode, + 'message' => $e->getMessage(), + 'responseBody' => $e->getResponseBody() + ]); + throw new ServiceException('Failed to create ionos mail: ' . $e->getMessage(), $statusCode, $e); + } catch (\Exception $e) { + $this->logger->error('Exception when calling MailConfigurationAPIApi->createMailbox', ['exception' => $e]); + throw new ServiceException('Failed to create ionos mail', 0, $e); + } + } + + /** + * Get the current user ID + * + * @throws ServiceException + */ + private function getCurrentUserId(): string { + $user = $this->userSession->getUser(); + if ($user === null) { + throw new ServiceException('No user session found'); + } + return $user->getUID(); + } + + /** + * Normalize SSL mode from API response to expected format + * + * Maps API SSL mode values (e.g., "TLS", "SSL") to standard values ("tls", "ssl", "none") + * + * @param string $apiSslMode SSL mode from API response + * @return string Normalized SSL mode: "tls", "ssl", or "none" + */ + private function normalizeSslMode(string $apiSslMode): string { + $normalized = strtolower($apiSslMode); + + if (str_contains($normalized, 'tls') || str_contains($normalized, 'starttls')) { + return 'tls'; + } + + if (str_contains($normalized, 'ssl')) { + return 'ssl'; + } + + return 'none'; + } + + /** + * Build success response with mail configuration + * + * @param MailAccountResponse $response + * @return MailAccountConfig + */ + private function buildSuccessResponse(MailAccountResponse $response): MailAccountConfig { + $smtpServer = $response->getServer()->getSmtp(); + $imapServer = $response->getServer()->getImap(); + + $imapConfig = new MailServerConfig( + host: $imapServer->getHost(), + port: $imapServer->getPort(), + security: $this->normalizeSslMode($imapServer->getSslMode()), + username: $response->getEmail(), + password: $response->getPassword(), + ); + + $smtpConfig = new MailServerConfig( + host: $smtpServer->getHost(), + port: $smtpServer->getPort(), + security: $this->normalizeSslMode($smtpServer->getSslMode()), + username: $response->getEmail(), + password: $response->getPassword(), + ); + + return new MailAccountConfig( + email: $response->getEmail(), + imap: $imapConfig, + smtp: $smtpConfig, + ); + } +} diff --git a/src/components/AccountForm.vue b/src/components/AccountForm.vue index f3cc2263d9..946bf0501c 100644 --- a/src/components/AccountForm.vue +++ b/src/components/AccountForm.vue @@ -199,6 +199,15 @@ required @change="clearFeedback" /> + + +
{{ t('mail', 'For the Google account to work with this app you need to enable two-factor authentication for Google and generate an app password.') }} @@ -252,6 +261,7 @@ import { import { CONSENT_ABORTED, getUserConsent } from '../integration/oauth.js' import useMainStore from '../store/mainStore.js' import { mapStores, mapState } from 'pinia' +import NewEmailAddressTab from './ionos/NewEmailAddressTab.vue' export default { name: 'AccountForm', @@ -264,6 +274,7 @@ export default { ButtonVue, IconLoading, IconCheck, + NewEmailAddressTab, }, props: { displayName: { @@ -321,6 +332,10 @@ export default { 'microsoftOauthUrl', ]), + useIonosMailconfig() { + return this.mainStore.getPreference('ionos-mailconfig-enabled', null) + }, + settingsPage() { return this.account !== undefined }, @@ -409,6 +424,12 @@ export default { this.manualConfig.smtpPassword = this.autoConfig.password } } + if (this.mode === 'create') { + // cleanup host info in order to remove isGoogleAccount message from interface + this.manualConfig.imapHost = undefined + this.manualConfig.smtpHost = undefined + this.clearFeedback() + } }, onImapSslModeChange(value) { this.clearFeedback() diff --git a/src/components/ionos/NewEmailAddressTab.vue b/src/components/ionos/NewEmailAddressTab.vue new file mode 100644 index 0000000000..cb4c290f49 --- /dev/null +++ b/src/components/ionos/NewEmailAddressTab.vue @@ -0,0 +1,220 @@ + + + + + + + + diff --git a/src/init.js b/src/init.js index 60cf29fa12..bc9529ad6a 100644 --- a/src/init.js +++ b/src/init.js @@ -37,6 +37,14 @@ export default function initAfterAppCreation() { key: 'version', value: preferences['config-installed-version'], }) + mainStore.savePreferenceMutation({ + key: 'ionos-mailconfig-enabled', + value: preferences['ionos-mailconfig-enabled'], + }) + mainStore.savePreferenceMutation({ + key: 'ionos-mailconfig-domain', + value: preferences['ionos-mailconfig-domain'], + }) mainStore.savePreferenceMutation({ key: 'external-avatars', value: preferences['external-avatars'], diff --git a/tests/Unit/Controller/IonosAccountsControllerTest.php b/tests/Unit/Controller/IonosAccountsControllerTest.php new file mode 100644 index 0000000000..ad43b0085d --- /dev/null +++ b/tests/Unit/Controller/IonosAccountsControllerTest.php @@ -0,0 +1,269 @@ +appName = 'mail'; + $this->request = $this->createMock(IRequest::class); + $this->ionosMailService = $this->createMock(IonosMailService::class); + $this->accountsController = $this->createMock(AccountsController::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->controller = new IonosAccountsController( + $this->appName, + $this->request, + $this->ionosMailService, + $this->accountsController, + $this->logger, + ); + } + + public function testCreateWithMissingFields(): void { + // Test with empty account name + $response = $this->controller->create('', 'testuser'); + $this->assertEquals(400, $response->getStatus()); + $data = $response->getData(); + $this->assertFalse($data['success']); + $this->assertEquals('All fields are required', $data['message']); + $this->assertEquals('IONOS_API_ERROR', $data['error']); + + // Test with empty email user + $response = $this->controller->create('Test Account', ''); + $this->assertEquals(400, $response->getStatus()); + $data = $response->getData(); + $this->assertFalse($data['success']); + $this->assertEquals('All fields are required', $data['message']); + $this->assertEquals('IONOS_API_ERROR', $data['error']); + } + + public function testCreateSuccess(): void { + $accountName = 'Test Account'; + $emailUser = 'test'; + $emailAddress = 'test@example.com'; + + // Create MailAccountConfig DTO + $imapConfig = new MailServerConfig( + host: 'mail.localhost', + port: 1143, + security: 'none', + username: $emailAddress, + password: 'tmp', + ); + + $smtpConfig = new MailServerConfig( + host: 'mail.localhost', + port: 1587, + security: 'none', + username: $emailAddress, + password: 'tmp', + ); + + $mailAccountConfig = new MailAccountConfig( + email: $emailAddress, + imap: $imapConfig, + smtp: $smtpConfig, + ); + + // Mock successful IONOS mail service response + $this->ionosMailService->method('createEmailAccount') + ->with($emailUser) + ->willReturn($mailAccountConfig); + + // Mock account creation response + $accountData = ['id' => 1, 'emailAddress' => $emailAddress]; + $accountResponse = $this->createMock(JSONResponse::class); + $accountResponse->method('getData')->willReturn($accountData); + + $this->accountsController + ->method('create') + ->with( + $accountName, + $emailAddress, + 'mail.localhost', + 1143, + 'none', + $emailAddress, + 'tmp', + 'mail.localhost', + 1587, + 'none', + $emailAddress, + 'tmp', + ) + ->willReturn($accountResponse); + + $response = $this->controller->create($accountName, $emailUser); + + // The controller now directly returns the AccountsController response + $this->assertSame($accountResponse, $response); + } + + public function testCreateWithServiceException(): void { + $accountName = 'Test Account'; + $emailUser = 'test'; + + // Mock IONOS mail service to throw ServiceException + $this->ionosMailService->method('createEmailAccount') + ->with($emailUser) + ->willThrowException(new ServiceException('Failed to create email account')); + + $this->logger + ->expects($this->once()) + ->method('error') + ->with( + 'IONOS service error: Failed to create email account', + [ + 'error' => 'IONOS_API_ERROR', + 'statusCode' => 0, + ] + ); + + $expectedResponse = \OCA\Mail\Http\JsonResponse::fail([ + 'error' => 'IONOS_API_ERROR', + 'statusCode' => 0, + ]); + $response = $this->controller->create($accountName, $emailUser); + + self::assertEquals($expectedResponse, $response); + } + + public function testCreateWithServiceExceptionWithStatusCode(): void { + $accountName = 'Test Account'; + $emailUser = 'test'; + + // Mock IONOS mail service to throw ServiceException with HTTP 409 (Duplicate) + $this->ionosMailService->method('createEmailAccount') + ->with($emailUser) + ->willThrowException(new ServiceException('Duplicate email account', 409)); + + $this->logger + ->expects($this->once()) + ->method('error') + ->with( + 'IONOS service error: Duplicate email account', + [ + 'error' => 'IONOS_API_ERROR', + 'statusCode' => 409, + ] + ); + + $expectedResponse = \OCA\Mail\Http\JsonResponse::fail([ + 'error' => 'IONOS_API_ERROR', + 'statusCode' => 409, + ]); + $response = $this->controller->create($accountName, $emailUser); + + self::assertEquals($expectedResponse, $response); + } + + public function testCreateWithGenericException(): void { + $accountName = 'Test Account'; + $emailUser = 'test'; + + // Mock IONOS mail service to throw a generic exception + $this->ionosMailService->method('createEmailAccount') + ->with($emailUser) + ->willThrowException(new \Exception('Generic error')); + + $expectedResponse = \OCA\Mail\Http\JsonResponse::error('Could not create account', + 500, + [], + 0 + ); + $response = $this->controller->create($accountName, $emailUser); + + self::assertEquals($expectedResponse, $response); + } + + + public function testCreateNextcloudMailAccount(): void { + $accountName = 'Test Account'; + $emailAddress = 'test@example.com'; + + $imapConfig = new MailServerConfig( + host: 'mail.localhost', + port: 1143, + security: 'none', + username: $emailAddress, + password: 'tmp', + ); + + $smtpConfig = new MailServerConfig( + host: 'mail.localhost', + port: 1587, + security: 'none', + username: $emailAddress, + password: 'tmp', + ); + + $mailConfig = new MailAccountConfig( + email: $emailAddress, + imap: $imapConfig, + smtp: $smtpConfig, + ); + + $expectedResponse = $this->createMock(JSONResponse::class); + + $this->accountsController + ->expects($this->once()) + ->method('create') + ->with( + $accountName, + $emailAddress, + 'mail.localhost', + 1143, + 'none', + $emailAddress, + 'tmp', + 'mail.localhost', + 1587, + 'none', + $emailAddress, + 'tmp', + ) + ->willReturn($expectedResponse); + + $reflection = new ReflectionClass($this->controller); + $method = $reflection->getMethod('createNextcloudMailAccount'); + $method->setAccessible(true); + + $result = $method->invoke($this->controller, $accountName, $mailConfig); + + $this->assertSame($expectedResponse, $result); + } +} diff --git a/tests/Unit/Controller/PageControllerTest.php b/tests/Unit/Controller/PageControllerTest.php index c78093b324..21f75432ee 100644 --- a/tests/Unit/Controller/PageControllerTest.php +++ b/tests/Unit/Controller/PageControllerTest.php @@ -21,6 +21,7 @@ use OCA\Mail\Service\AliasesService; use OCA\Mail\Service\Classification\ClassificationSettingsService; use OCA\Mail\Service\InternalAddressService; +use OCA\Mail\Service\IONOS\IonosConfigService; use OCA\Mail\Service\MailManager; use OCA\Mail\Service\OutboxService; use OCA\Mail\Service\QuickActionsService; @@ -113,6 +114,8 @@ class PageControllerTest extends TestCase { private IAvailabilityCoordinator&MockObject $availabilityCoordinator; + private IonosConfigService&MockObject $ionosConfigService; + protected function setUp(): void { parent::setUp(); @@ -139,6 +142,7 @@ protected function setUp(): void { $this->internalAddressService = $this->createMock(InternalAddressService::class); $this->availabilityCoordinator = $this->createMock(IAvailabilityCoordinator::class); $this->quickActionsService = $this->createMock(QuickActionsService::class); + $this->ionosConfigService = $this->createMock(IonosConfigService::class); $this->controller = new PageController( $this->appName, @@ -164,6 +168,7 @@ protected function setUp(): void { $this->internalAddressService, $this->availabilityCoordinator, $this->quickActionsService, + $this->ionosConfigService, ); } @@ -262,9 +267,10 @@ public function testIndex(): void { ['version', '0.0.0', '26.0.0'], ['app.mail.attachment-size-limit', 0, 123], ]); - $this->config->expects($this->exactly(7)) + $this->config->expects($this->exactly(8)) ->method('getAppValue') ->withConsecutive( + [ 'mail', 'ionos-mailconfig-enabled' ], [ 'mail', 'installed_version' ], ['mail', 'layout_message_view' ], ['mail', 'google_oauth_client_id' ], @@ -273,6 +279,7 @@ public function testIndex(): void { ['core', 'backgroundjobs_mode', 'ajax' ], ['mail', 'allow_new_mail_accounts', 'yes'], )->willReturnOnConsecutiveCalls( + $this->returnValue('no'), $this->returnValue('1.2.3'), $this->returnValue('threaded'), $this->returnValue(''), @@ -280,8 +287,10 @@ public function testIndex(): void { $this->returnValue(''), $this->returnValue('cron'), $this->returnValue('yes'), - $this->returnValue('no') ); + $this->ionosConfigService->expects($this->once()) + ->method('getMailDomain') + ->willReturn('example.tld'); $this->aiIntegrationsService->expects(self::exactly(4)) ->method('isLlmProcessingEnabled') ->willReturn(false); @@ -332,6 +341,8 @@ public function testIndex(): void { 'external-avatars' => 'true', 'reply-mode' => 'bottom', 'app-version' => '1.2.3', + 'ionos-mailconfig-enabled' => false, + 'ionos-mailconfig-domain' => 'example.tld', 'collect-data' => 'true', 'start-mailbox-id' => '123', 'tag-classified-messages' => 'false', diff --git a/tests/Unit/Service/IONOS/ApiMailConfigClientServiceTest.php b/tests/Unit/Service/IONOS/ApiMailConfigClientServiceTest.php new file mode 100644 index 0000000000..751c1f2e91 --- /dev/null +++ b/tests/Unit/Service/IONOS/ApiMailConfigClientServiceTest.php @@ -0,0 +1,113 @@ +service = new ApiMailConfigClientService(); + } + + public function testNewClientWithDefaultConfig(): void { + $config = []; + $client = $this->service->newClient($config); + + $this->assertInstanceOf(ClientInterface::class, $client); + $this->assertInstanceOf(Client::class, $client); + } + + public function testNewClientWithAuthConfig(): void { + $config = [ + 'auth' => ['username', 'password'], + 'verify' => true, + ]; + $client = $this->service->newClient($config); + + $this->assertInstanceOf(ClientInterface::class, $client); + $this->assertInstanceOf(Client::class, $client); + } + + public function testNewClientWithInsecureConfig(): void { + $config = [ + 'auth' => ['username', 'password'], + 'verify' => false, + ]; + $client = $this->service->newClient($config); + + $this->assertInstanceOf(ClientInterface::class, $client); + $this->assertInstanceOf(Client::class, $client); + } + + public function testNewEventAPIApi(): void { + $client = $this->createMock(ClientInterface::class); + $apiBaseUrl = 'https://api.example.com'; + + $apiInstance = $this->service->newEventAPIApi($client, $apiBaseUrl); + + $this->assertInstanceOf(MailConfigurationAPIApi::class, $apiInstance); + $this->assertEquals($apiBaseUrl, $apiInstance->getConfig()->getHost()); + } + + public function testNewEventAPIApiWithEmptyUrl(): void { + $client = $this->createMock(ClientInterface::class); + $apiBaseUrl = ''; + + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('API base URL is required'); + + $this->service->newEventAPIApi($client, $apiBaseUrl); + } + + public function testNewEventAPIApiWithDifferentUrls(): void { + $client = $this->createMock(ClientInterface::class); + + $urls = [ + 'https://api.example.com', + 'https://api.example.com/v1', + 'http://localhost:8080', + 'https://staging-api.example.com', + ]; + + foreach ($urls as $url) { + $apiInstance = $this->service->newEventAPIApi($client, $url); + $this->assertInstanceOf(MailConfigurationAPIApi::class, $apiInstance); + $this->assertEquals($url, $apiInstance->getConfig()->getHost()); + } + } + + public function testNewClientReturnsNewInstanceEachTime(): void { + $config = ['auth' => ['user', 'pass']]; + + $client1 = $this->service->newClient($config); + $client2 = $this->service->newClient($config); + + // Each call should return a new instance + $this->assertNotSame($client1, $client2); + } + + public function testNewEventAPIApiReturnsNewInstanceEachTime(): void { + $client = $this->createMock(ClientInterface::class); + $apiBaseUrl = 'https://api.example.com'; + + $api1 = $this->service->newEventAPIApi($client, $apiBaseUrl); + $api2 = $this->service->newEventAPIApi($client, $apiBaseUrl); + + // Each call should return a new instance + $this->assertNotSame($api1, $api2); + } +} diff --git a/tests/Unit/Service/IONOS/Dto/MailAccountConfigTest.php b/tests/Unit/Service/IONOS/Dto/MailAccountConfigTest.php new file mode 100644 index 0000000000..377e7c59ff --- /dev/null +++ b/tests/Unit/Service/IONOS/Dto/MailAccountConfigTest.php @@ -0,0 +1,248 @@ +imapConfig = new MailServerConfig( + host: 'imap.example.com', + port: 993, + security: 'ssl', + username: 'user@example.com', + password: 'imap-password', + ); + + $this->smtpConfig = new MailServerConfig( + host: 'smtp.example.com', + port: 587, + security: 'tls', + username: 'user@example.com', + password: 'smtp-password', + ); + + $this->accountConfig = new MailAccountConfig( + email: 'user@example.com', + imap: $this->imapConfig, + smtp: $this->smtpConfig, + ); + } + + public function testConstructor(): void { + $imap = new MailServerConfig('imap.test.com', 993, 'ssl', 'test@test.com', 'pass1'); + $smtp = new MailServerConfig('smtp.test.com', 465, 'ssl', 'test@test.com', 'pass2'); + + $config = new MailAccountConfig( + email: 'test@test.com', + imap: $imap, + smtp: $smtp, + ); + + $this->assertInstanceOf(MailAccountConfig::class, $config); + } + + public function testGetEmail(): void { + $this->assertEquals('user@example.com', $this->accountConfig->getEmail()); + } + + public function testGetImap(): void { + $imap = $this->accountConfig->getImap(); + + $this->assertInstanceOf(MailServerConfig::class, $imap); + $this->assertEquals('imap.example.com', $imap->getHost()); + $this->assertEquals(993, $imap->getPort()); + $this->assertEquals('ssl', $imap->getSecurity()); + $this->assertEquals('user@example.com', $imap->getUsername()); + $this->assertEquals('imap-password', $imap->getPassword()); + } + + public function testGetSmtp(): void { + $smtp = $this->accountConfig->getSmtp(); + + $this->assertInstanceOf(MailServerConfig::class, $smtp); + $this->assertEquals('smtp.example.com', $smtp->getHost()); + $this->assertEquals(587, $smtp->getPort()); + $this->assertEquals('tls', $smtp->getSecurity()); + $this->assertEquals('user@example.com', $smtp->getUsername()); + $this->assertEquals('smtp-password', $smtp->getPassword()); + } + + public function testToArray(): void { + $expected = [ + 'email' => 'user@example.com', + 'imap' => [ + 'host' => 'imap.example.com', + 'port' => 993, + 'security' => 'ssl', + 'username' => 'user@example.com', + 'password' => 'imap-password', + ], + 'smtp' => [ + 'host' => 'smtp.example.com', + 'port' => 587, + 'security' => 'tls', + 'username' => 'user@example.com', + 'password' => 'smtp-password', + ], + ]; + + $this->assertEquals($expected, $this->accountConfig->toArray()); + } + + public function testToArrayStructure(): void { + $array = $this->accountConfig->toArray(); + + $this->assertIsArray($array); + $this->assertArrayHasKey('email', $array); + $this->assertArrayHasKey('imap', $array); + $this->assertArrayHasKey('smtp', $array); + + $this->assertIsString($array['email']); + $this->assertIsArray($array['imap']); + $this->assertIsArray($array['smtp']); + + // Verify IMAP structure + $this->assertArrayHasKey('host', $array['imap']); + $this->assertArrayHasKey('port', $array['imap']); + $this->assertArrayHasKey('security', $array['imap']); + $this->assertArrayHasKey('username', $array['imap']); + $this->assertArrayHasKey('password', $array['imap']); + + // Verify SMTP structure + $this->assertArrayHasKey('host', $array['smtp']); + $this->assertArrayHasKey('port', $array['smtp']); + $this->assertArrayHasKey('security', $array['smtp']); + $this->assertArrayHasKey('username', $array['smtp']); + $this->assertArrayHasKey('password', $array['smtp']); + } + + public function testCompleteMailConfiguration(): void { + $array = $this->accountConfig->toArray(); + + // Verify that both IMAP and SMTP configs are complete + $this->assertEquals('user@example.com', $array['email']); + $this->assertEquals('imap.example.com', $array['imap']['host']); + $this->assertEquals('smtp.example.com', $array['smtp']['host']); + $this->assertNotEquals($array['imap']['password'], $array['smtp']['password']); + } + + public function testReadonlyProperties(): void { + // Test immutability by calling methods multiple times + $email1 = $this->accountConfig->getEmail(); + $email2 = $this->accountConfig->getEmail(); + $this->assertEquals($email1, $email2); + + $imap1 = $this->accountConfig->getImap(); + $imap2 = $this->accountConfig->getImap(); + $this->assertEquals($imap1, $imap2); + + $smtp1 = $this->accountConfig->getSmtp(); + $smtp2 = $this->accountConfig->getSmtp(); + $this->assertEquals($smtp1, $smtp2); + + $array1 = $this->accountConfig->toArray(); + $array2 = $this->accountConfig->toArray(); + $this->assertEquals($array1, $array2); + } + + public function testDifferentEmailFormats(): void { + $emails = [ + 'simple@example.com', + 'user.name@example.com', + 'user+tag@example.co.uk', + 'user_name@sub.example.com', + ]; + + foreach ($emails as $email) { + $imap = new MailServerConfig('imap.host', 993, 'ssl', $email, 'pass'); + $smtp = new MailServerConfig('smtp.host', 587, 'tls', $email, 'pass'); + $config = new MailAccountConfig($email, $imap, $smtp); + + $this->assertEquals($email, $config->getEmail()); + $this->assertEquals($email, $config->toArray()['email']); + } + } + + public function testSameCredentialsForImapAndSmtp(): void { + $email = 'user@example.com'; + $password = 'shared-password'; + + $imap = new MailServerConfig('imap.example.com', 993, 'ssl', $email, $password); + $smtp = new MailServerConfig('smtp.example.com', 587, 'tls', $email, $password); + + $config = new MailAccountConfig($email, $imap, $smtp); + + $this->assertEquals($password, $config->getImap()->getPassword()); + $this->assertEquals($password, $config->getSmtp()->getPassword()); + } + + public function testDifferentCredentialsForImapAndSmtp(): void { + $email = 'user@example.com'; + $imapPassword = 'imap-specific-password'; + $smtpPassword = 'smtp-specific-password'; + + $imap = new MailServerConfig('imap.example.com', 993, 'ssl', $email, $imapPassword); + $smtp = new MailServerConfig('smtp.example.com', 587, 'tls', $email, $smtpPassword); + + $config = new MailAccountConfig($email, $imap, $smtp); + + $this->assertEquals($imapPassword, $config->getImap()->getPassword()); + $this->assertEquals($smtpPassword, $config->getSmtp()->getPassword()); + $this->assertNotEquals( + $config->getImap()->getPassword(), + $config->getSmtp()->getPassword() + ); + } + + public function testEmptyEmail(): void { + $imap = new MailServerConfig('imap.host', 993, 'ssl', '', ''); + $smtp = new MailServerConfig('smtp.host', 587, 'tls', '', ''); + $config = new MailAccountConfig('', $imap, $smtp); + + $this->assertEquals('', $config->getEmail()); + $this->assertEquals('', $config->toArray()['email']); + } + + public function testToArrayBackwardsCompatibility(): void { + // Ensure the array format is compatible with existing code expecting this structure + $array = $this->accountConfig->toArray(); + + // Top-level keys + $this->assertCount(3, $array); + $this->assertArrayHasKey('email', $array); + $this->assertArrayHasKey('imap', $array); + $this->assertArrayHasKey('smtp', $array); + + // Server config keys + $serverKeys = ['host', 'port', 'security', 'username', 'password']; + foreach ($serverKeys as $key) { + $this->assertArrayHasKey($key, $array['imap'], "IMAP missing key: $key"); + $this->assertArrayHasKey($key, $array['smtp'], "SMTP missing key: $key"); + } + } + + public function testNestedObjectAccess(): void { + // Test that we can chain method calls + $imapHost = $this->accountConfig->getImap()->getHost(); + $smtpHost = $this->accountConfig->getSmtp()->getHost(); + + $this->assertEquals('imap.example.com', $imapHost); + $this->assertEquals('smtp.example.com', $smtpHost); + } +} diff --git a/tests/Unit/Service/IONOS/Dto/MailServerConfigTest.php b/tests/Unit/Service/IONOS/Dto/MailServerConfigTest.php new file mode 100644 index 0000000000..9c45508352 --- /dev/null +++ b/tests/Unit/Service/IONOS/Dto/MailServerConfigTest.php @@ -0,0 +1,168 @@ +config = new MailServerConfig( + host: 'imap.example.com', + port: 993, + security: 'ssl', + username: 'user@example.com', + password: 'secret123', + ); + } + + public function testConstructor(): void { + $config = new MailServerConfig( + host: 'smtp.example.com', + port: 465, + security: 'tls', + username: 'test@example.com', + password: 'password123', + ); + + $this->assertInstanceOf(MailServerConfig::class, $config); + } + + public function testGetHost(): void { + $this->assertEquals('imap.example.com', $this->config->getHost()); + } + + public function testGetPort(): void { + $this->assertEquals(993, $this->config->getPort()); + } + + public function testGetSecurity(): void { + $this->assertEquals('ssl', $this->config->getSecurity()); + } + + public function testGetUsername(): void { + $this->assertEquals('user@example.com', $this->config->getUsername()); + } + + public function testGetPassword(): void { + $this->assertEquals('secret123', $this->config->getPassword()); + } + + public function testToArray(): void { + $expected = [ + 'host' => 'imap.example.com', + 'port' => 993, + 'security' => 'ssl', + 'username' => 'user@example.com', + 'password' => 'secret123', + ]; + + $this->assertEquals($expected, $this->config->toArray()); + } + + public function testToArrayStructure(): void { + $array = $this->config->toArray(); + + $this->assertIsArray($array); + $this->assertArrayHasKey('host', $array); + $this->assertArrayHasKey('port', $array); + $this->assertArrayHasKey('security', $array); + $this->assertArrayHasKey('username', $array); + $this->assertArrayHasKey('password', $array); + $this->assertIsString($array['host']); + $this->assertIsInt($array['port']); + $this->assertIsString($array['security']); + $this->assertIsString($array['username']); + $this->assertIsString($array['password']); + } + + public function testImapConfiguration(): void { + $imapConfig = new MailServerConfig( + host: 'imap.example.com', + port: 993, + security: 'ssl', + username: 'user@example.com', + password: 'imap-password', + ); + + $this->assertEquals('imap.example.com', $imapConfig->getHost()); + $this->assertEquals(993, $imapConfig->getPort()); + $this->assertEquals('ssl', $imapConfig->getSecurity()); + } + + public function testSmtpConfiguration(): void { + $smtpConfig = new MailServerConfig( + host: 'smtp.example.com', + port: 587, + security: 'tls', + username: 'user@example.com', + password: 'smtp-password', + ); + + $this->assertEquals('smtp.example.com', $smtpConfig->getHost()); + $this->assertEquals(587, $smtpConfig->getPort()); + $this->assertEquals('tls', $smtpConfig->getSecurity()); + } + + public function testReadonlyProperties(): void { + // Test that properties are immutable by attempting to convert to array multiple times + $array1 = $this->config->toArray(); + $array2 = $this->config->toArray(); + + $this->assertEquals($array1, $array2); + $this->assertEquals('imap.example.com', $this->config->getHost()); + } + + public function testEmptyStringValues(): void { + $config = new MailServerConfig( + host: '', + port: 0, + security: '', + username: '', + password: '', + ); + + $this->assertEquals('', $config->getHost()); + $this->assertEquals(0, $config->getPort()); + $this->assertEquals('', $config->getSecurity()); + $this->assertEquals('', $config->getUsername()); + $this->assertEquals('', $config->getPassword()); + } + + public function testDifferentPortNumbers(): void { + $configs = [ + new MailServerConfig('host', 143, 'none', 'user', 'pass'), // IMAP + new MailServerConfig('host', 993, 'ssl', 'user', 'pass'), // IMAPS + new MailServerConfig('host', 25, 'none', 'user', 'pass'), // SMTP + new MailServerConfig('host', 465, 'ssl', 'user', 'pass'), // SMTPS + new MailServerConfig('host', 587, 'tls', 'user', 'pass'), // SMTP Submission + ]; + + $this->assertEquals(143, $configs[0]->getPort()); + $this->assertEquals(993, $configs[1]->getPort()); + $this->assertEquals(25, $configs[2]->getPort()); + $this->assertEquals(465, $configs[3]->getPort()); + $this->assertEquals(587, $configs[4]->getPort()); + } + + public function testDifferentSecurityTypes(): void { + $sslConfig = new MailServerConfig('host', 993, 'ssl', 'user', 'pass'); + $tlsConfig = new MailServerConfig('host', 587, 'tls', 'user', 'pass'); + $noneConfig = new MailServerConfig('host', 143, 'none', 'user', 'pass'); + + $this->assertEquals('ssl', $sslConfig->getSecurity()); + $this->assertEquals('tls', $tlsConfig->getSecurity()); + $this->assertEquals('none', $noneConfig->getSecurity()); + } +} diff --git a/tests/Unit/Service/IONOS/IonosConfigServiceTest.php b/tests/Unit/Service/IONOS/IonosConfigServiceTest.php new file mode 100644 index 0000000000..b4391f3e53 --- /dev/null +++ b/tests/Unit/Service/IONOS/IonosConfigServiceTest.php @@ -0,0 +1,220 @@ +config = $this->createMock(IConfig::class); + $this->appConfig = $this->createMock(IAppConfig::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->service = new IonosConfigService( + $this->config, + $this->appConfig, + $this->logger, + ); + } + + public function testGetExternalReferenceSuccess(): void { + $this->config->method('getSystemValue') + ->with('ncw.ext_ref') + ->willReturn('test-ext-ref'); + + $result = $this->service->getExternalReference(); + $this->assertEquals('test-ext-ref', $result); + } + + public function testGetExternalReferenceMissing(): void { + $this->config->method('getSystemValue') + ->with('ncw.ext_ref') + ->willReturn(''); + + $this->logger->expects($this->once()) + ->method('error') + ->with('No external reference is configured'); + + $this->expectException(AppConfigException::class); + $this->expectExceptionMessage('No external reference configured'); + + $this->service->getExternalReference(); + } + + public function testGetApiBaseUrlSuccess(): void { + $this->appConfig->method('getValueString') + ->with(Application::APP_ID, 'ionos_mailconfig_api_base_url') + ->willReturn('https://api.example.com'); + + $result = $this->service->getApiBaseUrl(); + $this->assertEquals('https://api.example.com', $result); + } + + public function testGetApiBaseUrlMissing(): void { + $this->appConfig->method('getValueString') + ->with(Application::APP_ID, 'ionos_mailconfig_api_base_url') + ->willReturn(''); + + $this->logger->expects($this->once()) + ->method('error') + ->with('No mailconfig service url is configured'); + + $this->expectException(AppConfigException::class); + $this->expectExceptionMessage('No mailconfig service url configured'); + + $this->service->getApiBaseUrl(); + } + + public function testGetAllowInsecure(): void { + $this->appConfig->method('getValueBool') + ->with(Application::APP_ID, 'ionos_mailconfig_api_allow_insecure', false) + ->willReturn(true); + + $result = $this->service->getAllowInsecure(); + $this->assertTrue($result); + } + + public function testGetBasicAuthUserSuccess(): void { + $this->appConfig->method('getValueString') + ->with(Application::APP_ID, 'ionos_mailconfig_api_auth_user') + ->willReturn('testuser'); + + $result = $this->service->getBasicAuthUser(); + $this->assertEquals('testuser', $result); + } + + public function testGetBasicAuthUserMissing(): void { + $this->appConfig->method('getValueString') + ->with(Application::APP_ID, 'ionos_mailconfig_api_auth_user') + ->willReturn(''); + + $this->logger->expects($this->once()) + ->method('error') + ->with('No mailconfig service user is configured'); + + $this->expectException(AppConfigException::class); + $this->expectExceptionMessage('No mailconfig service user configured'); + + $this->service->getBasicAuthUser(); + } + + public function testGetBasicAuthPasswordSuccess(): void { + $this->appConfig->method('getValueString') + ->with(Application::APP_ID, 'ionos_mailconfig_api_auth_pass') + ->willReturn('testpass'); + + $result = $this->service->getBasicAuthPassword(); + $this->assertEquals('testpass', $result); + } + + public function testGetBasicAuthPasswordMissing(): void { + $this->appConfig->method('getValueString') + ->with(Application::APP_ID, 'ionos_mailconfig_api_auth_pass') + ->willReturn(''); + + $this->logger->expects($this->once()) + ->method('error') + ->with('No mailconfig service password is configured'); + + $this->expectException(AppConfigException::class); + $this->expectExceptionMessage('No mailconfig service password configured'); + + $this->service->getBasicAuthPassword(); + } + + public function testGetApiConfigSuccess(): void { + $this->config->method('getSystemValue') + ->with('ncw.ext_ref') + ->willReturn('test-ext-ref'); + + $this->appConfig->method('getValueString') + ->willReturnCallback(function ($appId, $key) { + $values = [ + 'ionos_mailconfig_api_base_url' => 'https://api.example.com', + 'ionos_mailconfig_api_auth_user' => 'testuser', + 'ionos_mailconfig_api_auth_pass' => 'testpass', + ]; + return $values[$key] ?? ''; + }); + + $this->appConfig->method('getValueBool') + ->with(Application::APP_ID, 'ionos_mailconfig_api_allow_insecure', false) + ->willReturn(false); + + $result = $this->service->getApiConfig(); + + $this->assertEquals([ + 'extRef' => 'test-ext-ref', + 'apiBaseUrl' => 'https://api.example.com', + 'allowInsecure' => false, + 'basicAuthUser' => 'testuser', + 'basicAuthPass' => 'testpass', + ], $result); + } + + public function testGetMailDomainWithValidDomain(): void { + $this->config->method('getSystemValue') + ->with('ncw.customerDomain', '') + ->willReturn('mail.example.com'); + + $result = $this->service->getMailDomain(); + $this->assertEquals('example.com', $result); + } + + public function testGetMailDomainWithEmptyDomain(): void { + $this->config->method('getSystemValue') + ->with('ncw.customerDomain', '') + ->willReturn(''); + + $result = $this->service->getMailDomain(); + $this->assertEquals('', $result); + } + + public function testGetMailDomainWithMultiLevelTld(): void { + $this->config->method('getSystemValue') + ->with('ncw.customerDomain', '') + ->willReturn('mail.test.co.uk'); + + $result = $this->service->getMailDomain(); + $this->assertEquals('test.co.uk', $result); + } + + public function testGetMailDomainWithSubdomain(): void { + $this->config->method('getSystemValue') + ->with('ncw.customerDomain', '') + ->willReturn('foo.bar.lol'); + + $result = $this->service->getMailDomain(); + $this->assertEquals('bar.lol', $result); + } + + public function testGetMailDomainWithSimpleDomain(): void { + $this->config->method('getSystemValue') + ->with('ncw.customerDomain', '') + ->willReturn('example.com'); + + $result = $this->service->getMailDomain(); + $this->assertEquals('example.com', $result); + } +} diff --git a/tests/Unit/Service/IONOS/IonosMailServiceTest.php b/tests/Unit/Service/IONOS/IonosMailServiceTest.php new file mode 100644 index 0000000000..94842f920b --- /dev/null +++ b/tests/Unit/Service/IONOS/IonosMailServiceTest.php @@ -0,0 +1,382 @@ +apiClientService = $this->createMock(ApiMailConfigClientService::class); + $this->configService = $this->createMock(IonosConfigService::class); + $this->userSession = $this->createMock(IUserSession::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->service = new IonosMailService( + $this->apiClientService, + $this->configService, + $this->userSession, + $this->logger, + ); + } + + public function testCreateEmailAccountSuccess(): void { + $userName = 'test'; + $domain = 'example.com'; + $emailAddress = $userName . '@' . $domain; + + // Mock config + $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); + $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); + $this->configService->method('getAllowInsecure')->willReturn(false); + $this->configService->method('getBasicAuthUser')->willReturn('testuser'); + $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); + $this->configService->method('getMailDomain')->willReturn($domain); + + // Mock user session + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser123'); + $this->userSession->method('getUser')->willReturn($user); + + // Mock API client + $client = $this->createMock(ClientInterface::class); + $this->apiClientService->method('newClient') + ->with([ + 'auth' => ['testuser', 'testpass'], + 'verify' => true, + ]) + ->willReturn($client); + + $apiInstance = $this->createMock(MailConfigurationAPIApi::class); + $this->apiClientService->method('newEventAPIApi') + ->with($client, 'https://api.example.com') + ->willReturn($apiInstance); + + // Mock API response - use getMockBuilder with onlyMethods for existing methods + $imapServer = $this->getMockBuilder(Imap::class) + ->disableOriginalConstructor() + ->onlyMethods(['getHost', 'getPort', 'getSslMode']) + ->getMock(); + $imapServer->method('getHost')->willReturn('imap.example.com'); + $imapServer->method('getPort')->willReturn(993); + $imapServer->method('getSslMode')->willReturn('ssl'); + + $smtpServer = $this->getMockBuilder(Smtp::class) + ->disableOriginalConstructor() + ->onlyMethods(['getHost', 'getPort', 'getSslMode']) + ->getMock(); + $smtpServer->method('getHost')->willReturn('smtp.example.com'); + $smtpServer->method('getPort')->willReturn(587); + $smtpServer->method('getSslMode')->willReturn('tls'); + + $mailServer = $this->getMockBuilder(MailServer::class) + ->disableOriginalConstructor() + ->onlyMethods(['getImap', 'getSmtp']) + ->getMock(); + $mailServer->method('getImap')->willReturn($imapServer); + $mailServer->method('getSmtp')->willReturn($smtpServer); + + $mailAccountResponse = $this->getMockBuilder(MailAccountResponse::class) + ->disableOriginalConstructor() + ->onlyMethods(['getEmail', 'getPassword', 'getServer']) + ->getMock(); + $mailAccountResponse->method('getEmail')->willReturn($emailAddress); + $mailAccountResponse->method('getPassword')->willReturn('test-password'); + $mailAccountResponse->method('getServer')->willReturn($mailServer); + + $apiInstance->method('createMailbox')->willReturn($mailAccountResponse); + + $result = $this->service->createEmailAccount($userName); + + $this->assertInstanceOf(MailAccountConfig::class, $result); + $this->assertEquals($emailAddress, $result->getEmail()); + $this->assertEquals('imap.example.com', $result->getImap()->getHost()); + $this->assertEquals(993, $result->getImap()->getPort()); + $this->assertEquals('ssl', $result->getImap()->getSecurity()); + $this->assertEquals($emailAddress, $result->getImap()->getUsername()); + $this->assertEquals('test-password', $result->getImap()->getPassword()); + $this->assertEquals('smtp.example.com', $result->getSmtp()->getHost()); + $this->assertEquals(587, $result->getSmtp()->getPort()); + $this->assertEquals('tls', $result->getSmtp()->getSecurity()); + $this->assertEquals($emailAddress, $result->getSmtp()->getUsername()); + $this->assertEquals('test-password', $result->getSmtp()->getPassword()); + } + + public function testCreateEmailAccountWithApiException(): void { + $userName = 'test'; + $domain = 'example.com'; + + // Mock config + $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); + $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); + $this->configService->method('getAllowInsecure')->willReturn(false); + $this->configService->method('getBasicAuthUser')->willReturn('testuser'); + $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); + $this->configService->method('getMailDomain')->willReturn($domain); + + // Mock user session + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser123'); + $this->userSession->method('getUser')->willReturn($user); + + // Mock API client + $client = $this->createMock(ClientInterface::class); + $this->apiClientService->method('newClient')->willReturn($client); + + $apiInstance = $this->createMock(MailConfigurationAPIApi::class); + $this->apiClientService->method('newEventAPIApi')->willReturn($apiInstance); + + // Mock API to throw exception + $apiInstance->method('createMailbox') + ->willThrowException(new \Exception('API call failed')); + + $this->logger->expects($this->once()) + ->method('error') + ->with('Exception when calling MailConfigurationAPIApi->createMailbox', $this->anything()); + + $this->expectException(ServiceException::class); + $this->expectExceptionMessage('Failed to create ionos mail'); + + $this->service->createEmailAccount($userName); + } + + public function testCreateEmailAccountWithErrorMessageResponse(): void { + $userName = 'test'; + $domain = 'example.com'; + + // Mock config + $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); + $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); + $this->configService->method('getAllowInsecure')->willReturn(false); + $this->configService->method('getBasicAuthUser')->willReturn('testuser'); + $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); + $this->configService->method('getMailDomain')->willReturn($domain); + + // Mock user session + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser123'); + $this->userSession->method('getUser')->willReturn($user); + + // Mock API client + $client = $this->createMock(ClientInterface::class); + $this->apiClientService->method('newClient')->willReturn($client); + + $apiInstance = $this->createMock(MailConfigurationAPIApi::class); + $this->apiClientService->method('newEventAPIApi')->willReturn($apiInstance); + + // Mock ErrorMessage response + $errorMessage = $this->getMockBuilder(ErrorMessage::class) + ->disableOriginalConstructor() + ->onlyMethods(['getStatus', 'getMessage']) + ->getMock(); + $errorMessage->method('getStatus')->willReturn(400); + $errorMessage->method('getMessage')->willReturn('Bad Request'); + + $apiInstance->method('createMailbox')->willReturn($errorMessage); + + $this->expectException(ServiceException::class); + $this->expectExceptionMessage('Failed to create ionos mail'); + + $this->service->createEmailAccount($userName); + } + + public function testCreateEmailAccountWithUnknownResponseType(): void { + $userName = 'test'; + $domain = 'example.com'; + + // Mock config + $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); + $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); + $this->configService->method('getAllowInsecure')->willReturn(false); + $this->configService->method('getBasicAuthUser')->willReturn('testuser'); + $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); + $this->configService->method('getMailDomain')->willReturn($domain); + + // Mock user session + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser123'); + $this->userSession->method('getUser')->willReturn($user); + + // Mock API client + $client = $this->createMock(ClientInterface::class); + $this->apiClientService->method('newClient')->willReturn($client); + + $apiInstance = $this->createMock(MailConfigurationAPIApi::class); + $this->apiClientService->method('newEventAPIApi')->willReturn($apiInstance); + + // Mock unknown response type (return a stdClass instead of expected types) + $unknownResponse = new \stdClass(); + $apiInstance->method('createMailbox')->willReturn($unknownResponse); + + + $this->expectException(ServiceException::class); + $this->expectExceptionMessage('Failed to create ionos mail'); + + $this->service->createEmailAccount($userName); + } + + public function testCreateEmailAccountWithNoUserSession(): void { + $userName = 'test'; + + // Mock config + $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); + $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); + $this->configService->method('getAllowInsecure')->willReturn(false); + $this->configService->method('getBasicAuthUser')->willReturn('testuser'); + $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); + + // Mock no user session + $this->userSession->method('getUser')->willReturn(null); + + $this->expectException(ServiceException::class); + $this->expectExceptionMessage('No user session found'); + + $this->service->createEmailAccount($userName); + } + + /** + * Test SSL mode normalization with various API response values + * + * @dataProvider sslModeNormalizationProvider + */ + public function testSslModeNormalization(string $apiSslMode, string $expectedSecurity): void { + $userName = 'test'; + $domain = 'example.com'; + $emailAddress = $userName . '@' . $domain; + + // Mock config + $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); + $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); + $this->configService->method('getAllowInsecure')->willReturn(false); + $this->configService->method('getBasicAuthUser')->willReturn('testuser'); + $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); + $this->configService->method('getMailDomain')->willReturn($domain); + + // Mock user session + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser123'); + $this->userSession->method('getUser')->willReturn($user); + + // Mock API client + $client = $this->createMock(ClientInterface::class); + $this->apiClientService->method('newClient')->willReturn($client); + + $apiInstance = $this->createMock(MailConfigurationAPIApi::class); + $this->apiClientService->method('newEventAPIApi')->willReturn($apiInstance); + + // Mock API response with specific SSL mode + $imapServer = $this->getMockBuilder(Imap::class) + ->disableOriginalConstructor() + ->onlyMethods(['getHost', 'getPort', 'getSslMode']) + ->getMock(); + $imapServer->method('getHost')->willReturn('imap.example.com'); + $imapServer->method('getPort')->willReturn(993); + $imapServer->method('getSslMode')->willReturn($apiSslMode); + + $smtpServer = $this->getMockBuilder(Smtp::class) + ->disableOriginalConstructor() + ->onlyMethods(['getHost', 'getPort', 'getSslMode']) + ->getMock(); + $smtpServer->method('getHost')->willReturn('smtp.example.com'); + $smtpServer->method('getPort')->willReturn(587); + $smtpServer->method('getSslMode')->willReturn($apiSslMode); + + $mailServer = $this->getMockBuilder(MailServer::class) + ->disableOriginalConstructor() + ->onlyMethods(['getImap', 'getSmtp']) + ->getMock(); + $mailServer->method('getImap')->willReturn($imapServer); + $mailServer->method('getSmtp')->willReturn($smtpServer); + + $mailAccountResponse = $this->getMockBuilder(MailAccountResponse::class) + ->disableOriginalConstructor() + ->onlyMethods(['getEmail', 'getPassword', 'getServer']) + ->getMock(); + $mailAccountResponse->method('getEmail')->willReturn($emailAddress); + $mailAccountResponse->method('getPassword')->willReturn('test-password'); + $mailAccountResponse->method('getServer')->willReturn($mailServer); + + $apiInstance->method('createMailbox')->willReturn($mailAccountResponse); + + $result = $this->service->createEmailAccount($userName); + + $this->assertEquals($expectedSecurity, $result->getImap()->getSecurity()); + $this->assertEquals($expectedSecurity, $result->getSmtp()->getSecurity()); + } + + /** + * Data provider for SSL mode normalization tests + * + * @return array + */ + public static function sslModeNormalizationProvider(): array { + return [ + 'SSL should map to ssl' => [ + 'apiSslMode' => 'SSL', + 'expectedSecurity' => 'ssl', + ], + 'ssl should map to ssl' => [ + 'apiSslMode' => 'ssl', + 'expectedSecurity' => 'ssl', + ], + 'TLS should map to tls' => [ + 'apiSslMode' => 'TLS', + 'expectedSecurity' => 'tls', + ], + 'tls should map to tls' => [ + 'apiSslMode' => 'tls', + 'expectedSecurity' => 'tls', + ], + 'STARTTLS should map to tls' => [ + 'apiSslMode' => 'STARTTLS', + 'expectedSecurity' => 'tls', + ], + 'starttls should map to tls' => [ + 'apiSslMode' => 'starttls', + 'expectedSecurity' => 'tls', + ], + 'none should map to none' => [ + 'apiSslMode' => 'none', + 'expectedSecurity' => 'none', + ], + 'NONE should map to none' => [ + 'apiSslMode' => 'NONE', + 'expectedSecurity' => 'none', + ], + 'unknown value should default to none' => [ + 'apiSslMode' => 'unknown', + 'expectedSecurity' => 'none', + ], + ]; + } +}