From 805ef72aaa0fb597aa977541743b6d0e2ba2d46b Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Mon, 8 Dec 2025 12:49:25 +0100 Subject: [PATCH 01/24] IONOS(ionos-mail): update IONOS Mail API client reference to 2.0.0-20251208083401 composer update ionos-productivity/ionos-mail-configuration-api-client https://github.com/IONOS-Productivity/ionos-mail-configuration-api-client/releases/tag/2.0.0-20251208083401 Signed-off-by: Misha M.-Kupriyanov --- composer.json | 2 +- composer.lock | 4 ++-- lib/Service/IONOS/IonosMailService.php | 7 ++++--- tests/Unit/Service/IONOS/IonosMailServiceTest.php | 5 +++-- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/composer.json b/composer.json index 90a68468f4..03c8fabdc5 100644 --- a/composer.json +++ b/composer.json @@ -54,7 +54,7 @@ "source": { "type": "git", "url": "https://github.com/ionos-productivity/ionos-mail-configuration-api-client.git", - "reference": "2.0.0-20251110130214" + "reference": "2.0.0-20251208083401" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index 301d6b3bf5..d99348d985 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": "5341c5725717dffc9990db93ad12ab21", + "content-hash": "fb553591efe3fd5dbaed693076de60c6", "packages": [ { "name": "amphp/amp", @@ -1860,7 +1860,7 @@ "source": { "type": "git", "url": "https://github.com/ionos-productivity/ionos-mail-configuration-api-client.git", - "reference": "2.0.0-20251110130214" + "reference": "2.0.0-20251208083401" }, "type": "library", "autoload": { diff --git a/lib/Service/IONOS/IonosMailService.php b/lib/Service/IONOS/IonosMailService.php index 59279b6ee9..551d1aa1e0 100644 --- a/lib/Service/IONOS/IonosMailService.php +++ b/lib/Service/IONOS/IonosMailService.php @@ -11,6 +11,7 @@ use IONOS\MailConfigurationAPI\Client\Api\MailConfigurationAPIApi; use IONOS\MailConfigurationAPI\Client\ApiException; +use IONOS\MailConfigurationAPI\Client\Model\MailAccountCreatedResponse; use IONOS\MailConfigurationAPI\Client\Model\MailAccountResponse; use IONOS\MailConfigurationAPI\Client\Model\MailAddonErrorMessage; use IONOS\MailConfigurationAPI\Client\Model\MailCreateData; @@ -164,7 +165,7 @@ public function createEmailAccount(string $userName): MailAccountConfig { ]); throw new ServiceException('Failed to create ionos mail', $result->getStatus()); } - if ($result instanceof MailAccountResponse) { + if ($result instanceof MailAccountCreatedResponse) { $this->logger->info('Successfully created IONOS mail account', [ 'email' => $result->getEmail(), 'userId' => $userId, @@ -257,10 +258,10 @@ private function normalizeSslMode(string $apiSslMode): string { /** * Build success response with mail configuration * - * @param MailAccountResponse $response + * @param MailAccountCreatedResponse $response * @return MailAccountConfig */ - private function buildSuccessResponse(MailAccountResponse $response): MailAccountConfig { + private function buildSuccessResponse(MailAccountCreatedResponse $response): MailAccountConfig { $smtpServer = $response->getServer()->getSmtp(); $imapServer = $response->getServer()->getImap(); diff --git a/tests/Unit/Service/IONOS/IonosMailServiceTest.php b/tests/Unit/Service/IONOS/IonosMailServiceTest.php index 75ba54a88d..35a9fac9b0 100644 --- a/tests/Unit/Service/IONOS/IonosMailServiceTest.php +++ b/tests/Unit/Service/IONOS/IonosMailServiceTest.php @@ -13,6 +13,7 @@ use GuzzleHttp\ClientInterface; use IONOS\MailConfigurationAPI\Client\Api\MailConfigurationAPIApi; use IONOS\MailConfigurationAPI\Client\Model\Imap; +use IONOS\MailConfigurationAPI\Client\Model\MailAccountCreatedResponse; use IONOS\MailConfigurationAPI\Client\Model\MailAccountResponse; use IONOS\MailConfigurationAPI\Client\Model\MailAddonErrorMessage; use IONOS\MailConfigurationAPI\Client\Model\MailServer; @@ -106,7 +107,7 @@ public function testCreateEmailAccountSuccess(): void { $mailServer->method('getImap')->willReturn($imapServer); $mailServer->method('getSmtp')->willReturn($smtpServer); - $mailAccountResponse = $this->getMockBuilder(MailAccountResponse::class) + $mailAccountResponse = $this->getMockBuilder(MailAccountCreatedResponse::class) ->disableOriginalConstructor() ->onlyMethods(['getEmail', 'getPassword', 'getServer']) ->getMock(); @@ -369,7 +370,7 @@ public function testSslModeNormalization(string $apiSslMode, string $expectedSec $mailServer->method('getImap')->willReturn($imapServer); $mailServer->method('getSmtp')->willReturn($smtpServer); - $mailAccountResponse = $this->getMockBuilder(MailAccountResponse::class) + $mailAccountResponse = $this->getMockBuilder(MailAccountCreatedResponse::class) ->disableOriginalConstructor() ->onlyMethods(['getEmail', 'getPassword', 'getServer']) ->getMock(); From 3055744654fd5cdbcaf4b573656ea33815a5a9cb Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Fri, 5 Dec 2025 11:54:43 +0100 Subject: [PATCH 02/24] test(service): add unit tests for SetupService Added comprehensive unit tests for the SetupService class, covering account creation with various authentication methods, handling of connectivity tests, and validation of authentication methods. This enhances test coverage and ensures the reliability of the account setup functionality. Signed-off-by: Misha M.-Kupriyanov --- tests/Unit/Service/SetupServiceTest.php | 331 ++++++++++++++++++++++++ 1 file changed, 331 insertions(+) create mode 100644 tests/Unit/Service/SetupServiceTest.php diff --git a/tests/Unit/Service/SetupServiceTest.php b/tests/Unit/Service/SetupServiceTest.php new file mode 100644 index 0000000000..d687a399ee --- /dev/null +++ b/tests/Unit/Service/SetupServiceTest.php @@ -0,0 +1,331 @@ +accountService = $this->createMock(AccountService::class); + $this->crypto = $this->createMock(ICrypto::class); + $this->smtpClientFactory = $this->createMock(SmtpClientFactory::class); + $this->imapClientFactory = $this->createMock(IMAPClientFactory::class); + $this->logger = $this->createMock(LoggerInterface::class); + $this->tagMapper = $this->createMock(TagMapper::class); + + $this->setupService = new SetupService( + $this->accountService, + $this->crypto, + $this->smtpClientFactory, + $this->imapClientFactory, + $this->logger, + $this->tagMapper + ); + } + + private function mockSuccessfulImapConnection(): Horde_Imap_Client_Socket&MockObject { + $imapClient = $this->createMock(Horde_Imap_Client_Socket::class); + $imapClient->expects(self::once())->method('login'); + $imapClient->expects(self::once())->method('logout'); + + $this->imapClientFactory->expects(self::once()) + ->method('getClient') + ->willReturn($imapClient); + + return $imapClient; + } + + private function mockSuccessfulSmtpConnection(): Horde_Mail_Transport_Smtphorde&MockObject { + $smtpTransport = $this->createMock(Horde_Mail_Transport_Smtphorde::class); + $smtpTransport->expects(self::once())->method('getSMTPObject'); + + $this->smtpClientFactory->expects(self::once()) + ->method('create') + ->willReturn($smtpTransport); + + return $smtpTransport; + } + + private function mockPasswordEncryption(): void { + $this->crypto->expects(self::exactly(2)) + ->method('encrypt') + ->willReturnOnConsecutiveCalls('encrypted-imap-password', 'encrypted-smtp-password'); + } + + private function assertAccountPropertiesMatch( + MailAccount $account, + string $accountName, + string $emailAddress, + string $imapHost, + int $imapPort, + string $imapSslMode, + string $imapUser, + string $smtpHost, + int $smtpPort, + string $smtpSslMode, + string $smtpUser, + string $uid, + string $authMethod, + ): void { + self::assertSame($accountName, $account->getName(), 'Account name does not match'); + self::assertSame($emailAddress, $account->getEmail(), 'Email address does not match'); + self::assertSame($imapHost, $account->getInboundHost(), 'IMAP host does not match'); + self::assertSame($imapPort, $account->getInboundPort(), 'IMAP port does not match'); + self::assertSame($imapSslMode, $account->getInboundSslMode(), 'IMAP SSL mode does not match'); + self::assertSame($imapUser, $account->getInboundUser(), 'IMAP user does not match'); + self::assertSame($smtpHost, $account->getOutboundHost(), 'SMTP host does not match'); + self::assertSame($smtpPort, $account->getOutboundPort(), 'SMTP port does not match'); + self::assertSame($smtpSslMode, $account->getOutboundSslMode(), 'SMTP SSL mode does not match'); + self::assertSame($smtpUser, $account->getOutboundUser(), 'SMTP user does not match'); + self::assertSame($uid, $account->getUserId(), 'User ID does not match'); + self::assertSame($authMethod, $account->getAuthMethod(), 'Auth method does not match'); + } + + public function testCreateNewAccountWithPasswordAuth(): void { + $this->mockPasswordEncryption(); + + $this->logger->expects(self::once()) + ->method('info') + ->with('Setting up manually configured account'); + + $debugCalls = []; + $this->logger->expects(self::exactly(2)) + ->method('debug') + ->willReturnCallback(function (string $message, array $context = []) use (&$debugCalls): void { + $debugCalls[] = ['message' => $message, 'context' => $context]; + }); + + $this->mockSuccessfulImapConnection(); + $this->mockSuccessfulSmtpConnection(); + + $this->accountService->expects(self::once()) + ->method('save') + ->with(self::callback(function (MailAccount $account): bool { + $this->assertAccountPropertiesMatch( + $account, + self::ACCOUNT_NAME, + self::EMAIL_ADDRESS, + self::IMAP_HOST, + self::IMAP_PORT, + self::IMAP_SSL_MODE, + self::IMAP_USER, + self::SMTP_HOST, + self::SMTP_PORT, + self::SMTP_SSL_MODE, + self::SMTP_USER, + self::USER_ID, + self::AUTH_METHOD_PASSWORD + ); + return true; + })); + + $this->tagMapper->expects(self::once()) + ->method('createDefaultTags') + ->with(self::isInstanceOf(MailAccount::class)); + + $result = $this->setupService->createNewAccount( + self::ACCOUNT_NAME, + self::EMAIL_ADDRESS, + self::IMAP_HOST, + self::IMAP_PORT, + self::IMAP_SSL_MODE, + self::IMAP_USER, + self::IMAP_PASSWORD, + self::SMTP_HOST, + self::SMTP_PORT, + self::SMTP_SSL_MODE, + self::SMTP_USER, + self::SMTP_PASSWORD, + self::USER_ID, + self::AUTH_METHOD_PASSWORD + ); + + self::assertInstanceOf(Account::class, $result); + + // Verify debug log calls + self::assertCount(2, $debugCalls); + self::assertSame('Connecting to account {account}', $debugCalls[0]['message']); + self::assertSame(['account' => self::EMAIL_ADDRESS], $debugCalls[0]['context']); + self::assertStringContainsString('account created ', $debugCalls[1]['message']); + self::assertSame([], $debugCalls[1]['context']); + } + + public function testCreateNewAccountWithOAuth2(): void { + $this->crypto->expects(self::never())->method('encrypt'); + + $this->logger->expects(self::once()) + ->method('info') + ->with('Setting up manually configured account'); + $this->logger->expects(self::once()) + ->method('debug') + ->with(self::stringContains('account created ')); + + $this->imapClientFactory->expects(self::never())->method('getClient'); + $this->smtpClientFactory->expects(self::never())->method('create'); + + $this->accountService->expects(self::once()) + ->method('save') + ->with(self::callback(function (MailAccount $account): bool { + return $account->getAuthMethod() === self::AUTH_METHOD_OAUTH2; + })); + + $this->tagMapper->expects(self::once())->method('createDefaultTags'); + + $result = $this->setupService->createNewAccount( + 'OAuth2 Account', + 'oauth@example.com', + self::IMAP_HOST, + self::IMAP_PORT, + self::IMAP_SSL_MODE, + 'oauth@example.com', + null, + self::SMTP_HOST, + self::SMTP_PORT, + self::SMTP_SSL_MODE, + 'oauth@example.com', + null, + 'user456', + self::AUTH_METHOD_OAUTH2 + ); + + self::assertInstanceOf(Account::class, $result); + } + + public function testCreateNewAccountWithInvalidAuthMethod(): void { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid auth method invalid'); + + $this->setupService->createNewAccount( + self::ACCOUNT_NAME, + self::EMAIL_ADDRESS, + self::IMAP_HOST, + self::IMAP_PORT, + self::IMAP_SSL_MODE, + self::IMAP_USER, + self::IMAP_PASSWORD, + self::SMTP_HOST, + self::SMTP_PORT, + self::SMTP_SSL_MODE, + self::SMTP_USER, + self::SMTP_PASSWORD, + self::USER_ID, + 'invalid' + ); + } + + public function testCreateNewAccountImapConnectionFailure(): void { + $this->expectException(CouldNotConnectException::class); + + $this->mockPasswordEncryption(); + + $imapClient = $this->createMock(Horde_Imap_Client_Socket::class); + $imapClient->expects(self::once()) + ->method('login') + ->willThrowException(new Horde_Imap_Client_Exception('Connection failed')); + $imapClient->expects(self::once()) + ->method('logout'); + + $this->imapClientFactory->expects(self::once()) + ->method('getClient') + ->willReturn($imapClient); + + $this->setupService->createNewAccount( + self::ACCOUNT_NAME, + self::EMAIL_ADDRESS, + self::IMAP_HOST, + self::IMAP_PORT, + self::IMAP_SSL_MODE, + self::IMAP_USER, + self::IMAP_PASSWORD, + self::SMTP_HOST, + self::SMTP_PORT, + self::SMTP_SSL_MODE, + self::SMTP_USER, + self::SMTP_PASSWORD, + self::USER_ID, + self::AUTH_METHOD_PASSWORD + ); + } + + public function testCreateNewAccountSmtpConnectionFailure(): void { + $this->expectException(CouldNotConnectException::class); + + $this->mockPasswordEncryption(); + $this->mockSuccessfulImapConnection(); + + $smtpTransport = $this->createMock(Horde_Mail_Transport_Smtphorde::class); + $smtpTransport->expects(self::once()) + ->method('getSMTPObject') + ->willThrowException(new Horde_Mail_Exception('SMTP connection failed')); + + $this->smtpClientFactory->expects(self::once()) + ->method('create') + ->willReturn($smtpTransport); + + $this->setupService->createNewAccount( + self::ACCOUNT_NAME, + self::EMAIL_ADDRESS, + self::IMAP_HOST, + self::IMAP_PORT, + self::IMAP_SSL_MODE, + self::IMAP_USER, + self::IMAP_PASSWORD, + self::SMTP_HOST, + self::SMTP_PORT, + self::SMTP_SSL_MODE, + self::SMTP_USER, + self::SMTP_PASSWORD, + self::USER_ID, + self::AUTH_METHOD_PASSWORD + ); + } +} From 7b628e6ef99b48e673787957e8e77c7b5e1d7c97 Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Thu, 4 Dec 2025 18:24:04 +0100 Subject: [PATCH 03/24] IONOS(ionos-mail): SetupService: add skipConnectivityTest parameter to createNewAccount Add optional parameter to allow skipping IMAP/SMTP connectivity tests during account creation. This is useful when account credentials are already validated by external systems or when immediate connectivity cannot be guaranteed due to DNS propagation delays. The parameter defaults to false to maintain backward compatibility. Signed-off-by: Misha M.-Kupriyanov --- lib/Service/SetupService.php | 5 ++- tests/Unit/Service/SetupServiceTest.php | 55 +++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/lib/Service/SetupService.php b/lib/Service/SetupService.php index 40df87afd9..cc07821f7b 100644 --- a/lib/Service/SetupService.php +++ b/lib/Service/SetupService.php @@ -77,7 +77,8 @@ public function createNewAccount(string $accountName, ?string $smtpPassword, string $uid, string $authMethod, - ?int $accountId = null): Account { + ?int $accountId = null, + bool $skipConnectivityTest = false): Account { $this->logger->info('Setting up manually configured account'); $newAccount = new MailAccount([ 'accountId' => $accountId, @@ -105,7 +106,7 @@ public function createNewAccount(string $accountName, $newAccount->setAuthMethod($authMethod); $account = new Account($newAccount); - if ($authMethod === 'password' && $imapPassword !== null) { + if (!$skipConnectivityTest && $authMethod === 'password' && $imapPassword !== null) { $this->logger->debug('Connecting to account {account}', ['account' => $newAccount->getEmail()]); $this->testConnectivity($account); } diff --git a/tests/Unit/Service/SetupServiceTest.php b/tests/Unit/Service/SetupServiceTest.php index d687a399ee..8637b2c1ff 100644 --- a/tests/Unit/Service/SetupServiceTest.php +++ b/tests/Unit/Service/SetupServiceTest.php @@ -240,6 +240,61 @@ public function testCreateNewAccountWithOAuth2(): void { self::assertInstanceOf(Account::class, $result); } + public function testCreateNewAccountWithSkipConnectivityTest(): void { + $accountName = 'Skip Test Account'; + $emailAddress = 'skip@example.com'; + $imapHost = 'imap.example.com'; + $imapPort = 993; + $imapSslMode = 'ssl'; + $imapUser = 'skip@example.com'; + $imapPassword = 'password'; + $smtpHost = 'smtp.example.com'; + $smtpPort = 465; + $smtpSslMode = 'ssl'; + $smtpUser = 'skip@example.com'; + $smtpPassword = 'password'; + $uid = 'user789'; + $authMethod = 'password'; + $skipConnectivityTest = true; + + $this->crypto->expects(self::exactly(2)) + ->method('encrypt') + ->willReturnOnConsecutiveCalls('encrypted1', 'encrypted2'); + + $this->imapClientFactory->expects(self::never()) + ->method('getClient'); + + $this->smtpClientFactory->expects(self::never()) + ->method('create'); + + $this->accountService->expects(self::once()) + ->method('save'); + + $this->tagMapper->expects(self::once()) + ->method('createDefaultTags'); + + $result = $this->setupService->createNewAccount( + $accountName, + $emailAddress, + $imapHost, + $imapPort, + $imapSslMode, + $imapUser, + $imapPassword, + $smtpHost, + $smtpPort, + $smtpSslMode, + $smtpUser, + $smtpPassword, + $uid, + $authMethod, + null, + $skipConnectivityTest + ); + + self::assertInstanceOf(Account::class, $result); + } + public function testCreateNewAccountWithInvalidAuthMethod(): void { $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('Invalid auth method invalid'); From 0b28d3ea54d223bd88a363e702f552e1422c9596 Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Thu, 4 Dec 2025 18:36:36 +0100 Subject: [PATCH 04/24] IONOS(ionos-mail): AccountsController: add skipConnectivityTest parameter to create method Add optional parameter to AccountsController::create() to allow skipping IMAP/SMTP connectivity tests during account creation. This parameter is passed through to SetupService::createNewAccount(). This enables external systems (e.g., IONOS API) that have already validated credentials to skip redundant connectivity tests, particularly useful when DNS propagation delays might cause immediate connectivity tests to fail. The parameter defaults to false to maintain backward compatibility. --- lib/Controller/AccountsController.php | 5 +-- .../Controller/AccountsControllerTest.php | 33 +++++++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/lib/Controller/AccountsController.php b/lib/Controller/AccountsController.php index ea7c02de14..e2e2552564 100644 --- a/lib/Controller/AccountsController.php +++ b/lib/Controller/AccountsController.php @@ -346,7 +346,8 @@ public function create(string $accountName, ?string $smtpSslMode = null, ?string $smtpUser = null, ?string $smtpPassword = null, - string $authMethod = 'password'): JSONResponse { + string $authMethod = 'password', + bool $skipConnectivityTest = false): JSONResponse { if ($this->config->getAppValue(Application::APP_ID, 'allow_new_mail_accounts', 'yes') === 'no') { $this->logger->info('Creating account disabled by admin.'); return MailJsonResponse::error('Could not create account'); @@ -378,7 +379,7 @@ public function create(string $accountName, ); } try { - $account = $this->setup->createNewAccount($accountName, $emailAddress, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, $this->currentUserId, $authMethod); + $account = $this->setup->createNewAccount($accountName, $emailAddress, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, $this->currentUserId, $authMethod, null, $skipConnectivityTest); } catch (CouldNotConnectException $e) { $data = [ 'error' => $e->getReason(), diff --git a/tests/Unit/Controller/AccountsControllerTest.php b/tests/Unit/Controller/AccountsControllerTest.php index c37af7da55..4f311bc1c7 100644 --- a/tests/Unit/Controller/AccountsControllerTest.php +++ b/tests/Unit/Controller/AccountsControllerTest.php @@ -236,7 +236,7 @@ public function testCreateManualSuccess(): void { $account = $this->createMock(Account::class); $this->setupService->expects(self::once()) ->method('createNewAccount') - ->with($accountName, $email, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, $this->userId, 'password') + ->with($accountName, $email, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, $this->userId, 'password', null, false) ->willReturn($account); $response = $this->controller->create($accountName, $email, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword); @@ -246,6 +246,35 @@ public function testCreateManualSuccess(): void { self::assertEquals($expectedResponse, $response); } + public function testCreateManualSuccessWithSkipConnectivityTest(): void { + $this->config->expects(self::once()) + ->method('getAppValue') + ->willReturn('yes'); + $email = 'user@domain.tld'; + $accountName = 'Mail'; + $imapHost = 'localhost'; + $imapPort = 993; + $imapSslMode = 'ssl'; + $imapUser = 'user@domain.tld'; + $imapPassword = 'mypassword'; + $smtpHost = 'localhost'; + $smtpPort = 465; + $smtpSslMode = 'none'; + $smtpUser = 'user@domain.tld'; + $smtpPassword = 'mypassword'; + $account = $this->createMock(Account::class); + $this->setupService->expects(self::once()) + ->method('createNewAccount') + ->with($accountName, $email, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, $this->userId, 'password', null, true) + ->willReturn($account); + + $response = $this->controller->create($accountName, $email, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, 'password', true); + + $expectedResponse = \OCA\Mail\Http\JsonResponse::success($account, Http::STATUS_CREATED); + + self::assertEquals($expectedResponse, $response); + } + public function testCreateManualNotAllowed(): void { $email = 'user@domain.tld'; $accountName = 'Mail'; @@ -289,7 +318,7 @@ public function testCreateManualFailure(): void { $smtpPassword = 'mypassword'; $this->setupService->expects(self::once()) ->method('createNewAccount') - ->with($accountName, $email, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, $this->userId, 'password') + ->with($accountName, $email, $imapHost, $imapPort, $imapSslMode, $imapUser, $imapPassword, $smtpHost, $smtpPort, $smtpSslMode, $smtpUser, $smtpPassword, $this->userId, 'password', null, false) ->willThrowException(new ClientException()); $this->expectException(ClientException::class); From 7ded9da942cc5127d5a46e93c72f26bb8f62b9c7 Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Fri, 5 Dec 2025 13:29:36 +0100 Subject: [PATCH 05/24] IONOS(ionos-mail): refactor IonosMailServiceTest with consistent test data and improved mock setups Refactor test methods to utilize constants for user and email details, ensuring consistency and readability. This change improves maintainability and reduces duplication across test cases. Signed-off-by: Misha M.-Kupriyanov --- .../Service/IONOS/IonosMailServiceTest.php | 776 ++++++------------ 1 file changed, 251 insertions(+), 525 deletions(-) diff --git a/tests/Unit/Service/IONOS/IonosMailServiceTest.php b/tests/Unit/Service/IONOS/IonosMailServiceTest.php index 35a9fac9b0..ea9baeaeef 100644 --- a/tests/Unit/Service/IONOS/IonosMailServiceTest.php +++ b/tests/Unit/Service/IONOS/IonosMailServiceTest.php @@ -29,6 +29,20 @@ use Psr\Log\LoggerInterface; class IonosMailServiceTest extends TestCase { + private const TEST_USER_ID = 'testuser123'; + private const TEST_USER_NAME = 'test'; + private const TEST_DOMAIN = 'example.com'; + private const TEST_EMAIL = self::TEST_USER_NAME . '@' . self::TEST_DOMAIN; + private const TEST_PASSWORD = 'test-password'; + private const TEST_EXT_REF = 'test-ext-ref'; + private const TEST_API_BASE_URL = 'https://api.example.com'; + private const TEST_BASIC_AUTH_USER = 'testuser'; + private const TEST_BASIC_AUTH_PASSWORD = 'testpass'; + private const IMAP_HOST = 'imap.example.com'; + private const IMAP_PORT = 993; + private const SMTP_HOST = 'smtp.example.com'; + private const SMTP_PORT = 587; + private ApiMailConfigClientService&MockObject $apiClientService; private IonosConfigService&MockObject $configService; private IUserSession&MockObject $userSession; @@ -51,54 +65,102 @@ protected function setUp(): void { ); } - 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 + /** + * Setup standard config mocks with default values + */ + private function setupConfigMocks( + string $externalReference = self::TEST_EXT_REF, + string $apiBaseUrl = self::TEST_API_BASE_URL, + bool $allowInsecure = false, + string $basicAuthUser = self::TEST_BASIC_AUTH_USER, + string $basicAuthPassword = self::TEST_BASIC_AUTH_PASSWORD, + string $mailDomain = self::TEST_DOMAIN, + ): void { + $this->configService->method('getExternalReference')->willReturn($externalReference); + $this->configService->method('getApiBaseUrl')->willReturn($apiBaseUrl); + $this->configService->method('getAllowInsecure')->willReturn($allowInsecure); + $this->configService->method('getBasicAuthUser')->willReturn($basicAuthUser); + $this->configService->method('getBasicAuthPassword')->willReturn($basicAuthPassword); + $this->configService->method('getMailDomain')->willReturn($mailDomain); + } + + /** + * Setup user session with mock user + */ + private function setupUserSession(string $userId): IUser&MockObject { $user = $this->createMock(IUser::class); - $user->method('getUID')->willReturn('testuser123'); + $user->method('getUID')->willReturn($userId); $this->userSession->method('getUser')->willReturn($user); + return $user; + } - // Mock API client + /** + * Setup API client mocks and return API instance + */ + private function setupApiClient(bool $verifySSL = true): MailConfigurationAPIApi&MockObject { $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient') ->with([ - 'auth' => ['testuser', 'testpass'], - 'verify' => true, + 'auth' => [self::TEST_BASIC_AUTH_USER, self::TEST_BASIC_AUTH_PASSWORD], + 'verify' => $verifySSL, ]) ->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi') - ->with($client, 'https://api.example.com') + ->with($client, self::TEST_API_BASE_URL) ->willReturn($apiInstance); - // Mock API response - use getMockBuilder with onlyMethods for existing methods + return $apiInstance; + } + + /** + * Create a mock IMAP server + */ + private function createMockImapServer( + string $host = self::IMAP_HOST, + int $port = self::IMAP_PORT, + string $sslMode = 'ssl', + ): Imap&MockObject { $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'); + $imapServer->method('getHost')->willReturn($host); + $imapServer->method('getPort')->willReturn($port); + $imapServer->method('getSslMode')->willReturn($sslMode); + return $imapServer; + } + /** + * Create a mock SMTP server + */ + private function createMockSmtpServer( + string $host = self::SMTP_HOST, + int $port = self::SMTP_PORT, + string $sslMode = 'tls', + ): Smtp&MockObject { $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'); + $smtpServer->method('getHost')->willReturn($host); + $smtpServer->method('getPort')->willReturn($port); + $smtpServer->method('getSslMode')->willReturn($sslMode); + return $smtpServer; + } + + /** + * Create a mock MailAccountResponse + */ + private function createMockMailAccountResponse( + string $email = self::TEST_EMAIL, + string $password = self::TEST_PASSWORD, + ?string $imapSslMode = 'ssl', + ?string $smtpSslMode = 'tls', + ): MailAccountCreatedResponse&MockObject { + $imapServer = $this->createMockImapServer(self::IMAP_HOST, self::IMAP_PORT, $imapSslMode); + $smtpServer = $this->createMockSmtpServer(self::SMTP_HOST, self::SMTP_PORT, $smtpSslMode); $mailServer = $this->getMockBuilder(MailServer::class) ->disableOriginalConstructor() @@ -111,112 +173,85 @@ public function testCreateEmailAccountSuccess(): void { ->disableOriginalConstructor() ->onlyMethods(['getEmail', 'getPassword', 'getServer']) ->getMock(); - $mailAccountResponse->method('getEmail')->willReturn($emailAddress); - $mailAccountResponse->method('getPassword')->willReturn('test-password'); + $mailAccountResponse->method('getEmail')->willReturn($email); + $mailAccountResponse->method('getPassword')->willReturn($password); $mailAccountResponse->method('getServer')->willReturn($mailServer); - $apiInstance->method('createMailbox')->willReturn($mailAccountResponse); + return $mailAccountResponse; + } - // Expect logging calls - $this->logger->expects($this->exactly(4)) - ->method('debug'); + public function testCreateEmailAccountSuccess(): void { + $this->setupConfigMocks(); + $this->setupUserSession(self::TEST_USER_ID); + $apiInstance = $this->setupApiClient(); + $mailAccountResponse = $this->createMockMailAccountResponse(); + $apiInstance->method('createMailbox')->willReturn($mailAccountResponse); + + $this->logger->expects($this->exactly(4))->method('debug'); $this->logger->expects($this->once()) ->method('info') - ->with('Successfully created IONOS mail account', $this->callback(function ($context) use ($emailAddress) { - return $context['email'] === $emailAddress - && $context['userId'] === 'testuser123' - && $context['userName'] === 'test'; + ->with('Successfully created IONOS mail account', $this->callback(function ($context) { + return $context['email'] === self::TEST_EMAIL + && $context['userId'] === self::TEST_USER_ID + && $context['userName'] === self::TEST_USER_NAME; })); - $result = $this->service->createEmailAccount($userName); + $result = $this->service->createEmailAccount(self::TEST_USER_NAME); $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(self::TEST_EMAIL, $result->getEmail()); + $this->assertEquals(self::IMAP_HOST, $result->getImap()->getHost()); + $this->assertEquals(self::IMAP_PORT, $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(self::TEST_EMAIL, $result->getImap()->getUsername()); + $this->assertEquals(self::TEST_PASSWORD, $result->getImap()->getPassword()); + $this->assertEquals(self::SMTP_HOST, $result->getSmtp()->getHost()); + $this->assertEquals(self::SMTP_PORT, $result->getSmtp()->getPort()); $this->assertEquals('tls', $result->getSmtp()->getSecurity()); - $this->assertEquals($emailAddress, $result->getSmtp()->getUsername()); - $this->assertEquals('test-password', $result->getSmtp()->getPassword()); + $this->assertEquals(self::TEST_EMAIL, $result->getSmtp()->getUsername()); + $this->assertEquals(self::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); + $this->setupConfigMocks(); + $this->setupUserSession(self::TEST_USER_ID); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API to throw exception $apiInstance->method('createMailbox') ->willThrowException(new \Exception('API call failed')); - // Expect logging calls - $this->logger->expects($this->exactly(2)) - ->method('debug'); - + $this->logger->expects($this->exactly(2))->method('debug'); $this->logger->expects($this->once()) ->method('error') - ->with('Exception when calling MailConfigurationAPIApi->createMailbox', $this->callback(function ($context) use ($userName) { + ->with('Exception when calling MailConfigurationAPIApi->createMailbox', $this->callback(function ($context) { return isset($context['exception']) - && $context['userId'] === 'testuser123' - && $context['userName'] === $userName; + && $context['userId'] === self::TEST_USER_ID + && $context['userName'] === self::TEST_USER_NAME; })); $this->expectException(ServiceException::class); $this->expectExceptionMessage('Failed to create ionos mail'); $this->expectExceptionCode(500); - $this->service->createEmailAccount($userName); + $this->service->createEmailAccount(self::TEST_USER_NAME); } public function testCreateEmailAccountWithMailAddonErrorMessageResponse(): 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); + $this->setupConfigMocks(); + $this->setupUserSession(self::TEST_USER_ID); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock MailAddonErrorMessage response $errorMessage = $this->getMockBuilder(MailAddonErrorMessage::class) ->disableOriginalConstructor() ->onlyMethods(['getStatus', 'getMessage']) @@ -226,86 +261,55 @@ public function testCreateEmailAccountWithMailAddonErrorMessageResponse(): void $apiInstance->method('createMailbox')->willReturn($errorMessage); - // Expect logging calls - $this->logger->expects($this->exactly(2)) - ->method('debug'); - + $this->logger->expects($this->exactly(2))->method('debug'); $this->logger->expects($this->once()) ->method('error') - ->with('Failed to create ionos mail', $this->callback(function ($context) use ($userName) { + ->with('Failed to create ionos mail', $this->callback(function ($context) { return $context['status code'] === MailAddonErrorMessage::STATUS__400_BAD_REQUEST && $context['message'] === 'Bad Request' - && $context['userId'] === 'testuser123' - && $context['userName'] === $userName; + && $context['userId'] === self::TEST_USER_ID + && $context['userName'] === self::TEST_USER_NAME; })); $this->expectException(ServiceException::class); $this->expectExceptionMessage('Failed to create ionos mail'); $this->expectExceptionCode(400); - $this->service->createEmailAccount($userName); + $this->service->createEmailAccount(self::TEST_USER_NAME); } 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); + $this->setupConfigMocks(); + $this->setupUserSession(self::TEST_USER_ID); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock unknown response type (return a stdClass instead of expected types) $unknownResponse = new \stdClass(); $apiInstance->method('createMailbox')->willReturn($unknownResponse); - // Expect logging calls - $this->logger->expects($this->exactly(2)) - ->method('debug'); - + $this->logger->expects($this->exactly(2))->method('debug'); $this->logger->expects($this->once()) ->method('error') - ->with('Failed to create ionos mail: Unknown response type', $this->callback(function ($context) use ($userName) { - return $context['userId'] === 'testuser123' - && $context['userName'] === $userName; + ->with('Failed to create ionos mail: Unknown response type', $this->callback(function ($context) { + return $context['userId'] === self::TEST_USER_ID + && $context['userName'] === self::TEST_USER_NAME; })); $this->expectException(ServiceException::class); $this->expectExceptionMessage('Failed to create ionos mail'); $this->expectExceptionCode(500); - $this->service->createEmailAccount($userName); + $this->service->createEmailAccount(self::TEST_USER_NAME); } 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->setupConfigMocks(); $this->userSession->method('getUser')->willReturn(null); - // Expect logging call $this->logger->expects($this->once()) ->method('error') ->with('No user session found when attempting to create IONOS mail account'); @@ -313,7 +317,7 @@ public function testCreateEmailAccountWithNoUserSession(): void { $this->expectException(ServiceException::class); $this->expectExceptionMessage('No user session found'); - $this->service->createEmailAccount($userName); + $this->service->createEmailAccount(self::TEST_USER_NAME); } /** @@ -322,65 +326,20 @@ public function testCreateEmailAccountWithNoUserSession(): void { * @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('newMailConfigurationAPIApi')->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(MailAccountCreatedResponse::class) - ->disableOriginalConstructor() - ->onlyMethods(['getEmail', 'getPassword', 'getServer']) - ->getMock(); - $mailAccountResponse->method('getEmail')->willReturn($emailAddress); - $mailAccountResponse->method('getPassword')->willReturn('test-password'); - $mailAccountResponse->method('getServer')->willReturn($mailServer); + $this->setupConfigMocks(); + $this->setupUserSession(self::TEST_USER_ID); + $apiInstance = $this->setupApiClient(); + + $mailAccountResponse = $this->createMockMailAccountResponse( + self::TEST_EMAIL, + self::TEST_PASSWORD, + $apiSslMode, + $apiSslMode + ); $apiInstance->method('createMailbox')->willReturn($mailAccountResponse); - $result = $this->service->createEmailAccount($userName); + $result = $this->service->createEmailAccount(self::TEST_USER_NAME); $this->assertEquals($expectedSecurity, $result->getImap()->getSecurity()); $this->assertEquals($expectedSecurity, $result->getSmtp()->getSecurity()); @@ -433,39 +392,26 @@ public static function sslModeNormalizationProvider(): array { } public function testMailAccountExistsForCurrentUserReturnsTrueWhenAccountExists(): void { - // 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 user session - $user = $this->createMock(IUser::class); - $user->method('getUID')->willReturn('testuser123'); - $this->userSession->method('getUser')->willReturn($user); + $this->setupConfigMocks(); + $this->setupUserSession(self::TEST_USER_ID); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API response with existing account $mailAccountResponse = $this->getMockBuilder(MailAccountResponse::class) ->disableOriginalConstructor() ->onlyMethods(['getEmail']) ->getMock(); - $mailAccountResponse->method('getEmail')->willReturn('testuser@example.com'); + $mailAccountResponse->method('getEmail')->willReturn(self::TEST_EMAIL); $apiInstance->method('getFunctionalAccount') - ->with('IONOS', 'test-ext-ref', 'testuser123') + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willReturn($mailAccountResponse); - // Expect logging calls - $this->logger->expects($this->exactly(2)) - ->method('debug'); + $this->logger->expects($this->exactly(2))->method('debug'); $result = $this->service->mailAccountExistsForCurrentUser(); @@ -473,26 +419,15 @@ public function testMailAccountExistsForCurrentUserReturnsTrueWhenAccountExists( } public function testMailAccountExistsForCurrentUserReturnsFalseWhen404(): void { - // 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 user session - $user = $this->createMock(IUser::class); - $user->method('getUID')->willReturn('testuser123'); - $this->userSession->method('getUser')->willReturn($user); + $this->setupConfigMocks(); + $this->setupUserSession(self::TEST_USER_ID); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API to throw 404 exception $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( 'Not Found', 404, @@ -501,12 +436,10 @@ public function testMailAccountExistsForCurrentUserReturnsFalseWhen404(): void { ); $apiInstance->method('getFunctionalAccount') - ->with('IONOS', 'test-ext-ref', 'testuser123') + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException($apiException); - // Expect logging calls - $this->logger->expects($this->exactly(2)) - ->method('debug'); + $this->logger->expects($this->exactly(2))->method('debug'); $result = $this->service->mailAccountExistsForCurrentUser(); @@ -514,26 +447,15 @@ public function testMailAccountExistsForCurrentUserReturnsFalseWhen404(): void { } public function testMailAccountExistsForCurrentUserReturnsFalseOnApiError(): void { - // 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 user session - $user = $this->createMock(IUser::class); - $user->method('getUID')->willReturn('testuser123'); - $this->userSession->method('getUser')->willReturn($user); + $this->setupConfigMocks(); + $this->setupUserSession(self::TEST_USER_ID); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API to throw 500 exception $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( 'Internal Server Error', 500, @@ -542,18 +464,15 @@ public function testMailAccountExistsForCurrentUserReturnsFalseOnApiError(): voi ); $apiInstance->method('getFunctionalAccount') - ->with('IONOS', 'test-ext-ref', 'testuser123') + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException($apiException); - // Expect logging calls - $this->logger->expects($this->once()) - ->method('debug'); - + $this->logger->expects($this->once())->method('debug'); $this->logger->expects($this->once()) ->method('error') ->with('API Exception when getting IONOS mail account', $this->callback(function ($context) { return $context['statusCode'] === 500 - && $context['message'] === 'Internal Server Error'; + && $context['message'] === 'Internal Server Error'; })); $result = $this->service->mailAccountExistsForCurrentUser(); @@ -562,39 +481,25 @@ public function testMailAccountExistsForCurrentUserReturnsFalseOnApiError(): voi } public function testMailAccountExistsForCurrentUserReturnsFalseOnGeneralException(): void { - // 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 user session - $user = $this->createMock(IUser::class); - $user->method('getUID')->willReturn('testuser123'); - $this->userSession->method('getUser')->willReturn($user); + $this->setupConfigMocks(); + $this->setupUserSession(self::TEST_USER_ID); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API to throw general exception $apiInstance->method('getFunctionalAccount') - ->with('IONOS', 'test-ext-ref', 'testuser123') + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException(new \Exception('Unexpected error')); - // Expect logging calls - $this->logger->expects($this->once()) - ->method('debug'); - + $this->logger->expects($this->once())->method('debug'); $this->logger->expects($this->once()) ->method('error') ->with('Exception when getting IONOS mail account', $this->callback(function ($context) { return isset($context['exception']) - && $context['userId'] === 'testuser123'; + && $context['userId'] === self::TEST_USER_ID; })); $result = $this->service->mailAccountExistsForCurrentUser(); @@ -604,73 +509,42 @@ public function testMailAccountExistsForCurrentUserReturnsFalseOnGeneralExceptio public function testDeleteEmailAccountSuccess(): void { - $userId = 'testuser123'; + $this->setupConfigMocks(); + $apiInstance = $this->setupApiClient(); - // 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 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('newMailConfigurationAPIApi') - ->with($client, 'https://api.example.com') - ->willReturn($apiInstance); - - // Mock successful deletion (returns void) $apiInstance->expects($this->once()) ->method('deleteMailbox') - ->with('IONOS', 'test-ext-ref', $userId); + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID); - // Expect logging calls $callCount = 0; $this->logger->expects($this->exactly(2)) ->method('info') - ->willReturnCallback(function ($message, $context) use ($userId, &$callCount) { + ->willReturnCallback(function ($message, $context) use (&$callCount) { $callCount++; if ($callCount === 1) { $this->assertEquals('Attempting to delete IONOS email account', $message); - $this->assertEquals($userId, $context['userId']); - $this->assertEquals('test-ext-ref', $context['extRef']); + $this->assertEquals(self::TEST_USER_ID, $context['userId']); + $this->assertEquals(self::TEST_EXT_REF, $context['extRef']); } elseif ($callCount === 2) { $this->assertEquals('Successfully deleted IONOS email account', $message); - $this->assertEquals($userId, $context['userId']); + $this->assertEquals(self::TEST_USER_ID, $context['userId']); } }); - $result = $this->service->deleteEmailAccount($userId); + $result = $this->service->deleteEmailAccount(self::TEST_USER_ID); $this->assertTrue($result); } public function testDeleteEmailAccountReturns404AlreadyDeleted(): void { - $userId = 'testuser123'; - - // 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->setupConfigMocks(); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API to throw 404 exception (mailbox doesn't exist) $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( 'Not Found', 404, @@ -680,48 +554,37 @@ public function testDeleteEmailAccountReturns404AlreadyDeleted(): void { $apiInstance->expects($this->once()) ->method('deleteMailbox') - ->with('IONOS', 'test-ext-ref', $userId) + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException($apiException); - // Expect logging calls $this->logger->expects($this->once()) ->method('info') - ->with('Attempting to delete IONOS email account', $this->callback(function ($context) use ($userId) { - return $context['userId'] === $userId - && $context['extRef'] === 'test-ext-ref'; + ->with('Attempting to delete IONOS email account', $this->callback(function ($context) { + return $context['userId'] === self::TEST_USER_ID + && $context['extRef'] === self::TEST_EXT_REF; })); $this->logger->expects($this->once()) ->method('debug') - ->with('IONOS mailbox does not exist (already deleted or never created)', $this->callback(function ($context) use ($userId) { - return $context['userId'] === $userId + ->with('IONOS mailbox does not exist (already deleted or never created)', $this->callback(function ($context) { + return $context['userId'] === self::TEST_USER_ID && $context['statusCode'] === 404; })); - // Should return true for 404 (treat as success) - $result = $this->service->deleteEmailAccount($userId); + $result = $this->service->deleteEmailAccount(self::TEST_USER_ID); $this->assertTrue($result); } public function testDeleteEmailAccountThrowsExceptionOnApiError(): void { - $userId = 'testuser123'; + $this->setupConfigMocks(); - // 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 API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API to throw 500 exception $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( 'Internal Server Error', 500, @@ -731,295 +594,206 @@ public function testDeleteEmailAccountThrowsExceptionOnApiError(): void { $apiInstance->expects($this->once()) ->method('deleteMailbox') - ->with('IONOS', 'test-ext-ref', $userId) + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException($apiException); - // Expect logging calls $this->logger->expects($this->once()) ->method('info') - ->with('Attempting to delete IONOS email account', $this->callback(function ($context) use ($userId) { - return $context['userId'] === $userId - && $context['extRef'] === 'test-ext-ref'; + ->with('Attempting to delete IONOS email account', $this->callback(function ($context) { + return $context['userId'] === self::TEST_USER_ID + && $context['extRef'] === self::TEST_EXT_REF; })); $this->logger->expects($this->once()) ->method('error') - ->with('API Exception when calling MailConfigurationAPIApi->deleteMailbox', $this->callback(function ($context) use ($userId) { + ->with('API Exception when calling MailConfigurationAPIApi->deleteMailbox', $this->callback(function ($context) { return $context['statusCode'] === 500 && $context['message'] === 'Internal Server Error' - && $context['userId'] === $userId; + && $context['userId'] === self::TEST_USER_ID; })); $this->expectException(ServiceException::class); $this->expectExceptionMessage('Failed to delete IONOS mail: Internal Server Error'); $this->expectExceptionCode(500); - $this->service->deleteEmailAccount($userId); + $this->service->deleteEmailAccount(self::TEST_USER_ID); } public function testDeleteEmailAccountThrowsExceptionOnGeneralError(): void { - $userId = 'testuser123'; - - // 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->setupConfigMocks(); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API to throw general exception $generalException = new \Exception('Unexpected error'); $apiInstance->expects($this->once()) ->method('deleteMailbox') - ->with('IONOS', 'test-ext-ref', $userId) + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException($generalException); - // Expect logging calls $this->logger->expects($this->once()) ->method('info') - ->with('Attempting to delete IONOS email account', $this->callback(function ($context) use ($userId) { - return $context['userId'] === $userId - && $context['extRef'] === 'test-ext-ref'; + ->with('Attempting to delete IONOS email account', $this->callback(function ($context) { + return $context['userId'] === self::TEST_USER_ID + && $context['extRef'] === self::TEST_EXT_REF; })); $this->logger->expects($this->once()) ->method('error') - ->with('Exception when calling MailConfigurationAPIApi->deleteMailbox', $this->callback(function ($context) use ($userId) { + ->with('Exception when calling MailConfigurationAPIApi->deleteMailbox', $this->callback(function ($context) { return isset($context['exception']) - && $context['userId'] === $userId; + && $context['userId'] === self::TEST_USER_ID; })); $this->expectException(ServiceException::class); $this->expectExceptionMessage('Failed to delete IONOS mail'); $this->expectExceptionCode(500); - $this->service->deleteEmailAccount($userId); + $this->service->deleteEmailAccount(self::TEST_USER_ID); } public function testDeleteEmailAccountWithInsecureConnection(): void { - $userId = 'testuser123'; - - // Mock config with insecure connection allowed - $this->configService->method('getExternalReference')->willReturn('test-ext-ref'); - $this->configService->method('getApiBaseUrl')->willReturn('https://api.example.com'); - $this->configService->method('getAllowInsecure')->willReturn(true); - $this->configService->method('getBasicAuthUser')->willReturn('testuser'); - $this->configService->method('getBasicAuthPassword')->willReturn('testpass'); - - // Mock API client - verify should be false - $client = $this->createMock(ClientInterface::class); - $this->apiClientService->method('newClient') - ->with([ - 'auth' => ['testuser', 'testpass'], - 'verify' => false, - ]) - ->willReturn($client); - - $apiInstance = $this->createMock(MailConfigurationAPIApi::class); - $this->apiClientService->method('newMailConfigurationAPIApi') - ->with($client, 'https://api.example.com') - ->willReturn($apiInstance); + $this->setupConfigMocks(allowInsecure: true); + $apiInstance = $this->setupApiClient(verifySSL: false); - // Mock successful deletion $apiInstance->expects($this->once()) ->method('deleteMailbox') - ->with('IONOS', 'test-ext-ref', $userId); + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID); - $this->logger->expects($this->exactly(2)) - ->method('info'); + $this->logger->expects($this->exactly(2))->method('info'); - $result = $this->service->deleteEmailAccount($userId); + $result = $this->service->deleteEmailAccount(self::TEST_USER_ID); $this->assertTrue($result); } public function testTryDeleteEmailAccountWhenIntegrationDisabled(): void { - $userId = 'testuser123'; - - // Mock integration as disabled $this->configService->expects($this->once()) ->method('isIonosIntegrationEnabled') ->willReturn(false); - // Should log that integration is not enabled $this->logger->expects($this->once()) ->method('debug') ->with( 'IONOS integration is not enabled, skipping email account deletion', - ['userId' => $userId] + ['userId' => self::TEST_USER_ID] ); - // Should not attempt to create API client - $this->apiClientService->expects($this->never()) - ->method('newClient'); + $this->apiClientService->expects($this->never())->method('newClient'); - // Call tryDeleteEmailAccount - should not throw exception - $this->service->tryDeleteEmailAccount($userId); + $this->service->tryDeleteEmailAccount(self::TEST_USER_ID); $this->addToAssertionCount(1); } public function testTryDeleteEmailAccountWhenIntegrationEnabledSuccess(): void { - $userId = 'testuser123'; - - // Mock integration as enabled $this->configService->expects($this->once()) ->method('isIonosIntegrationEnabled') ->willReturn(true); - // 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->setupConfigMocks(); + $apiInstance = $this->setupApiClient(); - // 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('newMailConfigurationAPIApi') - ->with($client, 'https://api.example.com') - ->willReturn($apiInstance); - - // Mock successful deletion $apiInstance->expects($this->once()) ->method('deleteMailbox') - ->with('IONOS', 'test-ext-ref', $userId); + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID); - // Should log success at info level (from deleteEmailAccount only) $this->logger->expects($this->exactly(2)) ->method('info') - ->willReturnCallback(function ($message, $context) use ($userId) { + ->willReturnCallback(function ($message, $context) { if ($message === 'Attempting to delete IONOS email account') { - $this->assertSame($userId, $context['userId']); - $this->assertSame('test-ext-ref', $context['extRef']); + $this->assertSame(self::TEST_USER_ID, $context['userId']); + $this->assertSame(self::TEST_EXT_REF, $context['extRef']); } elseif ($message === 'Successfully deleted IONOS email account') { - $this->assertSame($userId, $context['userId']); + $this->assertSame(self::TEST_USER_ID, $context['userId']); } }); - // Call tryDeleteEmailAccount - should not throw exception - $this->service->tryDeleteEmailAccount($userId); + $this->service->tryDeleteEmailAccount(self::TEST_USER_ID); $this->addToAssertionCount(1); } public function testTryDeleteEmailAccountWhenIntegrationEnabledButDeletionFails(): void { - $userId = 'testuser123'; - - // Mock integration as enabled $this->configService->expects($this->once()) ->method('isIonosIntegrationEnabled') ->willReturn(true); - // 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->setupConfigMocks(); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient') ->with([ - 'auth' => ['testuser', 'testpass'], + 'auth' => [self::TEST_BASIC_AUTH_USER, self::TEST_BASIC_AUTH_PASSWORD], 'verify' => true, ]) ->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi') - ->with($client, 'https://api.example.com') + ->with($client, self::TEST_API_BASE_URL) ->willReturn($apiInstance); - // Mock API exception $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException('API Error', 500); $apiInstance->expects($this->once()) ->method('deleteMailbox') - ->with('IONOS', 'test-ext-ref', $userId) + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException($apiException); - // Should log the error from deleteEmailAccount and then from tryDeleteEmailAccount $this->logger->expects($this->exactly(2)) ->method('error') - ->willReturnCallback(function ($message, $context) use ($userId) { + ->willReturnCallback(function ($message, $context) { if ($message === 'API Exception when calling MailConfigurationAPIApi->deleteMailbox') { - // This is from deleteEmailAccount - $this->assertSame($userId, $context['userId']); + $this->assertSame(self::TEST_USER_ID, $context['userId']); $this->assertSame(500, $context['statusCode']); } elseif ($message === 'Failed to delete IONOS mailbox for user') { - // This is from tryDeleteEmailAccount - $this->assertSame($userId, $context['userId']); + $this->assertSame(self::TEST_USER_ID, $context['userId']); $this->assertInstanceOf(ServiceException::class, $context['exception']); } }); - // Call tryDeleteEmailAccount - should NOT throw exception (fire and forget) - $this->service->tryDeleteEmailAccount($userId); + $this->service->tryDeleteEmailAccount(self::TEST_USER_ID); $this->addToAssertionCount(1); } public function testTryDeleteEmailAccountWhenMailboxNotFound(): void { - $userId = 'testuser123'; - - // Mock integration as enabled $this->configService->expects($this->once()) ->method('isIonosIntegrationEnabled') ->willReturn(true); - // 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->setupConfigMocks(); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient') ->with([ - 'auth' => ['testuser', 'testpass'], + 'auth' => [self::TEST_BASIC_AUTH_USER, self::TEST_BASIC_AUTH_PASSWORD], 'verify' => true, ]) ->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi') - ->with($client, 'https://api.example.com') + ->with($client, self::TEST_API_BASE_URL) ->willReturn($apiInstance); - // Mock 404 API exception (mailbox already deleted or never existed) $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException('Not Found', 404); $apiInstance->expects($this->once()) ->method('deleteMailbox') - ->with('IONOS', 'test-ext-ref', $userId) + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException($apiException); - // Should log at info level (from deleteEmailAccount) and debug (404 is treated as success) $this->logger->expects($this->once()) ->method('info') ->with( 'Attempting to delete IONOS email account', [ - 'userId' => $userId, - 'extRef' => 'test-ext-ref', + 'userId' => self::TEST_USER_ID, + 'extRef' => self::TEST_EXT_REF, ] ); @@ -1028,73 +802,51 @@ public function testTryDeleteEmailAccountWhenMailboxNotFound(): void { ->with( 'IONOS mailbox does not exist (already deleted or never created)', [ - 'userId' => $userId, + 'userId' => self::TEST_USER_ID, 'statusCode' => 404 ] ); - // Call tryDeleteEmailAccount - should NOT throw exception - $this->service->tryDeleteEmailAccount($userId); + $this->service->tryDeleteEmailAccount(self::TEST_USER_ID); $this->addToAssertionCount(1); } public function testGetIonosEmailForUserReturnsEmailWhenAccountExists(): void { - $userId = 'testuser123'; - $expectedEmail = 'testuser@example.com'; + $this->setupConfigMocks(); - // 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 API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API response with existing account $mailAccountResponse = $this->getMockBuilder(MailAccountResponse::class) ->disableOriginalConstructor() ->onlyMethods(['getEmail']) ->getMock(); - $mailAccountResponse->method('getEmail')->willReturn($expectedEmail); + $mailAccountResponse->method('getEmail')->willReturn(self::TEST_EMAIL); $apiInstance->method('getFunctionalAccount') - ->with('IONOS', 'test-ext-ref', $userId) + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willReturn($mailAccountResponse); - // Expect logging calls - $this->logger->expects($this->exactly(2)) - ->method('debug'); + $this->logger->expects($this->exactly(2))->method('debug'); - $result = $this->service->getIonosEmailForUser($userId); + $result = $this->service->getIonosEmailForUser(self::TEST_USER_ID); - $this->assertEquals($expectedEmail, $result); + $this->assertEquals(self::TEST_EMAIL, $result); } public function testGetIonosEmailForUserReturnsNullWhen404(): void { - $userId = 'testuser123'; + $this->setupConfigMocks(); - // 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 API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API to throw 404 exception $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( 'Not Found', 404, @@ -1103,36 +855,25 @@ public function testGetIonosEmailForUserReturnsNullWhen404(): void { ); $apiInstance->method('getFunctionalAccount') - ->with('IONOS', 'test-ext-ref', $userId) + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException($apiException); - // Expect logging calls - $this->logger->expects($this->exactly(2)) - ->method('debug'); + $this->logger->expects($this->exactly(2))->method('debug'); - $result = $this->service->getIonosEmailForUser($userId); + $result = $this->service->getIonosEmailForUser(self::TEST_USER_ID); $this->assertNull($result); } public function testGetIonosEmailForUserReturnsNullOnApiError(): void { - $userId = 'testuser123'; + $this->setupConfigMocks(); - // 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 API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API to throw 500 exception $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( 'Internal Server Error', 500, @@ -1141,59 +882,44 @@ public function testGetIonosEmailForUserReturnsNullOnApiError(): void { ); $apiInstance->method('getFunctionalAccount') - ->with('IONOS', 'test-ext-ref', $userId) + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException($apiException); - // Expect logging calls - $this->logger->expects($this->once()) - ->method('debug'); - + $this->logger->expects($this->once())->method('debug'); $this->logger->expects($this->once()) ->method('error') ->with('API Exception when getting IONOS mail account', $this->callback(function ($context) { return $context['statusCode'] === 500 - && $context['message'] === 'Internal Server Error'; + && $context['message'] === 'Internal Server Error'; })); - $result = $this->service->getIonosEmailForUser($userId); + $result = $this->service->getIonosEmailForUser(self::TEST_USER_ID); $this->assertNull($result); } public function testGetIonosEmailForUserReturnsNullOnGeneralException(): void { - $userId = 'testuser123'; - - // 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->setupConfigMocks(); - // Mock API client $client = $this->createMock(ClientInterface::class); $this->apiClientService->method('newClient')->willReturn($client); $apiInstance = $this->createMock(MailConfigurationAPIApi::class); $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); - // Mock API to throw general exception $apiInstance->method('getFunctionalAccount') - ->with('IONOS', 'test-ext-ref', $userId) + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) ->willThrowException(new \Exception('Unexpected error')); - // Expect logging calls - $this->logger->expects($this->once()) - ->method('debug'); - + $this->logger->expects($this->once())->method('debug'); $this->logger->expects($this->once()) ->method('error') ->with('Exception when getting IONOS mail account', $this->callback(function ($context) { return isset($context['exception']) - && $context['userId'] === 'testuser123'; + && $context['userId'] === self::TEST_USER_ID; })); - $result = $this->service->getIonosEmailForUser($userId); + $result = $this->service->getIonosEmailForUser(self::TEST_USER_ID); $this->assertNull($result); } From 8e927678661aaaea8dff6dabe5e20338d820a9eb Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Thu, 4 Dec 2025 17:03:45 +0100 Subject: [PATCH 06/24] IONOS(ionos-mail): IonosMailService: add method to create email account for specific user This change introduces a new method to create an IONOS email account for a specified user, allowing for account creation without relying on the user session. This is particularly useful for OCC commands or administrative operations. Signed-off-by: Misha M.-Kupriyanov --- lib/Service/IONOS/IonosMailService.php | 22 ++++++++++-- .../Service/IONOS/IonosMailServiceTest.php | 35 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/lib/Service/IONOS/IonosMailService.php b/lib/Service/IONOS/IonosMailService.php index 551d1aa1e0..fe8b3a84fb 100644 --- a/lib/Service/IONOS/IonosMailService.php +++ b/lib/Service/IONOS/IonosMailService.php @@ -120,21 +120,39 @@ private function getMailAccountResponse(string $userId): ?MailAccountResponse { } /** - * Create an IONOS email account via API + * Create an IONOS email account via API for the current logged-in user * + * @param string $userName The local part of the email address (before @domain) * @return MailAccountConfig Mail account configuration * @throws ServiceException * @throws AppConfigException */ public function createEmailAccount(string $userName): MailAccountConfig { $userId = $this->getCurrentUserId(); + return $this->createEmailAccountForUser($userId, $userName); + } + + /** + * Create an IONOS email account via API for a specific user + * + * This method allows creating email accounts without relying on the user session, + * making it suitable for use in OCC commands or admin operations. + * + * @param string $userId The Nextcloud user ID + * @param string $userName The local part of the email address (before @domain) + * @return MailAccountConfig Mail account configuration + * @throws ServiceException + * @throws AppConfigException + */ + public function createEmailAccountForUser(string $userId, string $userName): MailAccountConfig { $domain = $this->configService->getMailDomain(); $this->logger->debug('Sending request to mailconfig service', [ 'extRef' => $this->configService->getExternalReference(), 'userName' => $userName, 'domain' => $domain, - 'apiBaseUrl' => $this->configService->getApiBaseUrl() + 'apiBaseUrl' => $this->configService->getApiBaseUrl(), + 'userId' => $userId ]); $apiInstance = $this->createApiInstance(); diff --git a/tests/Unit/Service/IONOS/IonosMailServiceTest.php b/tests/Unit/Service/IONOS/IonosMailServiceTest.php index ea9baeaeef..e6d1a433e8 100644 --- a/tests/Unit/Service/IONOS/IonosMailServiceTest.php +++ b/tests/Unit/Service/IONOS/IonosMailServiceTest.php @@ -320,6 +320,41 @@ public function testCreateEmailAccountWithNoUserSession(): void { $this->service->createEmailAccount(self::TEST_USER_NAME); } + public function testCreateEmailAccountForUserSuccess(): void { + $userId = 'admin123'; + $this->setupConfigMocks(); + $apiInstance = $this->setupApiClient(); + + // No user session needed for this method + + $mailAccountResponse = $this->createMockMailAccountResponse(); + $apiInstance->method('createMailbox')->willReturn($mailAccountResponse); + + $this->logger->expects($this->exactly(4))->method('debug'); + $this->logger->expects($this->once()) + ->method('info') + ->with('Successfully created IONOS mail account', $this->callback(function ($context) use ($userId) { + return $context['email'] === self::TEST_EMAIL + && $context['userId'] === $userId + && $context['userName'] === self::TEST_USER_NAME; + })); + + $result = $this->service->createEmailAccountForUser($userId, self::TEST_USER_NAME); + + $this->assertInstanceOf(MailAccountConfig::class, $result); + $this->assertEquals(self::TEST_EMAIL, $result->getEmail()); + $this->assertEquals(self::IMAP_HOST, $result->getImap()->getHost()); + $this->assertEquals(self::IMAP_PORT, $result->getImap()->getPort()); + $this->assertEquals('ssl', $result->getImap()->getSecurity()); + $this->assertEquals(self::TEST_EMAIL, $result->getImap()->getUsername()); + $this->assertEquals(self::TEST_PASSWORD, $result->getImap()->getPassword()); + $this->assertEquals(self::SMTP_HOST, $result->getSmtp()->getHost()); + $this->assertEquals(self::SMTP_PORT, $result->getSmtp()->getPort()); + $this->assertEquals('tls', $result->getSmtp()->getSecurity()); + $this->assertEquals(self::TEST_EMAIL, $result->getSmtp()->getUsername()); + $this->assertEquals(self::TEST_PASSWORD, $result->getSmtp()->getPassword()); + } + /** * Test SSL mode normalization with various API response values * From 4b0f456fe67f4052b93b4f29b68ef5ad5cddab81 Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Tue, 16 Dec 2025 14:46:01 +0100 Subject: [PATCH 07/24] IONOS(ionos-mail): feat(dto): add withPassword method to MailServerConfig Add immutable withPassword method to create new MailServerConfig instances with updated passwords while preserving other configuration values. This supports password reset scenarios where we need to update credentials without modifying the original configuration object. Signed-off-by: Misha M.-Kupriyanov --- lib/Service/IONOS/Dto/MailServerConfig.php | 16 ++++++++++++++++ .../IONOS/Dto/MailServerConfigTest.php | 19 +++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/lib/Service/IONOS/Dto/MailServerConfig.php b/lib/Service/IONOS/Dto/MailServerConfig.php index 2e06d9073d..27b5cb7aed 100644 --- a/lib/Service/IONOS/Dto/MailServerConfig.php +++ b/lib/Service/IONOS/Dto/MailServerConfig.php @@ -42,6 +42,22 @@ public function getPassword(): string { return $this->password; } + /** + * Create a new instance with a different password + * + * @param string $newPassword The new password to use + * @return self New instance with updated password + */ + public function withPassword(string $newPassword): self { + return new self( + host: $this->host, + port: $this->port, + security: $this->security, + username: $this->username, + password: $newPassword, + ); + } + /** * Convert to array format for backwards compatibility * diff --git a/tests/Unit/Service/IONOS/Dto/MailServerConfigTest.php b/tests/Unit/Service/IONOS/Dto/MailServerConfigTest.php index 9c45508352..a5a9b6625b 100644 --- a/tests/Unit/Service/IONOS/Dto/MailServerConfigTest.php +++ b/tests/Unit/Service/IONOS/Dto/MailServerConfigTest.php @@ -165,4 +165,23 @@ public function testDifferentSecurityTypes(): void { $this->assertEquals('tls', $tlsConfig->getSecurity()); $this->assertEquals('none', $noneConfig->getSecurity()); } + + public function testWithPassword(): void { + $newPassword = 'new-secure-password'; + + // Create a new config with updated password + $updatedConfig = $this->config->withPassword($newPassword); + + // Original config should remain unchanged (immutable) + $this->assertEquals('secret123', $this->config->getPassword()); + + // New config should have the new password + $this->assertEquals($newPassword, $updatedConfig->getPassword()); + + // Other properties should remain the same + $this->assertEquals($this->config->getHost(), $updatedConfig->getHost()); + $this->assertEquals($this->config->getPort(), $updatedConfig->getPort()); + $this->assertEquals($this->config->getSecurity(), $updatedConfig->getSecurity()); + $this->assertEquals($this->config->getUsername(), $updatedConfig->getUsername()); + } } From 98c13e2452d678bf74daef60fb3dbfb9204e8a10 Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Tue, 16 Dec 2025 14:49:15 +0100 Subject: [PATCH 08/24] IONOS(ionos-mail): feat(dto): add withPassword method to MailAccountConfig Add immutable withPassword method to create new MailAccountConfig instances with updated passwords for both IMAP and SMTP configurations. This leverages the MailServerConfig.withPassword method added previously. Signed-off-by: Misha M.-Kupriyanov --- lib/Service/IONOS/Dto/MailAccountConfig.php | 14 ++++++++++ .../IONOS/Dto/MailAccountConfigTest.php | 26 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/lib/Service/IONOS/Dto/MailAccountConfig.php b/lib/Service/IONOS/Dto/MailAccountConfig.php index 2a4c3a5fa5..3ac69a446f 100644 --- a/lib/Service/IONOS/Dto/MailAccountConfig.php +++ b/lib/Service/IONOS/Dto/MailAccountConfig.php @@ -32,6 +32,20 @@ public function getSmtp(): MailServerConfig { return $this->smtp; } + /** + * Create a new instance with updated passwords for both IMAP and SMTP + * + * @param string $newPassword The new password to use + * @return self New instance with updated passwords + */ + public function withPassword(string $newPassword): self { + return new self( + email: $this->email, + imap: $this->imap->withPassword($newPassword), + smtp: $this->smtp->withPassword($newPassword), + ); + } + /** * Convert to array format for backwards compatibility * diff --git a/tests/Unit/Service/IONOS/Dto/MailAccountConfigTest.php b/tests/Unit/Service/IONOS/Dto/MailAccountConfigTest.php index 377e7c59ff..5b7418a70d 100644 --- a/tests/Unit/Service/IONOS/Dto/MailAccountConfigTest.php +++ b/tests/Unit/Service/IONOS/Dto/MailAccountConfigTest.php @@ -245,4 +245,30 @@ public function testNestedObjectAccess(): void { $this->assertEquals('imap.example.com', $imapHost); $this->assertEquals('smtp.example.com', $smtpHost); } + + public function testWithPassword(): void { + $newPassword = 'new-secure-password'; + + // Create a new config with updated password + $updatedConfig = $this->accountConfig->withPassword($newPassword); + + // Original config should remain unchanged (immutable) + $this->assertEquals('imap-password', $this->accountConfig->getImap()->getPassword()); + $this->assertEquals('smtp-password', $this->accountConfig->getSmtp()->getPassword()); + + // New config should have the new password for both IMAP and SMTP + $this->assertEquals($newPassword, $updatedConfig->getImap()->getPassword()); + $this->assertEquals($newPassword, $updatedConfig->getSmtp()->getPassword()); + + // Other properties should remain the same + $this->assertEquals($this->accountConfig->getEmail(), $updatedConfig->getEmail()); + $this->assertEquals($this->accountConfig->getImap()->getHost(), $updatedConfig->getImap()->getHost()); + $this->assertEquals($this->accountConfig->getImap()->getPort(), $updatedConfig->getImap()->getPort()); + $this->assertEquals($this->accountConfig->getImap()->getSecurity(), $updatedConfig->getImap()->getSecurity()); + $this->assertEquals($this->accountConfig->getImap()->getUsername(), $updatedConfig->getImap()->getUsername()); + $this->assertEquals($this->accountConfig->getSmtp()->getHost(), $updatedConfig->getSmtp()->getHost()); + $this->assertEquals($this->accountConfig->getSmtp()->getPort(), $updatedConfig->getSmtp()->getPort()); + $this->assertEquals($this->accountConfig->getSmtp()->getSecurity(), $updatedConfig->getSmtp()->getSecurity()); + $this->assertEquals($this->accountConfig->getSmtp()->getUsername(), $updatedConfig->getSmtp()->getUsername()); + } } From 2792fa82a9be0eaac1fe4cc3af991cf142cb145f Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Tue, 16 Dec 2025 14:53:10 +0100 Subject: [PATCH 09/24] IONOS(ionos-mail): feat(config): add APP_NAME constant to IonosConfigService Add NEXTCLOUD_WORKSPACE constant for consistent app password management across IONOS API calls. This ensures the same application name is used when setting or resetting app passwords. Signed-off-by: Misha M.-Kupriyanov --- lib/Service/IONOS/IonosConfigService.php | 5 +++++ tests/Unit/Service/IONOS/IonosConfigServiceTest.php | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/lib/Service/IONOS/IonosConfigService.php b/lib/Service/IONOS/IonosConfigService.php index 3b5ff579ee..d72f57f99d 100644 --- a/lib/Service/IONOS/IonosConfigService.php +++ b/lib/Service/IONOS/IonosConfigService.php @@ -22,6 +22,11 @@ * Service for managing IONOS API configuration */ class IonosConfigService { + /** + * Application name used for IONOS app password management + */ + public const APP_NAME = 'NEXTCLOUD_WORKSPACE'; + public function __construct( private readonly IConfig $config, private readonly IAppConfig $appConfig, diff --git a/tests/Unit/Service/IONOS/IonosConfigServiceTest.php b/tests/Unit/Service/IONOS/IonosConfigServiceTest.php index 9a3f6ea83e..8951981ecf 100644 --- a/tests/Unit/Service/IONOS/IonosConfigServiceTest.php +++ b/tests/Unit/Service/IONOS/IonosConfigServiceTest.php @@ -38,6 +38,10 @@ protected function setUp(): void { ); } + public function testAppNameConstantExists(): void { + $this->assertSame('NEXTCLOUD_WORKSPACE', IonosConfigService::APP_NAME); + } + public function testGetExternalReferenceSuccess(): void { $this->config->method('getSystemValue') ->with('ncw.ext_ref') From 4f32400e9f8899602231be5ec638320066baf87c Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Tue, 16 Dec 2025 14:54:53 +0100 Subject: [PATCH 10/24] IONOS(ionos-mail): feat(service): add ConflictResolutionResult value object Add immutable result class for conflict resolution scenarios: - retry(): Account can be retried with existing config - noExistingAccount(): No IONOS account exists for conflict resolution - emailMismatch(): Existing account has different email than expected This provides a clean API for handling account creation conflicts. Signed-off-by: Misha M.-Kupriyanov --- .../IONOS/ConflictResolutionResult.php | 81 +++++++++ .../IONOS/ConflictResolutionResultTest.php | 158 ++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 lib/Service/IONOS/ConflictResolutionResult.php create mode 100644 tests/Unit/Service/IONOS/ConflictResolutionResultTest.php diff --git a/lib/Service/IONOS/ConflictResolutionResult.php b/lib/Service/IONOS/ConflictResolutionResult.php new file mode 100644 index 0000000000..f1a402c10e --- /dev/null +++ b/lib/Service/IONOS/ConflictResolutionResult.php @@ -0,0 +1,81 @@ +canRetry; + } + + public function getAccountConfig(): ?MailAccountConfig { + return $this->accountConfig; + } + + public function hasEmailMismatch(): bool { + return $this->expectedEmail !== null && $this->existingEmail !== null; + } + + public function getExpectedEmail(): ?string { + return $this->expectedEmail; + } + + public function getExistingEmail(): ?string { + return $this->existingEmail; + } +} diff --git a/tests/Unit/Service/IONOS/ConflictResolutionResultTest.php b/tests/Unit/Service/IONOS/ConflictResolutionResultTest.php new file mode 100644 index 0000000000..a140e4bca1 --- /dev/null +++ b/tests/Unit/Service/IONOS/ConflictResolutionResultTest.php @@ -0,0 +1,158 @@ +accountConfig = new MailAccountConfig( + email: 'user@example.com', + imap: $imapConfig, + smtp: $smtpConfig, + ); + } + + public function testRetryFactoryMethod(): void { + $result = ConflictResolutionResult::retry($this->accountConfig); + + $this->assertInstanceOf(ConflictResolutionResult::class, $result); + $this->assertTrue($result->canRetry()); + $this->assertInstanceOf(MailAccountConfig::class, $result->getAccountConfig()); + $this->assertSame($this->accountConfig, $result->getAccountConfig()); + $this->assertFalse($result->hasEmailMismatch()); + $this->assertNull($result->getExpectedEmail()); + $this->assertNull($result->getExistingEmail()); + } + + public function testNoExistingAccountFactoryMethod(): void { + $result = ConflictResolutionResult::noExistingAccount(); + + $this->assertInstanceOf(ConflictResolutionResult::class, $result); + $this->assertFalse($result->canRetry()); + $this->assertNull($result->getAccountConfig()); + $this->assertFalse($result->hasEmailMismatch()); + $this->assertNull($result->getExpectedEmail()); + $this->assertNull($result->getExistingEmail()); + } + + public function testEmailMismatchFactoryMethod(): void { + $expectedEmail = 'expected@example.com'; + $existingEmail = 'existing@example.com'; + + $result = ConflictResolutionResult::emailMismatch($expectedEmail, $existingEmail); + + $this->assertInstanceOf(ConflictResolutionResult::class, $result); + $this->assertFalse($result->canRetry()); + $this->assertNull($result->getAccountConfig()); + $this->assertTrue($result->hasEmailMismatch()); + $this->assertEquals($expectedEmail, $result->getExpectedEmail()); + $this->assertEquals($existingEmail, $result->getExistingEmail()); + } + + public function testRetryResultHasCorrectState(): void { + $result = ConflictResolutionResult::retry($this->accountConfig); + + // Verify all state is correct for retry scenario + $this->assertTrue($result->canRetry(), 'Should be able to retry'); + $this->assertNotNull($result->getAccountConfig(), 'Should have account config'); + $this->assertEquals('user@example.com', $result->getAccountConfig()->getEmail()); + } + + public function testNoExistingAccountResultHasCorrectState(): void { + $result = ConflictResolutionResult::noExistingAccount(); + + // Verify all state is correct for no existing account scenario + $this->assertFalse($result->canRetry(), 'Should not be able to retry'); + $this->assertNull($result->getAccountConfig(), 'Should not have account config'); + $this->assertFalse($result->hasEmailMismatch(), 'Should not have email mismatch'); + } + + public function testEmailMismatchResultHasCorrectState(): void { + $result = ConflictResolutionResult::emailMismatch('user1@example.com', 'user2@example.com'); + + // Verify all state is correct for email mismatch scenario + $this->assertFalse($result->canRetry(), 'Should not be able to retry'); + $this->assertNull($result->getAccountConfig(), 'Should not have account config'); + $this->assertTrue($result->hasEmailMismatch(), 'Should have email mismatch'); + $this->assertNotNull($result->getExpectedEmail(), 'Should have expected email'); + $this->assertNotNull($result->getExistingEmail(), 'Should have existing email'); + } + + public function testEmailMismatchWithSameEmail(): void { + // Even with same email, if using emailMismatch() factory, it should still mark as mismatch + $email = 'same@example.com'; + $result = ConflictResolutionResult::emailMismatch($email, $email); + + $this->assertTrue($result->hasEmailMismatch()); + $this->assertEquals($email, $result->getExpectedEmail()); + $this->assertEquals($email, $result->getExistingEmail()); + } + + public function testRetryResultPreservesAccountConfigData(): void { + $result = ConflictResolutionResult::retry($this->accountConfig); + $retrievedConfig = $result->getAccountConfig(); + + $this->assertNotNull($retrievedConfig); + $this->assertEquals('user@example.com', $retrievedConfig->getEmail()); + $this->assertEquals('imap.example.com', $retrievedConfig->getImap()->getHost()); + $this->assertEquals('smtp.example.com', $retrievedConfig->getSmtp()->getHost()); + } + + public function testEmailMismatchWithEmptyStrings(): void { + $result = ConflictResolutionResult::emailMismatch('', ''); + + $this->assertTrue($result->hasEmailMismatch()); + $this->assertEquals('', $result->getExpectedEmail()); + $this->assertEquals('', $result->getExistingEmail()); + } + + public function testMultipleInstancesAreIndependent(): void { + $result1 = ConflictResolutionResult::retry($this->accountConfig); + $result2 = ConflictResolutionResult::noExistingAccount(); + $result3 = ConflictResolutionResult::emailMismatch('a@test.com', 'b@test.com'); + + // Each instance should maintain its own state + $this->assertTrue($result1->canRetry()); + $this->assertFalse($result2->canRetry()); + $this->assertFalse($result3->canRetry()); + + $this->assertNotNull($result1->getAccountConfig()); + $this->assertNull($result2->getAccountConfig()); + $this->assertNull($result3->getAccountConfig()); + + $this->assertFalse($result1->hasEmailMismatch()); + $this->assertFalse($result2->hasEmailMismatch()); + $this->assertTrue($result3->hasEmailMismatch()); + } +} From 3fa5d8095bd02ace17c3296930c2749fc5d515c1 Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Tue, 16 Dec 2025 15:02:04 +0100 Subject: [PATCH 11/24] IONOS(ionos-mail): feat(service): add account config retrieval and password reset to IonosMailService Add new methods to IonosMailService: - getAccountConfigForUser: Retrieve existing IONOS account configuration - getAccountConfigForCurrentUser: Retrieve config for logged-in user - resetAppPassword: Reset/regenerate app password for a user - getMailDomain: Expose mail domain from config service Refactor buildSuccessResponse to use shared buildMailAccountConfig helper for consistent configuration building across different response types. Signed-off-by: Misha M.-Kupriyanov --- lib/Service/IONOS/IonosMailService.php | 163 ++++++++++++++++-- .../Service/IONOS/IonosMailServiceTest.php | 142 +++++++++++++++ 2 files changed, 295 insertions(+), 10 deletions(-) diff --git a/lib/Service/IONOS/IonosMailService.php b/lib/Service/IONOS/IonosMailService.php index fe8b3a84fb..f0e29f5719 100644 --- a/lib/Service/IONOS/IonosMailService.php +++ b/lib/Service/IONOS/IonosMailService.php @@ -11,10 +11,12 @@ use IONOS\MailConfigurationAPI\Client\Api\MailConfigurationAPIApi; use IONOS\MailConfigurationAPI\Client\ApiException; +use IONOS\MailConfigurationAPI\Client\Model\Imap; use IONOS\MailConfigurationAPI\Client\Model\MailAccountCreatedResponse; use IONOS\MailConfigurationAPI\Client\Model\MailAccountResponse; use IONOS\MailConfigurationAPI\Client\Model\MailAddonErrorMessage; use IONOS\MailConfigurationAPI\Client\Model\MailCreateData; +use IONOS\MailConfigurationAPI\Client\Model\Smtp; use OCA\Mail\Exception\ServiceException; use OCA\Mail\Service\IONOS\Dto\MailAccountConfig; use OCA\Mail\Service\IONOS\Dto\MailServerConfig; @@ -218,6 +220,45 @@ public function createEmailAccountForUser(string $userId, string $userName): Mai } } + /** + * Get IONOS account configuration for a specific user + * + * This method retrieves the configuration of an existing IONOS mail account. + * Useful when an account was previously created but Nextcloud account creation failed. + * + * @param string $userId The Nextcloud user ID + * @return MailAccountConfig|null Mail account configuration if exists, null otherwise + * @throws ServiceException + */ + public function getAccountConfigForUser(string $userId): ?MailAccountConfig { + $response = $this->getMailAccountResponse($userId); + + if ($response === null) { + $this->logger->debug('No existing IONOS account found for user', [ + 'userId' => $userId + ]); + return null; + } + + $this->logger->info('Retrieved existing IONOS account configuration', [ + 'email' => $response->getEmail(), + 'userId' => $userId + ]); + + return $this->buildConfigFromAccountResponse($response); + } + + /** + * Get IONOS account configuration for the current logged-in user + * + * @return MailAccountConfig|null Mail account configuration if exists, null otherwise + * @throws ServiceException + */ + public function getAccountConfigForCurrentUser(): ?MailAccountConfig { + $userId = $this->getCurrentUserId(); + return $this->getAccountConfigForUser($userId); + } + /** * Get the current user ID * @@ -274,38 +315,71 @@ private function normalizeSslMode(string $apiSslMode): string { } /** - * Build success response with mail configuration + * Build success response with mail configuration from MailAccountCreatedResponse (newly created account) * - * @param MailAccountCreatedResponse $response - * @return MailAccountConfig + * @param MailAccountCreatedResponse $response The account response from createFunctionalAccount + * @return MailAccountConfig The mail account configuration with password */ private function buildSuccessResponse(MailAccountCreatedResponse $response): MailAccountConfig { - $smtpServer = $response->getServer()->getSmtp(); - $imapServer = $response->getServer()->getImap(); + return $this->buildMailAccountConfig( + $response->getServer()->getImap(), + $response->getServer()->getSmtp(), + $response->getEmail(), + $response->getPassword() + ); + } + /** + * Build mail account configuration from server details + * + * @param Imap $imapServer IMAP server configuration object + * @param Smtp $smtpServer SMTP server configuration object + * @param string $email Email address + * @param string $password Account password + * @return MailAccountConfig Complete mail account configuration + */ + private function buildMailAccountConfig(Imap $imapServer, Smtp $smtpServer, string $email, string $password): MailAccountConfig { $imapConfig = new MailServerConfig( host: $imapServer->getHost(), port: $imapServer->getPort(), security: $this->normalizeSslMode($imapServer->getSslMode()), - username: $response->getEmail(), - password: $response->getPassword(), + username: $email, + password: $password, ); $smtpConfig = new MailServerConfig( host: $smtpServer->getHost(), port: $smtpServer->getPort(), security: $this->normalizeSslMode($smtpServer->getSslMode()), - username: $response->getEmail(), - password: $response->getPassword(), + username: $email, + password: $password, ); return new MailAccountConfig( - email: $response->getEmail(), + email: $email, imap: $imapConfig, smtp: $smtpConfig, ); } + /** + * Build configuration from MailAccountResponse (existing account) + * Note: MailAccountResponse does not include password for security reasons + * + * @param MailAccountResponse $response The account response from getFunctionalAccount + * @return MailAccountConfig The mail account configuration with empty password + */ + private function buildConfigFromAccountResponse(MailAccountResponse $response): MailAccountConfig { + // Password is not available when retrieving existing accounts + // It should be retrieved from Nextcloud's credential store separately + return $this->buildMailAccountConfig( + $response->getServer()->getImap(), + $response->getServer()->getSmtp(), + $response->getEmail(), + '' + ); + } + /** * Delete an IONOS email account via API * @@ -409,4 +483,73 @@ public function tryDeleteEmailAccount(string $userId): void { // Don't throw - this is a fire and forget operation } } + + /** + * Reset app password for the IONOS mail account (generates a new password) + * + * @param string $userId The Nextcloud user ID + * @param string $appName The application name for the password + * @return string The new password + * @throws ServiceException + */ + public function resetAppPassword(string $userId, string $appName): string { + $this->logger->debug('Resetting IONOS app password', [ + 'userId' => $userId, + 'appName' => $appName, + 'extRef' => $this->configService->getExternalReference(), + ]); + + try { + $apiInstance = $this->createApiInstance(); + $result = $apiInstance->setAppPassword( + self::BRAND, + $this->configService->getExternalReference(), + $userId, + $appName + ); + + if (is_string($result)) { + $this->logger->info('Successfully reset IONOS app password', [ + 'userId' => $userId, + 'appName' => $appName + ]); + return $result; + } + + $this->logger->error('Failed to reset IONOS app password: Unexpected response type', [ + 'userId' => $userId, + 'appName' => $appName, + 'result' => $result + ]); + throw new ServiceException('Failed to reset IONOS app password', self::HTTP_INTERNAL_SERVER_ERROR); + } catch (ServiceException $e) { + // Re-throw ServiceException without additional logging + throw $e; + } catch (ApiException $e) { + $this->logger->error('API Exception when calling MailConfigurationAPIApi->setAppPassword', [ + 'statusCode' => $e->getCode(), + 'message' => $e->getMessage(), + 'responseBody' => $e->getResponseBody(), + 'userId' => $userId, + 'appName' => $appName + ]); + throw new ServiceException('Failed to reset IONOS app password: ' . $e->getMessage(), $e->getCode(), $e); + } catch (\Exception $e) { + $this->logger->error('Exception when calling MailConfigurationAPIApi->setAppPassword', [ + 'exception' => $e, + 'userId' => $userId, + 'appName' => $appName + ]); + throw new ServiceException('Failed to reset IONOS app password', self::HTTP_INTERNAL_SERVER_ERROR, $e); + } + } + + /** + * Get the configured mail domain for IONOS accounts + * + * @return string The mail domain (e.g., "example.com") + */ + public function getMailDomain(): string { + return $this->configService->getMailDomain(); + } } diff --git a/tests/Unit/Service/IONOS/IonosMailServiceTest.php b/tests/Unit/Service/IONOS/IonosMailServiceTest.php index e6d1a433e8..2af70670cb 100644 --- a/tests/Unit/Service/IONOS/IonosMailServiceTest.php +++ b/tests/Unit/Service/IONOS/IonosMailServiceTest.php @@ -958,4 +958,146 @@ public function testGetIonosEmailForUserReturnsNullOnGeneralException(): void { $this->assertNull($result); } + + public function testGetAccountConfigForUserReturnsConfigWhenAccountExists(): void { + $this->setupConfigMocks(); + + $client = $this->createMock(ClientInterface::class); + $this->apiClientService->method('newClient')->willReturn($client); + + $apiInstance = $this->createMock(MailConfigurationAPIApi::class); + $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); + + // Create mock response for existing account + $imapServer = $this->createMockImapServer(); + $smtpServer = $this->createMockSmtpServer(); + + $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', 'getServer']) + ->getMock(); + $mailAccountResponse->method('getEmail')->willReturn(self::TEST_EMAIL); + $mailAccountResponse->method('getServer')->willReturn($mailServer); + + $apiInstance->method('getFunctionalAccount') + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) + ->willReturn($mailAccountResponse); + + $result = $this->service->getAccountConfigForUser(self::TEST_USER_ID); + + $this->assertInstanceOf(MailAccountConfig::class, $result); + $this->assertEquals(self::TEST_EMAIL, $result->getEmail()); + $this->assertEquals(self::IMAP_HOST, $result->getImap()->getHost()); + $this->assertEquals(self::IMAP_PORT, $result->getImap()->getPort()); + $this->assertEquals('ssl', $result->getImap()->getSecurity()); + // Password is empty when retrieving existing accounts + $this->assertEquals('', $result->getImap()->getPassword()); + $this->assertEquals(self::SMTP_HOST, $result->getSmtp()->getHost()); + $this->assertEquals(self::SMTP_PORT, $result->getSmtp()->getPort()); + $this->assertEquals('tls', $result->getSmtp()->getSecurity()); + $this->assertEquals('', $result->getSmtp()->getPassword()); + } + + public function testGetAccountConfigForUserReturnsNullWhenAccountDoesNotExist(): void { + $this->setupConfigMocks(); + + $client = $this->createMock(ClientInterface::class); + $this->apiClientService->method('newClient')->willReturn($client); + + $apiInstance = $this->createMock(MailConfigurationAPIApi::class); + $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); + + // Mock API to throw 404 exception + $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( + 'Not Found', + 404, + [], + '{"error": "Not Found"}' + ); + + $apiInstance->method('getFunctionalAccount') + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID) + ->willThrowException($apiException); + + $result = $this->service->getAccountConfigForUser(self::TEST_USER_ID); + + $this->assertNull($result); + } + + public function testResetAppPasswordSuccess(): void { + $this->setupConfigMocks(); + $apiInstance = $this->setupApiClient(); + $appName = 'NEXTCLOUD_WORKSPACE'; + $expectedPassword = 'new-app-password-123'; + + $apiInstance->expects($this->once()) + ->method('setAppPassword') + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID, $appName) + ->willReturn($expectedPassword); + + $result = $this->service->resetAppPassword(self::TEST_USER_ID, $appName); + + $this->assertEquals($expectedPassword, $result); + } + + public function testResetAppPasswordWithApiException(): void { + $this->setupConfigMocks(); + + $client = $this->createMock(ClientInterface::class); + $this->apiClientService->method('newClient')->willReturn($client); + + $apiInstance = $this->createMock(MailConfigurationAPIApi::class); + $this->apiClientService->method('newMailConfigurationAPIApi')->willReturn($apiInstance); + + $appName = 'NEXTCLOUD_WORKSPACE'; + + $apiException = new \IONOS\MailConfigurationAPI\Client\ApiException( + 'Not Found', + 404, + [], + '{"error": "Mailbox not found"}' + ); + + $apiInstance->method('setAppPassword') + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID, $appName) + ->willThrowException($apiException); + + $this->expectException(ServiceException::class); + $this->expectExceptionMessage('Failed to reset IONOS app password: Not Found'); + $this->expectExceptionCode(404); + + $this->service->resetAppPassword(self::TEST_USER_ID, $appName); + } + + public function testResetAppPasswordWithUnexpectedResponse(): void { + $this->setupConfigMocks(); + $apiInstance = $this->setupApiClient(); + $appName = 'NEXTCLOUD_WORKSPACE'; + + // API returns unexpected response type (not a string) + $apiInstance->method('setAppPassword') + ->with('IONOS', self::TEST_EXT_REF, self::TEST_USER_ID, $appName) + ->willReturn(['unexpected' => 'response']); + + $this->expectException(ServiceException::class); + $this->expectExceptionMessage('Failed to reset IONOS app password'); + $this->expectExceptionCode(500); + + $this->service->resetAppPassword(self::TEST_USER_ID, $appName); + } + + public function testGetMailDomain(): void { + $this->configService->method('getMailDomain')->willReturn(self::TEST_DOMAIN); + + $result = $this->service->getMailDomain(); + + $this->assertEquals(self::TEST_DOMAIN, $result); + } } From 5a2877bd688dcdd1ba965ad0d702b294c23e48ed Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Tue, 16 Dec 2025 15:04:55 +0100 Subject: [PATCH 12/24] IONOS(ionos-mail): feat(service): add IonosAccountConflictResolver Add service to handle conflict resolution when IONOS account creation fails: - Check if existing IONOS account matches requested email - Reset app password for existing accounts to enable retry - Report email mismatch when existing account has different email This enables retry scenarios where IONOS account was created but Nextcloud account creation failed due to DNS propagation delays. Signed-off-by: Misha M.-Kupriyanov --- .../IONOS/IonosAccountConflictResolver.php | 74 +++++++ .../IonosAccountConflictResolverTest.php | 182 ++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 lib/Service/IONOS/IonosAccountConflictResolver.php create mode 100644 tests/Unit/Service/IONOS/IonosAccountConflictResolverTest.php diff --git a/lib/Service/IONOS/IonosAccountConflictResolver.php b/lib/Service/IONOS/IonosAccountConflictResolver.php new file mode 100644 index 0000000000..9b77f3913a --- /dev/null +++ b/lib/Service/IONOS/IonosAccountConflictResolver.php @@ -0,0 +1,74 @@ +ionosMailService->getAccountConfigForUser($userId); + + if ($ionosConfig === null) { + $this->logger->debug('No existing IONOS account found for conflict resolution', [ + 'userId' => $userId + ]); + return ConflictResolutionResult::noExistingAccount(); + } + + // Construct full email address from username to compare with existing account + $domain = $this->ionosConfigService->getMailDomain(); + $expectedEmail = $emailUser . '@' . $domain; + + // Ensure the retrieved email matches the requested email + if ($ionosConfig->getEmail() === $expectedEmail) { + $this->logger->info('IONOS account already exists, retrieving new password for retry', [ + 'emailAddress' => $ionosConfig->getEmail(), + 'userId' => $userId + ]); + + // Get fresh password via resetAppPassword API since getAccountConfigForUser + // does not return password for security reasons + $newPassword = $this->ionosMailService->resetAppPassword($userId, IonosConfigService::APP_NAME); + + // Create new config with the fresh password + $configWithPassword = $ionosConfig->withPassword($newPassword); + + return ConflictResolutionResult::retry($configWithPassword); + } + + $this->logger->warning('IONOS account exists but email mismatch', [ + 'requestedEmail' => $expectedEmail, + 'existingEmail' => $ionosConfig->getEmail(), + 'userId' => $userId + ]); + + return ConflictResolutionResult::emailMismatch($expectedEmail, $ionosConfig->getEmail()); + } +} diff --git a/tests/Unit/Service/IONOS/IonosAccountConflictResolverTest.php b/tests/Unit/Service/IONOS/IonosAccountConflictResolverTest.php new file mode 100644 index 0000000000..5c8379f2b8 --- /dev/null +++ b/tests/Unit/Service/IONOS/IonosAccountConflictResolverTest.php @@ -0,0 +1,182 @@ +ionosMailService = $this->createMock(IonosMailService::class); + $this->ionosConfigService = $this->createMock(IonosConfigService::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->resolver = new IonosAccountConflictResolver( + $this->ionosMailService, + $this->ionosConfigService, + $this->logger, + ); + } + + public function testResolveConflictWithNoExistingAccount(): void { + $userId = 'testuser'; + $emailUser = 'test'; + + $this->ionosMailService->method('getAccountConfigForUser') + ->with($userId) + ->willReturn(null); + + $this->logger + ->expects($this->once()) + ->method('debug') + ->with('No existing IONOS account found for conflict resolution', ['userId' => $userId]); + + $result = $this->resolver->resolveConflict($userId, $emailUser); + + $this->assertFalse($result->canRetry()); + $this->assertNull($result->getAccountConfig()); + $this->assertFalse($result->hasEmailMismatch()); + } + + public function testResolveConflictWithMatchingEmail(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $domain = 'example.com'; + $emailAddress = 'test@example.com'; + $newPassword = 'new-app-password-123'; + + // Create MailAccountConfig DTO without password (as API returns) + $imapConfig = new MailServerConfig( + host: 'mail.localhost', + port: 1143, + security: 'none', + username: $emailAddress, + password: '', // Empty password from getAccountConfigForUser + ); + + $smtpConfig = new MailServerConfig( + host: 'mail.localhost', + port: 1587, + security: 'none', + username: $emailAddress, + password: '', // Empty password from getAccountConfigForUser + ); + + $mailAccountConfig = new MailAccountConfig( + email: $emailAddress, + imap: $imapConfig, + smtp: $smtpConfig, + ); + + $this->ionosMailService->method('getAccountConfigForUser') + ->with($userId) + ->willReturn($mailAccountConfig); + + $this->ionosConfigService->method('getMailDomain') + ->willReturn($domain); + + // Expect resetAppPassword to be called + $this->ionosMailService + ->expects($this->once()) + ->method('resetAppPassword') + ->with($userId, 'NEXTCLOUD_WORKSPACE') + ->willReturn($newPassword); + + $this->logger + ->expects($this->once()) + ->method('info') + ->with( + 'IONOS account already exists, retrieving new password for retry', + ['emailAddress' => $emailAddress, 'userId' => $userId] + ); + + $result = $this->resolver->resolveConflict($userId, $emailUser); + + $this->assertTrue($result->canRetry()); + $this->assertNotNull($result->getAccountConfig()); + $this->assertFalse($result->hasEmailMismatch()); + + // Verify the returned config has the new password + $resultConfig = $result->getAccountConfig(); + $this->assertEquals($newPassword, $resultConfig->getImap()->getPassword()); + $this->assertEquals($newPassword, $resultConfig->getSmtp()->getPassword()); + } + + public function testResolveConflictWithEmailMismatch(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $domain = 'example.com'; + $expectedEmail = 'test@example.com'; + $existingEmail = 'different@example.com'; + + // Create MailAccountConfig DTO with different email + $imapConfig = new MailServerConfig( + host: 'mail.localhost', + port: 1143, + security: 'none', + username: $existingEmail, + password: 'tmp', + ); + + $smtpConfig = new MailServerConfig( + host: 'mail.localhost', + port: 1587, + security: 'none', + username: $existingEmail, + password: 'tmp', + ); + + $mailAccountConfig = new MailAccountConfig( + email: $existingEmail, + imap: $imapConfig, + smtp: $smtpConfig, + ); + + $this->ionosMailService->method('getAccountConfigForUser') + ->with($userId) + ->willReturn($mailAccountConfig); + + $this->ionosConfigService->method('getMailDomain') + ->willReturn($domain); + + $this->logger + ->expects($this->once()) + ->method('warning') + ->with( + 'IONOS account exists but email mismatch', + ['requestedEmail' => $expectedEmail, 'existingEmail' => $existingEmail, 'userId' => $userId] + ); + + $result = $this->resolver->resolveConflict($userId, $emailUser); + + $this->assertFalse($result->canRetry()); + $this->assertNull($result->getAccountConfig()); + $this->assertTrue($result->hasEmailMismatch()); + $this->assertEquals($expectedEmail, $result->getExpectedEmail()); + $this->assertEquals($existingEmail, $result->getExistingEmail()); + } +} From eca8bb53a12f515a4c8c05597ea193d30e9376b3 Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Wed, 17 Dec 2025 14:06:08 +0100 Subject: [PATCH 13/24] IONOS(ionos-mail): feat(exception): add IonosServiceException for enhanced error handling Introduce IonosServiceException to provide a structured way to handle exceptions related to IONOS services, including additional data storage for context. Signed-off-by: Misha M.-Kupriyanov --- lib/Exception/IonosServiceException.php | 38 ++++++++++ .../Exception/IonosServiceExceptionTest.php | 72 +++++++++++++++++++ 2 files changed, 110 insertions(+) create mode 100644 lib/Exception/IonosServiceException.php create mode 100644 tests/Unit/Exception/IonosServiceExceptionTest.php diff --git a/lib/Exception/IonosServiceException.php b/lib/Exception/IonosServiceException.php new file mode 100644 index 0000000000..c3dfbdffb3 --- /dev/null +++ b/lib/Exception/IonosServiceException.php @@ -0,0 +1,38 @@ + $data [optional] Additional data to pass with the exception. + */ + public function __construct( + $message = '', + $code = 0, + ?Throwable $previous = null, + private readonly array $data = [], + ) { + parent::__construct($message, $code, $previous); + } + + /** + * Get additional data associated with the exception + * + * @return array + */ + public function getData(): array { + return $this->data; + } +} diff --git a/tests/Unit/Exception/IonosServiceExceptionTest.php b/tests/Unit/Exception/IonosServiceExceptionTest.php new file mode 100644 index 0000000000..390209e9df --- /dev/null +++ b/tests/Unit/Exception/IonosServiceExceptionTest.php @@ -0,0 +1,72 @@ +assertEquals('Test message', $exception->getMessage()); + $this->assertEquals(500, $exception->getCode()); + $this->assertEquals([], $exception->getData()); + } + + public function testConstructorWithData(): void { + $data = [ + 'errorCode' => 'DUPLICATE_EMAIL', + 'email' => 'test@example.com', + 'userId' => 'user123', + ]; + + $exception = new IonosServiceException('Duplicate email', 409, null, $data); + + $this->assertEquals('Duplicate email', $exception->getMessage()); + $this->assertEquals(409, $exception->getCode()); + $this->assertEquals($data, $exception->getData()); + } + + public function testConstructorWithPreviousException(): void { + $previous = new \Exception('Original error'); + $data = ['context' => 'test']; + + $exception = new IonosServiceException('Wrapped error', 500, $previous, $data); + + $this->assertEquals('Wrapped error', $exception->getMessage()); + $this->assertEquals(500, $exception->getCode()); + $this->assertEquals($previous, $exception->getPrevious()); + $this->assertEquals($data, $exception->getData()); + } + + public function testGetDataReturnsEmptyArrayByDefault(): void { + $exception = new IonosServiceException(); + + $this->assertEquals([], $exception->getData()); + } + + public function testGetDataPreservesComplexData(): void { + $data = [ + 'errorCode' => 'VALIDATION_ERROR', + 'fields' => ['email', 'password'], + 'metadata' => [ + 'timestamp' => 1234567890, + 'requestId' => 'req-123', + ], + ]; + + $exception = new IonosServiceException('Validation failed', 400, null, $data); + + $this->assertEquals($data, $exception->getData()); + $this->assertIsArray($exception->getData()['fields']); + $this->assertIsArray($exception->getData()['metadata']); + } +} From de62be36960b97405f6eec560eae77191da64e8f Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Tue, 16 Dec 2025 15:06:34 +0100 Subject: [PATCH 14/24] IONOS(ionos-mail): feat(service): add IonosAccountCreationService Add unified service for creating and updating IONOS mail accounts: - Check for existing Nextcloud accounts before creation - Handle conflict resolution with existing IONOS accounts - Create new accounts with encrypted credentials - Update existing accounts with fresh credentials This service provides consistent account creation logic for both CLI and web interfaces, with proper retry handling for DNS propagation delays. Signed-off-by: Misha M.-Kupriyanov --- .../IONOS/IonosAccountCreationService.php | 220 +++++++ lib/Service/IONOS/IonosMailService.php | 1 + .../IONOS/IonosAccountCreationServiceTest.php | 591 ++++++++++++++++++ 3 files changed, 812 insertions(+) create mode 100644 lib/Service/IONOS/IonosAccountCreationService.php create mode 100644 tests/Unit/Service/IONOS/IonosAccountCreationServiceTest.php diff --git a/lib/Service/IONOS/IonosAccountCreationService.php b/lib/Service/IONOS/IonosAccountCreationService.php new file mode 100644 index 0000000000..482ebc0db0 --- /dev/null +++ b/lib/Service/IONOS/IonosAccountCreationService.php @@ -0,0 +1,220 @@ +buildEmailAddress($emailUser); + + // Check if Nextcloud account already exists + $existingAccounts = $this->accountService->findByUserIdAndAddress($userId, $expectedEmail); + + if (!empty($existingAccounts)) { + return $this->handleExistingAccount($userId, $emailUser, $accountName, $existingAccounts[0]); + } + + // No existing account - create new one + return $this->handleNewAccount($userId, $emailUser, $accountName); + } + + /** + * Handle the case where a Nextcloud mail account already exists + */ + private function handleExistingAccount(string $userId, string $emailUser, string $accountName, $existingAccount): Account { + $this->logger->info('Nextcloud mail account already exists, resetting credentials', [ + 'accountId' => $existingAccount->getId(), + 'emailAddress' => $existingAccount->getEmail(), + 'userId' => $userId, + ]); + + try { + $resolutionResult = $this->conflictResolver->resolveConflict($userId, $emailUser); + + if (!$resolutionResult->canRetry()) { + if ($resolutionResult->hasEmailMismatch()) { + throw new IonosServiceException( + 'IONOS account exists but email mismatch. Expected: ' . $resolutionResult->getExpectedEmail() . ', Found: ' . $resolutionResult->getExistingEmail(), + IonosMailService::STATUS__409_CONFLICT, + null, + [ + 'expectedEmail' => $resolutionResult->getExpectedEmail(), + 'existingEmail' => $resolutionResult->getExistingEmail(), + ] + ); + } + throw new ServiceException('Nextcloud account exists but no IONOS account found', 500); + } + + $mailConfig = $resolutionResult->getAccountConfig(); + return $this->updateAccount($existingAccount->getMailAccount(), $accountName, $mailConfig); + } catch (IonosServiceException $e) { + // Re-throw IonosServiceException as-is + throw $e; + } catch (ServiceException $e) { + throw new ServiceException('Failed to reset IONOS account credentials: ' . $e->getMessage(), $e->getCode(), $e); + } + } + + /** + * Handle the case where no Nextcloud account exists yet + */ + private function handleNewAccount(string $userId, string $emailUser, string $accountName): Account { + try { + $this->logger->info('Creating new IONOS email account', [ + 'userId' => $userId, + 'emailUser' => $emailUser, + 'accountName' => $accountName + ]); + + $mailConfig = $this->ionosMailService->createEmailAccountForUser($userId, $emailUser); + + $this->logger->info('IONOS email account created successfully', [ + 'emailAddress' => $mailConfig->getEmail() + ]); + + return $this->createAccount($userId, $accountName, $mailConfig); + } catch (ServiceException $e) { + // Try to resolve conflict - IONOS account might already exist + $this->logger->info('IONOS account creation failed, attempting conflict resolution', [ + 'userId' => $userId, + 'emailUser' => $emailUser, + 'error' => $e->getMessage() + ]); + + $resolutionResult = $this->conflictResolver->resolveConflict($userId, $emailUser); + + if (!$resolutionResult->canRetry()) { + if ($resolutionResult->hasEmailMismatch()) { + throw new IonosServiceException( + 'IONOS account exists but email mismatch. Expected: ' . $resolutionResult->getExpectedEmail() . ', Found: ' . $resolutionResult->getExistingEmail(), + IonosMailService::STATUS__409_CONFLICT, + $e, + [ + 'expectedEmail' => $resolutionResult->getExpectedEmail(), + 'existingEmail' => $resolutionResult->getExistingEmail(), + ] + ); + } + // No existing IONOS account found - re-throw original error + throw $e; + } + + $mailConfig = $resolutionResult->getAccountConfig(); + return $this->createAccount($userId, $accountName, $mailConfig); + } + } + + /** + * Create a new Nextcloud mail account + */ + private function createAccount(string $userId, string $accountName, MailAccountConfig $mailConfig): Account { + $account = new MailAccount(); + $account->setUserId($userId); + $account->setName($accountName); + $account->setEmail($mailConfig->getEmail()); + $account->setAuthMethod('password'); + + $this->setAccountCredentials($account, $mailConfig); + + $account = $this->accountService->save($account); + + $this->logger->info('Created new Nextcloud mail account', [ + 'accountId' => $account->getId(), + 'emailAddress' => $account->getEmail(), + 'userId' => $userId, + ]); + + return new Account($account); + } + + /** + * Update an existing Nextcloud mail account + */ + private function updateAccount(MailAccount $account, string $accountName, MailAccountConfig $mailConfig): Account { + $account->setName($accountName); + $this->setAccountCredentials($account, $mailConfig); + + $account = $this->accountService->update($account); + + $this->logger->info('Updated existing Nextcloud mail account with new credentials', [ + 'accountId' => $account->getId(), + 'emailAddress' => $account->getEmail(), + 'userId' => $account->getUserId(), + ]); + + return new Account($account); + } + + /** + * Set IMAP and SMTP credentials on a mail account + */ + private function setAccountCredentials(MailAccount $account, MailAccountConfig $mailConfig): void { + $imap = $mailConfig->getImap(); + $account->setInboundHost($imap->getHost()); + $account->setInboundPort($imap->getPort()); + $account->setInboundSslMode($imap->getSecurity()); + $account->setInboundUser($imap->getUsername()); + $account->setInboundPassword($this->crypto->encrypt($imap->getPassword())); + + $smtp = $mailConfig->getSmtp(); + $account->setOutboundHost($smtp->getHost()); + $account->setOutboundPort($smtp->getPort()); + $account->setOutboundSslMode($smtp->getSecurity()); + $account->setOutboundUser($smtp->getUsername()); + $account->setOutboundPassword($this->crypto->encrypt($smtp->getPassword())); + } + + /** + * Build full email address from username + */ + private function buildEmailAddress(string $emailUser): string { + $domain = $this->ionosMailService->getMailDomain(); + return $emailUser . '@' . $domain; + } +} diff --git a/lib/Service/IONOS/IonosMailService.php b/lib/Service/IONOS/IonosMailService.php index f0e29f5719..84c9027271 100644 --- a/lib/Service/IONOS/IonosMailService.php +++ b/lib/Service/IONOS/IonosMailService.php @@ -30,6 +30,7 @@ class IonosMailService { private const BRAND = 'IONOS'; private const HTTP_NOT_FOUND = 404; + public const STATUS__409_CONFLICT = 409; private const HTTP_INTERNAL_SERVER_ERROR = 500; public function __construct( diff --git a/tests/Unit/Service/IONOS/IonosAccountCreationServiceTest.php b/tests/Unit/Service/IONOS/IonosAccountCreationServiceTest.php new file mode 100644 index 0000000000..3eb96d89c3 --- /dev/null +++ b/tests/Unit/Service/IONOS/IonosAccountCreationServiceTest.php @@ -0,0 +1,591 @@ +ionosMailService = $this->createMock(IonosMailService::class); + $this->conflictResolver = $this->createMock(IonosAccountConflictResolver::class); + $this->accountService = $this->createMock(AccountService::class); + $this->crypto = $this->createMock(ICrypto::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->service = new IonosAccountCreationService( + $this->ionosMailService, + $this->conflictResolver, + $this->accountService, + $this->crypto, + $this->logger, + ); + } + + public function testCreateOrUpdateAccountNewAccount(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $accountName = 'Test Account'; + $domain = 'example.com'; + $emailAddress = 'test@example.com'; + $password = 'test-password-123'; + + $mailConfig = $this->createMailAccountConfig($emailAddress, $password); + + $this->ionosMailService->method('getMailDomain') + ->willReturn($domain); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with($userId, $emailAddress) + ->willReturn([]); + + $this->ionosMailService->expects($this->once()) + ->method('createEmailAccountForUser') + ->with($userId, $emailUser) + ->willReturn($mailConfig); + + $this->crypto->expects($this->exactly(2)) + ->method('encrypt') + ->with($password) + ->willReturn('encrypted-' . $password); + + $savedAccount = new MailAccount(); + $savedAccount->setId(1); + $savedAccount->setUserId($userId); + $savedAccount->setEmail($emailAddress); + + $this->accountService->expects($this->once()) + ->method('save') + ->willReturnCallback(function (MailAccount $account) use ($savedAccount) { + $this->assertEquals('testuser', $account->getUserId()); + $this->assertEquals('Test Account', $account->getName()); + $this->assertEquals('test@example.com', $account->getEmail()); + $this->assertEquals('password', $account->getAuthMethod()); + return $savedAccount; + }); + + $this->logger->expects($this->exactly(3)) + ->method('info'); + + $result = $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + + $this->assertInstanceOf(Account::class, $result); + $this->assertEquals(1, $result->getId()); + } + + public function testCreateOrUpdateAccountExistingNextcloudAccountSuccess(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $accountName = 'Test Account'; + $domain = 'example.com'; + $emailAddress = 'test@example.com'; + $newPassword = 'new-password-456'; + + $existingAccount = new MailAccount(); + $existingAccount->setId(5); + $existingAccount->setUserId($userId); + $existingAccount->setEmail($emailAddress); + + $mailConfig = $this->createMailAccountConfig($emailAddress, $newPassword); + + $resolutionResult = ConflictResolutionResult::retry($mailConfig); + + $this->ionosMailService->method('getMailDomain') + ->willReturn($domain); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with($userId, $emailAddress) + ->willReturn([$this->createAccountWithMailAccount($existingAccount)]); + + $this->conflictResolver->expects($this->once()) + ->method('resolveConflict') + ->with($userId, $emailUser) + ->willReturn($resolutionResult); + + $this->crypto->expects($this->exactly(2)) + ->method('encrypt') + ->with($newPassword) + ->willReturn('encrypted-' . $newPassword); + + $this->accountService->expects($this->once()) + ->method('update') + ->with($existingAccount) + ->willReturn($existingAccount); + + $this->logger->expects($this->exactly(2)) + ->method('info'); + + $result = $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + + $this->assertInstanceOf(Account::class, $result); + $this->assertEquals(5, $result->getId()); + } + + public function testCreateOrUpdateAccountExistingAccountEmailMismatch(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $accountName = 'Test Account'; + $domain = 'example.com'; + $emailAddress = 'test@example.com'; + $existingEmail = 'different@example.com'; + + $existingAccount = new MailAccount(); + $existingAccount->setId(5); + $existingAccount->setUserId($userId); + $existingAccount->setEmail($emailAddress); + + $resolutionResult = ConflictResolutionResult::emailMismatch($emailAddress, $existingEmail); + + $this->ionosMailService->method('getMailDomain') + ->willReturn($domain); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with($userId, $emailAddress) + ->willReturn([$this->createAccountWithMailAccount($existingAccount)]); + + $this->conflictResolver->expects($this->once()) + ->method('resolveConflict') + ->with($userId, $emailUser) + ->willReturn($resolutionResult); + + $this->logger->expects($this->once()) + ->method('info') + ->with('Nextcloud mail account already exists, resetting credentials', $this->anything()); + + try { + $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + $this->fail('Expected IonosServiceException to be thrown'); + } catch (IonosServiceException $e) { + $this->assertEquals(409, $e->getCode()); + $this->assertStringContainsString('IONOS account exists but email mismatch', $e->getMessage()); + + $data = $e->getData(); + $this->assertArrayHasKey('expectedEmail', $data); + $this->assertArrayHasKey('existingEmail', $data); + $this->assertEquals($emailAddress, $data['expectedEmail']); + $this->assertEquals($existingEmail, $data['existingEmail']); + } + } + + public function testCreateOrUpdateAccountNewAccountWithConflictResolution(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $accountName = 'Test Account'; + $domain = 'example.com'; + $emailAddress = 'test@example.com'; + $password = 'reset-password-789'; + + $mailConfig = $this->createMailAccountConfig($emailAddress, $password); + + $resolutionResult = ConflictResolutionResult::retry($mailConfig); + + $this->ionosMailService->method('getMailDomain') + ->willReturn($domain); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with($userId, $emailAddress) + ->willReturn([]); + + // First attempt to create fails + $this->ionosMailService->expects($this->once()) + ->method('createEmailAccountForUser') + ->with($userId, $emailUser) + ->willThrowException(new ServiceException('Account already exists', 409)); + + // Conflict resolution succeeds + $this->conflictResolver->expects($this->once()) + ->method('resolveConflict') + ->with($userId, $emailUser) + ->willReturn($resolutionResult); + + $this->crypto->expects($this->exactly(2)) + ->method('encrypt') + ->with($password) + ->willReturn('encrypted-' . $password); + + $savedAccount = new MailAccount(); + $savedAccount->setId(2); + $savedAccount->setUserId($userId); + $savedAccount->setEmail($emailAddress); + + $this->accountService->expects($this->once()) + ->method('save') + ->willReturn($savedAccount); + + $this->logger->expects($this->exactly(3)) + ->method('info'); + + $result = $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + + $this->assertInstanceOf(Account::class, $result); + $this->assertEquals(2, $result->getId()); + } + + public function testCreateOrUpdateAccountSetsCorrectCredentials(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $accountName = 'Test Account'; + $domain = 'example.com'; + $emailAddress = 'test@example.com'; + $password = 'secret-password'; + + $mailConfig = $this->createMailAccountConfig($emailAddress, $password); + + $this->ionosMailService->method('getMailDomain') + ->willReturn($domain); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with($userId, $emailAddress) + ->willReturn([]); + + $this->ionosMailService->expects($this->once()) + ->method('createEmailAccountForUser') + ->with($userId, $emailUser) + ->willReturn($mailConfig); + + $this->crypto->expects($this->exactly(2)) + ->method('encrypt') + ->with($password) + ->willReturn('encrypted-' . $password); + + $savedAccount = new MailAccount(); + $savedAccount->setId(10); + + $this->accountService->expects($this->once()) + ->method('save') + ->willReturnCallback(function (MailAccount $account) use ($savedAccount, $emailAddress) { + // Verify IMAP settings + $this->assertEquals('imap.example.com', $account->getInboundHost()); + $this->assertEquals(993, $account->getInboundPort()); + $this->assertEquals('ssl', $account->getInboundSslMode()); + $this->assertEquals($emailAddress, $account->getInboundUser()); + $this->assertEquals('encrypted-secret-password', $account->getInboundPassword()); + + // Verify SMTP settings + $this->assertEquals('smtp.example.com', $account->getOutboundHost()); + $this->assertEquals(465, $account->getOutboundPort()); + $this->assertEquals('ssl', $account->getOutboundSslMode()); + $this->assertEquals($emailAddress, $account->getOutboundUser()); + $this->assertEquals('encrypted-secret-password', $account->getOutboundPassword()); + + return $savedAccount; + }); + + $result = $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + + $this->assertInstanceOf(Account::class, $result); + } + + private function createMailAccountConfig(string $emailAddress, string $password): MailAccountConfig { + $imapConfig = new MailServerConfig( + host: 'imap.example.com', + port: 993, + security: 'ssl', + username: $emailAddress, + password: $password, + ); + + $smtpConfig = new MailServerConfig( + host: 'smtp.example.com', + port: 465, + security: 'ssl', + username: $emailAddress, + password: $password, + ); + + return new MailAccountConfig( + email: $emailAddress, + imap: $imapConfig, + smtp: $smtpConfig, + ); + } + + /** + * Helper to create an account object with a MailAccount + * This simulates the structure returned by AccountService::findByUserIdAndAddress + */ + public function testCreateOrUpdateAccountExistingAccountNoIonosAccount(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $accountName = 'Test Account'; + $domain = 'example.com'; + $emailAddress = 'test@example.com'; + + $existingAccount = new MailAccount(); + $existingAccount->setId(5); + $existingAccount->setUserId($userId); + $existingAccount->setEmail($emailAddress); + + $resolutionResult = ConflictResolutionResult::noExistingAccount(); + + $this->ionosMailService->method('getMailDomain') + ->willReturn($domain); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with($userId, $emailAddress) + ->willReturn([$this->createAccountWithMailAccount($existingAccount)]); + + $this->conflictResolver->expects($this->once()) + ->method('resolveConflict') + ->with($userId, $emailUser) + ->willReturn($resolutionResult); + + $this->expectException(ServiceException::class); + $this->expectExceptionCode(500); + $this->expectExceptionMessage('Nextcloud account exists but no IONOS account found'); + + $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + } + + public function testCreateOrUpdateAccountExistingAccountConflictResolverThrows(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $accountName = 'Test Account'; + $domain = 'example.com'; + $emailAddress = 'test@example.com'; + + $existingAccount = new MailAccount(); + $existingAccount->setId(5); + $existingAccount->setUserId($userId); + $existingAccount->setEmail($emailAddress); + + $this->ionosMailService->method('getMailDomain') + ->willReturn($domain); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with($userId, $emailAddress) + ->willReturn([$this->createAccountWithMailAccount($existingAccount)]); + + $originalException = new ServiceException('IONOS API error', 503); + + $this->conflictResolver->expects($this->once()) + ->method('resolveConflict') + ->with($userId, $emailUser) + ->willThrowException($originalException); + + $this->expectException(ServiceException::class); + $this->expectExceptionCode(503); + $this->expectExceptionMessage('Failed to reset IONOS account credentials: IONOS API error'); + + $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + } + + public function testCreateOrUpdateAccountNewAccountConflictResolutionFails(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $accountName = 'Test Account'; + $domain = 'example.com'; + $emailAddress = 'test@example.com'; + + $this->ionosMailService->method('getMailDomain') + ->willReturn($domain); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with($userId, $emailAddress) + ->willReturn([]); + + $originalException = new ServiceException('Account creation failed', 500); + + $this->ionosMailService->expects($this->once()) + ->method('createEmailAccountForUser') + ->with($userId, $emailUser) + ->willThrowException($originalException); + + $resolutionResult = ConflictResolutionResult::noExistingAccount(); + + $this->conflictResolver->expects($this->once()) + ->method('resolveConflict') + ->with($userId, $emailUser) + ->willReturn($resolutionResult); + + $this->logger->expects($this->exactly(2)) + ->method('info'); + + $this->expectException(ServiceException::class); + $this->expectExceptionCode(500); + $this->expectExceptionMessage('Account creation failed'); + + $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + } + + public function testCreateOrUpdateAccountNewAccountConflictResolutionEmailMismatch(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $accountName = 'Test Account'; + $domain = 'example.com'; + $emailAddress = 'test@example.com'; + $existingEmail = 'other@example.com'; + + $this->ionosMailService->method('getMailDomain') + ->willReturn($domain); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with($userId, $emailAddress) + ->willReturn([]); + + $originalException = new ServiceException('Account already exists', 409); + + $this->ionosMailService->expects($this->once()) + ->method('createEmailAccountForUser') + ->with($userId, $emailUser) + ->willThrowException($originalException); + + $resolutionResult = ConflictResolutionResult::emailMismatch($emailAddress, $existingEmail); + + $this->conflictResolver->expects($this->once()) + ->method('resolveConflict') + ->with($userId, $emailUser) + ->willReturn($resolutionResult); + + $this->logger->expects($this->exactly(2)) + ->method('info'); + + try { + $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + $this->fail('Expected IonosServiceException to be thrown'); + } catch (IonosServiceException $e) { + $this->assertEquals(409, $e->getCode()); + $this->assertStringContainsString('IONOS account exists but email mismatch', $e->getMessage()); + $this->assertStringContainsString($emailAddress, $e->getMessage()); + $this->assertStringContainsString($existingEmail, $e->getMessage()); + + $data = $e->getData(); + $this->assertArrayHasKey('expectedEmail', $data); + $this->assertArrayHasKey('existingEmail', $data); + $this->assertEquals($emailAddress, $data['expectedEmail']); + $this->assertEquals($existingEmail, $data['existingEmail']); + + // Verify the previous exception is set + $this->assertSame($originalException, $e->getPrevious()); + } + } + + public function testUpdateAccountSetsCorrectCredentials(): void { + $userId = 'testuser'; + $emailUser = 'test'; + $accountName = 'Updated Account Name'; + $domain = 'example.com'; + $emailAddress = 'test@example.com'; + $newPassword = 'new-password-xyz'; + + $existingAccount = new MailAccount(); + $existingAccount->setId(7); + $existingAccount->setUserId($userId); + $existingAccount->setEmail($emailAddress); + $existingAccount->setName('Old Account Name'); + + $mailConfig = $this->createMailAccountConfig($emailAddress, $newPassword); + $resolutionResult = ConflictResolutionResult::retry($mailConfig); + + $this->ionosMailService->method('getMailDomain') + ->willReturn($domain); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with($userId, $emailAddress) + ->willReturn([$this->createAccountWithMailAccount($existingAccount)]); + + $this->conflictResolver->expects($this->once()) + ->method('resolveConflict') + ->with($userId, $emailUser) + ->willReturn($resolutionResult); + + $this->crypto->expects($this->exactly(2)) + ->method('encrypt') + ->with($newPassword) + ->willReturn('encrypted-' . $newPassword); + + $this->accountService->expects($this->once()) + ->method('update') + ->willReturnCallback(function (MailAccount $account) use ($existingAccount, $emailAddress, $accountName) { + // Verify account name is updated + $this->assertEquals($accountName, $account->getName()); + + // Verify IMAP settings + $this->assertEquals('imap.example.com', $account->getInboundHost()); + $this->assertEquals(993, $account->getInboundPort()); + $this->assertEquals('ssl', $account->getInboundSslMode()); + $this->assertEquals($emailAddress, $account->getInboundUser()); + $this->assertEquals('encrypted-new-password-xyz', $account->getInboundPassword()); + + // Verify SMTP settings + $this->assertEquals('smtp.example.com', $account->getOutboundHost()); + $this->assertEquals(465, $account->getOutboundPort()); + $this->assertEquals('ssl', $account->getOutboundSslMode()); + $this->assertEquals($emailAddress, $account->getOutboundUser()); + $this->assertEquals('encrypted-new-password-xyz', $account->getOutboundPassword()); + + return $existingAccount; + }); + + $result = $this->service->createOrUpdateAccount($userId, $emailUser, $accountName); + + $this->assertInstanceOf(Account::class, $result); + $this->assertEquals(7, $result->getId()); + } + + private function createAccountWithMailAccount(MailAccount $mailAccount): object { + return new class($mailAccount) { + public function __construct( + private MailAccount $mailAccount, + ) { + } + + public function getId(): int { + return $this->mailAccount->getId(); + } + + public function getEmail(): string { + return $this->mailAccount->getEmail(); + } + + public function getMailAccount(): MailAccount { + return $this->mailAccount; + } + }; + } +} From af100366189cc8e286d1da5b75f90949273d5bce Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Tue, 16 Dec 2025 15:08:05 +0100 Subject: [PATCH 15/24] IONOS(ionos-mail): refactor(service): enhance IonosMailConfigService availability check Update isMailConfigAvailable to check for local account configuration: - Add AccountService and IUserSession dependencies - Check if user has remote IONOS account - Check if remote account is configured locally in mail app - Show configuration if remote exists but local doesn't (retry scenario) This allows users to complete account setup if the initial Nextcloud account creation failed but the IONOS account was created. Signed-off-by: Misha M.-Kupriyanov --- lib/Service/IONOS/IonosMailConfigService.php | 46 +++++- .../IONOS/IonosMailConfigServiceTest.php | 143 ++++++++++++++++-- 2 files changed, 175 insertions(+), 14 deletions(-) diff --git a/lib/Service/IONOS/IonosMailConfigService.php b/lib/Service/IONOS/IonosMailConfigService.php index 77e9b8126e..9e4b7a6706 100644 --- a/lib/Service/IONOS/IonosMailConfigService.php +++ b/lib/Service/IONOS/IonosMailConfigService.php @@ -9,6 +9,8 @@ namespace OCA\Mail\Service\IONOS; +use OCA\Mail\Service\AccountService; +use OCP\IUserSession; use Psr\Log\LoggerInterface; /** @@ -18,6 +20,8 @@ class IonosMailConfigService { public function __construct( private IonosConfigService $ionosConfigService, private IonosMailService $ionosMailService, + private AccountService $accountService, + private IUserSession $userSession, private LoggerInterface $logger, ) { } @@ -27,7 +31,8 @@ public function __construct( * * The configuration is available only if: * 1. The IONOS integration is enabled and properly configured - * 2. The user does NOT already have an IONOS mail account + * 2. The user does NOT already have an IONOS mail account configured remotely + * 3. OR the user has a remote IONOS account but it's NOT configured locally in the mail app * * @return bool True if mail configuration should be shown, false otherwise */ @@ -38,14 +43,45 @@ public function isMailConfigAvailable(): bool { return false; } - // Check if user already has an account - $userHasAccount = $this->ionosMailService->mailAccountExistsForCurrentUser(); + // Get current user + $user = $this->userSession->getUser(); + if ($user === null) { + $this->logger->debug('IONOS mail config not available - no user session'); + return false; + } + $userId = $user->getUID(); + + // Check if user already has a remote IONOS account + $userHasRemoteAccount = $this->ionosMailService->mailAccountExistsForCurrentUser(); + + if (!$userHasRemoteAccount) { + // No remote account exists, configuration should be available + return true; + } - if ($userHasAccount) { - $this->logger->debug('IONOS mail config not available - user already has an account'); + // User has a remote account, check if it's configured locally + $ionosEmail = $this->ionosMailService->getIonosEmailForUser($userId); + if ($ionosEmail === null) { + // This shouldn't happen if userHasRemoteAccount is true, but handle it gracefully + $this->logger->warning('IONOS remote account exists but email could not be retrieved'); return false; } + // Check if the IONOS email is configured in the local mail app + $localAccounts = $this->accountService->findByUserIdAndAddress($userId, $ionosEmail); + $hasLocalAccount = count($localAccounts) > 0; + + if ($hasLocalAccount) { + $this->logger->debug('IONOS mail config not available - user already has account configured locally', [ + 'email' => $ionosEmail, + ]); + return false; + } + + // Remote account exists but not configured locally - show configuration + $this->logger->debug('IONOS mail config available - remote account exists but not configured locally', [ + 'email' => $ionosEmail, + ]); return true; } catch (\Exception $e) { $this->logger->error('Error checking IONOS mail config availability', [ diff --git a/tests/Unit/Service/IONOS/IonosMailConfigServiceTest.php b/tests/Unit/Service/IONOS/IonosMailConfigServiceTest.php index a05bdd9e82..e1fdcf980b 100644 --- a/tests/Unit/Service/IONOS/IonosMailConfigServiceTest.php +++ b/tests/Unit/Service/IONOS/IonosMailConfigServiceTest.php @@ -10,15 +10,20 @@ namespace OCA\Mail\Tests\Unit\Service\IONOS; use ChristophWurst\Nextcloud\Testing\TestCase; +use OCA\Mail\Service\AccountService; use OCA\Mail\Service\IONOS\IonosConfigService; use OCA\Mail\Service\IONOS\IonosMailConfigService; use OCA\Mail\Service\IONOS\IonosMailService; +use OCP\IUser; +use OCP\IUserSession; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; class IonosMailConfigServiceTest extends TestCase { private IonosConfigService&MockObject $ionosConfigService; private IonosMailService&MockObject $ionosMailService; + private AccountService&MockObject $accountService; + private IUserSession&MockObject $userSession; private LoggerInterface&MockObject $logger; private IonosMailConfigService $service; @@ -27,11 +32,15 @@ protected function setUp(): void { $this->ionosConfigService = $this->createMock(IonosConfigService::class); $this->ionosMailService = $this->createMock(IonosMailService::class); + $this->accountService = $this->createMock(AccountService::class); + $this->userSession = $this->createMock(IUserSession::class); $this->logger = $this->createMock(LoggerInterface::class); $this->service = new IonosMailConfigService( $this->ionosConfigService, $this->ionosMailService, + $this->accountService, + $this->userSession, $this->logger, ); } @@ -41,58 +50,174 @@ public function testIsMailConfigAvailableReturnsFalseWhenFeatureDisabled(): void ->method('isIonosIntegrationEnabled') ->willReturn(false); - $this->ionosMailService->expects($this->never()) - ->method('mailAccountExistsForCurrentUser'); + $this->userSession->expects($this->never()) + ->method('getUser'); $result = $this->service->isMailConfigAvailable(); $this->assertFalse($result); } - public function testIsMailConfigAvailableReturnsTrueWhenUserHasNoAccount(): void { + public function testIsMailConfigAvailableReturnsFalseWhenNoUserSession(): void { $this->ionosConfigService->expects($this->once()) ->method('isIonosIntegrationEnabled') ->willReturn(true); + $this->userSession->expects($this->once()) + ->method('getUser') + ->willReturn(null); + + $this->logger->expects($this->once()) + ->method('debug') + ->with('IONOS mail config not available - no user session'); + + $result = $this->service->isMailConfigAvailable(); + + $this->assertFalse($result); + } + + public function testIsMailConfigAvailableReturnsTrueWhenUserHasNoRemoteAccount(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->ionosConfigService->expects($this->once()) + ->method('isIonosIntegrationEnabled') + ->willReturn(true); + + $this->userSession->expects($this->once()) + ->method('getUser') + ->willReturn($user); + $this->ionosMailService->expects($this->once()) ->method('mailAccountExistsForCurrentUser') ->willReturn(false); - $this->logger->expects($this->never()) - ->method('debug'); + $this->accountService->expects($this->never()) + ->method('findByUserIdAndAddress'); $result = $this->service->isMailConfigAvailable(); $this->assertTrue($result); } - public function testIsMailConfigAvailableReturnsFalseWhenUserHasAccount(): void { + public function testIsMailConfigAvailableReturnsFalseWhenUserHasRemoteAndLocalAccount(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + $this->ionosConfigService->expects($this->once()) ->method('isIonosIntegrationEnabled') ->willReturn(true); + $this->userSession->expects($this->once()) + ->method('getUser') + ->willReturn($user); + $this->ionosMailService->expects($this->once()) ->method('mailAccountExistsForCurrentUser') ->willReturn(true); + $this->ionosMailService->expects($this->once()) + ->method('getIonosEmailForUser') + ->with('testuser') + ->willReturn('testuser@ionos.com'); + + // Return a non-empty array to simulate that a local account exists + $mockAccount = $this->createMock(\OCA\Mail\Account::class); + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with('testuser', 'testuser@ionos.com') + ->willReturn([$mockAccount]); + $this->logger->expects($this->once()) ->method('debug') - ->with('IONOS mail config not available - user already has an account'); + ->with('IONOS mail config not available - user already has account configured locally', [ + 'email' => 'testuser@ionos.com', + ]); $result = $this->service->isMailConfigAvailable(); $this->assertFalse($result); } - public function testIsMailConfigAvailableReturnsFalseOnException(): void { + public function testIsMailConfigAvailableReturnsTrueWhenUserHasRemoteAccountButNotLocal(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + $this->ionosConfigService->expects($this->once()) ->method('isIonosIntegrationEnabled') ->willReturn(true); - $exception = new \Exception('Test exception'); + $this->userSession->expects($this->once()) + ->method('getUser') + ->willReturn($user); $this->ionosMailService->expects($this->once()) ->method('mailAccountExistsForCurrentUser') + ->willReturn(true); + + $this->ionosMailService->expects($this->once()) + ->method('getIonosEmailForUser') + ->with('testuser') + ->willReturn('testuser@ionos.com'); + + $this->accountService->expects($this->once()) + ->method('findByUserIdAndAddress') + ->with('testuser', 'testuser@ionos.com') + ->willReturn([]); + + $this->logger->expects($this->once()) + ->method('debug') + ->with('IONOS mail config available - remote account exists but not configured locally', [ + 'email' => 'testuser@ionos.com', + ]); + + $result = $this->service->isMailConfigAvailable(); + + $this->assertTrue($result); + } + + public function testIsMailConfigAvailableReturnsFalseWhenEmailCannotBeRetrieved(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->ionosConfigService->expects($this->once()) + ->method('isIonosIntegrationEnabled') + ->willReturn(true); + + $this->userSession->expects($this->once()) + ->method('getUser') + ->willReturn($user); + + $this->ionosMailService->expects($this->once()) + ->method('mailAccountExistsForCurrentUser') + ->willReturn(true); + + $this->ionosMailService->expects($this->once()) + ->method('getIonosEmailForUser') + ->with('testuser') + ->willReturn(null); + + $this->logger->expects($this->once()) + ->method('warning') + ->with('IONOS remote account exists but email could not be retrieved'); + + $this->accountService->expects($this->never()) + ->method('findByUserIdAndAddress'); + + $result = $this->service->isMailConfigAvailable(); + + $this->assertFalse($result); + } + + public function testIsMailConfigAvailableReturnsFalseOnException(): void { + $this->ionosConfigService->expects($this->once()) + ->method('isIonosIntegrationEnabled') + ->willReturn(true); + + $exception = new \Exception('Test exception'); + + $this->userSession->expects($this->once()) + ->method('getUser') ->willThrowException($exception); $this->logger->expects($this->once()) From 9f1ede6872ff9b1a110bdc3b1af72e5cd690c01d Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Wed, 17 Dec 2025 10:01:12 +0100 Subject: [PATCH 16/24] IONOS(ionos-mail): refactor(controller): improve error handling and logging in IonosAccountsController Enhanced error handling by introducing a dedicated method for building service error responses. Improved logging messages for better clarity during account creation process. Signed-off-by: Misha M.-Kupriyanov --- lib/Controller/IonosAccountsController.php | 43 ++++++++++++---- .../IonosAccountsControllerTest.php | 50 +++++++++++++++++-- 2 files changed, 80 insertions(+), 13 deletions(-) diff --git a/lib/Controller/IonosAccountsController.php b/lib/Controller/IonosAccountsController.php index 60b647296e..5bf37185f1 100644 --- a/lib/Controller/IonosAccountsController.php +++ b/lib/Controller/IonosAccountsController.php @@ -54,20 +54,30 @@ public function create(string $accountName, string $emailUser): JSONResponse { } try { - $this->logger->info('Starting IONOS email account creation', [ 'emailAddress' => $emailUser, 'accountName' => $accountName ]); + $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); + $this->logger->info('IONOS email account created successfully', [ + 'emailAddress' => $ionosResponse->getEmail(), + ]); + + $response = $this->createNextcloudMailAccount($accountName, $ionosResponse); - return MailJsonResponse::fail($data); + $this->logger->info('Account creation completed successfully', [ + 'emailAddress' => $emailUser, + 'accountName' => $accountName, + ]); + + return $response; + } catch (ServiceException $e) { + return $this->buildServiceErrorResponse($e, 'account creation'); } catch (\Exception $e) { + $this->logger->error('Unexpected error during account creation: ' . $e->getMessage(), [ + 'exception' => $e, + ]); return MailJsonResponse::error('Could not create account'); } } @@ -91,4 +101,17 @@ private function createNextcloudMailAccount(string $accountName, MailAccountConf $smtp->getPassword(), ); } + + /** + * Build service error response + */ + private function buildServiceErrorResponse(ServiceException $e, string $context): JSONResponse { + $data = [ + 'error' => self::ERR_IONOS_API_ERROR, + 'statusCode' => $e->getCode(), + 'message' => $e->getMessage(), + ]; + $this->logger->error('IONOS service error during ' . $context . ': ' . $e->getMessage(), $data); + return MailJsonResponse::fail($data); + } } diff --git a/tests/Unit/Controller/IonosAccountsControllerTest.php b/tests/Unit/Controller/IonosAccountsControllerTest.php index ad43b0085d..8584313a62 100644 --- a/tests/Unit/Controller/IonosAccountsControllerTest.php +++ b/tests/Unit/Controller/IonosAccountsControllerTest.php @@ -104,6 +104,34 @@ public function testCreateSuccess(): void { ->with($emailUser) ->willReturn($mailAccountConfig); + // Verify logging calls + $this->logger + ->expects($this->exactly(3)) + ->method('info') + ->willReturnCallback(function ($message, $context) use ($emailUser, $accountName, $emailAddress) { + static $callCount = 0; + $callCount++; + + if ($callCount === 1) { + $this->assertEquals('Starting IONOS email account creation', $message); + $this->assertEquals([ + 'emailAddress' => $emailUser, + 'accountName' => $accountName, + ], $context); + } elseif ($callCount === 2) { + $this->assertEquals('IONOS email account created successfully', $message); + $this->assertEquals([ + 'emailAddress' => $emailAddress, + ], $context); + } elseif ($callCount === 3) { + $this->assertEquals('Account creation completed successfully', $message); + $this->assertEquals([ + 'emailAddress' => $emailUser, + 'accountName' => $accountName, + ], $context); + } + }); + // Mock account creation response $accountData = ['id' => 1, 'emailAddress' => $emailAddress]; $accountResponse = $this->createMock(JSONResponse::class); @@ -146,16 +174,18 @@ public function testCreateWithServiceException(): void { ->expects($this->once()) ->method('error') ->with( - 'IONOS service error: Failed to create email account', + 'IONOS service error during account creation: Failed to create email account', [ 'error' => 'IONOS_API_ERROR', 'statusCode' => 0, + 'message' => 'Failed to create email account', ] ); $expectedResponse = \OCA\Mail\Http\JsonResponse::fail([ 'error' => 'IONOS_API_ERROR', 'statusCode' => 0, + 'message' => 'Failed to create email account', ]); $response = $this->controller->create($accountName, $emailUser); @@ -175,16 +205,18 @@ public function testCreateWithServiceExceptionWithStatusCode(): void { ->expects($this->once()) ->method('error') ->with( - 'IONOS service error: Duplicate email account', + 'IONOS service error during account creation: Duplicate email account', [ 'error' => 'IONOS_API_ERROR', 'statusCode' => 409, + 'message' => 'Duplicate email account', ] ); $expectedResponse = \OCA\Mail\Http\JsonResponse::fail([ 'error' => 'IONOS_API_ERROR', 'statusCode' => 409, + 'message' => 'Duplicate email account', ]); $response = $this->controller->create($accountName, $emailUser); @@ -196,9 +228,21 @@ public function testCreateWithGenericException(): void { $emailUser = 'test'; // Mock IONOS mail service to throw a generic exception + $exception = new \Exception('Generic error'); $this->ionosMailService->method('createEmailAccount') ->with($emailUser) - ->willThrowException(new \Exception('Generic error')); + ->willThrowException($exception); + + // Verify error logging for unexpected exceptions + $this->logger + ->expects($this->once()) + ->method('error') + ->with( + 'Unexpected error during account creation: Generic error', + [ + 'exception' => $exception, + ] + ); $expectedResponse = \OCA\Mail\Http\JsonResponse::error('Could not create account', 500, From da32d37bec3c3b4d031d19e11d49872f48aa4a45 Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Wed, 17 Dec 2025 11:33:59 +0100 Subject: [PATCH 17/24] IONOS(ionos-mail): feat(controller): add user session handling for account creation Enhances the IonosAccountsController by integrating user session management, allowing for user identification during email account creation. This improves error handling by ensuring that a valid user session is present, providing more informative logging and responses in case of session absence. Signed-off-by: Misha M.-Kupriyanov --- lib/Controller/IonosAccountsController.php | 21 +++++- .../IonosAccountsControllerTest.php | 66 ++++++++++++++++++- 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/lib/Controller/IonosAccountsController.php b/lib/Controller/IonosAccountsController.php index 5bf37185f1..91e620a8db 100644 --- a/lib/Controller/IonosAccountsController.php +++ b/lib/Controller/IonosAccountsController.php @@ -17,6 +17,7 @@ use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\JSONResponse; use OCP\IRequest; +use OCP\IUserSession; use Psr\Log\LoggerInterface; #[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] @@ -31,6 +32,7 @@ public function __construct( IRequest $request, private IonosMailService $ionosMailService, private AccountsController $accountsController, + private IUserSession $userSession, private LoggerInterface $logger, ) { parent::__construct($appName, $request); @@ -54,7 +56,10 @@ public function create(string $accountName, string $emailUser): JSONResponse { } try { - $this->logger->info('Starting IONOS email account creation', [ + $userId = $this->getUserIdOrFail(); + + $this->logger->info('Starting IONOS email account creation from web', [ + 'userId' => $userId, 'emailAddress' => $emailUser, 'accountName' => $accountName, ]); @@ -102,6 +107,20 @@ private function createNextcloudMailAccount(string $accountName, MailAccountConf ); } + /** + * Get the current user ID + * + * @return string User ID string + * @throws ServiceException + */ + private function getUserIdOrFail(): string { + $user = $this->userSession->getUser(); + if ($user === null) { + throw new ServiceException('No user session found during account creation', 401); + } + return $user->getUID(); + } + /** * Build service error response */ diff --git a/tests/Unit/Controller/IonosAccountsControllerTest.php b/tests/Unit/Controller/IonosAccountsControllerTest.php index 8584313a62..4f0655b43b 100644 --- a/tests/Unit/Controller/IonosAccountsControllerTest.php +++ b/tests/Unit/Controller/IonosAccountsControllerTest.php @@ -18,6 +18,8 @@ use OCA\Mail\Service\IONOS\IonosMailService; use OCP\AppFramework\Http\JSONResponse; use OCP\IRequest; +use OCP\IUser; +use OCP\IUserSession; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; use ReflectionClass; @@ -31,6 +33,8 @@ class IonosAccountsControllerTest extends TestCase { private AccountsController&MockObject $accountsController; + private IUserSession&MockObject $userSession; + private LoggerInterface|MockObject $logger; private IonosAccountsController $controller; @@ -42,6 +46,7 @@ protected function setUp(): void { $this->request = $this->createMock(IRequest::class); $this->ionosMailService = $this->createMock(IonosMailService::class); $this->accountsController = $this->createMock(AccountsController::class); + $this->userSession = $this->createMock(IUserSession::class); $this->logger = $this->createMock(LoggerInterface::class); $this->controller = new IonosAccountsController( @@ -49,10 +54,20 @@ protected function setUp(): void { $this->request, $this->ionosMailService, $this->accountsController, + $this->userSession, $this->logger, ); } + /** + * Helper method to setup user session mock + */ + private function setupUserSession(string $userId): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn($userId); + $this->userSession->method('getUser')->willReturn($user); + } + public function testCreateWithMissingFields(): void { // Test with empty account name $response = $this->controller->create('', 'testuser'); @@ -75,6 +90,10 @@ public function testCreateSuccess(): void { $accountName = 'Test Account'; $emailUser = 'test'; $emailAddress = 'test@example.com'; + $userId = 'test-user-123'; + + // Setup user session + $this->setupUserSession($userId); // Create MailAccountConfig DTO $imapConfig = new MailServerConfig( @@ -108,13 +127,14 @@ public function testCreateSuccess(): void { $this->logger ->expects($this->exactly(3)) ->method('info') - ->willReturnCallback(function ($message, $context) use ($emailUser, $accountName, $emailAddress) { + ->willReturnCallback(function ($message, $context) use ($emailUser, $accountName, $emailAddress, $userId) { static $callCount = 0; $callCount++; if ($callCount === 1) { - $this->assertEquals('Starting IONOS email account creation', $message); + $this->assertEquals('Starting IONOS email account creation from web', $message); $this->assertEquals([ + 'userId' => $userId, 'emailAddress' => $emailUser, 'accountName' => $accountName, ], $context); @@ -164,6 +184,10 @@ public function testCreateSuccess(): void { public function testCreateWithServiceException(): void { $accountName = 'Test Account'; $emailUser = 'test'; + $userId = 'test-user-123'; + + // Setup user session + $this->setupUserSession($userId); // Mock IONOS mail service to throw ServiceException $this->ionosMailService->method('createEmailAccount') @@ -195,6 +219,10 @@ public function testCreateWithServiceException(): void { public function testCreateWithServiceExceptionWithStatusCode(): void { $accountName = 'Test Account'; $emailUser = 'test'; + $userId = 'test-user-123'; + + // Setup user session + $this->setupUserSession($userId); // Mock IONOS mail service to throw ServiceException with HTTP 409 (Duplicate) $this->ionosMailService->method('createEmailAccount') @@ -226,6 +254,10 @@ public function testCreateWithServiceExceptionWithStatusCode(): void { public function testCreateWithGenericException(): void { $accountName = 'Test Account'; $emailUser = 'test'; + $userId = 'test-user-123'; + + // Setup user session + $this->setupUserSession($userId); // Mock IONOS mail service to throw a generic exception $exception = new \Exception('Generic error'); @@ -254,6 +286,36 @@ public function testCreateWithGenericException(): void { self::assertEquals($expectedResponse, $response); } + public function testCreateWithNoUserSession(): void { + $accountName = 'Test Account'; + $emailUser = 'test'; + + // Mock user session to return null (no user logged in) + $this->userSession->method('getUser')->willReturn(null); + + // Should catch the ServiceException thrown by getUserIdOrFail + $this->logger + ->expects($this->once()) + ->method('error') + ->with( + 'IONOS service error during account creation: No user session found during account creation', + [ + 'error' => 'IONOS_API_ERROR', + 'statusCode' => 401, + 'message' => 'No user session found during account creation', + ] + ); + + $expectedResponse = \OCA\Mail\Http\JsonResponse::fail([ + 'error' => 'IONOS_API_ERROR', + 'statusCode' => 401, + 'message' => 'No user session found during account creation', + ]); + $response = $this->controller->create($accountName, $emailUser); + + self::assertEquals($expectedResponse, $response); + } + public function testCreateNextcloudMailAccount(): void { $accountName = 'Test Account'; From efdbb6c538b81e1420eb7df36289fb347f5c4d7b Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Tue, 16 Dec 2025 15:09:56 +0100 Subject: [PATCH 18/24] IONOS(ionos-mail): refactor(controller): use IonosAccountCreationService in IonosAccountsController Refactor IonosAccountsController to use the shared IonosAccountCreationService: - Replace IonosMailService and AccountsController with IonosAccountCreationService - Add IUserSession for user ID retrieval - Simplify create method by delegating to IonosAccountCreationService - Add error message to service exception responses This ensures consistent account creation logic between web and CLI interfaces. Signed-off-by: Misha M.-Kupriyanov --- lib/Controller/IonosAccountsController.php | 47 ++-- .../IonosAccountsControllerTest.php | 222 +++++++----------- 2 files changed, 102 insertions(+), 167 deletions(-) diff --git a/lib/Controller/IonosAccountsController.php b/lib/Controller/IonosAccountsController.php index 91e620a8db..761d6fd5c0 100644 --- a/lib/Controller/IonosAccountsController.php +++ b/lib/Controller/IonosAccountsController.php @@ -8,12 +8,13 @@ */ namespace OCA\Mail\Controller; +use OCA\Mail\Exception\IonosServiceException; use OCA\Mail\Exception\ServiceException; use OCA\Mail\Http\JsonResponse as MailJsonResponse; use OCA\Mail\Http\TrapError; -use OCA\Mail\Service\IONOS\Dto\MailAccountConfig; -use OCA\Mail\Service\IONOS\IonosMailService; +use OCA\Mail\Service\IONOS\IonosAccountCreationService; use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\OpenAPI; use OCP\AppFramework\Http\JSONResponse; use OCP\IRequest; @@ -30,8 +31,7 @@ class IonosAccountsController extends Controller { public function __construct( string $appName, IRequest $request, - private IonosMailService $ionosMailService, - private AccountsController $accountsController, + private IonosAccountCreationService $accountCreationService, private IUserSession $userSession, private LoggerInterface $logger, ) { @@ -63,20 +63,17 @@ public function create(string $accountName, string $emailUser): JSONResponse { 'emailAddress' => $emailUser, 'accountName' => $accountName, ]); - $ionosResponse = $this->ionosMailService->createEmailAccount($emailUser); - $this->logger->info('IONOS email account created successfully', [ - 'emailAddress' => $ionosResponse->getEmail(), - ]); - - $response = $this->createNextcloudMailAccount($accountName, $ionosResponse); + $account = $this->accountCreationService->createOrUpdateAccount($userId, $emailUser, $accountName); $this->logger->info('Account creation completed successfully', [ - 'emailAddress' => $emailUser, + 'emailAddress' => $account->getEmail(), 'accountName' => $accountName, + 'accountId' => $account->getId(), + 'userId' => $userId, ]); - return $response; + return MailJsonResponse::success($account, Http::STATUS_CREATED); } catch (ServiceException $e) { return $this->buildServiceErrorResponse($e, 'account creation'); } catch (\Exception $e) { @@ -87,26 +84,6 @@ public function create(string $accountName, string $emailUser): JSONResponse { } } - 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(), - ); - } - /** * Get the current user ID * @@ -130,6 +107,12 @@ private function buildServiceErrorResponse(ServiceException $e, string $context) 'statusCode' => $e->getCode(), 'message' => $e->getMessage(), ]; + + // If it's an IonosServiceException, merge in the additional data + if ($e instanceof IonosServiceException) { + $data = array_merge($data, $e->getData()); + } + $this->logger->error('IONOS service error during ' . $context . ': ' . $e->getMessage(), $data); return MailJsonResponse::fail($data); } diff --git a/tests/Unit/Controller/IonosAccountsControllerTest.php b/tests/Unit/Controller/IonosAccountsControllerTest.php index 4f0655b43b..3a0186c66b 100644 --- a/tests/Unit/Controller/IonosAccountsControllerTest.php +++ b/tests/Unit/Controller/IonosAccountsControllerTest.php @@ -10,28 +10,24 @@ namespace OCA\Mail\Tests\Unit\Controller; use ChristophWurst\Nextcloud\Testing\TestCase; -use OCA\Mail\Controller\AccountsController; +use OCA\Mail\Account; use OCA\Mail\Controller\IonosAccountsController; +use OCA\Mail\Db\MailAccount; +use OCA\Mail\Exception\IonosServiceException; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\Service\IONOS\Dto\MailAccountConfig; -use OCA\Mail\Service\IONOS\Dto\MailServerConfig; -use OCA\Mail\Service\IONOS\IonosMailService; -use OCP\AppFramework\Http\JSONResponse; +use OCA\Mail\Service\IONOS\IonosAccountCreationService; use OCP\IRequest; use OCP\IUser; use OCP\IUserSession; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; -use ReflectionClass; class IonosAccountsControllerTest extends TestCase { private string $appName; private IRequest&MockObject $request; - private IonosMailService&MockObject $ionosMailService; - - private AccountsController&MockObject $accountsController; + private IonosAccountCreationService&MockObject $accountCreationService; private IUserSession&MockObject $userSession; @@ -44,16 +40,14 @@ protected function setUp(): void { $this->appName = 'mail'; $this->request = $this->createMock(IRequest::class); - $this->ionosMailService = $this->createMock(IonosMailService::class); - $this->accountsController = $this->createMock(AccountsController::class); + $this->accountCreationService = $this->createMock(IonosAccountCreationService::class); $this->userSession = $this->createMock(IUserSession::class); $this->logger = $this->createMock(LoggerInterface::class); $this->controller = new IonosAccountsController( $this->appName, $this->request, - $this->ionosMailService, - $this->accountsController, + $this->accountCreationService, $this->userSession, $this->logger, ); @@ -95,37 +89,27 @@ public function testCreateSuccess(): void { // Setup user session $this->setupUserSession($userId); - // Create MailAccountConfig DTO - $imapConfig = new MailServerConfig( - host: 'mail.localhost', - port: 1143, - security: 'none', - username: $emailAddress, - password: 'tmp', - ); + // Create a real MailAccount instance and wrap it in Account + $mailAccount = new MailAccount(); + $mailAccount->setId(1); + $mailAccount->setUserId($userId); + $mailAccount->setName($accountName); + $mailAccount->setEmail($emailAddress); - $smtpConfig = new MailServerConfig( - host: 'mail.localhost', - port: 1587, - security: 'none', - username: $emailAddress, - password: 'tmp', - ); + $account = new Account($mailAccount); - $mailAccountConfig = new MailAccountConfig( - email: $emailAddress, - imap: $imapConfig, - smtp: $smtpConfig, - ); + // Verify response matches the expected MailJsonResponse::success() format + $accountResponse = \OCA\Mail\Http\JsonResponse::success($account, 201); - // Mock successful IONOS mail service response - $this->ionosMailService->method('createEmailAccount') - ->with($emailUser) - ->willReturn($mailAccountConfig); + // Mock account creation service to return a successful account + $this->accountCreationService->expects($this->once()) + ->method('createOrUpdateAccount') + ->with($userId, $emailUser, $accountName) + ->willReturn($account); // Verify logging calls $this->logger - ->expects($this->exactly(3)) + ->expects($this->exactly(2)) ->method('info') ->willReturnCallback(function ($message, $context) use ($emailUser, $accountName, $emailAddress, $userId) { static $callCount = 0; @@ -139,46 +123,19 @@ public function testCreateSuccess(): void { 'accountName' => $accountName, ], $context); } elseif ($callCount === 2) { - $this->assertEquals('IONOS email account created successfully', $message); - $this->assertEquals([ - 'emailAddress' => $emailAddress, - ], $context); - } elseif ($callCount === 3) { $this->assertEquals('Account creation completed successfully', $message); $this->assertEquals([ - 'emailAddress' => $emailUser, + 'emailAddress' => $emailAddress, 'accountName' => $accountName, + 'accountId' => 1, + 'userId' => $userId, ], $context); } }); - // 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); + $this->assertEquals($accountResponse, $response); } public function testCreateWithServiceException(): void { @@ -189,9 +146,10 @@ public function testCreateWithServiceException(): void { // Setup user session $this->setupUserSession($userId); - // Mock IONOS mail service to throw ServiceException - $this->ionosMailService->method('createEmailAccount') - ->with($emailUser) + // Mock account creation service to throw ServiceException + $this->accountCreationService->expects($this->once()) + ->method('createOrUpdateAccount') + ->with($userId, $emailUser, $accountName) ->willThrowException(new ServiceException('Failed to create email account')); $this->logger @@ -224,9 +182,10 @@ public function testCreateWithServiceExceptionWithStatusCode(): void { // Setup user session $this->setupUserSession($userId); - // Mock IONOS mail service to throw ServiceException with HTTP 409 (Duplicate) - $this->ionosMailService->method('createEmailAccount') - ->with($emailUser) + // Mock account creation service to throw ServiceException with status code + $this->accountCreationService->expects($this->once()) + ->method('createOrUpdateAccount') + ->with($userId, $emailUser, $accountName) ->willThrowException(new ServiceException('Duplicate email account', 409)); $this->logger @@ -251,6 +210,55 @@ public function testCreateWithServiceExceptionWithStatusCode(): void { self::assertEquals($expectedResponse, $response); } + public function testCreateWithIonosServiceExceptionWithAdditionalData(): void { + $accountName = 'Test Account'; + $emailUser = 'test'; + $userId = 'test-user-123'; + + // Setup user session + $this->setupUserSession($userId); + + // Create IonosServiceException with additional data + $additionalData = [ + 'errorCode' => 'DUPLICATE_EMAIL', + 'existingEmail' => 'test@example.com', + 'suggestedAlternative' => 'test2@example.com', + ]; + + // Mock account creation service to throw IonosServiceException with additional data + $this->accountCreationService->expects($this->once()) + ->method('createOrUpdateAccount') + ->with($userId, $emailUser, $accountName) + ->willThrowException(new IonosServiceException('Email already exists', 409, null, $additionalData)); + + $this->logger + ->expects($this->once()) + ->method('error') + ->with( + 'IONOS service error during account creation: Email already exists', + [ + 'error' => 'IONOS_API_ERROR', + 'statusCode' => 409, + 'message' => 'Email already exists', + 'errorCode' => 'DUPLICATE_EMAIL', + 'existingEmail' => 'test@example.com', + 'suggestedAlternative' => 'test2@example.com', + ] + ); + + $expectedResponse = \OCA\Mail\Http\JsonResponse::fail([ + 'error' => 'IONOS_API_ERROR', + 'statusCode' => 409, + 'message' => 'Email already exists', + 'errorCode' => 'DUPLICATE_EMAIL', + 'existingEmail' => 'test@example.com', + 'suggestedAlternative' => 'test2@example.com', + ]); + $response = $this->controller->create($accountName, $emailUser); + + self::assertEquals($expectedResponse, $response); + } + public function testCreateWithGenericException(): void { $accountName = 'Test Account'; $emailUser = 'test'; @@ -259,10 +267,11 @@ public function testCreateWithGenericException(): void { // Setup user session $this->setupUserSession($userId); - // Mock IONOS mail service to throw a generic exception + // Mock account creation service to throw a generic exception $exception = new \Exception('Generic error'); - $this->ionosMailService->method('createEmailAccount') - ->with($emailUser) + $this->accountCreationService->expects($this->once()) + ->method('createOrUpdateAccount') + ->with($userId, $emailUser, $accountName) ->willThrowException($exception); // Verify error logging for unexpected exceptions @@ -315,61 +324,4 @@ public function testCreateWithNoUserSession(): void { 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); - } } From d4022b9df92bef80c33acde5865a876710017c72 Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Wed, 17 Dec 2025 13:47:00 +0100 Subject: [PATCH 19/24] IONOS(ionos-mail): fix(NewEmailAddressTab): improve error message for existing email address conflict Updated the feedback message for the 409 error code to provide clearer guidance on existing IONOS email addresses, including the conflicting email address. Signed-off-by: Misha M.-Kupriyanov --- src/components/ionos/NewEmailAddressTab.vue | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/ionos/NewEmailAddressTab.vue b/src/components/ionos/NewEmailAddressTab.vue index cb4c290f49..5c65716ab3 100644 --- a/src/components/ionos/NewEmailAddressTab.vue +++ b/src/components/ionos/NewEmailAddressTab.vue @@ -128,6 +128,7 @@ export default { if (error.data?.error === 'IONOS_API_ERROR') { const statusCode = error.data?.statusCode + const existingEmail = error.data?.existingEmail || '' switch (statusCode) { case 400: @@ -137,7 +138,7 @@ export default { this.feedback = t('mail', 'Email service not found. Please contact support') break case 409: - this.feedback = t('mail', 'This email address already exists') + this.feedback = t('mail', 'You can only have one IONOS email address. Please use your existing account {email} or delete it first', { email: existingEmail }) break case 412: this.feedback = t('mail', 'Account state conflict. Please try again later') From b31e781ca94a3b473d059f783c67068e232cc6f1 Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Fri, 19 Dec 2025 10:36:36 +0100 Subject: [PATCH 20/24] IONOS(ionos-mail): feat(provider): implement external mail account provider system This commit introduces a new external mail account provider system for Nextcloud Mail, allowing integration with services like IONOS. It includes the implementation of the IonosProvider, which supports account creation, management, and app password generation. Additionally, it establishes a registry service for managing multiple providers and their capabilities. This enhancement aims to provide users with more flexible options for managing their email accounts directly within Nextcloud. Signed-off-by: Misha M.-Kupriyanov --- appinfo/routes.php | 17 + lib/AppInfo/Application.php | 16 + lib/Controller/ExternalAccountsController.php | 271 ++++++++++++ .../IMailAccountProvider.php | 106 +++++ .../IProviderCapabilities.php | 68 +++ .../Implementations/IonosProvider.php | 188 +++++++++ .../ProviderCapabilities.php | 51 +++ .../ProviderRegistryService.php | 171 ++++++++ lib/Provider/MailAccountProvider/README.md | 254 ++++++++++++ lib/Service/AccountProviderService.php | 100 +++++ .../ExternalAccountsControllerTest.php | 388 ++++++++++++++++++ .../Implementations/IonosProviderTest.php | 324 +++++++++++++++ .../ProviderRegistryServiceTest.php | 265 ++++++++++++ .../Service/AccountProviderServiceTest.php | 182 ++++++++ 14 files changed, 2401 insertions(+) create mode 100644 lib/Controller/ExternalAccountsController.php create mode 100644 lib/Provider/MailAccountProvider/IMailAccountProvider.php create mode 100644 lib/Provider/MailAccountProvider/IProviderCapabilities.php create mode 100644 lib/Provider/MailAccountProvider/Implementations/IonosProvider.php create mode 100644 lib/Provider/MailAccountProvider/ProviderCapabilities.php create mode 100644 lib/Provider/MailAccountProvider/ProviderRegistryService.php create mode 100644 lib/Provider/MailAccountProvider/README.md create mode 100644 lib/Service/AccountProviderService.php create mode 100644 tests/Unit/Controller/ExternalAccountsControllerTest.php create mode 100644 tests/Unit/Provider/MailAccountProvider/Implementations/IonosProviderTest.php create mode 100644 tests/Unit/Provider/MailAccountProvider/ProviderRegistryServiceTest.php create mode 100644 tests/Unit/Service/AccountProviderServiceTest.php diff --git a/appinfo/routes.php b/appinfo/routes.php index c62dc954c8..cbdabb3b81 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -120,6 +120,23 @@ 'url' => '/api/tags', 'verb' => 'POST' ], + // External provider routes (generic) + [ + 'name' => 'externalAccounts#getProviders', + 'url' => '/api/providers', + 'verb' => 'GET' + ], + [ + 'name' => 'externalAccounts#create', + 'url' => '/api/providers/{providerId}/accounts', + 'verb' => 'POST' + ], + [ + 'name' => 'externalAccounts#generatePassword', + 'url' => '/api/providers/{providerId}/password', + 'verb' => 'POST' + ], + // Legacy IONOS routes (backward compatibility) [ 'name' => 'ionosAccounts#create', 'url' => '/api/ionos/accounts', diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 3004b98093..325c5d5b37 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -54,6 +54,8 @@ use OCA\Mail\Listener\TaskProcessingListener; use OCA\Mail\Listener\UserDeletedListener; use OCA\Mail\Notification\Notifier; +use OCA\Mail\Provider\MailAccountProvider\Implementations\IonosProvider; +use OCA\Mail\Provider\MailAccountProvider\ProviderRegistryService; use OCA\Mail\Provider\MailProvider; use OCA\Mail\Search\FilteringProvider; use OCA\Mail\Service\Attachment\AttachmentService; @@ -172,5 +174,19 @@ public function register(IRegistrationContext $context): void { #[\Override] public function boot(IBootContext $context): void { + $container = $context->getServerContainer(); + + // Register mail account providers + try { + $providerRegistry = $container->get(ProviderRegistryService::class); + $ionosProvider = $container->get(IonosProvider::class); + $providerRegistry->registerProvider($ionosProvider); + } catch (\Exception $e) { + // Log but don't fail - provider registration is optional + $logger = $container->get(\Psr\Log\LoggerInterface::class); + $logger->error('Failed to register mail account providers', [ + 'exception' => $e, + ]); + } } } diff --git a/lib/Controller/ExternalAccountsController.php b/lib/Controller/ExternalAccountsController.php new file mode 100644 index 0000000000..a293031132 --- /dev/null +++ b/lib/Controller/ExternalAccountsController.php @@ -0,0 +1,271 @@ +getUserIdOrFail(); + + // Get parameters from request body + $parameters = $this->request->getParams(); + + // Remove Nextcloud-specific parameters + unset($parameters['providerId']); + unset($parameters['_route']); + + $this->logger->info('Starting external mail account creation', [ + 'userId' => $userId, + 'providerId' => $providerId, + 'parameters' => array_keys($parameters), + ]); + + // Get the provider + $provider = $this->providerRegistry->getProvider($providerId); + if ($provider === null) { + return MailJsonResponse::fail([ + 'error' => self::ERR_PROVIDER_NOT_FOUND, + 'message' => 'Provider not found: ' . $providerId, + ], Http::STATUS_NOT_FOUND); + } + + // Check if provider is enabled and available for this user + if (!$provider->isEnabled()) { + return MailJsonResponse::fail([ + 'error' => self::ERR_PROVIDER_NOT_AVAILABLE, + 'message' => 'Provider is not enabled: ' . $providerId, + ], Http::STATUS_BAD_REQUEST); + } + + if (!$provider->isAvailableForUser($userId)) { + return MailJsonResponse::fail([ + 'error' => self::ERR_PROVIDER_NOT_AVAILABLE, + 'message' => 'Provider is not available for this user', + ], Http::STATUS_BAD_REQUEST); + } + + // Create the account + $account = $provider->createAccount($userId, $parameters); + + $this->logger->info('External account creation completed successfully', [ + 'emailAddress' => $account->getEmail(), + 'accountId' => $account->getId(), + 'userId' => $userId, + 'providerId' => $providerId, + ]); + + return MailJsonResponse::success($account, Http::STATUS_CREATED); + } catch (ServiceException $e) { + return $this->buildServiceErrorResponse($e, $providerId); + } catch (\InvalidArgumentException $e) { + $this->logger->error('Invalid parameters for account creation', [ + 'providerId' => $providerId, + 'exception' => $e, + ]); + return MailJsonResponse::fail([ + 'error' => self::ERR_INVALID_PARAMETERS, + 'message' => $e->getMessage(), + ], Http::STATUS_BAD_REQUEST); + } catch (\Exception $e) { + $this->logger->error('Unexpected error during external account creation', [ + 'providerId' => $providerId, + 'exception' => $e, + ]); + return MailJsonResponse::error('Could not create account'); + } + } + + /** + * Get information about available providers + * + * @NoAdminRequired + * + * @return JSONResponse + */ + #[TrapError] + public function getProviders(): JSONResponse { + try { + $userId = $this->getUserIdOrFail(); + $availableProviders = $this->providerRegistry->getAvailableProvidersForUser($userId); + + $providersInfo = []; + foreach ($availableProviders as $provider) { + $capabilities = $provider->getCapabilities(); + $providersInfo[] = [ + 'id' => $provider->getId(), + 'name' => $provider->getName(), + 'capabilities' => [ + 'multipleAccounts' => $capabilities->allowsMultipleAccounts(), + 'appPasswords' => $capabilities->supportsAppPasswords(), + 'passwordReset' => $capabilities->supportsPasswordReset(), + 'emailDomain' => $capabilities->getEmailDomain(), + ], + 'parameterSchema' => $capabilities->getCreationParameterSchema(), + ]; + } + + return MailJsonResponse::success([ + 'providers' => $providersInfo, + ]); + } catch (\Exception $e) { + $this->logger->error('Error getting available providers', [ + 'exception' => $e, + ]); + return MailJsonResponse::error('Could not get providers'); + } + } + + /** + * Generate an app password for a provider-managed account + * + * @NoAdminRequired + * @NoCSRFRequired + * + * @param string $providerId The provider ID + * @return JSONResponse + */ + #[TrapError] + public function generatePassword(string $providerId): JSONResponse { + // Get accountId from request body + $accountId = $this->request->getParam('accountId'); + + if ($accountId === null) { + return MailJsonResponse::fail(['error' => 'Account ID is required']); + } + + try { + $userId = $this->getUserIdOrFail(); + + $this->logger->info('Generating app password', [ + 'accountId' => $accountId, + 'providerId' => $providerId, + ]); + + $provider = $this->providerRegistry->getProvider($providerId); + if ($provider === null) { + return MailJsonResponse::fail([ + 'error' => self::ERR_PROVIDER_NOT_FOUND, + 'message' => 'Provider not found', + ], Http::STATUS_NOT_FOUND); + } + + // Check if provider supports app passwords + if (!$provider->getCapabilities()->supportsAppPasswords()) { + return MailJsonResponse::fail([ + 'error' => 'NOT_SUPPORTED', + 'message' => 'Provider does not support app passwords', + ], Http::STATUS_BAD_REQUEST); + } + + // For now, delegate to IONOS-specific implementation + // In the future, this should be a method on the provider interface + if ($providerId === 'ionos') { + $ionosMailService = \OC::$server->get(\OCA\Mail\Service\IONOS\IonosMailService::class); + $password = $ionosMailService->generateUserAppPassword(); + + $this->logger->info('App password generated successfully', [ + 'accountId' => $accountId, + 'providerId' => $providerId, + ]); + + return MailJsonResponse::success(['password' => $password]); + } + + return MailJsonResponse::fail([ + 'error' => 'NOT_IMPLEMENTED', + 'message' => 'App password generation not implemented for this provider', + ], Http::STATUS_NOT_IMPLEMENTED); + } catch (ServiceException $e) { + return $this->buildServiceErrorResponse($e, $providerId); + } catch (\Exception $e) { + $this->logger->error('Unexpected error generating app password', [ + 'exception' => $e, + 'accountId' => $accountId, + 'providerId' => $providerId, + ]); + return MailJsonResponse::error('Could not generate app password'); + } + } + + /** + * Get the current user ID + * + * @return string User ID string + * @throws ServiceException + */ + private function getUserIdOrFail(): string { + $user = $this->userSession->getUser(); + if ($user === null) { + throw new ServiceException('No user session found', 401); + } + return $user->getUID(); + } + + /** + * Build service error response + */ + private function buildServiceErrorResponse(ServiceException $e, string $providerId): JSONResponse { + $data = [ + 'error' => self::ERR_SERVICE_ERROR, + 'statusCode' => $e->getCode(), + 'message' => $e->getMessage(), + ]; + + // If it's an IonosServiceException, merge in the additional data + if ($e instanceof IonosServiceException) { + $data = array_merge($data, $e->getData()); + } + + $this->logger->error('Service error during provider operation', array_merge($data, [ + 'providerId' => $providerId, + ])); + + return MailJsonResponse::fail($data); + } +} diff --git a/lib/Provider/MailAccountProvider/IMailAccountProvider.php b/lib/Provider/MailAccountProvider/IMailAccountProvider.php new file mode 100644 index 0000000000..c61a875d0d --- /dev/null +++ b/lib/Provider/MailAccountProvider/IMailAccountProvider.php @@ -0,0 +1,106 @@ + $parameters Provider-specific parameters (e.g., email username, domain) + * @return Account The created Nextcloud mail account + * @throws \OCA\Mail\Exception\ServiceException If account creation fails + */ + public function createAccount(string $userId, array $parameters): Account; + + /** + * Update an existing mail account (e.g., reset password) + * + * @param string $userId The Nextcloud user ID + * @param int $accountId The Nextcloud mail account ID + * @param array $parameters Provider-specific parameters + * @return Account The updated account + * @throws \OCA\Mail\Exception\ServiceException If update fails + */ + public function updateAccount(string $userId, int $accountId, array $parameters): Account; + + /** + * Delete a mail account from the external provider + * + * @param string $userId The Nextcloud user ID + * @param string $email The email address to delete + * @return bool True if deletion was successful + */ + public function deleteAccount(string $userId, string $email): bool; + + /** + * Check if the given email address is managed by this provider + * + * @param string $userId The Nextcloud user ID + * @param string $email The email address to check + * @return bool True if this provider manages this email + */ + public function managesEmail(string $userId, string $email): bool; + + /** + * Get the email address managed by this provider for the given user + * + * @param string $userId The Nextcloud user ID + * @return string|null The email address or null if no account exists + */ + public function getProvisionedEmail(string $userId): ?string; +} diff --git a/lib/Provider/MailAccountProvider/IProviderCapabilities.php b/lib/Provider/MailAccountProvider/IProviderCapabilities.php new file mode 100644 index 0000000000..c36247aa37 --- /dev/null +++ b/lib/Provider/MailAccountProvider/IProviderCapabilities.php @@ -0,0 +1,68 @@ + Config schema + */ + public function getConfigSchema(): array; + + /** + * Get the parameter schema for account creation + * + * Returns an array describing what parameters are needed + * when creating an account (e.g., username, domain). + * + * @return array Parameter schema + */ + public function getCreationParameterSchema(): array; + + /** + * Get the email domain for this provider (if applicable) + * + * Returns the domain suffix used for email addresses created by this provider. + * For example, "example.com" for accounts like "user@example.com" + * + * @return string|null The email domain or null if not applicable + */ + public function getEmailDomain(): ?string; +} diff --git a/lib/Provider/MailAccountProvider/Implementations/IonosProvider.php b/lib/Provider/MailAccountProvider/Implementations/IonosProvider.php new file mode 100644 index 0000000000..4e3cc02910 --- /dev/null +++ b/lib/Provider/MailAccountProvider/Implementations/IonosProvider.php @@ -0,0 +1,188 @@ +capabilities === null) { + // Get email domain from config service + $emailDomain = null; + try { + $emailDomain = $this->configService->getMailDomain(); + } catch (\Exception $e) { + $this->logger->debug('Could not get IONOS email domain', [ + 'exception' => $e, + ]); + } + + $this->capabilities = new ProviderCapabilities( + multipleAccounts: false, // IONOS allows only one account per user + appPasswords: true, // IONOS supports app password generation + passwordReset: true, // IONOS supports password reset + configSchema: [ + 'ionos_mailconfig_api_base_url' => [ + 'type' => 'string', + 'required' => true, + 'description' => 'Base URL for the IONOS Mail Configuration API', + ], + 'ionos_mailconfig_api_auth_user' => [ + 'type' => 'string', + 'required' => true, + 'description' => 'Basic auth username for IONOS API', + ], + 'ionos_mailconfig_api_auth_pass' => [ + 'type' => 'string', + 'required' => true, + 'description' => 'Basic auth password for IONOS API', + ], + 'ionos_mailconfig_api_allow_insecure' => [ + 'type' => 'boolean', + 'required' => false, + 'description' => 'Allow insecure connections (for development)', + ], + 'ncw.ext_ref' => [ + 'type' => 'string', + 'required' => true, + 'description' => 'External reference ID (system config)', + ], + 'ncw.customerDomain' => [ + 'type' => 'string', + 'required' => true, + 'description' => 'Customer domain for email addresses (system config)', + ], + ], + creationParameterSchema: [ + 'accountName' => [ + 'type' => 'string', + 'required' => true, + 'description' => 'Name', + ], + 'emailUser' => [ + 'type' => 'string', + 'required' => true, + 'description' => 'User', + ], + ], + emailDomain: $emailDomain, + ); + } + return $this->capabilities; + } + + public function isEnabled(): bool { + try { + return $this->configService->isIonosIntegrationEnabled(); + } catch (\Exception $e) { + $this->logger->debug('IONOS provider is not enabled', [ + 'exception' => $e, + ]); + return false; + } + } + + public function isAvailableForUser(string $userId): bool { + try { + // For IONOS, account is available only if user doesn't already have one + // (since multipleAccounts = false) + $hasAccount = $this->mailService->mailAccountExistsForCurrentUserId($userId); + return !$hasAccount; + } catch (\Exception $e) { + $this->logger->error('Error checking IONOS availability for user', [ + 'userId' => $userId, + 'exception' => $e, + ]); + return false; + } + } + + public function createAccount(string $userId, array $parameters): Account { + $emailUser = $parameters['emailUser'] ?? ''; + $accountName = $parameters['accountName'] ?? ''; + + if (empty($emailUser) || empty($accountName)) { + throw new \InvalidArgumentException('emailUser and accountName are required'); + } + + return $this->creationService->createOrUpdateAccount($userId, $emailUser, $accountName); + } + + public function updateAccount(string $userId, int $accountId, array $parameters): Account { + // For now, use same creation logic which handles updates + return $this->createAccount($userId, $parameters); + } + + public function deleteAccount(string $userId, string $email): bool { + return $this->mailService->deleteEmailAccount($userId); + } + + public function managesEmail(string $userId, string $email): bool { + try { + $ionosEmail = $this->mailService->getIonosEmailForUser($userId); + if ($ionosEmail === null) { + return false; + } + return strcasecmp($email, $ionosEmail) === 0; + } catch (\Exception $e) { + $this->logger->debug('Error checking if IONOS manages email', [ + 'userId' => $userId, + 'email' => $email, + 'exception' => $e, + ]); + return false; + } + } + + public function getProvisionedEmail(string $userId): ?string { + try { + return $this->mailService->getIonosEmailForUser($userId); + } catch (\Exception $e) { + $this->logger->debug('Error getting IONOS provisioned email', [ + 'userId' => $userId, + 'exception' => $e, + ]); + return null; + } + } +} diff --git a/lib/Provider/MailAccountProvider/ProviderCapabilities.php b/lib/Provider/MailAccountProvider/ProviderCapabilities.php new file mode 100644 index 0000000000..4668079bfb --- /dev/null +++ b/lib/Provider/MailAccountProvider/ProviderCapabilities.php @@ -0,0 +1,51 @@ +multipleAccounts; + } + + public function supportsAppPasswords(): bool { + return $this->appPasswords; + } + + public function supportsPasswordReset(): bool { + return $this->passwordReset; + } + + public function getConfigSchema(): array { + return $this->configSchema; + } + + public function getCreationParameterSchema(): array { + return $this->creationParameterSchema; + } + + public function getEmailDomain(): ?string { + return $this->emailDomain; + } +} diff --git a/lib/Provider/MailAccountProvider/ProviderRegistryService.php b/lib/Provider/MailAccountProvider/ProviderRegistryService.php new file mode 100644 index 0000000000..d74814fa0e --- /dev/null +++ b/lib/Provider/MailAccountProvider/ProviderRegistryService.php @@ -0,0 +1,171 @@ + */ + private array $providers = []; + + public function __construct( + private LoggerInterface $logger, + ) { + } + + /** + * Register a provider + * + * @param IMailAccountProvider $provider The provider to register + */ + public function registerProvider(IMailAccountProvider $provider): void { + $id = $provider->getId(); + + if (isset($this->providers[$id])) { + $this->logger->warning('Provider already registered, overwriting', [ + 'providerId' => $id, + ]); + } + + $this->providers[$id] = $provider; + $this->logger->debug('Registered mail account provider', [ + 'providerId' => $id, + 'providerName' => $provider->getName(), + ]); + } + + /** + * Get a provider by ID + * + * @param string $providerId The provider ID + * @return IMailAccountProvider|null The provider or null if not found + */ + public function getProvider(string $providerId): ?IMailAccountProvider { + return $this->providers[$providerId] ?? null; + } + + /** + * Get all registered providers + * + * @return array Array of providers indexed by ID + */ + public function getAllProviders(): array { + return $this->providers; + } + + /** + * Get all enabled providers + * + * @return array Array of enabled providers indexed by ID + */ + public function getEnabledProviders(): array { + return array_filter($this->providers, fn (IMailAccountProvider $provider) => $provider->isEnabled()); + } + + /** + * Get all providers available for a specific user + * + * @param string $userId The Nextcloud user ID + * @return array Array of available providers indexed by ID + */ + public function getAvailableProvidersForUser(string $userId): array { + return array_filter( + $this->getEnabledProviders(), + fn (IMailAccountProvider $provider) => $provider->isAvailableForUser($userId) + ); + } + + /** + * Find which provider manages a specific email address + * + * @param string $userId The Nextcloud user ID + * @param string $email The email address + * @return IMailAccountProvider|null The managing provider or null + */ + public function findProviderForEmail(string $userId, string $email): ?IMailAccountProvider { + foreach ($this->getEnabledProviders() as $provider) { + if ($provider->managesEmail($userId, $email)) { + return $provider; + } + } + return null; + } + + /** + * Get provider information for API responses + * + * @return array + */ + public function getProviderInfo(): array { + $info = []; + foreach ($this->providers as $id => $provider) { + $capabilities = $provider->getCapabilities(); + $info[$id] = [ + 'id' => $id, + 'name' => $provider->getName(), + 'enabled' => $provider->isEnabled(), + 'capabilities' => [ + 'multipleAccounts' => $capabilities->allowsMultipleAccounts(), + 'appPasswords' => $capabilities->supportsAppPasswords(), + 'passwordReset' => $capabilities->supportsPasswordReset(), + ], + ]; + } + return $info; + } + + /** + * Delete all provider-managed accounts for a specific user + * + * This method iterates through the user's accounts and deletes those + * that are managed by registered providers. + * + * @param string $userId The Nextcloud user ID + * @param array $accounts List of user's mail accounts + */ + public function deleteProviderManagedAccounts(string $userId, array $accounts): void { + foreach ($accounts as $account) { + $email = $account->getEmail(); + + // Check if this account is managed by a provider + $provider = $this->findProviderForEmail($userId, $email); + if ($provider !== null) { + try { + $this->logger->info('Deleting provider-managed account', [ + 'provider' => $provider->getId(), + 'userId' => $userId, + 'email' => $email, + ]); + + $provider->deleteAccount($userId, $email); + + $this->logger->info('Successfully deleted provider-managed account', [ + 'provider' => $provider->getId(), + 'userId' => $userId, + 'email' => $email, + ]); + } catch (\Exception $e) { + $this->logger->error('Failed to delete provider-managed account', [ + 'provider' => $provider->getId(), + 'userId' => $userId, + 'email' => $email, + 'exception' => $e, + ]); + // Continue with other accounts even if one fails + } + } + } + } +} diff --git a/lib/Provider/MailAccountProvider/README.md b/lib/Provider/MailAccountProvider/README.md new file mode 100644 index 0000000000..537e90ec12 --- /dev/null +++ b/lib/Provider/MailAccountProvider/README.md @@ -0,0 +1,254 @@ +# Mail Account Provider System + +This directory contains the pluggable mail account provider system for Nextcloud Mail. + +## Overview + +The provider system allows external mail services (like IONOS, Office365, Google Workspace, etc.) to provision mail accounts through their APIs and integrate seamlessly with Nextcloud Mail. + +## Architecture + +``` +lib/Provider/MailAccountProvider/ +├── IMailAccountProvider.php # Main provider interface +├── IProviderCapabilities.php # Capabilities interface +├── ProviderCapabilities.php # Base capabilities implementation +├── ProviderRegistryService.php # Central provider registry +└── Implementations/ + ├── IonosProvider.php # IONOS implementation + └── [Other providers...] +``` + +## Key Interfaces + +### IMailAccountProvider + +Main interface that all providers must implement: + +- `getId()`: Unique provider identifier (e.g., 'ionos', 'office365') +- `getName()`: Human-readable name +- `getCapabilities()`: What features the provider supports +- `isEnabled()`: Is the provider configured and ready to use? +- `isAvailableForUser()`: Can this user create accounts with this provider? +- `createAccount()`: Provision a new mail account +- `updateAccount()`: Update existing account (e.g., reset password) +- `deleteAccount()`: Delete account from provider +- `managesEmail()`: Does this provider manage a specific email address? +- `getProvisionedEmail()`: What email did this provider provision for a user? + +### IProviderCapabilities + +Declares what features a provider supports: + +- `allowsMultipleAccounts()`: Can a user have multiple accounts? +- `supportsAppPasswords()`: Can generate app-specific passwords? +- `supportsPasswordReset()`: Can reset account passwords? +- `getConfigSchema()`: What configuration fields are needed? +- `getCreationParameterSchema()`: What parameters are needed to create an account? + +## Creating a New Provider + +### 1. Create Provider Class + +```php + [ + 'type' => 'string', + 'required' => true, + 'description' => 'API endpoint URL', + ], + 'myprovider_api_key' => [ + 'type' => 'string', + 'required' => true, + 'description' => 'API authentication key', + ], + ], + creationParameterSchema: [ + 'username' => [ + 'type' => 'string', + 'required' => true, + 'description' => 'Email username', + ], + 'displayName' => [ + 'type' => 'string', + 'required' => false, + 'description' => 'User display name', + 'default' => '', + ], + ], + ); + } + + public function isEnabled(): bool { + // Check if configuration is valid + try { + $apiUrl = $this->config->getAppValue('mail', 'myprovider_api_url'); + $apiKey = $this->config->getAppValue('mail', 'myprovider_api_key'); + return !empty($apiUrl) && !empty($apiKey); + } catch (\Exception $e) { + return false; + } + } + + public function isAvailableForUser(string $userId): bool { + // Determine if user can create accounts + // E.g., check if they already have one (if multipleAccounts=false) + return true; + } + + public function createAccount(string $userId, array $parameters): Account { + // 1. Validate parameters + $username = $parameters['username'] ?? ''; + if (empty($username)) { + throw new \InvalidArgumentException('username is required'); + } + + // 2. Call external API to provision mailbox + $mailConfig = $this->callProviderAPI($userId, $username); + + // 3. Create Nextcloud Mail account + $account = new MailAccount(); + $account->setUserId($userId); + $account->setEmail($mailConfig['email']); + $account->setInboundHost($mailConfig['imap_host']); + // ... set other properties ... + + return new Account($this->accountService->save($account)); + } + + // Implement other methods... +} +``` + +### 2. Register Provider + +In `lib/AppInfo/Application.php`: + +```php +public function boot(IBootContext $context): void { + $container = $context->getServerContainer(); + $providerRegistry = $container->get(ProviderRegistryService::class); + + // Register your provider + $myProvider = $container->get(MyProvider::class); + $providerRegistry->registerProvider($myProvider); +} +``` + +### 3. Configure Provider + +```bash +# Via occ command +occ config:app:set mail myprovider_api_url --value="https://api.example.com" +occ config:app:set mail myprovider_api_key --value="secret-key" + +# Or via Admin UI (future enhancement) +``` + +### 4. Use Provider + +Your provider will automatically: +- Appear in `GET /api/providers` if enabled +- Be usable via `POST /api/providers/myprovider/accounts` +- Work with generic CLI commands (when implemented) +- Show in UI provider selection (when implemented) + +## API Endpoints + +### Get Available Providers +```http +GET /apps/mail/api/providers +``` + +Returns list of providers available to current user with capabilities and parameter schemas. + +### Create Account +```http +POST /apps/mail/api/providers/{providerId}/accounts +Content-Type: application/json + +{ + "param1": "value1", + "param2": "value2" +} +``` + +Creates a mail account using the specified provider with given parameters. + +### Generate App Password +```http +POST /apps/mail/api/providers/{providerId}/password +Content-Type: application/json + +{ + "accountId": 123 +} +``` + +Generates an app-specific password (if provider supports it). + +## Configuration Storage + +Providers store configuration using standard Nextcloud mechanisms: + +**App Config** (per-app settings): +```php +$this->appConfig->getValueString('mail', 'myprovider_setting'); +$this->appConfig->setValueString('mail', 'myprovider_setting', $value); +``` + +**System Config** (global settings): +```php +$this->config->getSystemValue('myprovider.global_setting'); +$this->config->setSystemValue('myprovider.global_setting', $value); +``` + +## Design Principles + +1. **No Database Changes**: Account metadata derived at runtime +2. **Plug-and-Play**: Providers are self-contained +3. **Declarative**: Capabilities and schemas describe behavior +4. **Safe Defaults**: Errors don't break the app +5. **Backward Compatible**: Existing accounts unaffected + +## Testing + +When creating a provider, test: + +1. **Configuration validation**: isEnabled() works correctly +2. **User availability**: isAvailableForUser() logic +3. **Account creation**: Full flow including API calls +4. **Account deletion**: Cleanup on provider side +5. **Error handling**: API failures, invalid parameters +6. **Email management**: managesEmail() correctly identifies accounts + +## Examples + +See `Implementations/IonosProvider.php` for a complete, production-ready example. + +## Further Reading + +- `PROVIDER_REFACTORING_GUIDE.md`: Architecture and implementation details +- `IMPLEMENTATION_SUMMARY.md`: Current status and next steps +- Core interfaces in this directory for full API documentation diff --git a/lib/Service/AccountProviderService.php b/lib/Service/AccountProviderService.php new file mode 100644 index 0000000000..240edf733d --- /dev/null +++ b/lib/Service/AccountProviderService.php @@ -0,0 +1,100 @@ + $accountJson The account JSON to enhance + * @param string $userId The user ID + * @param string $email The account email address + * @return array The enhanced account JSON + */ + public function addProviderMetadata(array $accountJson, string $userId, string $email): array { + try { + $provider = $this->providerRegistry->findProviderForEmail($userId, $email); + + if ($provider !== null) { + $capabilities = $provider->getCapabilities(); + + $accountJson['managedByProvider'] = $provider->getId(); + $accountJson['providerCapabilities'] = [ + 'multipleAccounts' => $capabilities->allowsMultipleAccounts(), + 'appPasswords' => $capabilities->supportsAppPasswords(), + 'passwordReset' => $capabilities->supportsPasswordReset(), + 'emailDomain' => $capabilities->getEmailDomain(), + ]; + } else { + $accountJson['managedByProvider'] = null; + $accountJson['providerCapabilities'] = null; + } + } catch (\Exception $e) { + $this->logger->debug('Error determining account provider', [ + 'userId' => $userId, + 'email' => $email, + 'exception' => $e, + ]); + + // Safe defaults on error + $accountJson['managedByProvider'] = null; + $accountJson['providerCapabilities'] = null; + } + + return $accountJson; + } + + /** + * Get all providers available for a user + * + * @param string $userId The user ID + * @return array + */ + public function getAvailableProvidersForUser(string $userId): array { + $providers = $this->providerRegistry->getAvailableProvidersForUser($userId); + $result = []; + + foreach ($providers as $provider) { + $capabilities = $provider->getCapabilities(); + $result[$provider->getId()] = [ + 'id' => $provider->getId(), + 'name' => $provider->getName(), + 'capabilities' => [ + 'multipleAccounts' => $capabilities->allowsMultipleAccounts(), + 'appPasswords' => $capabilities->supportsAppPasswords(), + 'passwordReset' => $capabilities->supportsPasswordReset(), + 'emailDomain' => $capabilities->getEmailDomain(), + ], + 'parameterSchema' => $capabilities->getCreationParameterSchema(), + ]; + } + + return $result; + } +} diff --git a/tests/Unit/Controller/ExternalAccountsControllerTest.php b/tests/Unit/Controller/ExternalAccountsControllerTest.php new file mode 100644 index 0000000000..7268433b25 --- /dev/null +++ b/tests/Unit/Controller/ExternalAccountsControllerTest.php @@ -0,0 +1,388 @@ +request = $this->createMock(IRequest::class); + $this->providerRegistry = $this->createMock(ProviderRegistryService::class); + $this->userSession = $this->createMock(IUserSession::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->controller = new ExternalAccountsController( + $this->appName, + $this->request, + $this->providerRegistry, + $this->userSession, + $this->logger, + ); + } + + public function testCreateWithNoUserSession(): void { + $this->userSession->method('getUser') + ->willReturn(null); + + $response = $this->controller->create('test-provider'); + + $this->assertEquals(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + } + + public function testCreateWithProviderNotFound(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParams') + ->willReturn(['param1' => 'value1']); + + $this->providerRegistry->method('getProvider') + ->with('nonexistent') + ->willReturn(null); + + $response = $this->controller->create('nonexistent'); + + $this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + $this->assertEquals('PROVIDER_NOT_FOUND', $data['data']['error']); + } + + public function testCreateWithDisabledProvider(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParams') + ->willReturn(['param1' => 'value1']); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('isEnabled') + ->willReturn(false); + + $this->providerRegistry->method('getProvider') + ->with('disabled-provider') + ->willReturn($provider); + + $response = $this->controller->create('disabled-provider'); + + $this->assertEquals(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + $this->assertEquals('PROVIDER_NOT_AVAILABLE', $data['data']['error']); + } + + public function testCreateWithProviderNotAvailableForUser(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParams') + ->willReturn(['param1' => 'value1']); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('isEnabled') + ->willReturn(true); + $provider->method('isAvailableForUser') + ->with('testuser') + ->willReturn(false); + + $this->providerRegistry->method('getProvider') + ->with('test-provider') + ->willReturn($provider); + + $response = $this->controller->create('test-provider'); + + $this->assertEquals(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + $this->assertEquals('PROVIDER_NOT_AVAILABLE', $data['data']['error']); + $this->assertStringContainsString('not available for this user', $data['data']['message']); + } + + public function testCreateSuccess(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParams') + ->willReturn([ + 'providerId' => 'test-provider', + '_route' => 'some-route', + 'emailUser' => 'user', + 'accountName' => 'Test Account', + ]); + + $mailAccount = new MailAccount(); + $mailAccount->setId(123); + $mailAccount->setEmail('user@example.com'); + $account = new Account($mailAccount); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('isEnabled') + ->willReturn(true); + $provider->method('isAvailableForUser') + ->with('testuser') + ->willReturn(true); + $provider->method('createAccount') + ->with('testuser', [ + 'emailUser' => 'user', + 'accountName' => 'Test Account', + ]) + ->willReturn($account); + + $this->providerRegistry->method('getProvider') + ->with('test-provider') + ->willReturn($provider); + + $response = $this->controller->create('test-provider'); + + $this->assertEquals(Http::STATUS_CREATED, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('success', $data['status']); + } + + public function testCreateWithInvalidArgumentException(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParams') + ->willReturn(['param1' => 'value1']); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('isEnabled') + ->willReturn(true); + $provider->method('isAvailableForUser') + ->willReturn(true); + $provider->method('createAccount') + ->willThrowException(new \InvalidArgumentException('Missing required parameter')); + + $this->providerRegistry->method('getProvider') + ->with('test-provider') + ->willReturn($provider); + + $response = $this->controller->create('test-provider'); + + $this->assertEquals(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + $this->assertEquals('INVALID_PARAMETERS', $data['data']['error']); + } + + public function testCreateWithServiceException(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParams') + ->willReturn(['param1' => 'value1']); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('isEnabled') + ->willReturn(true); + $provider->method('isAvailableForUser') + ->willReturn(true); + $provider->method('createAccount') + ->willThrowException(new ServiceException('Service error', 500)); + + $this->providerRegistry->method('getProvider') + ->with('test-provider') + ->willReturn($provider); + + $response = $this->controller->create('test-provider'); + + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + $this->assertEquals('SERVICE_ERROR', $data['data']['error']); + } + + public function testCreateWithIonosServiceException(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParams') + ->willReturn(['param1' => 'value1']); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('isEnabled') + ->willReturn(true); + $provider->method('isAvailableForUser') + ->willReturn(true); + $provider->method('createAccount') + ->willThrowException(new IonosServiceException('IONOS error', 503, null, ['detail' => 'API unavailable'])); + + $this->providerRegistry->method('getProvider') + ->with('test-provider') + ->willReturn($provider); + + $response = $this->controller->create('test-provider'); + + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + $this->assertEquals('SERVICE_ERROR', $data['data']['error']); + $this->assertEquals('API unavailable', $data['data']['detail']); + } + + public function testGetProviders(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $capabilities = new ProviderCapabilities( + multipleAccounts: true, + appPasswords: true, + passwordReset: false, + creationParameterSchema: [ + 'param1' => ['type' => 'string', 'required' => true], + ], + emailDomain: 'example.com', + ); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('getId')->willReturn('test-provider'); + $provider->method('getName')->willReturn('Test Provider'); + $provider->method('getCapabilities')->willReturn($capabilities); + + $this->providerRegistry->method('getAvailableProvidersForUser') + ->with('testuser') + ->willReturn(['test-provider' => $provider]); + + $response = $this->controller->getProviders(); + + $this->assertEquals(Http::STATUS_OK, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('success', $data['status']); + $this->assertArrayHasKey('providers', $data['data']); + $this->assertCount(1, $data['data']['providers']); + + $providerInfo = $data['data']['providers'][0]; + $this->assertEquals('test-provider', $providerInfo['id']); + $this->assertEquals('Test Provider', $providerInfo['name']); + $this->assertTrue($providerInfo['capabilities']['multipleAccounts']); + $this->assertTrue($providerInfo['capabilities']['appPasswords']); + $this->assertFalse($providerInfo['capabilities']['passwordReset']); + $this->assertEquals('example.com', $providerInfo['capabilities']['emailDomain']); + } + + public function testGeneratePasswordWithNoAccountId(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParam') + ->with('accountId') + ->willReturn(null); + + $response = $this->controller->generatePassword('test-provider'); + + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + } + + public function testGeneratePasswordWithProviderNotFound(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParam') + ->with('accountId') + ->willReturn(123); + + $this->providerRegistry->method('getProvider') + ->with('nonexistent') + ->willReturn(null); + + $response = $this->controller->generatePassword('nonexistent'); + + $this->assertEquals(Http::STATUS_NOT_FOUND, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + $this->assertEquals('PROVIDER_NOT_FOUND', $data['data']['error']); + } + + public function testGeneratePasswordWithProviderNotSupporting(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('testuser'); + + $this->userSession->method('getUser') + ->willReturn($user); + + $this->request->method('getParam') + ->with('accountId') + ->willReturn(123); + + $capabilities = new ProviderCapabilities( + appPasswords: false, + ); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('getCapabilities') + ->willReturn($capabilities); + + $this->providerRegistry->method('getProvider') + ->with('test-provider') + ->willReturn($provider); + + $response = $this->controller->generatePassword('test-provider'); + + $this->assertEquals(Http::STATUS_BAD_REQUEST, $response->getStatus()); + $data = $response->getData(); + $this->assertEquals('fail', $data['status']); + $this->assertEquals('NOT_SUPPORTED', $data['data']['error']); + } +} diff --git a/tests/Unit/Provider/MailAccountProvider/Implementations/IonosProviderTest.php b/tests/Unit/Provider/MailAccountProvider/Implementations/IonosProviderTest.php new file mode 100644 index 0000000000..338b19c50e --- /dev/null +++ b/tests/Unit/Provider/MailAccountProvider/Implementations/IonosProviderTest.php @@ -0,0 +1,324 @@ +configService = $this->createMock(IonosConfigService::class); + $this->mailService = $this->createMock(IonosMailService::class); + $this->creationService = $this->createMock(IonosAccountCreationService::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->provider = new IonosProvider( + $this->configService, + $this->mailService, + $this->creationService, + $this->logger, + ); + } + + public function testGetId(): void { + $this->assertEquals('ionos', $this->provider->getId()); + } + + public function testGetName(): void { + $this->assertEquals('IONOS Mail', $this->provider->getName()); + } + + public function testGetCapabilities(): void { + $this->configService->method('getMailDomain') + ->willReturn('example.com'); + + $capabilities = $this->provider->getCapabilities(); + + $this->assertFalse($capabilities->allowsMultipleAccounts()); + $this->assertTrue($capabilities->supportsAppPasswords()); + $this->assertTrue($capabilities->supportsPasswordReset()); + $this->assertEquals('example.com', $capabilities->getEmailDomain()); + + $configSchema = $capabilities->getConfigSchema(); + $this->assertArrayHasKey('ionos_mailconfig_api_base_url', $configSchema); + $this->assertArrayHasKey('ionos_mailconfig_api_auth_user', $configSchema); + $this->assertArrayHasKey('ionos_mailconfig_api_auth_pass', $configSchema); + + $creationSchema = $capabilities->getCreationParameterSchema(); + $this->assertArrayHasKey('accountName', $creationSchema); + $this->assertArrayHasKey('emailUser', $creationSchema); + } + + public function testGetCapabilitiesWithExceptionOnDomain(): void { + $this->configService->method('getMailDomain') + ->willThrowException(new \Exception('Config error')); + + $this->logger->expects($this->once()) + ->method('debug') + ->with('Could not get IONOS email domain', $this->anything()); + + $capabilities = $this->provider->getCapabilities(); + + $this->assertNull($capabilities->getEmailDomain()); + } + + public function testGetCapabilitiesCached(): void { + $this->configService->expects($this->once()) + ->method('getMailDomain') + ->willReturn('example.com'); + + // Call twice to test caching + $capabilities1 = $this->provider->getCapabilities(); + $capabilities2 = $this->provider->getCapabilities(); + + $this->assertSame($capabilities1, $capabilities2); + } + + public function testIsEnabledWhenEnabled(): void { + $this->configService->method('isIonosIntegrationEnabled') + ->willReturn(true); + + $this->assertTrue($this->provider->isEnabled()); + } + + public function testIsEnabledWhenDisabled(): void { + $this->configService->method('isIonosIntegrationEnabled') + ->willReturn(false); + + $this->assertFalse($this->provider->isEnabled()); + } + + public function testIsEnabledWithException(): void { + $this->configService->method('isIonosIntegrationEnabled') + ->willThrowException(new \Exception('Config error')); + + $this->logger->expects($this->once()) + ->method('debug') + ->with('IONOS provider is not enabled', $this->anything()); + + $this->assertFalse($this->provider->isEnabled()); + } + + public function testIsAvailableForUserWhenNoAccount(): void { + $this->mailService->method('mailAccountExistsForCurrentUserId') + ->with('testuser') + ->willReturn(false); + + $this->assertTrue($this->provider->isAvailableForUser('testuser')); + } + + public function testIsAvailableForUserWhenHasAccount(): void { + $this->mailService->method('mailAccountExistsForCurrentUserId') + ->with('testuser') + ->willReturn(true); + + $this->assertFalse($this->provider->isAvailableForUser('testuser')); + } + + public function testIsAvailableForUserWithException(): void { + $this->mailService->method('mailAccountExistsForCurrentUserId') + ->with('testuser') + ->willThrowException(new \Exception('Service error')); + + $this->logger->expects($this->once()) + ->method('error') + ->with('Error checking IONOS availability for user', $this->anything()); + + $this->assertFalse($this->provider->isAvailableForUser('testuser')); + } + + public function testCreateAccountSuccess(): void { + $userId = 'testuser'; + $parameters = [ + 'emailUser' => 'user', + 'accountName' => 'Test Account', + ]; + + $mailAccount = new MailAccount(); + $mailAccount->setId(123); + $mailAccount->setEmail('user@example.com'); + $account = new Account($mailAccount); + + $this->creationService->expects($this->once()) + ->method('createOrUpdateAccount') + ->with($userId, 'user', 'Test Account') + ->willReturn($account); + + $result = $this->provider->createAccount($userId, $parameters); + + $this->assertSame($account, $result); + $this->assertEquals('user@example.com', $result->getEmail()); + } + + public function testCreateAccountWithMissingEmailUser(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('emailUser and accountName are required'); + + $this->provider->createAccount('testuser', [ + 'accountName' => 'Test Account', + ]); + } + + public function testCreateAccountWithMissingAccountName(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('emailUser and accountName are required'); + + $this->provider->createAccount('testuser', [ + 'emailUser' => 'user', + ]); + } + + public function testCreateAccountWithEmptyEmailUser(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('emailUser and accountName are required'); + + $this->provider->createAccount('testuser', [ + 'emailUser' => '', + 'accountName' => 'Test Account', + ]); + } + + public function testCreateAccountWithEmptyAccountName(): void { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('emailUser and accountName are required'); + + $this->provider->createAccount('testuser', [ + 'emailUser' => 'user', + 'accountName' => '', + ]); + } + + public function testUpdateAccount(): void { + $userId = 'testuser'; + $accountId = 123; + $parameters = [ + 'emailUser' => 'user', + 'accountName' => 'Updated Account', + ]; + + $mailAccount = new MailAccount(); + $mailAccount->setId($accountId); + $mailAccount->setEmail('user@example.com'); + $account = new Account($mailAccount); + + $this->creationService->expects($this->once()) + ->method('createOrUpdateAccount') + ->with($userId, 'user', 'Updated Account') + ->willReturn($account); + + $result = $this->provider->updateAccount($userId, $accountId, $parameters); + + $this->assertSame($account, $result); + } + + public function testDeleteAccount(): void { + $this->mailService->expects($this->once()) + ->method('deleteEmailAccount') + ->with('testuser') + ->willReturn(true); + + $result = $this->provider->deleteAccount('testuser', 'user@example.com'); + + $this->assertTrue($result); + } + + public function testManagesEmailWhenMatches(): void { + $this->mailService->method('getIonosEmailForUser') + ->with('testuser') + ->willReturn('user@example.com'); + + $this->assertTrue($this->provider->managesEmail('testuser', 'user@example.com')); + } + + public function testManagesEmailCaseInsensitive(): void { + $this->mailService->method('getIonosEmailForUser') + ->with('testuser') + ->willReturn('user@example.com'); + + $this->assertTrue($this->provider->managesEmail('testuser', 'USER@EXAMPLE.COM')); + } + + public function testManagesEmailWhenDoesNotMatch(): void { + $this->mailService->method('getIonosEmailForUser') + ->with('testuser') + ->willReturn('user@example.com'); + + $this->assertFalse($this->provider->managesEmail('testuser', 'other@example.com')); + } + + public function testManagesEmailWhenNoIonosEmail(): void { + $this->mailService->method('getIonosEmailForUser') + ->with('testuser') + ->willReturn(null); + + $this->assertFalse($this->provider->managesEmail('testuser', 'user@example.com')); + } + + public function testManagesEmailWithException(): void { + $this->mailService->method('getIonosEmailForUser') + ->with('testuser') + ->willThrowException(new \Exception('Service error')); + + $this->logger->expects($this->once()) + ->method('debug') + ->with('Error checking if IONOS manages email', $this->anything()); + + $this->assertFalse($this->provider->managesEmail('testuser', 'user@example.com')); + } + + public function testGetProvisionedEmail(): void { + $this->mailService->method('getIonosEmailForUser') + ->with('testuser') + ->willReturn('user@example.com'); + + $result = $this->provider->getProvisionedEmail('testuser'); + + $this->assertEquals('user@example.com', $result); + } + + public function testGetProvisionedEmailWithNoEmail(): void { + $this->mailService->method('getIonosEmailForUser') + ->with('testuser') + ->willReturn(null); + + $result = $this->provider->getProvisionedEmail('testuser'); + + $this->assertNull($result); + } + + public function testGetProvisionedEmailWithException(): void { + $this->mailService->method('getIonosEmailForUser') + ->with('testuser') + ->willThrowException(new \Exception('Service error')); + + $this->logger->expects($this->once()) + ->method('debug') + ->with('Error getting IONOS provisioned email', $this->anything()); + + $result = $this->provider->getProvisionedEmail('testuser'); + + $this->assertNull($result); + } +} diff --git a/tests/Unit/Provider/MailAccountProvider/ProviderRegistryServiceTest.php b/tests/Unit/Provider/MailAccountProvider/ProviderRegistryServiceTest.php new file mode 100644 index 0000000000..0ed0176ed3 --- /dev/null +++ b/tests/Unit/Provider/MailAccountProvider/ProviderRegistryServiceTest.php @@ -0,0 +1,265 @@ +logger = $this->createMock(LoggerInterface::class); + $this->registry = new ProviderRegistryService($this->logger); + } + + public function testRegisterProvider(): void { + $provider = $this->createMockProvider('test', 'Test Provider'); + + $this->registry->registerProvider($provider); + + $this->assertEquals($provider, $this->registry->getProvider('test')); + } + + public function testGetProviderReturnsNullForUnknownId(): void { + $result = $this->registry->getProvider('unknown'); + + $this->assertNull($result); + } + + public function testGetAllProviders(): void { + $provider1 = $this->createMockProvider('test1', 'Test Provider 1'); + $provider2 = $this->createMockProvider('test2', 'Test Provider 2'); + + $this->registry->registerProvider($provider1); + $this->registry->registerProvider($provider2); + + $providers = $this->registry->getAllProviders(); + + $this->assertCount(2, $providers); + $this->assertArrayHasKey('test1', $providers); + $this->assertArrayHasKey('test2', $providers); + } + + public function testGetEnabledProviders(): void { + $enabledProvider = $this->createMockProvider('enabled', 'Enabled', true); + $disabledProvider = $this->createMockProvider('disabled', 'Disabled', false); + + $this->registry->registerProvider($enabledProvider); + $this->registry->registerProvider($disabledProvider); + + $enabled = $this->registry->getEnabledProviders(); + + $this->assertCount(1, $enabled); + $this->assertArrayHasKey('enabled', $enabled); + $this->assertArrayNotHasKey('disabled', $enabled); + } + + public function testGetAvailableProvidersForUser(): void { + $availableProvider = $this->createMockProvider('available', 'Available', true, true); + $unavailableProvider = $this->createMockProvider('unavailable', 'Unavailable', true, false); + + $this->registry->registerProvider($availableProvider); + $this->registry->registerProvider($unavailableProvider); + + $available = $this->registry->getAvailableProvidersForUser('testuser'); + + $this->assertCount(1, $available); + $this->assertArrayHasKey('available', $available); + } + + public function testFindProviderForEmail(): void { + $matchingProvider = $this->createMockProvider('matching', 'Matching', true); + $matchingProvider->method('managesEmail') + ->willReturn(true); + + $nonMatchingProvider = $this->createMockProvider('nonmatching', 'Non-Matching', true); + $nonMatchingProvider->method('managesEmail') + ->willReturn(false); + + $this->registry->registerProvider($matchingProvider); + $this->registry->registerProvider($nonMatchingProvider); + + $result = $this->registry->findProviderForEmail('user', 'test@example.com'); + + $this->assertEquals($matchingProvider, $result); + } + + public function testFindProviderForEmailReturnsNullIfNoneMatch(): void { + $provider = $this->createMockProvider('test', 'Test', true); + $provider->method('managesEmail') + ->willReturn(false); + + $this->registry->registerProvider($provider); + + $result = $this->registry->findProviderForEmail('user', 'test@example.com'); + + $this->assertNull($result); + } + + public function testGetProviderInfo(): void { + $capabilities = new ProviderCapabilities( + multipleAccounts: true, + appPasswords: true, + passwordReset: false + ); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('getId')->willReturn('test'); + $provider->method('getName')->willReturn('Test Provider'); + $provider->method('isEnabled')->willReturn(true); + $provider->method('getCapabilities')->willReturn($capabilities); + + $this->registry->registerProvider($provider); + + $info = $this->registry->getProviderInfo(); + + $this->assertArrayHasKey('test', $info); + $this->assertEquals('test', $info['test']['id']); + $this->assertEquals('Test Provider', $info['test']['name']); + $this->assertTrue($info['test']['enabled']); + $this->assertTrue($info['test']['capabilities']['multipleAccounts']); + $this->assertTrue($info['test']['capabilities']['appPasswords']); + $this->assertFalse($info['test']['capabilities']['passwordReset']); + } + + public function testDeleteProviderManagedAccountsWithNoProviderManaged(): void { + $userId = 'testuser'; + $account = $this->createMockAccount('user@example.com'); + + $provider = $this->createMockProvider('test', 'Test', true); + $provider->method('managesEmail') + ->willReturn(false); + + $this->registry->registerProvider($provider); + + $provider->expects($this->never()) + ->method('deleteAccount'); + + $this->registry->deleteProviderManagedAccounts($userId, [$account]); + } + + public function testDeleteProviderManagedAccountsWithProviderManaged(): void { + $userId = 'testuser'; + $email = 'user@example.com'; + $account = $this->createMockAccount($email); + + $provider = $this->createMockProvider('test', 'Test', true); + $provider->method('managesEmail') + ->with($userId, $email) + ->willReturn(true); + $provider->expects($this->once()) + ->method('deleteAccount') + ->with($userId, $email); + + $this->registry->registerProvider($provider); + + $this->registry->deleteProviderManagedAccounts($userId, [$account]); + } + + public function testDeleteProviderManagedAccountsWithMultipleAccounts(): void { + $userId = 'testuser'; + $email1 = 'user1@example.com'; + $email2 = 'user2@example.com'; + $email3 = 'user3@example.com'; + + $account1 = $this->createMockAccount($email1); + $account2 = $this->createMockAccount($email2); + $account3 = $this->createMockAccount($email3); + + $provider = $this->createMockProvider('test', 'Test', true); + $provider->method('managesEmail') + ->willReturnMap([ + [$userId, $email1, true], + [$userId, $email2, false], + [$userId, $email3, true], + ]); + $provider->expects($this->exactly(2)) + ->method('deleteAccount') + ->willReturnCallback(function ($uid, $email) use ($userId, $email1, $email3) { + $this->assertSame($userId, $uid); + $this->assertContains($email, [$email1, $email3]); + return true; + }); + + $this->registry->registerProvider($provider); + + $this->registry->deleteProviderManagedAccounts($userId, [$account1, $account2, $account3]); + } + + public function testDeleteProviderManagedAccountsContinuesOnException(): void { + $userId = 'testuser'; + $email1 = 'user1@example.com'; + $email2 = 'user2@example.com'; + + $account1 = $this->createMockAccount($email1); + $account2 = $this->createMockAccount($email2); + + $provider = $this->createMockProvider('test', 'Test', true); + $provider->method('managesEmail') + ->willReturn(true); + $provider->expects($this->exactly(2)) + ->method('deleteAccount') + ->willReturnCallback(function ($uid, $email) use ($email1) { + if ($email === $email1) { + throw new \Exception('Deletion failed'); + } + return true; + }); + + $this->registry->registerProvider($provider); + + $this->logger->expects($this->once()) + ->method('error') + ->with('Failed to delete provider-managed account', $this->anything()); + + // Should not throw exception, continues with second account + $this->registry->deleteProviderManagedAccounts($userId, [$account1, $account2]); + } + + private function createMockAccount(string $email): object { + return new class($email) { + private string $email; + + public function __construct(string $email) { + $this->email = $email; + } + + public function getEmail(): string { + return $this->email; + } + }; + } + + private function createMockProvider( + string $id, + string $name, + bool $enabled = true, + bool $availableForUser = true, + ): IMailAccountProvider&MockObject { + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('getId')->willReturn($id); + $provider->method('getName')->willReturn($name); + $provider->method('isEnabled')->willReturn($enabled); + $provider->method('isAvailableForUser')->willReturn($availableForUser); + + $capabilities = new ProviderCapabilities(); + $provider->method('getCapabilities')->willReturn($capabilities); + + return $provider; + } +} diff --git a/tests/Unit/Service/AccountProviderServiceTest.php b/tests/Unit/Service/AccountProviderServiceTest.php new file mode 100644 index 0000000000..f66d1f7d25 --- /dev/null +++ b/tests/Unit/Service/AccountProviderServiceTest.php @@ -0,0 +1,182 @@ +providerRegistry = $this->createMock(ProviderRegistryService::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->service = new AccountProviderService( + $this->providerRegistry, + $this->logger, + ); + } + + public function testAddProviderMetadataWithNoProvider(): void { + $accountJson = [ + 'id' => 123, + 'email' => 'user@example.com', + ]; + + $this->providerRegistry->method('findProviderForEmail') + ->with('testuser', 'user@example.com') + ->willReturn(null); + + $result = $this->service->addProviderMetadata($accountJson, 'testuser', 'user@example.com'); + + $this->assertArrayHasKey('managedByProvider', $result); + $this->assertNull($result['managedByProvider']); + $this->assertArrayHasKey('providerCapabilities', $result); + $this->assertNull($result['providerCapabilities']); + } + + public function testAddProviderMetadataWithProvider(): void { + $accountJson = [ + 'id' => 123, + 'email' => 'user@example.com', + ]; + + $capabilities = new ProviderCapabilities( + multipleAccounts: true, + appPasswords: true, + passwordReset: false, + emailDomain: 'example.com', + ); + + $provider = $this->createMock(IMailAccountProvider::class); + $provider->method('getId') + ->willReturn('test-provider'); + $provider->method('getCapabilities') + ->willReturn($capabilities); + + $this->providerRegistry->method('findProviderForEmail') + ->with('testuser', 'user@example.com') + ->willReturn($provider); + + $result = $this->service->addProviderMetadata($accountJson, 'testuser', 'user@example.com'); + + $this->assertEquals('test-provider', $result['managedByProvider']); + $this->assertIsArray($result['providerCapabilities']); + $this->assertTrue($result['providerCapabilities']['multipleAccounts']); + $this->assertTrue($result['providerCapabilities']['appPasswords']); + $this->assertFalse($result['providerCapabilities']['passwordReset']); + $this->assertEquals('example.com', $result['providerCapabilities']['emailDomain']); + } + + public function testAddProviderMetadataWithException(): void { + $accountJson = [ + 'id' => 123, + 'email' => 'user@example.com', + ]; + + $this->providerRegistry->method('findProviderForEmail') + ->with('testuser', 'user@example.com') + ->willThrowException(new \Exception('Test exception')); + + $this->logger->expects($this->once()) + ->method('debug') + ->with('Error determining account provider', $this->anything()); + + $result = $this->service->addProviderMetadata($accountJson, 'testuser', 'user@example.com'); + + // Should return safe defaults + $this->assertNull($result['managedByProvider']); + $this->assertNull($result['providerCapabilities']); + } + + public function testGetAvailableProvidersForUser(): void { + $capabilities1 = new ProviderCapabilities( + multipleAccounts: true, + appPasswords: true, + passwordReset: false, + creationParameterSchema: [ + 'param1' => ['type' => 'string', 'required' => true], + ], + emailDomain: 'example.com', + ); + + $capabilities2 = new ProviderCapabilities( + multipleAccounts: false, + appPasswords: false, + passwordReset: true, + creationParameterSchema: [ + 'param2' => ['type' => 'string', 'required' => false], + ], + emailDomain: 'test.com', + ); + + $provider1 = $this->createMock(IMailAccountProvider::class); + $provider1->method('getId')->willReturn('provider1'); + $provider1->method('getName')->willReturn('Provider 1'); + $provider1->method('getCapabilities')->willReturn($capabilities1); + + $provider2 = $this->createMock(IMailAccountProvider::class); + $provider2->method('getId')->willReturn('provider2'); + $provider2->method('getName')->willReturn('Provider 2'); + $provider2->method('getCapabilities')->willReturn($capabilities2); + + $this->providerRegistry->method('getAvailableProvidersForUser') + ->with('testuser') + ->willReturn([ + 'provider1' => $provider1, + 'provider2' => $provider2, + ]); + + $result = $this->service->getAvailableProvidersForUser('testuser'); + + $this->assertCount(2, $result); + $this->assertArrayHasKey('provider1', $result); + $this->assertArrayHasKey('provider2', $result); + + // Check provider1 + $this->assertEquals('provider1', $result['provider1']['id']); + $this->assertEquals('Provider 1', $result['provider1']['name']); + $this->assertTrue($result['provider1']['capabilities']['multipleAccounts']); + $this->assertTrue($result['provider1']['capabilities']['appPasswords']); + $this->assertFalse($result['provider1']['capabilities']['passwordReset']); + $this->assertEquals('example.com', $result['provider1']['capabilities']['emailDomain']); + $this->assertArrayHasKey('param1', $result['provider1']['parameterSchema']); + + // Check provider2 + $this->assertEquals('provider2', $result['provider2']['id']); + $this->assertEquals('Provider 2', $result['provider2']['name']); + $this->assertFalse($result['provider2']['capabilities']['multipleAccounts']); + $this->assertFalse($result['provider2']['capabilities']['appPasswords']); + $this->assertTrue($result['provider2']['capabilities']['passwordReset']); + $this->assertEquals('test.com', $result['provider2']['capabilities']['emailDomain']); + $this->assertArrayHasKey('param2', $result['provider2']['parameterSchema']); + } + + public function testGetAvailableProvidersForUserWithNoProviders(): void { + $this->providerRegistry->method('getAvailableProvidersForUser') + ->with('testuser') + ->willReturn([]); + + $result = $this->service->getAvailableProvidersForUser('testuser'); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } +} From 158efcc9edaffe46915e9ba5b33b5a1d429e6708 Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Fri, 19 Dec 2025 18:00:34 +0100 Subject: [PATCH 21/24] IONOS(ionos-mail): refactor(routes): remove legacy IONOS routes and update mail provider handling This commit removes outdated IONOS-specific routes to streamline the routing configuration. It also updates the handling of mail providers to support a more generic approach, allowing for easier integration of multiple email providers in the future. Signed-off-by: Misha M.-Kupriyanov --- appinfo/routes.php | 6 - lib/Controller/PageController.php | 9 +- lib/Listener/UserDeletedListener.php | 13 +- src/components/AccountForm.vue | 12 +- src/components/ExternalProviderTab.vue | 418 ++++++++++++++++++ src/components/ionos/NewEmailAddressTab.vue | 221 --------- src/init.js | 8 +- tests/Unit/Controller/PageControllerTest.php | 26 +- .../Unit/Listener/UserDeletedListenerTest.php | 52 +-- 9 files changed, 472 insertions(+), 293 deletions(-) create mode 100644 src/components/ExternalProviderTab.vue delete mode 100644 src/components/ionos/NewEmailAddressTab.vue diff --git a/appinfo/routes.php b/appinfo/routes.php index cbdabb3b81..6b10476d73 100644 --- a/appinfo/routes.php +++ b/appinfo/routes.php @@ -136,12 +136,6 @@ 'url' => '/api/providers/{providerId}/password', 'verb' => 'POST' ], - // Legacy IONOS routes (backward compatibility) - [ - 'name' => 'ionosAccounts#create', - 'url' => '/api/ionos/accounts', - 'verb' => 'POST' - ], [ 'name' => 'tags#update', 'url' => '/api/tags/{id}', diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index 6166e15a02..ad6f0d817e 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -16,13 +16,12 @@ use OCA\Mail\Contracts\IUserPreferences; use OCA\Mail\Db\SmimeCertificate; use OCA\Mail\Db\TagMapper; +use OCA\Mail\Service\AccountProviderService; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\AiIntegrations\AiIntegrationsService; 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\IONOS\IonosMailConfigService; use OCA\Mail\Service\OutboxService; use OCA\Mail\Service\QuickActionsService; use OCA\Mail\Service\SmimeService; @@ -100,8 +99,7 @@ public function __construct( InternalAddressService $internalAddressService, IAvailabilityCoordinator $availabilityCoordinator, QuickActionsService $quickActionsService, - private IonosConfigService $ionosConfigService, - private IonosMailConfigService $ionosMailConfigService, + private AccountProviderService $accountProviderService, ) { parent::__construct($appName, $request); @@ -216,8 +214,7 @@ public function index(): TemplateResponse { $this->initialStateService->provideInitialState('preferences', [ 'attachment-size-limit' => $this->config->getSystemValue('app.mail.attachment-size-limit', 0), - 'ionos-mailconfig-enabled' => $this->ionosMailConfigService->isMailConfigAvailable(), - 'ionos-mailconfig-domain' => $this->ionosConfigService->getMailDomain(), + 'mail-providers-available' => !empty($this->accountProviderService->getAvailableProvidersForUser($this->currentUserId)), '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/Listener/UserDeletedListener.php b/lib/Listener/UserDeletedListener.php index 30e06ee6a8..326f7192c2 100644 --- a/lib/Listener/UserDeletedListener.php +++ b/lib/Listener/UserDeletedListener.php @@ -10,8 +10,8 @@ namespace OCA\Mail\Listener; use OCA\Mail\Exception\ClientException; +use OCA\Mail\Provider\MailAccountProvider\ProviderRegistryService; use OCA\Mail\Service\AccountService; -use OCA\Mail\Service\IONOS\IonosMailService; use OCP\EventDispatcher\Event; use OCP\EventDispatcher\IEventListener; use OCP\User\Events\UserDeletedEvent; @@ -30,7 +30,7 @@ class UserDeletedListener implements IEventListener { public function __construct( AccountService $accountService, LoggerInterface $logger, - private readonly IonosMailService $ionosMailService, + private readonly ProviderRegistryService $providerRegistry, ) { $this->accountService = $accountService; $this->logger = $logger; @@ -46,11 +46,14 @@ public function handle(Event $event): void { $user = $event->getUser(); $userId = $user->getUID(); - // Delete IONOS mailbox if IONOS integration is enabled - $this->ionosMailService->tryDeleteEmailAccount($userId); + $accounts = $this->accountService->findByUserId($userId); + + // Delete provider-managed accounts (generic system) + // This works with any registered provider (IONOS, Office365, etc.) + $this->providerRegistry->deleteProviderManagedAccounts($userId, $accounts); // Delete all mail accounts in Nextcloud - foreach ($this->accountService->findByUserId($userId) as $account) { + foreach ($accounts as $account) { try { $this->accountService->delete( $userId, diff --git a/src/components/AccountForm.vue b/src/components/AccountForm.vue index 72ec99fa53..a5852cd802 100644 --- a/src/components/AccountForm.vue +++ b/src/components/AccountForm.vue @@ -199,11 +199,11 @@ required @change="clearFeedback" /> - - @@ -261,7 +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' +import ExternalProviderTab from './ExternalProviderTab.vue' export default { name: 'AccountForm', @@ -274,7 +274,7 @@ export default { ButtonVue, IconLoading, IconCheck, - NewEmailAddressTab, + ExternalProviderTab, }, props: { displayName: { @@ -332,8 +332,8 @@ export default { 'microsoftOauthUrl', ]), - useIonosMailconfig() { - return this.mainStore.getPreference('ionos-mailconfig-enabled', null) + useProviderMailconfig() { + return this.mainStore.getPreference('mail-providers-available', false) }, settingsPage() { diff --git a/src/components/ExternalProviderTab.vue b/src/components/ExternalProviderTab.vue new file mode 100644 index 0000000000..4c8f3d4025 --- /dev/null +++ b/src/components/ExternalProviderTab.vue @@ -0,0 +1,418 @@ + + + + + + diff --git a/src/components/ionos/NewEmailAddressTab.vue b/src/components/ionos/NewEmailAddressTab.vue deleted file mode 100644 index 5c65716ab3..0000000000 --- a/src/components/ionos/NewEmailAddressTab.vue +++ /dev/null @@ -1,221 +0,0 @@ - - - - - - - - diff --git a/src/init.js b/src/init.js index bc9529ad6a..a9a851251e 100644 --- a/src/init.js +++ b/src/init.js @@ -38,12 +38,8 @@ export default function initAfterAppCreation() { 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'], + key: 'mail-providers-available', + value: preferences['mail-providers-available'], }) mainStore.savePreferenceMutation({ key: 'external-avatars', diff --git a/tests/Unit/Controller/PageControllerTest.php b/tests/Unit/Controller/PageControllerTest.php index 234db9b33c..73aaa3035c 100644 --- a/tests/Unit/Controller/PageControllerTest.php +++ b/tests/Unit/Controller/PageControllerTest.php @@ -16,13 +16,12 @@ use OCA\Mail\Controller\PageController; use OCA\Mail\Db\Mailbox; use OCA\Mail\Db\TagMapper; +use OCA\Mail\Service\AccountProviderService; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\AiIntegrations\AiIntegrationsService; 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\IONOS\IonosMailConfigService; use OCA\Mail\Service\MailManager; use OCA\Mail\Service\OutboxService; use OCA\Mail\Service\QuickActionsService; @@ -115,9 +114,7 @@ class PageControllerTest extends TestCase { private IAvailabilityCoordinator&MockObject $availabilityCoordinator; - private IonosConfigService&MockObject $ionosConfigService; - - private IonosMailConfigService&MockObject $ionosMailConfigService; + private AccountProviderService&MockObject $accountProviderService; protected function setUp(): void { parent::setUp(); @@ -145,8 +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->ionosMailConfigService = $this->createMock(IonosMailConfigService::class); + $this->accountProviderService = $this->createMock(AccountProviderService::class); $this->controller = new PageController( $this->appName, @@ -172,8 +168,7 @@ protected function setUp(): void { $this->internalAddressService, $this->availabilityCoordinator, $this->quickActionsService, - $this->ionosConfigService, - $this->ionosMailConfigService, + $this->accountProviderService, ); } @@ -291,12 +286,10 @@ public function testIndex(): void { $this->returnValue('cron'), $this->returnValue('yes'), ); - $this->ionosMailConfigService->expects($this->once()) - ->method('isMailConfigAvailable') - ->willReturn(false); - $this->ionosConfigService->expects($this->once()) - ->method('getMailDomain') - ->willReturn('example.tld'); + $this->accountProviderService->expects($this->once()) + ->method('getAvailableProvidersForUser') + ->with($this->userId) + ->willReturn([]); $this->aiIntegrationsService->expects(self::exactly(4)) ->method('isLlmProcessingEnabled') ->willReturn(false); @@ -347,8 +340,6 @@ 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', @@ -356,6 +347,7 @@ public function testIndex(): void { 'layout-mode' => 'vertical-split', 'layout-message-view' => 'threaded', 'follow-up-reminders' => 'true', + 'mail-providers-available' => false, ]], ['prefill_displayName', 'Jane Doe'], ['prefill_email', 'jane@doe.cz'], diff --git a/tests/Unit/Listener/UserDeletedListenerTest.php b/tests/Unit/Listener/UserDeletedListenerTest.php index b027f03f6e..83db0e8a13 100644 --- a/tests/Unit/Listener/UserDeletedListenerTest.php +++ b/tests/Unit/Listener/UserDeletedListenerTest.php @@ -14,8 +14,8 @@ use OCA\Mail\Db\MailAccount; use OCA\Mail\Exception\ClientException; use OCA\Mail\Listener\UserDeletedListener; +use OCA\Mail\Provider\MailAccountProvider\ProviderRegistryService; use OCA\Mail\Service\AccountService; -use OCA\Mail\Service\IONOS\IonosMailService; use OCP\EventDispatcher\Event; use OCP\IUser; use OCP\User\Events\UserDeletedEvent; @@ -25,7 +25,7 @@ class UserDeletedListenerTest extends TestCase { private AccountService&MockObject $accountService; private LoggerInterface&MockObject $logger; - private IonosMailService&MockObject $ionosMailService; + private ProviderRegistryService&MockObject $providerRegistry; private UserDeletedListener $listener; protected function setUp(): void { @@ -33,12 +33,12 @@ protected function setUp(): void { $this->accountService = $this->createMock(AccountService::class); $this->logger = $this->createMock(LoggerInterface::class); - $this->ionosMailService = $this->createMock(IonosMailService::class); + $this->providerRegistry = $this->createMock(ProviderRegistryService::class); $this->listener = new UserDeletedListener( $this->accountService, $this->logger, - $this->ionosMailService + $this->providerRegistry ); } @@ -61,8 +61,8 @@ public function testImplementsIEventListener(): void { public function testHandleUnrelated(): void { $event = new Event(); - $this->ionosMailService->expects($this->never()) - ->method('tryDeleteEmailAccount'); + $this->providerRegistry->expects($this->never()) + ->method('deleteProviderManagedAccounts'); $this->accountService->expects($this->never()) ->method('findByUserId'); @@ -76,15 +76,15 @@ public function testHandleUserDeletedWithNoAccounts(): void { $user = $this->createUserMock('test-user'); $event = new UserDeletedEvent($user); - $this->ionosMailService->expects($this->once()) - ->method('tryDeleteEmailAccount') - ->with('test-user'); - $this->accountService->expects($this->once()) ->method('findByUserId') ->with('test-user') ->willReturn([]); + $this->providerRegistry->expects($this->once()) + ->method('deleteProviderManagedAccounts') + ->with('test-user', []); + $this->accountService->expects($this->never()) ->method('delete'); @@ -99,15 +99,15 @@ public function testHandleUserDeletedWithSingleAccount(): void { $account = $this->createAccountMock(42); $event = new UserDeletedEvent($user); - $this->ionosMailService->expects($this->once()) - ->method('tryDeleteEmailAccount') - ->with('test-user'); - $this->accountService->expects($this->once()) ->method('findByUserId') ->with('test-user') ->willReturn([$account]); + $this->providerRegistry->expects($this->once()) + ->method('deleteProviderManagedAccounts') + ->with('test-user', [$account]); + $this->accountService->expects($this->once()) ->method('delete') ->with('test-user', 42); @@ -125,15 +125,15 @@ public function testHandleUserDeletedWithMultipleAccounts(): void { $account3 = $this->createAccountMock(3); $event = new UserDeletedEvent($user); - $this->ionosMailService->expects($this->once()) - ->method('tryDeleteEmailAccount') - ->with('test-user'); - $this->accountService->expects($this->once()) ->method('findByUserId') ->with('test-user') ->willReturn([$account1, $account2, $account3]); + $this->providerRegistry->expects($this->once()) + ->method('deleteProviderManagedAccounts') + ->with('test-user', [$account1, $account2, $account3]); + $this->accountService->expects($this->exactly(3)) ->method('delete') ->willReturnCallback(function ($userId, $accountId) { @@ -154,15 +154,15 @@ public function testHandleUserDeletedWithClientException(): void { $exception = new ClientException('Test exception'); - $this->ionosMailService->expects($this->once()) - ->method('tryDeleteEmailAccount') - ->with('test-user'); - $this->accountService->expects($this->once()) ->method('findByUserId') ->with('test-user') ->willReturn([$account]); + $this->providerRegistry->expects($this->once()) + ->method('deleteProviderManagedAccounts') + ->with('test-user', [$account]); + $this->accountService->expects($this->once()) ->method('delete') ->with('test-user', 42) @@ -187,15 +187,15 @@ public function testHandleUserDeletedWithPartialFailure(): void { $exception = new ClientException('Failed to delete account 2'); - $this->ionosMailService->expects($this->once()) - ->method('tryDeleteEmailAccount') - ->with('test-user'); - $this->accountService->expects($this->once()) ->method('findByUserId') ->with('test-user') ->willReturn([$account1, $account2, $account3]); + $this->providerRegistry->expects($this->once()) + ->method('deleteProviderManagedAccounts') + ->with('test-user', [$account1, $account2, $account3]); + $this->accountService->expects($this->exactly(3)) ->method('delete') ->willReturnCallback(function ($userId, $accountId) use ($exception) { From 97870e079e695b6a22f4638a485f06b2f31b883f Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Fri, 19 Dec 2025 21:53:25 +0100 Subject: [PATCH 22/24] IONOS(ionos-mail): refactor(provider): implement facade pattern to reduce coupling Introduce IonosProviderFacade to reduce coupling between IonosProvider and IONOS services. This is Phase 1 of the provider architecture refactoring (Option C - Minimal Refactoring). Changes: - Create IonosProviderFacade with unified interface for IONOS operations - Update IonosProvider to use facade instead of 3+ service dependencies - Reduce IonosProvider dependencies by 66% (from 3+ to 1) - Add comprehensive unit tests for IonosProviderFacade (19 test methods) - Simplify IonosProviderTest to use facade mock - Add architecture analysis and refactoring roadmap documentation Benefits: - Improved testability (1 mock vs 3+ mocks in IonosProvider tests) - Reduced coupling between provider and service layers - Clear abstraction boundary for future provider implementations - Foundation for adding new providers (Office365, Google, etc.) Next Phase: Split IonosMailService into focused services Signed-off-by: Misha M.-Kupriyanov --- .../Ionos/IonosProviderFacade.php | 185 ++++++++++ .../Implementations/IonosProvider.php | 73 +--- .../Ionos/IonosProviderFacadeTest.php | 344 ++++++++++++++++++ .../Implementations/IonosProviderTest.php | 136 ++----- 4 files changed, 576 insertions(+), 162 deletions(-) create mode 100644 lib/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacade.php create mode 100644 tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacadeTest.php diff --git a/lib/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacade.php b/lib/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacade.php new file mode 100644 index 0000000000..246594e54b --- /dev/null +++ b/lib/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacade.php @@ -0,0 +1,185 @@ +configService->isIonosIntegrationEnabled(); + } catch (\Exception $e) { + $this->logger->debug('IONOS provider is not enabled', [ + 'exception' => $e, + ]); + return false; + } + } + + /** + * Check if IONOS account provisioning is available for a user + * + * For IONOS, account is available only if user doesn't already have one + * (since multipleAccounts = false) + * + * @param string $userId The Nextcloud user ID + * @return bool True if provisioning should be shown + */ + public function isAvailableForUser(string $userId): bool { + try { + $hasAccount = $this->mailService->mailAccountExistsForCurrentUserId($userId); + return !$hasAccount; + } catch (\Exception $e) { + $this->logger->error('Error checking IONOS availability for user', [ + 'userId' => $userId, + 'exception' => $e, + ]); + return false; + } + } + + /** + * Create or update an IONOS mail account + * + * @param string $userId The Nextcloud user ID + * @param string $emailUser The email username (local part before @) + * @param string $accountName The display name for the account + * @return Account The created or updated mail account + * @throws ServiceException If account creation fails + */ + public function createAccount(string $userId, string $emailUser, string $accountName): Account { + $this->logger->info('Creating IONOS account via facade', [ + 'userId' => $userId, + 'emailUser' => $emailUser, + ]); + + return $this->creationService->createOrUpdateAccount($userId, $emailUser, $accountName); + } + + /** + * Update an existing IONOS mail account + * + * Currently uses the same logic as creation (which handles updates) + * + * @param string $userId The Nextcloud user ID + * @param string $emailUser The email username (local part before @) + * @param string $accountName The display name for the account + * @return Account The updated account + * @throws ServiceException If update fails + */ + public function updateAccount(string $userId, string $emailUser, string $accountName): Account { + $this->logger->info('Updating IONOS account via facade', [ + 'userId' => $userId, + 'emailUser' => $emailUser, + ]); + + // Currently, creation service handles both create and update + return $this->creationService->createOrUpdateAccount($userId, $emailUser, $accountName); + } + + /** + * Delete an IONOS mail account + * + * @param string $userId The Nextcloud user ID + * @return bool True if deletion was successful + */ + public function deleteAccount(string $userId): bool { + $this->logger->info('Deleting IONOS account via facade', [ + 'userId' => $userId, + ]); + + try { + $this->mailService->tryDeleteEmailAccount($userId); + return true; + } catch (\Exception $e) { + $this->logger->error('Error deleting IONOS account via facade', [ + 'userId' => $userId, + 'exception' => $e, + ]); + return false; + } + } + + /** + * Get the provisioned email address for a user + * + * @param string $userId The Nextcloud user ID + * @return string|null The email address or null if no account exists + */ + public function getProvisionedEmail(string $userId): ?string { + try { + return $this->mailService->getIonosEmailForUser($userId); + } catch (\Exception $e) { + $this->logger->debug('Error getting IONOS provisioned email', [ + 'userId' => $userId, + 'exception' => $e, + ]); + return null; + } + } + + /** + * Check if a specific email address is managed by IONOS for a user + * + * @param string $userId The Nextcloud user ID + * @param string $email The email address to check + * @return bool True if this email is managed by IONOS + */ + public function managesEmail(string $userId, string $email): bool { + $ionosEmail = $this->getProvisionedEmail($userId); + if ($ionosEmail === null) { + return false; + } + return strcasecmp($email, $ionosEmail) === 0; + } + + /** + * Get the email domain used by IONOS + * + * @return string|null The email domain or null if not configured + */ + public function getEmailDomain(): ?string { + try { + return $this->configService->getMailDomain(); + } catch (\Exception $e) { + $this->logger->debug('Could not get IONOS email domain', [ + 'exception' => $e, + ]); + return null; + } + } +} diff --git a/lib/Provider/MailAccountProvider/Implementations/IonosProvider.php b/lib/Provider/MailAccountProvider/Implementations/IonosProvider.php index 4e3cc02910..dcaea65949 100644 --- a/lib/Provider/MailAccountProvider/Implementations/IonosProvider.php +++ b/lib/Provider/MailAccountProvider/Implementations/IonosProvider.php @@ -11,17 +11,15 @@ use OCA\Mail\Account; use OCA\Mail\Provider\MailAccountProvider\IMailAccountProvider; +use OCA\Mail\Provider\MailAccountProvider\Implementations\Ionos\IonosProviderFacade; use OCA\Mail\Provider\MailAccountProvider\IProviderCapabilities; use OCA\Mail\Provider\MailAccountProvider\ProviderCapabilities; -use OCA\Mail\Service\IONOS\IonosAccountCreationService; -use OCA\Mail\Service\IONOS\IonosConfigService; -use OCA\Mail\Service\IONOS\IonosMailService; -use Psr\Log\LoggerInterface; /** * IONOS Mail Account Provider * * Provides mail account provisioning through the IONOS Mail API + * Uses a facade pattern to reduce coupling with IONOS services */ class IonosProvider implements IMailAccountProvider { private const PROVIDER_ID = 'ionos'; @@ -30,10 +28,7 @@ class IonosProvider implements IMailAccountProvider { private ?IProviderCapabilities $capabilities = null; public function __construct( - private IonosConfigService $configService, - private IonosMailService $mailService, - private IonosAccountCreationService $creationService, - private LoggerInterface $logger, + private IonosProviderFacade $facade, ) { } @@ -47,15 +42,8 @@ public function getName(): string { public function getCapabilities(): IProviderCapabilities { if ($this->capabilities === null) { - // Get email domain from config service - $emailDomain = null; - try { - $emailDomain = $this->configService->getMailDomain(); - } catch (\Exception $e) { - $this->logger->debug('Could not get IONOS email domain', [ - 'exception' => $e, - ]); - } + // Get email domain via facade + $emailDomain = $this->facade->getEmailDomain(); $this->capabilities = new ProviderCapabilities( multipleAccounts: false, // IONOS allows only one account per user @@ -112,29 +100,11 @@ public function getCapabilities(): IProviderCapabilities { } public function isEnabled(): bool { - try { - return $this->configService->isIonosIntegrationEnabled(); - } catch (\Exception $e) { - $this->logger->debug('IONOS provider is not enabled', [ - 'exception' => $e, - ]); - return false; - } + return $this->facade->isEnabled(); } public function isAvailableForUser(string $userId): bool { - try { - // For IONOS, account is available only if user doesn't already have one - // (since multipleAccounts = false) - $hasAccount = $this->mailService->mailAccountExistsForCurrentUserId($userId); - return !$hasAccount; - } catch (\Exception $e) { - $this->logger->error('Error checking IONOS availability for user', [ - 'userId' => $userId, - 'exception' => $e, - ]); - return false; - } + return $this->facade->isAvailableForUser($userId); } public function createAccount(string $userId, array $parameters): Account { @@ -145,7 +115,7 @@ public function createAccount(string $userId, array $parameters): Account { throw new \InvalidArgumentException('emailUser and accountName are required'); } - return $this->creationService->createOrUpdateAccount($userId, $emailUser, $accountName); + return $this->facade->createAccount($userId, $emailUser, $accountName); } public function updateAccount(string $userId, int $accountId, array $parameters): Account { @@ -154,35 +124,14 @@ public function updateAccount(string $userId, int $accountId, array $parameters) } public function deleteAccount(string $userId, string $email): bool { - return $this->mailService->deleteEmailAccount($userId); + return $this->facade->deleteAccount($userId); } public function managesEmail(string $userId, string $email): bool { - try { - $ionosEmail = $this->mailService->getIonosEmailForUser($userId); - if ($ionosEmail === null) { - return false; - } - return strcasecmp($email, $ionosEmail) === 0; - } catch (\Exception $e) { - $this->logger->debug('Error checking if IONOS manages email', [ - 'userId' => $userId, - 'email' => $email, - 'exception' => $e, - ]); - return false; - } + return $this->facade->managesEmail($userId, $email); } public function getProvisionedEmail(string $userId): ?string { - try { - return $this->mailService->getIonosEmailForUser($userId); - } catch (\Exception $e) { - $this->logger->debug('Error getting IONOS provisioned email', [ - 'userId' => $userId, - 'exception' => $e, - ]); - return null; - } + return $this->facade->getProvisionedEmail($userId); } } diff --git a/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacadeTest.php b/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacadeTest.php new file mode 100644 index 0000000000..37072d9001 --- /dev/null +++ b/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacadeTest.php @@ -0,0 +1,344 @@ +configService = $this->createMock(IonosConfigService::class); + $this->mailService = $this->createMock(IonosMailService::class); + $this->creationService = $this->createMock(IonosAccountCreationService::class); + $this->deletionService = $this->createMock(IonosAccountDeletionService::class); + $this->logger = $this->createMock(LoggerInterface::class); + + $this->facade = new IonosProviderFacade( + $this->configService, + $this->mailService, + $this->creationService, + $this->deletionService, + $this->logger, + ); + } + + public function testIsEnabledReturnsTrue(): void { + $this->configService->expects($this->once()) + ->method('isIonosIntegrationEnabled') + ->willReturn(true); + + $result = $this->facade->isEnabled(); + + $this->assertTrue($result); + } + + public function testIsEnabledReturnsFalse(): void { + $this->configService->expects($this->once()) + ->method('isIonosIntegrationEnabled') + ->willReturn(false); + + $result = $this->facade->isEnabled(); + + $this->assertFalse($result); + } + + public function testIsEnabledHandlesException(): void { + $this->configService->expects($this->once()) + ->method('isIonosIntegrationEnabled') + ->willThrowException(new \Exception('Config error')); + + $this->logger->expects($this->once()) + ->method('debug') + ->with('IONOS provider is not enabled', $this->anything()); + + $result = $this->facade->isEnabled(); + + $this->assertFalse($result); + } + + public function testIsAvailableForUserReturnsTrueWhenNoAccount(): void { + $userId = 'user123'; + + $this->mailService->expects($this->once()) + ->method('mailAccountExistsForCurrentUserId') + ->with($userId) + ->willReturn(false); + + $result = $this->facade->isAvailableForUser($userId); + + $this->assertTrue($result); + } + + public function testIsAvailableForUserReturnsFalseWhenAccountExists(): void { + $userId = 'user123'; + + $this->mailService->expects($this->once()) + ->method('mailAccountExistsForCurrentUserId') + ->with($userId) + ->willReturn(true); + + $result = $this->facade->isAvailableForUser($userId); + + $this->assertFalse($result); + } + + public function testIsAvailableForUserHandlesException(): void { + $userId = 'user123'; + + $this->mailService->expects($this->once()) + ->method('mailAccountExistsForCurrentUserId') + ->with($userId) + ->willThrowException(new \Exception('Service error')); + + $this->logger->expects($this->once()) + ->method('error') + ->with('Error checking IONOS availability for user', $this->anything()); + + $result = $this->facade->isAvailableForUser($userId); + + $this->assertFalse($result); + } + + public function testCreateAccountSuccess(): void { + $userId = 'user123'; + $emailUser = 'john.doe'; + $accountName = 'John Doe'; + $account = $this->createMock(Account::class); + + $this->logger->expects($this->once()) + ->method('info') + ->with('Creating IONOS account via facade', [ + 'userId' => $userId, + 'emailUser' => $emailUser, + ]); + + $this->creationService->expects($this->once()) + ->method('createOrUpdateAccount') + ->with($userId, $emailUser, $accountName) + ->willReturn($account); + + $result = $this->facade->createAccount($userId, $emailUser, $accountName); + + $this->assertSame($account, $result); + } + + public function testUpdateAccountSuccess(): void { + $userId = 'user123'; + $emailUser = 'john.doe'; + $accountName = 'John Doe'; + $account = $this->createMock(Account::class); + + $this->logger->expects($this->once()) + ->method('info') + ->with('Updating IONOS account via facade', [ + 'userId' => $userId, + 'emailUser' => $emailUser, + ]); + + $this->creationService->expects($this->once()) + ->method('createOrUpdateAccount') + ->with($userId, $emailUser, $accountName) + ->willReturn($account); + + $result = $this->facade->updateAccount($userId, $emailUser, $accountName); + + $this->assertSame($account, $result); + } + + public function testDeleteAccountSuccess(): void { + $userId = 'user123'; + + $this->logger->expects($this->once()) + ->method('info') + ->with('Deleting IONOS account via facade', [ + 'userId' => $userId, + ]); + + $this->mailService->expects($this->once()) + ->method('tryDeleteEmailAccount') + ->with($userId); + + $result = $this->facade->deleteAccount($userId); + + $this->assertTrue($result); + } + + public function testDeleteAccountHandlesException(): void { + $userId = 'user123'; + + $this->logger->expects($this->once()) + ->method('info') + ->with('Deleting IONOS account via facade', [ + 'userId' => $userId, + ]); + + $this->mailService->expects($this->once()) + ->method('tryDeleteEmailAccount') + ->with($userId) + ->willThrowException(new \Exception('Deletion failed')); + + $this->logger->expects($this->once()) + ->method('error') + ->with('Error deleting IONOS account via facade', $this->anything()); + + $result = $this->facade->deleteAccount($userId); + + $this->assertFalse($result); + } + + public function testGetProvisionedEmailSuccess(): void { + $userId = 'user123'; + $email = 'user@ionos.com'; + + $this->mailService->expects($this->once()) + ->method('getIonosEmailForUser') + ->with($userId) + ->willReturn($email); + + $result = $this->facade->getProvisionedEmail($userId); + + $this->assertSame($email, $result); + } + + public function testGetProvisionedEmailHandlesException(): void { + $userId = 'user123'; + + $this->mailService->expects($this->once()) + ->method('getIonosEmailForUser') + ->with($userId) + ->willThrowException(new \Exception('Service error')); + + $this->logger->expects($this->once()) + ->method('debug') + ->with('Error getting IONOS provisioned email', $this->anything()); + + $result = $this->facade->getProvisionedEmail($userId); + + $this->assertNull($result); + } + + public function testManagesEmailReturnsTrue(): void { + $userId = 'user123'; + $email = 'user@ionos.com'; + + $this->mailService->expects($this->once()) + ->method('getIonosEmailForUser') + ->with($userId) + ->willReturn($email); + + $result = $this->facade->managesEmail($userId, $email); + + $this->assertTrue($result); + } + + public function testManagesEmailReturnsTrueCaseInsensitive(): void { + $userId = 'user123'; + $email = 'user@ionos.com'; + $checkEmail = 'USER@IONOS.COM'; + + $this->mailService->expects($this->once()) + ->method('getIonosEmailForUser') + ->with($userId) + ->willReturn($email); + + $result = $this->facade->managesEmail($userId, $checkEmail); + + $this->assertTrue($result); + } + + public function testManagesEmailReturnsFalseWhenNoIonosAccount(): void { + $userId = 'user123'; + $email = 'user@other.com'; + + $this->mailService->expects($this->once()) + ->method('getIonosEmailForUser') + ->with($userId) + ->willReturn(null); + + $result = $this->facade->managesEmail($userId, $email); + + $this->assertFalse($result); + } + + public function testManagesEmailReturnsFalseWhenDifferentEmail(): void { + $userId = 'user123'; + $ionosEmail = 'user@ionos.com'; + $checkEmail = 'other@ionos.com'; + + $this->mailService->expects($this->once()) + ->method('getIonosEmailForUser') + ->with($userId) + ->willReturn($ionosEmail); + + $result = $this->facade->managesEmail($userId, $checkEmail); + + $this->assertFalse($result); + } + + public function testManagesEmailHandlesException(): void { + $userId = 'user123'; + $email = 'user@ionos.com'; + + $this->mailService->expects($this->once()) + ->method('getIonosEmailForUser') + ->with($userId) + ->willThrowException(new \Exception('Service error')); + + $this->logger->expects($this->once()) + ->method('debug') + ->with('Error getting IONOS provisioned email', $this->anything()); + + $result = $this->facade->managesEmail($userId, $email); + + $this->assertFalse($result); + } + + public function testGetEmailDomainSuccess(): void { + $domain = 'ionos.com'; + + $this->configService->expects($this->once()) + ->method('getMailDomain') + ->willReturn($domain); + + $result = $this->facade->getEmailDomain(); + + $this->assertSame($domain, $result); + } + + public function testGetEmailDomainHandlesException(): void { + $this->configService->expects($this->once()) + ->method('getMailDomain') + ->willThrowException(new \Exception('Config error')); + + $this->logger->expects($this->once()) + ->method('debug') + ->with('Could not get IONOS email domain', $this->anything()); + + $result = $this->facade->getEmailDomain(); + + $this->assertNull($result); + } +} diff --git a/tests/Unit/Provider/MailAccountProvider/Implementations/IonosProviderTest.php b/tests/Unit/Provider/MailAccountProvider/Implementations/IonosProviderTest.php index 338b19c50e..bbf04c406e 100644 --- a/tests/Unit/Provider/MailAccountProvider/Implementations/IonosProviderTest.php +++ b/tests/Unit/Provider/MailAccountProvider/Implementations/IonosProviderTest.php @@ -9,36 +9,24 @@ namespace OCA\Mail\Tests\Unit\Provider\MailAccountProvider\Implementations; +use ChristophWurst\Nextcloud\Testing\TestCase; use OCA\Mail\Account; use OCA\Mail\Db\MailAccount; +use OCA\Mail\Provider\MailAccountProvider\Implementations\Ionos\IonosProviderFacade; use OCA\Mail\Provider\MailAccountProvider\Implementations\IonosProvider; -use OCA\Mail\Service\IONOS\IonosAccountCreationService; -use OCA\Mail\Service\IONOS\IonosConfigService; -use OCA\Mail\Service\IONOS\IonosMailService; use PHPUnit\Framework\MockObject\MockObject; -use Psr\Log\LoggerInterface; -use Test\TestCase; class IonosProviderTest extends TestCase { - private IonosConfigService&MockObject $configService; - private IonosMailService&MockObject $mailService; - private IonosAccountCreationService&MockObject $creationService; - private LoggerInterface&MockObject $logger; + private IonosProviderFacade&MockObject $facade; private IonosProvider $provider; protected function setUp(): void { parent::setUp(); - $this->configService = $this->createMock(IonosConfigService::class); - $this->mailService = $this->createMock(IonosMailService::class); - $this->creationService = $this->createMock(IonosAccountCreationService::class); - $this->logger = $this->createMock(LoggerInterface::class); + $this->facade = $this->createMock(IonosProviderFacade::class); $this->provider = new IonosProvider( - $this->configService, - $this->mailService, - $this->creationService, - $this->logger, + $this->facade, ); } @@ -51,7 +39,7 @@ public function testGetName(): void { } public function testGetCapabilities(): void { - $this->configService->method('getMailDomain') + $this->facade->method('getEmailDomain') ->willReturn('example.com'); $capabilities = $this->provider->getCapabilities(); @@ -72,12 +60,8 @@ public function testGetCapabilities(): void { } public function testGetCapabilitiesWithExceptionOnDomain(): void { - $this->configService->method('getMailDomain') - ->willThrowException(new \Exception('Config error')); - - $this->logger->expects($this->once()) - ->method('debug') - ->with('Could not get IONOS email domain', $this->anything()); + $this->facade->method('getEmailDomain') + ->willReturn(null); $capabilities = $this->provider->getCapabilities(); @@ -85,8 +69,8 @@ public function testGetCapabilitiesWithExceptionOnDomain(): void { } public function testGetCapabilitiesCached(): void { - $this->configService->expects($this->once()) - ->method('getMailDomain') + $this->facade->expects($this->once()) + ->method('getEmailDomain') ->willReturn('example.com'); // Call twice to test caching @@ -97,54 +81,31 @@ public function testGetCapabilitiesCached(): void { } public function testIsEnabledWhenEnabled(): void { - $this->configService->method('isIonosIntegrationEnabled') + $this->facade->method('isEnabled') ->willReturn(true); $this->assertTrue($this->provider->isEnabled()); } public function testIsEnabledWhenDisabled(): void { - $this->configService->method('isIonosIntegrationEnabled') + $this->facade->method('isEnabled') ->willReturn(false); $this->assertFalse($this->provider->isEnabled()); } - public function testIsEnabledWithException(): void { - $this->configService->method('isIonosIntegrationEnabled') - ->willThrowException(new \Exception('Config error')); - - $this->logger->expects($this->once()) - ->method('debug') - ->with('IONOS provider is not enabled', $this->anything()); - - $this->assertFalse($this->provider->isEnabled()); - } - public function testIsAvailableForUserWhenNoAccount(): void { - $this->mailService->method('mailAccountExistsForCurrentUserId') + $this->facade->method('isAvailableForUser') ->with('testuser') - ->willReturn(false); + ->willReturn(true); $this->assertTrue($this->provider->isAvailableForUser('testuser')); } public function testIsAvailableForUserWhenHasAccount(): void { - $this->mailService->method('mailAccountExistsForCurrentUserId') + $this->facade->method('isAvailableForUser') ->with('testuser') - ->willReturn(true); - - $this->assertFalse($this->provider->isAvailableForUser('testuser')); - } - - public function testIsAvailableForUserWithException(): void { - $this->mailService->method('mailAccountExistsForCurrentUserId') - ->with('testuser') - ->willThrowException(new \Exception('Service error')); - - $this->logger->expects($this->once()) - ->method('error') - ->with('Error checking IONOS availability for user', $this->anything()); + ->willReturn(false); $this->assertFalse($this->provider->isAvailableForUser('testuser')); } @@ -161,8 +122,8 @@ public function testCreateAccountSuccess(): void { $mailAccount->setEmail('user@example.com'); $account = new Account($mailAccount); - $this->creationService->expects($this->once()) - ->method('createOrUpdateAccount') + $this->facade->expects($this->once()) + ->method('createAccount') ->with($userId, 'user', 'Test Account') ->willReturn($account); @@ -223,8 +184,8 @@ public function testUpdateAccount(): void { $mailAccount->setEmail('user@example.com'); $account = new Account($mailAccount); - $this->creationService->expects($this->once()) - ->method('createOrUpdateAccount') + $this->facade->expects($this->once()) + ->method('createAccount') ->with($userId, 'user', 'Updated Account') ->willReturn($account); @@ -234,8 +195,8 @@ public function testUpdateAccount(): void { } public function testDeleteAccount(): void { - $this->mailService->expects($this->once()) - ->method('deleteEmailAccount') + $this->facade->expects($this->once()) + ->method('deleteAccount') ->with('testuser') ->willReturn(true); @@ -245,51 +206,39 @@ public function testDeleteAccount(): void { } public function testManagesEmailWhenMatches(): void { - $this->mailService->method('getIonosEmailForUser') - ->with('testuser') - ->willReturn('user@example.com'); + $this->facade->method('managesEmail') + ->with('testuser', 'user@example.com') + ->willReturn(true); $this->assertTrue($this->provider->managesEmail('testuser', 'user@example.com')); } public function testManagesEmailCaseInsensitive(): void { - $this->mailService->method('getIonosEmailForUser') - ->with('testuser') - ->willReturn('user@example.com'); + $this->facade->method('managesEmail') + ->with('testuser', 'USER@EXAMPLE.COM') + ->willReturn(true); $this->assertTrue($this->provider->managesEmail('testuser', 'USER@EXAMPLE.COM')); } public function testManagesEmailWhenDoesNotMatch(): void { - $this->mailService->method('getIonosEmailForUser') - ->with('testuser') - ->willReturn('user@example.com'); + $this->facade->method('managesEmail') + ->with('testuser', 'other@example.com') + ->willReturn(false); $this->assertFalse($this->provider->managesEmail('testuser', 'other@example.com')); } public function testManagesEmailWhenNoIonosEmail(): void { - $this->mailService->method('getIonosEmailForUser') - ->with('testuser') - ->willReturn(null); - - $this->assertFalse($this->provider->managesEmail('testuser', 'user@example.com')); - } - - public function testManagesEmailWithException(): void { - $this->mailService->method('getIonosEmailForUser') - ->with('testuser') - ->willThrowException(new \Exception('Service error')); - - $this->logger->expects($this->once()) - ->method('debug') - ->with('Error checking if IONOS manages email', $this->anything()); + $this->facade->method('managesEmail') + ->with('testuser', 'user@example.com') + ->willReturn(false); $this->assertFalse($this->provider->managesEmail('testuser', 'user@example.com')); } public function testGetProvisionedEmail(): void { - $this->mailService->method('getIonosEmailForUser') + $this->facade->method('getProvisionedEmail') ->with('testuser') ->willReturn('user@example.com'); @@ -299,23 +248,10 @@ public function testGetProvisionedEmail(): void { } public function testGetProvisionedEmailWithNoEmail(): void { - $this->mailService->method('getIonosEmailForUser') + $this->facade->method('getProvisionedEmail') ->with('testuser') ->willReturn(null); - $result = $this->provider->getProvisionedEmail('testuser'); - - $this->assertNull($result); - } - - public function testGetProvisionedEmailWithException(): void { - $this->mailService->method('getIonosEmailForUser') - ->with('testuser') - ->willThrowException(new \Exception('Service error')); - - $this->logger->expects($this->once()) - ->method('debug') - ->with('Error getting IONOS provisioned email', $this->anything()); $result = $this->provider->getProvisionedEmail('testuser'); From 246210a87ec47b0d096ddb328d83e6379936bf4a Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Fri, 19 Dec 2025 22:10:17 +0100 Subject: [PATCH 23/24] IONOS(ionos-mail): refactor(services): split IonosMailService into focused query and mutation services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split the 557-line IonosMailService into two focused services following Single Responsibility Principle and CQRS pattern. This is Phase 2 of the provider architecture refactoring (Option C - Minimal Refactoring). Changes: - Create IonosAccountQueryService (237 lines) for read operations - Create IonosAccountMutationService (381 lines) for write operations - Update IonosProviderFacade to use new split services - Update IonosProviderFacadeTest to use new service mocks - Achieve 32-57% reduction in service size Benefits: - Clear separation of concerns (CQRS pattern: Command/Query separation) - Single Responsibility Principle applied (read vs write) - Reduced cognitive complexity (smaller, focused services) - Better testability (isolated concerns) - Easier maintenance (clear boundaries) Query Service (Read Operations): - mailAccountExistsForCurrentUser/UserId - getMailAccountResponse - getAccountConfigForUser/CurrentUser - getIonosEmailForUser - getMailDomain Mutation Service (Write Operations): - createEmailAccount/ForUser - deleteEmailAccount - tryDeleteEmailAccount - resetAppPassword Test Results: - IonosProviderFacadeTest: 19 tests, 47 assertions ✅ - All Provider Tests: 53 tests, 114 assertions ✅ Files Created: - lib/Service/IONOS/Core/IonosAccountQueryService.php (237 lines) - lib/Service/IONOS/Core/IonosAccountMutationService.php (381 lines) Files Modified: - lib/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacade.php - tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacadeTest.php Next Phase: Move DTOs to shared location Signed-off-by: Misha M.-Kupriyanov --- .../Ionos/IonosProviderFacade.php | 14 +- .../Core/IonosAccountMutationService.php | 385 ++++++++++++++++++ .../IONOS/Core/IonosAccountQueryService.php | 245 +++++++++++ .../Ionos/IonosProviderFacadeTest.php | 46 +-- 4 files changed, 660 insertions(+), 30 deletions(-) create mode 100644 lib/Service/IONOS/Core/IonosAccountMutationService.php create mode 100644 lib/Service/IONOS/Core/IonosAccountQueryService.php diff --git a/lib/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacade.php b/lib/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacade.php index 246594e54b..c4144bb239 100644 --- a/lib/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacade.php +++ b/lib/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacade.php @@ -11,10 +11,10 @@ use OCA\Mail\Account; use OCA\Mail\Exception\ServiceException; +use OCA\Mail\Service\IONOS\Core\IonosAccountMutationService; +use OCA\Mail\Service\IONOS\Core\IonosAccountQueryService; use OCA\Mail\Service\IONOS\IonosAccountCreationService; -use OCA\Mail\Service\IONOS\IonosAccountDeletionService; use OCA\Mail\Service\IONOS\IonosConfigService; -use OCA\Mail\Service\IONOS\IonosMailService; use Psr\Log\LoggerInterface; /** @@ -27,9 +27,9 @@ class IonosProviderFacade { public function __construct( private IonosConfigService $configService, - private IonosMailService $mailService, + private IonosAccountQueryService $queryService, + private IonosAccountMutationService $mutationService, private IonosAccountCreationService $creationService, - private IonosAccountDeletionService $deletionService, private LoggerInterface $logger, ) { } @@ -61,7 +61,7 @@ public function isEnabled(): bool { */ public function isAvailableForUser(string $userId): bool { try { - $hasAccount = $this->mailService->mailAccountExistsForCurrentUserId($userId); + $hasAccount = $this->queryService->mailAccountExistsForUserId($userId); return !$hasAccount; } catch (\Exception $e) { $this->logger->error('Error checking IONOS availability for user', [ @@ -123,7 +123,7 @@ public function deleteAccount(string $userId): bool { ]); try { - $this->mailService->tryDeleteEmailAccount($userId); + $this->mutationService->tryDeleteEmailAccount($userId); return true; } catch (\Exception $e) { $this->logger->error('Error deleting IONOS account via facade', [ @@ -142,7 +142,7 @@ public function deleteAccount(string $userId): bool { */ public function getProvisionedEmail(string $userId): ?string { try { - return $this->mailService->getIonosEmailForUser($userId); + return $this->queryService->getIonosEmailForUser($userId); } catch (\Exception $e) { $this->logger->debug('Error getting IONOS provisioned email', [ 'userId' => $userId, diff --git a/lib/Service/IONOS/Core/IonosAccountMutationService.php b/lib/Service/IONOS/Core/IonosAccountMutationService.php new file mode 100644 index 0000000000..8f78e23cb4 --- /dev/null +++ b/lib/Service/IONOS/Core/IonosAccountMutationService.php @@ -0,0 +1,385 @@ +getCurrentUserId(); + return $this->createEmailAccountForUser($userId, $userName); + } + + /** + * Create an IONOS email account via API for a specific user + * + * This method allows creating email accounts without relying on the user session, + * making it suitable for use in OCC commands or admin operations. + * + * @param string $userId The Nextcloud user ID + * @param string $userName The local part of the email address (before @domain) + * @return MailAccountConfig Mail account configuration + * @throws ServiceException + */ + public function createEmailAccountForUser(string $userId, string $userName): MailAccountConfig { + $domain = $this->configService->getMailDomain(); + + $this->logger->debug('Sending request to mailconfig service', [ + 'extRef' => $this->configService->getExternalReference(), + 'userName' => $userName, + 'domain' => $domain, + 'apiBaseUrl' => $this->configService->getApiBaseUrl(), + 'userId' => $userId + ]); + + $apiInstance = $this->createApiInstance(); + + $mailCreateData = new MailCreateData(); + $mailCreateData->setNextcloudUserId($userId); + $mailCreateData->setLocalPart($userName); + + if (!$mailCreateData->valid()) { + $this->logger->error('Validate message to mailconfig service', [ + 'data' => $mailCreateData->listInvalidProperties(), + 'userId' => $userId, + 'userName' => $userName + ]); + throw new ServiceException('Invalid mail configuration', self::HTTP_INTERNAL_SERVER_ERROR); + } + + try { + $this->logger->debug('Send message to mailconfig service', ['data' => $mailCreateData]); + $result = $apiInstance->createMailbox(self::BRAND, $this->configService->getExternalReference(), $mailCreateData); + + if ($result instanceof MailAddonErrorMessage) { + $this->logger->error('Failed to create ionos mail', [ + 'status code' => $result->getStatus(), + 'message' => $result->getMessage(), + 'userId' => $userId, + 'userName' => $userName + ]); + throw new ServiceException('Failed to create ionos mail', $result->getStatus()); + } + if ($result instanceof MailAccountCreatedResponse) { + $this->logger->info('Successfully created IONOS mail account', [ + 'email' => $result->getEmail(), + 'userId' => $userId, + 'userName' => $userName + ]); + return $this->buildSuccessResponse($result); + } + + $this->logger->error('Failed to create ionos mail: Unknown response type', [ + 'data' => $result, + 'userId' => $userId, + 'userName' => $userName + ]); + throw new ServiceException('Failed to create ionos mail', self::HTTP_INTERNAL_SERVER_ERROR); + } catch (ServiceException $e) { + // Re-throw ServiceException without additional logging + throw $e; + } catch (ApiException $e) { + $this->logger->error('API Exception when calling MailConfigurationAPIApi->createMailbox', [ + 'statusCode' => $e->getCode(), + 'message' => $e->getMessage(), + 'responseBody' => $e->getResponseBody() + ]); + throw new ServiceException('Failed to create ionos mail: ' . $e->getMessage(), $e->getCode(), $e); + } catch (\Exception $e) { + $this->logger->error('Exception when calling MailConfigurationAPIApi->createMailbox', [ + 'exception' => $e, + 'userId' => $userId, + 'userName' => $userName + ]); + throw new ServiceException('Failed to create ionos mail', self::HTTP_INTERNAL_SERVER_ERROR, $e); + } + } + + /** + * Delete an IONOS email account via API + * + * @param string $userId The Nextcloud user ID + * @return bool true if deletion was successful + * @throws ServiceException + */ + public function deleteEmailAccount(string $userId): bool { + $this->logger->info('Attempting to delete IONOS email account', [ + 'userId' => $userId, + 'extRef' => $this->configService->getExternalReference(), + ]); + + try { + $apiInstance = $this->createApiInstance(); + + $apiInstance->deleteMailbox(self::BRAND, $this->configService->getExternalReference(), $userId); + + $this->logger->info('Successfully deleted IONOS email account', [ + 'userId' => $userId + ]); + + return true; + } catch (ApiException $e) { + // 404 means the mailbox doesn't exist - treat as success + if ($e->getCode() === self::HTTP_NOT_FOUND) { + $this->logger->debug('IONOS mailbox does not exist (already deleted or never created)', [ + 'userId' => $userId, + 'statusCode' => $e->getCode() + ]); + return true; + } + + $this->logger->error('API Exception when calling MailConfigurationAPIApi->deleteMailbox', [ + 'statusCode' => $e->getCode(), + 'message' => $e->getMessage(), + 'responseBody' => $e->getResponseBody(), + 'userId' => $userId + ]); + + throw new ServiceException('Failed to delete IONOS mail: ' . $e->getMessage(), $e->getCode(), $e); + } catch (\Exception $e) { + $this->logger->error('Exception when calling MailConfigurationAPIApi->deleteMailbox', [ + 'exception' => $e, + 'userId' => $userId + ]); + + throw new ServiceException('Failed to delete IONOS mail', self::HTTP_INTERNAL_SERVER_ERROR, $e); + } + } + + /** + * Delete an IONOS email account without throwing exceptions (fire and forget) + * + * This method checks if IONOS integration is enabled and attempts to delete + * the email account. All errors are logged but not thrown, making it safe + * to call in event listeners or other contexts where exceptions should not + * interrupt the flow. + * + * @param string $userId The Nextcloud user ID + * @return void + */ + public function tryDeleteEmailAccount(string $userId): void { + // Check if IONOS integration is enabled + if (!$this->configService->isIonosIntegrationEnabled()) { + $this->logger->debug('IONOS integration is not enabled, skipping email account deletion', [ + 'userId' => $userId + ]); + return; + } + + try { + $this->deleteEmailAccount($userId); + // Success is already logged by deleteEmailAccount + } catch (ServiceException $e) { + $this->logger->error('Failed to delete IONOS mailbox for user', [ + 'userId' => $userId, + 'exception' => $e, + ]); + // Don't throw - this is a fire and forget operation + } + } + + /** + * Reset app password for the IONOS mail account (generates a new password) + * + * @param string $userId The Nextcloud user ID + * @param string $appName The application name for the password + * @return string The new password + * @throws ServiceException + */ + public function resetAppPassword(string $userId, string $appName): string { + $this->logger->debug('Resetting IONOS app password', [ + 'userId' => $userId, + 'appName' => $appName, + 'extRef' => $this->configService->getExternalReference(), + ]); + + try { + $apiInstance = $this->createApiInstance(); + $result = $apiInstance->setAppPassword( + self::BRAND, + $this->configService->getExternalReference(), + $userId, + $appName + ); + + if (is_string($result)) { + $this->logger->info('Successfully reset IONOS app password', [ + 'userId' => $userId, + 'appName' => $appName + ]); + return $result; + } + + $this->logger->error('Failed to reset IONOS app password: Unexpected response type', [ + 'userId' => $userId, + 'appName' => $appName, + 'result' => $result + ]); + throw new ServiceException('Failed to reset IONOS app password', self::HTTP_INTERNAL_SERVER_ERROR); + } catch (ServiceException $e) { + // Re-throw ServiceException without additional logging + throw $e; + } catch (ApiException $e) { + $this->logger->error('API Exception when calling MailConfigurationAPIApi->setAppPassword', [ + 'statusCode' => $e->getCode(), + 'message' => $e->getMessage(), + 'responseBody' => $e->getResponseBody(), + 'userId' => $userId, + 'appName' => $appName + ]); + throw new ServiceException('Failed to reset IONOS app password: ' . $e->getMessage(), $e->getCode(), $e); + } catch (\Exception $e) { + $this->logger->error('Exception when calling MailConfigurationAPIApi->setAppPassword', [ + 'exception' => $e, + 'userId' => $userId, + 'appName' => $appName + ]); + throw new ServiceException('Failed to reset IONOS app password', self::HTTP_INTERNAL_SERVER_ERROR, $e); + } + } + + /** + * Get the current user ID from the session + * + * @return string The user ID + * @throws ServiceException If no user is logged in + */ + private function getCurrentUserId(): string { + $user = $this->userSession->getUser(); + if ($user === null) { + $this->logger->error('No user session found when attempting to create IONOS mail account'); + throw new ServiceException('No user session found'); + } + return $user->getUID(); + } + + /** + * Create and configure API instance with authentication + * + * @return \IONOS\MailConfigurationAPI\Client\Api\MailConfigurationAPIApi + */ + private function createApiInstance(): \IONOS\MailConfigurationAPI\Client\Api\MailConfigurationAPIApi { + $client = $this->apiClientService->newClient([ + 'auth' => [$this->configService->getBasicAuthUser(), $this->configService->getBasicAuthPassword()], + 'verify' => !$this->configService->getAllowInsecure(), + ]); + + return $this->apiClientService->newMailConfigurationAPIApi($client, $this->configService->getApiBaseUrl()); + } + + /** + * 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')) { + $result = 'tls'; + } elseif (str_contains($normalized, 'ssl')) { + $result = 'ssl'; + } else { + $result = 'none'; + } + + $this->logger->debug('Normalized SSL mode', [ + 'input' => $apiSslMode, + 'output' => $result + ]); + + return $result; + } + + /** + * Build success response with mail configuration from MailAccountCreatedResponse (newly created account) + * + * @param MailAccountCreatedResponse $response The account response from createFunctionalAccount + * @return MailAccountConfig The mail account configuration with password + */ + private function buildSuccessResponse(MailAccountCreatedResponse $response): MailAccountConfig { + return $this->buildMailAccountConfig( + $response->getServer()->getImap(), + $response->getServer()->getSmtp(), + $response->getEmail(), + $response->getPassword() + ); + } + + /** + * Build mail account configuration from server details + * + * @param Imap $imapServer IMAP server configuration object + * @param Smtp $smtpServer SMTP server configuration object + * @param string $email Email address + * @param string $password Account password + * @return MailAccountConfig Complete mail account configuration + */ + private function buildMailAccountConfig(Imap $imapServer, Smtp $smtpServer, string $email, string $password): MailAccountConfig { + $imapConfig = new MailServerConfig( + host: $imapServer->getHost(), + port: $imapServer->getPort(), + security: $this->normalizeSslMode($imapServer->getSslMode()), + username: $email, + password: $password, + ); + + $smtpConfig = new MailServerConfig( + host: $smtpServer->getHost(), + port: $smtpServer->getPort(), + security: $this->normalizeSslMode($smtpServer->getSslMode()), + username: $email, + password: $password, + ); + + return new MailAccountConfig( + email: $email, + imap: $imapConfig, + smtp: $smtpConfig, + ); + } +} diff --git a/lib/Service/IONOS/Core/IonosAccountQueryService.php b/lib/Service/IONOS/Core/IonosAccountQueryService.php new file mode 100644 index 0000000000..7da60b3365 --- /dev/null +++ b/lib/Service/IONOS/Core/IonosAccountQueryService.php @@ -0,0 +1,245 @@ +getCurrentUserId(); + return $this->mailAccountExistsForUserId($userId); + } + + /** + * Check if a specific user has an IONOS email account + * + * @param string $userId The user ID to check + * @return bool true if account exists, false otherwise + */ + public function mailAccountExistsForUserId(string $userId): bool { + $response = $this->getMailAccountResponse($userId); + + if ($response !== null) { + $this->logger->debug('User has existing IONOS mail account', [ + 'email' => $response->getEmail(), + 'userId' => $userId + ]); + return true; + } + + return false; + } + + /** + * Get the IONOS mail account response for a specific user + * + * @param string $userId The Nextcloud user ID + * @return MailAccountResponse|null The account response if it exists, null otherwise + */ + public function getMailAccountResponse(string $userId): ?MailAccountResponse { + try { + $this->logger->debug('Getting IONOS mail account for user', [ + 'userId' => $userId, + 'extRef' => $this->configService->getExternalReference(), + ]); + + $apiInstance = $this->createApiInstance(); + $result = $apiInstance->getFunctionalAccount( + self::BRAND, + $this->configService->getExternalReference(), + $userId + ); + + if ($result instanceof MailAccountResponse) { + return $result; + } + + return null; + } catch (ApiException $e) { + // 404 - no account exists + if ($e->getCode() === self::HTTP_NOT_FOUND) { + $this->logger->debug('No IONOS mail account found for user', [ + 'userId' => $userId, + 'statusCode' => $e->getCode(), + ]); + return null; + } + + // Other errors + $this->logger->error('Error checking IONOS mail account', [ + 'userId' => $userId, + 'statusCode' => $e->getCode(), + 'error' => $e->getMessage(), + ]); + return null; + } catch (\Exception $e) { + $this->logger->error('Unexpected error checking IONOS mail account', [ + 'userId' => $userId, + 'error' => $e->getMessage(), + ]); + return null; + } + } + + /** + * Get account configuration for a specific user + * + * @param string $userId The Nextcloud user ID + * @return MailAccountConfig|null Account configuration or null if not found + */ + public function getAccountConfigForUser(string $userId): ?MailAccountConfig { + $response = $this->getMailAccountResponse($userId); + + if ($response === null) { + return null; + } + + return $this->mapResponseToAccountConfig($response); + } + + /** + * Get account configuration for the current logged-in user + * + * @return MailAccountConfig|null Account configuration or null if not found + */ + public function getAccountConfigForCurrentUser(): ?MailAccountConfig { + $userId = $this->getCurrentUserId(); + return $this->getAccountConfigForUser($userId); + } + + /** + * Get the IONOS email address for a specific user + * + * @param string $userId The Nextcloud user ID + * @return string|null The email address or null if no account exists + */ + public function getIonosEmailForUser(string $userId): ?string { + try { + $response = $this->getMailAccountResponse($userId); + + if ($response === null) { + $this->logger->debug('No IONOS email found for user', [ + 'userId' => $userId, + ]); + return null; + } + + $email = $response->getEmail(); + $this->logger->debug('Retrieved IONOS email for user', [ + 'userId' => $userId, + 'email' => $email, + ]); + + return $email; + } catch (\Exception $e) { + $this->logger->error('Error getting IONOS email for user', [ + 'userId' => $userId, + 'error' => $e->getMessage(), + ]); + return null; + } + } + + /** + * Get the configured mail domain + * + * @return string The mail domain + */ + public function getMailDomain(): string { + return $this->configService->getMailDomain(); + } + + /** + * Get the current user ID from the session + * + * @return string The user ID + * @throws \RuntimeException If no user is logged in + */ + private function getCurrentUserId(): string { + $user = $this->userSession->getUser(); + if ($user === null) { + throw new \RuntimeException('No user logged in'); + } + return $user->getUID(); + } + + /** + * Create and configure API instance with authentication + * + * @return \IONOS\MailConfigurationAPI\Client\Api\MailConfigurationAPIApi + */ + private function createApiInstance(): \IONOS\MailConfigurationAPI\Client\Api\MailConfigurationAPIApi { + $client = $this->apiClientService->newClient([ + 'auth' => [$this->configService->getBasicAuthUser(), $this->configService->getBasicAuthPassword()], + 'verify' => !$this->configService->getAllowInsecure(), + ]); + + return $this->apiClientService->newMailConfigurationAPIApi($client, $this->configService->getApiBaseUrl()); + } + + /** + * Map API response to MailAccountConfig + * + * @param MailAccountResponse $response The API response + * @return MailAccountConfig The mapped configuration + */ + private function mapResponseToAccountConfig(MailAccountResponse $response): MailAccountConfig { + $imapServer = $response->getImap(); + $smtpServer = $response->getSmtp(); + + $imap = new MailServerConfig( + host: $imapServer->getHost(), + port: $imapServer->getPort(), + security: 'tls', // Default, should be normalized from API response + username: $response->getEmail(), + password: $imapServer->getPassword() + ); + + $smtp = new MailServerConfig( + host: $smtpServer->getHost(), + port: $smtpServer->getPort(), + security: 'tls', // Default, should be normalized from API response + username: $response->getEmail(), + password: $smtpServer->getPassword() + ); + + return new MailAccountConfig( + email: $response->getEmail(), + imap: $imap, + smtp: $smtp + ); + } +} diff --git a/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacadeTest.php b/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacadeTest.php index 37072d9001..f58c67fa6b 100644 --- a/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacadeTest.php +++ b/tests/Unit/Provider/MailAccountProvider/Implementations/Ionos/IonosProviderFacadeTest.php @@ -12,18 +12,18 @@ use ChristophWurst\Nextcloud\Testing\TestCase; use OCA\Mail\Account; use OCA\Mail\Provider\MailAccountProvider\Implementations\Ionos\IonosProviderFacade; +use OCA\Mail\Service\IONOS\Core\IonosAccountMutationService; +use OCA\Mail\Service\IONOS\Core\IonosAccountQueryService; use OCA\Mail\Service\IONOS\IonosAccountCreationService; -use OCA\Mail\Service\IONOS\IonosAccountDeletionService; use OCA\Mail\Service\IONOS\IonosConfigService; -use OCA\Mail\Service\IONOS\IonosMailService; use PHPUnit\Framework\MockObject\MockObject; use Psr\Log\LoggerInterface; class IonosProviderFacadeTest extends TestCase { private IonosConfigService&MockObject $configService; - private IonosMailService&MockObject $mailService; + private IonosAccountQueryService&MockObject $queryService; + private IonosAccountMutationService&MockObject $mutationService; private IonosAccountCreationService&MockObject $creationService; - private IonosAccountDeletionService&MockObject $deletionService; private LoggerInterface&MockObject $logger; private IonosProviderFacade $facade; @@ -31,16 +31,16 @@ protected function setUp(): void { parent::setUp(); $this->configService = $this->createMock(IonosConfigService::class); - $this->mailService = $this->createMock(IonosMailService::class); + $this->queryService = $this->createMock(IonosAccountQueryService::class); + $this->mutationService = $this->createMock(IonosAccountMutationService::class); $this->creationService = $this->createMock(IonosAccountCreationService::class); - $this->deletionService = $this->createMock(IonosAccountDeletionService::class); $this->logger = $this->createMock(LoggerInterface::class); $this->facade = new IonosProviderFacade( $this->configService, - $this->mailService, + $this->queryService, + $this->mutationService, $this->creationService, - $this->deletionService, $this->logger, ); } @@ -82,8 +82,8 @@ public function testIsEnabledHandlesException(): void { public function testIsAvailableForUserReturnsTrueWhenNoAccount(): void { $userId = 'user123'; - $this->mailService->expects($this->once()) - ->method('mailAccountExistsForCurrentUserId') + $this->queryService->expects($this->once()) + ->method('mailAccountExistsForUserId') ->with($userId) ->willReturn(false); @@ -95,8 +95,8 @@ public function testIsAvailableForUserReturnsTrueWhenNoAccount(): void { public function testIsAvailableForUserReturnsFalseWhenAccountExists(): void { $userId = 'user123'; - $this->mailService->expects($this->once()) - ->method('mailAccountExistsForCurrentUserId') + $this->queryService->expects($this->once()) + ->method('mailAccountExistsForUserId') ->with($userId) ->willReturn(true); @@ -108,8 +108,8 @@ public function testIsAvailableForUserReturnsFalseWhenAccountExists(): void { public function testIsAvailableForUserHandlesException(): void { $userId = 'user123'; - $this->mailService->expects($this->once()) - ->method('mailAccountExistsForCurrentUserId') + $this->queryService->expects($this->once()) + ->method('mailAccountExistsForUserId') ->with($userId) ->willThrowException(new \Exception('Service error')); @@ -177,7 +177,7 @@ public function testDeleteAccountSuccess(): void { 'userId' => $userId, ]); - $this->mailService->expects($this->once()) + $this->mutationService->expects($this->once()) ->method('tryDeleteEmailAccount') ->with($userId); @@ -195,7 +195,7 @@ public function testDeleteAccountHandlesException(): void { 'userId' => $userId, ]); - $this->mailService->expects($this->once()) + $this->mutationService->expects($this->once()) ->method('tryDeleteEmailAccount') ->with($userId) ->willThrowException(new \Exception('Deletion failed')); @@ -213,7 +213,7 @@ public function testGetProvisionedEmailSuccess(): void { $userId = 'user123'; $email = 'user@ionos.com'; - $this->mailService->expects($this->once()) + $this->queryService->expects($this->once()) ->method('getIonosEmailForUser') ->with($userId) ->willReturn($email); @@ -226,7 +226,7 @@ public function testGetProvisionedEmailSuccess(): void { public function testGetProvisionedEmailHandlesException(): void { $userId = 'user123'; - $this->mailService->expects($this->once()) + $this->queryService->expects($this->once()) ->method('getIonosEmailForUser') ->with($userId) ->willThrowException(new \Exception('Service error')); @@ -244,7 +244,7 @@ public function testManagesEmailReturnsTrue(): void { $userId = 'user123'; $email = 'user@ionos.com'; - $this->mailService->expects($this->once()) + $this->queryService->expects($this->once()) ->method('getIonosEmailForUser') ->with($userId) ->willReturn($email); @@ -259,7 +259,7 @@ public function testManagesEmailReturnsTrueCaseInsensitive(): void { $email = 'user@ionos.com'; $checkEmail = 'USER@IONOS.COM'; - $this->mailService->expects($this->once()) + $this->queryService->expects($this->once()) ->method('getIonosEmailForUser') ->with($userId) ->willReturn($email); @@ -273,7 +273,7 @@ public function testManagesEmailReturnsFalseWhenNoIonosAccount(): void { $userId = 'user123'; $email = 'user@other.com'; - $this->mailService->expects($this->once()) + $this->queryService->expects($this->once()) ->method('getIonosEmailForUser') ->with($userId) ->willReturn(null); @@ -288,7 +288,7 @@ public function testManagesEmailReturnsFalseWhenDifferentEmail(): void { $ionosEmail = 'user@ionos.com'; $checkEmail = 'other@ionos.com'; - $this->mailService->expects($this->once()) + $this->queryService->expects($this->once()) ->method('getIonosEmailForUser') ->with($userId) ->willReturn($ionosEmail); @@ -302,7 +302,7 @@ public function testManagesEmailHandlesException(): void { $userId = 'user123'; $email = 'user@ionos.com'; - $this->mailService->expects($this->once()) + $this->queryService->expects($this->once()) ->method('getIonosEmailForUser') ->with($userId) ->willThrowException(new \Exception('Service error')); From f0fde12b113c23f4f0e6f82763d371556a4c7702 Mon Sep 17 00:00:00 2001 From: "Misha M.-Kupriyanov" Date: Fri, 19 Dec 2025 22:17:20 +0100 Subject: [PATCH 24/24] IONOS(ionos-mail): refactor(dtos): move DTOs to shared Common location for reusability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move MailAccountConfig and MailServerConfig DTOs from IONOS-specific location to shared Common location, making them reusable by future providers (Office365, Google, etc.). This is Phase 3 of the provider architecture refactoring (Option C - Minimal Refactoring). Changes: - Create lib/Provider/MailAccountProvider/Common/Dto/ directory - Move MailServerConfig to Common/Dto with updated namespace - Move MailAccountConfig to Common/Dto with updated namespace - Update all 5 import statements across IONOS services - Enable 100% reusability across all future providers Benefits: - DTOs are now provider-agnostic and fully reusable - Clear namespace indicates shared infrastructure - No code duplication needed for future providers - Better organization with clear separation of concerns - Single location to maintain and extend DTOs Before (IONOS-specific): - Namespace: OCA\Mail\Service\IONOS\Dto - Location: lib/Service/IONOS/Dto/ - Usage: IONOS only After (Shared): - Namespace: OCA\Mail\Provider\MailAccountProvider\Common\Dto - Location: lib/Provider/MailAccountProvider/Common/Dto/ - Usage: All providers (IONOS, Office365, Google, etc.) Files Updated: - IonosAccountQueryService - IonosAccountMutationService - IonosAccountCreationService - ConflictResolutionResult - IonosMailService Test Results: - All Provider Tests: 53 tests, 114 assertions ✅ Files Created: - lib/Provider/MailAccountProvider/Common/Dto/MailServerConfig.php - lib/Provider/MailAccountProvider/Common/Dto/MailAccountConfig.php Next Phase: Documentation and polish Signed-off-by: Misha M.-Kupriyanov --- .../Common/Dto/MailAccountConfig.php | 63 +++++++++++++++ .../Common/Dto/MailServerConfig.php | 77 +++++++++++++++++++ .../IONOS/ConflictResolutionResult.php | 2 +- .../Core/IonosAccountMutationService.php | 4 +- .../IONOS/Core/IonosAccountQueryService.php | 4 +- .../IONOS/IonosAccountCreationService.php | 2 +- lib/Service/IONOS/IonosMailService.php | 4 +- .../IONOS/ConflictResolutionResultTest.php | 4 +- .../IonosAccountConflictResolverTest.php | 4 +- .../IONOS/IonosAccountCreationServiceTest.php | 4 +- .../Service/IONOS/IonosMailServiceTest.php | 2 +- 11 files changed, 155 insertions(+), 15 deletions(-) create mode 100644 lib/Provider/MailAccountProvider/Common/Dto/MailAccountConfig.php create mode 100644 lib/Provider/MailAccountProvider/Common/Dto/MailServerConfig.php diff --git a/lib/Provider/MailAccountProvider/Common/Dto/MailAccountConfig.php b/lib/Provider/MailAccountProvider/Common/Dto/MailAccountConfig.php new file mode 100644 index 0000000000..4ff748814a --- /dev/null +++ b/lib/Provider/MailAccountProvider/Common/Dto/MailAccountConfig.php @@ -0,0 +1,63 @@ +email; + } + + public function getImap(): MailServerConfig { + return $this->imap; + } + + public function getSmtp(): MailServerConfig { + return $this->smtp; + } + + /** + * Create a new instance with updated passwords for both IMAP and SMTP + * + * @param string $newPassword The new password to use + * @return self New instance with updated passwords + */ + public function withPassword(string $newPassword): self { + return new self( + email: $this->email, + imap: $this->imap->withPassword($newPassword), + smtp: $this->smtp->withPassword($newPassword), + ); + } + + /** + * 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/Provider/MailAccountProvider/Common/Dto/MailServerConfig.php b/lib/Provider/MailAccountProvider/Common/Dto/MailServerConfig.php new file mode 100644 index 0000000000..5092ef12c7 --- /dev/null +++ b/lib/Provider/MailAccountProvider/Common/Dto/MailServerConfig.php @@ -0,0 +1,77 @@ +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; + } + + /** + * Create a new instance with a different password + * + * @param string $newPassword The new password to use + * @return self New instance with updated password + */ + public function withPassword(string $newPassword): self { + return new self( + host: $this->host, + port: $this->port, + security: $this->security, + username: $this->username, + password: $newPassword, + ); + } + + /** + * 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/ConflictResolutionResult.php b/lib/Service/IONOS/ConflictResolutionResult.php index f1a402c10e..35db775627 100644 --- a/lib/Service/IONOS/ConflictResolutionResult.php +++ b/lib/Service/IONOS/ConflictResolutionResult.php @@ -8,7 +8,7 @@ */ namespace OCA\Mail\Service\IONOS; -use OCA\Mail\Service\IONOS\Dto\MailAccountConfig; +use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailAccountConfig; /** * Result of conflict resolution when IONOS account creation fails diff --git a/lib/Service/IONOS/Core/IonosAccountMutationService.php b/lib/Service/IONOS/Core/IonosAccountMutationService.php index 8f78e23cb4..45217bbe00 100644 --- a/lib/Service/IONOS/Core/IonosAccountMutationService.php +++ b/lib/Service/IONOS/Core/IonosAccountMutationService.php @@ -16,9 +16,9 @@ use IONOS\MailConfigurationAPI\Client\Model\MailCreateData; use IONOS\MailConfigurationAPI\Client\Model\Smtp; use OCA\Mail\Exception\ServiceException; +use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailAccountConfig; +use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailServerConfig; use OCA\Mail\Service\IONOS\ApiMailConfigClientService; -use OCA\Mail\Service\IONOS\Dto\MailAccountConfig; -use OCA\Mail\Service\IONOS\Dto\MailServerConfig; use OCA\Mail\Service\IONOS\IonosConfigService; use OCP\IUserSession; use Psr\Log\LoggerInterface; diff --git a/lib/Service/IONOS/Core/IonosAccountQueryService.php b/lib/Service/IONOS/Core/IonosAccountQueryService.php index 7da60b3365..9c7487a40d 100644 --- a/lib/Service/IONOS/Core/IonosAccountQueryService.php +++ b/lib/Service/IONOS/Core/IonosAccountQueryService.php @@ -11,9 +11,9 @@ use IONOS\MailConfigurationAPI\Client\ApiException; use IONOS\MailConfigurationAPI\Client\Model\MailAccountResponse; +use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailAccountConfig; +use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailServerConfig; use OCA\Mail\Service\IONOS\ApiMailConfigClientService; -use OCA\Mail\Service\IONOS\Dto\MailAccountConfig; -use OCA\Mail\Service\IONOS\Dto\MailServerConfig; use OCA\Mail\Service\IONOS\IonosConfigService; use OCP\IUserSession; use Psr\Log\LoggerInterface; diff --git a/lib/Service/IONOS/IonosAccountCreationService.php b/lib/Service/IONOS/IonosAccountCreationService.php index 482ebc0db0..0620845a8a 100644 --- a/lib/Service/IONOS/IonosAccountCreationService.php +++ b/lib/Service/IONOS/IonosAccountCreationService.php @@ -13,8 +13,8 @@ use OCA\Mail\Db\MailAccount; use OCA\Mail\Exception\IonosServiceException; use OCA\Mail\Exception\ServiceException; +use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailAccountConfig; use OCA\Mail\Service\AccountService; -use OCA\Mail\Service\IONOS\Dto\MailAccountConfig; use OCP\Security\ICrypto; use Psr\Log\LoggerInterface; diff --git a/lib/Service/IONOS/IonosMailService.php b/lib/Service/IONOS/IonosMailService.php index 84c9027271..0b5ee943d1 100644 --- a/lib/Service/IONOS/IonosMailService.php +++ b/lib/Service/IONOS/IonosMailService.php @@ -18,8 +18,8 @@ use IONOS\MailConfigurationAPI\Client\Model\MailCreateData; use IONOS\MailConfigurationAPI\Client\Model\Smtp; use OCA\Mail\Exception\ServiceException; -use OCA\Mail\Service\IONOS\Dto\MailAccountConfig; -use OCA\Mail\Service\IONOS\Dto\MailServerConfig; +use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailAccountConfig; +use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailServerConfig; use OCP\Exceptions\AppConfigException; use OCP\IUserSession; use Psr\Log\LoggerInterface; diff --git a/tests/Unit/Service/IONOS/ConflictResolutionResultTest.php b/tests/Unit/Service/IONOS/ConflictResolutionResultTest.php index a140e4bca1..5b38eae62a 100644 --- a/tests/Unit/Service/IONOS/ConflictResolutionResultTest.php +++ b/tests/Unit/Service/IONOS/ConflictResolutionResultTest.php @@ -10,9 +10,9 @@ namespace OCA\Mail\Tests\Unit\Service\IONOS; use ChristophWurst\Nextcloud\Testing\TestCase; +use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailAccountConfig; +use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailServerConfig; use OCA\Mail\Service\IONOS\ConflictResolutionResult; -use OCA\Mail\Service\IONOS\Dto\MailAccountConfig; -use OCA\Mail\Service\IONOS\Dto\MailServerConfig; class ConflictResolutionResultTest extends TestCase { private MailAccountConfig $accountConfig; diff --git a/tests/Unit/Service/IONOS/IonosAccountConflictResolverTest.php b/tests/Unit/Service/IONOS/IonosAccountConflictResolverTest.php index 5c8379f2b8..ccc6541304 100644 --- a/tests/Unit/Service/IONOS/IonosAccountConflictResolverTest.php +++ b/tests/Unit/Service/IONOS/IonosAccountConflictResolverTest.php @@ -10,8 +10,8 @@ namespace OCA\Mail\Tests\Unit\Service\IONOS; use ChristophWurst\Nextcloud\Testing\TestCase; -use OCA\Mail\Service\IONOS\Dto\MailAccountConfig; -use OCA\Mail\Service\IONOS\Dto\MailServerConfig; +use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailAccountConfig; +use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailServerConfig; use OCA\Mail\Service\IONOS\IonosAccountConflictResolver; use OCA\Mail\Service\IONOS\IonosConfigService; use OCA\Mail\Service\IONOS\IonosMailService; diff --git a/tests/Unit/Service/IONOS/IonosAccountCreationServiceTest.php b/tests/Unit/Service/IONOS/IonosAccountCreationServiceTest.php index 3eb96d89c3..6814772616 100644 --- a/tests/Unit/Service/IONOS/IonosAccountCreationServiceTest.php +++ b/tests/Unit/Service/IONOS/IonosAccountCreationServiceTest.php @@ -14,10 +14,10 @@ use OCA\Mail\Db\MailAccount; use OCA\Mail\Exception\IonosServiceException; use OCA\Mail\Exception\ServiceException; +use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailAccountConfig; +use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailServerConfig; use OCA\Mail\Service\AccountService; use OCA\Mail\Service\IONOS\ConflictResolutionResult; -use OCA\Mail\Service\IONOS\Dto\MailAccountConfig; -use OCA\Mail\Service\IONOS\Dto\MailServerConfig; use OCA\Mail\Service\IONOS\IonosAccountConflictResolver; use OCA\Mail\Service\IONOS\IonosAccountCreationService; use OCA\Mail\Service\IONOS\IonosMailService; diff --git a/tests/Unit/Service/IONOS/IonosMailServiceTest.php b/tests/Unit/Service/IONOS/IonosMailServiceTest.php index 2af70670cb..64a2e24e65 100644 --- a/tests/Unit/Service/IONOS/IonosMailServiceTest.php +++ b/tests/Unit/Service/IONOS/IonosMailServiceTest.php @@ -19,8 +19,8 @@ use IONOS\MailConfigurationAPI\Client\Model\MailServer; use IONOS\MailConfigurationAPI\Client\Model\Smtp; use OCA\Mail\Exception\ServiceException; +use OCA\Mail\Provider\MailAccountProvider\Common\Dto\MailAccountConfig; use OCA\Mail\Service\IONOS\ApiMailConfigClientService; -use OCA\Mail\Service\IONOS\Dto\MailAccountConfig; use OCA\Mail\Service\IONOS\IonosConfigService; use OCA\Mail\Service\IONOS\IonosMailService; use OCP\IUser;