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 @@
+
+
+
+
+
+
+ {{ t('mail', 'Please enter an email of the format name@example.com') }}
+
+
@myworkspace.com
+
+
+
+
+
+
+
+ {{ buttonText }}
+
+
+
+ {{ feedback }}
+
+
+
+
+
+
+
+
+
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;
+ }
+}