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
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
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
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);
}
}

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
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
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?

}
}

$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();
}
}

$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();
}
}

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