mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-11-05 07:58:20 +00:00
[Dovecot][Web] imapsync: verify certificates against the source host, make the check optional per source
This commit is contained in:
13 files changed
+126
-18
No files matched your search
@@ -36,7 +36,7 @@ token_sync_interval = 60
|
|||||||
next_sql = """SELECT i.id, i.user1, i.user2, i.password1, i.exclude, i.delete2duplicates, i.maxage,
|
next_sql = """SELECT i.id, i.user1, i.user2, i.password1, i.exclude, i.delete2duplicates, i.maxage,
|
||||||
i.subfolder2, i.delete1, i.delete2, i.automap, i.skipcrossduplicates, i.maxbytespersecond, i.custom_params,
|
i.subfolder2, i.delete1, i.delete2, i.automap, i.skipcrossduplicates, i.maxbytespersecond, i.custom_params,
|
||||||
i.subscribeall, i.timeout1, i.timeout2, i.dry, i.source_id,
|
i.subscribeall, i.timeout1, i.timeout2, i.dry, i.source_id,
|
||||||
s.host1, s.port1, s.enc1, s.auth_type, s.oauth_flow
|
s.host1, s.port1, s.enc1, s.verify_cert, s.auth_type, s.oauth_flow
|
||||||
FROM imapsync i JOIN imapsync_source s ON i.source_id = s.id
|
FROM imapsync i JOIN imapsync_source s ON i.source_id = s.id
|
||||||
WHERE i.active = 1
|
WHERE i.active = 1
|
||||||
AND s.active = 1
|
AND s.active = 1
|
||||||
@@ -131,6 +131,14 @@ def internal_host(host):
|
|||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
# SNI takes host names only
|
||||||
|
def ip_literal(host):
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(host)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
def oauth_token(j):
|
def oauth_token(j):
|
||||||
if j['oauth_flow'] == 'authorization_code':
|
if j['oauth_flow'] == 'authorization_code':
|
||||||
return query(user_token_sql, (j['source_id'], j['user2'], j['user1'])).fetchone() or {}
|
return query(user_token_sql, (j['source_id'], j['user2'], j['user1'])).fetchone() or {}
|
||||||
@@ -239,8 +247,13 @@ def prepare_job(j, global_bw, files):
|
|||||||
cmd.append(opt)
|
cmd.append(opt)
|
||||||
enc1 = {'TLS': '--tls1', 'SSL': '--ssl1'}.get(j['enc1'])
|
enc1 = {'TLS': '--tls1', 'SSL': '--ssl1'}.get(j['enc1'])
|
||||||
if enc1:
|
if enc1:
|
||||||
# Check the certificate of the source (imapsync does not by default), custom sslargs1 can turn it off
|
# Mail::IMAPClient starts TLS without a host name: check the certificate against host1, not its address
|
||||||
cmd += [enc1, '--sslargs1', 'SSL_verify_mode=1']
|
cmd += [enc1, '--sslargs1', 'SSL_verifycn_scheme=imap', '--sslargs1', 'SSL_verifycn_name=' + j['host1']]
|
||||||
|
if not ip_literal(j['host1']):
|
||||||
|
cmd += ['--sslargs1', 'SSL_hostname=' + j['host1']]
|
||||||
|
# imapsync does not check certificates by default; custom sslargs1 of password jobs can still change it
|
||||||
|
if j['verify_cert'] == 1:
|
||||||
|
cmd += ['--sslargs1', 'SSL_verify_mode=1']
|
||||||
cmd += ['--host1', j['host1'], '--user1', j['user1']] + auth_args + ['--port1', str(j['port1'])]
|
cmd += ['--host1', j['host1'], '--user1', j['user1']] + auth_args + ['--port1', str(j['port1'])]
|
||||||
cmd += ['--host2', 'localhost', '--user2', j['user2'] + '*' + master_user, '--passfile2', tmpfile(master_pass + '\n')]
|
cmd += ['--host2', 'localhost', '--user2', j['user2'] + '*' + master_user, '--passfile2', tmpfile(master_pass + '\n')]
|
||||||
if j['dry'] == 1:
|
if j['dry'] == 1:
|
||||||
|
|||||||
@@ -1936,6 +1936,14 @@ paths:
|
|||||||
- TLS
|
- TLS
|
||||||
- SSL
|
- SSL
|
||||||
- PLAIN
|
- PLAIN
|
||||||
|
verify_cert:
|
||||||
|
description: >-
|
||||||
|
1 = check the server certificate (SSL and STARTTLS, default), 0 = do not check
|
||||||
|
it (self-signed, expired or not matching the host).
|
||||||
|
type: integer
|
||||||
|
enum:
|
||||||
|
- 0
|
||||||
|
- 1
|
||||||
auth_type:
|
auth_type:
|
||||||
description: >-
|
description: >-
|
||||||
authentication mechanism; PLAIN/LOGIN/CRAM-MD5 use the per-job
|
authentication mechanism; PLAIN/LOGIN/CRAM-MD5 use the per-job
|
||||||
@@ -5080,6 +5088,15 @@ paths:
|
|||||||
- TLS
|
- TLS
|
||||||
- SSL
|
- SSL
|
||||||
- PLAIN
|
- PLAIN
|
||||||
|
verify_cert:
|
||||||
|
description: >-
|
||||||
|
1 = check the server certificate (SSL and STARTTLS, default), 0 = do not check
|
||||||
|
it (self-signed, expired or not matching the host). Turning the check off
|
||||||
|
needs access to all sync jobs of the source, like changing the server.
|
||||||
|
type: integer
|
||||||
|
enum:
|
||||||
|
- 0
|
||||||
|
- 1
|
||||||
auth_type:
|
auth_type:
|
||||||
description: >-
|
description: >-
|
||||||
authentication mechanism; PLAIN/LOGIN/CRAM-MD5 use the per-job
|
authentication mechanism; PLAIN/LOGIN/CRAM-MD5 use the per-job
|
||||||
@@ -7313,6 +7330,7 @@ paths:
|
|||||||
host1: imap.example.org
|
host1: imap.example.org
|
||||||
port1: 993
|
port1: 993
|
||||||
enc1: SSL
|
enc1: SSL
|
||||||
|
verify_cert: 1
|
||||||
auth_type: XOAUTH2
|
auth_type: XOAUTH2
|
||||||
oauth_flow: authorization_code
|
oauth_flow: authorization_code
|
||||||
oauth_token_endpoint: https://login.example.org/token
|
oauth_token_endpoint: https://login.example.org/token
|
||||||
|
|||||||
@@ -1098,6 +1098,7 @@ function init_db_schema()
|
|||||||
"host1" => "VARCHAR(255) NOT NULL",
|
"host1" => "VARCHAR(255) NOT NULL",
|
||||||
"port1" => "SMALLINT UNSIGNED NOT NULL",
|
"port1" => "SMALLINT UNSIGNED NOT NULL",
|
||||||
"enc1" => "ENUM('TLS','SSL','PLAIN') DEFAULT 'TLS'",
|
"enc1" => "ENUM('TLS','SSL','PLAIN') DEFAULT 'TLS'",
|
||||||
|
"verify_cert" => "TINYINT(1) NOT NULL DEFAULT '1'",
|
||||||
"auth_type" => "ENUM('PLAIN','LOGIN','CRAM-MD5','XOAUTH2') DEFAULT 'PLAIN'",
|
"auth_type" => "ENUM('PLAIN','LOGIN','CRAM-MD5','XOAUTH2') DEFAULT 'PLAIN'",
|
||||||
"oauth_flow" => "ENUM('client_credentials','authorization_code') NOT NULL DEFAULT 'client_credentials'",
|
"oauth_flow" => "ENUM('client_credentials','authorization_code') NOT NULL DEFAULT 'client_credentials'",
|
||||||
"oauth_token_endpoint" => "VARCHAR(500) DEFAULT NULL",
|
"oauth_token_endpoint" => "VARCHAR(500) DEFAULT NULL",
|
||||||
@@ -1679,9 +1680,15 @@ function init_db_schema()
|
|||||||
// Converted JSON may not fit into the old VARCHAR(512)
|
// Converted JSON may not fit into the old VARCHAR(512)
|
||||||
$pdo->exec("ALTER TABLE `imapsync` MODIFY COLUMN `custom_params` TEXT");
|
$pdo->exec("ALTER TABLE `imapsync` MODIFY COLUMN `custom_params` TEXT");
|
||||||
$imapsync_allow = \Mailcow\Imapsync\Model\CustomParams::allowlist($GLOBALS['IMAPSYNC_OPTIONS'] ?? [], $GLOBALS['IMAPSYNC_OPTIONS_DEFAULT'] ?? []);
|
$imapsync_allow = \Mailcow\Imapsync\Model\CustomParams::allowlist($GLOBALS['IMAPSYNC_OPTIONS'] ?? [], $GLOBALS['IMAPSYNC_OPTIONS_DEFAULT'] ?? []);
|
||||||
$source_sel = $pdo->prepare("SELECT `id` FROM `imapsync_source` WHERE `created_by` = '' AND `name` = :name");
|
// A legacy source is found by its connection and its only target domain (none for jobs without domain)
|
||||||
$source_ins = $pdo->prepare("INSERT IGNORE INTO `imapsync_source` (`name`, `description`, `created_by`, `scope`, `host1`, `port1`, `enc1`, `auth_type`, `active`)
|
$source_sel = $pdo->prepare("SELECT `id` FROM `imapsync_source` s WHERE `created_by` = '' AND `host1` = :host AND `port1` = :port
|
||||||
VALUES (:name, '', '', 'domain', :host, :port, :enc, :auth, 1)");
|
AND `enc1` = :enc AND `auth_type` = :auth
|
||||||
|
AND (SELECT GROUP_CONCAT(`domain`) FROM `imapsync_source_domain` d WHERE d.`source_id` = s.`id`) <=> NULLIF(:domain, '')
|
||||||
|
ORDER BY `id` LIMIT 1");
|
||||||
|
$source_name_sel = $pdo->prepare("SELECT COUNT(*) FROM `imapsync_source` WHERE `created_by` = '' AND `name` = :name");
|
||||||
|
// The old runner never checked certificates, legacy sources keep that
|
||||||
|
$source_ins = $pdo->prepare("INSERT IGNORE INTO `imapsync_source` (`name`, `description`, `created_by`, `scope`, `host1`, `port1`, `enc1`, `verify_cert`, `auth_type`, `active`)
|
||||||
|
VALUES (:name, 'Migrated sync jobs', '', 'domain', :host, :port, :enc, 0, :auth, 1)");
|
||||||
$source_domain_ins = $pdo->prepare("INSERT IGNORE INTO `imapsync_source_domain` (`source_id`, `domain`) VALUES (:id, :domain)");
|
$source_domain_ins = $pdo->prepare("INSERT IGNORE INTO `imapsync_source_domain` (`source_id`, `domain`) VALUES (:id, :domain)");
|
||||||
$job_upd = $pdo->prepare("UPDATE `imapsync` SET `source_id` = :source_id, `custom_params` = :cp, `custom_params_legacy` = :cp_legacy,
|
$job_upd = $pdo->prepare("UPDATE `imapsync` SET `source_id` = :source_id, `custom_params` = :cp, `custom_params_legacy` = :cp_legacy,
|
||||||
`dry` = GREATEST(`dry`, :dry) WHERE `id` = :id AND `source_id` IS NULL");
|
`dry` = GREATEST(`dry`, :dry) WHERE `id` = :id AND `source_id` IS NULL");
|
||||||
@@ -1741,16 +1748,20 @@ function init_db_schema()
|
|||||||
$enc = in_array($row['enc1'], array('TLS', 'SSL', 'PLAIN'), true) ? $row['enc1'] : 'TLS';
|
$enc = in_array($row['enc1'], array('TLS', 'SSL', 'PLAIN'), true) ? $row['enc1'] : 'TLS';
|
||||||
$at = strrchr((string)$row['user2'], '@');
|
$at = strrchr((string)$row['user2'], '@');
|
||||||
$domain = ($at === false) ? '' : strtolower(substr($at, 1));
|
$domain = ($at === false) ? '' : strtolower(substr($at, 1));
|
||||||
// One source per (host, port, enc, auth, domain), name always unique and <= 100 chars
|
// One source per (host, port, enc, auth, domain), named "host (domain)" plus " 2", " 3"... when taken (<= 100 chars)
|
||||||
$name = 'legacy-' . mb_substr($host, 0, 60) . '-' . $port . '-' . substr(sha1($host . '|' . $port . '|' . $enc . '|' . $auth . '|' . $domain), 0, 8);
|
$conn = array(':host' => $host, ':port' => $port, ':enc' => $enc, ':auth' => $auth);
|
||||||
$source_sel->execute(array(':name' => $name));
|
$source_sel->execute($conn + array(':domain' => $domain));
|
||||||
if (($source_id = $source_sel->fetchColumn()) === false) {
|
if (($source_id = $source_sel->fetchColumn()) === false) {
|
||||||
$source_ins->execute(array(':name' => $name, ':host' => $host, ':port' => $port, ':enc' => $enc, ':auth' => $auth));
|
$base = mb_substr($host . ($domain !== '' ? ' (' . $domain . ')' : ''), 0, 94);
|
||||||
$source_sel->execute(array(':name' => $name));
|
for ($i = 1, $name = $base; $i < 1000; $name = $base . ' ' . ++$i) {
|
||||||
if (($source_id = $source_sel->fetchColumn()) === false) continue;
|
$source_name_sel->execute(array(':name' => $name));
|
||||||
}
|
if ($source_name_sel->fetchColumn() == 0) break;
|
||||||
if ($domain !== '') {
|
}
|
||||||
$source_domain_ins->execute(array(':id' => $source_id, ':domain' => $domain));
|
$source_ins->execute($conn + array(':name' => $name));
|
||||||
|
if (($source_id = $pdo->lastInsertId()) == 0) continue;
|
||||||
|
if ($domain !== '') {
|
||||||
|
$source_domain_ins->execute(array(':id' => $source_id, ':domain' => $domain));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
$job_upd->execute(array(
|
$job_upd->execute(array(
|
||||||
':source_id' => $source_id,
|
':source_id' => $source_id,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class Source
|
|||||||
public string $host = '';
|
public string $host = '';
|
||||||
public int $port = 0;
|
public int $port = 0;
|
||||||
public ?string $enc = 'TLS';
|
public ?string $enc = 'TLS';
|
||||||
|
public int $verifyCert = 1;
|
||||||
public ?string $authType = 'PLAIN';
|
public ?string $authType = 'PLAIN';
|
||||||
public string $oauthFlow = self::FLOW_CLIENT_CREDENTIALS;
|
public string $oauthFlow = self::FLOW_CLIENT_CREDENTIALS;
|
||||||
public ?string $tokenEndpoint = null;
|
public ?string $tokenEndpoint = null;
|
||||||
@@ -65,6 +66,7 @@ class Source
|
|||||||
$source->scope = (string)($row['scope'] ?? 'user');
|
$source->scope = (string)($row['scope'] ?? 'user');
|
||||||
$source->host = (string)($row['host1'] ?? '');
|
$source->host = (string)($row['host1'] ?? '');
|
||||||
$source->port = (int)($row['port1'] ?? 0);
|
$source->port = (int)($row['port1'] ?? 0);
|
||||||
|
$source->verifyCert = (int)($row['verify_cert'] ?? 1);
|
||||||
$source->oauthFlow = (string)($row['oauth_flow'] ?? self::FLOW_CLIENT_CREDENTIALS);
|
$source->oauthFlow = (string)($row['oauth_flow'] ?? self::FLOW_CLIENT_CREDENTIALS);
|
||||||
$source->tokenExpires = isset($row['oauth_token_expires']) ? (int)$row['oauth_token_expires'] : null;
|
$source->tokenExpires = isset($row['oauth_token_expires']) ? (int)$row['oauth_token_expires'] : null;
|
||||||
$source->refreshAttemptAge = isset($row['refresh_attempt_age']) ? (int)$row['refresh_attempt_age'] : null;
|
$source->refreshAttemptAge = isset($row['refresh_attempt_age']) ? (int)$row['refresh_attempt_age'] : null;
|
||||||
@@ -112,6 +114,7 @@ class Source
|
|||||||
'host1' => $this->host,
|
'host1' => $this->host,
|
||||||
'port1' => $this->port,
|
'port1' => $this->port,
|
||||||
'enc1' => $this->enc,
|
'enc1' => $this->enc,
|
||||||
|
'verify_cert' => $this->verifyCert,
|
||||||
'auth_type' => $this->authType,
|
'auth_type' => $this->authType,
|
||||||
'oauth_flow' => $this->oauthFlow,
|
'oauth_flow' => $this->oauthFlow,
|
||||||
'oauth_token_endpoint' => $this->tokenEndpoint,
|
'oauth_token_endpoint' => $this->tokenEndpoint,
|
||||||
|
|||||||
@@ -206,6 +206,7 @@ class SourceRepository extends Repository
|
|||||||
'host1' => $source->host,
|
'host1' => $source->host,
|
||||||
'port1' => $source->port,
|
'port1' => $source->port,
|
||||||
'enc1' => $source->enc,
|
'enc1' => $source->enc,
|
||||||
|
'verify_cert' => $source->verifyCert,
|
||||||
'auth_type' => $source->authType,
|
'auth_type' => $source->authType,
|
||||||
'oauth_flow' => $source->oauthFlow,
|
'oauth_flow' => $source->oauthFlow,
|
||||||
'oauth_token_endpoint' => $source->tokenEndpoint,
|
'oauth_token_endpoint' => $source->tokenEndpoint,
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ class SourceService
|
|||||||
$this->applyScope($source, $input['scope'] ?? $current->scope, $input['domains'] ?? [], $input['users'] ?? []);
|
$this->applyScope($source, $input['scope'] ?? $current->scope, $input['domains'] ?? [], $input['users'] ?? []);
|
||||||
}
|
}
|
||||||
$connectionChanged = self::connection($current) !== self::connection($source);
|
$connectionChanged = self::connection($current) !== self::connection($source);
|
||||||
if ($connectionChanged) {
|
if ($connectionChanged || ($current->verifyCert && !$source->verifyCert)) {
|
||||||
$this->requireAllJobsAccessible($source);
|
$this->requireAllJobsAccessible($source);
|
||||||
}
|
}
|
||||||
$this->repo->update($source, $writeTargets, $connectionChanged);
|
$this->repo->update($source, $writeTargets, $connectionChanged);
|
||||||
@@ -296,6 +296,7 @@ class SourceService
|
|||||||
$source->host = $host;
|
$source->host = $host;
|
||||||
$source->port = $port;
|
$source->port = $port;
|
||||||
$source->enc = $enc;
|
$source->enc = $enc;
|
||||||
|
$source->verifyCert = isset($input['verify_cert']) ? (int)!empty($input['verify_cert']) : $source->verifyCert;
|
||||||
$source->authType = $authType;
|
$source->authType = $authType;
|
||||||
$source->active = isset($input['active']) ? (int)$input['active'] : $source->active;
|
$source->active = isset($input['active']) ? (int)$input['active'] : $source->active;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,7 +184,11 @@ var mailcowImapsync = (function($) {
|
|||||||
dataSrc: function(json) {
|
dataSrc: function(json) {
|
||||||
if (!Array.isArray(json)) return [];
|
if (!Array.isArray(json)) return [];
|
||||||
$.each(json, function(i, item) {
|
$.each(json, function(i, item) {
|
||||||
item.endpoint = wrapCell(esc((item.host || item.host1) + ':' + (item.port || item.port1) + ' (' + (item.enc || item.enc1) + ')'));
|
item.endpoint = esc((item.host || item.host1) + ':' + (item.port || item.port1) + ' (' + (item.enc || item.enc1) + ')');
|
||||||
|
if (item.verify_cert == 0 && (item.enc || item.enc1) !== 'PLAIN') {
|
||||||
|
item.endpoint += ' <i class="bi bi-shield-exclamation text-warning" title="' + esc(L('source_verify_cert_off')) + '"></i>';
|
||||||
|
}
|
||||||
|
item.endpoint = wrapCell(item.endpoint);
|
||||||
item.name_display = esc(item.name);
|
item.name_display = esc(item.name);
|
||||||
if (item.description) item.name_display += '<div class="small text-muted">' + esc(item.description) + '</div>';
|
if (item.description) item.name_display += '<div class="small text-muted">' + esc(item.description) + '</div>';
|
||||||
item.name_display = wrapCell(item.name_display);
|
item.name_display = wrapCell(item.name_display);
|
||||||
|
|||||||
@@ -1538,6 +1538,9 @@
|
|||||||
"source_tenant_hint": "Verzeichnis-ID (Mandant) oder eine Domain des Tenants, z. B. contoso.onmicrosoft.com. Wird in die Endpunkte eingesetzt.",
|
"source_tenant_hint": "Verzeichnis-ID (Mandant) oder eine Domain des Tenants, z. B. contoso.onmicrosoft.com. Wird in die Endpunkte eingesetzt.",
|
||||||
"source_scope_cc_hint": "Quellen mit Client-Credentials-Flow sind immer nur für ihren Ersteller sichtbar.",
|
"source_scope_cc_hint": "Quellen mit Client-Credentials-Flow sind immer nur für ihren Ersteller sichtbar.",
|
||||||
"source_readonly_hint": "Diese Sync-Quelle ist freigegeben und kann nur angesehen werden.",
|
"source_readonly_hint": "Diese Sync-Quelle ist freigegeben und kann nur angesehen werden.",
|
||||||
|
"source_verify_cert": "Zertifikat des Servers prüfen",
|
||||||
|
"source_verify_cert_hint": "Gilt für SSL und STARTTLS. Für Server mit selbstsigniertem, abgelaufenem oder nicht zum Host passendem Zertifikat abschalten.",
|
||||||
|
"source_verify_cert_off": "Das Zertifikat des Servers wird nicht geprüft",
|
||||||
"copy": "Kopieren",
|
"copy": "Kopieren",
|
||||||
"copied": "In die Zwischenablage kopiert",
|
"copied": "In die Zwischenablage kopiert",
|
||||||
"source_view": "Anzeigen",
|
"source_view": "Anzeigen",
|
||||||
|
|||||||
@@ -1545,6 +1545,9 @@
|
|||||||
"source_tenant_hint": "Directory (tenant) ID or a domain of the tenant, e.g. contoso.onmicrosoft.com. It is inserted into the endpoints.",
|
"source_tenant_hint": "Directory (tenant) ID or a domain of the tenant, e.g. contoso.onmicrosoft.com. It is inserted into the endpoints.",
|
||||||
"source_scope_cc_hint": "Sources using the client credentials flow are always private to their creator.",
|
"source_scope_cc_hint": "Sources using the client credentials flow are always private to their creator.",
|
||||||
"source_readonly_hint": "This sync source is shared with you and can only be viewed.",
|
"source_readonly_hint": "This sync source is shared with you and can only be viewed.",
|
||||||
|
"source_verify_cert": "Verify the server certificate",
|
||||||
|
"source_verify_cert_hint": "For SSL and STARTTLS. Turn off for servers with a self-signed or expired certificate or a certificate that does not match the host.",
|
||||||
|
"source_verify_cert_off": "The server certificate is not verified",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"copied": "Copied to clipboard",
|
"copied": "Copied to clipboard",
|
||||||
"source_view": "Show",
|
"source_view": "Show",
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<form class="form-horizontal" data-id="edit_imapsync_source" role="form" method="post">
|
<form class="form-horizontal" data-id="edit_imapsync_source" role="form" method="post">
|
||||||
<input type="hidden" value="0" name="active">
|
<input type="hidden" value="0" name="active">
|
||||||
|
<input type="hidden" value="0" name="verify_cert">
|
||||||
<div class="row mb-2">
|
<div class="row mb-2">
|
||||||
<label class="control-label col-sm-2" for="name">{{ lang.syncjobs.source_name }}</label>
|
<label class="control-label col-sm-2" for="name">{{ lang.syncjobs.source_name }}</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
@@ -99,6 +100,14 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="offset-sm-2 col-sm-10">
|
||||||
|
<div class="form-check">
|
||||||
|
<label><input type="checkbox" class="form-check-input" value="1" name="verify_cert"{% if result.verify_cert == '1' %} checked{% endif %}{% if ro %} disabled{% endif %}> {{ lang.syncjobs.source_verify_cert }}</label>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">{{ lang.syncjobs.source_verify_cert_hint }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<label class="control-label col-sm-2" for="auth_type">{{ lang.syncjobs.source_auth_type }}</label>
|
<label class="control-label col-sm-2" for="auth_type">{{ lang.syncjobs.source_auth_type }}</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<form class="form-horizontal" data-cached-form="true" role="form" data-id="add_imapsync_source">
|
<form class="form-horizontal" data-cached-form="true" role="form" data-id="add_imapsync_source">
|
||||||
<input type="hidden" value="0" name="active">
|
<input type="hidden" value="0" name="active">
|
||||||
|
<input type="hidden" value="0" name="verify_cert">
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<label class="control-label col-sm-2 text-sm-end">{{ lang.syncjobs.source_presets }}</label>
|
<label class="control-label col-sm-2 text-sm-end">{{ lang.syncjobs.source_presets }}</label>
|
||||||
<div class="col-sm-10 small">
|
<div class="col-sm-10 small">
|
||||||
@@ -99,6 +100,14 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="offset-sm-2 col-sm-10">
|
||||||
|
<div class="form-check">
|
||||||
|
<label><input type="checkbox" class="form-check-input" value="1" name="verify_cert" checked> {{ lang.syncjobs.source_verify_cert }}</label>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">{{ lang.syncjobs.source_verify_cert_hint }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<label class="control-label col-sm-2 text-sm-end" for="addsrc_auth_type">{{ lang.syncjobs.source_auth_type }}</label>
|
<label class="control-label col-sm-2 text-sm-end" for="addsrc_auth_type">{{ lang.syncjobs.source_auth_type }}</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
# Version of the database schema
|
# Version of the database schema
|
||||||
x-db-version: &db_version DB_VERSION=28102026_1300
|
x-db-version: &db_version DB_VERSION=02112026_1600
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
|
|||||||
@@ -219,6 +219,23 @@ class SourceServiceTest extends TestCase
|
|||||||
$this->service(FakeContext::admin())->create(['name' => 'a', 'host1' => '192.0.2.10', 'port1' => 143, 'enc1' => 'PLAIN']);
|
$this->service(FakeContext::admin())->create(['name' => 'a', 'host1' => '192.0.2.10', 'port1' => 143, 'enc1' => 'PLAIN']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testCertificateIsCheckedUnlessTurnedOff(): void
|
||||||
|
{
|
||||||
|
$stored = [];
|
||||||
|
$this->repo->expects($this->exactly(3))->method('insert')->willReturnCallback(function (Source $s) use (&$stored): int {
|
||||||
|
$stored[] = $s->verifyCert;
|
||||||
|
return 1;
|
||||||
|
});
|
||||||
|
$service = $this->service(FakeContext::admin());
|
||||||
|
$basics = ['name' => 'a', 'host' => 'imap.example.org', 'port' => 143, 'enc' => 'TLS'];
|
||||||
|
|
||||||
|
$service->create($basics);
|
||||||
|
$service->create($basics + ['verify_cert' => '0']);
|
||||||
|
$service->create($basics + ['verify_cert' => '1']);
|
||||||
|
$this->assertSame([1, 0, 1], $stored);
|
||||||
|
$this->assertSame(0, Source::fromRow(['verify_cert' => '0'])->toArray(true, '')['verify_cert']);
|
||||||
|
}
|
||||||
|
|
||||||
public function testNameMustBeUniquePerCreator(): void
|
public function testNameMustBeUniquePerCreator(): void
|
||||||
{
|
{
|
||||||
$this->repo->method('nameExists')->with('<b>', 'admin', null)->willReturn(true);
|
$this->repo->method('nameExists')->with('<b>', 'admin', null)->willReturn(true);
|
||||||
@@ -306,6 +323,22 @@ class SourceServiceTest extends TestCase
|
|||||||
$service->update(7, ['description' => 'x']);
|
$service->update(7, ['description' => 'x']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testCertificateCheckOnlyTurnsOffWhenAllJobsAreAccessible(): void
|
||||||
|
{
|
||||||
|
$this->repo->method('findVisible')->willReturnOnConsecutiveCalls(
|
||||||
|
self::source(['created_by' => 'u@example.org']),
|
||||||
|
self::source(['created_by' => 'u@example.org', 'verify_cert' => 0])
|
||||||
|
);
|
||||||
|
$this->repo->expects($this->once())->method('jobMailboxes')->willReturn(['u@example.org', 'other@example.org']);
|
||||||
|
// Turning it on needs no check and keeps the tokens
|
||||||
|
$this->repo->expects($this->once())->method('update')
|
||||||
|
->with($this->callback(fn(Source $s): bool => $s->verifyCert === 1), $this->anything(), false);
|
||||||
|
$service = $this->service(FakeContext::user('u@example.org'));
|
||||||
|
|
||||||
|
$this->expectUserError(['imapsync_source_used_by_others', 'src'], fn() => $service->update(7, ['verify_cert' => '0']));
|
||||||
|
$service->update(7, ['verify_cert' => '1']);
|
||||||
|
}
|
||||||
|
|
||||||
public function testUsersMayNotSwitchToXOAuth2(): void
|
public function testUsersMayNotSwitchToXOAuth2(): void
|
||||||
{
|
{
|
||||||
$this->repo->method('findVisible')->willReturn(self::source(['created_by' => 'u@example.org']));
|
$this->repo->method('findVisible')->willReturn(self::source(['created_by' => 'u@example.org']));
|
||||||
|
|||||||
Reference in new issue
Block a user