From 60bff2595212855e1894ef0655ba3f408c451412 Mon Sep 17 00:00:00 2001 From: Timo Nieminen Date: Sat, 24 Jan 2026 07:11:16 +0000 Subject: [PATCH 1/2] feat(provisioning): add support for Dovecot master user authentication with new columns and UI updates Signed-off-by: Timo Nieminen --- lib/Db/Provisioning.php | 10 + lib/Db/ProvisioningMapper.php | 11 + lib/IMAP/IMAPClientFactory.php | 246 +++++++++--------- .../Version5007Date20260124120000.php | 52 ++++ lib/SMTP/SmtpClientFactory.php | 22 +- lib/Sieve/SieveClientFactory.php | 15 +- src/components/settings/ProvisionPreview.vue | 54 +++- .../settings/ProvisioningSettings.vue | 39 ++- 8 files changed, 326 insertions(+), 123 deletions(-) create mode 100644 lib/Migration/Version5007Date20260124120000.php diff --git a/lib/Db/Provisioning.php b/lib/Db/Provisioning.php index 8d85c6d153..ee74c00840 100644 --- a/lib/Db/Provisioning.php +++ b/lib/Db/Provisioning.php @@ -39,6 +39,10 @@ * @method void setMasterPasswordEnabled(bool $masterPasswordEnabled) * @method string|null getMasterPassword() * @method void setMasterPassword(string $masterPassword) + * @method string|null getMasterUser() + * @method void setMasterUser(?string $masterUser) + * @method string|null getMasterUserSeparator() + * @method void setMasterUserSeparator(?string $masterUserSeparator) * @method bool|null getSieveEnabled() * @method void setSieveEnabled(bool $sieveEnabled) * @method string|null getSieveHost() @@ -72,6 +76,8 @@ class Provisioning extends Entity implements JsonSerializable { protected $smtpSslMode; protected $masterPasswordEnabled; protected $masterPassword; + protected $masterUser; + protected $masterUserSeparator; protected $sieveEnabled; protected $sieveUser; protected $sieveHost; @@ -86,6 +92,8 @@ public function __construct() { $this->addType('smtpPort', 'integer'); $this->addType('masterPasswordEnabled', 'boolean'); $this->addType('masterPassword', 'string'); + $this->addType('masterUser', 'string'); + $this->addType('masterUserSeparator', 'string'); $this->addType('sieveEnabled', 'boolean'); $this->addType('sievePort', 'integer'); $this->addType('ldapAliasesProvisioning', 'boolean'); @@ -108,6 +116,8 @@ public function jsonSerialize() { 'smtpSslMode' => $this->getSmtpSslMode(), 'masterPasswordEnabled' => $this->getMasterPasswordEnabled(), 'masterPassword' => !empty($this->getMasterPassword()) ? self::MASTER_PASSWORD_PLACEHOLDER : null, + 'masterUser' => !empty($this->getMasterUser()) ? self::MASTER_PASSWORD_PLACEHOLDER : null, + 'masterUserSeparator' => $this->getMasterUserSeparator() ?? '*', 'sieveEnabled' => $this->getSieveEnabled(), 'sieveUser' => $this->getSieveUser(), 'sieveHost' => $this->getSieveHost(), diff --git a/lib/Db/ProvisioningMapper.php b/lib/Db/ProvisioningMapper.php index 0486b84a30..312ee430e9 100644 --- a/lib/Db/ProvisioningMapper.php +++ b/lib/Db/ProvisioningMapper.php @@ -92,6 +92,13 @@ public function validate(array $data): Provisioning { $exception->setField('ldapAliasesAttribute', false); } + // Master password is required when master user is set + $masterUser = $data['masterUser'] ?? ''; + $masterPasswordEnabled = (bool)($data['masterPasswordEnabled'] ?? false); + if (!empty($masterUser) && $masterUser !== Provisioning::MASTER_PASSWORD_PLACEHOLDER && !$masterPasswordEnabled) { + $exception->setField('masterPasswordEnabled', false); + } + if (!empty($exception->getFields())) { throw $exception; } @@ -113,6 +120,10 @@ public function validate(array $data): Provisioning { if (isset($data['masterPassword']) && $data['masterPassword'] !== Provisioning::MASTER_PASSWORD_PLACEHOLDER) { $provisioning->setMasterPassword($data['masterPassword']); } + if (isset($data['masterUser']) && $data['masterUser'] !== Provisioning::MASTER_PASSWORD_PLACEHOLDER) { + $provisioning->setMasterUser($data['masterUser']); + } + $provisioning->setMasterUserSeparator($data['masterUserSeparator'] ?? '*'); $provisioning->setSieveEnabled((bool)$data['sieveEnabled']); $provisioning->setSieveHost($data['sieveHost'] ?? ''); diff --git a/lib/IMAP/IMAPClientFactory.php b/lib/IMAP/IMAPClientFactory.php index 93f88d6040..65e028f6a7 100644 --- a/lib/IMAP/IMAPClientFactory.php +++ b/lib/IMAP/IMAPClientFactory.php @@ -14,6 +14,7 @@ use Horde_Imap_Client_Socket; use OCA\Mail\Account; use OCA\Mail\Cache\HordeCacheFactory; +use OCA\Mail\Db\ProvisioningMapper; use OCA\Mail\Events\BeforeImapClientCreated; use OCA\Mail\Exception\ServiceException; use OCP\AppFramework\Utility\ITimeFactory; @@ -27,120 +28,133 @@ use function json_encode; class IMAPClientFactory { - /** @var ICrypto */ - private $crypto; - - /** @var IConfig */ - private $config; - - /** @var ICacheFactory */ - private $cacheFactory; - - /** @var IEventDispatcher */ - private $eventDispatcher; - - private ITimeFactory $timeFactory; - private HordeCacheFactory $hordeCacheFactory; - - public function __construct(ICrypto $crypto, - IConfig $config, - ICacheFactory $cacheFactory, - IEventDispatcher $eventDispatcher, - ITimeFactory $timeFactory, - HordeCacheFactory $hordeCacheFactory) { - $this->crypto = $crypto; - $this->config = $config; - $this->cacheFactory = $cacheFactory; - $this->eventDispatcher = $eventDispatcher; - $this->timeFactory = $timeFactory; - $this->hordeCacheFactory = $hordeCacheFactory; - } - - /** - * Get the connection object for the given account - * - * Connections are not closed until destruction, so the caller site is - * responsible to log out as soon as possible to keep the number of open - * (and stale) connections at a minimum. - * - * @param Account $account - * @param bool $useCache - * - * @return Horde_Imap_Client_Socket - * @throws ServiceException - */ - public function getClient(Account $account, bool $useCache = true): Horde_Imap_Client_Socket { - $this->eventDispatcher->dispatchTyped( - new BeforeImapClientCreated($account) - ); - $host = $account->getMailAccount()->getInboundHost(); - $user = $account->getMailAccount()->getInboundUser(); - $decryptedPassword = null; - if ($account->getMailAccount()->getInboundPassword() !== null) { - $decryptedPassword = $this->crypto->decrypt($account->getMailAccount()->getInboundPassword()); - } - $port = $account->getMailAccount()->getInboundPort(); - $sslMode = $account->getMailAccount()->getInboundSslMode(); - if ($sslMode === 'none') { - $sslMode = false; - } - - $params = [ - 'username' => $user, - 'password' => $decryptedPassword, - 'hostspec' => $host, - 'port' => $port, - 'secure' => $sslMode, - 'timeout' => (int)$this->config->getSystemValue('app.mail.imap.timeout', 5), - 'context' => [ - 'ssl' => [ - 'verify_peer' => $this->config->getSystemValueBool('app.mail.verify-tls-peer', true), - 'verify_peer_name' => $this->config->getSystemValueBool('app.mail.verify-tls-peer', true), - ], - ], - ]; - if ($account->getMailAccount()->getAuthMethod() === 'xoauth2') { - try { - $oauthAccessToken = $account->getMailAccount()->getOauthAccessToken(); - if ($oauthAccessToken === null) { - throw new ServiceException('Missing access token for xoauth2 account'); - } - $decryptedAccessToken = $this->crypto->decrypt($oauthAccessToken); - } catch (Exception $e) { - throw new ServiceException('Could not decrypt account access token: ' . $e->getMessage(), 0, $e); - } - - $params['password'] = $decryptedAccessToken; // Not used, but Horde wants this - $params['xoauth2_token'] = new Horde_Imap_Client_Password_Xoauth2( - $account->getEmail(), - $decryptedAccessToken, - ); - } - $paramHash = hash( - 'sha512', - implode('-', [ - $this->config->getSystemValueString('secret'), - $account->getId(), - json_encode($params) - ]), - ); - if ($useCache) { - $params['cache'] = [ - 'backend' => $this->hordeCacheFactory->newCache($account), - ]; - } - if ($account->getDebug() || $this->config->getSystemValueBool('app.mail.debug')) { - $fn = 'mail-' . $account->getUserId() . '-' . $account->getId() . '-imap.log'; - $params['debug'] = $this->config->getSystemValue('datadirectory') . '/' . $fn; - } - - $client = new HordeImapClient($params); - - $rateLimitingCache = $this->cacheFactory->createDistributed('mail_imap_ratelimit'); - if ($rateLimitingCache instanceof IMemcache) { - $client->enableRateLimiter($rateLimitingCache, $paramHash, $this->timeFactory); - } - - return $client; - } + /** @var ICrypto */ + private $crypto; + + /** @var IConfig */ + private $config; + + /** @var ICacheFactory */ + private $cacheFactory; + + /** @var IEventDispatcher */ + private $eventDispatcher; + + private ITimeFactory $timeFactory; + private HordeCacheFactory $hordeCacheFactory; + private ProvisioningMapper $provisioningMapper; + + public function __construct(ICrypto $crypto, + IConfig $config, + ICacheFactory $cacheFactory, + IEventDispatcher $eventDispatcher, + ITimeFactory $timeFactory, + HordeCacheFactory $hordeCacheFactory, + ProvisioningMapper $provisioningMapper) { + $this->crypto = $crypto; + $this->config = $config; + $this->cacheFactory = $cacheFactory; + $this->eventDispatcher = $eventDispatcher; + $this->timeFactory = $timeFactory; + $this->hordeCacheFactory = $hordeCacheFactory; + $this->provisioningMapper = $provisioningMapper; + } + + /** + * Get the connection object for the given account + * + * Connections are not closed until destruction, so the caller site is + * responsible to log out as soon as possible to keep the number of open + * (and stale) connections at a minimum. + * + * @param Account $account + * @param bool $useCache + * + * @return Horde_Imap_Client_Socket + * @throws ServiceException + */ + public function getClient(Account $account, bool $useCache = true): Horde_Imap_Client_Socket { + $this->eventDispatcher->dispatchTyped( + new BeforeImapClientCreated($account) + ); + $host = $account->getMailAccount()->getInboundHost(); + $user = $account->getMailAccount()->getInboundUser(); + $decryptedPassword = null; + if ($account->getMailAccount()->getInboundPassword() !== null) { + $decryptedPassword = $this->crypto->decrypt($account->getMailAccount()->getInboundPassword()); + } + $port = $account->getMailAccount()->getInboundPort(); + $sslMode = $account->getMailAccount()->getInboundSslMode(); + if ($sslMode === 'none') { + $sslMode = false; + } + + // Check for Dovecot master user authentication + $provisioningId = $account->getMailAccount()->getProvisioningId(); + if ($provisioningId !== null) { + $provisioning = $this->provisioningMapper->get($provisioningId); + if ($provisioning !== null && !empty($provisioning->getMasterUser())) { + $separator = $provisioning->getMasterUserSeparator() ?? '*'; + $user = $user . $separator . $provisioning->getMasterUser(); + } + } + + $params = [ + 'username' => $user, + 'password' => $decryptedPassword, + 'hostspec' => $host, + 'port' => $port, + 'secure' => $sslMode, + 'timeout' => (int)$this->config->getSystemValue('app.mail.imap.timeout', 5), + 'context' => [ + 'ssl' => [ + 'verify_peer' => $this->config->getSystemValueBool('app.mail.verify-tls-peer', true), + 'verify_peer_name' => $this->config->getSystemValueBool('app.mail.verify-tls-peer', true), + ], + ], + ]; + if ($account->getMailAccount()->getAuthMethod() === 'xoauth2') { + try { + $oauthAccessToken = $account->getMailAccount()->getOauthAccessToken(); + if ($oauthAccessToken === null) { + throw new ServiceException('Missing access token for xoauth2 account'); + } + $decryptedAccessToken = $this->crypto->decrypt($oauthAccessToken); + } catch (Exception $e) { + throw new ServiceException('Could not decrypt account access token: ' . $e->getMessage(), 0, $e); + } + + $params['password'] = $decryptedAccessToken; // Not used, but Horde wants this + $params['xoauth2_token'] = new Horde_Imap_Client_Password_Xoauth2( + $account->getEmail(), + $decryptedAccessToken, + ); + } + $paramHash = hash( + 'sha512', + implode('-', [ + $this->config->getSystemValueString('secret'), + $account->getId(), + json_encode($params) + ]), + ); + if ($useCache) { + $params['cache'] = [ + 'backend' => $this->hordeCacheFactory->newCache($account), + ]; + } + if ($account->getDebug() || $this->config->getSystemValueBool('app.mail.debug')) { + $fn = 'mail-' . $account->getUserId() . '-' . $account->getId() . '-imap.log'; + $params['debug'] = $this->config->getSystemValue('datadirectory') . '/' . $fn; + } + + $client = new HordeImapClient($params); + + $rateLimitingCache = $this->cacheFactory->createDistributed('mail_imap_ratelimit'); + if ($rateLimitingCache instanceof IMemcache) { + $client->enableRateLimiter($rateLimitingCache, $paramHash, $this->timeFactory); + } + + return $client; + } } diff --git a/lib/Migration/Version5007Date20260124120000.php b/lib/Migration/Version5007Date20260124120000.php new file mode 100644 index 0000000000..5826e86f8c --- /dev/null +++ b/lib/Migration/Version5007Date20260124120000.php @@ -0,0 +1,52 @@ +getTable('mail_provisionings'); + if (!$provisioningTable->hasColumn('master_user')) { + $provisioningTable->addColumn('master_user', Types::STRING, [ + 'notnull' => false, + 'length' => 256, + ]); + } + if (!$provisioningTable->hasColumn('master_user_separator')) { + $provisioningTable->addColumn('master_user_separator', Types::STRING, [ + 'notnull' => false, + 'length' => 8, + 'default' => '*', + ]); + } + + return $schema; + } +} diff --git a/lib/SMTP/SmtpClientFactory.php b/lib/SMTP/SmtpClientFactory.php index 00eed8cf3d..8dc1e398bf 100644 --- a/lib/SMTP/SmtpClientFactory.php +++ b/lib/SMTP/SmtpClientFactory.php @@ -14,6 +14,7 @@ use Horde_Mail_Transport_Smtphorde; use Horde_Smtp_Password_Xoauth2; use OCA\Mail\Account; +use OCA\Mail\Db\ProvisioningMapper; use OCA\Mail\Exception\ServiceException; use OCA\Mail\Support\HostNameFactory; use OCP\IConfig; @@ -29,12 +30,16 @@ class SmtpClientFactory { /** @var HostNameFactory */ private $hostNameFactory; + private ProvisioningMapper $provisioningMapper; + public function __construct(IConfig $config, ICrypto $crypto, - HostNameFactory $hostNameFactory) { + HostNameFactory $hostNameFactory, + ProvisioningMapper $provisioningMapper) { $this->config = $config; $this->crypto = $crypto; $this->hostNameFactory = $hostNameFactory; + $this->provisioningMapper = $provisioningMapper; } /** @@ -50,12 +55,25 @@ public function create(Account $account): Horde_Mail_Transport { $decryptedPassword = $this->crypto->decrypt($mailAccount->getOutboundPassword()); } $security = $mailAccount->getOutboundSslMode(); + + $username = $mailAccount->getOutboundUser(); + + // Check for Dovecot master user authentication + $provisioningId = $mailAccount->getProvisioningId(); + if ($provisioningId !== null) { + $provisioning = $this->provisioningMapper->get($provisioningId); + if ($provisioning !== null && !empty($provisioning->getMasterUser())) { + $separator = $provisioning->getMasterUserSeparator() ?? '*'; + $username = $username . $separator . $provisioning->getMasterUser(); + } + } + $params = [ 'localhost' => $this->hostNameFactory->getHostName(), 'host' => $mailAccount->getOutboundHost(), 'password' => $decryptedPassword, 'port' => $mailAccount->getOutboundPort(), - 'username' => $mailAccount->getOutboundUser(), + 'username' => $username, 'secure' => $security === 'none' ? false : $security, 'timeout' => (int)$this->config->getSystemValue('app.mail.smtp.timeout', 20), 'context' => [ diff --git a/lib/Sieve/SieveClientFactory.php b/lib/Sieve/SieveClientFactory.php index bd0fb0b4b6..b381b1ea8f 100644 --- a/lib/Sieve/SieveClientFactory.php +++ b/lib/Sieve/SieveClientFactory.php @@ -11,17 +11,20 @@ use Horde\ManageSieve; use OCA\Mail\Account; +use OCA\Mail\Db\ProvisioningMapper; use OCP\IConfig; use OCP\Security\ICrypto; class SieveClientFactory { private ICrypto $crypto; private IConfig $config; + private ProvisioningMapper $provisioningMapper; private array $cache = []; - public function __construct(ICrypto $crypto, IConfig $config) { + public function __construct(ICrypto $crypto, IConfig $config, ProvisioningMapper $provisioningMapper) { $this->crypto = $crypto; $this->config = $config; + $this->provisioningMapper = $provisioningMapper; } /** @@ -38,6 +41,16 @@ public function getClient(Account $account): ManageSieve { $password = $account->getMailAccount()->getInboundPassword(); } + // Check for Dovecot master user authentication + $provisioningId = $account->getMailAccount()->getProvisioningId(); + if ($provisioningId !== null) { + $provisioning = $this->provisioningMapper->get($provisioningId); + if ($provisioning !== null && !empty($provisioning->getMasterUser())) { + $separator = $provisioning->getMasterUserSeparator() ?? '*'; + $user = $user . $separator . $provisioning->getMasterUser(); + } + } + if ($account->getDebug() || $this->config->getSystemValueBool('app.mail.debug')) { $logFile = $this->config->getSystemValue('datadirectory') . '/mail-' . $account->getUserId() . '-' . $account->getId() . '-sieve.log'; } else { diff --git a/src/components/settings/ProvisionPreview.vue b/src/components/settings/ProvisionPreview.vue index 715f7edf61..e3ba6b5fb0 100644 --- a/src/components/settings/ProvisionPreview.vue +++ b/src/components/settings/ProvisionPreview.vue @@ -15,7 +15,7 @@ {{ t('mail', 'Email: {email}', { email }) }}
{{ t('mail', 'IMAP: {user} on {host}:{port} ({ssl} encryption)', { - user: imapUser, + user: imapLoginUser, host: imapHost, port: imapPort, ssl: imapSslMode, @@ -23,7 +23,7 @@ }}
{{ t('mail', 'SMTP: {user} on {host}:{port} ({ssl} encryption)', { - user: smtpUser, + user: smtpLoginUser, host: smtpHost, port: smtpPort, ssl: smtpSslMode, @@ -32,13 +32,21 @@ {{ t('mail', 'Sieve: {user} on {host}:{port} ({ssl} encryption)', { - user: sieveUser, + user: sieveLoginUser, host: sieveHost, port: sievePort, ssl: sieveSslMode, }) }}
+ + +
+ {{ t('mail', 'Using static password for all users') }} +
@@ -117,6 +125,46 @@ export default { sieveUser() { return this.templates.sieveUser.replace('%USERID%', this.data.uid).replace('%EMAIL%', this.data.email) }, + + masterPasswordEnabled() { + return this.templates.masterPasswordEnabled + }, + + masterUser() { + return this.templates.masterUser || '' + }, + + masterUserSeparator() { + return this.templates.masterUserSeparator || '*' + }, + + hasMasterUser() { + return this.masterUser && this.masterUser !== '********' && this.masterUser.length > 0 + }, + + imapLoginUser() { + const baseUser = this.imapUser + if (this.hasMasterUser) { + return baseUser + this.masterUserSeparator + this.masterUser + } + return baseUser + }, + + smtpLoginUser() { + const baseUser = this.smtpUser + if (this.hasMasterUser) { + return baseUser + this.masterUserSeparator + this.masterUser + } + return baseUser + }, + + sieveLoginUser() { + const baseUser = this.sieveUser + if (this.hasMasterUser) { + return baseUser + this.masterUserSeparator + this.masterUser + } + return baseUser + }, }, } diff --git a/src/components/settings/ProvisioningSettings.vue b/src/components/settings/ProvisioningSettings.vue index ac7be23921..10b2282f7e 100644 --- a/src/components/settings/ProvisioningSettings.vue +++ b/src/components/settings/ProvisioningSettings.vue @@ -191,7 +191,8 @@ :id="'mail-master-password-enabled' + setting.id" v-model="masterPasswordEnabled" type="checkbox" - class="checkbox"> + class="checkbox" + :required="masterUser.length > 0 && masterUser !== '********'"> @@ -205,6 +206,36 @@ :required="masterPasswordEnabled"> +

+ {{ t('mail', 'When only master password is set, all users will authenticate with their normal username and this static password.') }} +

+
+ +

{{ t('mail', 'When master user is set, authentication will use the Dovecot master user format: user{separator}masteruser with the master password.') }}

+
+
+ +

{{ t('mail', 'The separator between the user and master user (default: *)') }}

+
@@ -426,6 +457,8 @@ export default { smtpSslMode: this.setting.smtpSslMode || 'tls', masterPasswordEnabled: this.setting.masterPasswordEnabled === true, masterPassword: this.setting.masterPassword || '', + masterUser: this.setting.masterUser || '', + masterUserSeparator: this.setting.masterUserSeparator || '*', sieveEnabled: this.setting.sieveEnabled || '', sieveHost: this.setting.sieveHost || '', sievePort: this.setting.sievePort || '', @@ -462,6 +495,8 @@ export default { smtpSslMode: this.smtpSslMode, masterPasswordEnabled: this.masterPasswordEnabled, masterPassword: this.masterPassword, + masterUser: this.masterUser, + masterUserSeparator: this.masterUserSeparator, sieveEnabled: this.sieveEnabled, sieveUser: this.sieveUser, sieveHost: this.sieveHost, @@ -497,6 +532,8 @@ export default { smtpSslMode: this.smtpSslMode, masterPasswordEnabled: this.masterPasswordEnabled, masterPassword: this.masterPassword, + masterUser: this.masterUser, + masterUserSeparator: this.masterUserSeparator, sieveEnabled: this.sieveEnabled, sieveUser: this.sieveUser, sieveHost: this.sieveHost, From 00447a252dbc89c3735b72f8db42a40b7de263ac Mon Sep 17 00:00:00 2001 From: Timo Nieminen Date: Sat, 24 Jan 2026 13:42:29 +0000 Subject: [PATCH 2/2] feat(provisioning): integrate ProvisioningMapper into IMAP and Sieve client factories and tests Signed-off-by: Timo Nieminen --- lib/IMAP/IMAPClientFactory.php | 258 +++++++++--------- tests/Integration/Framework/Caching.php | 2 + .../IMAP/IMAPClientFactoryTest.php | 4 + .../Sieve/SieveClientFactoryTest.php | 7 +- tests/Unit/SMTP/SmtpClientFactoryTest.php | 7 +- 5 files changed, 147 insertions(+), 131 deletions(-) diff --git a/lib/IMAP/IMAPClientFactory.php b/lib/IMAP/IMAPClientFactory.php index 65e028f6a7..5e37f5cab2 100644 --- a/lib/IMAP/IMAPClientFactory.php +++ b/lib/IMAP/IMAPClientFactory.php @@ -28,133 +28,133 @@ use function json_encode; class IMAPClientFactory { - /** @var ICrypto */ - private $crypto; - - /** @var IConfig */ - private $config; - - /** @var ICacheFactory */ - private $cacheFactory; - - /** @var IEventDispatcher */ - private $eventDispatcher; - - private ITimeFactory $timeFactory; - private HordeCacheFactory $hordeCacheFactory; - private ProvisioningMapper $provisioningMapper; - - public function __construct(ICrypto $crypto, - IConfig $config, - ICacheFactory $cacheFactory, - IEventDispatcher $eventDispatcher, - ITimeFactory $timeFactory, - HordeCacheFactory $hordeCacheFactory, - ProvisioningMapper $provisioningMapper) { - $this->crypto = $crypto; - $this->config = $config; - $this->cacheFactory = $cacheFactory; - $this->eventDispatcher = $eventDispatcher; - $this->timeFactory = $timeFactory; - $this->hordeCacheFactory = $hordeCacheFactory; - $this->provisioningMapper = $provisioningMapper; - } - - /** - * Get the connection object for the given account - * - * Connections are not closed until destruction, so the caller site is - * responsible to log out as soon as possible to keep the number of open - * (and stale) connections at a minimum. - * - * @param Account $account - * @param bool $useCache - * - * @return Horde_Imap_Client_Socket - * @throws ServiceException - */ - public function getClient(Account $account, bool $useCache = true): Horde_Imap_Client_Socket { - $this->eventDispatcher->dispatchTyped( - new BeforeImapClientCreated($account) - ); - $host = $account->getMailAccount()->getInboundHost(); - $user = $account->getMailAccount()->getInboundUser(); - $decryptedPassword = null; - if ($account->getMailAccount()->getInboundPassword() !== null) { - $decryptedPassword = $this->crypto->decrypt($account->getMailAccount()->getInboundPassword()); - } - $port = $account->getMailAccount()->getInboundPort(); - $sslMode = $account->getMailAccount()->getInboundSslMode(); - if ($sslMode === 'none') { - $sslMode = false; - } - - // Check for Dovecot master user authentication - $provisioningId = $account->getMailAccount()->getProvisioningId(); - if ($provisioningId !== null) { - $provisioning = $this->provisioningMapper->get($provisioningId); - if ($provisioning !== null && !empty($provisioning->getMasterUser())) { - $separator = $provisioning->getMasterUserSeparator() ?? '*'; - $user = $user . $separator . $provisioning->getMasterUser(); - } - } - - $params = [ - 'username' => $user, - 'password' => $decryptedPassword, - 'hostspec' => $host, - 'port' => $port, - 'secure' => $sslMode, - 'timeout' => (int)$this->config->getSystemValue('app.mail.imap.timeout', 5), - 'context' => [ - 'ssl' => [ - 'verify_peer' => $this->config->getSystemValueBool('app.mail.verify-tls-peer', true), - 'verify_peer_name' => $this->config->getSystemValueBool('app.mail.verify-tls-peer', true), - ], - ], - ]; - if ($account->getMailAccount()->getAuthMethod() === 'xoauth2') { - try { - $oauthAccessToken = $account->getMailAccount()->getOauthAccessToken(); - if ($oauthAccessToken === null) { - throw new ServiceException('Missing access token for xoauth2 account'); - } - $decryptedAccessToken = $this->crypto->decrypt($oauthAccessToken); - } catch (Exception $e) { - throw new ServiceException('Could not decrypt account access token: ' . $e->getMessage(), 0, $e); - } - - $params['password'] = $decryptedAccessToken; // Not used, but Horde wants this - $params['xoauth2_token'] = new Horde_Imap_Client_Password_Xoauth2( - $account->getEmail(), - $decryptedAccessToken, - ); - } - $paramHash = hash( - 'sha512', - implode('-', [ - $this->config->getSystemValueString('secret'), - $account->getId(), - json_encode($params) - ]), - ); - if ($useCache) { - $params['cache'] = [ - 'backend' => $this->hordeCacheFactory->newCache($account), - ]; - } - if ($account->getDebug() || $this->config->getSystemValueBool('app.mail.debug')) { - $fn = 'mail-' . $account->getUserId() . '-' . $account->getId() . '-imap.log'; - $params['debug'] = $this->config->getSystemValue('datadirectory') . '/' . $fn; - } - - $client = new HordeImapClient($params); - - $rateLimitingCache = $this->cacheFactory->createDistributed('mail_imap_ratelimit'); - if ($rateLimitingCache instanceof IMemcache) { - $client->enableRateLimiter($rateLimitingCache, $paramHash, $this->timeFactory); - } - - return $client; - } + /** @var ICrypto */ + private $crypto; + + /** @var IConfig */ + private $config; + + /** @var ICacheFactory */ + private $cacheFactory; + + /** @var IEventDispatcher */ + private $eventDispatcher; + + private ITimeFactory $timeFactory; + private HordeCacheFactory $hordeCacheFactory; + private ProvisioningMapper $provisioningMapper; + + public function __construct(ICrypto $crypto, + IConfig $config, + ICacheFactory $cacheFactory, + IEventDispatcher $eventDispatcher, + ITimeFactory $timeFactory, + HordeCacheFactory $hordeCacheFactory, + ProvisioningMapper $provisioningMapper) { + $this->crypto = $crypto; + $this->config = $config; + $this->cacheFactory = $cacheFactory; + $this->eventDispatcher = $eventDispatcher; + $this->timeFactory = $timeFactory; + $this->hordeCacheFactory = $hordeCacheFactory; + $this->provisioningMapper = $provisioningMapper; + } + + /** + * Get the connection object for the given account + * + * Connections are not closed until destruction, so the caller site is + * responsible to log out as soon as possible to keep the number of open + * (and stale) connections at a minimum. + * + * @param Account $account + * @param bool $useCache + * + * @return Horde_Imap_Client_Socket + * @throws ServiceException + */ + public function getClient(Account $account, bool $useCache = true): Horde_Imap_Client_Socket { + $this->eventDispatcher->dispatchTyped( + new BeforeImapClientCreated($account) + ); + $host = $account->getMailAccount()->getInboundHost(); + $user = $account->getMailAccount()->getInboundUser(); + $decryptedPassword = null; + if ($account->getMailAccount()->getInboundPassword() !== null) { + $decryptedPassword = $this->crypto->decrypt($account->getMailAccount()->getInboundPassword()); + } + $port = $account->getMailAccount()->getInboundPort(); + $sslMode = $account->getMailAccount()->getInboundSslMode(); + if ($sslMode === 'none') { + $sslMode = false; + } + + // Check for Dovecot master user authentication + $provisioningId = $account->getMailAccount()->getProvisioningId(); + if ($provisioningId !== null) { + $provisioning = $this->provisioningMapper->get($provisioningId); + if ($provisioning !== null && !empty($provisioning->getMasterUser())) { + $separator = $provisioning->getMasterUserSeparator() ?? '*'; + $user = $user . $separator . $provisioning->getMasterUser(); + } + } + + $params = [ + 'username' => $user, + 'password' => $decryptedPassword, + 'hostspec' => $host, + 'port' => $port, + 'secure' => $sslMode, + 'timeout' => (int)$this->config->getSystemValue('app.mail.imap.timeout', 5), + 'context' => [ + 'ssl' => [ + 'verify_peer' => $this->config->getSystemValueBool('app.mail.verify-tls-peer', true), + 'verify_peer_name' => $this->config->getSystemValueBool('app.mail.verify-tls-peer', true), + ], + ], + ]; + if ($account->getMailAccount()->getAuthMethod() === 'xoauth2') { + try { + $oauthAccessToken = $account->getMailAccount()->getOauthAccessToken(); + if ($oauthAccessToken === null) { + throw new ServiceException('Missing access token for xoauth2 account'); + } + $decryptedAccessToken = $this->crypto->decrypt($oauthAccessToken); + } catch (Exception $e) { + throw new ServiceException('Could not decrypt account access token: ' . $e->getMessage(), 0, $e); + } + + $params['password'] = $decryptedAccessToken; // Not used, but Horde wants this + $params['xoauth2_token'] = new Horde_Imap_Client_Password_Xoauth2( + $account->getEmail(), + $decryptedAccessToken, + ); + } + $paramHash = hash( + 'sha512', + implode('-', [ + $this->config->getSystemValueString('secret'), + $account->getId(), + json_encode($params) + ]), + ); + if ($useCache) { + $params['cache'] = [ + 'backend' => $this->hordeCacheFactory->newCache($account), + ]; + } + if ($account->getDebug() || $this->config->getSystemValueBool('app.mail.debug')) { + $fn = 'mail-' . $account->getUserId() . '-' . $account->getId() . '-imap.log'; + $params['debug'] = $this->config->getSystemValue('datadirectory') . '/' . $fn; + } + + $client = new HordeImapClient($params); + + $rateLimitingCache = $this->cacheFactory->createDistributed('mail_imap_ratelimit'); + if ($rateLimitingCache instanceof IMemcache) { + $client->enableRateLimiter($rateLimitingCache, $paramHash, $this->timeFactory); + } + + return $client; + } } diff --git a/tests/Integration/Framework/Caching.php b/tests/Integration/Framework/Caching.php index d54d74714f..2c966fc35d 100644 --- a/tests/Integration/Framework/Caching.php +++ b/tests/Integration/Framework/Caching.php @@ -11,6 +11,7 @@ use OC\Memcache\Factory; use OCA\Mail\Cache\HordeCacheFactory; +use OCA\Mail\Db\ProvisioningMapper; use OCA\Mail\IMAP\IMAPClientFactory; use OCP\AppFramework\Utility\ITimeFactory; use OCP\EventDispatcher\IEventDispatcher; @@ -60,6 +61,7 @@ public static function getImapClientFactoryAndConfiguredCacheFactory(?ICrypto $c Server::get(IEventDispatcher::class), Server::get(ITimeFactory::class), Server::get(HordeCacheFactory::class), + Server::get(ProvisioningMapper::class), ); return [$imapClient, $cacheFactory]; } diff --git a/tests/Integration/IMAP/IMAPClientFactoryTest.php b/tests/Integration/IMAP/IMAPClientFactoryTest.php index 0d60515db8..75ed255580 100644 --- a/tests/Integration/IMAP/IMAPClientFactoryTest.php +++ b/tests/Integration/IMAP/IMAPClientFactoryTest.php @@ -17,6 +17,7 @@ use OCA\Mail\Account; use OCA\Mail\Cache\HordeCacheFactory; use OCA\Mail\Db\MailAccount; +use OCA\Mail\Db\ProvisioningMapper; use OCA\Mail\IMAP\HordeImapClient; use OCA\Mail\IMAP\IMAPClientFactory; use OCA\Mail\Tests\Integration\Framework\Caching; @@ -44,6 +45,7 @@ class IMAPClientFactoryTest extends TestCase { private IEventDispatcher|MockObject $eventDispatcher; private ITimeFactory|MockObject $timeFactory; private HordeCacheFactory|MockObject $hordeCacheFactory; + private ProvisioningMapper|MockObject $provisioningMapper; protected function setUp(): void { parent::setUp(); @@ -54,6 +56,7 @@ protected function setUp(): void { $this->eventDispatcher = $this->createMock(IEventDispatcher::class); $this->timeFactory = $this->createMock(ITimeFactory::class); $this->hordeCacheFactory = $this->createMock(HordeCacheFactory::class); + $this->provisioningMapper = $this->createMock(ProvisioningMapper::class); $this->factory = new IMAPClientFactory( $this->crypto, @@ -62,6 +65,7 @@ protected function setUp(): void { $this->eventDispatcher, $this->timeFactory, $this->hordeCacheFactory, + $this->provisioningMapper, ); } diff --git a/tests/Integration/Sieve/SieveClientFactoryTest.php b/tests/Integration/Sieve/SieveClientFactoryTest.php index 8218228251..25daf0afbc 100644 --- a/tests/Integration/Sieve/SieveClientFactoryTest.php +++ b/tests/Integration/Sieve/SieveClientFactoryTest.php @@ -13,6 +13,7 @@ use Horde\ManageSieve; use OCA\Mail\Account; use OCA\Mail\Db\MailAccount; +use OCA\Mail\Db\ProvisioningMapper; use OCA\Mail\Sieve\SieveClientFactory; use OCP\IConfig; use OCP\Security\ICrypto; @@ -26,6 +27,9 @@ class SieveClientFactoryTest extends TestCase { /** @var IConfig|MockObject */ private $config; + /** @var ProvisioningMapper|MockObject */ + private $provisioningMapper; + /** @var SieveClientFactory */ private $factory; @@ -34,6 +38,7 @@ protected function setUp(): void { $this->crypto = $this->createMock(ICrypto::class); $this->config = $this->createMock(IConfig::class); + $this->provisioningMapper = $this->createMock(ProvisioningMapper::class); $this->config->method('getSystemValueInt') ->willReturnMap([ @@ -46,7 +51,7 @@ protected function setUp(): void { ['app.mail.debug', false, false], ]); - $this->factory = new SieveClientFactory($this->crypto, $this->config); + $this->factory = new SieveClientFactory($this->crypto, $this->config, $this->provisioningMapper); } /** diff --git a/tests/Unit/SMTP/SmtpClientFactoryTest.php b/tests/Unit/SMTP/SmtpClientFactoryTest.php index 1e9e918874..f778debd5d 100644 --- a/tests/Unit/SMTP/SmtpClientFactoryTest.php +++ b/tests/Unit/SMTP/SmtpClientFactoryTest.php @@ -13,6 +13,7 @@ use Horde_Mail_Transport_Smtphorde; use OCA\Mail\Account; use OCA\Mail\Db\MailAccount; +use OCA\Mail\Db\ProvisioningMapper; use OCA\Mail\SMTP\SmtpClientFactory; use OCA\Mail\Support\HostNameFactory; use OCP\IConfig; @@ -29,6 +30,9 @@ class SmtpClientFactoryTest extends TestCase { /** @var HostNameFactory|MockObject */ private $hostNameFactory; + /** @var ProvisioningMapper|MockObject */ + private $provisioningMapper; + /** @var SmtpClientFactory */ private $factory; @@ -38,8 +42,9 @@ protected function setUp(): void { $this->config = $this->createMock(IConfig::class); $this->crypto = $this->createMock(ICrypto::class); $this->hostNameFactory = $this->createMock(HostNameFactory::class); + $this->provisioningMapper = $this->createMock(ProvisioningMapper::class); - $this->factory = new SmtpClientFactory($this->config, $this->crypto, $this->hostNameFactory); + $this->factory = new SmtpClientFactory($this->config, $this->crypto, $this->hostNameFactory, $this->provisioningMapper); } public function testSmtpTransport() {