diff --git a/lib/Controller/AccountsController.php b/lib/Controller/AccountsController.php index f64f1ac6be..cedf0a86a3 100644 --- a/lib/Controller/AccountsController.php +++ b/lib/Controller/AccountsController.php @@ -97,7 +97,7 @@ public function index(): JSONResponse { $json = []; foreach ($mailAccounts as $mailAccount) { $conf = $mailAccount->jsonSerialize(); - $conf['aliases'] = $this->aliasesService->findAll($conf['accountId'], $this->currentUserId); + $conf['aliases'] = $this->aliasesService->findAllWithDeletable($conf['accountId'], $this->currentUserId); $json[] = $conf; } return new JSONResponse($json); diff --git a/lib/Controller/AliasesController.php b/lib/Controller/AliasesController.php index 641ef0e7b7..e1383c4e6d 100644 --- a/lib/Controller/AliasesController.php +++ b/lib/Controller/AliasesController.php @@ -42,7 +42,9 @@ public function __construct(string $appName, */ #[TrapError] public function index(int $accountId): JSONResponse { - return new JSONResponse($this->aliasService->findAll($accountId, $this->currentUserId)); + return new JSONResponse( + $this->aliasService->findAllWithDeletable($accountId, $this->currentUserId) + ); } /** diff --git a/lib/Controller/PageController.php b/lib/Controller/PageController.php index e167e60af3..20ce0b03ca 100644 --- a/lib/Controller/PageController.php +++ b/lib/Controller/PageController.php @@ -152,7 +152,7 @@ public function index(): TemplateResponse { $accountsJson = []; foreach ($mailAccounts as $mailAccount) { $json = $mailAccount->jsonSerialize(); - $json['aliases'] = $this->aliasesService->findAll($mailAccount->getId(), + $json['aliases'] = $this->aliasesService->findAllWithDeletable($mailAccount->getId(), $this->currentUserId); try { $mailboxes = $this->mailManager->getMailboxes($mailAccount); diff --git a/lib/Db/AliasMapper.php b/lib/Db/AliasMapper.php index 2abae420ac..578363c387 100644 --- a/lib/Db/AliasMapper.php +++ b/lib/Db/AliasMapper.php @@ -67,6 +67,25 @@ public function findByAlias(string $alias, string $currentUserId): Alias { return $this->findEntity($qb); } + /** + * @throws DoesNotExistException + */ + public function findByAliasAndName(string $alias, string $name, string $currentUserId): Alias { + $qb = $this->db->getQueryBuilder(); + $qb->select('aliases.*', 'accounts.provisioning_id') + ->from($this->getTableName(), 'aliases') + ->join('aliases', 'mail_accounts', 'accounts', $qb->expr()->eq('aliases.account_id', 'accounts.id')) + ->where( + $qb->expr()->andX( + $qb->expr()->eq('accounts.user_id', $qb->createNamedParameter($currentUserId)), + $qb->expr()->eq('aliases.alias', $qb->createNamedParameter($alias)), + $qb->expr()->eq('aliases.name', $qb->createNamedParameter($name)) + ) + ); + + return $this->findEntity($qb); + } + /** * @param int $accountId * @param string $currentUserId diff --git a/lib/Db/Provisioning.php b/lib/Db/Provisioning.php index 8d85c6d153..3c0026c02c 100644 --- a/lib/Db/Provisioning.php +++ b/lib/Db/Provisioning.php @@ -55,6 +55,8 @@ * @method void setLdapAliasesProvisioning(bool $ldapAliasesProvisioning) * @method string|null getLdapAliasesAttribute() * @method void setLdapAliasesAttribute(?string $ldapAliasesAttribute) + * @method string|null getNameTemplates() + * @method void setNameTemplates(?string $nameTemplates) */ class Provisioning extends Entity implements JsonSerializable { public const WILDCARD = '*'; @@ -80,6 +82,7 @@ class Provisioning extends Entity implements JsonSerializable { protected $aliases = []; protected $ldapAliasesProvisioning; protected $ldapAliasesAttribute; + protected $nameTemplates; public function __construct() { $this->addType('imapPort', 'integer'); @@ -116,6 +119,7 @@ public function jsonSerialize() { 'aliases' => $this->getAliases(), 'ldapAliasesProvisioning' => $this->getLdapAliasesProvisioning(), 'ldapAliasesAttribute' => $this->getLdapAliasesAttribute(), + 'nameTemplates' => json_decode($this->getNameTemplates() ?? '[]', true), ]; } @@ -175,4 +179,25 @@ public function buildSieveUser(IUser $user) { } return $this->buildEmail($user); } + + /** + * Build account names from the name templates. + * + * Supports placeholders: %USERID%, %DISPLAYNAME% + * @return string[] + */ + public function buildNames(IUser $user): array { + $displayName = $user->getDisplayName() ?? ''; + $templates = json_decode($this->getNameTemplates() ?? '[]', true); + if (empty($templates)) { + return [$displayName]; + } + + return array_map(function ($template) use ($user, $displayName) { + if ($user->getUID() !== null) { + $template = str_replace('%USERID%', $user->getUID(), $template); + } + return str_replace('%DISPLAYNAME%', $displayName, $template); + }, $templates); + } } diff --git a/lib/Db/ProvisioningMapper.php b/lib/Db/ProvisioningMapper.php index 0486b84a30..fba0bac066 100644 --- a/lib/Db/ProvisioningMapper.php +++ b/lib/Db/ProvisioningMapper.php @@ -122,6 +122,8 @@ public function validate(array $data): Provisioning { $provisioning->setLdapAliasesProvisioning($ldapAliasesProvisioning); $provisioning->setLdapAliasesAttribute($ldapAliasesAttribute); + $nameTemplates = $data['nameTemplates'] ?? []; + $provisioning->setNameTemplates(!empty($nameTemplates) ? json_encode($nameTemplates) : null); return $provisioning; } diff --git a/lib/Migration/Version5007Date20260114100548.php b/lib/Migration/Version5007Date20260114100548.php new file mode 100644 index 0000000000..400b837d5b --- /dev/null +++ b/lib/Migration/Version5007Date20260114100548.php @@ -0,0 +1,35 @@ +getTable('mail_provisionings'); + + if (!$provisioningTable->hasColumn('name_templates')) { + $provisioningTable->addColumn('name_templates', Types::TEXT, [ + 'notnull' => false, + 'default' => '["%DISPLAYNAME%"]', + ]); + } + + return $schema; + } +} diff --git a/lib/Service/AliasesService.php b/lib/Service/AliasesService.php index 62f4538fd6..37e0766fa0 100644 --- a/lib/Service/AliasesService.php +++ b/lib/Service/AliasesService.php @@ -13,8 +13,10 @@ use OCA\Mail\Db\Alias; use OCA\Mail\Db\AliasMapper; use OCA\Mail\Db\MailAccountMapper; +use OCA\Mail\Db\ProvisioningMapper; use OCA\Mail\Exception\ClientException; use OCP\AppFramework\Db\DoesNotExistException; +use OCP\IUserManager; class AliasesService { /** @var AliasMapper */ @@ -23,9 +25,19 @@ class AliasesService { /** @var MailAccountMapper */ private $mailAccountMapper; - public function __construct(AliasMapper $aliasMapper, MailAccountMapper $mailAccountMapper) { + private ProvisioningMapper $provisioningMapper; + private IUserManager $userManager; + + public function __construct( + AliasMapper $aliasMapper, + MailAccountMapper $mailAccountMapper, + ProvisioningMapper $provisioningMapper, + IUserManager $userManager, + ) { $this->aliasMapper = $aliasMapper; $this->mailAccountMapper = $mailAccountMapper; + $this->provisioningMapper = $provisioningMapper; + $this->userManager = $userManager; } /** @@ -37,6 +49,20 @@ public function findAll(int $accountId, string $currentUserId): array { return $this->aliasMapper->findAll($accountId, $currentUserId); } + /** + * Get all aliases with deletable flag included. + * + * @return list + */ + public function findAllWithDeletable(int $accountId, string $currentUserId): array { + $aliases = $this->findAll($accountId, $currentUserId); + return array_map(function ($alias) use ($currentUserId) { + $data = $alias->jsonSerialize(); + $data['deletable'] = $this->isAliasDeletable($alias, $currentUserId); + return $data; + }, $aliases); + } + /** * @param int $aliasId * @param string $currentUserId @@ -84,13 +110,50 @@ public function create(string $userId, int $accountId, string $alias, string $al public function delete(string $userId, int $aliasId): Alias { $entity = $this->aliasMapper->find($aliasId, $userId); - if ($entity->isProvisioned()) { + if ($entity->isProvisioned() && !$this->isAliasDeletable($entity, $userId)) { throw new ClientException('Deleting a provisioned alias is not allowed.'); } return $this->aliasMapper->delete($entity); } + /** + * Check if a provisioned alias can be deleted. + * + * A provisioned alias is deletable if its name is no longer in the + * current provisioning name templates. This allows users to clean up + * aliases after an admin removes a name template. + */ + public function isAliasDeletable(Alias $alias, string $userId): bool { + // Non-provisioned aliases are always deletable + if (!$alias->isProvisioned()) { + return true; + } + + $account = $this->mailAccountMapper->find($userId, $alias->getAccountId()); + $provisioningId = $account->getProvisioningId(); + + // No provisioning config means alias shouldn't exist as provisioned - allow deletion + if ($provisioningId === null) { + return true; + } + + $provisioning = $this->provisioningMapper->get($provisioningId); + if ($provisioning === null) { + return true; + } + + $user = $this->userManager->get($userId); + if ($user === null) { + return false; + } + + $validNames = $provisioning->buildNames($user); + + // If alias name is NOT in valid names, it's deletable + return !in_array($alias->getName(), $validNames, true); + } + /** * Deletes all aliases of an account. * diff --git a/lib/Service/Provisioning/Manager.php b/lib/Service/Provisioning/Manager.php index ce117cc423..b633ed521c 100644 --- a/lib/Service/Provisioning/Manager.php +++ b/lib/Service/Provisioning/Manager.php @@ -122,15 +122,19 @@ public function provision(): int { /** * Delete orphaned aliases for the given account. * - * A alias is orphaned if not listed in newAliases anymore + * An alias is orphaned if its email is no longer in the valid emails list. * (=> the provisioning configuration does contain it anymore) * * @throws \OCP\DB\Exception */ - private function deleteOrphanedAliases(string $userId, int $accountId, array $newAliases): void { + private function deleteOrphanedAliases(string $userId, int $accountId, array $validEmails): void { $existingAliases = $this->aliasMapper->findAll($accountId, $userId); foreach ($existingAliases as $existingAlias) { - if (!in_array($existingAlias->getAlias(), $newAliases, true)) { + if ($existingAlias->getProvisioningId() === null) { + continue; + } + + if (!in_array($existingAlias->getAlias(), $validEmails, true)) { $this->aliasMapper->delete($existingAlias); } } @@ -141,20 +145,26 @@ private function deleteOrphanedAliases(string $userId, int $accountId, array $ne * * @throws \OCP\DB\Exception */ - private function createNewAliases(string $userId, int $accountId, array $newAliases, string $displayName, string $accountEmail): void { - foreach ($newAliases as $newAlias) { - if ($newAlias === $accountEmail) { - continue; // skip alias when identical to account email - } - - try { - $this->aliasMapper->findByAlias($newAlias, $userId); - } catch (DoesNotExistException $e) { - $alias = new Alias(); - $alias->setAccountId($accountId); - $alias->setName($displayName); - $alias->setAlias($newAlias); - $this->aliasMapper->insert($alias); + private function createNewAliases(string $userId, int $accountId, array $aliasEmails, array $names, string $accountEmail): void { + // Include account email for secondary name templates + $allEmails = array_unique(array_merge([$accountEmail], $aliasEmails)); + + foreach ($allEmails as $email) { + foreach ($names as $index => $name) { + // Skip first name for account email (already set on account) + if ($email === $accountEmail && $index === 0) { + continue; + } + + try { + $this->aliasMapper->findByAliasAndName($email, $name, $userId); + } catch (DoesNotExistException $e) { + $alias = new Alias(); + $alias->setAccountId($accountId); + $alias->setName($name); + $alias->setAlias($email); + $this->aliasMapper->insert($alias); + } } } } @@ -227,14 +237,19 @@ public function provisionSingleUser(array $provisionings, IUser $user): bool { return true; } + // Include account email to preserve aliases created via name templates + $aliasEmails = array_unique(array_merge([$mailAccount->getEmail()], $provisioning->getAliases())); + + $names = $provisioning->buildNames($user); + try { - $this->deleteOrphanedAliases($user->getUID(), $mailAccount->getId(), $provisioning->getAliases()); + $this->deleteOrphanedAliases($user->getUID(), $mailAccount->getId(), $aliasEmails); } catch (\Throwable $e) { $this->logger->warning('Deleting orphaned aliases failed', ['exception' => $e]); } try { - $this->createNewAliases($user->getUID(), $mailAccount->getId(), $provisioning->getAliases(), $this->userManager->getDisplayName($user->getUID()), $mailAccount->getEmail()); + $this->createNewAliases($user->getUID(), $mailAccount->getId(), $provisioning->getAliases(), $names, $mailAccount->getEmail()); } catch (\Throwable $e) { $this->logger->warning('Creating new aliases failed', ['exception' => $e]); } @@ -274,7 +289,7 @@ private function updateAccount(IUser $user, MailAccount $account, Provisioning $ $account->setProvisioningId($config->getId()); $account->setEmail($config->buildEmail($user)); - $account->setName($this->userManager->getDisplayName($user->getUID())); + $account->setName($config->buildNames($user)[0] ?? ''); $account->setInboundUser($config->buildImapUser($user)); $account->setInboundHost($config->getImapHost()); $account->setInboundPort($config->getImapPort()); diff --git a/src/components/AliasForm.vue b/src/components/AliasForm.vue index d8b7b878eb..3a69d6b48b 100644 --- a/src/components/AliasForm.vue +++ b/src/components/AliasForm.vue @@ -55,7 +55,7 @@ {{ t('mail', 'Email: {email}', { email }) }}
+ {{ t('mail', 'Names: {names}', { names }) }}
{{ t('mail', 'IMAP: {user} on {host}:{port} ({ssl} encryption)', { user: imapUser, @@ -62,6 +63,17 @@ export default { return this.templates.email.replace('%USERID%', this.data.uid).replace('%EMAIL%', this.data.email) }, + names() { + const templates = this.templates.nameTemplates || [] + if (!templates.length) { + return this.data.displayName || 'Display Name' + } + return templates.map(t => t + .replace('%USERID%', this.data.uid) + .replace('%DISPLAYNAME%', this.data.displayName || '') + ).join(', ') + }, + provisioningDomain() { return this.templates.provisioningDomain }, diff --git a/src/components/settings/ProvisioningSettings.vue b/src/components/settings/ProvisioningSettings.vue index ac7be23921..af43565d72 100644 --- a/src/components/settings/ProvisioningSettings.vue +++ b/src/components/settings/ProvisioningSettings.vue @@ -31,6 +31,16 @@ :disabled="loading" name="emailTemplate" type="text"> +
+ +
+