mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-10-22 18:48:54 +00:00
[Web][Dovecot] imapsync: update to 2.314, refresh OAuth tokens of running jobs, add source presets
This commit is contained in:
15 files changed
+7060
-3406
No files matched your search
@@ -83,6 +83,7 @@ RUN addgroup -g 5000 vmail \
|
||||
perl-test-warn \
|
||||
perl-try-tiny \
|
||||
perl-unicode-string \
|
||||
perl-proc-processtable \
|
||||
perl-app-cpanminus \
|
||||
procps \
|
||||
python3 \
|
||||
|
||||
+6759
-3364
File diff suppressed because it is too large.
Load diff
@@ -16,29 +16,41 @@ run_dir = '/tmp'
|
||||
# Max size of imapsync output stored in returned_text (the tail holds the summary)
|
||||
max_output = 16 * 1024 * 1024
|
||||
lock_file = run_dir + '/imapsync_busy.lock'
|
||||
# An OAuth run needs a token valid this long at start (half the lifetime for short-lived tokens)
|
||||
token_min_valid = 900
|
||||
# Refreshed tokens are written to the token files of running jobs this often, imapsync re-reads them on reconnect
|
||||
token_sync_interval = 60
|
||||
|
||||
# Next due job: higher prio first, never-run jobs first, then oldest run
|
||||
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.subscribeall, i.timeout1, i.timeout2, i.dry, i.source_id,
|
||||
s.host1, s.port1, s.enc1, s.auth_type, s.oauth_flow, s.oauth_access_token, s.oauth_token_expires
|
||||
s.host1, s.port1, s.enc1, s.auth_type, s.oauth_flow
|
||||
FROM imapsync i JOIN imapsync_source s ON i.source_id = s.id
|
||||
WHERE i.active = 1
|
||||
AND s.active = 1
|
||||
AND i.is_running = 0
|
||||
AND (UNIX_TIMESTAMP(NOW()) - UNIX_TIMESTAMP(i.last_run) > i.mins_interval * 60 OR i.last_run IS NULL)
|
||||
{skip}
|
||||
ORDER BY i.prio DESC, i.last_run IS NOT NULL, i.last_run ASC, i.id ASC
|
||||
LIMIT 1"""
|
||||
# Atomic claim, a job is only started when this flips is_running 0 -> 1
|
||||
claim_sql = """UPDATE imapsync SET is_running = 1, success = NULL, exit_status = NULL
|
||||
WHERE id = %s AND is_running = 0 AND active = 1"""
|
||||
# Token of authorization_code sources: source + local mailbox (user2) + remote user (user1)
|
||||
token_sql = """SELECT access_token, token_expires FROM imapsync_source_oauth_token
|
||||
# Access token: client_credentials share the source token, authorization_code use the token of
|
||||
# source + local mailbox (user2) + remote user (user1). issued/error describe the last refresh.
|
||||
source_token_sql = """SELECT oauth_access_token AS access_token, oauth_token_expires AS token_expires,
|
||||
UNIX_TIMESTAMP(oauth_last_refresh_attempt) AS issued, oauth_last_refresh_error AS error
|
||||
FROM imapsync_source WHERE id = %s"""
|
||||
user_token_sql = """SELECT access_token, token_expires, UNIX_TIMESTAMP(oauth_last_refresh_attempt) AS issued,
|
||||
last_refresh_error AS error FROM imapsync_source_oauth_token
|
||||
WHERE source_id = %s AND owner = %s AND username = %s"""
|
||||
|
||||
conn = None
|
||||
# Popen => job of running workers
|
||||
running = {}
|
||||
# Jobs released without a run (token not ready), skipped for the rest of this runner round
|
||||
deferred = set()
|
||||
stopping = False
|
||||
|
||||
def num(value):
|
||||
@@ -88,6 +100,41 @@ def finish_job(job_id, text, success, exit_status):
|
||||
except MySQLdb.Error:
|
||||
query("UPDATE imapsync SET success = 0, last_run = NOW(), is_running = 0 WHERE id = %s", (job_id,))
|
||||
|
||||
# Release a job without a run; last_run stays, so the next runner round retries it
|
||||
def defer_job(job_id, text, exit_status):
|
||||
query("UPDATE imapsync SET returned_text = %s, success = 0, exit_status = %s, is_running = 0 WHERE id = %s",
|
||||
(text, exit_status, job_id))
|
||||
deferred.add(job_id)
|
||||
|
||||
def oauth_token(j):
|
||||
if j['oauth_flow'] == 'authorization_code':
|
||||
return query(user_token_sql, (j['source_id'], j['user2'], j['user1'])).fetchone() or {}
|
||||
return query(source_token_sql, (j['source_id'],)).fetchone() or {}
|
||||
|
||||
# Valid for token_min_valid, or half the lifetime of a short-lived token (known when its last refresh succeeded)
|
||||
def token_ready(row):
|
||||
token, expires, issued = row.get('access_token'), row.get('token_expires'), row.get('issued')
|
||||
if not token or expires is None:
|
||||
return False
|
||||
need = token_min_valid
|
||||
if issued and not row.get('error') and expires > int(issued):
|
||||
need = min(need, (expires - int(issued)) // 2)
|
||||
return expires - time.time() >= need
|
||||
|
||||
# Write a refreshed token over the token file of a running job (atomic, same path)
|
||||
def sync_token_file(oauth):
|
||||
token = oauth_token(oauth['job']).get('access_token')
|
||||
if not token or token == oauth['token']:
|
||||
return
|
||||
fd, tmp = tempfile.mkstemp(prefix='imapsync.', dir=run_dir)
|
||||
try:
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
||||
f.write(token + '\n')
|
||||
os.replace(tmp, oauth['file'])
|
||||
oauth['token'] = token
|
||||
except OSError:
|
||||
unlink([tmp])
|
||||
|
||||
# Max concurrent imapsync processes and global per-process bandwidth cap in bytes/s (0 = off),
|
||||
# re-read before every claim round so changes apply to long runs
|
||||
def read_settings():
|
||||
@@ -104,18 +151,15 @@ def prepare_job(j, global_bw, files):
|
||||
return path
|
||||
|
||||
auth_type = j['auth_type'] or 'PLAIN'
|
||||
oauth = None
|
||||
if auth_type == 'XOAUTH2':
|
||||
# authorization_code: token of this local mailbox + remote user, client_credentials: shared source token
|
||||
token, expires = j['oauth_access_token'], j['oauth_token_expires']
|
||||
if j['oauth_flow'] == 'authorization_code':
|
||||
row = query(token_sql, (j['source_id'], j['user2'], j['user1'])).fetchone() or {}
|
||||
token, expires = row.get('access_token'), row.get('token_expires')
|
||||
# Skip sync if the cached token is missing or about to expire (<2 min)
|
||||
if not token or expires is None or expires - time.time() < 120:
|
||||
finish_job(j['id'], "OAuth access token for the configured source is missing or expires too soon; refresh cron will retry.",
|
||||
0, 'OAUTH_TOKEN_NOT_READY')
|
||||
row = oauth_token(j)
|
||||
if not token_ready(row):
|
||||
defer_job(j['id'], "OAuth access token for the configured source is missing or expires too soon; refresh cron will retry.",
|
||||
'OAUTH_TOKEN_NOT_READY')
|
||||
return None
|
||||
auth_args = ['--authmech1', 'XOAUTH2', '--oauthaccesstoken1', tmpfile(token + '\n')]
|
||||
oauth = {'job': j, 'token': row['access_token'], 'file': tmpfile(row['access_token'] + '\n')}
|
||||
auth_args = ['--authmech1', 'XOAUTH2', '--oauthaccesstoken1', oauth['file']]
|
||||
else:
|
||||
auth_args = ['--passfile1', tmpfile(j['password1'] + '\n')]
|
||||
# imapsync auto-picks PLAIN, only pass --authmech1 for LOGIN/CRAM-MD5
|
||||
@@ -140,7 +184,8 @@ def prepare_job(j, global_bw, files):
|
||||
if global_bw > 0 and (eff_bw <= 0 or eff_bw > global_bw):
|
||||
eff_bw = global_bw
|
||||
|
||||
cmd = ['/usr/local/bin/imapsync', '--tmpdir', '/tmp', '--nofoldersizes', '--addheader']
|
||||
# --nolog: the output is stored in returned_text, imapsync's own log dir would be the (read-only) cwd
|
||||
cmd = ['/usr/local/bin/imapsync', '--tmpdir', '/tmp', '--nolog', '--nofoldersizes', '--addheader']
|
||||
if num(j['timeout1']) > 0:
|
||||
cmd += ['--timeout1', str(j['timeout1'])]
|
||||
if num(j['timeout2']) > 0:
|
||||
@@ -154,7 +199,7 @@ def prepare_job(j, global_bw, files):
|
||||
if eff_bw > 0:
|
||||
cmd += ['--maxbytespersecond', str(eff_bw)]
|
||||
for col, opt in (('delete2duplicates', '--delete2duplicates'), ('subscribeall', '--subscribeall'),
|
||||
('delete1', '--delete'), ('delete2', '--delete2'), ('automap', '--automap'),
|
||||
('delete1', '--delete1'), ('delete2', '--delete2'), ('automap', '--automap'),
|
||||
('skipcrossduplicates', '--skipcrossduplicates')):
|
||||
if j[col] == 1:
|
||||
cmd.append(opt)
|
||||
@@ -167,7 +212,7 @@ def prepare_job(j, global_bw, files):
|
||||
cmd.append('--dry')
|
||||
cmd += ['--no-modulesversion', '--noreleasecheck'] + custom_params
|
||||
|
||||
return {'id': j['id'], 'files': files, 'outfile': tmpfile(''), 'cmd': cmd}
|
||||
return {'id': j['id'], 'files': files, 'outfile': tmpfile(''), 'cmd': cmd, 'oauth': oauth}
|
||||
|
||||
# Worker: imapsync with output to a file, no inherited fds (DB socket, lock)
|
||||
def start_job(job):
|
||||
@@ -225,6 +270,7 @@ with open('/etc/sogo/sieve.creds') as f:
|
||||
|
||||
# Worker pool: keep up to max_parallel workers busy, claim the next due job whenever a slot is free
|
||||
# (after every worker exit and at least every 10 seconds while workers run)
|
||||
last_token_sync = time.monotonic()
|
||||
try:
|
||||
while True:
|
||||
for proc, job in list(running.items()):
|
||||
@@ -232,14 +278,25 @@ try:
|
||||
del running[proc]
|
||||
reap_job(job)
|
||||
|
||||
if time.monotonic() - last_token_sync >= token_sync_interval:
|
||||
last_token_sync = time.monotonic()
|
||||
for job in running.values():
|
||||
if job['oauth']:
|
||||
try:
|
||||
sync_token_file(job['oauth'])
|
||||
except MySQLdb.Error as e:
|
||||
print('imapsync job %d: %s' % (job['id'], e), file=sys.stderr)
|
||||
|
||||
max_parallel, global_bw = read_settings()
|
||||
attempts = 0
|
||||
while not stopping and len(running) < max_parallel and attempts < 100:
|
||||
attempts += 1
|
||||
j = query(next_sql).fetchone()
|
||||
# Deferred and finished jobs drop out of next_sql, only failed claims are capped
|
||||
failed_claims = 0
|
||||
while not stopping and len(running) < max_parallel and failed_claims < 100:
|
||||
skip = ('AND i.id NOT IN (%s)' % ', '.join(['%s'] * len(deferred))) if deferred else ''
|
||||
j = query(next_sql.format(skip=skip), tuple(deferred) or None).fetchone()
|
||||
if not j:
|
||||
break
|
||||
if query(claim_sql, (j['id'],)).rowcount != 1:
|
||||
failed_claims += 1
|
||||
continue
|
||||
|
||||
files = []
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace Mailcow\Imapsync\Controller;
|
||||
use Mailcow\Core\AccessDenied;
|
||||
use Mailcow\Core\ApiResponse;
|
||||
use Mailcow\Core\UserError;
|
||||
use Mailcow\Imapsync\Model\Source;
|
||||
use Mailcow\Imapsync\Service\JobService;
|
||||
use Mailcow\Imapsync\Service\SourceService;
|
||||
use PDOException;
|
||||
@@ -37,8 +38,11 @@ class ImapsyncApiController
|
||||
/** POST add/syncjob_source */
|
||||
public function addSource(array $attr): bool
|
||||
{
|
||||
return $this->handle('add', 'source', $attr,
|
||||
fn(array $log): bool => $this->response->success(['imapsync_source_added', $this->sources->create($attr)->name], $log));
|
||||
return $this->handle('add', 'source', $attr, function (array $log) use ($attr): bool {
|
||||
$source = $this->sources->create($attr);
|
||||
$this->tokenFailure($source, $log);
|
||||
return $this->response->success(['imapsync_source_added', $source->name], $log);
|
||||
});
|
||||
}
|
||||
|
||||
/** POST edit/syncjob: one message per id; `id` in $attr wins over $items (array_merge like before) */
|
||||
@@ -70,8 +74,11 @@ class ImapsyncApiController
|
||||
$data = array_merge(['id' => $items], $attr);
|
||||
return $this->handle('edit', 'source', $data, function (array $log) use ($data): bool {
|
||||
$this->sources->requireEditor();
|
||||
return $this->batch(array_filter((array)($data['id'] ?? []), 'is_numeric'), $log,
|
||||
fn(int $id): array => ['imapsync_source_modified', $this->sources->update($id, $data)->name]);
|
||||
return $this->batch(array_filter((array)($data['id'] ?? []), 'is_numeric'), $log, function (int $id) use ($data, $log): array {
|
||||
$source = $this->sources->update($id, $data);
|
||||
$this->tokenFailure($source, $log);
|
||||
return ['imapsync_source_modified', $source->name];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -201,6 +208,14 @@ class ImapsyncApiController
|
||||
$this->response->flush();
|
||||
}
|
||||
|
||||
/** The token fetched when saving a client_credentials source failed: the source is saved, report the error */
|
||||
private function tokenFailure(Source $source, array $log): void
|
||||
{
|
||||
if ($source->usesClientCredentials() && $source->active === 1 && $source->lastRefreshError !== null) {
|
||||
$this->response->danger(['imapsync_source_token_refresh_failed', $source->name . ' (' . $source->lastRefreshError . ')'], $log);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one write call: $work gets the log entry and returns the result for process_*_return().
|
||||
* A UserError becomes a danger message, a database error stops the call with one `mysql_error` message.
|
||||
|
||||
@@ -37,7 +37,7 @@ class Source
|
||||
public ?string $clientId = null;
|
||||
public ?string $clientSecret = null;
|
||||
public ?string $oauthScope = null;
|
||||
/** Extra token request parameters, JSON object text */
|
||||
/** Extra OAuth parameters (sign-in or token request, see OAuthService), JSON object text */
|
||||
public ?string $extraParams = null;
|
||||
/** Shared token of a client_credentials source */
|
||||
public ?string $accessToken = null;
|
||||
|
||||
@@ -22,10 +22,12 @@ use PDOException;
|
||||
*/
|
||||
class OAuthService
|
||||
{
|
||||
/** Tokens expiring within this many seconds are refreshed */
|
||||
private const REFRESH_AHEAD = 300;
|
||||
/** Tokens expiring within this many seconds are refreshed (the runner starts jobs with >= 15 min left) */
|
||||
private const REFRESH_AHEAD = 1200;
|
||||
/** A token whose last refresh failed is retried after this many seconds */
|
||||
private const RETRY_AFTER = 900;
|
||||
private const RETRY_AFTER = 180;
|
||||
/** Extra params that belong to the browser sign-in; all others go to the token request */
|
||||
private const AUTHORIZE_PARAMS = ['prompt', 'access_type', 'login_hint', 'domain_hint', 'hd'];
|
||||
/** Per-user tokens that no job uses are kept this long (time to save the job after signing in) */
|
||||
private const UNUSED_TOKEN_GRACE = 86400;
|
||||
|
||||
@@ -106,9 +108,11 @@ class OAuthService
|
||||
if (!$this->endpointAllowed($endpoint, $source->createdBy)) {
|
||||
return null;
|
||||
}
|
||||
return $endpoint . (str_contains($endpoint, '?') ? '&' : '?') . http_build_query(['client_id' => $source->clientId,
|
||||
// select_account: never sign in silently with the account the browser already uses (often the admin's)
|
||||
$params = array_merge(['prompt' => 'select_account'], $this->extraParamsFor($source, true), ['client_id' => $source->clientId,
|
||||
'response_type' => 'code', 'redirect_uri' => $this->redirectUri(), 'response_mode' => 'query',
|
||||
'scope' => $source->oauthScope, 'state' => $state]);
|
||||
return $endpoint . (str_contains($endpoint, '?') ? '&' : '?') . http_build_query($params);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -248,9 +252,17 @@ class OAuthService
|
||||
return $value ?? throw new UserError('imapsync_source_oauth_invalid');
|
||||
}
|
||||
|
||||
/** Stored extra params of the browser sign-in ($authorize) or of the token request */
|
||||
private function extraParamsFor(Source $source, bool $authorize): array
|
||||
{
|
||||
$extra = json_decode((string)$source->extraParams, true);
|
||||
return array_filter(is_array($extra) ? $extra : [],
|
||||
fn($key): bool => in_array($key, self::AUTHORIZE_PARAMS, true) === $authorize, ARRAY_FILTER_USE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra token request parameters: a JSON object with scalar values, as string or as array
|
||||
* (after 011-api.js auto-parsing). Returns the JSON to store, null for none.
|
||||
* Extra parameters: a JSON object with scalar values, as string or as array (after 011-api.js
|
||||
* auto-parsing). Returns the JSON to store, null for none.
|
||||
*/
|
||||
private function extraParams(mixed $extra): ?string
|
||||
{
|
||||
@@ -277,8 +289,7 @@ class OAuthService
|
||||
*/
|
||||
private function requestToken(Source $source, array $grant): array
|
||||
{
|
||||
$extra = json_decode((string)$source->extraParams, true);
|
||||
$post = array_merge(is_array($extra) ? $extra : [],
|
||||
$post = array_merge($this->extraParamsFor($source, false),
|
||||
['client_id' => $source->clientId, 'client_secret' => $source->clientSecret, 'scope' => $source->oauthScope], $grant);
|
||||
$token = $this->client->request((string)$source->tokenEndpoint, $this->isStrict($source->createdBy), $post);
|
||||
if (empty($token['access_token']) || !is_string($token['access_token'])) {
|
||||
|
||||
@@ -97,6 +97,7 @@ class SourceService
|
||||
throw new UserError('object_exists', [htmlspecialchars($source->name)]);
|
||||
}
|
||||
$this->repo->insert($source);
|
||||
$this->fetchToken($source);
|
||||
return $source;
|
||||
}
|
||||
|
||||
@@ -121,6 +122,7 @@ class SourceService
|
||||
$this->applyScope($source, $input['scope'] ?? $current->scope, $input['domains'] ?? [], $input['users'] ?? []);
|
||||
}
|
||||
$this->repo->update($source, $writeTargets, self::connection($current) !== self::connection($source));
|
||||
$this->fetchToken($source);
|
||||
return $source;
|
||||
}
|
||||
|
||||
@@ -338,6 +340,14 @@ class SourceService
|
||||
return array_values(array_unique($clean));
|
||||
}
|
||||
|
||||
/** A saved, active client_credentials source gets its token now instead of at the next cron run (error in lastRefreshError) */
|
||||
private function fetchToken(Source $source): void
|
||||
{
|
||||
if ($source->usesClientCredentials() && $source->active === 1) {
|
||||
$source->lastRefreshError = $this->oauth->refreshSourceToken($source);
|
||||
}
|
||||
}
|
||||
|
||||
/** Tokens never follow a source to another OAuth client or IMAP server, so these values reset them */
|
||||
private static function connection(Source $source): array
|
||||
{
|
||||
|
||||
@@ -294,7 +294,7 @@ $RSPAMD_MAPS = array(
|
||||
// Anything not listed here is rejected when a job is saved (the runner passes stored options as they are).
|
||||
// Override in vars.local.inc.php per option, e.g. $IMAPSYNC_OPTIONS['someoption'] = true; or unset($IMAPSYNC_OPTIONS['debug']);
|
||||
// The old format $IMAPSYNC_OPTIONS['whitelist'] (list of names) is no longer supported and ignored (logged).
|
||||
// NEVER add (RCE): 'delete2foldersonly', 'delete2foldersbutnot', 'regexflag', 'regexmess', 'pipemess', 'regextrans2', 'maxlinelengthcmd'
|
||||
// NEVER add (RCE): 'delete2foldersonly', 'delete2foldersbutnot', 'regexflag', 'regexmess', 'pipemess', 'regextrans2', 'maxlinelengthcmd', 'oauthrefreshcmd1', 'oauthrefreshcmd2'
|
||||
// NEVER add options taking local file paths (e.g. 'logfile', 'logdir', 'pidfile', 'passfile1', 'sslargs1' with SSL_ca_file)
|
||||
$IMAPSYNC_OPTIONS_DEFAULT = array(
|
||||
'abort' => false,
|
||||
|
||||
@@ -588,6 +588,74 @@ var mailcowImapsync = (function($) {
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Provider presets (add source modal) =====
|
||||
|
||||
// Server and OAuth settings of well-known providers; fields a preset leaves out get the defaults of applyPreset
|
||||
var m365 = 'https://login.microsoftonline.com/{tenant}/oauth2/v2.0/';
|
||||
var presets = {
|
||||
m365_app: {host: 'outlook.office365.com', auth_type: 'XOAUTH2', oauth_token_endpoint: m365 + 'token',
|
||||
oauth_scope: 'https://outlook.office365.com/.default'},
|
||||
m365_user: {host: 'outlook.office365.com', auth_type: 'XOAUTH2', oauth_flow: 'authorization_code',
|
||||
oauth_authorize_endpoint: m365 + 'authorize', oauth_token_endpoint: m365 + 'token',
|
||||
oauth_scope: 'https://outlook.office.com/IMAP.AccessAsUser.All offline_access openid email profile'},
|
||||
// Google issues a refresh token only for offline access, and again only after consent
|
||||
google_user: {host: 'imap.gmail.com', auth_type: 'XOAUTH2', oauth_flow: 'authorization_code',
|
||||
oauth_authorize_endpoint: 'https://accounts.google.com/o/oauth2/v2/auth', oauth_token_endpoint: 'https://oauth2.googleapis.com/token',
|
||||
oauth_scope: 'https://mail.google.com/ openid email', oauth_extra_params: '{"access_type":"offline","prompt":"consent select_account"}'},
|
||||
gmail: {host: 'imap.gmail.com'},
|
||||
gmx: {host: 'imap.gmx.net'},
|
||||
webde: {host: 'imap.web.de'},
|
||||
tonline: {host: 'secureimap.t-online.de'},
|
||||
icloud: {host: 'imap.mail.me.com'},
|
||||
yahoo: {host: 'imap.mail.yahoo.com'},
|
||||
ionos: {host: 'imap.ionos.de'},
|
||||
strato: {host: 'imap.strato.de'}
|
||||
};
|
||||
|
||||
// The name follows the preset until it is changed by hand; client ID and secret are kept
|
||||
function applyPreset($link) {
|
||||
var $form = $link.closest('form');
|
||||
var preset = $.extend({port: 993, enc: 'SSL', auth_type: 'PLAIN', oauth_flow: 'client_credentials', oauth_authorize_endpoint: '',
|
||||
oauth_token_endpoint: '', oauth_userinfo_endpoint: '', oauth_scope: '', oauth_extra_params: ''}, presets[$link.data('preset')]);
|
||||
var $name = $form.find('input[name="name"]');
|
||||
var names = $form.find('.imapsync-source-preset').map(function() { return $.trim($(this).text()); }).get();
|
||||
if (!$name.val() || names.indexOf($name.val()) !== -1) $name.val($.trim($link.text()));
|
||||
// bootstrap-select 1.14 duplicates the button label on refresh after .val(), use its own setter
|
||||
$.each(preset, function(field, value) {
|
||||
var $f = $form.find('[name="' + field + '"]');
|
||||
if ($f.data('selectpicker')) {
|
||||
$f.selectpicker('val', value);
|
||||
} else {
|
||||
$f.val(value);
|
||||
}
|
||||
});
|
||||
var needsTenant = preset.oauth_token_endpoint.indexOf('{tenant}') !== -1;
|
||||
$form.find('.imapsync-source-tenant-row').toggle(needsTenant);
|
||||
applyTenant($form);
|
||||
sourceFormToggle($form);
|
||||
if (needsTenant) $form.find('.imapsync-source-tenant').trigger('focus');
|
||||
}
|
||||
|
||||
// M365: the tenant (ID or domain) is part of the endpoint URLs
|
||||
function applyTenant($form) {
|
||||
var tenant = encodeURIComponent($.trim($form.find('.imapsync-source-tenant').val() || '')) || '{tenant}';
|
||||
$form.find('input[name="oauth_authorize_endpoint"], input[name="oauth_token_endpoint"]').val(function(_, url) {
|
||||
return url.replace(/^(https:\/\/login\.microsoftonline\.com\/)[^\/]*/, function(_, base) { return base + tenant; });
|
||||
});
|
||||
}
|
||||
|
||||
// A visible endpoint must not keep the {tenant} placeholder; checked before the form is posted
|
||||
function tenantValid($form) {
|
||||
var missing = $form.find('input[name="oauth_authorize_endpoint"]:visible, input[name="oauth_token_endpoint"]:visible')
|
||||
.filter(function() { return this.value.indexOf('{tenant}') !== -1; }).length > 0;
|
||||
$form.find('.imapsync-source-tenant').toggleClass('inputMissingAttr', missing);
|
||||
if (missing) {
|
||||
$form.find('.imapsync-source-tenant-row').show();
|
||||
mailcow_alert_box(lang_danger.imapsync_source_tenant_missing, 'danger');
|
||||
}
|
||||
return !missing;
|
||||
}
|
||||
|
||||
// ===== Wiring =====
|
||||
|
||||
function bind() {
|
||||
@@ -659,12 +727,20 @@ var mailcowImapsync = (function($) {
|
||||
sourceScopeToggle($(this).closest('form'));
|
||||
});
|
||||
$('[data-action="add_item"][data-id="add_imapsync_source"], [data-action="edit_selected"][data-id="edit_imapsync_source"]').on('click', function(e) {
|
||||
if (!extraParamsValid($(this).closest('form'))) {
|
||||
var $form = $(this).closest('form');
|
||||
if (!extraParamsValid($form) || !tenantValid($form)) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
return false;
|
||||
}
|
||||
});
|
||||
$(document).on('click', '.imapsync-source-preset', function(e) {
|
||||
e.preventDefault();
|
||||
applyPreset($(this));
|
||||
});
|
||||
$(document).on('input', '.imapsync-source-tenant', function() {
|
||||
applyTenant($(this).closest('form'));
|
||||
});
|
||||
$(document).on('click', '.imapsync-copy-btn', function(e) {
|
||||
e.preventDefault();
|
||||
copyField($(this).data('copy-target'));
|
||||
|
||||
@@ -572,6 +572,7 @@
|
||||
"imapsync_source_oauth_required": "OAuth2 erfordert Token-Endpunkt, Client-ID, Client-Secret und Scope",
|
||||
"imapsync_source_oauth_endpoint_invalid": "Ungültiger OAuth2-Endpunkt: Erforderlich ist eine https://-URL mit gültigem Hostnamen (Quellen, die nicht von einem Administrator angelegt wurden, brauchen einen öffentlich erreichbaren Host)",
|
||||
"imapsync_source_oauth_extra_invalid": "OAuth2-Extra-Parameter müssen ein JSON-Objekt mit einfachen Werten sein, z. B. {\"resource\": \"https://example.com\"}",
|
||||
"imapsync_source_tenant_missing": "Bitte den Microsoft-365-Tenant angeben",
|
||||
"imapsync_source_oauth_invalid": "Ungültige OAuth2-Konfiguration",
|
||||
"imapsync_max_parallel_invalid": "Die Anzahl paralleler Prozesse muss zwischen 1 und 32 liegen",
|
||||
"imapsync_source_description_invalid": "Die Beschreibung darf höchstens 255 Zeichen lang sein",
|
||||
@@ -1356,7 +1357,11 @@
|
||||
"source_oauth_client_secret": "Client-Secret",
|
||||
"source_oauth_scope": "Scope",
|
||||
"source_oauth_extra_params": "Zusätzliche Parameter (JSON)",
|
||||
"source_oauth_extra_params_hint": "Optionales JSON-Objekt mit zusätzlichen Parametern für die Token-Anfrage, z. B. {\"resource\": \"https://provider.example.com\"}",
|
||||
"source_oauth_extra_params_hint": "Optionales JSON-Objekt. prompt, access_type, login_hint, domain_hint und hd gehen an die Anmeldung beim Provider, alle anderen an die Token-Anfrage, z. B. {\"resource\": \"https://provider.example.com\"}",
|
||||
"source_presets": "Vorlagen",
|
||||
"source_presets_hint": "Füllt die Serverdaten des Anbieters aus. Viele Anbieter (z. B. Gmail, iCloud, Yahoo) verlangen für IMAP ein App-Passwort oder die Freischaltung von IMAP in den Postfach-Einstellungen.",
|
||||
"source_tenant": "Microsoft-365-Tenant",
|
||||
"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_readonly_hint": "Diese Sync-Quelle ist freigegeben und kann nur angesehen werden.",
|
||||
"copy": "Kopieren",
|
||||
|
||||
@@ -572,6 +572,7 @@
|
||||
"imapsync_source_oauth_required": "OAuth2 requires token endpoint, client ID, client secret and scope",
|
||||
"imapsync_source_oauth_endpoint_invalid": "Invalid OAuth2 endpoint: an https:// URL with a valid host name is required (sources not created by an administrator need a publicly reachable host)",
|
||||
"imapsync_source_oauth_extra_invalid": "OAuth2 extra parameters must be a JSON object with simple values, e.g. {\"resource\": \"https://example.com\"}",
|
||||
"imapsync_source_tenant_missing": "Please enter the Microsoft 365 tenant",
|
||||
"imapsync_source_oauth_invalid": "Invalid OAuth2 configuration",
|
||||
"imapsync_max_parallel_invalid": "Parallel process count must be between 1 and 32",
|
||||
"imapsync_source_description_invalid": "The description must not exceed 255 characters",
|
||||
@@ -1363,7 +1364,11 @@
|
||||
"source_oauth_client_secret": "Client secret",
|
||||
"source_oauth_scope": "Scope",
|
||||
"source_oauth_extra_params": "Extra parameters (JSON)",
|
||||
"source_oauth_extra_params_hint": "Optional JSON object with additional parameters for the token request, e.g. {\"resource\": \"https://provider.example.com\"}",
|
||||
"source_oauth_extra_params_hint": "Optional JSON object. prompt, access_type, login_hint, domain_hint and hd are sent with the provider sign-in, all others with the token request, e.g. {\"resource\": \"https://provider.example.com\"}",
|
||||
"source_presets": "Presets",
|
||||
"source_presets_hint": "Fills in the provider's server settings. Many providers (e.g. Gmail, iCloud, Yahoo) require an app password or IMAP access to be enabled in the mailbox settings.",
|
||||
"source_tenant": "Microsoft 365 tenant",
|
||||
"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_readonly_hint": "This sync source is shared with you and can only be viewed.",
|
||||
"copy": "Copy",
|
||||
|
||||
@@ -9,6 +9,29 @@
|
||||
<div class="modal-body">
|
||||
<form class="form-horizontal" data-cached-form="true" role="form" data-id="add_imapsync_source">
|
||||
<input type="hidden" value="0" name="active">
|
||||
<div class="row mb-3">
|
||||
<label class="control-label col-sm-2 text-sm-end">{{ lang.syncjobs.source_presets }}</label>
|
||||
<div class="col-sm-10 small">
|
||||
{% if mailcow_cc_role != 'user' %}
|
||||
<div class="d-flex flex-wrap column-gap-3">
|
||||
<a href="#" class="imapsync-source-preset" data-preset="m365_app">Microsoft 365 ({{ lang.syncjobs.source_flow_badge_app }})</a>
|
||||
<a href="#" class="imapsync-source-preset" data-preset="m365_user">Microsoft 365 ({{ lang.syncjobs.source_flow_badge_user }})</a>
|
||||
<a href="#" class="imapsync-source-preset" data-preset="google_user">Google ({{ lang.syncjobs.source_flow_badge_user }})</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="d-flex flex-wrap column-gap-3">
|
||||
<a href="#" class="imapsync-source-preset" data-preset="gmail">Gmail</a>
|
||||
<a href="#" class="imapsync-source-preset" data-preset="gmx">GMX</a>
|
||||
<a href="#" class="imapsync-source-preset" data-preset="webde">WEB.DE</a>
|
||||
<a href="#" class="imapsync-source-preset" data-preset="tonline">T-Online</a>
|
||||
<a href="#" class="imapsync-source-preset" data-preset="icloud">iCloud</a>
|
||||
<a href="#" class="imapsync-source-preset" data-preset="yahoo">Yahoo</a>
|
||||
<a href="#" class="imapsync-source-preset" data-preset="ionos">IONOS</a>
|
||||
<a href="#" class="imapsync-source-preset" data-preset="strato">STRATO</a>
|
||||
</div>
|
||||
<span class="text-muted">{{ lang.syncjobs.source_presets_hint }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsrc_name">{{ lang.syncjobs.source_name }}</label>
|
||||
<div class="col-sm-10">
|
||||
@@ -101,6 +124,13 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2 imapsync-source-tenant-row" style="display:none;">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsrc_tenant">{{ lang.syncjobs.source_tenant }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control imapsync-source-tenant" id="addsrc_tenant" placeholder="contoso.onmicrosoft.com">
|
||||
<small class="text-muted">{{ lang.syncjobs.source_tenant_hint }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2 imapsync-source-authcode-block" style="display:none;">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsrc_oauth_authorize_endpoint">{{ lang.syncjobs.source_authorize_endpoint }}</label>
|
||||
<div class="col-sm-10">
|
||||
|
||||
@@ -85,6 +85,20 @@ class ApiControllerTest extends TestCase
|
||||
$this->assertSame('*', $_SESSION['return'][0]['log'][3]['oauth_client_secret']);
|
||||
}
|
||||
|
||||
public function testSavingAClientCredentialsSourceReportsAFailedFirstToken(): void
|
||||
{
|
||||
$source = Source::fromRow(['id' => 5, 'name' => 'm365', 'auth_type' => 'XOAUTH2', 'oauth_last_refresh_error' => 'HTTP 401: invalid_client']);
|
||||
$this->sources->method('create')->willReturn($source);
|
||||
$this->sources->method('update')->willReturn($source);
|
||||
|
||||
$this->assertTrue($this->api->addSource(['name' => 'm365']));
|
||||
$this->assertTrue($this->api->editSources([5], ['name' => 'm365']));
|
||||
|
||||
$failed = ['danger', ['imapsync_source_token_refresh_failed', 'm365 (HTTP 401: invalid_client)']];
|
||||
$this->assertSame([$failed, ['success', ['imapsync_source_added', 'm365']], $failed, ['success', ['imapsync_source_modified', 'm365']]],
|
||||
$this->messages());
|
||||
}
|
||||
|
||||
public function testEditJobsKeepsBatchMessageOrder(): void
|
||||
{
|
||||
$this->jobs->method('update')->willReturnCallback(function (int $id, array $data): Job {
|
||||
|
||||
@@ -75,12 +75,29 @@ class OAuthServiceTest extends TestCase
|
||||
$this->client->method('isAllowed')->willReturnOnConsecutiveCalls(true, false);
|
||||
$source = self::source(['oauth_flow' => 'authorization_code']);
|
||||
|
||||
$this->assertSame('https://login.example.org/authorize?tenant=x&client_id=client&response_type=code'
|
||||
$this->assertSame('https://login.example.org/authorize?tenant=x&prompt=select_account&client_id=client&response_type=code'
|
||||
. '&redirect_uri=https%3A%2F%2Fmail.example.org%2Fsyncjob-oauth&response_mode=query&scope=scope&state=abc',
|
||||
$this->service()->authorizeUrl($source, 'abc'));
|
||||
$this->assertNull($this->service()->authorizeUrl($source, 'abc'));
|
||||
}
|
||||
|
||||
public function testSignInParamsGoToTheAuthorizeUrlOnly(): void
|
||||
{
|
||||
$this->client->method('isAllowed')->willReturn(true);
|
||||
$this->client->expects($this->once())->method('request')->with(
|
||||
'https://login.example.org/token',
|
||||
true,
|
||||
['resource' => 'r', 'client_id' => 'client', 'client_secret' => 'secret', 'scope' => 'scope', 'grant_type' => 'client_credentials']
|
||||
)->willReturn(['access_token' => 'new']);
|
||||
$extra = '{"prompt":"consent select_account","access_type":"offline","resource":"r"}';
|
||||
|
||||
// prompt overrides the default; sign-in params never reach the token endpoint and vice versa
|
||||
$this->assertSame('https://login.example.org/authorize?tenant=x&prompt=consent+select_account&access_type=offline&client_id=client'
|
||||
. '&response_type=code&redirect_uri=https%3A%2F%2Fmail.example.org%2Fsyncjob-oauth&response_mode=query&scope=scope&state=abc',
|
||||
$this->service()->authorizeUrl(self::source(['oauth_flow' => 'authorization_code', 'oauth_extra_params' => $extra]), 'abc'));
|
||||
$this->assertNull($this->service()->refreshSourceToken(self::source(['oauth_extra_params' => $extra])));
|
||||
}
|
||||
|
||||
public function testClientCredentialsRefreshStoresToken(): void
|
||||
{
|
||||
$this->client->expects($this->once())->method('request')->with(
|
||||
@@ -152,12 +169,13 @@ class OAuthServiceTest extends TestCase
|
||||
|
||||
public function testRefreshDueAppliesExpiryAndRetryRules(): void
|
||||
{
|
||||
$soon = time() + 60;
|
||||
// due 20 min ahead, retried 3 min after a failure
|
||||
$soon = time() + 1100;
|
||||
$this->tokens->method('refreshableSources')->willReturn([
|
||||
self::source(['id' => 1, 'name' => 'no token']),
|
||||
self::source(['id' => 2, 'name' => 'valid', 'oauth_token_expires' => time() + 3600]),
|
||||
self::source(['id' => 3, 'name' => 'failed recently', 'oauth_token_expires' => $soon, 'oauth_last_refresh_error' => 'HTTP 500', 'refresh_attempt_age' => 900]),
|
||||
self::source(['id' => 4, 'name' => 'failed long ago', 'oauth_token_expires' => $soon, 'oauth_last_refresh_error' => 'HTTP 500', 'refresh_attempt_age' => 901]),
|
||||
self::source(['id' => 2, 'name' => 'valid', 'oauth_token_expires' => time() + 1300]),
|
||||
self::source(['id' => 3, 'name' => 'failed recently', 'oauth_token_expires' => $soon, 'oauth_last_refresh_error' => 'HTTP 500', 'refresh_attempt_age' => 180]),
|
||||
self::source(['id' => 4, 'name' => 'failed long ago', 'oauth_token_expires' => $soon, 'oauth_last_refresh_error' => 'HTTP 500', 'refresh_attempt_age' => 181]),
|
||||
]);
|
||||
$this->tokens->method('deleteUnusedOlderThan')->with(86400)->willReturn(0);
|
||||
$this->tokens->method('refreshableUserTokens')->willReturn([]);
|
||||
|
||||
@@ -429,6 +429,23 @@ class SourceServiceTest extends TestCase
|
||||
$this->assertSame('src', $service->refreshToken(7)->name);
|
||||
}
|
||||
|
||||
public function testSavedClientCredentialsSourcesFetchTheirFirstToken(): void
|
||||
{
|
||||
$this->repo->method('findVisible')->willReturnOnConsecutiveCalls(self::oauthSource(), self::oauthSource(['active' => 0]));
|
||||
$this->client->expects($this->exactly(2))->method('request')->willReturnOnConsecutiveCalls(
|
||||
['access_token' => 'first', 'expires_in' => 3600],
|
||||
$this->throwException(new OAuthException('HTTP 401: invalid_client'))
|
||||
);
|
||||
$this->tokens->expects($this->once())->method('storeSourceToken');
|
||||
$this->tokens->expects($this->once())->method('storeSourceError')->with(7, 'HTTP 401: invalid_client');
|
||||
$service = $this->service(FakeContext::admin());
|
||||
|
||||
$this->assertNull($service->create($this->oauthInput())->lastRefreshError);
|
||||
$this->assertSame('HTTP 401: invalid_client', $service->update(7, ['oauth_scope' => 'new-scope'])->lastRefreshError);
|
||||
// inactive sources get no token
|
||||
$service->update(7, ['oauth_scope' => 'other-scope']);
|
||||
}
|
||||
|
||||
public function testConnectableSourceIsActiveAuthorizationCode(): void
|
||||
{
|
||||
$this->repo->method('findVisible')->willReturnOnConsecutiveCalls(
|
||||
|
||||
Reference in new issue
Block a user