mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-10-22 10:48:13 +00:00
[Web][Dovecot] Rework imapsync: sync sources, OAuth2 (XOAUTH2), worker pool, Python runner
This commit is contained in:
39 files changed
+3582
-1358
No files matched your search
@@ -68,7 +68,6 @@ RUN addgroup -g 5000 vmail \
|
||||
perl-package-stash-xs \
|
||||
perl-par-packer \
|
||||
perl-parse-recdescent \
|
||||
perl-lockfile-simple \
|
||||
libproc2 \
|
||||
perl-readonly \
|
||||
perl-regexp-common \
|
||||
@@ -84,7 +83,6 @@ RUN addgroup -g 5000 vmail \
|
||||
perl-test-warn \
|
||||
perl-try-tiny \
|
||||
perl-unicode-string \
|
||||
perl-proc-processtable \
|
||||
perl-app-cpanminus \
|
||||
procps \
|
||||
python3 \
|
||||
@@ -120,7 +118,7 @@ COPY clean_q_aged.sh /usr/local/bin/clean_q_aged.sh
|
||||
COPY syslog-ng.conf /etc/syslog-ng/syslog-ng.conf
|
||||
COPY syslog-ng-redis_slave.conf /etc/syslog-ng/syslog-ng-redis_slave.conf
|
||||
COPY imapsync /usr/local/bin/imapsync
|
||||
COPY imapsync_runner.pl /usr/local/bin/imapsync_runner.pl
|
||||
COPY imapsync_runner.py /usr/local/bin/imapsync_runner.py
|
||||
COPY report-spam.sieve /usr/lib/dovecot/sieve/report-spam.sieve
|
||||
COPY report-ham.sieve /usr/lib/dovecot/sieve/report-ham.sieve
|
||||
COPY rspamd-pipe-ham /usr/lib/dovecot/sieve/rspamd-pipe-ham
|
||||
|
||||
@@ -279,7 +279,7 @@ chmod g+rw /dev/console
|
||||
chown root:tty /dev/console
|
||||
chmod +x /usr/lib/dovecot/sieve/rspamd-pipe-ham \
|
||||
/usr/lib/dovecot/sieve/rspamd-pipe-spam \
|
||||
/usr/local/bin/imapsync_runner.pl \
|
||||
/usr/local/bin/imapsync_runner.py \
|
||||
/usr/local/bin/imapsync \
|
||||
/usr/local/bin/trim_logs.sh \
|
||||
/usr/local/bin/sa-rules.sh \
|
||||
|
||||
@@ -1,196 +0,0 @@
|
||||
#!/usr/bin/perl
|
||||
|
||||
use DBI;
|
||||
use LockFile::Simple qw(lock trylock unlock);
|
||||
use Proc::ProcessTable;
|
||||
use Data::Dumper qw(Dumper);
|
||||
use IPC::Run 'run';
|
||||
use File::Temp;
|
||||
use Try::Tiny;
|
||||
use sigtrap 'handler' => \&sig_handler, qw(INT TERM KILL QUIT);
|
||||
|
||||
sub trim { my $s = shift; $s =~ s/^\s+|\s+$//g; return $s };
|
||||
my $t = Proc::ProcessTable->new;
|
||||
my $imapsync_running = grep { $_->{cmndline} =~ /imapsync\s/i } @{$t->table};
|
||||
if ($imapsync_running ge 1)
|
||||
{
|
||||
print "imapsync is active, exiting...";
|
||||
exit;
|
||||
}
|
||||
|
||||
sub qqw($) {
|
||||
my @params = ();
|
||||
my @values = split(/(?=--)/, $_[0]);
|
||||
foreach my $val (@values) {
|
||||
my @tmpparam = split(/ /, $val, 2);
|
||||
foreach my $tmpval (@tmpparam) {
|
||||
if ($tmpval ne '') {
|
||||
push @params, $tmpval;
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach my $val (@params) {
|
||||
$val=trim($val);
|
||||
}
|
||||
return @params;
|
||||
}
|
||||
|
||||
$run_dir="/tmp";
|
||||
$dsn = 'DBI:mysql:database=' . $ENV{'DBNAME'} . ';mysql_socket=/var/run/mysqld/mysqld.sock';
|
||||
$lock_file = $run_dir . "/imapsync_busy";
|
||||
$lockmgr = LockFile::Simple->make(-autoclean => 1, -max => 1);
|
||||
$lockmgr->lock($lock_file) || die "can't lock ${lock_file}";
|
||||
$dbh = DBI->connect($dsn, $ENV{'DBUSER'}, $ENV{'DBPASS'}, {
|
||||
mysql_auto_reconnect => 1,
|
||||
mysql_enable_utf8mb4 => 1
|
||||
});
|
||||
$dbh->do("UPDATE imapsync SET is_running = 0");
|
||||
|
||||
sub sig_handler {
|
||||
# Send die to force exception in "run"
|
||||
die "sig_handler received signal, preparing to exit...\n";
|
||||
};
|
||||
|
||||
open my $file, '<', "/etc/sogo/sieve.creds";
|
||||
my $creds = <$file>;
|
||||
close $file;
|
||||
my ($master_user, $master_pass) = split /:/, $creds;
|
||||
my $sth = $dbh->prepare("SELECT id,
|
||||
user1,
|
||||
user2,
|
||||
host1,
|
||||
authmech1,
|
||||
password1,
|
||||
exclude,
|
||||
port1,
|
||||
enc1,
|
||||
delete2duplicates,
|
||||
maxage,
|
||||
subfolder2,
|
||||
delete1,
|
||||
delete2,
|
||||
automap,
|
||||
skipcrossduplicates,
|
||||
maxbytespersecond,
|
||||
custom_params,
|
||||
subscribeall,
|
||||
timeout1,
|
||||
timeout2,
|
||||
dry
|
||||
FROM imapsync
|
||||
WHERE active = 1
|
||||
AND is_running = 0
|
||||
AND (
|
||||
UNIX_TIMESTAMP(NOW()) - UNIX_TIMESTAMP(last_run) > mins_interval * 60
|
||||
OR
|
||||
last_run IS NULL)
|
||||
ORDER BY last_run");
|
||||
|
||||
$sth->execute();
|
||||
my $row;
|
||||
|
||||
while ($row = $sth->fetchrow_arrayref()) {
|
||||
|
||||
$id = @$row[0];
|
||||
$user1 = @$row[1];
|
||||
$user2 = @$row[2];
|
||||
$host1 = @$row[3];
|
||||
$authmech1 = @$row[4];
|
||||
$password1 = @$row[5];
|
||||
$exclude = @$row[6];
|
||||
$port1 = @$row[7];
|
||||
$enc1 = @$row[8];
|
||||
$delete2duplicates = @$row[9];
|
||||
$maxage = @$row[10];
|
||||
$subfolder2 = @$row[11];
|
||||
$delete1 = @$row[12];
|
||||
$delete2 = @$row[13];
|
||||
$automap = @$row[14];
|
||||
$skipcrossduplicates = @$row[15];
|
||||
$maxbytespersecond = @$row[16];
|
||||
$custom_params = @$row[17];
|
||||
$subscribeall = @$row[18];
|
||||
$timeout1 = @$row[19];
|
||||
$timeout2 = @$row[20];
|
||||
$dry = @$row[21];
|
||||
|
||||
if ($enc1 eq "TLS") { $enc1 = "--tls1"; } elsif ($enc1 eq "SSL") { $enc1 = "--ssl1"; } else { undef $enc1; }
|
||||
|
||||
my $template = $run_dir . '/imapsync.XXXXXXX';
|
||||
my $passfile1 = File::Temp->new(TEMPLATE => $template);
|
||||
my $passfile2 = File::Temp->new(TEMPLATE => $template);
|
||||
|
||||
binmode( $passfile1, ":utf8" );
|
||||
|
||||
print $passfile1 "$password1\n";
|
||||
print $passfile2 trim($master_pass) . "\n";
|
||||
|
||||
my @custom_params_a = qqw($custom_params);
|
||||
my $custom_params_ref = \@custom_params_a;
|
||||
|
||||
my $generated_cmds = [ "/usr/local/bin/imapsync",
|
||||
"--tmpdir", "/tmp",
|
||||
"--nofoldersizes",
|
||||
"--addheader",
|
||||
($timeout1 le "0" ? () : ('--timeout1', $timeout1)),
|
||||
($timeout2 le "0" ? () : ('--timeout2', $timeout2)),
|
||||
($exclude eq "" ? () : ("--exclude", $exclude)),
|
||||
($subfolder2 eq "" ? () : ('--subfolder2', $subfolder2)),
|
||||
($maxage eq "0" ? () : ('--maxage', $maxage)),
|
||||
($maxbytespersecond eq "0" ? () : ('--maxbytespersecond', $maxbytespersecond)),
|
||||
($delete2duplicates ne "1" ? () : ('--delete2duplicates')),
|
||||
($subscribeall ne "1" ? () : ('--subscribeall')),
|
||||
($delete1 ne "1" ? () : ('--delete')),
|
||||
($delete2 ne "1" ? () : ('--delete2')),
|
||||
($automap ne "1" ? () : ('--automap')),
|
||||
($skipcrossduplicates ne "1" ? () : ('--skipcrossduplicates')),
|
||||
(!defined($enc1) ? () : ($enc1)),
|
||||
"--host1", $host1,
|
||||
"--user1", $user1,
|
||||
"--passfile1", $passfile1->filename,
|
||||
"--port1", $port1,
|
||||
"--host2", "localhost",
|
||||
"--user2", $user2 . '*' . trim($master_user),
|
||||
"--passfile2", $passfile2->filename,
|
||||
($dry eq "1" ? ('--dry') : ()),
|
||||
'--no-modulesversion',
|
||||
'--noreleasecheck'];
|
||||
|
||||
try {
|
||||
$is_running = $dbh->prepare("UPDATE imapsync SET is_running = 1, success = NULL, exit_status = NULL WHERE id = ?");
|
||||
$is_running->bind_param( 1, ${id} );
|
||||
$is_running->execute();
|
||||
|
||||
run [@$generated_cmds, @$custom_params_ref], '&>', \my $stdout;
|
||||
|
||||
# check exit code and status
|
||||
($exit_code, $exit_status) = ($stdout =~ m/Exiting\swith\sreturn\svalue\s(\d+)\s\(([^:)]+)/);
|
||||
|
||||
$success = 0;
|
||||
if (defined $exit_code && $exit_code == 0) {
|
||||
$success = 1;
|
||||
}
|
||||
|
||||
$update = $dbh->prepare("UPDATE imapsync SET returned_text = ?, success = ?, exit_status = ? WHERE id = ?");
|
||||
$update->bind_param( 1, ${stdout} );
|
||||
$update->bind_param( 2, ${success} );
|
||||
$update->bind_param( 3, ${exit_status} );
|
||||
$update->bind_param( 4, ${id} );
|
||||
$update->execute();
|
||||
} catch {
|
||||
$update = $dbh->prepare("UPDATE imapsync SET returned_text = 'Could not start or finish imapsync', success = 0 WHERE id = ?");
|
||||
$update->bind_param( 1, ${id} );
|
||||
$update->execute();
|
||||
} finally {
|
||||
$update = $dbh->prepare("UPDATE imapsync SET last_run = NOW(), is_running = 0 WHERE id = ?");
|
||||
$update->bind_param( 1, ${id} );
|
||||
$update->execute();
|
||||
};
|
||||
|
||||
|
||||
}
|
||||
|
||||
$sth->finish();
|
||||
$dbh->disconnect();
|
||||
|
||||
$lockmgr->unlock($lock_file);
|
||||
Executable
+274
@@ -0,0 +1,274 @@
|
||||
#!/usr/bin/python3
|
||||
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import MySQLdb
|
||||
import MySQLdb.cursors
|
||||
|
||||
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'
|
||||
|
||||
# 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
|
||||
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)
|
||||
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
|
||||
WHERE source_id = %s AND owner = %s AND username = %s"""
|
||||
|
||||
conn = None
|
||||
# Popen => job of running workers
|
||||
running = {}
|
||||
stopping = False
|
||||
|
||||
def num(value):
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
def unlink(files):
|
||||
for path in files:
|
||||
try:
|
||||
os.unlink(path)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def imapsync_running():
|
||||
for pid in filter(str.isdigit, os.listdir('/proc')):
|
||||
try:
|
||||
with open('/proc/' + pid + '/cmdline', 'rb') as f:
|
||||
cmdline = f.read().replace(b'\0', b' ')
|
||||
except OSError:
|
||||
continue
|
||||
if re.search(rb'imapsync\s', cmdline, re.I):
|
||||
return True
|
||||
return False
|
||||
|
||||
def connect():
|
||||
return MySQLdb.connect(unix_socket='/var/run/mysqld/mysqld.sock', user=os.environ['DBUSER'],
|
||||
passwd=os.environ['DBPASS'], db=os.environ['DBNAME'], charset='utf8mb4', autocommit=True)
|
||||
|
||||
# Reconnect when the server dropped the connection (syncs can outlast wait_timeout)
|
||||
def query(sql, args=None):
|
||||
global conn
|
||||
try:
|
||||
conn.ping()
|
||||
except MySQLdb.Error:
|
||||
conn = connect()
|
||||
cur = conn.cursor(MySQLdb.cursors.DictCursor)
|
||||
cur.execute(sql, args)
|
||||
return cur
|
||||
|
||||
# Store a result and release the job
|
||||
def finish_job(job_id, text, success, exit_status):
|
||||
try:
|
||||
query("UPDATE imapsync SET returned_text = %s, success = %s, exit_status = %s, last_run = NOW(), is_running = 0 WHERE id = %s",
|
||||
(text, success, exit_status, job_id))
|
||||
except MySQLdb.Error:
|
||||
query("UPDATE imapsync SET success = 0, last_run = NOW(), is_running = 0 WHERE id = %s", (job_id,))
|
||||
|
||||
# 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():
|
||||
settings = {r['name']: r['value'] for r in query("SELECT name, value FROM imapsync_settings")}
|
||||
return max(num(settings.get('max_parallel')), 1), max(num(settings.get('max_bytes_per_second')), 0)
|
||||
|
||||
# Build temp files and argv for a claimed job. Returns None when the job was finished without running imapsync.
|
||||
def prepare_job(j, global_bw, files):
|
||||
def tmpfile(content):
|
||||
fd, path = tempfile.mkstemp(prefix='imapsync.', dir=run_dir)
|
||||
files.append(path)
|
||||
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
||||
f.write(content)
|
||||
return path
|
||||
|
||||
auth_type = j['auth_type'] or 'PLAIN'
|
||||
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')
|
||||
return None
|
||||
auth_args = ['--authmech1', 'XOAUTH2', '--oauthaccesstoken1', tmpfile(token + '\n')]
|
||||
else:
|
||||
auth_args = ['--passfile1', tmpfile(j['password1'] + '\n')]
|
||||
# imapsync auto-picks PLAIN, only pass --authmech1 for LOGIN/CRAM-MD5
|
||||
if auth_type != 'PLAIN':
|
||||
auth_args += ['--authmech1', auth_type]
|
||||
|
||||
# Custom params: JSON array of {o,v}, validated by the backend on write. One argv token per pair,
|
||||
# "--opt" or "--opt=value" (value glued to its option, no shell)
|
||||
custom_params = []
|
||||
try:
|
||||
pairs = json.loads(j['custom_params'] or '[]')
|
||||
except ValueError:
|
||||
pairs = []
|
||||
for p in pairs if isinstance(pairs, list) else []:
|
||||
if not isinstance(p, dict) or not isinstance(p.get('o'), str) or not re.fullmatch(r'[a-z0-9]+', p['o']):
|
||||
continue
|
||||
value = '' if p.get('v') is None else str(p['v'])
|
||||
custom_params.append('--' + p['o'] if value == '' else '--' + p['o'] + '=' + value)
|
||||
|
||||
# Effective per-process bandwidth cap (bytes/s): per-job value, capped by the global limit
|
||||
eff_bw = num(j['maxbytespersecond'])
|
||||
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']
|
||||
if num(j['timeout1']) > 0:
|
||||
cmd += ['--timeout1', str(j['timeout1'])]
|
||||
if num(j['timeout2']) > 0:
|
||||
cmd += ['--timeout2', str(j['timeout2'])]
|
||||
if j['exclude']:
|
||||
cmd += ['--exclude', j['exclude']]
|
||||
if j['subfolder2']:
|
||||
cmd += ['--subfolder2', j['subfolder2']]
|
||||
if num(j['maxage']) != 0:
|
||||
cmd += ['--maxage', str(j['maxage'])]
|
||||
if eff_bw > 0:
|
||||
cmd += ['--maxbytespersecond', str(eff_bw)]
|
||||
for col, opt in (('delete2duplicates', '--delete2duplicates'), ('subscribeall', '--subscribeall'),
|
||||
('delete1', '--delete'), ('delete2', '--delete2'), ('automap', '--automap'),
|
||||
('skipcrossduplicates', '--skipcrossduplicates')):
|
||||
if j[col] == 1:
|
||||
cmd.append(opt)
|
||||
enc1 = {'TLS': '--tls1', 'SSL': '--ssl1'}.get(j['enc1'])
|
||||
if enc1:
|
||||
cmd.append(enc1)
|
||||
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')]
|
||||
if j['dry'] == 1:
|
||||
cmd.append('--dry')
|
||||
cmd += ['--no-modulesversion', '--noreleasecheck'] + custom_params
|
||||
|
||||
return {'id': j['id'], 'files': files, 'outfile': tmpfile(''), 'cmd': cmd}
|
||||
|
||||
# Worker: imapsync with output to a file, no inherited fds (DB socket, lock)
|
||||
def start_job(job):
|
||||
with open(job['outfile'], 'wb') as out:
|
||||
return subprocess.Popen(job['cmd'], stdin=subprocess.DEVNULL, stdout=out, stderr=subprocess.STDOUT)
|
||||
|
||||
# Store the result of a finished worker and remove its temp files
|
||||
def reap_job(job):
|
||||
stdout = b''
|
||||
try:
|
||||
with open(job['outfile'], 'rb') as f:
|
||||
size = os.fstat(f.fileno()).st_size
|
||||
if size > max_output:
|
||||
f.seek(size - max_output)
|
||||
stdout = b'[output truncated, last %d bytes]\n' % max_output
|
||||
stdout += f.read()
|
||||
except OSError:
|
||||
pass
|
||||
unlink(job['files'])
|
||||
text = stdout.decode('utf-8', 'replace')
|
||||
# Last match: the echoed command line at the top may contain user controlled values
|
||||
m = re.findall(r'Exiting\swith\sreturn\svalue\s(\d+)\s\(([^:)]+)', text)
|
||||
exit_code, exit_status = m[-1] if m else (None, None)
|
||||
if exit_code is None:
|
||||
text = 'Could not start or finish imapsync\n' + text
|
||||
finish_job(job['id'], text, 1 if exit_code is not None and int(exit_code) == 0 else 0, exit_status)
|
||||
|
||||
# Stop claiming new jobs, pass the signal on to the running imapsync processes
|
||||
def stop(signum, frame):
|
||||
global stopping
|
||||
stopping = True
|
||||
for proc in list(running):
|
||||
proc.send_signal(signal.SIGTERM)
|
||||
|
||||
# Released by the kernel when the process ends, a crashed run leaves no stale lock
|
||||
lock_fd = os.open(lock_file, os.O_RDWR | os.O_CREAT, 0o600)
|
||||
try:
|
||||
fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError:
|
||||
sys.exit("can't lock " + lock_file)
|
||||
|
||||
if imapsync_running():
|
||||
print('imapsync is active, exiting...')
|
||||
sys.exit(0)
|
||||
|
||||
for sig in (signal.SIGINT, signal.SIGTERM, signal.SIGQUIT):
|
||||
signal.signal(sig, stop)
|
||||
|
||||
conn = connect()
|
||||
# Stale flags from crashed runs, safe only here: under the lock and with no imapsync running
|
||||
query("UPDATE imapsync SET is_running = 0")
|
||||
|
||||
with open('/etc/sogo/sieve.creds') as f:
|
||||
master_user, master_pass = [s.strip() for s in f.readline().split(':', 1)]
|
||||
|
||||
# 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)
|
||||
try:
|
||||
while True:
|
||||
for proc, job in list(running.items()):
|
||||
if proc.poll() is not None:
|
||||
del running[proc]
|
||||
reap_job(job)
|
||||
|
||||
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()
|
||||
if not j:
|
||||
break
|
||||
if query(claim_sql, (j['id'],)).rowcount != 1:
|
||||
continue
|
||||
|
||||
files = []
|
||||
try:
|
||||
job = prepare_job(j, global_bw, files)
|
||||
except Exception as e:
|
||||
print('imapsync job %d: %s' % (j['id'], e), file=sys.stderr)
|
||||
finish_job(j['id'], 'Could not start or finish imapsync', 0, None)
|
||||
job = None
|
||||
if not job:
|
||||
unlink(files)
|
||||
continue
|
||||
try:
|
||||
running[start_job(job)] = job
|
||||
except OSError as e:
|
||||
# Release the claim, the job is picked up again later
|
||||
print('imapsync job %d: %s' % (j['id'], e), file=sys.stderr)
|
||||
unlink(files)
|
||||
query("UPDATE imapsync SET is_running = 0 WHERE id = %s", (j['id'],))
|
||||
break
|
||||
|
||||
if not running:
|
||||
break
|
||||
# A worker exit ends the wait early
|
||||
for _ in range(50):
|
||||
if any(proc.poll() is not None for proc in running):
|
||||
break
|
||||
time.sleep(0.2)
|
||||
finally:
|
||||
# Never leave password/token files behind
|
||||
for job in running.values():
|
||||
unlink(job['files'])
|
||||
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
require_once(__DIR__ . '/../web/inc/vars.inc.php');
|
||||
if (file_exists(__DIR__ . '/../web/inc/vars.local.inc.php')) {
|
||||
include_once(__DIR__ . '/../web/inc/vars.local.inc.php');
|
||||
}
|
||||
require_once __DIR__ . '/../web/inc/lib/vendor/autoload.php';
|
||||
|
||||
$dsn = $database_type . ":unix_socket=" . $database_sock . ";dbname=" . $database_name;
|
||||
$opt = [
|
||||
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||
PDO::ATTR_EMULATE_PREPARES => false,
|
||||
];
|
||||
try {
|
||||
$pdo = new PDO($dsn, $database_user, $database_pass, $opt);
|
||||
} catch (PDOException $e) {
|
||||
fwrite(STDERR, "DB connect failed: " . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$redis = new Redis();
|
||||
try {
|
||||
if (!empty(getenv('REDIS_SLAVEOF_IP'))) {
|
||||
$redis->connect(getenv('REDIS_SLAVEOF_IP'), getenv('REDIS_SLAVEOF_PORT'));
|
||||
} else {
|
||||
$redis->connect('redis-mailcow', 6379);
|
||||
}
|
||||
$redis->auth(getenv("REDISPASS"));
|
||||
} catch (Exception $e) {
|
||||
fwrite(STDERR, "Redis connect failed: " . $e->getMessage() . "\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
function logMsg($priority, $message, $task = "imapsync OAuth refresh") {
|
||||
global $redis;
|
||||
$redis->lPush('CRON_LOG', json_encode(array(
|
||||
"time" => time(),
|
||||
"priority" => $priority,
|
||||
"task" => $task,
|
||||
"message" => $message,
|
||||
)));
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../web/inc/functions.inc.php';
|
||||
require_once __DIR__ . '/../web/inc/functions.auth.inc.php';
|
||||
require_once __DIR__ . '/../web/inc/sessions.inc.php';
|
||||
require_once __DIR__ . '/../web/inc/functions.mailbox.inc.php';
|
||||
|
||||
// File-lock to prevent concurrent runs
|
||||
$lock_file = '/tmp/imapsync-oauth-refresh.lock';
|
||||
if (file_exists($lock_file)) {
|
||||
$pid = (int)trim(@file_get_contents($lock_file));
|
||||
if ($pid > 0 && posix_kill($pid, 0)) {
|
||||
logMsg("info", "Previous refresh still running (pid=$pid), exiting");
|
||||
exit(0);
|
||||
}
|
||||
@unlink($lock_file);
|
||||
}
|
||||
file_put_contents($lock_file, getmypid());
|
||||
|
||||
try {
|
||||
// client_credentials source tokens, unused per-user tokens (1 day grace), authorization_code per-user tokens;
|
||||
// failing tokens are retried 15 minutes after their last attempt (C2), errors are sanitized
|
||||
$imapsync = \Mailcow\Imapsync\ImapsyncModule::create($pdo, \Mailcow\Core\Context::system(), [
|
||||
'hostname' => (string)getenv('MAILCOW_HOSTNAME'),
|
||||
]);
|
||||
foreach ($imapsync->oauth->refreshDue() as $e) {
|
||||
logMsg($e['priority'], $e['message']);
|
||||
}
|
||||
} finally {
|
||||
@unlink($lock_file);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ $_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
|
||||
|
||||
|
||||
|
||||
$js_minifier->add('/web/js/site/imapsync.js');
|
||||
$js_minifier->add('/web/js/site/mailbox.js');
|
||||
$js_minifier->add('/web/js/presets/sieveMailbox.js');
|
||||
$js_minifier->add('/web/js/site/pwgen.js');
|
||||
@@ -42,6 +43,7 @@ $template_data = [
|
||||
'lang_rl' => json_encode($lang['ratelimit']),
|
||||
'lang_edit' => json_encode($lang['edit']),
|
||||
'lang_datatables' => json_encode($lang['datatables']),
|
||||
'imapsync_redirect_uri' => \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->oauth->redirectUri(),
|
||||
];
|
||||
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
|
||||
@@ -118,6 +118,7 @@ $template_data = [
|
||||
'ui_themes' => customize('get', 'ui_themes'),
|
||||
'ui_theme' => customize('get', 'ui_theme'),
|
||||
'password_complexity' => password_complexity('get'),
|
||||
'imapsync_settings' => \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getSettings(),
|
||||
'show_rspamd_global_filters' => @$_SESSION['show_rspamd_global_filters'],
|
||||
'cors_settings' => $cors_settings,
|
||||
'is_https' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on',
|
||||
|
||||
+1004
-158
File diff suppressed because it is too large.
Load diff
@@ -9,6 +9,7 @@ $_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
|
||||
|
||||
|
||||
|
||||
$js_minifier->add('/web/js/site/imapsync.js');
|
||||
$js_minifier->add('/web/js/site/mailbox.js');
|
||||
$js_minifier->add('/web/js/presets/sieveMailbox.js');
|
||||
$js_minifier->add('/web/js/site/pwgen.js');
|
||||
@@ -42,6 +43,7 @@ $template_data = [
|
||||
'lang_rl' => json_encode($lang['ratelimit']),
|
||||
'lang_edit' => json_encode($lang['edit']),
|
||||
'lang_datatables' => json_encode($lang['datatables']),
|
||||
'imapsync_redirect_uri' => \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->oauth->redirectUri(),
|
||||
];
|
||||
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
|
||||
+9
-1
@@ -204,9 +204,15 @@ if (isset($_SESSION['mailcow_cc_role'])) {
|
||||
if (isset($_GET['syncjob']) &&
|
||||
is_numeric($_GET['syncjob'])) {
|
||||
$id = $_GET["syncjob"];
|
||||
$result = mailbox('get', 'syncjob_details', $id);
|
||||
$result = \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getJob($id);
|
||||
$template = 'edit/syncjob.twig';
|
||||
}
|
||||
elseif (isset($_GET['syncjob_source']) &&
|
||||
is_numeric($_GET['syncjob_source'])) {
|
||||
$id = $_GET["syncjob_source"];
|
||||
$result = \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getSource($id);
|
||||
$template = 'edit/imapsync_source.twig';
|
||||
}
|
||||
elseif (isset($_GET['filter']) &&
|
||||
is_numeric($_GET['filter'])) {
|
||||
$id = $_GET["filter"];
|
||||
@@ -225,6 +231,7 @@ else {
|
||||
$template_data['access_denied'] = true;
|
||||
}
|
||||
|
||||
$js_minifier->add('/web/js/site/imapsync.js');
|
||||
$js_minifier->add('/web/js/site/edit.js');
|
||||
$js_minifier->add('/web/js/site/pwgen.js');
|
||||
|
||||
@@ -233,5 +240,6 @@ $template_data['return_to'] = $_SESSION['return_to'];
|
||||
$template_data['lang_user'] = json_encode($lang['user']);
|
||||
$template_data['lang_admin'] = json_encode($lang['admin']);
|
||||
$template_data['lang_datatables'] = json_encode($lang['datatables']);
|
||||
$template_data['imapsync_redirect_uri'] = \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->oauth->redirectUri();
|
||||
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
|
||||
@@ -6,7 +6,7 @@ if (!isset($_SESSION['mailcow_cc_role'])) {
|
||||
}
|
||||
|
||||
if (isset($_GET['id']) && is_numeric($_GET['id'])) {
|
||||
if ($details = mailbox('get', 'syncjob_details', intval($_GET['id']))) {
|
||||
if ($details = \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getJob(intval($_GET['id']))) {
|
||||
echo (empty($details['log'])) ? '-' : $details['log'];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,11 +33,11 @@ if (array_key_exists('pending_tfa_methods', $_SESSION)) {
|
||||
if (isset($pending_tfa_authmechs['webauthn'])) {
|
||||
$pending_tfa_authmechs['webauthn'] = true;
|
||||
}
|
||||
if (!isset($pending_tfa_authmechs['webauthn'])
|
||||
if (!isset($pending_tfa_authmechs['webauthn'])
|
||||
&& isset($pending_tfa_authmechs['yubi_otp'])) {
|
||||
$pending_tfa_authmechs['yubi_otp'] = true;
|
||||
}
|
||||
if (!isset($pending_tfa_authmechs['webauthn'])
|
||||
if (!isset($pending_tfa_authmechs['webauthn'])
|
||||
&& !isset($pending_tfa_authmechs['yubi_otp'])
|
||||
&& isset($pending_tfa_authmechs['totp'])) {
|
||||
$pending_tfa_authmechs['totp'] = true;
|
||||
@@ -72,6 +72,7 @@ $globalVariables = [
|
||||
'lang_fido2' => json_encode($lang['fido2']),
|
||||
'lang_success' => json_encode($lang['success']),
|
||||
'lang_danger' => json_encode($lang['danger']),
|
||||
'lang_syncjobs' => json_encode($lang['syncjobs']),
|
||||
'docker_timeout' => $DOCKER_TIMEOUT,
|
||||
'session_lifetime' => (int)$SESSION_LIFETIME,
|
||||
'csrf_token' => $_SESSION['CSRF']['TOKEN'],
|
||||
|
||||
@@ -103,7 +103,7 @@ function admin($_action, $_data = null) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!ctype_alnum(str_replace(array('_', '.', '-'), '', $username_new))) {
|
||||
if (!ctype_alnum(str_replace(array('_', '.', '-'), '', $username_new)) || $username_new == 'API') {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_data_log),
|
||||
@@ -179,6 +179,9 @@ function admin($_action, $_data = null) {
|
||||
$stmt = $pdo->prepare("UPDATE `tfa` SET `username` = :username_new WHERE `username` = :username");
|
||||
$stmt->execute(array(':username_new' => $username_new, ':username' => $username));
|
||||
}
|
||||
if ($username_new != $username) {
|
||||
\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->accountChanged($username, $username_new);
|
||||
}
|
||||
}
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'success',
|
||||
@@ -211,6 +214,7 @@ function admin($_action, $_data = null) {
|
||||
$stmt->execute(array(
|
||||
':username' => $username,
|
||||
));
|
||||
\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->accountChanged($username);
|
||||
$stmt = $pdo->prepare("DELETE FROM `domain_admins` WHERE `username` = :username");
|
||||
$stmt->execute(array(
|
||||
':username' => $username,
|
||||
|
||||
@@ -156,7 +156,7 @@ function domain_admin($_action, $_data = null) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!ctype_alnum(str_replace(array('_', '.', '-'), '', $username_new))) {
|
||||
if (!ctype_alnum(str_replace(array('_', '.', '-'), '', $username_new)) || $username_new == 'API') {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_data_log),
|
||||
@@ -183,6 +183,9 @@ function domain_admin($_action, $_data = null) {
|
||||
':username_new' => $username_new,
|
||||
':username' => $username
|
||||
));
|
||||
if ($username_new != $username) {
|
||||
\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->accountChanged($username, $username_new);
|
||||
}
|
||||
if (!empty($domains)) {
|
||||
foreach ($domains as $domain) {
|
||||
$stmt = $pdo->prepare("INSERT INTO `domain_admins` (`username`, `domain`, `created`, `active`)
|
||||
@@ -331,6 +334,8 @@ function domain_admin($_action, $_data = null) {
|
||||
$stmt->execute(array(
|
||||
':username' => $username,
|
||||
));
|
||||
// sync sources created by this account must not pass to a re-created account of the same name
|
||||
\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->accountChanged($username);
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'success',
|
||||
'log' => array(__FUNCTION__, $_action, $_data_log),
|
||||
|
||||
@@ -302,192 +302,6 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
'msg' => array('mailbox_modified', $username)
|
||||
);
|
||||
break;
|
||||
case 'syncjob':
|
||||
if (!isset($_SESSION['acl']['syncjobs']) || $_SESSION['acl']['syncjobs'] != "1" ) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (isset($_data['username']) && filter_var($_data['username'], FILTER_VALIDATE_EMAIL)) {
|
||||
if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $_data['username'])) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
else {
|
||||
$username = $_data['username'];
|
||||
}
|
||||
}
|
||||
elseif ($_SESSION['mailcow_cc_role'] == "user") {
|
||||
$username = $_SESSION['mailcow_cc_username'];
|
||||
}
|
||||
else {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'no_user_defined'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
$active = intval($_data['active']);
|
||||
$subscribeall = intval($_data['subscribeall']);
|
||||
$delete2duplicates = intval($_data['delete2duplicates']);
|
||||
$delete1 = intval($_data['delete1']);
|
||||
$delete2 = intval($_data['delete2']);
|
||||
$timeout1 = intval($_data['timeout1']);
|
||||
$timeout2 = intval($_data['timeout2']);
|
||||
$skipcrossduplicates = intval($_data['skipcrossduplicates']);
|
||||
$automap = intval($_data['automap']);
|
||||
$dry = intval($_data['dry']);
|
||||
$port1 = $_data['port1'];
|
||||
$host1 = strtolower($_data['host1']);
|
||||
$password1 = $_data['password1'];
|
||||
$exclude = $_data['exclude'];
|
||||
$maxage = $_data['maxage'];
|
||||
$maxbytespersecond = $_data['maxbytespersecond'];
|
||||
$subfolder2 = $_data['subfolder2'];
|
||||
$user1 = $_data['user1'];
|
||||
$mins_interval = $_data['mins_interval'];
|
||||
$enc1 = $_data['enc1'];
|
||||
$custom_params = (empty(trim($_data['custom_params']))) ? '' : trim($_data['custom_params']);
|
||||
|
||||
// validate custom params
|
||||
foreach (explode('-', $custom_params) as $param){
|
||||
if(empty($param)) continue;
|
||||
|
||||
// extract option
|
||||
if (str_contains($param, '=')) $param = explode('=', $param)[0];
|
||||
else $param = rtrim($param, ' ');
|
||||
// remove first char if first char is -
|
||||
if ($param[0] == '-') $param = ltrim($param, $param[0]);
|
||||
|
||||
if (str_contains($param, ' ')) {
|
||||
// bad char
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'bad character SPACE'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// check if param is whitelisted
|
||||
if (!in_array(strtolower($param), $GLOBALS["IMAPSYNC_OPTIONS"]["whitelist"])){
|
||||
// bad option
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'bad option '. $param
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (empty($subfolder2)) {
|
||||
$subfolder2 = "";
|
||||
}
|
||||
if (!isset($maxage) || !filter_var($maxage, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 32000)))) {
|
||||
$maxage = "0";
|
||||
}
|
||||
if (!isset($timeout1) || !filter_var($timeout1, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 32000)))) {
|
||||
$timeout1 = "600";
|
||||
}
|
||||
if (!isset($timeout2) || !filter_var($timeout2, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 32000)))) {
|
||||
$timeout2 = "600";
|
||||
}
|
||||
if (!isset($maxbytespersecond) || !filter_var($maxbytespersecond, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 125000000)))) {
|
||||
$maxbytespersecond = "0";
|
||||
}
|
||||
if (!filter_var($port1, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 65535)))) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (!filter_var($mins_interval, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 43800)))) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
// if (!is_valid_domain_name($host1)) {
|
||||
// $_SESSION['return'][] = array(
|
||||
// 'type' => 'danger',
|
||||
// 'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
// 'msg' => 'access_denied'
|
||||
// );
|
||||
// return false;
|
||||
// }
|
||||
if ($enc1 != "TLS" && $enc1 != "SSL" && $enc1 != "PLAIN") {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
if (@preg_match("/" . $exclude . "/", null) === false) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
$stmt = $pdo->prepare("SELECT '1' FROM `imapsync`
|
||||
WHERE `user2` = :user2 AND `user1` = :user1 AND `host1` = :host1");
|
||||
$stmt->execute(array(':user1' => $user1, ':user2' => $username, ':host1' => $host1));
|
||||
$num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
if ($num_results != 0) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => array('object_exists', htmlspecialchars($host1 . ' / ' . $user1))
|
||||
);
|
||||
return false;
|
||||
}
|
||||
$stmt = $pdo->prepare("INSERT INTO `imapsync` (`user2`, `exclude`, `delete1`, `delete2`, `timeout1`, `timeout2`, `automap`, `skipcrossduplicates`, `maxbytespersecond`, `subscribeall`, `dry`, `maxage`, `subfolder2`, `host1`, `authmech1`, `user1`, `password1`, `mins_interval`, `port1`, `enc1`, `delete2duplicates`, `custom_params`, `active`)
|
||||
VALUES (:user2, :exclude, :delete1, :delete2, :timeout1, :timeout2, :automap, :skipcrossduplicates, :maxbytespersecond, :subscribeall, :dry, :maxage, :subfolder2, :host1, :authmech1, :user1, :password1, :mins_interval, :port1, :enc1, :delete2duplicates, :custom_params, :active)");
|
||||
$stmt->execute(array(
|
||||
':user2' => $username,
|
||||
':custom_params' => $custom_params,
|
||||
':exclude' => $exclude,
|
||||
':maxage' => $maxage,
|
||||
':delete1' => $delete1,
|
||||
':delete2' => $delete2,
|
||||
':timeout1' => $timeout1,
|
||||
':timeout2' => $timeout2,
|
||||
':automap' => $automap,
|
||||
':skipcrossduplicates' => $skipcrossduplicates,
|
||||
':maxbytespersecond' => $maxbytespersecond,
|
||||
':subscribeall' => $subscribeall,
|
||||
':dry' => $dry,
|
||||
':subfolder2' => $subfolder2,
|
||||
':host1' => $host1,
|
||||
':authmech1' => 'PLAIN',
|
||||
':user1' => $user1,
|
||||
':password1' => $password1,
|
||||
':mins_interval' => $mins_interval,
|
||||
':port1' => $port1,
|
||||
':enc1' => $enc1,
|
||||
':delete2duplicates' => $delete2duplicates,
|
||||
':active' => $active,
|
||||
));
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'success',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => array('mailbox_modified', $username)
|
||||
);
|
||||
break;
|
||||
case 'domain':
|
||||
if ($_SESSION['mailcow_cc_role'] != "admin") {
|
||||
$_SESSION['return'][] = array(
|
||||
@@ -2280,202 +2094,6 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'syncjob':
|
||||
if (!is_array($_data['id'])) {
|
||||
$ids = array();
|
||||
$ids[] = $_data['id'];
|
||||
}
|
||||
else {
|
||||
$ids = $_data['id'];
|
||||
}
|
||||
if (!isset($_SESSION['acl']['syncjobs']) || $_SESSION['acl']['syncjobs'] != "1" ) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
foreach ($ids as $id) {
|
||||
$is_now = mailbox('get', 'syncjob_details', $id, array('with_password'));
|
||||
if (!empty($is_now)) {
|
||||
$username = $is_now['user2'];
|
||||
$user1 = (!empty($_data['user1'])) ? $_data['user1'] : $is_now['user1'];
|
||||
$active = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active'];
|
||||
$last_run = (isset($_data['last_run'])) ? NULL : $is_now['last_run'];
|
||||
$success = (isset($_data['success'])) ? NULL : $is_now['success'];
|
||||
$delete2duplicates = (isset($_data['delete2duplicates'])) ? intval($_data['delete2duplicates']) : $is_now['delete2duplicates'];
|
||||
$subscribeall = (isset($_data['subscribeall'])) ? intval($_data['subscribeall']) : $is_now['subscribeall'];
|
||||
$dry = (isset($_data['dry'])) ? intval($_data['dry']) : $is_now['dry'];
|
||||
$delete1 = (isset($_data['delete1'])) ? intval($_data['delete1']) : $is_now['delete1'];
|
||||
$delete2 = (isset($_data['delete2'])) ? intval($_data['delete2']) : $is_now['delete2'];
|
||||
$automap = (isset($_data['automap'])) ? intval($_data['automap']) : $is_now['automap'];
|
||||
$skipcrossduplicates = (isset($_data['skipcrossduplicates'])) ? intval($_data['skipcrossduplicates']) : $is_now['skipcrossduplicates'];
|
||||
$port1 = (!empty($_data['port1'])) ? $_data['port1'] : $is_now['port1'];
|
||||
$password1 = (!empty($_data['password1'])) ? $_data['password1'] : $is_now['password1'];
|
||||
$host1 = (!empty($_data['host1'])) ? $_data['host1'] : $is_now['host1'];
|
||||
$subfolder2 = (isset($_data['subfolder2'])) ? $_data['subfolder2'] : $is_now['subfolder2'];
|
||||
$enc1 = (!empty($_data['enc1'])) ? $_data['enc1'] : $is_now['enc1'];
|
||||
$mins_interval = (!empty($_data['mins_interval'])) ? $_data['mins_interval'] : $is_now['mins_interval'];
|
||||
$exclude = (isset($_data['exclude'])) ? $_data['exclude'] : $is_now['exclude'];
|
||||
$custom_params = (isset($_data['custom_params'])) ? $_data['custom_params'] : $is_now['custom_params'];
|
||||
$maxage = (isset($_data['maxage']) && $_data['maxage'] != "") ? intval($_data['maxage']) : $is_now['maxage'];
|
||||
$maxbytespersecond = (isset($_data['maxbytespersecond']) && $_data['maxbytespersecond'] != "") ? intval($_data['maxbytespersecond']) : $is_now['maxbytespersecond'];
|
||||
$timeout1 = (isset($_data['timeout1']) && $_data['timeout1'] != "") ? intval($_data['timeout1']) : $is_now['timeout1'];
|
||||
$timeout2 = (isset($_data['timeout2']) && $_data['timeout2'] != "") ? intval($_data['timeout2']) : $is_now['timeout2'];
|
||||
}
|
||||
else {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// validate custom params
|
||||
foreach (explode('-', $custom_params) as $param){
|
||||
if(empty($param)) continue;
|
||||
|
||||
// extract option
|
||||
if (str_contains($param, '=')) $param = explode('=', $param)[0];
|
||||
else $param = rtrim($param, ' ');
|
||||
// remove first char if first char is -
|
||||
if ($param[0] == '-') $param = ltrim($param, $param[0]);
|
||||
|
||||
if (str_contains($param, ' ')) {
|
||||
// bad char
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'bad character SPACE'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// check if param is whitelisted
|
||||
if (!in_array(strtolower($param), $GLOBALS["IMAPSYNC_OPTIONS"]["whitelist"])){
|
||||
// bad option
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'bad option '. $param
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (empty($subfolder2)) {
|
||||
$subfolder2 = "";
|
||||
}
|
||||
if (!isset($maxage) || !filter_var($maxage, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 32000)))) {
|
||||
$maxage = "0";
|
||||
}
|
||||
if (!isset($timeout1) || !filter_var($timeout1, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 32000)))) {
|
||||
$timeout1 = "600";
|
||||
}
|
||||
if (!isset($timeout2) || !filter_var($timeout2, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 32000)))) {
|
||||
$timeout2 = "600";
|
||||
}
|
||||
if (!isset($maxbytespersecond) || !filter_var($maxbytespersecond, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 125000000)))) {
|
||||
$maxbytespersecond = "0";
|
||||
}
|
||||
if (!filter_var($port1, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 65535)))) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!filter_var($mins_interval, FILTER_VALIDATE_INT, array('options' => array('min_range' => 1, 'max_range' => 43800)))) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (!is_valid_domain_name($host1)) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if ($enc1 != "TLS" && $enc1 != "SSL" && $enc1 != "PLAIN") {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (@preg_match("/" . $exclude . "/", null) === false) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
$stmt = $pdo->prepare("UPDATE `imapsync` SET `delete1` = :delete1,
|
||||
`delete2` = :delete2,
|
||||
`automap` = :automap,
|
||||
`skipcrossduplicates` = :skipcrossduplicates,
|
||||
`maxage` = :maxage,
|
||||
`maxbytespersecond` = :maxbytespersecond,
|
||||
`subfolder2` = :subfolder2,
|
||||
`exclude` = :exclude,
|
||||
`host1` = :host1,
|
||||
`last_run` = :last_run,
|
||||
`success` = :success,
|
||||
`user1` = :user1,
|
||||
`password1` = :password1,
|
||||
`mins_interval` = :mins_interval,
|
||||
`port1` = :port1,
|
||||
`enc1` = :enc1,
|
||||
`delete2duplicates` = :delete2duplicates,
|
||||
`custom_params` = :custom_params,
|
||||
`timeout1` = :timeout1,
|
||||
`timeout2` = :timeout2,
|
||||
`subscribeall` = :subscribeall,
|
||||
`dry` = :dry,
|
||||
`active` = :active
|
||||
WHERE `id` = :id");
|
||||
$stmt->execute(array(
|
||||
':delete1' => $delete1,
|
||||
':delete2' => $delete2,
|
||||
':automap' => $automap,
|
||||
':skipcrossduplicates' => $skipcrossduplicates,
|
||||
':id' => $id,
|
||||
':exclude' => $exclude,
|
||||
':maxage' => $maxage,
|
||||
':maxbytespersecond' => $maxbytespersecond,
|
||||
':subfolder2' => $subfolder2,
|
||||
':host1' => $host1,
|
||||
':user1' => $user1,
|
||||
':password1' => $password1,
|
||||
':last_run' => $last_run,
|
||||
':success' => $success,
|
||||
':mins_interval' => $mins_interval,
|
||||
':port1' => $port1,
|
||||
':enc1' => $enc1,
|
||||
':delete2duplicates' => $delete2duplicates,
|
||||
':custom_params' => $custom_params,
|
||||
':timeout1' => $timeout1,
|
||||
':timeout2' => $timeout2,
|
||||
':subscribeall' => $subscribeall,
|
||||
':dry' => $dry,
|
||||
':active' => $active,
|
||||
));
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'success',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => array('mailbox_modified', $username)
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'filter':
|
||||
if (!isset($_SESSION['acl']['filters']) || $_SESSION['acl']['filters'] != "1" ) {
|
||||
$_SESSION['return'][] = array(
|
||||
@@ -3652,6 +3270,9 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
}
|
||||
}
|
||||
|
||||
// Sync sources created by / targeting the mailbox and its OAuth tokens (owner); a PDOException rolls back below
|
||||
\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->sources->onAccountRenamed($old_username, $new_username);
|
||||
|
||||
// Update c_uid, c_name and mail in _sogo_static_view table
|
||||
$pdo->prepare("UPDATE _sogo_static_view SET c_uid = :new_username, c_name = :new_username2, mail = :new_username3 WHERE c_uid = :old_username")
|
||||
->execute([
|
||||
@@ -4603,68 +4224,6 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
}
|
||||
return false;
|
||||
break;
|
||||
case 'syncjob_details':
|
||||
$syncjobdetails = array();
|
||||
if (!is_numeric($_data)) {
|
||||
return false;
|
||||
}
|
||||
if (isset($_extra) && in_array('no_log', $_extra)) {
|
||||
$field_query = $pdo->query('SHOW FIELDS FROM `imapsync` WHERE FIELD NOT IN ("returned_text", "password1")');
|
||||
$fields = $field_query->fetchAll(PDO::FETCH_ASSOC);
|
||||
while($field = array_shift($fields)) {
|
||||
$shown_fields[] = $field['Field'];
|
||||
}
|
||||
$stmt = $pdo->prepare("SELECT " . implode(',', (array)$shown_fields) . ",
|
||||
`active`
|
||||
FROM `imapsync` WHERE id = :id");
|
||||
}
|
||||
elseif (isset($_extra) && in_array('with_password', $_extra)) {
|
||||
$stmt = $pdo->prepare("SELECT *,
|
||||
`active`
|
||||
FROM `imapsync` WHERE id = :id");
|
||||
}
|
||||
else {
|
||||
$field_query = $pdo->query('SHOW FIELDS FROM `imapsync` WHERE FIELD NOT IN ("password1")');
|
||||
$fields = $field_query->fetchAll(PDO::FETCH_ASSOC);
|
||||
while($field = array_shift($fields)) {
|
||||
$shown_fields[] = $field['Field'];
|
||||
}
|
||||
$stmt = $pdo->prepare("SELECT " . implode(',', (array)$shown_fields) . ",
|
||||
`active`
|
||||
FROM `imapsync` WHERE id = :id");
|
||||
}
|
||||
$stmt->execute(array(':id' => $_data));
|
||||
$syncjobdetails = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!empty($syncjobdetails['returned_text'])) {
|
||||
$syncjobdetails['log'] = $syncjobdetails['returned_text'];
|
||||
}
|
||||
else {
|
||||
$syncjobdetails['log'] = '';
|
||||
}
|
||||
unset($syncjobdetails['returned_text']);
|
||||
if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $syncjobdetails['user2'])) {
|
||||
return false;
|
||||
}
|
||||
return $syncjobdetails;
|
||||
break;
|
||||
case 'syncjobs':
|
||||
$syncjobdata = array();
|
||||
if (isset($_data) && filter_var($_data, FILTER_VALIDATE_EMAIL)) {
|
||||
if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $_data)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else {
|
||||
$_data = $_SESSION['mailcow_cc_username'];
|
||||
}
|
||||
$stmt = $pdo->prepare("SELECT `id` FROM `imapsync` WHERE `user2` = :username");
|
||||
$stmt->execute(array(':username' => $_data));
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
while($row = array_shift($rows)) {
|
||||
$syncjobdata[] = $row['id'];
|
||||
}
|
||||
return $syncjobdata;
|
||||
break;
|
||||
case 'spam_score':
|
||||
$curl = curl_init();
|
||||
curl_setopt($curl, CURLOPT_UNIX_SOCKET_PATH, '/var/lib/rspamd/rspamd.sock');
|
||||
@@ -5494,46 +5053,6 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
break;
|
||||
case 'delete':
|
||||
switch ($_type) {
|
||||
case 'syncjob':
|
||||
if (!is_array($_data['id'])) {
|
||||
$ids = array();
|
||||
$ids[] = $_data['id'];
|
||||
}
|
||||
else {
|
||||
$ids = $_data['id'];
|
||||
}
|
||||
if (!isset($_SESSION['acl']['syncjobs']) || $_SESSION['acl']['syncjobs'] != "1" ) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
return false;
|
||||
}
|
||||
foreach ($ids as $id) {
|
||||
if (!is_numeric($id)) {
|
||||
return false;
|
||||
}
|
||||
$stmt = $pdo->prepare("SELECT `user2` FROM `imapsync` WHERE id = :id");
|
||||
$stmt->execute(array(':id' => $id));
|
||||
$user2 = $stmt->fetch(PDO::FETCH_ASSOC)['user2'];
|
||||
if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $user2)) {
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'danger',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => 'access_denied'
|
||||
);
|
||||
continue;
|
||||
}
|
||||
$stmt = $pdo->prepare("DELETE FROM `imapsync` WHERE `id`= :id");
|
||||
$stmt->execute(array(':id' => $id));
|
||||
$_SESSION['return'][] = array(
|
||||
'type' => 'success',
|
||||
'log' => array(__FUNCTION__, $_action, $_type, $_data_log, $_attr),
|
||||
'msg' => array('deleted_syncjob', $id)
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'filter':
|
||||
if (!is_array($_data['id'])) {
|
||||
$ids = array();
|
||||
@@ -5764,6 +5283,10 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
$stmt->execute(array(
|
||||
':domain' => $domain,
|
||||
));
|
||||
$stmt = $pdo->prepare("DELETE FROM `imapsync_source_domain` WHERE `domain` = :domain");
|
||||
$stmt->execute(array(
|
||||
':domain' => $domain,
|
||||
));
|
||||
$stmt = $pdo->prepare("DELETE FROM `domain_admins` WHERE `domain` = :domain");
|
||||
$stmt->execute(array(
|
||||
':domain' => $domain,
|
||||
@@ -6071,6 +5594,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
||||
$stmt->execute(array(
|
||||
':username' => $username
|
||||
));
|
||||
// Tokens, scope targets and own sync sources of the mailbox
|
||||
\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->accountChanged($username);
|
||||
$stmt = $pdo->prepare("DELETE FROM `filterconf` WHERE `object` = :username");
|
||||
$stmt->execute(array(
|
||||
':username' => $username
|
||||
|
||||
+293
-19
@@ -4,7 +4,7 @@ function init_db_schema()
|
||||
try {
|
||||
global $pdo;
|
||||
|
||||
$db_version = "18082026_1200";
|
||||
$db_version = "16102026_1200";
|
||||
|
||||
$stmt = $pdo->query("SHOW TABLES LIKE 'versions'");
|
||||
$num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
@@ -755,35 +755,153 @@ function init_db_schema()
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
),
|
||||
"imapsync_source" => array(
|
||||
"cols" => array(
|
||||
"id" => "INT NOT NULL AUTO_INCREMENT",
|
||||
"name" => "VARCHAR(100) NOT NULL",
|
||||
"description" => "VARCHAR(255) NOT NULL DEFAULT ''",
|
||||
"created_by" => "VARCHAR(255) NOT NULL DEFAULT ''",
|
||||
"scope" => "ENUM('all','domain','user') NOT NULL DEFAULT 'user'",
|
||||
"host1" => "VARCHAR(255) NOT NULL",
|
||||
"port1" => "SMALLINT UNSIGNED NOT NULL",
|
||||
"enc1" => "ENUM('TLS','SSL','PLAIN') DEFAULT 'TLS'",
|
||||
"auth_type" => "ENUM('PLAIN','LOGIN','CRAM-MD5','XOAUTH2') DEFAULT 'PLAIN'",
|
||||
"oauth_flow" => "ENUM('client_credentials','authorization_code') NOT NULL DEFAULT 'client_credentials'",
|
||||
"oauth_token_endpoint" => "VARCHAR(500) DEFAULT NULL",
|
||||
"oauth_authorize_endpoint" => "VARCHAR(500) DEFAULT NULL",
|
||||
"oauth_userinfo_endpoint" => "VARCHAR(500) DEFAULT NULL",
|
||||
"oauth_client_id" => "VARCHAR(500) DEFAULT NULL",
|
||||
"oauth_client_secret" => "VARCHAR(500) DEFAULT NULL",
|
||||
"oauth_scope" => "VARCHAR(500) DEFAULT NULL",
|
||||
"oauth_extra_params" => "TEXT DEFAULT NULL",
|
||||
"oauth_access_token" => "TEXT DEFAULT NULL",
|
||||
"oauth_token_expires" => "BIGINT UNSIGNED DEFAULT NULL",
|
||||
"oauth_last_refresh_error" => "TEXT DEFAULT NULL",
|
||||
"oauth_last_refresh_attempt" => "DATETIME NULL DEFAULT NULL",
|
||||
"created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)",
|
||||
"modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP",
|
||||
"active" => "TINYINT(1) NOT NULL DEFAULT '1'"
|
||||
),
|
||||
"keys" => array(
|
||||
"primary" => array(
|
||||
"" => array("id")
|
||||
),
|
||||
"unique" => array(
|
||||
"uniq_creator_name" => array("created_by", "name")
|
||||
),
|
||||
"key" => array(
|
||||
"idx_created_by" => array("created_by"),
|
||||
"idx_scope" => array("scope")
|
||||
)
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
),
|
||||
"imapsync_source_domain" => array(
|
||||
"cols" => array(
|
||||
"source_id" => "INT NOT NULL",
|
||||
"domain" => "VARCHAR(255) NOT NULL"
|
||||
),
|
||||
"keys" => array(
|
||||
"primary" => array(
|
||||
"" => array("source_id", "domain")
|
||||
),
|
||||
"key" => array(
|
||||
"idx_domain" => array("domain")
|
||||
),
|
||||
"fkey" => array(
|
||||
"fk_imapsync_source_domain" => array(
|
||||
"col" => "source_id",
|
||||
"ref" => "imapsync_source.id",
|
||||
"delete" => "CASCADE",
|
||||
"update" => "NO ACTION"
|
||||
)
|
||||
)
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
),
|
||||
"imapsync_source_user" => array(
|
||||
"cols" => array(
|
||||
"source_id" => "INT NOT NULL",
|
||||
"username" => "VARCHAR(255) NOT NULL"
|
||||
),
|
||||
"keys" => array(
|
||||
"primary" => array(
|
||||
"" => array("source_id", "username")
|
||||
),
|
||||
"key" => array(
|
||||
"idx_username" => array("username")
|
||||
),
|
||||
"fkey" => array(
|
||||
"fk_imapsync_source_user" => array(
|
||||
"col" => "source_id",
|
||||
"ref" => "imapsync_source.id",
|
||||
"delete" => "CASCADE",
|
||||
"update" => "NO ACTION"
|
||||
)
|
||||
)
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
),
|
||||
"imapsync_source_oauth_token" => array(
|
||||
"cols" => array(
|
||||
"source_id" => "INT NOT NULL",
|
||||
"owner" => "VARCHAR(255) NOT NULL",
|
||||
"username" => "VARCHAR(255) NOT NULL",
|
||||
"access_token" => "TEXT DEFAULT NULL",
|
||||
"refresh_token" => "TEXT DEFAULT NULL",
|
||||
"token_expires" => "BIGINT UNSIGNED DEFAULT NULL",
|
||||
"last_refresh_error" => "TEXT DEFAULT NULL",
|
||||
"oauth_last_refresh_attempt" => "DATETIME NULL DEFAULT NULL",
|
||||
"created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)",
|
||||
"modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP"
|
||||
),
|
||||
"keys" => array(
|
||||
"primary" => array(
|
||||
"" => array("source_id", "owner", "username")
|
||||
),
|
||||
"key" => array(
|
||||
"idx_owner" => array("owner")
|
||||
),
|
||||
"fkey" => array(
|
||||
"fk_imapsync_source_oauth_token" => array(
|
||||
"col" => "source_id",
|
||||
"ref" => "imapsync_source.id",
|
||||
"delete" => "CASCADE",
|
||||
"update" => "NO ACTION"
|
||||
)
|
||||
)
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
),
|
||||
"imapsync" => array(
|
||||
"cols" => array(
|
||||
"id" => "INT NOT NULL AUTO_INCREMENT",
|
||||
"user2" => "VARCHAR(255) NOT NULL",
|
||||
"host1" => "VARCHAR(255) NOT NULL",
|
||||
"authmech1" => "ENUM('PLAIN','LOGIN','CRAM-MD5') DEFAULT 'PLAIN'",
|
||||
"source_id" => "INT NOT NULL",
|
||||
"regextrans2" => "VARCHAR(255) DEFAULT ''",
|
||||
"authmd51" => "TINYINT(1) NOT NULL DEFAULT 0",
|
||||
"domain2" => "VARCHAR(255) NOT NULL DEFAULT ''",
|
||||
"subfolder2" => "VARCHAR(255) NOT NULL DEFAULT ''",
|
||||
"user1" => "VARCHAR(255) NOT NULL",
|
||||
"password1" => "VARCHAR(255) NOT NULL",
|
||||
"password1" => "VARCHAR(255) NOT NULL DEFAULT ''",
|
||||
"exclude" => "VARCHAR(500) NOT NULL DEFAULT ''",
|
||||
"maxage" => "SMALLINT NOT NULL DEFAULT '0'",
|
||||
"mins_interval" => "SMALLINT UNSIGNED NOT NULL DEFAULT '0'",
|
||||
"maxbytespersecond" => "VARCHAR(50) NOT NULL DEFAULT '0'",
|
||||
"port1" => "SMALLINT UNSIGNED NOT NULL",
|
||||
"enc1" => "ENUM('TLS','SSL','PLAIN') DEFAULT 'TLS'",
|
||||
"delete2duplicates" => "TINYINT(1) NOT NULL DEFAULT '1'",
|
||||
"delete1" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||
"delete2" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||
"automap" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||
"skipcrossduplicates" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||
"custom_params" => "VARCHAR(512) NOT NULL DEFAULT ''",
|
||||
"custom_params" => "TEXT",
|
||||
// original legacy custom_params string, only set when options were dropped during migration (never used by the runner)
|
||||
"custom_params_legacy" => "TEXT DEFAULT NULL",
|
||||
"timeout1" => "SMALLINT NOT NULL DEFAULT '600'",
|
||||
"timeout2" => "SMALLINT NOT NULL DEFAULT '600'",
|
||||
"subscribeall" => "TINYINT(1) NOT NULL DEFAULT '1'",
|
||||
"dry" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||
"is_running" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||
"prio" => "INT NOT NULL DEFAULT 0",
|
||||
"returned_text" => "LONGTEXT",
|
||||
"last_run" => "TIMESTAMP NULL DEFAULT NULL",
|
||||
"success" => "TINYINT(1) UNSIGNED DEFAULT NULL",
|
||||
@@ -795,6 +913,32 @@ function init_db_schema()
|
||||
"keys" => array(
|
||||
"primary" => array(
|
||||
"" => array("id")
|
||||
),
|
||||
"key" => array(
|
||||
"idx_source_id" => array("source_id"),
|
||||
"idx_prio" => array("prio")
|
||||
),
|
||||
"fkey" => array(
|
||||
"fk_imapsync_source" => array(
|
||||
"col" => "source_id",
|
||||
"ref" => "imapsync_source.id",
|
||||
"delete" => "RESTRICT",
|
||||
"update" => "NO ACTION"
|
||||
)
|
||||
)
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
),
|
||||
"imapsync_settings" => array(
|
||||
"cols" => array(
|
||||
"name" => "VARCHAR(64) NOT NULL",
|
||||
"value" => "VARCHAR(255) NOT NULL",
|
||||
"created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)",
|
||||
"modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP"
|
||||
),
|
||||
"keys" => array(
|
||||
"primary" => array(
|
||||
"" => array("name")
|
||||
)
|
||||
),
|
||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||
@@ -1184,11 +1328,128 @@ function init_db_schema()
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate legacy imapsync jobs (host/port/enc per job) to domain-scoped legacy sources
|
||||
// Re-entrant: only rows with source_id IS NULL are migrated, the legacy columns are kept while any are left
|
||||
if ($table == 'imapsync') {
|
||||
$stmt = $pdo->query("SHOW TABLES LIKE 'imapsync'");
|
||||
if (count($stmt->fetchAll(PDO::FETCH_ASSOC)) != 0) {
|
||||
$imapsync_cols = array_column($pdo->query("SHOW COLUMNS FROM `imapsync`")->fetchAll(PDO::FETCH_ASSOC), 'Field', 'Field');
|
||||
if (isset($imapsync_cols['host1'])) {
|
||||
if (!isset($imapsync_cols['source_id'])) {
|
||||
$pdo->exec("ALTER TABLE `imapsync` ADD COLUMN `source_id` INT DEFAULT NULL");
|
||||
}
|
||||
if (!isset($imapsync_cols['custom_params_legacy'])) {
|
||||
$pdo->exec("ALTER TABLE `imapsync` ADD COLUMN `custom_params_legacy` TEXT DEFAULT NULL");
|
||||
}
|
||||
// Converted JSON may not fit into the old VARCHAR(512)
|
||||
$pdo->exec("ALTER TABLE `imapsync` MODIFY COLUMN `custom_params` TEXT");
|
||||
$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");
|
||||
$source_ins = $pdo->prepare("INSERT IGNORE INTO `imapsync_source` (`name`, `description`, `created_by`, `scope`, `host1`, `port1`, `enc1`, `auth_type`, `active`)
|
||||
VALUES (:name, '', '', 'domain', :host, :port, :enc, :auth, 1)");
|
||||
$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,
|
||||
`dry` = GREATEST(`dry`, :dry) WHERE `id` = :id AND `source_id` IS NULL");
|
||||
$stmt = $pdo->query("SELECT `id`, `user2`, `host1`, `port1`, `enc1`, `custom_params` FROM `imapsync` WHERE `source_id` IS NULL");
|
||||
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
// Parse the legacy custom_params string like the old runner did (all tokens up to the next option are its value)
|
||||
// into pairs valid under $IMAPSYNC_OPTIONS, a token after a flag is never its value.
|
||||
// --authmech1 PLAIN|LOGIN|CRAM-MD5 becomes the source auth type, --dry the job's dry flag (never run a simulation for real).
|
||||
// Anything else that does not fit is dropped, the original string is then kept in custom_params_legacy.
|
||||
$pairs = array();
|
||||
$auth = null;
|
||||
$dry = false;
|
||||
$dropped = false;
|
||||
$tokens = preg_split('/\s+/', trim((string)$row['custom_params']), -1, PREG_SPLIT_NO_EMPTY);
|
||||
for ($i = 0, $n = count($tokens); $i < $n; $i++) {
|
||||
if ($tokens[$i][0] !== '-') {
|
||||
// stray value without option
|
||||
$dropped = true;
|
||||
continue;
|
||||
}
|
||||
@list($opt, $val) = explode('=', ltrim($tokens[$i], '-'), 2);
|
||||
$opt = strtolower($opt);
|
||||
$inline = ($val !== null);
|
||||
$rest = array();
|
||||
while ($i + 1 < $n && $tokens[$i + 1][0] !== '-') {
|
||||
$rest[] = $tokens[++$i];
|
||||
}
|
||||
$rule = ($opt === 'authmech1') ? true : ($imapsync_allow[$opt] ?? null);
|
||||
if ($opt === 'dry' || $rule === false) {
|
||||
if ($inline || !empty($rest)) $dropped = true;
|
||||
if ($opt === 'dry') {
|
||||
$dry = true;
|
||||
} elseif (!$inline) {
|
||||
$pairs[] = array('o' => $opt, 'v' => '');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// unknown option, or "--opt=a b" (the old runner cut the value at the first space)
|
||||
if ($rule === null || ($inline && !empty($rest))) {
|
||||
$dropped = true;
|
||||
continue;
|
||||
}
|
||||
$val = $inline ? $val : implode(' ', $rest);
|
||||
if ($opt === 'authmech1' && $auth === null && in_array(strtoupper($val), array('PLAIN', 'LOGIN', 'CRAM-MD5'), true)) {
|
||||
$auth = strtoupper($val);
|
||||
} elseif ($opt === 'authmech1' || trim($val) === ''
|
||||
|| (is_string($rule) && preg_match('~^(?:' . str_replace('~', '\~', $rule) . ')$~D', $val) !== 1)) {
|
||||
$dropped = true;
|
||||
} else {
|
||||
$pairs[] = array('o' => $opt, 'v' => $val);
|
||||
}
|
||||
}
|
||||
// Old runner ignored the authmech1 column, only a custom --authmech1 was effective
|
||||
$auth = $auth ?? 'PLAIN';
|
||||
$host = strtolower(trim((string)$row['host1']));
|
||||
$port = (intval($row['port1']) >= 1 && intval($row['port1']) <= 65535) ? intval($row['port1']) : 143;
|
||||
$enc = in_array($row['enc1'], array('TLS', 'SSL', 'PLAIN'), true) ? $row['enc1'] : 'TLS';
|
||||
$at = strrchr((string)$row['user2'], '@');
|
||||
$domain = ($at === false) ? '' : strtolower(substr($at, 1));
|
||||
// One source per (host, port, enc, auth, domain), name always unique and <= 100 chars
|
||||
$name = 'legacy-' . mb_substr($host, 0, 60) . '-' . $port . '-' . substr(sha1($host . '|' . $port . '|' . $enc . '|' . $auth . '|' . $domain), 0, 8);
|
||||
$source_sel->execute(array(':name' => $name));
|
||||
if (($source_id = $source_sel->fetchColumn()) === false) {
|
||||
$source_ins->execute(array(':name' => $name, ':host' => $host, ':port' => $port, ':enc' => $enc, ':auth' => $auth));
|
||||
$source_sel->execute(array(':name' => $name));
|
||||
if (($source_id = $source_sel->fetchColumn()) === false) continue;
|
||||
}
|
||||
if ($domain !== '') {
|
||||
$source_domain_ins->execute(array(':id' => $source_id, ':domain' => $domain));
|
||||
}
|
||||
$job_upd->execute(array(
|
||||
':source_id' => $source_id,
|
||||
':cp' => json_encode($pairs, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE),
|
||||
':cp_legacy' => $dropped ? $row['custom_params'] : null,
|
||||
':dry' => $dry ? 1 : 0,
|
||||
':id' => $row['id']
|
||||
));
|
||||
}
|
||||
|
||||
// Never throw on leftovers: keep source_id nullable and the legacy columns, retried with the next db_version
|
||||
$pending = intval($pdo->query("SELECT COUNT(*) FROM `imapsync` WHERE `source_id` IS NULL")->fetchColumn());
|
||||
if ($pending > 0) {
|
||||
$properties['cols']['source_id'] = "INT DEFAULT NULL";
|
||||
$legacy_col_types = array(
|
||||
'host1' => "VARCHAR(255) DEFAULT NULL",
|
||||
'port1' => "SMALLINT UNSIGNED DEFAULT NULL",
|
||||
'enc1' => "ENUM('TLS','SSL','PLAIN') DEFAULT NULL",
|
||||
'authmech1' => "ENUM('PLAIN','LOGIN','CRAM-MD5') DEFAULT NULL"
|
||||
);
|
||||
foreach ($legacy_col_types as $col => $type) {
|
||||
if (isset($imapsync_cols[$col])) $properties['cols'][$col] = $type;
|
||||
}
|
||||
error_log('mailcow: imapsync migration: ' . $pending . ' sync job(s) could not be assigned to a source and will not run');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$stmt = $pdo->query("SHOW TABLES LIKE '" . $table . "'");
|
||||
$num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
if ($num_results != 0) {
|
||||
$stmt = $pdo->prepare("SELECT CONCAT('ALTER TABLE `', `table_schema`, '`.', `table_name`, ' DROP FOREIGN KEY ', `constraint_name`, ';') AS `FKEY_DROP` FROM `information_schema`.`table_constraints`
|
||||
WHERE `constraint_type` = 'FOREIGN KEY' AND `table_name` = :table;");
|
||||
WHERE `constraint_type` = 'FOREIGN KEY' AND `table_schema` = DATABASE() AND `table_name` = :table;");
|
||||
$stmt->execute(array(':table' => $table));
|
||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
while ($row = array_shift($rows)) {
|
||||
@@ -1349,20 +1610,29 @@ function init_db_schema()
|
||||
AND (JSON_CONTAINS_PATH(`call`, 'one', '$[2].password')
|
||||
OR JSON_CONTAINS_PATH(`call`, 'one', '$[2].password2'));");
|
||||
|
||||
// Mitigate imapsync argument injection issue
|
||||
$pdo->query("UPDATE `imapsync` SET `custom_params` = ''
|
||||
WHERE `custom_params` LIKE '%pipemess%'
|
||||
OR custom_params LIKE '%skipmess%'
|
||||
OR custom_params LIKE '%delete2foldersonly%'
|
||||
OR custom_params LIKE '%delete2foldersbutnot%'
|
||||
OR custom_params LIKE '%regexflag%'
|
||||
OR custom_params LIKE '%pipemess%'
|
||||
OR custom_params LIKE '%regextrans2%'
|
||||
OR custom_params LIKE '%maxlinelengthcmd%';");
|
||||
// Mask syncjob secrets in old log entries (data and attr arguments of the logged call)
|
||||
$mask_paths = array();
|
||||
$unmasked = array();
|
||||
foreach (array(3, 4) as $idx) {
|
||||
foreach (array('password1', 'password2', 'oauth_client_secret', 'oauth_extra_params') as $key) {
|
||||
$mask_paths[] = "'$[" . $idx . "]." . $key . "'";
|
||||
// only rows still holding a value other than the string '*' (objects/arrays/numbers included), missing paths are NULL
|
||||
$unmasked[] = "JSON_UNQUOTE(JSON_EXTRACT(`call`, '$[" . $idx . "]." . $key . "')) <> '*'";
|
||||
}
|
||||
}
|
||||
$pdo->exec("UPDATE `logs` SET `call` = JSON_REPLACE(`call`, " . implode(", '*', ", $mask_paths) . ", '*')
|
||||
WHERE JSON_VALID(`call`)
|
||||
AND (JSON_VALUE(`call`, '$[0]') = 'syncjob'
|
||||
OR JSON_VALUE(`call`, '$[0]') LIKE 'imapsync%'
|
||||
OR (JSON_VALUE(`call`, '$[0]') = 'mailbox' AND JSON_VALUE(`call`, '$[2]') = 'syncjob'))
|
||||
AND (" . implode(" OR ", $unmasked) . ");");
|
||||
|
||||
// Migrate webauthn tfa
|
||||
$stmt = $pdo->query("ALTER TABLE `tfa` MODIFY COLUMN `authmech` ENUM('yubi_otp', 'u2f', 'hotp', 'totp', 'webauthn')");
|
||||
|
||||
// Syncjobs: seed global settings
|
||||
$pdo->query("INSERT IGNORE INTO `imapsync_settings` (`name`, `value`) VALUES ('max_parallel', '1')");
|
||||
|
||||
// Inject admin if not exists
|
||||
$stmt = $pdo->query("SELECT NULL FROM `admin`");
|
||||
$num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
@@ -1544,6 +1814,10 @@ function init_db_schema()
|
||||
}
|
||||
if (php_sapi_name() == "cli") {
|
||||
include '/web/inc/vars.inc.php';
|
||||
if (file_exists('/web/inc/vars.local.inc.php')) {
|
||||
include '/web/inc/vars.local.inc.php';
|
||||
}
|
||||
require_once '/web/inc/lib/vendor/autoload.php';
|
||||
include '/web/inc/functions.inc.php';
|
||||
include '/web/inc/functions.docker.inc.php';
|
||||
// $now = new DateTime();
|
||||
@@ -1561,7 +1835,7 @@ if (php_sapi_name() == "cli") {
|
||||
//PDO::MYSQL_ATTR_INIT_COMMAND => "SET time_zone = '" . $offset . "', group_concat_max_len = 3423543543;",
|
||||
];
|
||||
$pdo = new PDO($dsn, $database_user, $database_pass, $opt);
|
||||
$stmt = $pdo->query("SELECT COUNT('OK') AS OK_C FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME = 'sogo_view' OR TABLE_NAME = '_sogo_static_view';");
|
||||
$stmt = $pdo->query("SELECT COUNT('OK') AS OK_C FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = DATABASE() AND (TABLE_NAME = 'sogo_view' OR TABLE_NAME = '_sogo_static_view');");
|
||||
$res = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (intval($res['OK_C']) === 2) {
|
||||
// Be more precise when replacing into _sogo_static_view, col orders may change
|
||||
|
||||
+94
-127
@@ -287,131 +287,98 @@ $RSPAMD_MAPS = array(
|
||||
);
|
||||
|
||||
|
||||
$IMAPSYNC_OPTIONS = array(
|
||||
'whitelist' => array(
|
||||
'abort',
|
||||
'authmd51',
|
||||
'authmd52',
|
||||
'authmech1',
|
||||
'authmech2',
|
||||
'authuser1',
|
||||
'authuser2',
|
||||
'debug',
|
||||
'debugcontent',
|
||||
'debugcrossduplicates',
|
||||
'debugflags',
|
||||
'debugfolders',
|
||||
'debugimap',
|
||||
'debugimap1',
|
||||
'debugimap2',
|
||||
'debugmemory',
|
||||
'debugssl',
|
||||
'delete1emptyfolders',
|
||||
'delete2folders',
|
||||
'disarmreadreceipts',
|
||||
'domain1',
|
||||
'domain2',
|
||||
'domino1',
|
||||
'domino2',
|
||||
'dry',
|
||||
'errorsmax',
|
||||
'exchange1',
|
||||
'exchange2',
|
||||
'exitwhenover',
|
||||
'expunge1',
|
||||
'f1f2',
|
||||
'filterbuggyflags',
|
||||
'folder',
|
||||
'folderfirst',
|
||||
'folderlast',
|
||||
'folderrec',
|
||||
'gmail1',
|
||||
'gmail2',
|
||||
'idatefromheader',
|
||||
'include',
|
||||
'inet4',
|
||||
'inet6',
|
||||
'justconnect',
|
||||
'justfolders',
|
||||
'justfoldersizes',
|
||||
'justlogin',
|
||||
'keepalive1',
|
||||
'keepalive2',
|
||||
'log',
|
||||
'logdir',
|
||||
'logfile',
|
||||
'maxbytesafter',
|
||||
'maxlinelength',
|
||||
'maxmessagespersecond',
|
||||
'maxsize',
|
||||
'maxsleep',
|
||||
'minage',
|
||||
'minsize',
|
||||
'noabletosearch',
|
||||
'noabletosearch1',
|
||||
'noabletosearch2',
|
||||
'noexpunge1',
|
||||
'noexpunge2',
|
||||
'nofoldersizesatend',
|
||||
'noid',
|
||||
'nolog',
|
||||
'nomixfolders',
|
||||
'noresyncflags',
|
||||
'nossl1',
|
||||
'nossl2',
|
||||
'nosyncacls',
|
||||
'notls1',
|
||||
'notls2',
|
||||
'nouidexpunge2',
|
||||
'nousecache',
|
||||
'oauthaccesstoken1',
|
||||
'oauthaccesstoken2',
|
||||
'oauthdirect1',
|
||||
'oauthdirect2',
|
||||
'office1',
|
||||
'office2',
|
||||
'pidfile',
|
||||
'pidfilelocking',
|
||||
'prefix1',
|
||||
'prefix2',
|
||||
'proxyauth1',
|
||||
'proxyauth2',
|
||||
'resyncflags',
|
||||
'resynclabels',
|
||||
'search',
|
||||
'search1',
|
||||
'search2',
|
||||
'sep1',
|
||||
'sep2',
|
||||
'showpasswords',
|
||||
'skipemptyfolders',
|
||||
'ssl2',
|
||||
'sslargs1',
|
||||
'sslargs2',
|
||||
'subfolder1',
|
||||
'subscribe',
|
||||
'subscribed',
|
||||
'syncacls',
|
||||
'syncduplicates',
|
||||
'syncinternaldates',
|
||||
'synclabels',
|
||||
'tests',
|
||||
'testslive',
|
||||
'testslive6',
|
||||
'tls2',
|
||||
'truncmess',
|
||||
'usecache',
|
||||
'useheader',
|
||||
'useuid'
|
||||
),
|
||||
'blacklist' => array(
|
||||
'skipmess',
|
||||
'delete2foldersonly',
|
||||
'delete2foldersbutnot',
|
||||
'regexflag',
|
||||
'regexmess',
|
||||
'pipemess',
|
||||
'regextrans2',
|
||||
'maxlinelengthcmd'
|
||||
)
|
||||
// Allowed imapsync options for a sync job's custom_params (key = option name, lowercase, without dashes):
|
||||
// true = option requires a non-empty value
|
||||
// false = pure flag, must NOT have a value
|
||||
// string = PCRE (no delimiters) the whole non-empty value must match
|
||||
// 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 options taking local file paths (e.g. 'logfile', 'logdir', 'pidfile', 'passfile1', 'sslargs1' with SSL_ca_file)
|
||||
$IMAPSYNC_OPTIONS_DEFAULT = array(
|
||||
'abort' => false,
|
||||
'authmd51' => false,
|
||||
'authuser1' => true,
|
||||
'debug' => false,
|
||||
'debugcontent' => false,
|
||||
'debugcrossduplicates' => false,
|
||||
'debugflags' => false,
|
||||
'debugfolders' => false,
|
||||
'debugimap' => false,
|
||||
'debugimap1' => false,
|
||||
'debugmemory' => false,
|
||||
'debugssl' => true,
|
||||
'delete1emptyfolders' => false,
|
||||
'delete2folders' => false,
|
||||
'disarmreadreceipts' => false,
|
||||
'domain1' => true,
|
||||
'domino1' => false,
|
||||
'errorsmax' => true,
|
||||
'exchange1' => false,
|
||||
'exitwhenover' => true,
|
||||
'expunge1' => false,
|
||||
'f1f2' => true,
|
||||
'filterbuggyflags' => false,
|
||||
'folder' => true,
|
||||
'folderfirst' => true,
|
||||
'folderlast' => true,
|
||||
'folderrec' => true,
|
||||
'gmail1' => false,
|
||||
'idatefromheader' => false,
|
||||
'include' => true,
|
||||
'inet4' => false,
|
||||
'inet6' => false,
|
||||
'justconnect' => false,
|
||||
'justfolders' => false,
|
||||
'justfoldersizes' => false,
|
||||
'justlogin' => false,
|
||||
'keepalive1' => false,
|
||||
'log' => false,
|
||||
'maxbytesafter' => true,
|
||||
'maxlinelength' => true,
|
||||
'maxmessagespersecond' => true,
|
||||
'maxsize' => true,
|
||||
'maxsleep' => true,
|
||||
'minage' => true,
|
||||
'minsize' => true,
|
||||
'noabletosearch' => false,
|
||||
'noabletosearch1' => false,
|
||||
'noexpunge1' => false,
|
||||
'nofoldersizesatend' => false,
|
||||
'noid' => false,
|
||||
'nolog' => false,
|
||||
'nomixfolders' => false,
|
||||
'noresyncflags' => false,
|
||||
'nossl1' => false,
|
||||
'nosyncacls' => false,
|
||||
'notls1' => false,
|
||||
'nousecache' => false,
|
||||
'office1' => false,
|
||||
'prefix1' => true,
|
||||
'prefix2' => true,
|
||||
'proxyauth1' => false,
|
||||
'resyncflags' => false,
|
||||
'resynclabels' => false,
|
||||
'search' => true,
|
||||
'search1' => true,
|
||||
'sep1' => true,
|
||||
'sep2' => true,
|
||||
'skipemptyfolders' => false,
|
||||
'sslargs1' => '^SSL_verify_mode=[01]$',
|
||||
'subfolder1' => true,
|
||||
'subscribe' => false,
|
||||
'subscribed' => false,
|
||||
'syncacls' => false,
|
||||
'syncduplicates' => false,
|
||||
'syncinternaldates' => false,
|
||||
'synclabels' => false,
|
||||
'tests' => false,
|
||||
'testslive' => false,
|
||||
'testslive6' => false,
|
||||
'truncmess' => true,
|
||||
'usecache' => false,
|
||||
'useheader' => true,
|
||||
'useuid' => false,
|
||||
);
|
||||
$IMAPSYNC_OPTIONS = $IMAPSYNC_OPTIONS_DEFAULT;
|
||||
@@ -73,7 +73,8 @@ $(document).ready(function() {
|
||||
}
|
||||
|
||||
// form cache
|
||||
$('[data-cached-form="true"]').formcache({key: $(this).data('id')});
|
||||
// fields marked with data-formcache-skip (secrets) are never written to localStorage
|
||||
$('[data-cached-form="true"]').formcache({key: $(this).data('id'), controls: ['select', 'textarea', 'input:not([data-formcache-skip])']});
|
||||
|
||||
// tooltips
|
||||
$(function () {
|
||||
@@ -113,6 +114,8 @@ $(document).ready(function() {
|
||||
}
|
||||
|
||||
localStorage.setItem(key, tab_id);
|
||||
// remember which card of the tab was opened
|
||||
localStorage.setItem(key + ':collapse', $(this).attr('data-bs-target').substring(1));
|
||||
});
|
||||
// open last tab
|
||||
$('[role="tablist"]').each(function (idx, elem) {
|
||||
@@ -128,7 +131,10 @@ $(document).ready(function() {
|
||||
dropdown.find('.dropdown-menu').removeClass('show');
|
||||
dropdown.find('.dropdown-toggle').attr('aria-expanded', 'false');
|
||||
var tab = $('[id^="' + lastTab + '"]');
|
||||
$(tab).find('.card-body.collapse:first').collapse('show');
|
||||
var lastCollapse = localStorage.getItem(key + ':collapse');
|
||||
var $card = lastCollapse ? $(tab).find('.card-body.collapse').filter(function() { return this.id === lastCollapse; }) : $();
|
||||
if (!$card.length) $card = $(tab).find('.card-body.collapse:first');
|
||||
$card.collapse('show');
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,707 @@
|
||||
// IMAP sync jobs and sync sources (admin/domainadmin mailbox page, user page, edit page).
|
||||
// Loaded via $js_minifier only on pages that show sync jobs.
|
||||
var mailcowImapsync = (function($) {
|
||||
'use strict';
|
||||
|
||||
// Lang strings: lang.syncjobs (mailbox/user page) or lang_syncjobs (edit page), '' if missing
|
||||
function L(key) {
|
||||
var src = (typeof lang_syncjobs !== 'undefined' && lang_syncjobs) ? lang_syncjobs :
|
||||
((typeof lang !== 'undefined' && lang && lang.syncjobs) ? lang.syncjobs : {});
|
||||
return (src[key] !== undefined && src[key] !== null) ? src[key] : '';
|
||||
}
|
||||
function esc(s) {
|
||||
return escapeHtml(s === null || s === undefined ? '' : s);
|
||||
}
|
||||
// same format as "last run" (YYYY-MM-DD HH:mm:ss), independent of the browser locale
|
||||
function formatDate(ts) {
|
||||
if (!ts) return '';
|
||||
var d = new Date(ts * 1000);
|
||||
var p = function(n) { return (n < 10 ? '0' : '') + n; };
|
||||
return d.getFullYear() + '-' + p(d.getMonth() + 1) + '-' + p(d.getDate()) + ' ' +
|
||||
p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds());
|
||||
}
|
||||
function isAdmin() {
|
||||
return typeof mailcow_cc_role !== 'undefined' && mailcow_cc_role === 'admin';
|
||||
}
|
||||
|
||||
// bootstrap-select: refresh an existing instance, or init with the mailcow defaults
|
||||
function refreshSelect($sel) {
|
||||
if (typeof $sel.selectpicker !== 'function') return;
|
||||
$sel.each(function() {
|
||||
var $s = $(this);
|
||||
if ($s.data('selectpicker')) {
|
||||
$s.selectpicker('refresh');
|
||||
} else {
|
||||
$s.selectpicker({
|
||||
'styleBase': 'btn btn-xs-lg',
|
||||
'noneSelectedText': (typeof lang_footer !== 'undefined') ? lang_footer.nothing_selected : ''
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Long source labels must not overflow the modal on small screens
|
||||
function injectStyle() {
|
||||
if ($('#imapsync-style').length) return;
|
||||
$('head').append('<style id="imapsync-style">' +
|
||||
'.imapsync-select-wrap .bootstrap-select{max-width:100%;}' +
|
||||
'.imapsync-select-wrap .bootstrap-select .dropdown-menu{max-width:100%;}' +
|
||||
'.imapsync-select-wrap .bootstrap-select .dropdown-menu .text{white-space:normal;word-break:break-word;}' +
|
||||
'.imapsync-select-wrap .bootstrap-select .filter-option-inner-inner{overflow:hidden;text-overflow:ellipsis;}' +
|
||||
// long hosts / legacy names in table cells must wrap, otherwise the responsive tables hide every other column
|
||||
'.imapsync-wrap{min-width:7rem;overflow-wrap:anywhere;word-break:break-word;}' +
|
||||
'</style>');
|
||||
}
|
||||
|
||||
// ===== Display helpers (tables) =====
|
||||
|
||||
// Visibility label of a source row
|
||||
function scopeDisplay(item) {
|
||||
if (item.scope === 'all') return '<i class="bi bi-globe"></i> ' + esc(L('source_scope_all'));
|
||||
if (item.scope === 'domain') return esc(L('source_scope_domain') + ': ' + (item.domains || []).join(', '));
|
||||
var us = item.users || [];
|
||||
if (us.length === 0) return esc(item.created_by || '');
|
||||
return esc(L('source_scope_user') + ': ' + us.join(', '));
|
||||
}
|
||||
|
||||
// Token badge of a client_credentials source: based on token existence/error, expiry in title
|
||||
function sourceTokenBadge(item) {
|
||||
if (item.oauth_last_refresh_error) {
|
||||
return '<span class="badge bg-danger" title="' + esc(item.oauth_last_refresh_error) + '">' + esc(L('token_state_failed')) + '</span>';
|
||||
}
|
||||
if (item.has_access_token) {
|
||||
var title = item.oauth_token_expires ? L('source_token_status') + ': ' + formatDate(item.oauth_token_expires) : '';
|
||||
return '<span class="badge bg-success" title="' + esc(title) + '">' + esc(L('token_state_issued')) + '</span>';
|
||||
}
|
||||
return '<span class="badge bg-warning">' + esc(L('token_state_none')) + '</span>';
|
||||
}
|
||||
|
||||
// Auth column of a source row: auth type, and for XOAUTH2 the flow (+ token badge for client credentials)
|
||||
function authDisplay(item) {
|
||||
var out = esc(item.auth_type);
|
||||
if (item.auth_type !== 'XOAUTH2') return out;
|
||||
var flow = item.oauth_flow || 'client_credentials';
|
||||
out += ' <span class="text-muted">(' + esc(flow === 'authorization_code' ? L('source_flow_badge_user') : L('source_flow_badge_app')) + ')</span>';
|
||||
// authorization_code tokens live per job (local mailbox + remote user), not on the source
|
||||
if (flow === 'authorization_code') return out;
|
||||
return out + '<div class="mt-1">' + sourceTokenBadge(item) + '</div>';
|
||||
}
|
||||
|
||||
// Per-job token badge for authorization_code sources ('' for everything else)
|
||||
function jobTokenBadge(item) {
|
||||
if (item.source_auth_type !== 'XOAUTH2' || item.source_oauth_flow !== 'authorization_code') return '';
|
||||
if (item.user_token_error) {
|
||||
return '<span class="badge bg-danger" title="' + esc(item.user_token_error) + '">' + esc(L('token_state_failed')) + '</span>';
|
||||
}
|
||||
if (parseInt(item.user_token_exists) === 1) {
|
||||
var title = item.user_token_expires ? L('source_token_status') + ': ' + formatDate(item.user_token_expires) : '';
|
||||
return '<span class="badge bg-success" title="' + esc(title) + '">' + esc(L('token_state_issued')) + '</span>';
|
||||
}
|
||||
return '<span class="badge bg-warning">' + esc(L('token_state_pending')) + '</span>';
|
||||
}
|
||||
|
||||
// Warning badge for jobs with not migrated legacy custom parameters
|
||||
function legacyBadge(item) {
|
||||
if (!item.custom_params_legacy) return '';
|
||||
return '<span class="badge bg-warning text-dark" title="' + esc(L('custom_params_legacy_badge_title')) + '">' +
|
||||
'<i class="bi bi-exclamation-triangle"></i> ' + esc(L('custom_params_legacy_badge')) + '</span>';
|
||||
}
|
||||
|
||||
// Wrappable table cell content (long hosts, mailboxes, legacy source names)
|
||||
function wrapCell(html) {
|
||||
return '<div class="imapsync-wrap">' + html + '</div>';
|
||||
}
|
||||
|
||||
// Server column: "remote user @ host:port", source name below, badges after
|
||||
function serverDisplay(item) {
|
||||
var out = esc(item.user1 || '') + ' @ ' + esc(item.source_host || '?') + ':' + esc(item.source_port || '?');
|
||||
if (item.source_name) out += '<div class="small text-muted">' + esc(item.source_name) + '</div>';
|
||||
var badges = [jobTokenBadge(item), legacyBadge(item)].filter(Boolean);
|
||||
if (badges.length) out += '<div class="mt-1">' + badges.join(' ') + '</div>';
|
||||
return wrapCell(out);
|
||||
}
|
||||
|
||||
// Prepares the shared fields of a job row (run state, last run, result)
|
||||
function prepareJobRow(item) {
|
||||
item.server_w_port = serverDisplay(item);
|
||||
if (parseInt(item.is_running) === 1) {
|
||||
item.is_running = '<span class="badge fs-6 bg-success">' + esc(L('status_running')) + '</span>';
|
||||
} else if (parseInt(item.active) !== 1) {
|
||||
item.is_running = '<span class="badge fs-6 bg-secondary">' + esc(L('status_inactive')) + '</span>';
|
||||
} else {
|
||||
item.is_running = '<span class="badge fs-6 bg-warning">' + esc(L('status_waiting')) + '</span>';
|
||||
}
|
||||
if (!item.last_run) {
|
||||
item.last_run = '<span class="text-muted">' + esc(L('never_run')) + '</span>';
|
||||
}
|
||||
if (item.success == null) {
|
||||
item.success = '-';
|
||||
item.exit_status = '';
|
||||
} else {
|
||||
item.success = '<i class="text-' + (item.success == 1 ? 'success' : 'danger') + ' bi bi-' + (item.success == 1 ? 'check-lg' : 'x-lg') + '"></i>';
|
||||
}
|
||||
if (L('syncjob_' + item.exit_status)) {
|
||||
item.exit_status = L('syncjob_' + item.exit_status);
|
||||
} else if (item.success != '-') {
|
||||
item.exit_status = L('syncjob_check_log');
|
||||
}
|
||||
item.exit_status = item.success + ' ' + esc(item.exit_status);
|
||||
return item;
|
||||
}
|
||||
|
||||
// Job tables: ignore DataTables state saved before the rework (old ID sort, column visibility)
|
||||
var JOB_TABLE_STATE_VERSION = 2;
|
||||
function jobTableStateSave(settings, data) {
|
||||
data.imapsync_state = JOB_TABLE_STATE_VERSION;
|
||||
}
|
||||
function jobTableStateLoad(settings, data) {
|
||||
if (!data || data.imapsync_state !== JOB_TABLE_STATE_VERSION) return false;
|
||||
// column visibility depends on the role, never restore it
|
||||
if (Array.isArray(data.columns)) {
|
||||
data.columns.forEach(function(c) { if (c) delete c.visible; });
|
||||
}
|
||||
}
|
||||
|
||||
// Sync source table (mailbox + user page). canManage: session may add/edit sources at all.
|
||||
function drawSourceTable(canManage) {
|
||||
if ($.fn.DataTable.isDataTable('#imapsync_source_table')) {
|
||||
$('#imapsync_source_table').DataTable().columns.adjust().responsive.recalc();
|
||||
return;
|
||||
}
|
||||
$('#imapsync_source_table').DataTable({
|
||||
responsive: true,
|
||||
processing: true,
|
||||
serverSide: false,
|
||||
stateSave: true,
|
||||
pageLength: pagination_size,
|
||||
dom: "<'row'<'col-sm-12 col-md-6'f><'col-sm-12 col-md-6'l>>" + "tr" +
|
||||
"<'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>",
|
||||
language: lang_datatables,
|
||||
order: [[2, 'asc']],
|
||||
ajax: {
|
||||
type: "GET",
|
||||
url: "/api/v1/get/syncjob_source/all",
|
||||
dataSrc: function(json) {
|
||||
if (!Array.isArray(json)) return [];
|
||||
$.each(json, function(i, item) {
|
||||
item.endpoint = wrapCell(esc((item.host || item.host1) + ':' + (item.port || item.port1) + ' (' + (item.enc || item.enc1) + ')'));
|
||||
item.name_display = esc(item.name);
|
||||
if (item.description) item.name_display += '<div class="small text-muted">' + esc(item.description) + '</div>';
|
||||
item.name_display = wrapCell(item.name_display);
|
||||
item.auth_display = authDisplay(item);
|
||||
item.visibility_display = wrapCell(scopeDisplay(item));
|
||||
if (canManage && item.can_edit) {
|
||||
item.action = '<div class="btn-group">' +
|
||||
'<a href="/edit/syncjob_source/' + encodeURIComponent(item.id) + '" class="btn btn-sm btn-xs-lg btn-xs-half btn-secondary"><i class="bi bi-pencil-fill"></i> ' + esc(lang.edit) + '</a>' +
|
||||
'<a href="#" data-action="delete_selected" data-id="single-syncjob_source" data-api-url="delete/syncjob_source" data-item="' + encodeURIComponent(item.id) + '" class="btn btn-sm btn-xs-lg btn-xs-half btn-danger"><i class="bi bi-trash"></i> ' + esc(lang.remove) + '</a>' +
|
||||
'</div>';
|
||||
} else {
|
||||
item.action = '<a href="/edit/syncjob_source/' + encodeURIComponent(item.id) + '" class="btn btn-sm btn-xs-lg btn-secondary"><i class="bi bi-eye"></i> ' + esc(L('source_view')) + '</a>';
|
||||
}
|
||||
});
|
||||
return json;
|
||||
},
|
||||
error: function() {
|
||||
mailcow_alert_box(L('source_list_error'), 'danger');
|
||||
}
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
// placeholder, so the responsive control does not cover the ID
|
||||
title: '',
|
||||
data: null,
|
||||
searchable: false,
|
||||
orderable: false,
|
||||
defaultContent: '',
|
||||
responsivePriority: 1
|
||||
},
|
||||
{ title: L('id'), data: 'id', defaultContent: '', responsivePriority: 4 },
|
||||
{ title: L('source_name'), data: 'name_display', defaultContent: '', responsivePriority: 2 },
|
||||
{ title: L('source_scope'), data: 'visibility_display', defaultContent: '', responsivePriority: 6 },
|
||||
{ title: L('source_server'), data: 'endpoint', defaultContent: '', responsivePriority: 5 },
|
||||
{ title: L('source_auth_type'), data: 'auth_display', defaultContent: '', responsivePriority: 3 },
|
||||
{
|
||||
title: lang.active,
|
||||
data: 'active',
|
||||
defaultContent: '',
|
||||
responsivePriority: 7,
|
||||
render: function(d) { return d == 1 ? '<i class="bi bi-check-lg"></i>' : '<i class="bi bi-x-lg"></i>'; }
|
||||
},
|
||||
{
|
||||
title: lang.action,
|
||||
data: 'action',
|
||||
orderable: false,
|
||||
className: 'dt-sm-head-hidden dt-data-w100 dtr-col-md dt-text-right',
|
||||
responsivePriority: 3,
|
||||
defaultContent: ''
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Source dropdown in the job modal / edit page =====
|
||||
|
||||
function setNoSourceState($sel, empty) {
|
||||
$sel.closest('form').find('.imapsync-no-source').toggleClass('d-none', !empty);
|
||||
}
|
||||
|
||||
// Replace the options of a source dropdown (array of jQuery options, kept in order) and select keepId (if present and enabled).
|
||||
// bootstrap-select 1.14 keeps a detached title option (incl. its selectedness) across empty()/refresh,
|
||||
// which overrides the selected option and leaves stale list entries, so destroy and re-init instead.
|
||||
function rebuildSourceSelect($sel, $options, keepId) {
|
||||
if (typeof $sel.selectpicker === 'function' && $sel.data('selectpicker')) $sel.selectpicker('destroy');
|
||||
$sel.find('option').remove();
|
||||
$sel.append($options);
|
||||
var $keep = (keepId !== '') ? $sel.find('option').filter(function() {
|
||||
return $(this).val() === keepId && !$(this).prop('disabled');
|
||||
}).first() : $();
|
||||
$sel.find('option').prop('selected', false).removeAttr('selected');
|
||||
if ($keep.length) {
|
||||
$keep.attr('selected', 'selected').prop('selected', true);
|
||||
} else {
|
||||
// nothing to keep: select nothing, the required check makes the user pick a source
|
||||
$sel.prop('selectedIndex', -1);
|
||||
}
|
||||
$sel.prop('disabled', false);
|
||||
refreshSelect($sel);
|
||||
if ($keep.length && $sel.data('selectpicker')) $sel.selectpicker('val', keepId);
|
||||
return $keep.length > 0;
|
||||
}
|
||||
|
||||
// (Re)build every source dropdown from the ACL-filtered API
|
||||
function populateSourceSelects() {
|
||||
var $selects = $('select.imapsync-source-select');
|
||||
if (!$selects.length) return;
|
||||
$selects.each(function() {
|
||||
var $sel = $(this);
|
||||
if ($sel.val()) $sel.data('keep-id', String($sel.val()));
|
||||
$sel.data('saved-options', $sel.find('option:not(.bs-title-option)').clone());
|
||||
// disable while loading instead of a transient placeholder option
|
||||
$sel.prop('disabled', true);
|
||||
refreshSelect($sel);
|
||||
});
|
||||
$.get('/api/v1/get/syncjob_source/all').done(function(sources) {
|
||||
if (!Array.isArray(sources)) sources = [];
|
||||
sources.sort(function(a, b) { return String(a.name).localeCompare(String(b.name)); });
|
||||
$selects.each(function() {
|
||||
var $sel = $(this);
|
||||
var cur = String($sel.data('current-source-id') || '');
|
||||
var keepId = String($sel.data('keep-id') || cur);
|
||||
var opts = [];
|
||||
var selectable = 0;
|
||||
var listed = false;
|
||||
$.each(sources, function(_, src) {
|
||||
var id = String(src.id);
|
||||
var $opt = sourceOption(src.id, src.name, src.host || src.host1, src.port || src.port1, src.auth_type, src.oauth_flow);
|
||||
if (src.active != 1) {
|
||||
$opt.attr('data-subtext', $opt.attr('data-subtext') + ' · ' + L('status_inactive'));
|
||||
// the backend rejects inactive sources, only the assigned one stays selectable on the edit page
|
||||
if (id !== cur) $opt.prop('disabled', true);
|
||||
}
|
||||
if (!$opt.prop('disabled')) selectable++;
|
||||
if (id === cur) listed = true;
|
||||
opts.push($opt);
|
||||
});
|
||||
// edit page: the assigned source stays selectable even if the viewer cannot see it
|
||||
if (cur !== '' && !listed) {
|
||||
var $cur = sourceOption(cur, $sel.data('current-source-name'), $sel.data('current-source-host'),
|
||||
$sel.data('current-source-port'), $sel.data('current-source-auth-type'), $sel.data('current-source-oauth-flow'));
|
||||
$cur.text($cur.text() + ' ' + L('source_not_visible'));
|
||||
opts.unshift($cur);
|
||||
selectable++;
|
||||
}
|
||||
rebuildSourceSelect($sel, opts, keepId);
|
||||
setNoSourceState($sel, selectable === 0);
|
||||
$sel.trigger('change');
|
||||
});
|
||||
}).fail(function() {
|
||||
// keep what was there before (e.g. the assigned source on the edit page)
|
||||
$selects.each(function() {
|
||||
var $sel = $(this);
|
||||
rebuildSourceSelect($sel, $sel.data('saved-options') || $(), String($sel.data('keep-id') || $sel.data('current-source-id') || ''));
|
||||
$sel.trigger('change');
|
||||
});
|
||||
mailcow_alert_box(L('source_list_error'), 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
function sourceOption(id, name, host, port, authType, flow) {
|
||||
var sub = (host || '?') + ':' + (port || '?') + ' · ' + (authType || '');
|
||||
if (authType === 'XOAUTH2') sub += ' (' + (flow === 'authorization_code' ? L('source_flow_badge_user') : L('source_flow_badge_app')) + ')';
|
||||
return $('<option></option>').val(id).text(name || ('#' + id))
|
||||
.attr('data-subtext', sub)
|
||||
.attr('data-auth-type', authType || '')
|
||||
.attr('data-oauth-flow', flow || '');
|
||||
}
|
||||
|
||||
// Toggle password / user1 / connect rows by the selected source's auth type
|
||||
function applySourceSelection($sel) {
|
||||
var $opt = $sel.find('option:selected');
|
||||
var $form = $sel.closest('form');
|
||||
var isOauth = ($opt.attr('data-auth-type') === 'XOAUTH2');
|
||||
var isAuthCode = isOauth && ($opt.attr('data-oauth-flow') === 'authorization_code');
|
||||
var isEdit = ($form.data('id') === 'editsyncjob');
|
||||
var $user1 = $form.find('input[name="user1"]');
|
||||
var prevSource = $sel.data('prev-source');
|
||||
var curSource = $sel.val() || '';
|
||||
// a token belongs to (source, mailbox, remote user): switching the source drops the connect result
|
||||
if (prevSource !== undefined && prevSource !== curSource && $user1.prop('readonly')) {
|
||||
$user1.prop('readonly', false).val(isEdit ? $user1.data('initial') || '' : '');
|
||||
setConnectStatus($form, '', '');
|
||||
}
|
||||
$sel.data('prev-source', curSource);
|
||||
$form.find('.password1-row').toggle(!isOauth);
|
||||
// edit: an empty password keeps the stored one
|
||||
$form.find('input[name="password1"]').prop('required', !isOauth && !isEdit);
|
||||
$form.find('.imapsync-oauth-connect-row').toggle(isAuthCode);
|
||||
$form.find('.imapsync-user1-row').toggle(!isAuthCode);
|
||||
$user1.prop('required', !isAuthCode && !isEdit);
|
||||
}
|
||||
|
||||
// ===== OAuth connect (authorization_code) =====
|
||||
|
||||
var oauthPending = null;
|
||||
|
||||
function setConnectStatus($form, type, text, busy) {
|
||||
var $status = $form.find('.imapsync-oauth-connect-status');
|
||||
$status.attr('class', 'imapsync-oauth-connect-status ms-2' + (type ? ' text-' + type : '')).empty();
|
||||
if (busy) $status.append('<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>');
|
||||
$status.append(document.createTextNode(text || ''));
|
||||
}
|
||||
|
||||
function clearPending() {
|
||||
if (oauthPending && oauthPending.timer) clearInterval(oauthPending.timer);
|
||||
oauthPending = null;
|
||||
}
|
||||
|
||||
// Local mailbox the token is stored for (C1)
|
||||
function connectMailbox($form) {
|
||||
var $mb = $form.find('[name="username"]');
|
||||
if ($mb.length) return $mb.val() || '';
|
||||
if ($form.data('mailbox')) return String($form.data('mailbox'));
|
||||
return (typeof mailcow_cc_username !== 'undefined') ? mailcow_cc_username : '';
|
||||
}
|
||||
|
||||
function startConnect($form) {
|
||||
var sourceId = $form.find('select.imapsync-source-select').val();
|
||||
if (!sourceId) return;
|
||||
var mailbox = connectMailbox($form);
|
||||
if (!mailbox) {
|
||||
setConnectStatus($form, 'danger', L('oauth_select_mailbox'));
|
||||
return;
|
||||
}
|
||||
var url = '/syncjob-oauth?action=start&source_id=' + encodeURIComponent(sourceId) + '&mailbox=' + encodeURIComponent(mailbox);
|
||||
var popup = window.open(url, 'syncjob_oauth', 'width=600,height=700');
|
||||
if (!popup || popup.closed || typeof popup.closed === 'undefined') {
|
||||
setConnectStatus($form, 'danger', L('oauth_popup_blocked'));
|
||||
return;
|
||||
}
|
||||
clearPending();
|
||||
var pending = { form: $form, sourceId: String(sourceId), mailbox: mailbox.toLowerCase() };
|
||||
oauthPending = pending;
|
||||
setConnectStatus($form, 'muted', L('oauth_waiting'), true);
|
||||
pending.timer = setInterval(function() {
|
||||
if (!popup.closed) return;
|
||||
clearInterval(pending.timer);
|
||||
pending.timer = null;
|
||||
// stays pending: a result message that arrives after the close still wins
|
||||
if (oauthPending === pending) setConnectStatus($form, 'muted', L('oauth_window_closed'));
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function onOauthMessage(ev) {
|
||||
if (ev.origin !== window.location.origin || !ev.data || ev.data.type !== 'syncjob_oauth') return;
|
||||
var p = oauthPending;
|
||||
if (!p) return;
|
||||
// only the row that started the flow is updated
|
||||
if (ev.data.ok && (String(ev.data.source_id) !== p.sourceId || String(ev.data.mailbox || '').toLowerCase() !== p.mailbox)) return;
|
||||
clearPending();
|
||||
if (ev.data.ok) {
|
||||
p.form.find('input[name="user1"]').val(ev.data.user1).prop('readonly', true);
|
||||
setConnectStatus(p.form, 'success', L('syncjob_oauth_connected') + ': ' + ev.data.user1);
|
||||
} else {
|
||||
var code = String(ev.data.error || '').replace(/[^a-z_]/g, '');
|
||||
setConnectStatus(p.form, 'danger', L('oauth_error_' + code) || L('oauth_error_unknown'));
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Custom params editor =====
|
||||
|
||||
// Allowlist { name: true (value) | false (flag) | 'regex' (value) }, loaded once on demand
|
||||
var cpOptions = null;
|
||||
var cpLoading = null;
|
||||
function cpEnsureOptions(cb) {
|
||||
if (cpOptions !== null) { cb(); return; }
|
||||
if (!cpLoading) {
|
||||
cpLoading = $.get('/api/v1/get/syncjob_options').done(function(opts) {
|
||||
cpOptions = (opts && typeof opts === 'object' && !Array.isArray(opts)) ? opts : {};
|
||||
}).fail(function() {
|
||||
cpLoading = null;
|
||||
mailcow_alert_box(L('custom_params_options_error'), 'danger');
|
||||
});
|
||||
}
|
||||
cpLoading.done(function() { cb(); });
|
||||
}
|
||||
|
||||
function cpApplyValueState($row) {
|
||||
var name = $row.find('.imapsync-cp-opt').val() || '';
|
||||
var isFlag = (name !== '') && !!cpOptions && cpOptions[name] === false;
|
||||
var $val = $row.find('.imapsync-cp-val');
|
||||
$val.prop('disabled', isFlag);
|
||||
if (isFlag) $val.val('');
|
||||
$val.prop('required', (name !== '') && !isFlag);
|
||||
$val.attr('placeholder', isFlag ? L('custom_param_no_value') : L('custom_param_value'));
|
||||
}
|
||||
|
||||
var cpRowSeq = 0;
|
||||
function cpAddRow($editor, o, v) {
|
||||
var n = ++cpRowSeq;
|
||||
var $row = $(
|
||||
'<div class="input-group mb-2 imapsync-cp-row">' +
|
||||
'<select class="form-select imapsync-cp-opt" id="imapsync-cp-opt-' + n + '" style="flex:0 0 35%"></select>' +
|
||||
'<input type="text" class="form-control imapsync-cp-val" id="imapsync-cp-val-' + n + '">' +
|
||||
'<button type="button" class="btn btn-secondary imapsync-cp-remove"><i class="bi bi-trash" aria-hidden="true"></i></button>' +
|
||||
'</div>'
|
||||
);
|
||||
// aria-label: label elements inside .input-group break its first/last child styling
|
||||
$row.find('.imapsync-cp-opt').attr('aria-label', L('custom_param_option'));
|
||||
$row.find('.imapsync-cp-val').attr('aria-label', L('custom_param_value'));
|
||||
$row.find('.imapsync-cp-remove').attr({ 'title': L('custom_param_remove'), 'aria-label': L('custom_param_remove') });
|
||||
var $sel = $row.find('.imapsync-cp-opt');
|
||||
$sel.append($('<option></option>').attr('value', '').text('— ' + L('custom_param_option') + ' —'));
|
||||
Object.keys(cpOptions || {}).sort().forEach(function(name) {
|
||||
$sel.append($('<option></option>').attr('value', name).text('--' + name));
|
||||
});
|
||||
// keep a stored option that is no longer allowlisted visible (the backend rejects it on save)
|
||||
if (o && !(cpOptions && (o in cpOptions))) {
|
||||
$sel.append($('<option></option>').attr('value', o).text('--' + o));
|
||||
}
|
||||
$sel.val(o || '');
|
||||
$row.find('.imapsync-cp-val').val(v || '');
|
||||
$editor.find('.imapsync-cp-rows').append($row);
|
||||
cpApplyValueState($row);
|
||||
}
|
||||
|
||||
function cpBuild($editor) {
|
||||
var $hidden = $editor.siblings('.imapsync-cp-value');
|
||||
$editor.find('.imapsync-cp-rows').empty();
|
||||
var pairs = [];
|
||||
try { pairs = JSON.parse($hidden.val() || '[]'); } catch (e) { pairs = []; }
|
||||
if (!Array.isArray(pairs)) pairs = [];
|
||||
pairs.forEach(function(p) {
|
||||
if (p && typeof p === 'object') cpAddRow($editor, p.o, p.v);
|
||||
});
|
||||
}
|
||||
|
||||
function cpSerialize($editor) {
|
||||
var out = [];
|
||||
$editor.find('.imapsync-cp-row').each(function() {
|
||||
var o = $.trim($(this).find('.imapsync-cp-opt').val() || '').replace(/^-+/, '');
|
||||
if (o === '') return;
|
||||
out.push({ o: o, v: $(this).find('.imapsync-cp-val').val() || '' });
|
||||
});
|
||||
$editor.siblings('.imapsync-cp-value').val(JSON.stringify(out));
|
||||
}
|
||||
|
||||
function cpInit($scope) {
|
||||
var $editors = $scope.find('.imapsync-cp-editor');
|
||||
if (!$editors.length) return;
|
||||
cpEnsureOptions(function() {
|
||||
$editors.each(function() { cpBuild($(this)); });
|
||||
});
|
||||
}
|
||||
|
||||
// ===== Source editor (add modal + edit page) =====
|
||||
|
||||
function sourceFormToggle($form) {
|
||||
var isOauth = $form.find('select.imapsync-source-auth-type').val() === 'XOAUTH2';
|
||||
var isAuthCode = $form.find('select.imapsync-source-oauth-flow').val() === 'authorization_code';
|
||||
var isCc = isOauth && !isAuthCode;
|
||||
$form.find('.imapsync-source-oauth-block').toggle(isOauth);
|
||||
$form.find('.imapsync-source-authcode-block').toggle(isOauth && isAuthCode);
|
||||
$form.find('.imapsync-source-cc-block').toggle(isCc);
|
||||
// client credentials sources are forced private by the backend
|
||||
$form.find('.imapsync-source-scope-block').toggle(!isCc);
|
||||
$form.find('.imapsync-source-scope-cc-hint').toggle(isCc);
|
||||
}
|
||||
|
||||
function sourceScopeToggle($form) {
|
||||
var scope = $form.find('select.imapsync-source-scope').val();
|
||||
$form.find('.imapsync-source-domains-row').toggle(scope === 'domain');
|
||||
$form.find('.imapsync-source-users-row').toggle(scope === 'user');
|
||||
if (scope === 'domain') fillScopeSelect($form.find('select.imapsync-source-domains'), 'domain/all', 'domain_name');
|
||||
if (scope === 'user') fillScopeSelect($form.find('select.imapsync-source-users'), 'mailbox/all', 'username');
|
||||
}
|
||||
|
||||
// Add the selectable domains/mailboxes to a scope multiselect (keeps selected options)
|
||||
function fillScopeSelect($sel, url, field) {
|
||||
if (!$sel.length || $sel.prop('disabled') || $sel.data('filled')) return;
|
||||
$sel.data('filled', true);
|
||||
$.get('/api/v1/get/' + url).done(function(rows) {
|
||||
var have = {};
|
||||
$sel.find('option').each(function() { have[$(this).val()] = true; });
|
||||
$.each(Array.isArray(rows) ? rows : [], function(_, r) {
|
||||
var v = r[field];
|
||||
if (!v || have[v]) return;
|
||||
$sel.append($('<option></option>').val(v).text(v));
|
||||
});
|
||||
// refresh duplicates preselected entries in the button label, re-init instead
|
||||
if (typeof $sel.selectpicker === 'function' && $sel.data('selectpicker')) {
|
||||
var keep = $sel.val();
|
||||
$sel.selectpicker('destroy');
|
||||
refreshSelect($sel);
|
||||
$sel.selectpicker('val', keep);
|
||||
} else {
|
||||
refreshSelect($sel);
|
||||
}
|
||||
}).fail(function() {
|
||||
$sel.data('filled', false);
|
||||
mailcow_alert_box(lang_danger.access_denied, 'danger');
|
||||
});
|
||||
}
|
||||
|
||||
// Extra params must be a JSON object; checked before the form is posted
|
||||
function extraParamsValid($form) {
|
||||
var $f = $form.find('input[name="oauth_extra_params"]');
|
||||
if (!$f.length || $f.prop('disabled') || !$f.is(':visible')) return true;
|
||||
var v = $.trim($f.val() || '');
|
||||
if (v === '') { $f.removeClass('inputMissingAttr'); return true; }
|
||||
var ok = false;
|
||||
try {
|
||||
var o = JSON.parse(v);
|
||||
ok = (o !== null && typeof o === 'object' && !Array.isArray(o));
|
||||
} catch (e) { ok = false; }
|
||||
$f.toggleClass('inputMissingAttr', !ok);
|
||||
if (!ok) mailcow_alert_box(lang_danger.imapsync_source_oauth_extra_invalid, 'danger');
|
||||
return ok;
|
||||
}
|
||||
|
||||
function copyField(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (!el) return;
|
||||
var done = function() { mailcow_alert_box(L('copied'), 'success'); };
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(el.value).then(done);
|
||||
} else {
|
||||
el.select();
|
||||
try { document.execCommand('copy'); done(); } catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Wiring =====
|
||||
|
||||
function bind() {
|
||||
injectStyle();
|
||||
|
||||
// custom params editor
|
||||
$(document).on('change', '.imapsync-cp-opt', function() {
|
||||
cpApplyValueState($(this).closest('.imapsync-cp-row'));
|
||||
cpSerialize($(this).closest('.imapsync-cp-editor'));
|
||||
});
|
||||
$(document).on('input', '.imapsync-cp-val', function() {
|
||||
cpSerialize($(this).closest('.imapsync-cp-editor'));
|
||||
});
|
||||
$(document).on('click', '.imapsync-cp-add', function() {
|
||||
var $editor = $(this).closest('.imapsync-cp-editor');
|
||||
cpEnsureOptions(function() { cpAddRow($editor); });
|
||||
});
|
||||
$(document).on('click', '.imapsync-cp-remove', function() {
|
||||
var $editor = $(this).closest('.imapsync-cp-editor');
|
||||
$(this).closest('.imapsync-cp-row').remove();
|
||||
cpSerialize($editor);
|
||||
});
|
||||
|
||||
// job form
|
||||
$(document).on('change', 'select.imapsync-source-select', function() {
|
||||
applySourceSelection($(this));
|
||||
});
|
||||
// a token is bound to the local mailbox: changing it drops the connect result
|
||||
$(document).on('change', 'form[data-id="add_syncjob"] select[name="username"]', function() {
|
||||
var $form = $(this).closest('form');
|
||||
var $user1 = $form.find('input[name="user1"]');
|
||||
if ($user1.prop('readonly')) {
|
||||
$user1.prop('readonly', false).val('');
|
||||
setConnectStatus($form, '', '');
|
||||
}
|
||||
});
|
||||
$(document).on('click', '.imapsync-oauth-connect-btn', function() {
|
||||
startConnect($(this).closest('form'));
|
||||
});
|
||||
window.addEventListener('message', onOauthMessage);
|
||||
$(document).on('click', '.imapsync-open-source-modal', function(e) {
|
||||
e.preventDefault();
|
||||
var $current = $(this).closest('.modal');
|
||||
var open = function() { $('#addImapsyncSourceModal').modal('show'); };
|
||||
if ($current.length) {
|
||||
$current.one('hidden.bs.modal', open).modal('hide');
|
||||
} else {
|
||||
open();
|
||||
}
|
||||
});
|
||||
// authorization_code: user1 comes from the provider sign-in, require it before posting
|
||||
$('[data-action="add_item"][data-id="add_syncjob"], [data-action="edit_selected"][data-id="editsyncjob"]').on('click', function(e) {
|
||||
var $form = $(this).closest('form');
|
||||
var $opt = $form.find('select.imapsync-source-select option:selected');
|
||||
if ($opt.attr('data-auth-type') === 'XOAUTH2' && $opt.attr('data-oauth-flow') === 'authorization_code' &&
|
||||
!$.trim($form.find('input[name="user1"]').val() || '')) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
setConnectStatus($form, 'danger', lang_danger.imapsync_source_oauth_connect_required);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// source editor
|
||||
$(document).on('change', 'select.imapsync-source-auth-type, select.imapsync-source-oauth-flow', function() {
|
||||
sourceFormToggle($(this).closest('form'));
|
||||
});
|
||||
$(document).on('change', 'select.imapsync-source-scope', 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'))) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
return false;
|
||||
}
|
||||
});
|
||||
$(document).on('click', '.imapsync-copy-btn', function(e) {
|
||||
e.preventDefault();
|
||||
copyField($(this).data('copy-target'));
|
||||
});
|
||||
|
||||
// modals
|
||||
$(document).on('shown.bs.modal', '#addImapsyncSourceModal', function() {
|
||||
var $form = $(this).find('form');
|
||||
sourceFormToggle($form);
|
||||
sourceScopeToggle($form);
|
||||
});
|
||||
$(document).on('shown.bs.modal', '#addSyncJobModalAdmin, #addSyncJobModal', function() {
|
||||
populateSourceSelects();
|
||||
cpInit($(this));
|
||||
});
|
||||
|
||||
// edit page: forms are already rendered
|
||||
$('form[data-id="edit_imapsync_source"]').each(function() {
|
||||
sourceFormToggle($(this));
|
||||
sourceScopeToggle($(this));
|
||||
});
|
||||
$('form[data-id="editsyncjob"]').each(function() {
|
||||
var $form = $(this);
|
||||
$form.find('input[name="user1"]').data('initial', $form.find('input[name="user1"]').val());
|
||||
populateSourceSelects();
|
||||
cpInit($form);
|
||||
});
|
||||
}
|
||||
|
||||
$(document).ready(bind);
|
||||
|
||||
return {
|
||||
drawSourceTable: drawSourceTable,
|
||||
jobTableStateSave: jobTableStateSave,
|
||||
jobTableStateLoad: jobTableStateLoad,
|
||||
prepareJobRow: prepareJobRow,
|
||||
wrapCell: wrapCell,
|
||||
L: L
|
||||
};
|
||||
})(jQuery);
|
||||
+26
-33
@@ -2169,7 +2169,11 @@ jQuery(function($){
|
||||
"tr" +
|
||||
"<'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>",
|
||||
language: lang_datatables,
|
||||
order: [[2, 'desc']],
|
||||
// keep the backend order (C4: prio desc, never run first, least recently run)
|
||||
order: [],
|
||||
// drop state saved before the rework (it would restore the old ID sort / column visibility)
|
||||
stateSaveParams: mailcowImapsync.jobTableStateSave,
|
||||
stateLoadParams: mailcowImapsync.jobTableStateLoad,
|
||||
initComplete: function(){
|
||||
hideTableExpandCollapseBtn('#tab-syncjobs', '#sync_job_table');
|
||||
},
|
||||
@@ -2177,40 +2181,21 @@ jQuery(function($){
|
||||
type: "GET",
|
||||
url: "/api/v1/get/syncjobs/all/no_log",
|
||||
dataSrc: function(json){
|
||||
if (!Array.isArray(json)) return [];
|
||||
$.each(json, function (i, item) {
|
||||
item.log = '<a href="#syncjobLogModal" data-bs-toggle="modal" data-syncjob-id="' + encodeURIComponent(item.id) + '">' + lang.open_logs + '</a>'
|
||||
item.user2 = escapeHtml(item.user2);
|
||||
if (!item.exclude > 0) {
|
||||
item.exclude = '-';
|
||||
} else {
|
||||
item.exclude = '<code>' + escapeHtml(item.exclude) + '</code>';
|
||||
}
|
||||
item.server_w_port = escapeHtml(item.user1) + '@' + escapeHtml(item.host1) + ':' + escapeHtml(item.port1);
|
||||
mailcowImapsync.prepareJobRow(item);
|
||||
item.user2 = mailcowImapsync.wrapCell(escapeHtml(item.user2));
|
||||
item.action = '<div class="btn-group">' +
|
||||
'<a href="/edit/syncjob/' + item.id + '" class="btn btn-sm btn-xs-lg btn-xs-half btn-secondary"><i class="bi bi-pencil-fill"></i> ' + lang.edit + '</a>' +
|
||||
'<a href="#syncjobLogModal" data-bs-toggle="modal" data-syncjob-id="' + encodeURIComponent(item.id) + '" class="btn btn-sm btn-xs-lg btn-xs-half btn-info"><i class="bi bi-card-list"></i> ' + lang.open_logs + '</a>' +
|
||||
'<a href="#" data-action="delete_selected" data-id="single-syncjob" data-api-url="delete/syncjob" data-item="' + item.id + '" class="btn btn-sm btn-xs-lg btn-xs-half btn-danger"><i class="bi bi-trash"></i> ' + lang.remove + '</a>' +
|
||||
'</div>';
|
||||
item.chkbox = '<input type="checkbox" class="form-check-input" data-id="syncjob" name="multi_select" value="' + item.id + '" />';
|
||||
if (item.is_running == 1) {
|
||||
item.is_running = '<span id="active-script" class="badge fs-6 bg-success">' + lang.running + '</span>';
|
||||
} else {
|
||||
item.is_running = '<span id="inactive-script" class="badge fs-6 bg-warning">' + lang.waiting + '</span>';
|
||||
}
|
||||
if (!item.last_run > 0) {
|
||||
item.last_run = lang.waiting;
|
||||
}
|
||||
if (item.success == null) {
|
||||
item.success = '-';
|
||||
item.exit_status = '';
|
||||
} else {
|
||||
item.success = '<i class="text-' + (item.success == 1 ? 'success' : 'danger') + ' bi bi-' + (item.success == 1 ? 'check-lg' : 'x-lg') + '"></i>';
|
||||
}
|
||||
if (lang['syncjob_'+item.exit_status]) {
|
||||
item.exit_status = lang['syncjob_'+item.exit_status];
|
||||
} else if (item.success != '-') {
|
||||
item.exit_status = lang.syncjob_check_log;
|
||||
}
|
||||
item.exit_status = item.success + ' ' + item.exit_status;
|
||||
});
|
||||
|
||||
return json;
|
||||
@@ -2235,40 +2220,46 @@ jQuery(function($){
|
||||
responsivePriority: 2
|
||||
},
|
||||
{
|
||||
title: 'ID',
|
||||
title: mailcowImapsync.L('id'),
|
||||
data: 'id',
|
||||
responsivePriority: 3,
|
||||
defaultContent: ''
|
||||
},
|
||||
{
|
||||
title: lang.owner,
|
||||
title: lang.syncjobs.prio,
|
||||
data: 'prio',
|
||||
visible: role === 'admin',
|
||||
responsivePriority: 3,
|
||||
defaultContent: ''
|
||||
},
|
||||
{
|
||||
title: lang.syncjobs.user2_label,
|
||||
data: 'user2',
|
||||
responsivePriority: 4,
|
||||
defaultContent: ''
|
||||
},
|
||||
{
|
||||
title: 'Server',
|
||||
title: lang.syncjobs.source_server,
|
||||
data: 'server_w_port',
|
||||
responsivePriority: 6,
|
||||
defaultContent: ''
|
||||
},
|
||||
{
|
||||
title: lang.last_run,
|
||||
data: 'last_run',
|
||||
responsivePriority: 9,
|
||||
defaultContent: ''
|
||||
},
|
||||
{
|
||||
title: lang.syncjob_last_run_result,
|
||||
title: lang.syncjobs.syncjob_last_run_result,
|
||||
data: 'exit_status',
|
||||
defaultContent: ''
|
||||
},
|
||||
{
|
||||
title: 'Log',
|
||||
data: 'log',
|
||||
responsivePriority: 8,
|
||||
defaultContent: ''
|
||||
},
|
||||
{
|
||||
title: lang.active,
|
||||
data: 'active',
|
||||
responsivePriority: 10,
|
||||
defaultContent: '',
|
||||
render: function (data, type) {
|
||||
return 1==data?'<i class="bi bi-check-lg"><span class="sorting-value">1</span></i>':0==data&&'<i class="bi bi-x-lg"><span class="sorting-value">0</span></i>';
|
||||
@@ -2277,6 +2268,7 @@ jQuery(function($){
|
||||
{
|
||||
title: lang.status,
|
||||
data: 'is_running',
|
||||
responsivePriority: 7,
|
||||
defaultContent: ''
|
||||
},
|
||||
{
|
||||
@@ -2486,6 +2478,7 @@ jQuery(function($){
|
||||
onVisible("[id^=alias_table]", () => draw_alias_table());
|
||||
onVisible("[id^=aliasdomain_table]", () => draw_aliasdomain_table());
|
||||
onVisible("[id^=sync_job_table]", () => draw_sync_job_table());
|
||||
onVisible("[id^=imapsync_source_table]", () => mailcowImapsync.drawSourceTable(role === 'admin' || acl_data.syncjobs === 1));
|
||||
onVisible("[id^=filter_table]", () => draw_filter_table());
|
||||
onVisible("[id^=bcc_table]", () => draw_bcc_table());
|
||||
onVisible("[id^=recipient_map_table]", () => draw_recipient_map_table());
|
||||
|
||||
+18
-44
@@ -272,22 +272,27 @@ jQuery(function($){
|
||||
"tr" +
|
||||
"<'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>",
|
||||
language: lang_datatables,
|
||||
// keep the backend order (C4: prio desc, never run first, least recently run)
|
||||
order: [],
|
||||
// drop state saved before the rework (it would restore the old ID sort / column visibility)
|
||||
stateSaveParams: mailcowImapsync.jobTableStateSave,
|
||||
stateLoadParams: mailcowImapsync.jobTableStateLoad,
|
||||
ajax: {
|
||||
type: "GET",
|
||||
url: '/api/v1/get/syncjobs/' + encodeURIComponent(mailcow_cc_username) + '/no_log',
|
||||
dataSrc: function(data){
|
||||
if (!Array.isArray(data)) return [];
|
||||
$.each(data, function (i, item) {
|
||||
item.user1 = escapeHtml(item.user1);
|
||||
item.log = '<a href="#syncjobLogModal" data-bs-toggle="modal" data-syncjob-id="' + item.id + '">' + lang.open_logs + '</a>'
|
||||
if (!item.exclude > 0) {
|
||||
item.exclude = '-';
|
||||
} else {
|
||||
item.exclude = '<code>' + escapeHtml(item.exclude) + '</code>';
|
||||
}
|
||||
item.server_w_port = escapeHtml(item.user1 + '@' + item.host1 + ':' + item.port1);
|
||||
mailcowImapsync.prepareJobRow(item);
|
||||
if (acl_data.syncjobs === 1) {
|
||||
item.action = '<div class="btn-group">' +
|
||||
'<a href="/edit/syncjob/' + item.id + '" class="btn btn-xs btn-xs-half btn-secondary"><i class="bi bi-pencil-fill"></i> ' + lang.edit + '</a>' +
|
||||
'<a href="#syncjobLogModal" data-bs-toggle="modal" data-syncjob-id="' + item.id + '" class="btn btn-xs btn-xs-half btn-info"><i class="bi bi-card-list"></i> ' + lang.open_logs + '</a>' +
|
||||
'<a href="#" data-action="delete_selected" data-id="single-syncjob" data-api-url="delete/syncjob" data-item="' + item.id + '" class="btn btn-xs btn-xs-half btn-danger"><i class="bi bi-trash"></i> ' + lang.remove + '</a>' +
|
||||
'</div>';
|
||||
item.chkbox = '<input type="checkbox" class="form-check-input" data-id="syncjob" name="multi_select" value="' + item.id + '" />';
|
||||
@@ -296,26 +301,6 @@ jQuery(function($){
|
||||
item.action = '<span>-</span>';
|
||||
item.chkbox = '<input type="checkbox" class="form-check-input" disabled />';
|
||||
}
|
||||
if (item.is_running == 1) {
|
||||
item.is_running = '<span id="active-script" class="badge fs-6 bg-success">' + lang.running + '</span>';
|
||||
} else {
|
||||
item.is_running = '<span id="inactive-script" class="badge fs-6 bg-warning">' + lang.waiting + '</span>';
|
||||
}
|
||||
if (!item.last_run > 0) {
|
||||
item.last_run = lang.waiting;
|
||||
}
|
||||
if (item.success == null) {
|
||||
item.success = '-';
|
||||
item.exit_status = '';
|
||||
} else {
|
||||
item.success = '<i class="text-' + (item.success == 1 ? 'success' : 'danger') + ' bi bi-' + (item.success == 1 ? 'check-lg' : 'x-lg') + '"></i>';
|
||||
}
|
||||
if (lang['syncjob_'+item.exit_status]) {
|
||||
item.exit_status = lang['syncjob_'+item.exit_status];
|
||||
} else if (item.success != '-') {
|
||||
item.exit_status = lang.syncjob_check_log;
|
||||
}
|
||||
item.exit_status = item.success + ' ' + item.exit_status;
|
||||
});
|
||||
|
||||
return data;
|
||||
@@ -340,41 +325,34 @@ jQuery(function($){
|
||||
responsivePriority: 2
|
||||
},
|
||||
{
|
||||
title: 'ID',
|
||||
title: mailcowImapsync.L('id'),
|
||||
data: 'id',
|
||||
defaultContent: '',
|
||||
responsivePriority: 3
|
||||
},
|
||||
{
|
||||
title: 'Server',
|
||||
title: lang.syncjobs.source_server,
|
||||
data: 'server_w_port',
|
||||
defaultContent: ''
|
||||
},
|
||||
{
|
||||
title: lang.username,
|
||||
data: 'user1',
|
||||
defaultContent: '',
|
||||
responsivePriority: 3
|
||||
responsivePriority: 4
|
||||
},
|
||||
{
|
||||
title: lang.last_run,
|
||||
data: 'last_run',
|
||||
defaultContent: ''
|
||||
defaultContent: '',
|
||||
responsivePriority: 7
|
||||
},
|
||||
{
|
||||
title: lang.syncjob_last_run_result,
|
||||
title: lang.syncjobs.syncjob_last_run_result,
|
||||
data: 'exit_status',
|
||||
defaultContent: ''
|
||||
},
|
||||
{
|
||||
title: 'Log',
|
||||
data: 'log',
|
||||
defaultContent: ''
|
||||
defaultContent: '',
|
||||
responsivePriority: 6
|
||||
},
|
||||
{
|
||||
title: lang.active,
|
||||
data: 'active',
|
||||
defaultContent: '',
|
||||
responsivePriority: 8,
|
||||
render: function (data, type) {
|
||||
return 1==data?'<i class="bi bi-check-lg"></i>':0==data&&'<i class="bi bi-x-lg"></i>'
|
||||
}
|
||||
@@ -385,11 +363,6 @@ jQuery(function($){
|
||||
defaultContent: '',
|
||||
responsivePriority: 5
|
||||
},
|
||||
{
|
||||
title: lang.encryption,
|
||||
data: 'enc1',
|
||||
defaultContent: ''
|
||||
},
|
||||
{
|
||||
title: lang.excludes,
|
||||
data: 'exclude',
|
||||
@@ -699,6 +672,7 @@ jQuery(function($){
|
||||
onVisible("[id^=bl_policy_mailbox_table]", () => draw_bl_policy_mailbox_table());
|
||||
onVisible("[id^=wl_policy_mailbox_table]", () => draw_wl_policy_mailbox_table());
|
||||
onVisible("[id^=sync_job_table]", () => draw_sync_job_table());
|
||||
onVisible("[id^=imapsync_source_table]", () => mailcowImapsync.drawSourceTable(acl_data.syncjobs === 1));
|
||||
onVisible("[id^=app_passwd_table]", () => draw_app_passwd_table());
|
||||
onVisible("[id^=recent-logins]", () => last_logins('get'));
|
||||
});
|
||||
+53
-51
@@ -19,7 +19,7 @@ function api_log($_data) {
|
||||
if ($value) {
|
||||
if (is_array($value)) unset($value["csrf_token"]);
|
||||
foreach ($value as $key => &$val) {
|
||||
if(preg_match("/pass/i", $key)) {
|
||||
if(preg_match("/pass|secret|oauth_extra_params/i", $key)) {
|
||||
$val = '*';
|
||||
}
|
||||
}
|
||||
@@ -317,7 +317,10 @@ if (isset($_GET['query'])) {
|
||||
process_add_return(admin('add', $attr));
|
||||
break;
|
||||
case "syncjob":
|
||||
process_add_return(mailbox('add', 'syncjob', $attr));
|
||||
process_add_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->addJob($attr));
|
||||
break;
|
||||
case "syncjob_source":
|
||||
process_add_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->addSource($attr));
|
||||
break;
|
||||
case "bcc":
|
||||
process_add_return(bcc('add', $attr));
|
||||
@@ -1046,61 +1049,37 @@ if (isset($_GET['query'])) {
|
||||
}
|
||||
process_get_return($data);
|
||||
break;
|
||||
case "syncjob_options":
|
||||
process_get_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getOptions());
|
||||
break;
|
||||
case "imapsync_settings":
|
||||
process_get_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getSettings());
|
||||
break;
|
||||
case "syncjob_source":
|
||||
switch ($object) {
|
||||
case "all":
|
||||
process_get_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getSources());
|
||||
break;
|
||||
default:
|
||||
process_get_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getSource($object));
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case "syncjobs":
|
||||
switch ($object) {
|
||||
case "all":
|
||||
$domains = mailbox('get', 'domains');
|
||||
if (!empty($domains)) {
|
||||
foreach ($domains as $domain) {
|
||||
$mailboxes = mailbox('get', 'mailboxes', $domain);
|
||||
if (!empty($mailboxes)) {
|
||||
foreach ($mailboxes as $mailbox) {
|
||||
$syncjobs = mailbox('get', 'syncjobs', $mailbox);
|
||||
if (!empty($syncjobs)) {
|
||||
foreach ($syncjobs as $syncjob) {
|
||||
if (isset($extra)) {
|
||||
$details = mailbox('get', 'syncjob_details', $syncjob, explode(',', $extra));
|
||||
}
|
||||
else {
|
||||
$details = mailbox('get', 'syncjob_details', $syncjob);
|
||||
}
|
||||
if ($details) {
|
||||
$data[] = $details;
|
||||
}
|
||||
else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Admins see every job (incl. orphaned ones), others the jobs of the mailboxes they manage
|
||||
$mailboxes = array();
|
||||
if ($_SESSION['mailcow_cc_role'] != 'admin') {
|
||||
foreach (mailbox('get', 'domains') ?: array() as $domain) {
|
||||
$mailboxes = array_merge($mailboxes, mailbox('get', 'mailboxes', $domain) ?: array());
|
||||
}
|
||||
process_get_return($data);
|
||||
}
|
||||
else {
|
||||
echo '{}';
|
||||
}
|
||||
process_get_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getAllJobs($extra, $mailboxes));
|
||||
break;
|
||||
|
||||
default:
|
||||
$syncjobs = mailbox('get', 'syncjobs', $object);
|
||||
if (!empty($syncjobs)) {
|
||||
foreach ($syncjobs as $syncjob) {
|
||||
if (isset($extra)) {
|
||||
$details = mailbox('get', 'syncjob_details', $syncjob, explode(',', $extra));
|
||||
}
|
||||
else {
|
||||
$details = mailbox('get', 'syncjob_details', $syncjob);
|
||||
}
|
||||
if ($details) {
|
||||
$data[] = $details;
|
||||
}
|
||||
else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
process_get_return($data);
|
||||
process_get_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getJobs($object, $extra));
|
||||
break;
|
||||
}
|
||||
break;
|
||||
@@ -1742,7 +1721,10 @@ if (isset($_GET['query'])) {
|
||||
process_delete_return(rsettings('delete', array('id' => $items)));
|
||||
break;
|
||||
case "syncjob":
|
||||
process_delete_return(mailbox('delete', 'syncjob', array('id' => $items)));
|
||||
process_delete_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->deleteJobs($items));
|
||||
break;
|
||||
case "syncjob_source":
|
||||
process_delete_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->deleteSources($items));
|
||||
break;
|
||||
case "filter":
|
||||
process_delete_return(mailbox('delete', 'filter', array('id' => $items)));
|
||||
@@ -1968,7 +1950,27 @@ if (isset($_GET['query'])) {
|
||||
}
|
||||
break;
|
||||
case "syncjob":
|
||||
process_edit_return(mailbox('edit', 'syncjob', array_merge(array('id' => $items), $attr)));
|
||||
switch ($object) {
|
||||
case "run_next":
|
||||
process_edit_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->runNext($items));
|
||||
break;
|
||||
default:
|
||||
process_edit_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->editJobs($items, $attr));
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case "imapsync_settings":
|
||||
process_edit_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->editSettings($attr));
|
||||
break;
|
||||
case "syncjob_source":
|
||||
switch ($object) {
|
||||
case "refresh_token":
|
||||
process_edit_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->refreshSourceToken($items));
|
||||
break;
|
||||
default:
|
||||
process_edit_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->editSources($items, $attr));
|
||||
break;
|
||||
}
|
||||
break;
|
||||
case "filter":
|
||||
process_edit_return(mailbox('edit', 'filter', array_merge(array('id' => $items), $attr)));
|
||||
|
||||
+157
-52
@@ -45,24 +45,16 @@
|
||||
"app_name": "App name",
|
||||
"app_password": "Add app password",
|
||||
"app_passwd_protocols": "Allowed protocols for app password",
|
||||
"automap": "Try to automap folders (\"Sent items\", \"Sent\" => \"Sent\" etc.)",
|
||||
"backup_mx_options": "Relay options",
|
||||
"bcc_dest_format": "BCC destination must be a single valid email address.<br>If you need to send a copy to multiple addresses, create an alias and use it here.",
|
||||
"comment_info": "A private comment is not visible to the user, while a public comment is shown as tooltip when hovering it in a user's overview",
|
||||
"custom_params": "Custom parameters",
|
||||
"custom_params_hint": "Right: --param=xy, wrong: --param xy",
|
||||
"delete1": "Delete from source when completed",
|
||||
"delete2": "Delete messages on destination that are not on source",
|
||||
"delete2duplicates": "Delete duplicates on destination",
|
||||
"description": "Description",
|
||||
"destination": "Destination",
|
||||
"disable_login": "Disallow login (incoming mail is still accepted)",
|
||||
"domain": "Domain",
|
||||
"domain_matches_hostname": "Domain %s matches hostname",
|
||||
"domain_quota_m": "Total domain quota (MiB)",
|
||||
"dry": "Simulate synchronization",
|
||||
"enc_method": "Encryption method",
|
||||
"exclude": "Exclude objects (regex)",
|
||||
"full_name": "Full name",
|
||||
"gal": "Global Address List",
|
||||
"gal_info": "The GAL contains all objects of a domain and cannot be edited by any user. Free/busy information in SOGo is missing, if disabled! <b>Restart SOGo to apply changes.</b>",
|
||||
@@ -81,7 +73,6 @@
|
||||
"mailbox_username": "Username (left part of an email address)",
|
||||
"max_aliases": "Max. possible aliases",
|
||||
"max_mailboxes": "Max. possible mailboxes",
|
||||
"mins_interval": "Polling interval (minutes)",
|
||||
"multiple_bookings": "Multiple bookings",
|
||||
"nexthop": "Next hop",
|
||||
"password": "Password",
|
||||
@@ -101,16 +92,10 @@
|
||||
"select_domain": "Please select a domain first",
|
||||
"sieve_desc": "Short description",
|
||||
"sieve_type": "Filter type",
|
||||
"skipcrossduplicates": "Skip duplicate messages across folders (first come, first serve)",
|
||||
"subscribeall": "Subscribe all folders",
|
||||
"syncjob": "Add sync job",
|
||||
"syncjob_hint": "Be aware that passwords need to be saved plain-text!",
|
||||
"tags": "Tags",
|
||||
"target_address": "Goto addresses",
|
||||
"target_address_info": "<small>Full email address/es (comma-separated).</small>",
|
||||
"target_domain": "Target domain",
|
||||
"timeout1": "Timeout for connection to remote host",
|
||||
"timeout2": "Timeout for connection to local host",
|
||||
"username": "Username",
|
||||
"validate": "Validate",
|
||||
"validation_success": "Validated successfully"
|
||||
@@ -418,7 +403,12 @@
|
||||
"user_quicklink": "Hide Quicklink to User Login Page",
|
||||
"validate_license_now": "Validate GUID against license server",
|
||||
"verify": "Verify",
|
||||
"yes": "✓"
|
||||
"yes": "✓",
|
||||
"syncjobs": "Sync jobs",
|
||||
"syncjob_max_parallel": "Max. parallel sync processes",
|
||||
"syncjob_max_parallel_info": "How many sync jobs may run at the same time (1-32).",
|
||||
"syncjob_max_kb_per_second": "Bandwidth limit per process (KB/s)",
|
||||
"syncjob_max_kb_per_second_info": "0 = unlimited. Caps every sync process in KB/s; a per-job limit (bytes/s) is capped by this. Total ≈ this × max parallel."
|
||||
},
|
||||
"danger": {
|
||||
"access_denied": "Access denied or invalid form data",
|
||||
@@ -567,7 +557,30 @@
|
||||
"validity_missing": "Please assign a period of validity",
|
||||
"value_missing": "Please provide all values",
|
||||
"version_invalid": "Version %s is invalid",
|
||||
"yotp_verification_failed": "Yubico OTP verification failed: %s"
|
||||
"yotp_verification_failed": "Yubico OTP verification failed: %s",
|
||||
"imapsync_source_in_use": "Sync source %s is still used by a sync job and cannot be deleted",
|
||||
"imapsync_source_token_refresh_failed": "OAuth token refresh for %s failed",
|
||||
"imapsync_source_scope_empty": "Select at least one domain for the chosen visibility",
|
||||
"imapsync_source_unavailable": "The selected sync source is unavailable",
|
||||
"imapsync_source_oauth_connect_required": "Sign in with the provider before saving this sync job",
|
||||
"imapsync_source_name_invalid": "Invalid sync source name",
|
||||
"imapsync_source_host_invalid": "Invalid host name or IP address",
|
||||
"imapsync_source_port_invalid": "Invalid port",
|
||||
"imapsync_source_enc_invalid": "Invalid encryption method",
|
||||
"imapsync_source_auth_invalid": "Invalid authentication method",
|
||||
"imapsync_custom_param_invalid": "Custom parameters contain a disallowed imapsync option: %s",
|
||||
"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_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",
|
||||
"imapsync_source_refresh_unsupported": "Manual token refresh is only available for client credentials sources",
|
||||
"imapsync_custom_params_format": "Custom parameters must be sent as a list of option/value pairs",
|
||||
"imapsync_custom_param_value": "Invalid value for imapsync option %s (missing value, value on a flag or disallowed value)",
|
||||
"imapsync_job_running": "Sync job %s is currently running and cannot be scheduled next",
|
||||
"imapsync_user1_invalid": "Invalid remote username",
|
||||
"imapsync_max_kb_per_second_invalid": "Bandwidth limit must be between 0 and 10485760 KB/s"
|
||||
},
|
||||
"datatables": {
|
||||
"collapse_all": "Collapse All",
|
||||
@@ -656,7 +669,6 @@
|
||||
"app_name": "App name",
|
||||
"app_passwd": "App password",
|
||||
"app_passwd_protocols": "Allowed protocols for app password",
|
||||
"automap": "Try to automap folders (\"Sent items\", \"Sent\" => \"Sent\" etc.)",
|
||||
"backup_mx_options": "Relay options",
|
||||
"bcc_dest_format": "BCC destination must be a single valid email address.<br>If you need to send a copy to multiple addresses, create an alias and use it here.",
|
||||
"client_id": "Client ID",
|
||||
@@ -664,9 +676,6 @@
|
||||
"comment_info": "A private comment is not visible to the user, while a public comment is shown as tooltip when hovering it in a user's overview",
|
||||
"created_on": "Created on",
|
||||
"custom_attributes": "Custom attributes",
|
||||
"delete1": "Delete from source when completed",
|
||||
"delete2": "Delete messages on destination that are not on source",
|
||||
"delete2duplicates": "Delete duplicates on destination",
|
||||
"delete_ays": "Please confirm the deletion process.",
|
||||
"description": "Description",
|
||||
"disable_login": "Disallow login (incoming mail is still accepted)",
|
||||
@@ -690,7 +699,6 @@
|
||||
"dont_check_sender_acl": "Disable sender check for domain %s (+ alias domains)",
|
||||
"edit_alias_domain": "Edit Alias domain",
|
||||
"encryption": "Encryption",
|
||||
"exclude": "Exclude objects (regex)",
|
||||
"extended_sender_acl": "External sender addresses",
|
||||
"extended_sender_acl_info": "A DKIM domain key should be imported, if available.<br>\r\n Remember to add this server to the corresponding SPF TXT record.<br>\r\n Whenever a domain or alias domain is added to this server, that overlaps with an external address, the external address is removed.<br>\r\n Use @domain.tld to allow to send as *@domain.tld.",
|
||||
"force_pw_update": "Force password update at next login",
|
||||
@@ -721,10 +729,7 @@
|
||||
"max_aliases": "Max. aliases",
|
||||
"max_mailboxes": "Max. possible mailboxes",
|
||||
"max_quota": "Max. quota per mailbox (MiB)",
|
||||
"maxage": "Maximum age of messages in days that will be polled from remote<br><small>(0 = ignore age)</small>",
|
||||
"maxbytespersecond": "Max. bytes per second <br><small>(0 = unlimited)</small>",
|
||||
"mbox_rl_info": "This rate limit is applied on the SASL login name, it matches any \"from\" address used by the logged-in user. A mailbox rate limit overrides a domain-wide rate limit.",
|
||||
"mins_interval": "Interval (min)",
|
||||
"mta_sts": "MTA-STS",
|
||||
"mta_sts_info": "<a href='https://en.wikipedia.org/wiki/Simple_Mail_Transfer_Protocol#SMTP_MTA_Strict_Transport_Security' target='_blank'>MTA-STS</a> is a standard that enforces email delivery between mail servers to use TLS with valid certificates. <br>It is used when <a target='_blank' href='https://en.wikipedia.org/wiki/DNS-based_Authentication_of_Named_Entities'>DANE</a> is not possible due to missing or unsupported DNSSEC.<br><b>Note</b>: If the receiving domain supports DANE with DNSSEC, DANE is <b>always</b> preferred – MTA-STS only acts as a fallback.",
|
||||
"mta_sts_version": "Version",
|
||||
@@ -777,7 +782,6 @@
|
||||
"sender_acl_info": "If mailbox user A is allowed to send as mailbox user B, the sender address is not automatically displayed as selectable \"from\" field in SOGo.<br>\r\n Mailbox user B needs to create a delegation in SOGo to allow mailbox user A to select their address as sender. To delegate a mailbox in SOGo, use the menu (three dots) to the right of your mailbox name in the upper left while in mail view. This behaviour does not apply to alias addresses.",
|
||||
"sieve_desc": "Short description",
|
||||
"sieve_type": "Filter type",
|
||||
"skipcrossduplicates": "Skip duplicate messages across folders (first come, first serve)",
|
||||
"sogo_access": "Direct forwarding to SOGo",
|
||||
"sogo_access_info": "After logging in, the user is automatically redirected to SOGo.",
|
||||
"sogo_visible": "Alias is visible in SOGo",
|
||||
@@ -786,12 +790,8 @@
|
||||
"spam_filter": "Spam filter",
|
||||
"spam_policy": "Add or remove items to allow-/denylist",
|
||||
"spam_score": "Set a custom spam score",
|
||||
"subfolder2": "Sync into subfolder on destination<br><small>(empty = do not use subfolder)</small>",
|
||||
"syncjob": "Edit sync job",
|
||||
"target_address": "Goto address/es <small>(comma-separated)</small>",
|
||||
"target_domain": "Target domain",
|
||||
"timeout1": "Timeout for connection to remote host",
|
||||
"timeout2": "Timeout for connection to local host",
|
||||
"title": "Edit object",
|
||||
"unchanged_if_empty": "If unchanged leave blank",
|
||||
"username": "Username",
|
||||
@@ -1013,16 +1013,6 @@
|
||||
"spam_aliases": "Temp. alias",
|
||||
"stats": "Statistics",
|
||||
"status": "Status",
|
||||
"sync_jobs": "Sync jobs",
|
||||
"syncjob_check_log": "Check log",
|
||||
"syncjob_last_run_result": "Last run result",
|
||||
"syncjob_EX_OK": "Success",
|
||||
"syncjob_EXIT_CONNECTION_FAILURE": "Connection problem",
|
||||
"syncjob_EXIT_TLS_FAILURE": "Problem with encrypted connection",
|
||||
"syncjob_EXIT_AUTHENTICATION_FAILURE": "Authentication problem",
|
||||
"syncjob_EXIT_OVERQUOTA": "Target mailbox is over quota",
|
||||
"syncjob_EXIT_CONNECTION_FAILURE_HOST1": "Can't connect to remote server",
|
||||
"syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Wrong username or password",
|
||||
"table_size": "Table size",
|
||||
"table_size_show_n": "Show %s items",
|
||||
"target_address": "Goto address",
|
||||
@@ -1225,7 +1215,13 @@
|
||||
"verified_fido2_login": "Verified FIDO2 login",
|
||||
"verified_totp_login": "Verified TOTP login",
|
||||
"verified_webauthn_login": "Verified WebAuthn login",
|
||||
"verified_yotp_login": "Verified Yubico OTP login"
|
||||
"verified_yotp_login": "Verified Yubico OTP login",
|
||||
"imapsync_source_added": "Sync source %s has been added",
|
||||
"imapsync_source_modified": "Sync source %s has been saved",
|
||||
"imapsync_source_deleted": "Sync source %s has been deleted",
|
||||
"imapsync_source_token_refreshed": "OAuth token for %s has been refreshed",
|
||||
"max_parallel_saved": "Sync job settings saved",
|
||||
"imapsync_run_next_done": "%s sync job(s) moved to the front of the queue"
|
||||
},
|
||||
"tfa": {
|
||||
"authenticators": "Authenticators",
|
||||
@@ -1260,6 +1256,126 @@
|
||||
"waiting_usb_register": "<i>Waiting for USB device...</i><br><br>Please enter your password above and confirm your registration by tapping the button on your USB device.",
|
||||
"yubi_otp": "Yubico OTP authentication"
|
||||
},
|
||||
"syncjobs": {
|
||||
"sources_panel": "Sync sources",
|
||||
"sources_hint": "Sync sources define IMAP servers (and their authentication method) that sync jobs can reuse. Define each source once, then pick it from the dropdown when creating sync jobs.",
|
||||
"source_add": "Add source",
|
||||
"source": "Source",
|
||||
"source_name": "Name",
|
||||
"source_description": "Description",
|
||||
"source_scope": "Visibility",
|
||||
"source_scope_all": "All users",
|
||||
"source_scope_domain": "Specific domains",
|
||||
"source_scope_user": "Specific users",
|
||||
"source_domains": "Domains",
|
||||
"source_users": "Users",
|
||||
"source_select": "Select source",
|
||||
"source_auth_type": "Authentication",
|
||||
"source_oauth_block": "OAuth2 configuration",
|
||||
"source_token_status": "Token valid until",
|
||||
"source_token_error": "Last refresh error",
|
||||
"source_refresh_now": "Refresh token now",
|
||||
"source_secret_unchanged_hint": "Leave blank to keep the stored secret.",
|
||||
"source_oauth_flow": "OAuth2 flow",
|
||||
"source_oauth_flow_app": "App (client credentials)",
|
||||
"source_oauth_flow_user": "User login (authorization code)",
|
||||
"source_flow_badge_app": "Client credentials",
|
||||
"source_flow_badge_user": "User login",
|
||||
"token_state_issued": "Token issued",
|
||||
"token_state_pending": "Token pending",
|
||||
"token_state_failed": "Token failed",
|
||||
"source_authorize_endpoint": "Authorize endpoint",
|
||||
"source_userinfo_endpoint": "Userinfo endpoint",
|
||||
"source_redirect_uri": "Redirect URI",
|
||||
"source_redirect_uri_hint": "Register this URI in the provider app (e.g. Azure). Provider sign-in only works when mailcow is opened via this host name.",
|
||||
"syncjob_oauth_connect": "Sign in with the provider",
|
||||
"syncjob_oauth_connected": "Connected",
|
||||
"sync_jobs": "Sync jobs",
|
||||
"syncjob_check_log": "Check log",
|
||||
"syncjob_last_run_result": "Last run result",
|
||||
"syncjob_EX_OK": "Success",
|
||||
"syncjob_EXIT_CONNECTION_FAILURE": "Connection problem",
|
||||
"syncjob_EXIT_TLS_FAILURE": "Problem with encrypted connection",
|
||||
"syncjob_EXIT_AUTHENTICATION_FAILURE": "Authentication problem",
|
||||
"syncjob_EXIT_OVERQUOTA": "Target mailbox is over quota",
|
||||
"syncjob_EXIT_CONNECTION_FAILURE_HOST1": "Can't connect to remote server",
|
||||
"syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Wrong username or password",
|
||||
"syncjob_OAUTH_TOKEN_NOT_READY": "OAuth token not ready (sign in again or wait for the token refresh)",
|
||||
"add": "Add sync job",
|
||||
"add_hint": "Be aware that passwords need to be saved plain-text!",
|
||||
"edit": "Edit sync job",
|
||||
"create": "Create new sync job",
|
||||
"dry": "Simulate synchronization",
|
||||
"subfolder2": "Sync into subfolder on destination<br><small>(empty = do not use subfolder)</small>",
|
||||
"mins_interval": "Polling interval (minutes)",
|
||||
"maxage": "Maximum age of messages in days that will be polled from remote<br><small>(0 = ignore age)</small>",
|
||||
"exclude": "Exclude objects (regex)",
|
||||
"automap": "Try to automap folders (\"Sent items\", \"Sent\" => \"Sent\" etc.)",
|
||||
"delete1": "Delete from source when completed",
|
||||
"delete2": "Delete messages on destination that are not on source",
|
||||
"delete2duplicates": "Delete duplicates on destination",
|
||||
"skipcrossduplicates": "Skip duplicate messages across folders (first come, first serve)",
|
||||
"subscribeall": "Subscribe all folders",
|
||||
"timeout1": "Timeout for connection to remote host",
|
||||
"timeout2": "Timeout for connection to local host",
|
||||
"maxbytespersecond": "Max. bandwidth in bytes/s<br><small>(0 = unlimited, additionally capped by the global limit per process in KB/s)</small>",
|
||||
"custom_params": "Custom parameters",
|
||||
"custom_params_hint": "Add one imapsync option per row. Only allowlisted options are accepted; the value may contain any character (spaces, regex, …) and is passed as a single argument.",
|
||||
"custom_param_option": "Option",
|
||||
"custom_param_value": "Value",
|
||||
"custom_param_no_value": "no value needed",
|
||||
"custom_param_add": "Add parameter",
|
||||
"prio": "Priority",
|
||||
"prio_hint": "Jobs with a higher priority run first. Within the same priority, jobs that never ran come first, then the least recently run ones (0 = normal).",
|
||||
"oauth_popup_title": "Sign in with the provider",
|
||||
"oauth_popup_success": "Connected. You can close this window.",
|
||||
"oauth_popup_failed": "Sign-in failed. You can close this window.",
|
||||
"oauth_error_access_denied": "You are not allowed to manage sync jobs",
|
||||
"oauth_error_mailbox_denied": "No access to the selected local mailbox",
|
||||
"oauth_error_source_unavailable": "The sync source is unavailable or not configured for provider sign-in",
|
||||
"oauth_error_provider_denied": "The provider refused the sign-in",
|
||||
"oauth_error_invalid_state": "The sign-in session expired or is invalid, please try again",
|
||||
"oauth_error_token_failed": "The provider did not issue a token (check client ID, secret and redirect URI)",
|
||||
"oauth_error_no_identity": "The provider did not return an e-mail address for the signed-in account",
|
||||
"user2_label": "Local mailbox (destination)",
|
||||
"user1_label": "Remote username (source)",
|
||||
"password1_label": "Remote password (source)",
|
||||
"no_sources": "No sync source is available. Please add a sync source first.",
|
||||
"source_list_error": "Sync sources could not be loaded",
|
||||
"source_not_visible": "(not visible)",
|
||||
"oauth_waiting": "Waiting for the provider…",
|
||||
"oauth_popup_blocked": "The sign-in window was blocked. Please allow pop-ups for this site and try again.",
|
||||
"oauth_select_mailbox": "Please select the local mailbox first",
|
||||
"oauth_error_unknown": "Sign-in failed",
|
||||
"oauth_hint": "The remote username is taken from the account used to sign in with the provider.",
|
||||
"token_state_none": "Token pending",
|
||||
"custom_params_legacy": "Legacy parameters (not migrated)",
|
||||
"custom_params_legacy_hint": "These custom parameters of the original sync job could not be converted and are ignored. Add the options you still need with the parameter editor above.",
|
||||
"custom_params_legacy_badge": "Legacy parameters",
|
||||
"custom_param_remove": "Remove parameter",
|
||||
"never_run": "Never",
|
||||
"status_inactive": "Inactive",
|
||||
"source_server": "Source server",
|
||||
"id": "ID",
|
||||
"source_created_by": "Created by",
|
||||
"source_oauth_token_endpoint": "Token endpoint",
|
||||
"source_oauth_client_id": "Client ID",
|
||||
"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_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",
|
||||
"copied": "Copied to clipboard",
|
||||
"source_view": "Show",
|
||||
"custom_params_options_error": "The list of allowed imapsync options could not be loaded",
|
||||
"source_missing": "No sync source is assigned to this job. Please select a source.",
|
||||
"oauth_window_closed": "The sign-in window was closed before a result arrived. If you completed the sign-in, reload the page or try again.",
|
||||
"custom_params_legacy_badge_title": "Legacy custom parameters that could not be converted are ignored. Open the edit page for details.",
|
||||
"status_waiting": "Waiting",
|
||||
"status_running": "Running"
|
||||
},
|
||||
"user": {
|
||||
"action": "Action",
|
||||
"active": "Active",
|
||||
@@ -1290,7 +1406,6 @@
|
||||
"clear_recent_successful_connections": "Clear seen successful connections",
|
||||
"client_configuration": "Show configuration guides for email clients and smartphones",
|
||||
"create_app_passwd": "Create app password",
|
||||
"create_syncjob": "Create new sync job",
|
||||
"created_on": "Created on",
|
||||
"daily": "Daily",
|
||||
"day": "day",
|
||||
@@ -1400,16 +1515,6 @@
|
||||
"spamfilter_wl_desc": "Allowlisted email addresses are programmed to <b>never</b> classify as spam. Wildcards may be used. A filter is only applied to direct aliases (aliases with a single target mailbox) excluding catch-all aliases and a mailbox itself.",
|
||||
"spamfilter_yellow": "Yellow: this message may be spam, will be tagged as spam and moved to your junk folder",
|
||||
"status": "Status",
|
||||
"sync_jobs": "Sync jobs",
|
||||
"syncjob_check_log": "Check log",
|
||||
"syncjob_last_run_result": "Last run result",
|
||||
"syncjob_EX_OK": "Success",
|
||||
"syncjob_EXIT_CONNECTION_FAILURE": "Connection problem",
|
||||
"syncjob_EXIT_TLS_FAILURE": "Problem with encrypted connection",
|
||||
"syncjob_EXIT_AUTHENTICATION_FAILURE": "Authentication problem",
|
||||
"syncjob_EXIT_OVERQUOTA": "Target mailbox is over quota",
|
||||
"syncjob_EXIT_CONNECTION_FAILURE_HOST1": "Can't connect to remote server",
|
||||
"syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Wrong username or password",
|
||||
"tag_handling": "Set handling for tagged mail",
|
||||
"tag_help_example": "Example for a tagged email address: me<b>+Facebook</b>@example.org",
|
||||
"tag_help_explain": "In subfolder: a new subfolder named after the tag will be created below INBOX (\"INBOX/Facebook\").<br>\r\nIn subject: the tags name will be prepended to the mails subject, example: \"[Facebook] My News\".",
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
// Per-user OAuth2 authorization_code flow for IMAP sync sources.
|
||||
// Opened as a popup from the sync-job modal:
|
||||
// ?action=start&source_id=N&mailbox=M -> redirect the logged-in user to the provider consent screen
|
||||
// (callback, ?code&state) -> exchange code, store the token for mailbox M, message the opener
|
||||
// The token is bound to the local mailbox M (owner) and the authenticated remote identity (syncjob user1).
|
||||
// The redirect URI is always https://MAILCOW_HOSTNAME/syncjob-oauth, so the UI must be used on that host.
|
||||
// postMessage only carries short error codes, the UI translates them:
|
||||
// access_denied, mailbox_denied, source_unavailable, provider_denied, invalid_state, token_failed, no_identity
|
||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/prerequisites.inc.php';
|
||||
|
||||
function syncjob_oauth_finish($ok, $payload) {
|
||||
// Renders a tiny page that hands the result back to the opener modal and closes the popup.
|
||||
global $lang;
|
||||
header('Content-Type: text/html; charset=utf-8');
|
||||
header('Cache-Control: no-store');
|
||||
$json = json_encode(array_merge(array('type' => 'syncjob_oauth', 'ok' => $ok), $payload),
|
||||
JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT);
|
||||
$title = $lang['syncjobs']['oauth_popup_title'] ?? 'Sign in with the provider';
|
||||
$text = $ok
|
||||
? ($lang['syncjobs']['oauth_popup_success'] ?? 'Connected. You can close this window.')
|
||||
: ($lang['syncjobs']['oauth_popup_failed'] ?? 'Authentication failed. You can close this window.');
|
||||
echo '<!doctype html><html><head><meta charset="utf-8"><title>' . htmlspecialchars($title) . '</title></head>'
|
||||
. '<body><p>' . htmlspecialchars($text) . '</p><script>'
|
||||
. 'try { if (window.opener) window.opener.postMessage(' . $json . ', window.location.origin); } catch (e) {}'
|
||||
. 'window.close();'
|
||||
. '</script></body></html>';
|
||||
exit;
|
||||
}
|
||||
|
||||
// Must be an authenticated session with the syncjobs ACL (user_acl / da_acl; always set for admins).
|
||||
if (empty($_SESSION['mailcow_cc_username']) || empty($_SESSION['mailcow_cc_role'])) {
|
||||
header('Location: /');
|
||||
exit;
|
||||
}
|
||||
$context = \Mailcow\Core\Context::fromSession($_SESSION);
|
||||
$imapsync = \Mailcow\Imapsync\ImapsyncModule::fromGlobals($context);
|
||||
if (!$context->hasAcl('syncjobs')) {
|
||||
syncjob_oauth_finish(false, array('error' => 'access_denied'));
|
||||
}
|
||||
|
||||
$action = isset($_GET['action']) ? $_GET['action'] : ((isset($_GET['code']) || isset($_GET['error'])) ? 'callback' : '');
|
||||
|
||||
if ($action == 'start') {
|
||||
$source_id = intval($_GET['source_id'] ?? 0);
|
||||
// C1: the token is stored for this local mailbox; default is the own mailbox (role user)
|
||||
$mailbox = (isset($_GET['mailbox']) && is_string($_GET['mailbox']) && $_GET['mailbox'] !== '')
|
||||
? strtolower(trim($_GET['mailbox'])) : $_SESSION['mailcow_cc_username'];
|
||||
if (!filter_var($mailbox, FILTER_VALIDATE_EMAIL) || !$context->canAccessMailbox($mailbox)) {
|
||||
syncjob_oauth_finish(false, array('error' => 'mailbox_denied'));
|
||||
}
|
||||
// findConnectable enforces visibility for the current session
|
||||
$state = bin2hex(random_bytes(16));
|
||||
$source = $imapsync->sources->findConnectable($source_id);
|
||||
$url = $source ? $imapsync->oauth->authorizeUrl($source, $state) : null;
|
||||
if ($url === null) {
|
||||
syncjob_oauth_finish(false, array('error' => 'source_unavailable'));
|
||||
}
|
||||
$_SESSION['syncjob_oauth2state'] = $state;
|
||||
$_SESSION['syncjob_oauth_source_id'] = $source_id;
|
||||
$_SESSION['syncjob_oauth_mailbox'] = $mailbox;
|
||||
header('Location: ' . $url);
|
||||
exit;
|
||||
}
|
||||
|
||||
// --- callback ---
|
||||
// CSRF: state must match the one stored at 'start'
|
||||
$state_ok = !empty($_GET['state']) && is_string($_GET['state'])
|
||||
&& hash_equals((string)($_SESSION['syncjob_oauth2state'] ?? ''), $_GET['state']);
|
||||
$source_id = intval($_SESSION['syncjob_oauth_source_id'] ?? 0);
|
||||
$mailbox = (string)($_SESSION['syncjob_oauth_mailbox'] ?? '');
|
||||
if ($state_ok) {
|
||||
unset($_SESSION['syncjob_oauth2state'], $_SESSION['syncjob_oauth_source_id'], $_SESSION['syncjob_oauth_mailbox']);
|
||||
}
|
||||
if (isset($_GET['error'])) {
|
||||
// Provider error codes are not passed through (only a fixed code)
|
||||
syncjob_oauth_finish(false, array('error' => 'provider_denied'));
|
||||
}
|
||||
if (!$state_ok || empty($_GET['code']) || !is_string($_GET['code']) || $source_id <= 0 || $mailbox === '') {
|
||||
syncjob_oauth_finish(false, array('error' => 'invalid_state'));
|
||||
}
|
||||
// Re-check access to the owner mailbox (session could have changed in between)
|
||||
if (!$context->canAccessMailbox($mailbox)) {
|
||||
syncjob_oauth_finish(false, array('error' => 'mailbox_denied'));
|
||||
}
|
||||
|
||||
$source = $imapsync->sources->findConnectable($source_id);
|
||||
if (!$source) {
|
||||
syncjob_oauth_finish(false, array('error' => 'source_unavailable'));
|
||||
}
|
||||
|
||||
// C1: the token is bound to the local owner mailbox and the remote identity (id_token claims, else userinfo).
|
||||
// Sanitized details go to the php-fpm log only, the opener gets a fixed code.
|
||||
try {
|
||||
$user1 = $imapsync->oauth->connectUser($source, $mailbox, $_GET['code']);
|
||||
}
|
||||
catch (\Mailcow\Core\UserError $e) {
|
||||
if ($e->args) {
|
||||
error_log('syncjob-oauth: ' . $e->args[0]);
|
||||
}
|
||||
syncjob_oauth_finish(false, array('error' => $e->key));
|
||||
}
|
||||
syncjob_oauth_finish(true, array('source_id' => $source_id, 'mailbox' => $mailbox, 'user1' => $user1));
|
||||
@@ -22,6 +22,7 @@
|
||||
<li><button class="dropdown-item" data-bs-target="#tab-config-f2b" aria-selected="false" aria-controls="tab-config-f2b" role="tab" data-bs-toggle="tab">{{ lang.admin.f2b_parameters }}</button></li>
|
||||
<li><button class="dropdown-item" data-bs-target="#tab-config-quarantine" aria-selected="false" aria-controls="tab-config-quarantine" role="tab" data-bs-toggle="tab">{{ lang.admin.quarantine }}</button></li>
|
||||
<li><button class="dropdown-item" data-bs-target="#tab-config-quota" aria-selected="false" aria-controls="tab-config-quota" role="tab" data-bs-toggle="tab">{{ lang.admin.quota_notifications }}</button></li>
|
||||
<li><button class="dropdown-item" data-bs-target="#tab-config-syncjob" aria-selected="false" aria-controls="tab-config-syncjob" role="tab" data-bs-toggle="tab">{{ lang.admin.syncjobs }}</button></li>
|
||||
<li><button class="dropdown-item" data-bs-target="#tab-config-rsettings" aria-selected="false" aria-controls="tab-config-rsettings" role="tab" data-bs-toggle="tab">{{ lang.admin.rspamd_settings_map }}</button></li>
|
||||
<li><button class="dropdown-item" data-bs-target="#tab-config-password-settings" aria-selected="false" aria-controls="tab-config-password-settings" role="tab" data-bs-toggle="tab">{{ lang.admin.password_settings }}</button></li>
|
||||
<li><button class="dropdown-item" data-bs-target="#tab-config-customize" aria-selected="false" aria-controls="tab-config-customize" role="tab" data-bs-toggle="tab">{{ lang.admin.customize }}</button></li>
|
||||
@@ -51,6 +52,7 @@
|
||||
{% include 'admin/tab-config-f2b.twig' %}
|
||||
{% include 'admin/tab-config-quarantine.twig' %}
|
||||
{% include 'admin/tab-config-quota.twig' %}
|
||||
{% include 'admin/tab-config-syncjob.twig' %}
|
||||
{% include 'admin/tab-config-rsettings.twig' %}
|
||||
{% include 'admin/tab-config-customize.twig' %}
|
||||
{% include 'admin/tab-config-password-settings.twig' %}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<div class="tab-pane fade" id="tab-config-syncjob" role="tabpanel" aria-labelledby="tab-config-syncjob">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex fs-5">
|
||||
<button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-config-syncjob" data-bs-toggle="collapse" aria-controls="collapse-tab-config-syncjob">
|
||||
{{ lang.admin.syncjobs }}
|
||||
</button>
|
||||
<span class="d-none d-md-block">{{ lang.admin.syncjobs }}</span>
|
||||
</div>
|
||||
<div id="collapse-tab-config-syncjob" class="card-body collapse" data-bs-parent="#admin-content">
|
||||
<form class="form" role="form" data-id="imapsync_settings" method="post">
|
||||
<div class="row mb-4">
|
||||
<div class="col-sm-6">
|
||||
<label for="max_parallel">{{ lang.admin.syncjob_max_parallel }}:</label>
|
||||
<input type="number" class="form-control" id="max_parallel" name="max_parallel" min="1" max="32" value="{{ imapsync_settings.max_parallel }}">
|
||||
<small class="text-muted">{{ lang.admin.syncjob_max_parallel_info }}</small>
|
||||
</div>
|
||||
<div class="col-sm-6">
|
||||
<label for="max_kb_per_second">{{ lang.admin.syncjob_max_kb_per_second }}:</label>
|
||||
<input type="number" class="form-control" id="max_kb_per_second" name="max_kb_per_second" min="0" max="10485760" value="{{ (imapsync_settings.max_bytes_per_second / 1024)|round }}">
|
||||
<small class="text-muted">{{ lang.admin.syncjob_max_kb_per_second_info }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-sm-10">
|
||||
<a type="button" class="btn btn-sm d-block d-sm-inline btn-success" data-action="edit_selected"
|
||||
data-item="imapsync_settings"
|
||||
data-id="imapsync_settings"
|
||||
data-api-url='edit/imapsync_settings'
|
||||
data-api-attr='{}'><i class="bi bi-check-lg"></i> {{ lang.user.save_changes }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -26,6 +26,7 @@
|
||||
var lang_user = {{ lang_user|raw }};
|
||||
var lang_admin = {{ lang_admin|raw }};
|
||||
var lang_datatables = {{ lang_datatables|raw }};
|
||||
var lang_syncjobs = {{ lang_syncjobs|raw }};
|
||||
var csrf_token = '{{ csrf_token }}';
|
||||
var pagination_size = Math.trunc('{{ pagination_size }}');
|
||||
var table_for_domain = '{{ domain }}';
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
{% extends 'edit.twig' %}
|
||||
|
||||
{% block inner_content %}
|
||||
{% if result %}
|
||||
{# XOAUTH2 sources are managed by admins and domain admins only #}
|
||||
{% set ro = not result.can_edit or (mailcow_cc_role == 'user' and result.auth_type == 'XOAUTH2') %}
|
||||
{% set show_oauth = mailcow_cc_role != 'user' or result.auth_type == 'XOAUTH2' %}
|
||||
{% set is_cc = result.auth_type == 'XOAUTH2' and result.oauth_flow != 'authorization_code' %}
|
||||
<h4 class="mb-4">{{ lang.syncjobs.source }}: {{ result.name }}</h4>
|
||||
{% if ro %}
|
||||
<div class="alert alert-info" role="alert"><i class="bi bi-eye"></i> {{ lang.syncjobs.source_readonly_hint }}</div>
|
||||
{% endif %}
|
||||
<form class="form-horizontal" data-id="edit_imapsync_source" role="form" method="post">
|
||||
<input type="hidden" value="0" name="active">
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="name">{{ lang.syncjobs.source_name }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="name" id="name" maxlength="100" value="{{ result.name }}" required{% if ro %} disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="description">{{ lang.syncjobs.source_description }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="description" id="description" maxlength="255" value="{{ result.description }}"{% if ro %} disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
{% if result.created_by %}
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="created_by_display">{{ lang.syncjobs.source_created_by }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" readonly class="form-control-plaintext" id="created_by_display" value="{{ result.created_by }}">
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if (mailcow_cc_role == 'admin' or mailcow_cc_role == 'domainadmin') and not ro %}
|
||||
<div class="imapsync-source-scope-block"{% if is_cc %} style="display:none;"{% endif %}>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="scope">{{ lang.syncjobs.source_scope }}</label>
|
||||
<div class="col-sm-10">
|
||||
<select class="form-control imapsync-source-scope" name="scope" id="scope" data-width="100%">
|
||||
{% if mailcow_cc_role == 'admin' %}
|
||||
<option value="all"{% if result.scope == 'all' %} selected{% endif %}>{{ lang.syncjobs.source_scope_all }}</option>
|
||||
{% endif %}
|
||||
<option value="domain"{% if result.scope == 'domain' %} selected{% endif %}>{{ lang.syncjobs.source_scope_domain }}</option>
|
||||
<option value="user"{% if result.scope == 'user' %} selected{% endif %}>{{ lang.syncjobs.source_scope_user }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2 imapsync-source-domains-row" style="display:{% if result.scope == 'domain' %}flex{% else %}none{% endif %};">
|
||||
<label class="control-label col-sm-2" for="domains">{{ lang.syncjobs.source_domains }}</label>
|
||||
<div class="col-sm-10">
|
||||
<select class="form-control imapsync-source-domains" name="domains" id="domains" data-live-search="true" data-width="100%" title="{{ lang.syncjobs.source_domains }}" multiple>
|
||||
{% for d in result.domains %}<option value="{{ d }}" selected>{{ d }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2 imapsync-source-users-row" style="display:{% if result.scope == 'user' %}flex{% else %}none{% endif %};">
|
||||
<label class="control-label col-sm-2" for="users">{{ lang.syncjobs.source_users }}</label>
|
||||
<div class="col-sm-10">
|
||||
<select class="form-control imapsync-source-users" name="users" id="users" data-live-search="true" data-width="100%" title="{{ lang.syncjobs.source_users }}" multiple>
|
||||
{% for u in result.users %}<option value="{{ u }}" selected>{{ u }}</option>{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2 imapsync-source-scope-cc-hint"{% if not is_cc %} style="display:none;"{% endif %}>
|
||||
<div class="offset-sm-2 col-sm-10">
|
||||
<small class="text-muted"><i class="bi bi-lock"></i> {{ lang.syncjobs.source_scope_cc_hint }}</small>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="scope_display">{{ lang.syncjobs.source_scope }}</label>
|
||||
<div class="col-sm-10">
|
||||
{% set scope_targets = result.scope == 'domain' ? result.domains : (result.scope == 'user' ? result.users : []) %}
|
||||
<input type="text" readonly class="form-control-plaintext" id="scope_display" value="{{ lang.syncjobs['source_scope_' ~ result.scope] }}{% if scope_targets is not empty %}: {{ scope_targets|join(', ') }}{% endif %}">
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="host">{{ lang.add.hostname }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="host" id="host" maxlength="253" value="{{ result.host1 }}" required{% if ro %} disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="port">{{ lang.add.port }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="number" class="form-control" name="port" id="port" min="1" max="65535" value="{{ result.port1 }}" required{% if ro %} disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="enc">{{ lang.add.enc_method }}</label>
|
||||
<div class="col-sm-10">
|
||||
<select name="enc" id="enc" data-width="100%"{% if ro %} disabled{% endif %}>
|
||||
<option value="SSL"{% if result.enc1 == 'SSL' %} selected{% endif %}>SSL</option>
|
||||
<option value="TLS"{% if result.enc1 == 'TLS' %} selected{% endif %}>STARTTLS</option>
|
||||
<option value="PLAIN"{% if result.enc1 == 'PLAIN' %} selected{% endif %}>PLAIN</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
<label class="control-label col-sm-2" for="auth_type">{{ lang.syncjobs.source_auth_type }}</label>
|
||||
<div class="col-sm-10">
|
||||
<select class="form-control imapsync-source-auth-type" name="auth_type" id="auth_type" data-width="100%" required{% if ro %} disabled{% endif %}>
|
||||
<option value="PLAIN"{% if result.auth_type == 'PLAIN' %} selected{% endif %}>PLAIN</option>
|
||||
<option value="LOGIN"{% if result.auth_type == 'LOGIN' %} selected{% endif %}>LOGIN</option>
|
||||
<option value="CRAM-MD5"{% if result.auth_type == 'CRAM-MD5' %} selected{% endif %}>CRAM-MD5</option>
|
||||
{% if show_oauth %}
|
||||
<option value="XOAUTH2"{% if result.auth_type == 'XOAUTH2' %} selected{% endif %}>XOAUTH2 (OAuth2)</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{% if show_oauth %}
|
||||
<fieldset class="imapsync-source-oauth-block" style="display:{% if result.auth_type == 'XOAUTH2' %}block{% else %}none{% endif %};">
|
||||
<legend class="fs-5">{{ lang.syncjobs.source_oauth_block }}</legend>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="oauth_flow">{{ lang.syncjobs.source_oauth_flow }}</label>
|
||||
<div class="col-sm-10">
|
||||
<select class="form-control imapsync-source-oauth-flow" name="oauth_flow" id="oauth_flow" data-width="100%"{% if ro %} disabled{% endif %}>
|
||||
<option value="client_credentials"{% if result.oauth_flow != 'authorization_code' %} selected{% endif %}>{{ lang.syncjobs.source_oauth_flow_app }}</option>
|
||||
<option value="authorization_code"{% if result.oauth_flow == 'authorization_code' %} selected{% endif %}>{{ lang.syncjobs.source_oauth_flow_user }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2 imapsync-source-authcode-block" style="display:{% if result.oauth_flow == 'authorization_code' %}flex{% else %}none{% endif %};">
|
||||
<label class="control-label col-sm-2" for="oauth_authorize_endpoint">{{ lang.syncjobs.source_authorize_endpoint }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="url" class="form-control" name="oauth_authorize_endpoint" id="oauth_authorize_endpoint" value="{{ result.oauth_authorize_endpoint }}"{% if ro %} disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="oauth_token_endpoint">{{ lang.syncjobs.source_oauth_token_endpoint }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="url" class="form-control" name="oauth_token_endpoint" id="oauth_token_endpoint" value="{{ result.oauth_token_endpoint }}"{% if ro %} disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="imapsync-source-authcode-block" style="display:{% if result.oauth_flow == 'authorization_code' %}block{% else %}none{% endif %};">
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="oauth_userinfo_endpoint">{{ lang.syncjobs.source_userinfo_endpoint }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="url" class="form-control" name="oauth_userinfo_endpoint" id="oauth_userinfo_endpoint" value="{{ result.oauth_userinfo_endpoint }}"{% if ro %} disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="redirect_uri">{{ lang.syncjobs.source_redirect_uri }}</label>
|
||||
<div class="col-sm-10">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="redirect_uri" readonly value="{{ result.oauth_redirect_uri ?: imapsync_redirect_uri }}">
|
||||
<button type="button" class="btn btn-secondary imapsync-copy-btn" data-copy-target="redirect_uri" title="{{ lang.syncjobs.copy }}"><i class="bi bi-clipboard" aria-hidden="true"></i> {{ lang.syncjobs.copy }}</button>
|
||||
</div>
|
||||
{% if not ro %}
|
||||
<small class="text-muted">{{ lang.syncjobs.source_redirect_uri_hint }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="oauth_client_id">{{ lang.syncjobs.source_oauth_client_id }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="oauth_client_id" id="oauth_client_id" value="{{ result.oauth_client_id }}"{% if ro %} disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
{% if not ro %}
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="oauth_client_secret">{{ lang.syncjobs.source_oauth_client_secret }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="password" class="form-control" name="oauth_client_secret" id="oauth_client_secret" autocomplete="new-password"{% if result.has_client_secret %} placeholder="••••••••"{% endif %}>
|
||||
<small class="text-muted">{{ lang.syncjobs.source_secret_unchanged_hint }}</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="oauth_scope">{{ lang.syncjobs.source_oauth_scope }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="oauth_scope" id="oauth_scope" value="{{ result.oauth_scope }}"{% if ro %} disabled{% endif %}>
|
||||
</div>
|
||||
</div>
|
||||
{% if not ro %}
|
||||
<div class="row mb-4">
|
||||
<label class="control-label col-sm-2" for="oauth_extra_params">{{ lang.syncjobs.source_oauth_extra_params }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="oauth_extra_params" id="oauth_extra_params" value="{{ result.oauth_extra_params }}" placeholder='{"resource":"https://provider.example.com"}'>
|
||||
<small class="text-muted">{{ lang.syncjobs.source_oauth_extra_params_hint }}</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if is_cc %}
|
||||
{# token state of the stored client credentials token (authorization_code tokens live per sync job) #}
|
||||
<div class="row mb-4 imapsync-source-cc-block">
|
||||
<div class="offset-sm-2 col-sm-10">
|
||||
<p class="mb-1">
|
||||
{% if result.oauth_last_refresh_error %}
|
||||
<span class="badge bg-danger">{{ lang.syncjobs.token_state_failed }}</span>
|
||||
{% elseif result.has_access_token %}
|
||||
<span class="badge bg-success">{{ lang.syncjobs.token_state_issued }}</span>
|
||||
{% if result.oauth_token_expires %}{{ lang.syncjobs.source_token_status }}: <code>{{ result.oauth_token_expires|date('Y-m-d H:i:s') }}</code>{% endif %}
|
||||
{% else %}
|
||||
<span class="badge bg-warning">{{ lang.syncjobs.token_state_none }}</span>
|
||||
{% endif %}
|
||||
</p>
|
||||
{% if result.oauth_last_refresh_error %}
|
||||
<p class="text-danger">{{ lang.syncjobs.source_token_error }}: <code>{{ result.oauth_last_refresh_error }}</code></p>
|
||||
{% endif %}
|
||||
{% if not ro %}
|
||||
{# own data-id: must not harvest/post the form fields #}
|
||||
<button type="button" class="btn btn-sm btn-secondary" data-action="edit_selected" data-id="imapsync_source_refresh" data-item="{{ result.id }}" data-api-url='edit/syncjob_source/refresh_token' data-api-attr='{}'><i class="bi bi-arrow-repeat" aria-hidden="true"></i> {{ lang.syncjobs.source_refresh_now }}</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
<div class="row mb-4">
|
||||
<div class="offset-sm-2 col-sm-10">
|
||||
<div class="form-check">
|
||||
<label><input type="checkbox" class="form-check-input" value="1" name="active"{% if result.active == '1' %} checked{% endif %}{% if ro %} disabled{% endif %}> {{ lang.edit.active }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if not ro %}
|
||||
<div class="row mb-2">
|
||||
<div class="offset-sm-2 col-sm-10">
|
||||
<button class="btn btn-xs-lg d-block d-sm-inline btn-success" data-action="edit_selected" data-id="edit_imapsync_source" data-item="{{ result.id }}" data-api-url='edit/syncjob_source' data-api-attr='{}' href="#">{{ lang.edit.save }}</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
{% else %}
|
||||
{{ parent() }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
{% block inner_content %}
|
||||
{% if result %}
|
||||
<h4 class="mb-4">{{ lang.edit.syncjob }}</h4>
|
||||
<form class="form-horizontal" data-id="editsyncjob" role="form" method="post">
|
||||
<h4 class="mb-4">{{ lang.syncjobs.edit }}</h4>
|
||||
<form class="form-horizontal" data-id="editsyncjob" data-mailbox="{{ result.user2 }}" role="form" method="post">
|
||||
<input type="hidden" value="0" name="delete2duplicates">
|
||||
<input type="hidden" value="0" name="delete1">
|
||||
<input type="hidden" value="0" name="delete2">
|
||||
@@ -13,139 +13,180 @@
|
||||
<input type="hidden" value="0" name="subscribeall">
|
||||
<input type="hidden" value="0" name="dry">
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="host1">{{ lang.edit.hostname }}</label>
|
||||
<label class="control-label col-sm-2" for="user2_display">{{ lang.syncjobs.user2_label }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="host1" id="host1" value="{{ result.host1 }}">
|
||||
<input type="text" readonly class="form-control-plaintext" id="user2_display" value="{{ result.user2 }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
<label class="control-label col-sm-2" for="port1">Port</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="number" class="form-control" name="port1" id="port1" min="1" max="65535" value="{{ result.port1 }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="user1">{{ lang.edit.username }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="user1" id="user1" value="{{ result.user1 }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
<label class="control-label col-sm-2" for="password1">{{ lang.edit.password }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="password" class="form-control" name="password1" id="password1" value="{{ result.password1 }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="enc1">{{ lang.edit.encryption }}</label>
|
||||
<div class="col-sm-10">
|
||||
<select id="enc1" name="enc1">
|
||||
<option value="SSL"{% if result.enc1 == 'SSL' %} selected{% endif %}>SSL</option>
|
||||
<option value="TLS"{% if result.enc1 == 'TLS' %} selected{% endif %}>STARTTLS</option>
|
||||
<option value="PLAIN"{% if result.enc1 == 'PLAIN' %} selected{% endif %}>PLAIN</option>
|
||||
<label class="control-label col-sm-2" for="source_id">{{ lang.syncjobs.source }}</label>
|
||||
<div class="col-sm-10 imapsync-select-wrap">
|
||||
<select class="imapsync-source-select" name="source_id" id="source_id"
|
||||
data-live-search="true" data-show-subtext="true" data-width="100%"
|
||||
data-current-source-id="{{ result.source_id }}"
|
||||
data-current-source-name="{{ result.source_name }}"
|
||||
data-current-source-host="{{ result.source_host }}"
|
||||
data-current-source-port="{{ result.source_port }}"
|
||||
data-current-source-auth-type="{{ result.source_auth_type }}"
|
||||
data-current-source-oauth-flow="{{ result.source_oauth_flow }}" title="{{ lang.syncjobs.source_select }}" required>
|
||||
{% if result.source_id %}
|
||||
<option value="{{ result.source_id }}" data-auth-type="{{ result.source_auth_type }}" data-oauth-flow="{{ result.source_oauth_flow }}" data-subtext="{{ result.source_host }}:{{ result.source_port }} · {{ result.source_auth_type }}" selected>{{ result.source_name }}</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
{% if not result.source_id %}
|
||||
<div class="alert alert-warning mt-2 mb-0" role="alert"><i class="bi bi-exclamation-triangle" aria-hidden="true"></i> {{ lang.syncjobs.source_missing }}</div>
|
||||
{% endif %}
|
||||
<div class="imapsync-no-source d-none mt-2"><small class="text-muted">{{ lang.syncjobs.no_sources }}</small></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2 imapsync-user1-row" style="display:{% if result.source_oauth_flow == 'authorization_code' %}none{% else %}flex{% endif %};">
|
||||
<label class="control-label col-sm-2" for="user1">{{ lang.syncjobs.user1_label }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="user1" id="user1" value="{{ result.user1 }}" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2 imapsync-oauth-connect-row" style="display:{% if result.source_oauth_flow == 'authorization_code' %}flex{% else %}none{% endif %};">
|
||||
<div class="offset-sm-2 col-sm-10">
|
||||
<button type="button" class="btn btn-sm btn-primary imapsync-oauth-connect-btn"><i class="bi bi-box-arrow-up-right" aria-hidden="true"></i> {{ lang.syncjobs.syncjob_oauth_connect }}</button>
|
||||
{% if result.user_token_exists == 1 and not result.user_token_error %}
|
||||
<span class="imapsync-oauth-connect-status ms-2 text-success" role="status" aria-live="polite">{{ lang.syncjobs.syncjob_oauth_connected }}: {{ result.user1 }}</span>
|
||||
{% elseif result.user_token_error %}
|
||||
<span class="imapsync-oauth-connect-status ms-2 text-danger" role="status" aria-live="polite">{{ lang.syncjobs.token_state_failed }}: {{ result.user1 }}</span>
|
||||
{% else %}
|
||||
<span class="imapsync-oauth-connect-status ms-2 text-warning" role="status" aria-live="polite">{{ lang.syncjobs.token_state_pending }}</span>
|
||||
{% endif %}
|
||||
<div><small class="text-muted">{{ lang.syncjobs.oauth_hint }}</small></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-4 password1-row" style="display:{% if result.source_auth_type == 'XOAUTH2' %}none{% else %}flex{% endif %};">
|
||||
<label class="control-label col-sm-2" for="password1">{{ lang.syncjobs.password1_label }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="password" class="form-control" name="password1" id="password1" value="" placeholder="{{ lang.edit.unchanged_if_empty }}" autocomplete="new-password">
|
||||
<small class="text-muted">{{ lang.edit.unchanged_if_empty }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="mins_interval">{{ lang.edit.mins_interval }}</label>
|
||||
<label class="control-label col-sm-2" for="mins_interval">{{ lang.syncjobs.mins_interval }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="number" class="form-control" name="mins_interval" min="1" max="43800" value="{{ result.mins_interval }}" required>
|
||||
<input type="number" class="form-control" name="mins_interval" id="mins_interval" min="1" max="43800" value="{{ result.mins_interval }}" required>
|
||||
<small class="text-muted">1-43800</small>
|
||||
</div>
|
||||
</div>
|
||||
{% if mailcow_cc_role == 'admin' %}
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="subfolder2">{{ lang.edit.subfolder2|raw }}</label>
|
||||
<label class="control-label col-sm-2" for="prio">{{ lang.syncjobs.prio }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="number" class="form-control" name="prio" id="prio" min="0" value="{{ result.prio }}">
|
||||
<small class="text-muted">{{ lang.syncjobs.prio_hint }}</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="subfolder2">{{ lang.syncjobs.subfolder2|raw }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="subfolder2" id="subfolder2" value="{{ result.subfolder2 }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="maxage">{{ lang.edit.maxage|raw }}</label>
|
||||
<label class="control-label col-sm-2" for="maxage">{{ lang.syncjobs.maxage|raw }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="number" class="form-control" name="maxage" id="maxage" min="0" max="32000" value="{{ result.maxage }}">
|
||||
<small class="text-muted">0-32000</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="maxbytespersecond">{{ lang.edit.maxbytespersecond|raw }}</label>
|
||||
<label class="control-label col-sm-2" for="maxbytespersecond">{{ lang.syncjobs.maxbytespersecond|raw }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="number" class="form-control" name="maxbytespersecond" id="maxbytespersecond" min="0" max="125000000" value="{{ result.maxbytespersecond }}">
|
||||
<small class="text-muted">0-125000000</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="timeout1">{{ lang.add.timeout1 }}</label>
|
||||
<label class="control-label col-sm-2" for="timeout1">{{ lang.syncjobs.timeout1 }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="number" class="form-control" name="timeout1" id="timeout1" min="1" max="32000" value="{{ result.timeout1 }}">
|
||||
<small class="text-muted">1-32000</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="timeout2">{{ lang.add.timeout2 }}</label>
|
||||
<label class="control-label col-sm-2" for="timeout2">{{ lang.syncjobs.timeout2 }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="number" class="form-control" name="timeout2" id="timeout2" min="1" max="32000" value="{{ result.timeout2 }}">
|
||||
<small class="text-muted">1-32000</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="exclude">{{ lang.edit.exclude }}</label>
|
||||
<label class="control-label col-sm-2" for="exclude">{{ lang.syncjobs.exclude }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="exclude" id="exclude" value="{{ result.exclude }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
<label class="control-label col-sm-2" for="custom_params">{{ lang.add.custom_params }}</label>
|
||||
<label class="control-label col-sm-2" id="custom_params_label">{{ lang.syncjobs.custom_params }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="custom_params" id="custom_params" value="{{ result.custom_params }}" placeholder="--some-param=xy --other-param=yx">
|
||||
<small class="text-muted">{{ lang.add.custom_params_hint }}</small>
|
||||
<div class="imapsync-cp-editor" role="group" aria-labelledby="custom_params_label">
|
||||
<div class="imapsync-cp-rows"></div>
|
||||
<button type="button" class="btn btn-sm btn-secondary imapsync-cp-add"><i class="bi bi-plus-lg" aria-hidden="true"></i> {{ lang.syncjobs.custom_param_add }}</button>
|
||||
</div>
|
||||
<input type="hidden" name="custom_params" class="imapsync-cp-value" value="{{ result.custom_params }}">
|
||||
<small class="text-muted d-block mt-1">{{ lang.syncjobs.custom_params_hint }}</small>
|
||||
</div>
|
||||
</div>
|
||||
{% if result.custom_params_legacy %}
|
||||
<div class="row mb-4">
|
||||
<label class="control-label col-sm-2" for="custom_params_legacy_display">{{ lang.syncjobs.custom_params_legacy }}</label>
|
||||
<div class="col-sm-10">
|
||||
<div class="alert alert-warning mb-2"><i class="bi bi-exclamation-triangle"></i> {{ lang.syncjobs.custom_params_legacy_hint }}</div>
|
||||
<textarea class="form-control font-monospace" id="custom_params_legacy_display" rows="2" readonly>{{ result.custom_params_legacy }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<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="delete2duplicates"{% if result.delete2duplicates == '1' %} checked{% endif %}> {{ lang.edit.delete2duplicates }} (--delete2duplicates)</label>
|
||||
<label><input type="checkbox" class="form-check-input" value="1" name="delete2duplicates"{% if result.delete2duplicates == '1' %} checked{% endif %}> {{ lang.syncjobs.delete2duplicates }} (--delete2duplicates)</label>
|
||||
</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="delete1"{% if result.delete1 == '1' %} checked{% endif %}> {{ lang.edit.delete1 }} (--delete1)</label>
|
||||
<label><input type="checkbox" class="form-check-input" value="1" name="delete1"{% if result.delete1 == '1' %} checked{% endif %}> {{ lang.syncjobs.delete1 }} (--delete1)</label>
|
||||
</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="delete2"{% if result.delete2 == '1' %} checked{% endif %}> {{ lang.edit.delete2 }} (--delete2)</label>
|
||||
<label><input type="checkbox" class="form-check-input" value="1" name="delete2"{% if result.delete2 == '1' %} checked{% endif %}> {{ lang.syncjobs.delete2 }} (--delete2)</label>
|
||||
</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="automap"{% if result.automap == '1' %} checked{% endif %}> {{ lang.edit.automap }} (--automap)</label>
|
||||
<label><input type="checkbox" class="form-check-input" value="1" name="automap"{% if result.automap == '1' %} checked{% endif %}> {{ lang.syncjobs.automap }} (--automap)</label>
|
||||
</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="skipcrossduplicates"{% if result.skipcrossduplicates == '1' %} checked{% endif %}> {{ lang.edit.skipcrossduplicates }} (--skipcrossduplicates)</label>
|
||||
<label><input type="checkbox" class="form-check-input" value="1" name="skipcrossduplicates"{% if result.skipcrossduplicates == '1' %} checked{% endif %}> {{ lang.syncjobs.skipcrossduplicates }} (--skipcrossduplicates)</label>
|
||||
</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="subscribeall"{% if result.subscribeall == '1' %} checked{% endif %}> {{ lang.add.subscribeall }} (--subscribeall)</label>
|
||||
<label><input type="checkbox" class="form-check-input" value="1" name="subscribeall"{% if result.subscribeall == '1' %} checked{% endif %}> {{ lang.syncjobs.subscribeall }} (--subscribeall)</label>
|
||||
</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="dry"{% if result.dry == '1' %} checked{% endif %}> {{ lang.add.dry }} (--dry)</label>
|
||||
<label><input type="checkbox" class="form-check-input" value="1" name="dry"{% if result.dry == '1' %} checked{% endif %}> {{ lang.syncjobs.dry }} (--dry)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<li role="presentation"><button class="dropdown-item" aria-selected="false" aria-controls="tab-domain-aliases" role="tab" data-bs-toggle="tab" data-bs-target="#tab-domain-aliases">{{ lang.mailbox.domain_aliases }}</button></li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation"><button class="nav-link" aria-selected="false" aria-controls="tab-syncjobs" role="tab" data-bs-toggle="tab" data-bs-target="#tab-syncjobs">{{ lang.mailbox.sync_jobs }}</button></li>
|
||||
<li class="nav-item" role="presentation"><button class="nav-link" aria-selected="false" aria-controls="tab-syncjobs" role="tab" data-bs-toggle="tab" data-bs-target="#tab-syncjobs">{{ lang.syncjobs.sync_jobs }}</button></li>
|
||||
<li class="nav-item" role="presentation"><button class="nav-link" aria-selected="false" aria-controls="tab-filters" role="tab" data-bs-toggle="tab" data-bs-target="#tab-filters">{{ lang.mailbox.filters }}</button></li>
|
||||
<li class="nav-item" role="presentation"><button class="nav-link" aria-selected="false" aria-controls="tab-bcc" role="tab" data-bs-toggle="tab" data-bs-target="#tab-bcc">{{ lang.mailbox.address_rewriting }}</button></li>
|
||||
<li class="nav-item" role="presentation"{% if mailcow_cc_role != 'admin' %} class="d-none"{% endif %}><button class="nav-link" aria-selected="false" aria-controls="tab-tls-policy" role="tab" data-bs-toggle="tab" data-bs-target="#tab-tls-policy">{{ lang.mailbox.tls_policy_maps }}</button></li>
|
||||
@@ -67,6 +67,7 @@
|
||||
<script type='text/javascript'>
|
||||
var acl = '{{ acl_json|raw }}';
|
||||
var lang = {{ lang_mailbox|raw }};
|
||||
lang.syncjobs = {{ lang_syncjobs|raw }};
|
||||
var lang_rl = {{ lang_rl|raw }};
|
||||
var lang_edit = {{ lang_edit|raw }};
|
||||
var lang_datatables = {{ lang_datatables|raw }};
|
||||
|
||||
@@ -1,10 +1,35 @@
|
||||
<div class="tab-pane fade" id="tab-syncjobs" role="tabpanel" aria-labelledby="tab-syncjobs">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex fs-5">
|
||||
<button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-syncjobs" data-bs-toggle="collapse" aria-controls="collapse-tab-syncjobs">
|
||||
{{ lang.mailbox.sync_jobs }} <span class="badge bg-info table-lines"></span>
|
||||
<button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-imapsync-sources" data-bs-toggle="collapse" aria-controls="collapse-tab-imapsync-sources">
|
||||
{{ lang.syncjobs.sources_panel }} <span class="badge bg-info table-lines"></span>
|
||||
</button>
|
||||
<span class="d-none d-md-block">{{ lang.mailbox.sync_jobs }} <span class="badge bg-info table-lines"></span></span>
|
||||
<span class="d-none d-md-block">{{ lang.syncjobs.sources_panel }} <span class="badge bg-info table-lines"></span></span>
|
||||
<div class="btn-group ms-auto d-flex">
|
||||
<button class="btn btn-xs btn-secondary refresh_table" data-draw="draw_imapsync_source_table" data-table="imapsync_source_table">{{ lang.admin.refresh }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="collapse-tab-imapsync-sources" class="card-body collapse" data-bs-parent="#mail-content">
|
||||
<p class="text-muted">{{ lang.syncjobs.sources_hint }}</p>
|
||||
<div class="mass-actions-mailbox mb-4 d-none d-sm-block">
|
||||
<div class="btn-group" data-acl="{{ acl.syncjobs }}">
|
||||
<a class="btn btn-sm btn-success" href="#" data-bs-toggle="modal" data-bs-target="#addImapsyncSourceModal"><i class="bi bi-plus-lg"></i> {{ lang.syncjobs.source_add }}</a>
|
||||
</div>
|
||||
</div>
|
||||
<table id="imapsync_source_table" class="table table-striped dt-responsive w-100"></table>
|
||||
<div class="mass-actions-mailbox mt-4">
|
||||
<div class="btn-group" data-acl="{{ acl.syncjobs }}">
|
||||
<a class="btn btn-sm btn-xs-lg btn-success" href="#" data-bs-toggle="modal" data-bs-target="#addImapsyncSourceModal"><i class="bi bi-plus-lg"></i> {{ lang.syncjobs.source_add }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex fs-5">
|
||||
<button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-syncjobs" data-bs-toggle="collapse" aria-controls="collapse-tab-syncjobs">
|
||||
{{ lang.syncjobs.sync_jobs }} <span class="badge bg-info table-lines"></span>
|
||||
</button>
|
||||
<span class="d-none d-md-block">{{ lang.syncjobs.sync_jobs }} <span class="badge bg-info table-lines"></span></span>
|
||||
|
||||
<div class="btn-group ms-auto d-flex">
|
||||
<button class="btn btn-xs btn-secondary refresh_table" data-draw="draw_sync_job_table" data-table="sync_job_table">{{ lang.admin.refresh }}</button>
|
||||
@@ -16,8 +41,10 @@
|
||||
<a class="btn btn-sm btn-xs-half btn-secondary" id="toggle_multi_select_all" data-id="syncjob" href="#"><i class="bi bi-check-all"></i> {{ lang.mailbox.toggle_all }}</a>
|
||||
<a class="btn btn-sm btn-xs-half btn-secondary dropdown-toggle" data-bs-toggle="dropdown" href="#">{{ lang.mailbox.quick_actions }}</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" data-action="edit_selected" data-id="syncjob" data-api-url='edit/syncjob' data-api-attr='{"last_run":"","success":""}' href="#">{{ lang.mailbox.last_run_reset }}</a></li>
|
||||
{% if mailcow_cc_role == 'admin' %}
|
||||
<li><a class="dropdown-item" data-action="edit_selected" data-id="syncjob" data-api-url='edit/syncjob/run_next' data-api-attr='{}' href="#">{{ lang.mailbox.last_run_reset }}</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
{% endif %}
|
||||
<li><a class="dropdown-item" data-action="edit_selected" data-id="syncjob" data-api-url='edit/syncjob' data-api-attr='{"active":"1"}' href="#">{{ lang.mailbox.activate }}</a></li>
|
||||
<li><a class="dropdown-item" data-action="edit_selected" data-id="syncjob" data-api-url='edit/syncjob' data-api-attr='{"active":"0"}' href="#">{{ lang.mailbox.deactivate }}</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
@@ -26,7 +53,7 @@
|
||||
<li class="table_collapse_option"><a class="dropdown-item" data-datatables-expand="sync_job_table">{{ lang.datatables.expand_all }}</a></li>
|
||||
<li class="table_collapse_option"><a class="dropdown-item" data-datatables-collapse="sync_job_table">{{ lang.datatables.collapse_all }}</a></li>
|
||||
</ul>
|
||||
<a class="btn btn-sm btn-success" href="#" data-bs-toggle="modal" data-bs-target="#addSyncJobModalAdmin"><i class="bi bi-plus-lg"></i> {{ lang.user.create_syncjob }}</a>
|
||||
<a class="btn btn-sm btn-success" href="#" data-bs-toggle="modal" data-bs-target="#addSyncJobModalAdmin"><i class="bi bi-plus-lg"></i> {{ lang.syncjobs.create }}</a>
|
||||
</div>
|
||||
</div>
|
||||
<table id="sync_job_table" class="table table-striped dt-responsive w-100"></table>
|
||||
@@ -35,8 +62,10 @@
|
||||
<a class="btn btn-sm btn-xs-lg btn-xs-half btn-secondary" id="toggle_multi_select_all" data-id="syncjob" href="#"><i class="bi bi-check-all"></i> {{ lang.mailbox.toggle_all }}</a>
|
||||
<a class="btn btn-sm btn-xs-lg btn-xs-half btn-secondary dropdown-toggle" data-bs-toggle="dropdown" href="#">{{ lang.mailbox.quick_actions }}</a>
|
||||
<ul class="dropdown-menu">
|
||||
<li><a class="dropdown-item" data-action="edit_selected" data-id="syncjob" data-api-url='edit/syncjob' data-api-attr='{"last_run":"","success":""}' href="#">{{ lang.mailbox.last_run_reset }}</a></li>
|
||||
{% if mailcow_cc_role == 'admin' %}
|
||||
<li><a class="dropdown-item" data-action="edit_selected" data-id="syncjob" data-api-url='edit/syncjob/run_next' data-api-attr='{}' href="#">{{ lang.mailbox.last_run_reset }}</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
{% endif %}
|
||||
<li><a class="dropdown-item" data-action="edit_selected" data-id="syncjob" data-api-url='edit/syncjob' data-api-attr='{"active":"1"}' href="#">{{ lang.mailbox.activate }}</a></li>
|
||||
<li><a class="dropdown-item" data-action="edit_selected" data-id="syncjob" data-api-url='edit/syncjob' data-api-attr='{"active":"0"}' href="#">{{ lang.mailbox.deactivate }}</a></li>
|
||||
<li><hr class="dropdown-divider"></li>
|
||||
@@ -45,7 +74,7 @@
|
||||
<li class="table_collapse_option"><a class="dropdown-item" data-datatables-expand="sync_job_table">{{ lang.datatables.expand_all }}</a></li>
|
||||
<li class="table_collapse_option"><a class="dropdown-item" data-datatables-collapse="sync_job_table">{{ lang.datatables.collapse_all }}</a></li>
|
||||
</ul>
|
||||
<a class="btn btn-sm btn-xs-lg btn-success" href="#" data-bs-toggle="modal" data-bs-target="#addSyncJobModalAdmin"><i class="bi bi-plus-lg"></i> {{ lang.user.create_syncjob }}</a>
|
||||
<a class="btn btn-sm btn-xs-lg btn-success" href="#" data-bs-toggle="modal" data-bs-target="#addSyncJobModalAdmin"><i class="bi bi-plus-lg"></i> {{ lang.syncjobs.create }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
<!-- add imapsync source modal (shared between user and admin views) -->
|
||||
<div class="modal fade" id="addImapsyncSourceModal" tabindex="-1" role="dialog" aria-hidden="true" aria-labelledby="addImapsyncSourceModalLabel">
|
||||
<div class="modal-dialog modal-xl">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title" id="addImapsyncSourceModalLabel">{{ lang.syncjobs.source_add }}</h3>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ lang.footer.cancel }}"></button>
|
||||
</div>
|
||||
<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-2">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsrc_name">{{ lang.syncjobs.source_name }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" id="addsrc_name" name="name" maxlength="100" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsrc_description">{{ lang.syncjobs.source_description }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" id="addsrc_description" name="description" maxlength="255">
|
||||
</div>
|
||||
</div>
|
||||
{% if mailcow_cc_role == 'admin' or mailcow_cc_role == 'domainadmin' %}
|
||||
<div class="imapsync-source-scope-block">
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsrc_scope">{{ lang.syncjobs.source_scope }}</label>
|
||||
<div class="col-sm-10">
|
||||
<select class="form-control imapsync-source-scope" id="addsrc_scope" name="scope" data-width="100%">
|
||||
{% if mailcow_cc_role == 'admin' %}
|
||||
<option value="all">{{ lang.syncjobs.source_scope_all }}</option>
|
||||
{% endif %}
|
||||
<option value="domain">{{ lang.syncjobs.source_scope_domain }}</option>
|
||||
<option value="user" selected>{{ lang.syncjobs.source_scope_user }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2 imapsync-source-domains-row" style="display:none;">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsrc_domains">{{ lang.syncjobs.source_domains }}</label>
|
||||
<div class="col-sm-10">
|
||||
<select class="form-control imapsync-source-domains" id="addsrc_domains" name="domains" data-live-search="true" data-width="100%" title="{{ lang.syncjobs.source_domains }}" multiple></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2 imapsync-source-users-row" style="display:none;">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsrc_users">{{ lang.syncjobs.source_users }}</label>
|
||||
<div class="col-sm-10">
|
||||
<select class="form-control imapsync-source-users" id="addsrc_users" name="users" data-live-search="true" data-width="100%" title="{{ lang.syncjobs.source_users }}" multiple></select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2 imapsync-source-scope-cc-hint" style="display:none;">
|
||||
<div class="offset-sm-2 col-sm-10">
|
||||
<small class="text-muted"><i class="bi bi-lock"></i> {{ lang.syncjobs.source_scope_cc_hint }}</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsrc_host">{{ lang.add.hostname }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" id="addsrc_host" name="host" maxlength="253" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsrc_port">{{ lang.add.port }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="number" class="form-control" id="addsrc_port" name="port" min="1" max="65535" value="993" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsrc_enc">{{ lang.add.enc_method }}</label>
|
||||
<div class="col-sm-10">
|
||||
<select id="addsrc_enc" name="enc" title="{{ lang.add.select }}" data-width="100%" required>
|
||||
<option value="SSL" selected>SSL</option>
|
||||
<option value="TLS">STARTTLS</option>
|
||||
<option value="PLAIN">PLAIN</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
<div class="col-sm-10">
|
||||
<select class="form-control imapsync-source-auth-type" id="addsrc_auth_type" name="auth_type" data-width="100%" required>
|
||||
<option value="PLAIN" selected>PLAIN</option>
|
||||
<option value="LOGIN">LOGIN</option>
|
||||
<option value="CRAM-MD5">CRAM-MD5</option>
|
||||
{% if mailcow_cc_role != 'user' %}
|
||||
<option value="XOAUTH2">XOAUTH2 (OAuth2)</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
{% if mailcow_cc_role != 'user' %}
|
||||
<fieldset class="imapsync-source-oauth-block" style="display:none;">
|
||||
<legend class="fs-5">{{ lang.syncjobs.source_oauth_block }}</legend>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsrc_oauth_flow">{{ lang.syncjobs.source_oauth_flow }}</label>
|
||||
<div class="col-sm-10">
|
||||
<select class="form-control imapsync-source-oauth-flow" id="addsrc_oauth_flow" name="oauth_flow" data-width="100%">
|
||||
<option value="client_credentials" selected>{{ lang.syncjobs.source_oauth_flow_app }}</option>
|
||||
<option value="authorization_code">{{ lang.syncjobs.source_oauth_flow_user }}</option>
|
||||
</select>
|
||||
</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">
|
||||
<input type="url" class="form-control" id="addsrc_oauth_authorize_endpoint" name="oauth_authorize_endpoint" placeholder="https://provider.example.com/oauth2/authorize">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsrc_oauth_token_endpoint">{{ lang.syncjobs.source_oauth_token_endpoint }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="url" class="form-control" id="addsrc_oauth_token_endpoint" name="oauth_token_endpoint" placeholder="https://provider.example.com/oauth2/token">
|
||||
</div>
|
||||
</div>
|
||||
<div class="imapsync-source-authcode-block" style="display:none;">
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsrc_oauth_userinfo_endpoint">{{ lang.syncjobs.source_userinfo_endpoint }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="url" class="form-control" id="addsrc_oauth_userinfo_endpoint" name="oauth_userinfo_endpoint" placeholder="https://provider.example.com/oauth2/userinfo">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsrc_redirect_uri">{{ lang.syncjobs.source_redirect_uri }}</label>
|
||||
<div class="col-sm-10">
|
||||
<div class="input-group">
|
||||
<input type="text" class="form-control" id="addsrc_redirect_uri" readonly value="{{ imapsync_redirect_uri }}" data-formcache-skip>
|
||||
<button type="button" class="btn btn-secondary imapsync-copy-btn" data-copy-target="addsrc_redirect_uri" title="{{ lang.syncjobs.copy }}"><i class="bi bi-clipboard" aria-hidden="true"></i> {{ lang.syncjobs.copy }}</button>
|
||||
</div>
|
||||
<small class="text-muted">{{ lang.syncjobs.source_redirect_uri_hint }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsrc_oauth_client_id">{{ lang.syncjobs.source_oauth_client_id }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" id="addsrc_oauth_client_id" name="oauth_client_id">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsrc_oauth_client_secret">{{ lang.syncjobs.source_oauth_client_secret }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="password" class="form-control" id="addsrc_oauth_client_secret" name="oauth_client_secret" autocomplete="new-password" data-formcache-skip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsrc_oauth_scope">{{ lang.syncjobs.source_oauth_scope }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" id="addsrc_oauth_scope" name="oauth_scope" placeholder="openid email offline_access">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsrc_oauth_extra_params">{{ lang.syncjobs.source_oauth_extra_params }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" id="addsrc_oauth_extra_params" name="oauth_extra_params" placeholder='{"resource":"https://provider.example.com"}'>
|
||||
<small class="text-muted">{{ lang.syncjobs.source_oauth_extra_params_hint }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
{% endif %}
|
||||
<div class="row mb-4">
|
||||
<div class="offset-sm-2 col-sm-10">
|
||||
<div class="form-check">
|
||||
<label><input type="checkbox" class="form-check-input" value="1" name="active" checked> {{ lang.add.active }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="offset-sm-2 col-sm-10">
|
||||
<button class="btn btn-xs-lg d-block d-sm-inline btn-success" data-action="add_item" data-id="add_imapsync_source" data-api-url='add/syncjob_source' data-api-attr='{}' href="#">{{ lang.admin.add }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -933,20 +933,20 @@
|
||||
</div>
|
||||
</div><!-- add domain alias modal -->
|
||||
<!-- add sync job modal -->
|
||||
<div class="modal fade" id="addSyncJobModalAdmin" tabindex="-1" role="dialog" aria-hidden="true">
|
||||
<div class="modal fade" id="addSyncJobModalAdmin" tabindex="-1" role="dialog" aria-hidden="true" aria-labelledby="addSyncJobModalAdminLabel">
|
||||
<div class="modal-dialog modal-xl">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">{{ lang.add.syncjob }}</h3>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
<h3 class="modal-title" id="addSyncJobModalAdminLabel">{{ lang.syncjobs.add }}</h3>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ lang.footer.cancel }}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-muted">{{ lang.add.syncjob_hint }}</p>
|
||||
<p class="text-muted">{{ lang.syncjobs.add_hint }}</p>
|
||||
<form class="form-horizontal" data-cached-form="false" role="form" data-id="add_syncjob">
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="username">{{ lang.add.username }}</label>
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsja_username">{{ lang.syncjobs.user2_label }}</label>
|
||||
<div class="col-sm-10">
|
||||
<select data-live-search="true" name="username" title="{{ lang.add.select }}" required>
|
||||
<select data-live-search="true" id="addsja_username" name="username" title="{{ lang.add.select }}" data-width="100%" required>
|
||||
{% for mailbox in mailboxes %}
|
||||
<option>{{ mailbox }}</option>
|
||||
{% endfor %}
|
||||
@@ -954,140 +954,142 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="host1">{{ lang.add.hostname }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="host1" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="port1">{{ lang.add.port }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="number" class="form-control" name="port1" min="1" max="65535" value="143" required>
|
||||
<small class="text-muted">1-65535</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="user1">{{ lang.add.username }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="user1" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="password1">{{ lang.add.password }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="password" class="form-control" name="password1" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="enc1">{{ lang.add.enc_method }}</label>
|
||||
<div class="col-sm-10">
|
||||
<select name="enc1" title="{{ lang.add.select }}" required>
|
||||
<option value="SSL" selected>SSL</option>
|
||||
<option value="TLS">STARTTLS</option>
|
||||
<option value="PLAIN">PLAIN</option>
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsja_source_id">{{ lang.syncjobs.source_select }}</label>
|
||||
<div class="col-sm-10 imapsync-select-wrap">
|
||||
<select class="imapsync-source-select" id="addsja_source_id" data-live-search="true" data-show-subtext="true" data-width="100%" name="source_id" title="{{ lang.syncjobs.source_select }}" required>
|
||||
{# options are loaded via the API on modal open (js/site/imapsync.js) #}
|
||||
</select>
|
||||
<div class="imapsync-no-source d-none mt-2">
|
||||
<small class="text-muted">{{ lang.syncjobs.no_sources }}</small>
|
||||
{% if mailcow_cc_role == 'admin' or acl.syncjobs == 1 %}
|
||||
<button type="button" class="btn btn-sm btn-success ms-2 imapsync-open-source-modal"><i class="bi bi-plus-lg" aria-hidden="true"></i> {{ lang.syncjobs.source_add }}</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2 imapsync-user1-row">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsja_user1">{{ lang.syncjobs.user1_label }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" id="addsja_user1" name="user1" autocomplete="off" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2 imapsync-oauth-connect-row" style="display:none;">
|
||||
<div class="offset-sm-2 col-sm-10">
|
||||
<button type="button" class="btn btn-sm btn-primary imapsync-oauth-connect-btn"><i class="bi bi-box-arrow-up-right" aria-hidden="true"></i> {{ lang.syncjobs.syncjob_oauth_connect }}</button>
|
||||
<span class="imapsync-oauth-connect-status ms-2" role="status" aria-live="polite"></span>
|
||||
<div><small class="text-muted">{{ lang.syncjobs.oauth_hint }}</small></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-4 password1-row">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsja_password1">{{ lang.syncjobs.password1_label }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="password" class="form-control" id="addsja_password1" name="password1" autocomplete="new-password" data-formcache-skip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="mins_interval">{{ lang.add.mins_interval }}</label>
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsja_mins_interval">{{ lang.syncjobs.mins_interval }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="number" class="form-control" name="mins_interval" min="1" max="43800" value="20" required>
|
||||
<input type="number" class="form-control" id="addsja_mins_interval" name="mins_interval" min="1" max="43800" value="20" required>
|
||||
<small class="text-muted">1-43800</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="subfolder2">{{ lang.edit.subfolder2|raw }}</label>
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsja_subfolder2">{{ lang.syncjobs.subfolder2|raw }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="subfolder2" value="">
|
||||
<input type="text" class="form-control" id="addsja_subfolder2" name="subfolder2" value="">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="maxage">{{ lang.edit.maxage|raw }}</label>
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsja_maxage">{{ lang.syncjobs.maxage|raw }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="number" class="form-control" name="maxage" min="0" max="32000" value="0">
|
||||
<input type="number" class="form-control" id="addsja_maxage" name="maxage" min="0" max="32000" value="0">
|
||||
<small class="text-muted">0-32000</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="maxbytespersecond">{{ lang.edit.maxbytespersecond|raw }}</label>
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsja_maxbytespersecond">{{ lang.syncjobs.maxbytespersecond|raw }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="number" class="form-control" name="maxbytespersecond" min="0" max="125000000" value="0">
|
||||
<input type="number" class="form-control" id="addsja_maxbytespersecond" name="maxbytespersecond" min="0" max="125000000" value="0">
|
||||
<small class="text-muted">0-125000000</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="timeout1">{{ lang.edit.timeout1 }}</label>
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsja_timeout1">{{ lang.syncjobs.timeout1 }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="number" class="form-control" name="timeout1" min="1" max="32000" value="600">
|
||||
<input type="number" class="form-control" id="addsja_timeout1" name="timeout1" min="1" max="32000" value="600">
|
||||
<small class="text-muted">1-32000</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="timeout2">{{ lang.edit.timeout2 }}</label>
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsja_timeout2">{{ lang.syncjobs.timeout2 }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="number" class="form-control" name="timeout2" min="1" max="32000" value="600">
|
||||
<input type="number" class="form-control" id="addsja_timeout2" name="timeout2" min="1" max="32000" value="600">
|
||||
<small class="text-muted">1-32000</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="exclude">{{ lang.add.exclude }}</label>
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsja_exclude">{{ lang.syncjobs.exclude }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="exclude" value="(?i)spam|(?i)junk">
|
||||
<input type="text" class="form-control" id="addsja_exclude" name="exclude" value="(?i)spam|(?i)junk">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="custom_params">{{ lang.add.custom_params }}</label>
|
||||
<label class="control-label col-sm-2 text-sm-end" id="addsja_custom_params_label">{{ lang.syncjobs.custom_params }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="custom_params" placeholder="--some-param=xy --other-param=yx">
|
||||
<small class="text-muted">{{ lang.add.custom_params_hint }}</small>
|
||||
<div class="imapsync-cp-editor" role="group" aria-labelledby="addsja_custom_params_label">
|
||||
<div class="imapsync-cp-rows"></div>
|
||||
<button type="button" class="btn btn-sm btn-secondary imapsync-cp-add"><i class="bi bi-plus-lg" aria-hidden="true"></i> {{ lang.syncjobs.custom_param_add }}</button>
|
||||
</div>
|
||||
<input type="hidden" name="custom_params" class="imapsync-cp-value" value="[]">
|
||||
<small class="text-muted d-block mt-1">{{ lang.syncjobs.custom_params_hint }}</small>
|
||||
</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="delete2duplicates" checked> {{ lang.add.delete2duplicates }} (--delete2duplicates)</label>
|
||||
<label><input type="checkbox" class="form-check-input" value="1" name="delete2duplicates" checked> {{ lang.syncjobs.delete2duplicates }} (--delete2duplicates)</label>
|
||||
</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="delete1"> {{ lang.add.delete1 }} (--delete1)</label>
|
||||
<label><input type="checkbox" class="form-check-input" value="1" name="delete1"> {{ lang.syncjobs.delete1 }} (--delete1)</label>
|
||||
</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="delete2"> {{ lang.add.delete2 }} (--delete2)</label>
|
||||
<label><input type="checkbox" class="form-check-input" value="1" name="delete2"> {{ lang.syncjobs.delete2 }} (--delete2)</label>
|
||||
</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="automap" checked> {{ lang.add.automap }} (--automap)</label>
|
||||
<label><input type="checkbox" class="form-check-input" value="1" name="automap" checked> {{ lang.syncjobs.automap }} (--automap)</label>
|
||||
</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="skipcrossduplicates"> {{ lang.add.skipcrossduplicates }} (--skipcrossduplicates)</label>
|
||||
<label><input type="checkbox" class="form-check-input" value="1" name="skipcrossduplicates"> {{ lang.syncjobs.skipcrossduplicates }} (--skipcrossduplicates)</label>
|
||||
</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="subscribeall" checked> {{ lang.add.subscribeall }} (--subscribeall)</label>
|
||||
<label><input type="checkbox" class="form-check-input" value="1" name="subscribeall" checked> {{ lang.syncjobs.subscribeall }} (--subscribeall)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
<div class="offset-sm-2 col-sm-10">
|
||||
<div class="form-check">
|
||||
<label><input type="checkbox" class="form-check-input" value="1" name="dry"> {{ lang.add.dry }} (--dry)</label>
|
||||
<label><input type="checkbox" class="form-check-input" value="1" name="dry"> {{ lang.syncjobs.dry }} (--dry)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1347,3 +1349,5 @@
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- DNS info modal -->
|
||||
|
||||
{% include "modals/imapsync_source.twig" %}
|
||||
@@ -27,150 +27,153 @@
|
||||
</div>
|
||||
</div><!-- add domain admin modal -->
|
||||
<!-- add sync job modal -->
|
||||
<div class="modal fade" id="addSyncJobModal" tabindex="-1" role="dialog" aria-hidden="true">
|
||||
<div class="modal fade" id="addSyncJobModal" tabindex="-1" role="dialog" aria-hidden="true" aria-labelledby="addSyncJobModalLabel">
|
||||
<div class="modal-dialog modal-xl">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">{{ lang.add.syncjob }}</h3>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||
<h3 class="modal-title" id="addSyncJobModalLabel">{{ lang.syncjobs.add }}</h3>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ lang.footer.cancel }}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>{{ lang.add.syncjob_hint }}</p>
|
||||
<p>{{ lang.syncjobs.add_hint }}</p>
|
||||
<form class="form-horizontal" data-cached-form="true" role="form" data-id="add_syncjob">
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="host1">{{ lang.add.hostname }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="host1" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
<label class="control-label col-sm-2" for="port1">{{ lang.add.port }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="number" class="form-control" name="port1" min="1" max="65535" value="143" required>
|
||||
<small class="text-muted">1-65535</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="user1">{{ lang.add.username }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="user1" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
<label class="control-label col-sm-2" for="password1">{{ lang.add.password }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="password" class="form-control" name="password1" data-hibp="true" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="enc1">{{ lang.add.enc_method }}</label>
|
||||
<div class="col-sm-10">
|
||||
<select name="enc1" title="{{ lang.add.select }}" required>
|
||||
<option value="SSL" selected>SSL</option>
|
||||
<option value="TLS">STARTTLS</option>
|
||||
<option value="PLAIN">PLAIN</option>
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsju_source_id">{{ lang.syncjobs.source_select }}</label>
|
||||
<div class="col-sm-10 imapsync-select-wrap">
|
||||
<select class="imapsync-source-select" id="addsju_source_id" data-live-search="true" data-show-subtext="true" data-width="100%" name="source_id" title="{{ lang.syncjobs.source_select }}" required>
|
||||
{# options are loaded via the API on modal open (js/site/imapsync.js) #}
|
||||
</select>
|
||||
<div class="imapsync-no-source d-none mt-2">
|
||||
<small class="text-muted">{{ lang.syncjobs.no_sources }}</small>
|
||||
{% if mailcow_cc_role == 'admin' or acl.syncjobs == 1 %}
|
||||
<button type="button" class="btn btn-sm btn-success ms-2 imapsync-open-source-modal"><i class="bi bi-plus-lg" aria-hidden="true"></i> {{ lang.syncjobs.source_add }}</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2 imapsync-user1-row">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsju_user1">{{ lang.syncjobs.user1_label }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" id="addsju_user1" name="user1" autocomplete="off" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2 imapsync-oauth-connect-row" style="display:none;">
|
||||
<div class="offset-sm-2 col-sm-10">
|
||||
<button type="button" class="btn btn-sm btn-primary imapsync-oauth-connect-btn"><i class="bi bi-box-arrow-up-right" aria-hidden="true"></i> {{ lang.syncjobs.syncjob_oauth_connect }}</button>
|
||||
<span class="imapsync-oauth-connect-status ms-2" role="status" aria-live="polite"></span>
|
||||
<div><small class="text-muted">{{ lang.syncjobs.oauth_hint }}</small></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-4 password1-row">
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsju_password1">{{ lang.syncjobs.password1_label }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="password" class="form-control" id="addsju_password1" name="password1" autocomplete="new-password" data-formcache-skip data-hibp="true">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="mins_interval">{{ lang.add.mins_interval }}</label>
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsju_mins_interval">{{ lang.syncjobs.mins_interval }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="number" class="form-control" name="mins_interval" min="1" max="43800" value="20" required>
|
||||
<input type="number" class="form-control" id="addsju_mins_interval" name="mins_interval" min="1" max="43800" value="20" required>
|
||||
<small class="text-muted">1-43800</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="subfolder2">{{ lang.edit.subfolder2|raw }}</label>
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsju_subfolder2">{{ lang.syncjobs.subfolder2|raw }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="subfolder2" value="">
|
||||
<input type="text" class="form-control" id="addsju_subfolder2" name="subfolder2" value="">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="maxage">{{ lang.edit.maxage|raw }}</label>
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsju_maxage">{{ lang.syncjobs.maxage|raw }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="number" class="form-control" name="maxage" min="0" max="32000" value="0">
|
||||
<input type="number" class="form-control" id="addsju_maxage" name="maxage" min="0" max="32000" value="0">
|
||||
<small class="text-muted">0-32000</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="maxbytespersecond">{{ lang.edit.maxbytespersecond|raw }}</label>
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsju_maxbytespersecond">{{ lang.syncjobs.maxbytespersecond|raw }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="number" class="form-control" name="maxbytespersecond" min="0" max="125000000" value="0">
|
||||
<input type="number" class="form-control" id="addsju_maxbytespersecond" name="maxbytespersecond" min="0" max="125000000" value="0">
|
||||
<small class="text-muted">0-125000000</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="timeout1">{{ lang.edit.timeout1 }}</label>
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsju_timeout1">{{ lang.syncjobs.timeout1 }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="number" class="form-control" name="timeout1" min="1" max="32000" value="600">
|
||||
<input type="number" class="form-control" id="addsju_timeout1" name="timeout1" min="1" max="32000" value="600">
|
||||
<small class="text-muted">1-32000</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="timeout2">{{ lang.edit.timeout2 }}</label>
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsju_timeout2">{{ lang.syncjobs.timeout2 }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="number" class="form-control" name="timeout2" min="1" max="32000" value="600">
|
||||
<input type="number" class="form-control" id="addsju_timeout2" name="timeout2" min="1" max="32000" value="600">
|
||||
<small class="text-muted">1-32000</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<label class="control-label col-sm-2" for="exclude">{{ lang.add.exclude }}</label>
|
||||
<label class="control-label col-sm-2 text-sm-end" for="addsju_exclude">{{ lang.syncjobs.exclude }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="exclude" value="(?i)spam|(?i)junk">
|
||||
<input type="text" class="form-control" id="addsju_exclude" name="exclude" value="(?i)spam|(?i)junk">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-4">
|
||||
<label class="control-label col-sm-2" for="custom_params">{{ lang.add.custom_params }}</label>
|
||||
<label class="control-label col-sm-2 text-sm-end" id="addsju_custom_params_label">{{ lang.syncjobs.custom_params }}</label>
|
||||
<div class="col-sm-10">
|
||||
<input type="text" class="form-control" name="custom_params" placeholder="--delete2folders --otheroption">
|
||||
<div class="imapsync-cp-editor" role="group" aria-labelledby="addsju_custom_params_label">
|
||||
<div class="imapsync-cp-rows"></div>
|
||||
<button type="button" class="btn btn-sm btn-secondary imapsync-cp-add"><i class="bi bi-plus-lg" aria-hidden="true"></i> {{ lang.syncjobs.custom_param_add }}</button>
|
||||
</div>
|
||||
<input type="hidden" name="custom_params" class="imapsync-cp-value" value="[]">
|
||||
<small class="text-muted d-block mt-1">{{ lang.syncjobs.custom_params_hint }}</small>
|
||||
</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="delete2duplicates" checked> {{ lang.add.delete2duplicates }} (--delete2duplicates)</label>
|
||||
<label><input type="checkbox" class="form-check-input" value="1" name="delete2duplicates" checked> {{ lang.syncjobs.delete2duplicates }} (--delete2duplicates)</label>
|
||||
</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="delete1"> {{ lang.add.delete1 }} (--delete1)</label>
|
||||
<label><input type="checkbox" class="form-check-input" value="1" name="delete1"> {{ lang.syncjobs.delete1 }} (--delete1)</label>
|
||||
</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="delete2"> {{ lang.add.delete2 }} (--delete2)</label>
|
||||
<label><input type="checkbox" class="form-check-input" value="1" name="delete2"> {{ lang.syncjobs.delete2 }} (--delete2)</label>
|
||||
</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="automap" checked> {{ lang.add.automap }} (--automap)</label>
|
||||
<label><input type="checkbox" class="form-check-input" value="1" name="automap" checked> {{ lang.syncjobs.automap }} (--automap)</label>
|
||||
</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="skipcrossduplicates"> {{ lang.add.skipcrossduplicates }} (--skipcrossduplicates)</label>
|
||||
<label><input type="checkbox" class="form-check-input" value="1" name="skipcrossduplicates"> {{ lang.syncjobs.skipcrossduplicates }} (--skipcrossduplicates)</label>
|
||||
</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="subscribeall" checked> {{ lang.add.subscribeall }} (--subscribeall)</label>
|
||||
<label><input type="checkbox" class="form-check-input" value="1" name="subscribeall" checked> {{ lang.syncjobs.subscribeall }} (--subscribeall)</label>
|
||||
</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="dry" checked> {{ lang.add.dry }} (--dry)</label>
|
||||
<label><input type="checkbox" class="form-check-input" value="1" name="dry" checked> {{ lang.syncjobs.dry }} (--dry)</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -191,6 +194,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- add sync job modal -->
|
||||
{% include 'modals/imapsync_source.twig' %}
|
||||
<!-- app passwd modal -->
|
||||
<div class="modal fade" id="addAppPasswdModal" tabindex="-1" role="dialog" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg">
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<li class="nav-item" role="presentation"><button class="nav-link" role="tab" aria-selected="false" aria-controls="Spamfilter" role="tab" data-bs-toggle="tab" data-bs-target="#Spamfilter">{{ lang.user.spamfilter }}</button></li>
|
||||
{% endif %}
|
||||
{% if acl.syncjobs == 1 %}
|
||||
<li class="nav-item" role="presentation"><button class="nav-link" role="tab" aria-selected="false" aria-controls="Syncjobs" role="tab" data-bs-toggle="tab" data-bs-target="#Syncjobs">{{ lang.user.sync_jobs }}</button></li>
|
||||
<li class="nav-item" role="presentation"><button class="nav-link" role="tab" aria-selected="false" aria-controls="Syncjobs" role="tab" data-bs-toggle="tab" data-bs-target="#Syncjobs">{{ lang.syncjobs.sync_jobs }}</button></li>
|
||||
{% endif %}
|
||||
{% if acl.app_passwds == 1 %}
|
||||
<li class="nav-item" role="presentation"><button class="nav-link" role="tab" aria-selected="false" aria-controls="AppPasswds" role="tab" data-bs-toggle="tab" data-bs-target="#AppPasswds">{{ lang.user.app_passwds }}</button></li>
|
||||
|
||||
@@ -1,12 +1,29 @@
|
||||
<div class="tab-pane fade" id="Syncjobs" role="tabpanel" aria-labelledby="Syncjobs">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex fs-5">
|
||||
<button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-Syncjobs" data-bs-toggle="collapse" aria-controls="collapse-tab-Syncjobs">
|
||||
{{ lang.user.sync_jobs }}
|
||||
<span class="d-none d-md-block">{{ lang.syncjobs.sources_panel }}</span>
|
||||
<button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-imapsync-sources" data-bs-toggle="collapse" aria-controls="collapse-tab-imapsync-sources">
|
||||
{{ lang.syncjobs.sources_panel }}
|
||||
</button>
|
||||
<span class="d-none d-md-block">{{ lang.user.sync_jobs }}</span>
|
||||
</div>
|
||||
<div id="collapse-tab-Syncjobs" class="card-body collapse" data-bs-parent="#user-content">
|
||||
<div id="collapse-tab-imapsync-sources" class="card-body collapse" data-bs-parent="#user-content">
|
||||
<p class="text-muted">{{ lang.syncjobs.sources_hint }}</p>
|
||||
<div class="mass-actions-user mb-4">
|
||||
<div class="btn-group" data-acl="{{ acl.syncjobs }}">
|
||||
<a class="btn btn-sm d-block d-sm-inline btn-success" href="#" data-bs-toggle="modal" data-bs-target="#addImapsyncSourceModal"><i class="bi bi-plus-lg"></i> {{ lang.syncjobs.source_add }}</a>
|
||||
</div>
|
||||
</div>
|
||||
<table id="imapsync_source_table" class="table table-striped dt-responsive w-100"></table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card mb-4">
|
||||
<div class="card-header d-flex fs-5">
|
||||
<button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-Syncjobs" data-bs-toggle="collapse" aria-controls="collapse-tab-Syncjobs">
|
||||
{{ lang.syncjobs.sync_jobs }}
|
||||
</button>
|
||||
<span class="d-none d-md-block">{{ lang.syncjobs.sync_jobs }}</span>
|
||||
</div>
|
||||
<div id="collapse-tab-Syncjobs" class="card-body collapse" data-bs-parent="#user-content">
|
||||
<div class="mass-actions-user mb-4">
|
||||
<div class="btn-group" data-acl="{{ acl.syncjobs }}">
|
||||
<div class="btn-group">
|
||||
@@ -23,7 +40,7 @@
|
||||
</ul>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<a class="btn btn-sm d-block d-sm-inline btn-success" href="#" data-bs-toggle="modal" data-bs-target="#addSyncJobModal"><i class="bi bi-plus-lg"></i> {{ lang.user.create_syncjob }}</a>
|
||||
<a class="btn btn-sm d-block d-sm-inline btn-success" href="#" data-bs-toggle="modal" data-bs-target="#addSyncJobModal"><i class="bi bi-plus-lg"></i> {{ lang.syncjobs.create }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -44,7 +61,7 @@
|
||||
</ul>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
<a class="btn btn-sm d-block d-sm-inline btn-success" href="#" data-bs-toggle="modal" data-bs-target="#addSyncJobModal"><i class="bi bi-plus-lg"></i> {{ lang.user.create_syncjob }}</a>
|
||||
<a class="btn btn-sm d-block d-sm-inline btn-success" href="#" data-bs-toggle="modal" data-bs-target="#addSyncJobModal"><i class="bi bi-plus-lg"></i> {{ lang.syncjobs.create }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<script type='text/javascript'>
|
||||
var acl = '{{ acl_json|raw }}';
|
||||
var lang = {{ lang_user|raw }};
|
||||
lang.syncjobs = {{ lang_syncjobs|raw }};
|
||||
var csrf_token = '{{ csrf_token }}';
|
||||
var pagination_size = Math.trunc('{{ pagination_size }}');
|
||||
var mailcow_cc_username = '{{ mailcow_cc_username }}';
|
||||
|
||||
@@ -73,8 +73,10 @@ $template_data = [
|
||||
'lang_user' => json_encode($lang['user']),
|
||||
'number_of_app_passwords' => $number_of_app_passwords,
|
||||
'lang_datatables' => json_encode($lang['datatables']),
|
||||
'imapsync_redirect_uri' => \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->oauth->redirectUri(),
|
||||
];
|
||||
|
||||
$js_minifier->add('/web/js/site/imapsync.js');
|
||||
$js_minifier->add('/web/js/site/user.js');
|
||||
$js_minifier->add('/web/js/site/pwgen.js');
|
||||
|
||||
|
||||
+5
-2
@@ -194,6 +194,9 @@ services:
|
||||
ofelia.job-exec.phpfpm_ldap_sync.schedule: "0 * * * * *"
|
||||
ofelia.job-exec.phpfpm_ldap_sync.no-overlap: "true"
|
||||
ofelia.job-exec.phpfpm_ldap_sync.command: "/bin/bash -c \"php /crons/ldap-sync.php || exit 0\""
|
||||
ofelia.job-exec.phpfpm_imapsync_oauth_refresh.schedule: "0 * * * * *"
|
||||
ofelia.job-exec.phpfpm_imapsync_oauth_refresh.no-overlap: "true"
|
||||
ofelia.job-exec.phpfpm_imapsync_oauth_refresh.command: "/bin/bash -c \"php /crons/imapsync-oauth-refresh.php || exit 0\""
|
||||
networks:
|
||||
mailcow-network:
|
||||
aliases:
|
||||
@@ -252,7 +255,7 @@ services:
|
||||
- sogo
|
||||
|
||||
dovecot-mailcow:
|
||||
image: ghcr.io/mailcow/dovecot:2.3.21.1-2
|
||||
image: ghcr.io/mailcow/dovecot:2.3.21.1-3
|
||||
depends_on:
|
||||
- mysql-mailcow
|
||||
- netfilter-mailcow
|
||||
@@ -312,7 +315,7 @@ services:
|
||||
ofelia.enabled: "true"
|
||||
ofelia.job-exec.dovecot_imapsync_runner.schedule: "0 * * * * *"
|
||||
ofelia.job-exec.dovecot_imapsync_runner.no-overlap: "true"
|
||||
ofelia.job-exec.dovecot_imapsync_runner.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && /usr/local/bin/gosu nobody /usr/local/bin/imapsync_runner.pl || exit 0\""
|
||||
ofelia.job-exec.dovecot_imapsync_runner.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && /usr/local/bin/gosu nobody /usr/local/bin/imapsync_runner.py || exit 0\""
|
||||
ofelia.job-exec.dovecot_trim_logs.schedule: "0 * * * * *"
|
||||
ofelia.job-exec.dovecot_trim_logs.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && /usr/local/bin/gosu vmail /usr/local/bin/trim_logs.sh || exit 0\""
|
||||
ofelia.job-exec.dovecot_quarantine.schedule: "0 */20 * * * *"
|
||||
|
||||
Reference in new issue
Block a user