Skip to content
Draft
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
2 changes: 2 additions & 0 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,8 @@ Learn more about the Nextcloud Ethical AI Rating [in our blog](https://nextcloud
</commands>
<settings>
<admin>OCA\Mail\Settings\AdminSettings</admin>
<admin>OCA\Mail\Settings\ProviderAccountOverviewSettings</admin>
<admin-section>OCA\Mail\Settings\Section\MailProviderAccountsSection</admin-section>
</settings>
<navigations>
<navigation>
Expand Down
15 changes: 15 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,21 @@
'url' => '/api/providers/{providerId}/password',
'verb' => 'POST'
],
[
'name' => 'externalAccounts#indexMailboxes',
'url' => '/api/providers/{providerId}/mailboxes',
'verb' => 'GET'
],
[
'name' => 'externalAccounts#updateMailbox',
'url' => '/api/providers/{providerId}/mailboxes/{userId}',
'verb' => 'PUT'
],
[
'name' => 'externalAccounts#destroyMailbox',
'url' => '/api/providers/{providerId}/mailboxes/{userId}',
'verb' => 'DELETE'
],
[
'name' => 'tags#update',
'url' => '/api/tags/{id}',
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-20251208083401"
"reference": "2.0.0-20260210132735"
},
"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.

184 changes: 184 additions & 0 deletions lib/Controller/ExternalAccountsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,190 @@ public function generatePassword(string $providerId): JSONResponse {
}
}

/**
* List all mailboxes for a specific provider
*
* @NoAdminRequired
*
* @param string $providerId The provider ID
* @return JSONResponse
*/
#[TrapError]
public function indexMailboxes(string $providerId): JSONResponse {
try {
$userId = $this->getUserIdOrFail();

$this->logger->debug('Listing mailboxes for provider', [
'providerId' => $providerId,
'userId' => $userId,
]);

$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);
}

$mailboxes = $provider->getMailboxes();

return MailJsonResponse::success(['mailboxes' => $mailboxes]);
} catch (\Exception $e) {
$this->logger->error('Error listing mailboxes', [
'providerId' => $providerId,
'exception' => $e,
]);
return MailJsonResponse::error('Could not list mailboxes');
}
}

/**
* Update a mailbox (e.g., change localpart)
*
* @NoAdminRequired
*
* @param string $providerId The provider ID
* @param string $userId The user ID whose mailbox to update
* @return JSONResponse
*/
#[TrapError]
public function updateMailbox(string $providerId, string $userId): JSONResponse {
try {
$currentUserId = $this->getUserIdOrFail();

// Get update data from request
$data = $this->request->getParams();
unset($data['providerId']);
unset($data['userId']);
unset($data['_route']);

// Validate localpart if provided
if (isset($data['localpart'])) {
$localpart = trim($data['localpart']);
if (empty($localpart)) {
return MailJsonResponse::fail([
'error' => self::ERR_INVALID_PARAMETERS,
'message' => 'Localpart cannot be empty',
], Http::STATUS_BAD_REQUEST);
}
// Basic validation: alphanumeric, dots, hyphens, underscores
if (!preg_match('/^[a-zA-Z0-9._-]+$/', $localpart)) {
return MailJsonResponse::fail([
'error' => self::ERR_INVALID_PARAMETERS,
'message' => 'Localpart contains invalid characters',
], Http::STATUS_BAD_REQUEST);
}
$data['localpart'] = $localpart;
}

$this->logger->info('Updating mailbox', [
'providerId' => $providerId,
'userId' => $userId,
'currentUserId' => $currentUserId,
'data' => array_keys($data),
]);

$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);
}

$mailbox = $provider->updateMailbox($userId, $data);

$this->logger->info('Mailbox updated successfully', [
'userId' => $userId,
'email' => $mailbox['email'] ?? null,
]);

return MailJsonResponse::success($mailbox);
} catch (\OCA\Mail\Exception\AccountAlreadyExistsException $e) {
$this->logger->warning('Email address already taken', [
'providerId' => $providerId,
'userId' => $userId,
]);
return MailJsonResponse::fail([
'error' => 'EMAIL_ALREADY_TAKEN',
'message' => 'Email is already taken',
], Http::STATUS_CONFLICT);
} catch (ServiceException $e) {
return $this->buildServiceErrorResponse($e, $providerId);
} catch (\InvalidArgumentException $e) {
$this->logger->error('Invalid parameters for mailbox update', [
'providerId' => $providerId,
'userId' => $userId,
'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 updating mailbox', [
'providerId' => $providerId,
'userId' => $userId,
'exception' => $e,
]);
return MailJsonResponse::error('Could not update mailbox');
}
}

/**
* Delete a mailbox
*
* @NoAdminRequired
*
* @param string $providerId The provider ID
* @param string $userId The user ID whose mailbox to delete
* @return JSONResponse
*/
#[TrapError]
public function destroyMailbox(string $providerId, string $userId): JSONResponse {
try {
$currentUserId = $this->getUserIdOrFail();

$this->logger->info('Deleting mailbox', [
'providerId' => $providerId,
'userId' => $userId,
'currentUserId' => $currentUserId,
]);

$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);
}

$success = $provider->deleteMailbox($userId);

if ($success) {
$this->logger->info('Mailbox deleted successfully', [
'userId' => $userId,
]);
return MailJsonResponse::success(['deleted' => true]);
} else {
return MailJsonResponse::fail([
'error' => self::ERR_SERVICE_ERROR,
'message' => 'Failed to delete mailbox',
], Http::STATUS_INTERNAL_SERVER_ERROR);
}
} catch (ServiceException $e) {
return $this->buildServiceErrorResponse($e, $providerId);
} catch (\Exception $e) {
$this->logger->error('Unexpected error deleting mailbox', [
'providerId' => $providerId,
'userId' => $userId,
'exception' => $e,
]);
return MailJsonResponse::error('Could not delete mailbox');
}
}

/**
* Get the current user ID
*
Expand Down
17 changes: 17 additions & 0 deletions lib/Exception/AccountAlreadyExistsException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

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

namespace OCA\Mail\Exception;

/**
* Exception thrown when attempting to create or update a mail account
* with an email address that already exists for another user
*/
class AccountAlreadyExistsException extends ServiceException {
}
31 changes: 31 additions & 0 deletions lib/Provider/MailAccountProvider/IMailAccountProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -128,4 +128,35 @@ public function getExistingAccountEmail(string $userId): ?string;
* @throws \InvalidArgumentException If provider doesn't support app passwords
*/
public function generateAppPassword(string $userId): string;

/**
* Get all mailboxes managed by this provider
*
* Returns a list of all mailboxes (email accounts) managed by this provider
* across all users. Used for administration/overview purposes.
*
* @return array<int, array{userId: string, email: string, name: string}> List of mailbox information
* @throws \OCA\Mail\Exception\ServiceException If fetching mailboxes fails
*/
public function getMailboxes(): array;

/**
* Update a mailbox (e.g., change localpart/username)
*
* @param string $userId The Nextcloud user ID
* @param array<string, mixed> $data Update data (e.g., ['localpart' => 'newusername'])
* @return array{userId: string, email: string, name: string} Updated mailbox information
* @throws \OCA\Mail\Exception\AccountAlreadyExistsException If email is already taken
* @throws \OCA\Mail\Exception\ServiceException If update fails
*/
public function updateMailbox(string $userId, array $data): array;

/**
* Delete a mailbox
*
* @param string $userId The Nextcloud user ID
* @return bool True if deletion was successful
* @throws \OCA\Mail\Exception\ServiceException If deletion fails
*/
public function deleteMailbox(string $userId): bool;
}
Original file line number Diff line number Diff line change
Expand Up @@ -222,4 +222,91 @@ public function generateAppPassword(string $userId): string {

return $this->mutationService->resetAppPassword($userId, IonosConfigService::APP_PASSWORD_NAME_USER);
}

/**
* Get all mailboxes managed by this provider
*
* Returns a list of all mailboxes (email accounts) managed by this provider
* across all users. Used for administration/overview purposes.
*
* @return array<int, array{userId: string, email: string, name: string}> List of mailbox information
*/
public function getMailboxes(): array {
$this->logger->debug('Getting all IONOS mailboxes');

try {
$accountResponses = $this->queryService->getAllMailAccountResponses();

$mailboxes = [];
foreach ($accountResponses as $response) {
// Extract user ID from the email or use a placeholder
// The API response contains the email but we need to derive the userId
$email = $response->getEmail();

// For IONOS, the email format is typically userId@domain
// Extract userId from the email localpart
$emailParts = explode('@', $email);
$userId = $emailParts[0] ?? '';

$mailboxes[] = [
'userId' => $userId,
'email' => $email,
'name' => $response->getName() ?? $userId,
];
}

$this->logger->debug('Retrieved IONOS mailboxes', [
'count' => count($mailboxes),
]);

return $mailboxes;
} catch (\Exception $e) {
$this->logger->error('Error getting IONOS mailboxes', [
'exception' => $e,
]);
return [];
}
}

/**
* Update a mailbox (e.g., change localpart)
*
* @param string $userId The Nextcloud user ID
* @param array<string, mixed> $data Update data
* @return array{userId: string, email: string, name: string} Updated mailbox information
* @throws \OCA\Mail\Exception\ServiceException If update fails
*/
public function updateMailbox(string $userId, array $data): array {
$this->logger->info('Updating IONOS mailbox via facade', [
'userId' => $userId,
'data' => array_keys($data),
]);

$localpart = $data['localpart'] ?? null;
$name = $data['name'] ?? '';

if ($localpart === null || $localpart === '') {
throw new \InvalidArgumentException('localpart is required for mailbox update');
}

// Update the account using the creation service (which handles updates)
$account = $this->creationService->createOrUpdateAccount($userId, $localpart, $name);

return [
'userId' => $userId,
'email' => $account->getEmail(),
'name' => $account->getName(),
];
}

/**
* Delete a mailbox
*
* @param string $userId The Nextcloud user ID
* @return bool True if deletion was successful
* @throws \OCA\Mail\Exception\ServiceException If deletion fails
*/
public function deleteMailbox(string $userId): bool {
return $this->deleteAccount($userId);
}
}
Loading
Loading