Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
805ef72
IONOS(ionos-mail): update IONOS Mail API client reference to 2.0.0-20…
printminion-co Dec 8, 2025
3055744
test(service): add unit tests for SetupService
printminion-co Dec 5, 2025
7b628e6
IONOS(ionos-mail): SetupService: add skipConnectivityTest parameter t…
printminion-co Dec 4, 2025
0b28d3e
IONOS(ionos-mail): AccountsController: add skipConnectivityTest param…
printminion-co Dec 4, 2025
7ded9da
IONOS(ionos-mail): refactor IonosMailServiceTest with consistent test…
printminion-co Dec 5, 2025
8e92767
IONOS(ionos-mail): IonosMailService: add method to create email accou…
printminion-co Dec 4, 2025
4b0f456
IONOS(ionos-mail): feat(dto): add withPassword method to MailServerCo…
printminion-co Dec 16, 2025
98c13e2
IONOS(ionos-mail): feat(dto): add withPassword method to MailAccountC…
printminion-co Dec 16, 2025
2792fa8
IONOS(ionos-mail): feat(config): add APP_NAME constant to IonosConfig…
printminion-co Dec 16, 2025
4f32400
IONOS(ionos-mail): feat(service): add ConflictResolutionResult value …
printminion-co Dec 16, 2025
3fa5d80
IONOS(ionos-mail): feat(service): add account config retrieval and pa…
printminion-co Dec 16, 2025
5a2877b
IONOS(ionos-mail): feat(service): add IonosAccountConflictResolver
printminion-co Dec 16, 2025
eca8bb5
IONOS(ionos-mail): feat(exception): add IonosServiceException for enh…
printminion-co Dec 17, 2025
de62be3
IONOS(ionos-mail): feat(service): add IonosAccountCreationService
printminion-co Dec 16, 2025
af10036
IONOS(ionos-mail): refactor(service): enhance IonosMailConfigService …
printminion-co Dec 16, 2025
9f1ede6
IONOS(ionos-mail): refactor(controller): improve error handling and l…
printminion-co Dec 17, 2025
da32d37
IONOS(ionos-mail): feat(controller): add user session handling for ac…
printminion-co Dec 17, 2025
efdbb6c
IONOS(ionos-mail): refactor(controller): use IonosAccountCreationServ…
printminion-co Dec 16, 2025
d4022b9
IONOS(ionos-mail): fix(NewEmailAddressTab): improve error message for…
printminion-co Dec 17, 2025
b31e781
IONOS(ionos-mail): feat(provider): implement external mail account pr…
printminion-co Dec 19, 2025
158efcc
IONOS(ionos-mail): refactor(routes): remove legacy IONOS routes and u…
printminion-co Dec 19, 2025
97870e0
IONOS(ionos-mail): refactor(provider): implement facade pattern to re…
printminion-co Dec 19, 2025
246210a
IONOS(ionos-mail): refactor(services): split IonosMailService into fo…
printminion-co Dec 19, 2025
f0fde12
IONOS(ionos-mail): refactor(dtos): move DTOs to shared Common locatio…
printminion-co Dec 19, 2025
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
15 changes: 13 additions & 2 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,20 @@
'url' => '/api/tags',
'verb' => 'POST'
],
// External provider routes (generic)
[
'name' => 'ionosAccounts#create',
'url' => '/api/ionos/accounts',
'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'
],
[
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
4 changes: 2 additions & 2 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
]);
}
}
}
5 changes: 3 additions & 2 deletions lib/Controller/AccountsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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(),
Expand Down
271 changes: 271 additions & 0 deletions lib/Controller/ExternalAccountsController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,271 @@
<?php

declare(strict_types=1);

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

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\Provider\MailAccountProvider\ProviderRegistryService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
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)]
class ExternalAccountsController extends Controller {
// Error message constants
private const ERR_PROVIDER_NOT_FOUND = 'PROVIDER_NOT_FOUND';
private const ERR_PROVIDER_NOT_AVAILABLE = 'PROVIDER_NOT_AVAILABLE';
private const ERR_INVALID_PARAMETERS = 'INVALID_PARAMETERS';
private const ERR_SERVICE_ERROR = 'SERVICE_ERROR';

public function __construct(
string $appName,
IRequest $request,
private ProviderRegistryService $providerRegistry,
private IUserSession $userSession,
private LoggerInterface $logger,
) {
parent::__construct($appName, $request);
}

/**
* Create a new external mail account via provider
*
* @NoAdminRequired
*
* @param string $providerId Provider identifier
* @return JSONResponse
*/
#[TrapError]
public function create(string $providerId): JSONResponse {
try {
$userId = $this->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);
}
}
Loading
Loading