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..5496bf7711 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-e22cc02" + }, + "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..86f92d3fb2 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": "ed55281e53b39c89bf8969dee2bfa0b1", "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-e22cc02" + }, + "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..34fde227d9 --- /dev/null +++ b/lib/Controller/IonosAccountsController.php @@ -0,0 +1,163 @@ + false, 'message' => self::ERR_ALL_FIELDS_REQUIRED, 'error' => self::ERR_IONOS_API_ERROR], 400); + } + if (!filter_var($emailAddress, FILTER_VALIDATE_EMAIL)) { + return new JSONResponse(['success' => false, 'message' => 'Invalid email address format', 'error' => self::ERR_IONOS_API_ERROR], 400); + } + return null; + } + + /** + * @NoAdminRequired + */ + #[TrapError] + public function create(string $accountName, string $emailAddress): JSONResponse { + if ($error = $this->validateInput($accountName, $emailAddress)) { + return $error; + } + + try { + $this->logger->info('Starting IONOS email account creation', [ 'emailAddress' => $emailAddress, 'accountName' => $accountName ]); + $mailConfig = $this->createIonosEmailAccount($accountName, $emailAddress); + $accountResponse = $this->createNextcloudMailAccount($accountName, $emailAddress, $mailConfig); + $this->logger->info('IONOS email account created successfully', [ 'emailAddress' => $emailAddress ]); + return new JSONResponse([ + 'success' => true, + 'message' => 'Email account created successfully via IONOS', + 'account' => $accountResponse->getData(), + ], 201); + } catch (ServiceException $e) { + return $this->handleServiceException($e, $emailAddress); + } catch (\Exception $e) { + return $this->handleGenericException($e, $emailAddress); + } + } + + /** + * @throws ServiceException + */ + private function createIonosEmailAccount(string $accountName, string $emailAddress): array { + $ionosResponse = $this->callIonosCreateEmailAPI($accountName, $emailAddress); + if ($ionosResponse === null || !($ionosResponse['success'] ?? false)) { + $this->logger->error('Failed to create IONOS email account', [ 'emailAddress' => $emailAddress, 'response' => $ionosResponse ]); + throw new ServiceException(self::ERR_CREATE_EMAIL_FAILED); + } + $mailConfig = $ionosResponse['mailConfig'] ?? null; + if (!is_array($mailConfig)) { + $this->logger->error('IONOS API response missing mailConfig', [ 'emailAddress' => $emailAddress, 'response' => $ionosResponse ]); + throw new ServiceException('Invalid IONOS API response: missing mail configuration'); + } + return $mailConfig; + } + + /** + * @throws ServiceException + */ + private function createNextcloudMailAccount(string $accountName, string $emailAddress, array $mailConfig): JSONResponse { + if (!isset($mailConfig['imap'], $mailConfig['smtp'])) { + throw new ServiceException('Invalid mail configuration: missing IMAP or SMTP configuration'); + } + $imap = $mailConfig['imap']; + $smtp = $mailConfig['smtp']; + if (!is_array($imap) || !is_array($smtp)) { + throw new ServiceException('Invalid mail configuration: IMAP or SMTP configuration must be arrays'); + } + return $this->accountsController->create( + $accountName, + $emailAddress, + (string)$imap['host'], + (int)$imap['port'], + (string)$imap['security'], + (string)($imap['username'] ?? $emailAddress), + (string)($imap['password'] ?? ''), + (string)$smtp['host'], + (int)$smtp['port'], + (string)$smtp['security'], + (string)($smtp['username'] ?? $emailAddress), + (string)($smtp['password'] ?? ''), + ); + } + + private function handleServiceException(ServiceException $e, string $emailAddress): JSONResponse { + $this->logger->error('IONOS service error', [ 'exception' => $e, 'emailAddress' => $emailAddress ]); + return new JSONResponse(['success' => false, 'message' => $e->getMessage(), 'error' => self::ERR_IONOS_API_ERROR], 400); + } + + /** + * @throws ServiceException + */ + protected function callIonosCreateEmailAPI(string $accountName, string $emailAddress): ?array { + $atPosition = strrchr($emailAddress, '@'); + if ($atPosition === false) { + throw new ServiceException('Invalid email address: unable to extract domain'); + } + $domain = substr($atPosition, 1); + if ($domain === '') { + throw new ServiceException('Invalid email address: unable to extract domain'); + } + return [ + 'success' => true, + 'message' => 'Email account created successfully via IONOS (mock)', + 'mailConfig' => [ + 'imap' => [ + 'host' => 'mail.localhost', // 'imap.' . $domain, + 'password' => 'tmp', + 'port' => 1143, // 993, + 'security' => 'none', + 'username' => 'admin@strado.de' // $emailAddress, + ], + 'smtp' => [ + 'host' => 'mail.localhost', // 'smtp.' . $domain, + 'password' => 'tmp', + 'port' => 1587, // 465, + 'security' => 'none', + 'username' => 'admin@strado.de' // $emailAddress, + ] + ] + ]; + } + + private function handleGenericException(\Exception $e, string $emailAddress): JSONResponse { + $this->logger->error('Unexpected error during IONOS account creation', [ 'exception' => $e, 'emailAddress' => $emailAddress ]); + return new JSONResponse(['success' => false, 'message' => self::ERR_GENERIC_SETUP, 'error' => self::ERR_UNKNOWN_ERROR], 500); + } +} diff --git a/src/components/AccountForm.vue b/src/components/AccountForm.vue index f3cc2263d9..f5a775eb49 100644 --- a/src/components/AccountForm.vue +++ b/src/components/AccountForm.vue @@ -199,6 +199,11 @@ 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 +257,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 +270,7 @@ export default { ButtonVue, IconLoading, IconCheck, + NewEmailAddressTab, }, props: { displayName: { diff --git a/src/components/ionos/NewEmailAddressTab.vue b/src/components/ionos/NewEmailAddressTab.vue new file mode 100644 index 0000000000..d0c2c9f3b3 --- /dev/null +++ b/src/components/ionos/NewEmailAddressTab.vue @@ -0,0 +1,176 @@ + + + + + + + + diff --git a/tests/Unit/Controller/IonosAccountsControllerTest.php b/tests/Unit/Controller/IonosAccountsControllerTest.php new file mode 100644 index 0000000000..65553ae35a --- /dev/null +++ b/tests/Unit/Controller/IonosAccountsControllerTest.php @@ -0,0 +1,391 @@ +appName = 'mail'; + $this->request = $this->createMock(IRequest::class); + $this->accountsController = $this->createMock(AccountsController::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->controller = new IonosAccountsController( + $this->appName, + $this->request, + $this->accountsController, + $this->logger, + ); + } + + public function testCreateWithMissingFields(): void { + // Test with empty account name + $response = $this->controller->create('', 'test@example.com'); + $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 address + $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 testCreateWithInvalidEmailFormat(): void { + $response = $this->controller->create('Test Account', 'invalid-email'); + $this->assertEquals(400, $response->getStatus()); + $data = $response->getData(); + $this->assertFalse($data['success']); + $this->assertEquals('Invalid email address format', $data['message']); + $this->assertEquals('IONOS_API_ERROR', $data['error']); + } + + public function testCreateSuccess(): void { + $accountName = 'Test Account'; + $emailAddress = 'test@example.com'; + + // Mock successful IONOS API response - using the actual mock data from controller + $this->mockCallIonosCreateEmailAPI([ + 'success' => true, + 'message' => 'Email account created successfully via IONOS (mock)', + 'mailConfig' => [ + 'imap' => [ + 'host' => 'mail.localhost', + 'password' => 'tmp', + 'port' => 1143, + 'security' => 'none', + 'username' => 'admin@strado.de', + ], + 'smtp' => [ + 'host' => 'mail.localhost', + 'password' => 'tmp', + 'port' => 1587, + 'security' => 'none', + 'username' => 'admin@strado.de', + ] + ] + ]); + + // Mock account creation response + $accountData = ['id' => 1, 'emailAddress' => $emailAddress]; + $accountResponse = $this->createMock(JSONResponse::class); + $accountResponse->method('getData')->willReturn($accountData); + + $this->accountsController + ->method('create') + ->willReturn($accountResponse); + + $response = $this->controller->create($accountName, $emailAddress); + + $this->assertEquals(201, $response->getStatus()); + $data = $response->getData(); + $this->assertTrue($data['success']); + $this->assertEquals('Email account created successfully via IONOS', $data['message']); + $this->assertEquals($accountData, $data['account']); + } + + public function testCreateWithServiceException(): void { + $accountName = 'Test Account'; + $emailAddress = 'test@example.com'; + + // Mock failed IONOS API response by throwing ServiceException + $this->mockCallIonosCreateEmailAPI(null, new ServiceException('Failed to create email account')); + + $this->logger + ->expects($this->atLeastOnce()) + ->method('error') + ->with( + $this->callback(function ($message) { + return $message === 'IONOS service error' || $message === 'Unexpected error during IONOS account creation'; + }), + $this->callback(function ($context) use ($emailAddress) { + return $context['emailAddress'] === $emailAddress; + }) + ); + + $response = $this->controller->create($accountName, $emailAddress); + + $this->assertEquals(400, $response->getStatus()); + $data = $response->getData(); + $this->assertFalse($data['success']); + $this->assertEquals('IONOS_API_ERROR', $data['error']); + $this->assertEquals('Failed to create email account', $data['message']); + } + + public function testCreateWithGenericException(): void { + $accountName = 'Test Account'; + $emailAddress = 'test@example.com'; + + // Mock IONOS API to throw a generic exception + $this->mockCallIonosCreateEmailAPI(null, new \Exception('Generic error')); + + $this->logger + ->expects($this->atLeastOnce()) + ->method('error') + ->with( + $this->callback(function ($message) { + return $message === 'IONOS service error' || $message === 'Unexpected error during IONOS account creation'; + }), + $this->callback(function ($context) use ($emailAddress) { + return $context['emailAddress'] === $emailAddress; + }) + ); + + $response = $this->controller->create($accountName, $emailAddress); + + $this->assertEquals(500, $response->getStatus()); + $data = $response->getData(); + $this->assertFalse($data['success']); + $this->assertEquals('UNKNOWN_ERROR', $data['error']); + $this->assertEquals('There was an error while setting up your account', $data['message']); + } + + public function testCreateIonosEmailAccountSuccess(): void { + $accountName = 'Test Account'; + $emailAddress = 'test@example.com'; + + // Mock successful IONOS API response - using actual mock data + $mockResponse = [ + 'success' => true, + 'mailConfig' => [ + 'imap' => [ + 'host' => 'mail.localhost', + 'port' => 1143, + 'security' => 'none', + 'username' => 'admin@strado.de', + ], + 'smtp' => [ + 'host' => 'mail.localhost', + 'port' => 1587, + 'security' => 'none', + 'username' => 'admin@strado.de', + ] + ] + ]; + + $this->mockCallIonosCreateEmailAPI($mockResponse); + + $reflection = new ReflectionClass($this->controller); + $method = $reflection->getMethod('createIonosEmailAccount'); + $method->setAccessible(true); + + $result = $method->invoke($this->controller, $accountName, $emailAddress); + $this->assertEquals($mockResponse['mailConfig'], $result); + } + + public function testCreateIonosEmailAccountFailure(): void { + $accountName = 'Test Account'; + $emailAddress = 'test@example.com'; + + // Mock failed IONOS API response + $this->mockCallIonosCreateEmailAPI(['success' => false]); + + $this->logger + ->expects($this->once()) + ->method('error'); + + $reflection = new ReflectionClass($this->controller); + $method = $reflection->getMethod('createIonosEmailAccount'); + $method->setAccessible(true); + + $this->expectException(ServiceException::class); + $this->expectExceptionMessage('Failed to create email account'); + + $method->invoke($this->controller, $accountName, $emailAddress); + } + + public function testCreateIonosEmailAccountMissingMailConfig(): void { + $accountName = 'Test Account'; + $emailAddress = 'test@example.com'; + + // Mock IONOS API response without mailConfig + $this->mockCallIonosCreateEmailAPI(['success' => true]); + + $this->logger + ->expects($this->once()) + ->method('error'); + + $reflection = new ReflectionClass($this->controller); + $method = $reflection->getMethod('createIonosEmailAccount'); + $method->setAccessible(true); + + $this->expectException(ServiceException::class); + $this->expectExceptionMessage('Invalid IONOS API response: missing mail configuration'); + + $method->invoke($this->controller, $accountName, $emailAddress); + } + + public function testCreateNextcloudMailAccount(): void { + $accountName = 'Test Account'; + $emailAddress = 'test@example.com'; + $mailConfig = [ + 'imap' => [ + 'host' => 'mail.localhost', + 'port' => 1143, + 'security' => 'none', + 'username' => 'admin@strado.de', + 'password' => 'tmp' + ], + 'smtp' => [ + 'host' => 'mail.localhost', + 'port' => 1587, + 'security' => 'none', + 'username' => 'admin@strado.de', + 'password' => 'tmp' + ] + ]; + + $expectedResponse = $this->createMock(JSONResponse::class); + + $this->accountsController + ->expects($this->once()) + ->method('create') + ->with( + $accountName, + $emailAddress, + 'mail.localhost', + 1143, + 'none', + 'admin@strado.de', + 'tmp', + 'mail.localhost', + 1587, + 'none', + 'admin@strado.de', + 'tmp', + ) + ->willReturn($expectedResponse); + + $reflection = new ReflectionClass($this->controller); + $method = $reflection->getMethod('createNextcloudMailAccount'); + $method->setAccessible(true); + + $result = $method->invoke($this->controller, $accountName, $emailAddress, $mailConfig); + + $this->assertSame($expectedResponse, $result); + } + + public function testHandleServiceException(): void { + $emailAddress = 'test@example.com'; + $exception = new ServiceException('Test service error'); + + $this->logger + ->expects($this->once()) + ->method('error') + ->with('IONOS service error', [ + 'exception' => $exception, + 'emailAddress' => $emailAddress + ]); + + $reflection = new ReflectionClass($this->controller); + $method = $reflection->getMethod('handleServiceException'); + $method->setAccessible(true); + + $result = $method->invoke($this->controller, $exception, $emailAddress); + + $this->assertInstanceOf(JSONResponse::class, $result); + $this->assertEquals(400, $result->getStatus()); + $data = $result->getData(); + $this->assertFalse($data['success']); + $this->assertEquals('IONOS_API_ERROR', $data['error']); + $this->assertEquals('Test service error', $data['message']); + } + + public function testHandleGenericException(): void { + $emailAddress = 'test@example.com'; + $exception = new \Exception('Test generic error'); + + $this->logger + ->expects($this->once()) + ->method('error') + ->with('Unexpected error during IONOS account creation', [ + 'exception' => $exception, + 'emailAddress' => $emailAddress + ]); + + $reflection = new ReflectionClass($this->controller); + $method = $reflection->getMethod('handleGenericException'); + $method->setAccessible(true); + + $result = $method->invoke($this->controller, $exception, $emailAddress); + + $this->assertInstanceOf(JSONResponse::class, $result); + $this->assertEquals(500, $result->getStatus()); + $data = $result->getData(); + $this->assertFalse($data['success']); + $this->assertEquals('UNKNOWN_ERROR', $data['error']); + $this->assertEquals('There was an error while setting up your account', $data['message']); + } + + public function testCallIonosCreateEmailAPIWithInvalidDomain(): void { + $accountName = 'Test Account'; + $emailAddress = 'invalid-email-without-at'; + + $reflection = new ReflectionClass($this->controller); + $method = $reflection->getMethod('callIonosCreateEmailAPI'); + $method->setAccessible(true); + + $this->expectException(ServiceException::class); + $this->expectExceptionMessage('Invalid email address: unable to extract domain'); + + $method->invoke($this->controller, $accountName, $emailAddress); + } + + /** + * Helper method to mock the callIonosCreateEmailAPI method + */ + private function mockCallIonosCreateEmailAPI($returnValue, $exception = null): void { + // Create a partial mock to override the protected method + $controllerMock = $this->getMockBuilder(IonosAccountsController::class) + ->setConstructorArgs([ + $this->appName, + $this->request, + $this->accountsController, + $this->logger + ]) + ->onlyMethods(['callIonosCreateEmailAPI']) + ->getMock(); + + if ($exception) { + $controllerMock->method('callIonosCreateEmailAPI')->willThrowException($exception); + } else { + $controllerMock->method('callIonosCreateEmailAPI')->willReturn($returnValue); + } + + // Replace the controller instance with the mock + $this->controller = $controllerMock; + } +}