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" />
+
+ $emit('account-created', account)" />
+
{{ 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 @@
+
+
+
+
+
+
+ {{ t('mail', 'Please enter a valid email user name') }}
+
+
@{{ emailDomain }}
+
+
+
+
+
+
+ {{ buttonText }}
+
+
+
+ {{ feedback }}
+
+
+
+
+
+
+
+
+
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',
+ ],
+ ];
+ }
+}