Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions lib/Service/AccountService.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
use OCA\Mail\Exception\ClientException;
use OCA\Mail\Exception\ServiceException;
use OCA\Mail\IMAP\IMAPClientFactory;
use OCA\Mail\Service\IONOS\IonosAccountDeletionService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\IJob;
Expand Down Expand Up @@ -56,6 +57,7 @@ public function __construct(
IMAPClientFactory $imapClientFactory,
private readonly IConfig $config,
private readonly ITimeFactory $timeFactory,
private readonly IonosAccountDeletionService $ionosAccountDeletionService,
) {
$this->mapper = $mapper;
$this->aliasesService = $aliasesService;
Expand Down Expand Up @@ -151,6 +153,7 @@ public function delete(string $currentUserId, int $accountId): void {
} catch (DoesNotExistException $e) {
throw new ClientException("Account $accountId does not exist", 0, $e);
}
$this->ionosAccountDeletionService->handleMailAccountDeletion($mailAccount);
$this->aliasesService->deleteAll($accountId);
$this->mapper->delete($mailAccount);
}
Expand All @@ -166,6 +169,7 @@ public function deleteByAccountId(int $accountId): void {
} catch (DoesNotExistException $e) {
throw new ClientException("Account $accountId does not exist", 0, $e);
}
$this->ionosAccountDeletionService->handleMailAccountDeletion($mailAccount);
$this->aliasesService->deleteAll($accountId);
$this->mapper->delete($mailAccount);
}
Expand Down
136 changes: 136 additions & 0 deletions lib/Service/IONOS/IonosAccountDeletionService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2025 STRATO GmbH
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Mail\Service\IONOS;

use OCA\Mail\Db\MailAccount;
use Psr\Log\LoggerInterface;

/**
* Service for handling IONOS mailbox deletion when mail accounts are deleted
*/
class IonosAccountDeletionService {
public function __construct(
private readonly IonosMailService $ionosMailService,
private readonly IonosConfigService $ionosConfigService,
private readonly LoggerInterface $logger,
) {
}

/**
* Check if the mail account is an IONOS account and delete the IONOS mailbox
*
* This method determines if an account belongs to IONOS by:
* 1. Checking if the email domain matches the configured IONOS mail domain
* 2. Verifying that the account email matches the IONOS provisioned email for this user
*
* This two-step verification prevents accidentally deleting the wrong IONOS mailbox
* when a user has multiple accounts or manually configured an account with an IONOS domain.
*
* @param MailAccount $mailAccount The mail account being deleted
* @return void
*/
public function handleMailAccountDeletion(MailAccount $mailAccount): void {
// Check if IONOS integration is enabled
if (!$this->ionosConfigService->isIonosIntegrationEnabled()) {
return;
}

try {
if (!$this->shouldDeleteIonosMailbox($mailAccount)) {
return;
}

$this->logger->info('Detected IONOS mail account deletion, attempting to delete IONOS mailbox', [
'email' => $mailAccount->getEmail(),
'userId' => $mailAccount->getUserId(),
'accountId' => $mailAccount->getId(),
]);

// Use tryDeleteEmailAccount to avoid throwing exceptions
$this->ionosMailService->tryDeleteEmailAccount($mailAccount->getUserId());
} catch (\Exception $e) {
// Log but don't throw - account deletion in Nextcloud should proceed
$this->logger->error('Error checking/deleting IONOS mailbox during account deletion', [
'exception' => $e,
'accountId' => $mailAccount->getId(),
]);
}
}

/**
* Check if the mail account is an IONOS-managed account that should be deleted
*
* @param MailAccount $mailAccount The mail account to check
* @return bool True if this is an IONOS-managed account that should be deleted
*/
private function shouldDeleteIonosMailbox(MailAccount $mailAccount): bool {
$email = $mailAccount->getEmail();
$userId = $mailAccount->getUserId();
$accountId = $mailAccount->getId();
$ionosMailDomain = $this->ionosConfigService->getMailDomain();

// Check if the account's email domain matches the IONOS mail domain
if (empty($ionosMailDomain) || !$this->isIonosEmail($email, $ionosMailDomain)) {
return false;
}

// Get the IONOS provisioned email for this user
$ionosProvisionedEmail = $this->ionosMailService->getIonosEmailForUser($userId);

// If no IONOS account exists for this user, skip deletion
if ($ionosProvisionedEmail === null) {
$this->logger->debug('No IONOS provisioned account found for user, skipping deletion', [
'email' => $email,
'userId' => $userId,
'accountId' => $accountId,
]);
return false;
}

// Verify that the account being deleted matches the IONOS provisioned email
if (strcasecmp($email, $ionosProvisionedEmail) !== 0) {
$this->logger->warning('Mail account email does not match IONOS provisioned email, skipping deletion', [
'accountEmail' => $email,
'ionosEmail' => $ionosProvisionedEmail,
'userId' => $userId,
'accountId' => $accountId,
]);
return false;
}

return true;
}

/**
* Check if an email address belongs to the IONOS mail domain
*
* @param string $email The email address to check
* @param string $ionosMailDomain The IONOS mail domain
* @return bool True if the email belongs to the IONOS domain
*/
private function isIonosEmail(string $email, string $ionosMailDomain): bool {
if (empty($email) || empty($ionosMailDomain)) {
return false;
}

// Extract domain from email address
$atPosition = strrpos($email, '@');
if ($atPosition === false) {
return false;
}

$emailDomain = substr($email, $atPosition + 1);
if ($emailDomain === '') {
return false;
}

return strcasecmp($emailDomain, $ionosMailDomain) === 0;
}
}
71 changes: 56 additions & 15 deletions lib/Service/IONOS/IonosMailService.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,47 +54,67 @@ public function mailAccountExistsForCurrentUser(): bool {
* @return bool true if account exists, false otherwise
*/
public function mailAccountExistsForCurrentUserId(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
*/
private function getMailAccountResponse(string $userId): ?MailAccountResponse {
try {
$this->logger->debug('Checking if user has email account', [
$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);
$result = $apiInstance->getFunctionalAccount(
self::BRAND,
$this->configService->getExternalReference(),
$userId
);

if ($result instanceof MailAccountResponse) {
$this->logger->debug('User has existing IONOS mail account', [
'email' => $result->getEmail(),
'userId' => $userId
]);
return true;
return $result;
}

return false;
return null;
} catch (ApiException $e) {
// 404 - no account exists
if ($e->getCode() === self::HTTP_NOT_FOUND) {
$this->logger->debug('User does not have IONOS mail account', [
'userId' => $userId,
'statusCode' => $e->getCode()
]);
return false;
return null;
}

$this->logger->error('API Exception when checking for existing mail account', [
$this->logger->error('API Exception when getting IONOS mail account', [
'statusCode' => $e->getCode(),
'message' => $e->getMessage(),
'responseBody' => $e->getResponseBody()
'responseBody' => $e->getResponseBody(),
'userId' => $userId
]);
return false;
return null;
} catch (\Exception $e) {
$this->logger->error('Exception when checking for existing mail account', [
$this->logger->error('Exception when getting IONOS mail account', [
'exception' => $e,
'userId' => $userId
]);
return false;
return null;
}
}

Expand Down Expand Up @@ -318,6 +338,27 @@ public function deleteEmailAccount(string $userId): bool {
}
}

/**
* Get the email address of the IONOS account for a specific user
*
* @param string $userId The Nextcloud user ID
* @return string|null The email address if account exists, null otherwise
*/
public function getIonosEmailForUser(string $userId): ?string {
$response = $this->getMailAccountResponse($userId);

if ($response !== null) {
$email = $response->getEmail();
$this->logger->debug('Found IONOS mail account for user', [
'email' => $email,
'userId' => $userId
]);
return $email;
}

return null;
}

/**
* Delete an IONOS email account without throwing exceptions (fire and forget)
*
Expand Down
12 changes: 12 additions & 0 deletions tests/Unit/Service/AccountServiceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
use OCA\Mail\IMAP\IMAPClientFactory;
use OCA\Mail\Service\AccountService;
use OCA\Mail\Service\AliasesService;
use OCA\Mail\Service\IONOS\IonosAccountDeletionService;
use OCP\AppFramework\Utility\ITimeFactory;
use OCP\BackgroundJob\IJobList;
use OCP\IConfig;
Expand Down Expand Up @@ -61,6 +62,7 @@ class AccountServiceTest extends TestCase {

private IConfig&MockObject $config;
private ITimeFactory&MockObject $time;
private IonosAccountDeletionService&MockObject $ionosAccountDeletionService;

protected function setUp(): void {
parent::setUp();
Expand All @@ -72,13 +74,15 @@ protected function setUp(): void {
$this->imapClientFactory = $this->createMock(IMAPClientFactory::class);
$this->config = $this->createMock(IConfig::class);
$this->time = $this->createMock(ITimeFactory::class);
$this->ionosAccountDeletionService = $this->createMock(IonosAccountDeletionService::class);
$this->accountService = new AccountService(
$this->mapper,
$this->aliasesService,
$this->jobList,
$this->imapClientFactory,
$this->config,
$this->time,
$this->ionosAccountDeletionService,
);

$this->account1 = new MailAccount();
Expand Down Expand Up @@ -139,6 +143,10 @@ public function testFindById() {
public function testDelete() {
$accountId = 33;

$this->ionosAccountDeletionService->expects($this->once())
->method('handleMailAccountDeletion')
->with($this->account1);

$this->mapper->expects($this->once())
->method('find')
->with($this->user, $accountId)
Expand All @@ -153,6 +161,10 @@ public function testDelete() {
public function testDeleteByAccountId() {
$accountId = 33;

$this->ionosAccountDeletionService->expects($this->once())
->method('handleMailAccountDeletion')
->with($this->account1);

$this->mapper->expects($this->once())
->method('findById')
->with($accountId)
Expand Down
Loading
Loading