Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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/).
]]></description>
<version>5.7.0-rc.1</version>
<version>5.7.0-rc.2</version>
<licence>agpl</licence>
<author homepage="https://github.com/ChristophWurst">Christoph Wurst</author>
<author homepage="https://github.com/GretaD">GretaD</author>
Expand Down
12 changes: 11 additions & 1 deletion lib/Db/Provisioning.php
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,11 @@
* @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()
* @method void setMasterUserSeparator(?string $masterUserSeparator)
* @method bool|null getSieveEnabled()
* @method void setSieveEnabled(bool $sieveEnabled)
* @method string|null getSieveHost()
Expand Down Expand Up @@ -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;
Expand All @@ -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');
Expand All @@ -108,6 +116,8 @@ public function jsonSerialize() {
'smtpSslMode' => $this->getSmtpSslMode(),
'masterPasswordEnabled' => $this->getMasterPasswordEnabled(),
'masterPassword' => !empty($this->getMasterPassword()) ? self::MASTER_PASSWORD_PLACEHOLDER : null,
'masterUser' => $this->getMasterUser(),
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The initial PR flagged the master user as confidential (like the masterPasword). The username doesn't sound too critical to me, so I've dropped it.

'masterUserSeparator' => $this->getMasterUserSeparator(),
'sieveEnabled' => $this->getSieveEnabled(),
'sieveUser' => $this->getSieveUser(),
'sieveHost' => $this->getSieveHost(),
Expand Down
36 changes: 29 additions & 7 deletions lib/Db/ProvisioningMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated, yet I fixed it while on it. It's extra commit; we can pull that out if necessary.

}
if (!isset($data['imapSslMode']) || $data['imapSslMode'] === '') {
$exception->setField('imapSslMode', false);
Expand All @@ -92,6 +92,20 @@ public function validate(array $data): Provisioning {
$exception->setField('ldapAliasesAttribute', false);
}

$masterPasswordEnabled = (bool)($data['masterPasswordEnabled'] ?? false);
$masterPassword = $data['masterPassword'] ?? '';
$masterUser = $data['masterUser'] ?? '';
$masterUserSeparator = $data['masterUserSeparator'] ?? '';
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The initial PR had a check "if masterUser not empty, and masterUser is not the placeholder, and masterPasswordEnabled is false", then make masterPasswordEnabled required.

I've reworked it to only show the inputs for password, username, and separator when the checkbox is toggled.

Backend-wise, the validation should follow the checkbox. If master password enabled, then we need a password. If non-empty username is given, also the separator is needed.

In addition, the current values are now cleared if the master password is disabled.


if ($masterPasswordEnabled) {
if ($masterPassword === '') {
$exception->setField('masterPassword', false);
}
if ($masterUser !== '' && $masterUserSeparator === '') {
$exception->setField('masterUserSeparator', false);
}
}
Comment on lines +95 to +107
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

masterUserSeparator is treated as required when masterUser is set, but the PR description calls the separator optional and other code paths default to * when unset. If the separator should be optional, adjust validation to treat empty as default * (and ideally normalize empty to null so factories can reliably fall back).

Copilot uses AI. Check for mistakes.

if (!empty($exception->getFields())) {
throw $exception;
}
Expand All @@ -108,12 +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']);
}

$provisioning->setSieveEnabled((bool)$data['sieveEnabled']);
$provisioning->setSieveHost($data['sieveHost'] ?? '');
$provisioning->setSieveUser($data['sieveUser'] ?? '');
Expand All @@ -123,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;
}

Expand Down
18 changes: 16 additions & 2 deletions lib/IMAP/IMAPClientFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -42,12 +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) {
HordeCacheFactory $hordeCacheFactory,
private ProvisioningMapper $provisioningMapper
) {
$this->crypto = $crypto;
$this->config = $config;
$this->cacheFactory = $cacheFactory;
Expand Down Expand Up @@ -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() ?? '*';
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ChristophWurst if $provisioning = null, throw (like for oauth)?

$user = $user . $separator . $provisioning->getMasterUser();
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ChristophWurst wdyt about moving that logic to a trait?

}
}
Comment on lines +92 to +100
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The newly introduced Dovecot master-user behavior isn’t covered by tests in this file. Add a test case where the account has a provisioning with masterUser (and separator) set, and assert that the IMAP client is created with the expected combined username (including default * behavior when separator is unset/null).

Copilot uses AI. Check for mistakes.
Comment on lines +92 to +100
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds an extra DB lookup ($this->provisioningMapper->get(...)) every time an IMAP client is created for a provisioned account. If getClient is called frequently, this can become a noticeable overhead. Consider caching provisionings by ID in the factory (or passing the provisioning data along with the MailAccount) so the lookup happens at most once per provisioning per request.

Copilot uses AI. Check for mistakes.
Comment on lines +92 to +100
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new master-user username formatting logic is duplicated across IMAP/SMTP/Sieve factories. To reduce drift (e.g., default separator handling, enablement checks), consider extracting this into a shared helper (e.g., on Provisioning or a small utility) and reuse it here.

Copilot uses AI. Check for mistakes.

$params = [
'username' => $user,
'password' => $decryptedPassword,
Expand Down
51 changes: 51 additions & 0 deletions lib/Migration/Version5007Date20260124120000.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\Mail\Migration;

use Closure;
use OCP\DB\ISchemaWrapper;
use OCP\DB\Types;
use OCP\Migration\IOutput;
use OCP\Migration\SimpleMigrationStep;

/**
* Add master_user and master_user_separator columns to mail_provisionings table
* for Dovecot Master User authentication support.
*
* @codeCoverageIgnore
*/
class Version5007Date20260124120000 extends SimpleMigrationStep {
/**
* @param IOutput $output
* @param Closure $schemaClosure The `\Closure` returns a `ISchemaWrapper`
* @param array $options
* @return null|ISchemaWrapper
*/
#[\Override]
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
$schema = $schemaClosure();

$provisioningTable = $schema->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,
]);
}

return $schema;
}
}
22 changes: 20 additions & 2 deletions lib/SMTP/SmtpClientFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}

/**
Expand All @@ -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();
}
}
Comment on lines +61 to +69
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This adds an additional DB lookup ($this->provisioningMapper->get(...)) every time an SMTP transport is created for a provisioned account. If transports are created multiple times per request or during background jobs, consider caching provisionings by ID in the factory (or attaching provisioning data to the MailAccount) to avoid repeated queries.

Copilot uses AI. Check for mistakes.
Comment on lines +61 to +69
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The newly introduced Dovecot master-user behavior isn’t covered by unit tests. Consider adding a test that sets a provisioning ID on the MailAccount, mocks ProvisioningMapper->get(...) to return a Provisioning with masterUser/separator, and asserts the resulting transport params use the combined username.

Copilot uses AI. Check for mistakes.

$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' => [
Expand Down
15 changes: 14 additions & 1 deletion lib/Sieve/SieveClientFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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();
}
}
Comment on lines +44 to +52
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The newly introduced Dovecot master-user behavior isn’t covered by tests. Consider adding an integration/unit test that sets a provisioning ID on the MailAccount, mocks ProvisioningMapper->get(...) to return a Provisioning with masterUser/separator, and asserts the created Sieve client uses the expected combined username.

Copilot uses AI. Check for mistakes.

if ($account->getMailAccount()->getDebug() || $this->config->getSystemValueBool('app.mail.debug')) {
$logFile = $this->config->getSystemValue('datadirectory') . '/mail-' . $account->getUserId() . '-' . $account->getId() . '-sieve.log';
} else {
Expand Down
54 changes: 51 additions & 3 deletions src/components/settings/ProvisionPreview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@
{{ t('mail', 'Email: {email}', { email }) }}<br>
{{
t('mail', 'IMAP: {user} on {host}:{port} ({ssl} encryption)', {
user: imapUser,
user: imapLoginUser,
host: imapHost,
port: imapPort,
ssl: imapSslMode,
})
}}<br>
{{
t('mail', 'SMTP: {user} on {host}:{port} ({ssl} encryption)', {
user: smtpUser,
user: smtpLoginUser,
host: smtpHost,
port: smtpPort,
ssl: smtpSslMode,
Expand All @@ -32,13 +32,21 @@
<span v-if="sieveEnabled">
{{
t('mail', 'Sieve: {user} on {host}:{port} ({ssl} encryption)', {
user: sieveUser,
user: sieveLoginUser,
host: sieveHost,
port: sievePort,
ssl: sieveSslMode,
})
}}<br>
</span>
<span v-if="hasMasterUser" class="master-user-info">
<br>
<em>{{ t('mail', 'Using Dovecot master user authentication') }}</em>
</span>
<span v-else-if="masterPasswordEnabled" class="master-password-info">
<br>
<em>{{ t('mail', 'Using static password for all users') }}</em>
</span>
</div>
</template>

Expand Down Expand Up @@ -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.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
},
},
}
</script>
Expand Down
Loading
Loading