From 5db4d044ce9cb0b3837ff2207ea8ee30152fd86b Mon Sep 17 00:00:00 2001 From: Timo Nieminen Date: Sat, 24 Jan 2026 07:11:16 +0000 Subject: [PATCH 01/12] feat(provisioning): Add support for Dovecot master user authentication Signed-off-by: Timo Nieminen --- lib/Db/Provisioning.php | 10 ++++ lib/Db/ProvisioningMapper.php | 11 ++++ lib/IMAP/IMAPClientFactory.php | 16 +++++- .../Version5007Date20260124120000.php | 52 ++++++++++++++++++ lib/SMTP/SmtpClientFactory.php | 22 +++++++- lib/Sieve/SieveClientFactory.php | 15 +++++- src/components/settings/ProvisionPreview.vue | 54 +++++++++++++++++-- .../settings/ProvisioningSettings.vue | 39 +++++++++++++- tests/Integration/Framework/Caching.php | 2 + .../IMAP/IMAPClientFactoryTest.php | 4 ++ .../Sieve/SieveClientFactoryTest.php | 7 ++- tests/Unit/SMTP/SmtpClientFactoryTest.php | 7 ++- 12 files changed, 229 insertions(+), 10 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 229d09b641..1d024fbdc2 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; @@ -41,19 +42,22 @@ class IMAPClientFactory { private ITimeFactory $timeFactory; private HordeCacheFactory $hordeCacheFactory; + private ProvisioningMapper $provisioningMapper; public function __construct(ICrypto $crypto, IConfig $config, ICacheFactory $cacheFactory, IEventDispatcher $eventDispatcher, ITimeFactory $timeFactory, - HordeCacheFactory $hordeCacheFactory) { + 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; } /** @@ -85,6 +89,16 @@ public function getClient(Account $account, bool $useCache = true): Horde_Imap_C $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, 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 0315414a12..072c6059cf 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 fb9264ae6b..d1c209a74e 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->getMailAccount()->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, 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() { From d85b9c6e7021ed187523f79386fd2510cf5c4da9 Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Tue, 17 Feb 2026 18:29:12 +0100 Subject: [PATCH 02/12] fixup! feat(provisioning): Add support for Dovecot master user authentication --- appinfo/info.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/appinfo/info.xml b/appinfo/info.xml index 7eb0b1411b..79b7fe75a3 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -34,7 +34,7 @@ The rating depends on the installed text processing backend. See [the rating ove Learn more about the Nextcloud Ethical AI Rating [in our blog](https://nextcloud.com/blog/nextcloud-ethical-ai-rating/). ]]> - 5.7.0-rc.1 + 5.7.0-rc.2 agpl Christoph Wurst GretaD From db13be6129bca5ea275a36d535306ee82ad37eeb Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Tue, 17 Feb 2026 19:59:34 +0100 Subject: [PATCH 03/12] fixup! feat(provisioning): Add support for Dovecot master user authentication --- lib/IMAP/IMAPClientFactory.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/IMAP/IMAPClientFactory.php b/lib/IMAP/IMAPClientFactory.php index 1d024fbdc2..5b4de11d55 100644 --- a/lib/IMAP/IMAPClientFactory.php +++ b/lib/IMAP/IMAPClientFactory.php @@ -42,7 +42,6 @@ class IMAPClientFactory { private ITimeFactory $timeFactory; private HordeCacheFactory $hordeCacheFactory; - private ProvisioningMapper $provisioningMapper; public function __construct(ICrypto $crypto, IConfig $config, @@ -50,14 +49,13 @@ public function __construct(ICrypto $crypto, IEventDispatcher $eventDispatcher, ITimeFactory $timeFactory, HordeCacheFactory $hordeCacheFactory, - ProvisioningMapper $provisioningMapper) { + private ProvisioningMapper $provisioningMapper) { $this->crypto = $crypto; $this->config = $config; $this->cacheFactory = $cacheFactory; $this->eventDispatcher = $eventDispatcher; $this->timeFactory = $timeFactory; $this->hordeCacheFactory = $hordeCacheFactory; - $this->provisioningMapper = $provisioningMapper; } /** From 507857f15821fc778a93f84a140dfd80d753d632 Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Tue, 17 Feb 2026 20:27:42 +0100 Subject: [PATCH 04/12] fix(provisioning): Flag port as faulty instead of host Signed-off-by: Daniel Kesselberg --- lib/Db/ProvisioningMapper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Db/ProvisioningMapper.php b/lib/Db/ProvisioningMapper.php index 312ee430e9..14fbd2a585 100644 --- a/lib/Db/ProvisioningMapper.php +++ b/lib/Db/ProvisioningMapper.php @@ -67,7 +67,7 @@ public function validate(array $data): Provisioning { $exception->setField('imapHost', false); } if (!isset($data['imapPort']) || (int)$data['imapPort'] === 0) { - $exception->setField('imapHost', false); + $exception->setField('imapPort', false); } if (!isset($data['imapSslMode']) || $data['imapSslMode'] === '') { $exception->setField('imapSslMode', false); From 332dd28cbc6a96e569d687b117c75d9a54aa0619 Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Tue, 17 Feb 2026 20:45:33 +0100 Subject: [PATCH 05/12] fixup! feat(provisioning): Add support for Dovecot master user authentication --- tests/Unit/Db/ProvisioningMapperTest.php | 156 +++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 tests/Unit/Db/ProvisioningMapperTest.php diff --git a/tests/Unit/Db/ProvisioningMapperTest.php b/tests/Unit/Db/ProvisioningMapperTest.php new file mode 100644 index 0000000000..f7f4ea8248 --- /dev/null +++ b/tests/Unit/Db/ProvisioningMapperTest.php @@ -0,0 +1,156 @@ +mapper = new ProvisioningMapper( + $this->createMock(IDBConnection::class), + new NullLogger(), + ); + } + + public function testValidateEmptyHost(): void { + $data = [ + 'provisioningDomain' => 'static.test', + 'emailTemplate' => '%USERID%@static.test', + 'imapUser' => '%EMAIL%', + 'imapHost' => '', + 'imapPort' => '143', + 'imapSslMode' => 'none', + 'smtpUser' => '%EMAIL%', + 'smtpHost' => '', + 'smtpPort' => '25', + 'smtpSslMode' => 'none', + ]; + + $exceptionWasThrown = false; + + try { + $this->mapper->validate($data); + } catch (ValidationException $e) { + $exceptionWasThrown = true; + $fields = $e->getFields(); + $this->assertCount(2, $fields); + $this->assertArrayHasKey('imapHost', $fields); + $this->assertArrayHasKey('smtpHost', $fields); + } + + $this->assertTrue($exceptionWasThrown); + } + + public function testValidateLdapAliasesProvisioningNeedsAttribute(): void { + $data = [ + 'provisioningDomain' => 'static.test', + 'emailTemplate' => '%USERID%@static.test', + 'imapUser' => '%EMAIL%', + 'imapHost' => 'static.test', + 'imapPort' => '143', + 'imapSslMode' => 'none', + 'smtpUser' => '%EMAIL%', + 'smtpHost' => 'static.test', + 'smtpPort' => '25', + 'smtpSslMode' => 'none', + 'ldapAliasesProvisioning' => true, + 'ldapAliasesAttribute' => '' + ]; + + $exceptionWasThrown = false; + + try { + $this->mapper->validate($data); + } catch (ValidationException $e) { + $exceptionWasThrown = true; + $fields = $e->getFields(); + $this->assertCount(1, $fields); + $this->assertArrayHasKey('ldapAliasesAttribute', $fields); + } + + $this->assertTrue($exceptionWasThrown); + } + + public function testValidateMasterPasswordNeedsPassword(): void { + $data = [ + 'provisioningDomain' => 'static.test', + 'emailTemplate' => '%USERID%@static.test', + 'imapUser' => '%EMAIL%', + 'imapHost' => 'static.test', + 'imapPort' => '143', + 'imapSslMode' => 'none', + 'smtpUser' => '%EMAIL%', + 'smtpHost' => 'static.test', + 'smtpPort' => '25', + 'smtpSslMode' => 'none', + 'masterPasswordEnabled' => true, + 'masterPassword' => '', + ]; + + $exceptionWasThrown = false; + + try { + $this->mapper->validate($data); + } catch (ValidationException $e) { + $exceptionWasThrown = true; + $fields = $e->getFields(); + $this->assertCount(1, $fields); + $this->assertArrayHasKey('masterPassword', $fields); + } + + $this->assertTrue($exceptionWasThrown); + } + + public function testValidateMasterPasswordWitUserNeedsPasswordAndSeparator(): void { + $data = [ + 'provisioningDomain' => 'static.test', + 'emailTemplate' => '%USERID%@static.test', + 'imapUser' => '%EMAIL%', + 'imapHost' => 'static.test', + 'imapPort' => '143', + 'imapSslMode' => 'none', + 'smtpUser' => '%EMAIL%', + 'smtpHost' => 'static.test', + 'smtpPort' => '25', + 'smtpSslMode' => 'none', + 'masterPasswordEnabled' => true, + 'masterPassword' => '', + 'masterUser' => 'master', + 'masterUserSeparator' => '', + ]; + + $exceptionWasThrown = false; + + try { + $this->mapper->validate($data); + } catch (ValidationException $e) { + $exceptionWasThrown = true; + $fields = $e->getFields(); + $this->assertCount(2, $fields); + $this->assertArrayHasKey('masterPassword', $fields); + $this->assertArrayHasKey('masterUserSeparator', $fields); + } + + $this->assertTrue($exceptionWasThrown); + } + + +} From 7b9191fe0435c3acee383d0b52ea0f8a04018afd Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Tue, 17 Feb 2026 20:45:44 +0100 Subject: [PATCH 06/12] fixup! feat(provisioning): Add support for Dovecot master user authentication --- lib/Db/Provisioning.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Db/Provisioning.php b/lib/Db/Provisioning.php index ee74c00840..ccdcc5b786 100644 --- a/lib/Db/Provisioning.php +++ b/lib/Db/Provisioning.php @@ -38,7 +38,7 @@ * @method bool|null getMasterPasswordEnabled() * @method void setMasterPasswordEnabled(bool $masterPasswordEnabled) * @method string|null getMasterPassword() - * @method void setMasterPassword(string $masterPassword) + * @method void setMasterPassword(?string $masterPassword) * @method string|null getMasterUser() * @method void setMasterUser(?string $masterUser) * @method string|null getMasterUserSeparator() From ba82449c6958e3b64e3eaa29251a919d00eeadb2 Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Tue, 17 Feb 2026 21:09:10 +0100 Subject: [PATCH 07/12] fixup! feat(provisioning): Add support for Dovecot master user authentication --- tests/Unit/Db/ProvisioningMapperTest.php | 25 ++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/tests/Unit/Db/ProvisioningMapperTest.php b/tests/Unit/Db/ProvisioningMapperTest.php index f7f4ea8248..f28fba44c3 100644 --- a/tests/Unit/Db/ProvisioningMapperTest.php +++ b/tests/Unit/Db/ProvisioningMapperTest.php @@ -10,10 +10,9 @@ namespace OCA\Mail\Tests\Unit\Db; use ChristophWurst\Nextcloud\Testing\TestCase; +use OCA\Mail\Db\Provisioning; use OCA\Mail\Db\ProvisioningMapper; use OCA\Mail\Exception\ValidationException; -use OCA\Mail\Tests\Integration\Db\Alias; -use OCA\Mail\Tests\Integration\Db\MockObject; use OCP\IDBConnection; use Psr\Log\NullLogger; @@ -152,5 +151,27 @@ public function testValidateMasterPasswordWitUserNeedsPasswordAndSeparator(): vo $this->assertTrue($exceptionWasThrown); } + public function testValidateKeepMasterPasswordSkipPlaceholder(): void { + $data = [ + 'provisioningDomain' => 'static.test', + 'emailTemplate' => '%USERID%@static.test', + 'imapUser' => '%EMAIL%', + 'imapHost' => 'static.test', + 'imapPort' => '143', + 'imapSslMode' => 'none', + 'smtpUser' => '%EMAIL%', + 'smtpHost' => 'static.test', + 'smtpPort' => '25', + 'smtpSslMode' => 'none', + 'sieveEnabled' => false, + 'masterPasswordEnabled' => true, + 'masterPassword' => Provisioning::MASTER_PASSWORD_PLACEHOLDER, + ]; + + $provisioning = $this->mapper->validate($data); + + $this->assertTrue($provisioning->getMasterPasswordEnabled()); + $this->assertNull($provisioning->getMasterPassword()); + } } From df3627d9d32286ec03e994fc24a8f575cb34af7e Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Tue, 17 Feb 2026 21:12:18 +0100 Subject: [PATCH 08/12] fixup! feat(provisioning): Add support for Dovecot master user authentication --- lib/Db/Provisioning.php | 4 ++-- lib/Db/ProvisioningMapper.php | 39 ++++++++++++++++++++++------------ lib/IMAP/IMAPClientFactory.php | 6 ++++-- 3 files changed, 31 insertions(+), 18 deletions(-) diff --git a/lib/Db/Provisioning.php b/lib/Db/Provisioning.php index ccdcc5b786..f7cae5121b 100644 --- a/lib/Db/Provisioning.php +++ b/lib/Db/Provisioning.php @@ -116,8 +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() ?? '*', + 'masterUser' => $this->getMasterUser(), + '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 14fbd2a585..f47a90cd66 100644 --- a/lib/Db/ProvisioningMapper.php +++ b/lib/Db/ProvisioningMapper.php @@ -92,11 +92,18 @@ 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); + $masterPassword = $data['masterPassword'] ?? ''; + $masterUser = $data['masterUser'] ?? ''; + $masterUserSeparator = $data['masterUserSeparator'] ?? ''; + + if ($masterPasswordEnabled) { + if ($masterPassword === '') { + $exception->setField('masterPassword', false); + } + if ($masterUser !== '' && $masterUserSeparator === '') { + $exception->setField('masterUserSeparator', false); + } } if (!empty($exception->getFields())) { @@ -115,16 +122,6 @@ public function validate(array $data): Provisioning { $provisioning->setSmtpHost($data['smtpHost']); $provisioning->setSmtpPort((int)$data['smtpPort']); $provisioning->setSmtpSslMode($data['smtpSslMode']); - - $provisioning->setMasterPasswordEnabled((bool)($data['masterPasswordEnabled'] ?? false)); - 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'] ?? ''); $provisioning->setSieveUser($data['sieveUser'] ?? ''); @@ -134,6 +131,20 @@ public function validate(array $data): Provisioning { $provisioning->setLdapAliasesProvisioning($ldapAliasesProvisioning); $provisioning->setLdapAliasesAttribute($ldapAliasesAttribute); + if ($masterPasswordEnabled) { + $provisioning->setMasterPasswordEnabled(true); + if ($masterPassword !== Provisioning::MASTER_PASSWORD_PLACEHOLDER) { + $provisioning->setMasterPassword($masterPassword); + } + $provisioning->setMasterUser($masterUser); + $provisioning->setMasterUserSeparator($masterUserSeparator); + } else { + $provisioning->setMasterPasswordEnabled(false); + $provisioning->setMasterPassword(null); + $provisioning->setMasterUser(null); + $provisioning->setMasterUserSeparator(null); + } + return $provisioning; } diff --git a/lib/IMAP/IMAPClientFactory.php b/lib/IMAP/IMAPClientFactory.php index 5b4de11d55..476dbeca8e 100644 --- a/lib/IMAP/IMAPClientFactory.php +++ b/lib/IMAP/IMAPClientFactory.php @@ -43,13 +43,15 @@ class IMAPClientFactory { private ITimeFactory $timeFactory; private HordeCacheFactory $hordeCacheFactory; - public function __construct(ICrypto $crypto, + public function __construct( + ICrypto $crypto, IConfig $config, ICacheFactory $cacheFactory, IEventDispatcher $eventDispatcher, ITimeFactory $timeFactory, HordeCacheFactory $hordeCacheFactory, - private ProvisioningMapper $provisioningMapper) { + private ProvisioningMapper $provisioningMapper + ) { $this->crypto = $crypto; $this->config = $config; $this->cacheFactory = $cacheFactory; From f7a0068d033adb8ad331320e2a556589d4b30bcf Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Tue, 17 Feb 2026 21:23:05 +0100 Subject: [PATCH 09/12] fixup! feat(provisioning): Add support for Dovecot master user authentication --- .../settings/ProvisioningSettings.vue | 80 ++++++++++--------- 1 file changed, 43 insertions(+), 37 deletions(-) diff --git a/src/components/settings/ProvisioningSettings.vue b/src/components/settings/ProvisioningSettings.vue index 10b2282f7e..699b165294 100644 --- a/src/components/settings/ProvisioningSettings.vue +++ b/src/components/settings/ProvisioningSettings.vue @@ -197,44 +197,46 @@ {{ t('mail', 'Use master password') }}
-
- - -
-

- {{ 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.') }}

-
-
-
@@ -458,7 +460,7 @@ export default { masterPasswordEnabled: this.setting.masterPasswordEnabled === true, masterPassword: this.setting.masterPassword || '', masterUser: this.setting.masterUser || '', - masterUserSeparator: this.setting.masterUserSeparator || '*', + masterUserSeparator: this.setting.masterUserSeparator || '', sieveEnabled: this.setting.sieveEnabled || '', sieveHost: this.setting.sieveHost || '', sievePort: this.setting.sievePort || '', @@ -481,6 +483,10 @@ export default { }, computed: { + needsMasterUserSeparator() { + return this.masterPasswordEnabled && this.masterUser.length > 0 + }, + previewTemplates() { return { email: this.emailTemplate, From 2a222c6dbfba17912e522cfd2ec64bf67307bd41 Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Tue, 17 Feb 2026 21:34:05 +0100 Subject: [PATCH 10/12] fixup! feat(provisioning): Add support for Dovecot master user authentication --- src/components/settings/ProvisioningSettings.vue | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/settings/ProvisioningSettings.vue b/src/components/settings/ProvisioningSettings.vue index 699b165294..f445b6ee8b 100644 --- a/src/components/settings/ProvisioningSettings.vue +++ b/src/components/settings/ProvisioningSettings.vue @@ -191,8 +191,7 @@ :id="'mail-master-password-enabled' + setting.id" v-model="masterPasswordEnabled" type="checkbox" - class="checkbox" - :required="masterUser.length > 0 && masterUser !== '********'"> + class="checkbox"> From ba3e0973f6dee7e39304222adce6340ff30be4c3 Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Tue, 17 Feb 2026 21:39:31 +0100 Subject: [PATCH 11/12] fixup! feat(provisioning): Add support for Dovecot master user authentication --- lib/Migration/Version5007Date20260124120000.php | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/Migration/Version5007Date20260124120000.php b/lib/Migration/Version5007Date20260124120000.php index 5826e86f8c..f005b7e09a 100644 --- a/lib/Migration/Version5007Date20260124120000.php +++ b/lib/Migration/Version5007Date20260124120000.php @@ -43,7 +43,6 @@ public function changeSchema(IOutput $output, Closure $schemaClosure, array $opt $provisioningTable->addColumn('master_user_separator', Types::STRING, [ 'notnull' => false, 'length' => 8, - 'default' => '*', ]); } From 393be4da6332aba1710dcc5250d61327c9409ba2 Mon Sep 17 00:00:00 2001 From: Daniel Kesselberg Date: Tue, 17 Feb 2026 22:00:15 +0100 Subject: [PATCH 12/12] fixup! feat(provisioning): Add support for Dovecot master user authentication --- src/components/settings/ProvisionPreview.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/settings/ProvisionPreview.vue b/src/components/settings/ProvisionPreview.vue index e3ba6b5fb0..149bbc0e24 100644 --- a/src/components/settings/ProvisionPreview.vue +++ b/src/components/settings/ProvisionPreview.vue @@ -139,7 +139,7 @@ export default { }, hasMasterUser() { - return this.masterUser && this.masterUser !== '********' && this.masterUser.length > 0 + return this.masterUser.length > 0 }, imapLoginUser() {