f7cloud_client/apps/mail/lib/Service/Provisioning/Manager.php
root 8b6a0139db f7cloud_client
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-17 22:59:26 +00:00

377 lines
12 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2019 F7cloud GmbH and F7cloud contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
namespace OCA\Mail\Service\Provisioning;
use Horde_Mail_Rfc822_Address;
use OCA\Mail\Db\Alias;
use OCA\Mail\Db\AliasMapper;
use OCA\Mail\Db\MailAccount;
use OCA\Mail\Db\MailAccountMapper;
use OCA\Mail\Db\Provisioning;
use OCA\Mail\Db\ProvisioningMapper;
use OCA\Mail\Db\TagMapper;
use OCA\Mail\Exception\ValidationException;
use OCA\Mail\Service\AccountService;
use OCP\AppFramework\Db\DoesNotExistException;
use OCP\AppFramework\Db\MultipleObjectsReturnedException;
use OCP\ICacheFactory;
use OCP\IUser;
use OCP\IUserManager;
use OCP\LDAP\ILDAPProviderFactory;
use OCP\Security\ICrypto;
use Psr\Log\LoggerInterface;
class Manager {
public const MAIL_PROVISIONINGS = 'mail_provisionings';
/** @var IUserManager */
private $userManager;
/** @var ProvisioningMapper */
private $provisioningMapper;
/** @var MailAccountMapper */
private $mailAccountMapper;
/** @var ICrypto */
private $crypto;
/** @var ILDAPProviderFactory */
private $ldapProviderFactory;
/** @var AliasMapper */
private $aliasMapper;
/** @var LoggerInterface */
private $logger;
/** @var TagMapper */
private $tagMapper;
/** @var ICacheFactory */
private $cacheFactory;
public function __construct(
IUserManager $userManager,
ProvisioningMapper $provisioningMapper,
MailAccountMapper $mailAccountMapper,
ICrypto $crypto,
ILDAPProviderFactory $ldapProviderFactory,
AliasMapper $aliasMapper,
LoggerInterface $logger,
TagMapper $tagMapper,
ICacheFactory $cacheFactory,
private AccountService $accountService,
) {
$this->userManager = $userManager;
$this->provisioningMapper = $provisioningMapper;
$this->mailAccountMapper = $mailAccountMapper;
$this->crypto = $crypto;
$this->ldapProviderFactory = $ldapProviderFactory;
$this->aliasMapper = $aliasMapper;
$this->logger = $logger;
$this->tagMapper = $tagMapper;
$this->cacheFactory = $cacheFactory;
}
public function getConfigById(int $provisioningId): ?Provisioning {
return $this->provisioningMapper->get($provisioningId);
}
public function getConfigs(): array {
$cache = null;
if ($this->cacheFactory->isAvailable()) {
$cache = $this->cacheFactory->createDistributed(self::MAIL_PROVISIONINGS);
$cached = $cache->get('provisionings_all');
if ($cached !== null) {
return unserialize($cached, ['allowed_classes' => [Provisioning::class]]);
}
}
$provisionings = $this->provisioningMapper->getAll();
// let's cache the provisionings for 5 minutes
if ($cache !== null) {
$cache->set('provisionings_all', serialize($provisionings), 60 * 5);
}
return $provisionings;
}
public function provision(): int {
$counter = 0;
$configs = $this->getConfigs();
if (count($configs) === 0) {
return $counter;
}
$this->userManager->callForAllUsers(function (IUser $user) use ($configs, &$counter) {
if ($this->provisionSingleUser($configs, $user)) {
$counter++;
}
});
return $counter;
}
/**
* Delete orphaned aliases for the given account.
*
* A alias is orphaned if not listed in newAliases anymore
* (=> the provisioning configuration does contain it anymore)
*
* @throws \OCP\DB\Exception
*/
private function deleteOrphanedAliases(string $userId, int $accountId, array $newAliases): void {
$existingAliases = $this->aliasMapper->findAll($accountId, $userId);
foreach ($existingAliases as $existingAlias) {
if (!in_array($existingAlias->getAlias(), $newAliases, true)) {
$this->aliasMapper->delete($existingAlias);
}
}
}
/**
* Create new aliases for the given account.
*
* @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);
}
}
}
/**
* @throws \Exception if user id was not found in LDAP
*
* @TODO: Remove psalm-suppress once Mail requires F7cloud 22 or above
*/
public function ldapAliasesIntegration(Provisioning $provisioning, IUser $user): Provisioning {
if ($user->getBackendClassName() !== 'LDAP' || $provisioning->getLdapAliasesProvisioning() === false || empty($provisioning->getLdapAliasesAttribute())) {
return $provisioning;
}
/** @psalm-suppress UndefinedInterfaceMethod */
if ($this->ldapProviderFactory->isAvailable() === false) {
$this->logger->debug('Request to provision mail aliases but LDAP not available');
return $provisioning;
}
$ldapProvider = $this->ldapProviderFactory->getLDAPProvider();
/** @psalm-suppress UndefinedInterfaceMethod */
$provisioning->setAliases($ldapProvider->getMultiValueUserAttribute($user->getUID(), $provisioning->getLdapAliasesAttribute()));
return $provisioning;
}
/**
* @param Provisioning[] $provisionings
*/
public function provisionSingleUser(array $provisionings, IUser $user): bool {
$provisioning = $this->findMatchingConfig($provisionings, $user);
if ($provisioning === null) {
return false;
}
try {
// TODO: match by UID only, catch multiple objects returned below and delete all those accounts
$mailAccount = $this->mailAccountMapper->findProvisionedAccount($user);
$mailAccount = $this->mailAccountMapper->update(
$this->updateAccount($user, $mailAccount, $provisioning)
);
} catch (DoesNotExistException|MultipleObjectsReturnedException $e) {
if ($e instanceof MultipleObjectsReturnedException) {
// This is unlikely to happen but not impossible.
// Let's wipe any existing accounts and start fresh
$this->aliasMapper->deleteProvisionedAliasesByUid($user->getUID());
$this->mailAccountMapper->deleteProvisionedAccountsByUid($user->getUID());
}
// Fine, then we create a new one
$mailAccount = new MailAccount();
$mailAccount->setUserId($user->getUID());
$mailAccount = $this->mailAccountMapper->insert(
$this->updateAccount($user, $mailAccount, $provisioning)
);
$this->accountService->scheduleBackgroundJobs($mailAccount->getId());
$this->tagMapper->createDefaultTags($mailAccount);
}
try {
$provisioning = $this->ldapAliasesIntegration($provisioning, $user);
} catch (\Throwable $e) {
$this->logger->warning('Request to provision mail aliases failed', ['exception' => $e]);
// return here to avoid provisioning of aliases.
return true;
}
try {
$this->deleteOrphanedAliases($user->getUID(), $mailAccount->getId(), $provisioning->getAliases());
} 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());
} catch (\Throwable $e) {
$this->logger->warning('Creating new aliases failed', ['exception' => $e]);
}
return true;
}
/**
* @throws ValidationException
* @throws \OCP\DB\Exception
*/
public function newProvisioning(array $data): Provisioning {
$provisioning = $this->provisioningMapper->validate($data);
$provisioning = $this->provisioningMapper->insert($provisioning);
if ($this->cacheFactory->isAvailable()) {
$cache = $this->cacheFactory->createDistributed(self::MAIL_PROVISIONINGS);
$cache->clear();
}
return $provisioning;
}
/**
* @throws ValidationException
* @throws \OCP\DB\Exception
*/
public function updateProvisioning(array $data): void {
$provisioning = $this->provisioningMapper->validate($data);
$this->provisioningMapper->update($provisioning);
if ($this->cacheFactory->isAvailable()) {
$cache = $this->cacheFactory->createDistributed(self::MAIL_PROVISIONINGS);
$cache->clear();
}
}
private function updateAccount(IUser $user, MailAccount $account, Provisioning $config): MailAccount {
// Set the ID to make sure it reflects when the account switches from one config to another
$account->setProvisioningId($config->getId());
$account->setEmail($config->buildEmail($user));
$account->setName($this->userManager->getDisplayName($user->getUID()));
$account->setInboundUser($config->buildImapUser($user));
$account->setInboundHost($config->getImapHost());
$account->setInboundPort($config->getImapPort());
$account->setInboundSslMode($config->getImapSslMode());
$account->setOutboundUser($config->buildSmtpUser($user));
$account->setOutboundHost($config->getSmtpHost());
$account->setOutboundPort($config->getSmtpPort());
$account->setOutboundSslMode($config->getSmtpSslMode());
$account->setSieveEnabled($config->getSieveEnabled());
if ($config->getSieveEnabled()) {
$account->setSieveUser($config->buildSieveUser($user));
$account->setSieveHost($config->getSieveHost());
$account->setSievePort($config->getSievePort());
$account->setSieveSslMode($config->getSieveSslMode());
} else {
$account->setSieveUser(null);
$account->setSieveHost(null);
$account->setSievePort(null);
$account->setSieveSslMode(null);
}
return $account;
}
public function deprovision(Provisioning $provisioning): void {
$this->mailAccountMapper->deleteProvisionedAccounts($provisioning->getId());
$this->provisioningMapper->delete($provisioning);
if ($this->cacheFactory->isAvailable()) {
$cache = $this->cacheFactory->createDistributed(self::MAIL_PROVISIONINGS);
$cache->clear();
}
}
/**
* @param Provisioning[] $provisionings
*/
public function updatePassword(IUser $user, ?string $password, array $provisionings): void {
try {
$account = $this->mailAccountMapper->findProvisionedAccount($user);
$provisioning = $this->findMatchingConfig($provisionings, $user);
if ($provisioning === null) {
return;
}
// FIXME: Need to check for an empty string here too?
// The password is empty (and not null) when using WebAuthn passwordless login.
// Maybe research other providers as well.
// Ref \OCA\Mail\Controller\PageController::index()
// -> inital state for password-is-unavailable
if ($provisioning->getMasterPasswordEnabled() === true && $provisioning->getMasterPassword() !== null) {
$password = $provisioning->getMasterPassword();
$this->logger->debug('Password set to master password for ' . $user->getUID());
} elseif ($password === null) {
$this->logger->debug('No password set for ' . $user->getUID());
return;
}
if (!empty($account->getInboundPassword())
&& $this->crypto->decrypt($account->getInboundPassword()) === $password
&& !empty($account->getOutboundPassword())
&& $this->crypto->decrypt($account->getOutboundPassword()) === $password) {
$this->logger->debug('Password of provisioned account is up to date');
return;
}
$account->setInboundPassword($this->crypto->encrypt($password));
$account->setOutboundPassword($this->crypto->encrypt($password));
$this->mailAccountMapper->update($account);
$this->logger->debug('Provisioned account password udpated');
} catch (DoesNotExistException $e) {
// Nothing to update
}
}
/**
* @param Provisioning[] $provisionings
*/
private function findMatchingConfig(array $provisionings, IUser $user): ?Provisioning {
foreach ($provisionings as $provisioning) {
if ($provisioning->getProvisioningDomain() === Provisioning::WILDCARD) {
return $provisioning;
}
$email = $user->getEMailAddress();
if ($email === null) {
continue;
}
$rfc822Address = new Horde_Mail_Rfc822_Address($email);
if ($rfc822Address->matchDomain($provisioning->getProvisioningDomain())) {
return $provisioning;
}
}
return null;
}
}