Compare commits

..
Author SHA1 Message Date
renovate[bot] 2c3a3dfa0e Update dependency composer/composer to v2.10.3
Signed-off-by: milkmaker <milkmaker@mailcow.de>
2026-09-24 20:32:28 +00:00
155 changed files with 5723 additions and 16603 deletions

No files matched your search

+1 -5
View File
@@ -64,8 +64,6 @@ data/web/templates/cache/*
!data/web/templates/cache/.gitkeep
data/web/.well-known/acme-challenge
data/web/css/build/0081-custom-mailcow.css
data/web/css/themes/*/
!data/web/css/themes/servercow/
data/web/inc/vars.local.inc.php
data/web/inc/app_info.inc.php
data/web/nextcloud*/
@@ -78,6 +76,4 @@ refresh_images.sh
update_diffs/
create_cold_standby.sh
!data/conf/nginx/mailcow_auth.conf
data/conf/postfix/postfix-tlspol
tests/php/vendor/
tests/php/.phpunit.cache/
data/conf/postfix/postfix-tlspol
+3 -1
View File
@@ -68,6 +68,7 @@ RUN addgroup -g 5000 vmail \
perl-package-stash-xs \
perl-par-packer \
perl-parse-recdescent \
perl-lockfile-simple \
libproc2 \
perl-readonly \
perl-regexp-common \
@@ -83,6 +84,7 @@ RUN addgroup -g 5000 vmail \
perl-test-warn \
perl-try-tiny \
perl-unicode-string \
perl-proc-processtable \
perl-app-cpanminus \
procps \
python3 \
@@ -118,7 +120,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.py /usr/local/bin/imapsync_runner.py
COPY imapsync_runner.pl /usr/local/bin/imapsync_runner.pl
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.py \
/usr/local/bin/imapsync_runner.pl \
/usr/local/bin/imapsync \
/usr/local/bin/trim_logs.sh \
/usr/local/bin/sa-rules.sh \
+196
View File
@@ -0,0 +1,196 @@
#!/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);
-274
View File
@@ -1,274 +0,0 @@
#!/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'])
+1 -1
View File
@@ -1,4 +1,4 @@
FROM nginx:1.30.6-alpine
FROM nginx:1.30.4-alpine
LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>"
ENV PIP_BREAK_SYSTEM_PACKAGES=1
+9 -14
View File
@@ -1,4 +1,4 @@
FROM php:8.2-fpm-alpine3.24
FROM php:8.2-fpm-alpine3.21
LABEL maintainer = "The Infrastructure Company GmbH <info@servercow.de>"
@@ -13,7 +13,7 @@ ARG MEMCACHED_PECL_VERSION=3.4.0
# renovate: datasource=github-tags depName=phpredis/phpredis versioning=semver-coerced extractVersion=(?<version>.*)$
ARG REDIS_PECL_VERSION=6.3.0
# renovate: datasource=github-tags depName=composer/composer versioning=semver-coerced extractVersion=(?<version>.*)$
ARG COMPOSER_VERSION=2.10.2
ARG COMPOSER_VERSION=2.10.3
RUN apk add -U --no-cache autoconf \
aspell-dev \
@@ -47,7 +47,6 @@ RUN apk add -U --no-cache autoconf \
libressl-dev \
librsvg \
libtool \
libwebp \
libwebp-dev \
libxml2-dev \
libxpm \
@@ -64,16 +63,13 @@ RUN apk add -U --no-cache autoconf \
samba-client \
zlib-dev \
tzdata \
&& docker-php-source extract \
# PECL extensions from their GitHub tags (pecl.php.net is not reliable enough for builds)
&& mkdir -p /usr/src/php/ext/apcu /usr/src/php/ext/imagick /usr/src/php/ext/mailparse /usr/src/php/ext/memcached /usr/src/php/ext/redis \
&& curl -fsSL https://github.com/krakjoe/apcu/archive/refs/tags/v${APCU_PECL_VERSION}.tar.gz | tar -xz --strip-components=1 -C /usr/src/php/ext/apcu \
&& curl -fsSL https://github.com/Imagick/imagick/archive/refs/tags/${IMAGICK_PECL_VERSION}.tar.gz | tar -xz --strip-components=1 -C /usr/src/php/ext/imagick \
&& sed -i "s/@PACKAGE_VERSION@/${IMAGICK_PECL_VERSION}/" /usr/src/php/ext/imagick/php_imagick.h \
&& curl -fsSL https://github.com/php/pecl-mail-mailparse/archive/refs/tags/v${MAILPARSE_PECL_VERSION}.tar.gz | tar -xz --strip-components=1 -C /usr/src/php/ext/mailparse \
&& curl -fsSL https://github.com/php-memcached-dev/php-memcached/archive/refs/tags/v${MEMCACHED_PECL_VERSION}.tar.gz | tar -xz --strip-components=1 -C /usr/src/php/ext/memcached \
&& curl -fsSL https://github.com/phpredis/phpredis/archive/refs/tags/${REDIS_PECL_VERSION}.tar.gz | tar -xz --strip-components=1 -C /usr/src/php/ext/redis \
&& docker-php-ext-install -j 4 apcu imagick mailparse memcached redis \
&& pecl install APCu-${APCU_PECL_VERSION} \
&& pecl install imagick-${IMAGICK_PECL_VERSION} \
&& pecl install mailparse-${MAILPARSE_PECL_VERSION} \
&& pecl install memcached-${MEMCACHED_PECL_VERSION} \
&& pecl install redis-${REDIS_PECL_VERSION} \
&& docker-php-ext-enable apcu imagick memcached mailparse redis \
&& pecl clear-cache \
&& docker-php-ext-configure intl \
&& docker-php-ext-configure exif \
&& docker-php-ext-configure gd --with-freetype=/usr/include/ \
@@ -87,7 +83,6 @@ RUN apk add -U --no-cache autoconf \
&& curl --silent --show-error https://getcomposer.org/installer | php -- --version=${COMPOSER_VERSION} \
&& mv composer.phar /usr/local/bin/composer \
&& chmod +x /usr/local/bin/composer \
&& docker-php-source delete \
&& apk del --purge autoconf \
aspell-dev \
cyrus-sasl-dev \
+1 -1
View File
@@ -4,7 +4,7 @@ WORKDIR /src
ENV CGO_ENABLED=0 \
GO111MODULE=on \
NOOPT=1 \
VERSION=1.14.0
VERSION=1.11.0
RUN git clone --branch v${VERSION} https://github.com/Zuplu/postfix-tlspol && \
cd /src/postfix-tlspol && \
+1 -1
View File
@@ -2,7 +2,7 @@ FROM debian:trixie-slim
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
ARG DEBIAN_FRONTEND=noninteractive
ARG RSPAMD_VER=rspamd_4.2.1-1~b7a16ae
ARG RSPAMD_VER=rspamd_4.1.4-1~beb659b
ARG CODENAME=trixie
ENV LC_ALL=C
+3 -3
View File
@@ -1,6 +1,6 @@
# SOGo built from source to enable security patch application
# Repository: https://github.com/Alinto/sogo
# Version: SOGo-5.12.12
# Version: SOGo-5.12.11
#
# Applied security patches:
# -
@@ -12,8 +12,8 @@ FROM debian:bookworm
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
ARG DEBIAN_FRONTEND=noninteractive
ARG SOGO_VERSION=SOGo-5.12.12
ARG SOPE_VERSION=SOPE-5.12.12
ARG SOGO_VERSION=SOGo-5.12.11
ARG SOPE_VERSION=SOPE-5.12.11
# Security patches to apply (space-separated commit hashes)
ARG SOGO_SECURITY_PATCHES=""
# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?<version>.*)$
@@ -1,73 +0,0 @@
<?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);
}
-2
View File
@@ -1,5 +1,3 @@
max_execution_time = 3600
max_input_time = 3600
memory_limit = 512M
; not used by mailcow, disabled as hardening (PHP security release 2026-10-21)
session.upload_progress.enabled = Off
+6 -9
View File
@@ -1,6 +1,6 @@
# Whitelist generated by Postwhite v3.4 on Thu Oct 1 00:35:20 UTC 2026
# Whitelist generated by Postwhite v3.4 on Tue Sep 1 00:34:07 UTC 2026
# https://github.com/stevejenkins/postwhite/
# 2246 total rules
# 2243 total rules
2a00:1450:4000::/36 permit
2a00:1450:4864::/56 permit
2a01:111:f400::/48 permit
@@ -358,7 +358,6 @@
64.127.115.252 permit
64.132.88.0/23 permit
64.132.92.0/24 permit
64.181.136.241 permit
64.181.194.190 permit
64.181.213.254 permit
64.207.219.7 permit
@@ -1452,7 +1451,8 @@
132.226.49.32 permit
132.226.56.24 permit
134.98.248.128/25 permit
134.128.64.0/18 permit
134.128.64.0/19 permit
134.128.96.0/19 permit
134.170.27.8 permit
134.170.113.0/26 permit
134.170.141.64/26 permit
@@ -1546,7 +1546,7 @@
149.97.173.180 permit
149.118.160.128/25 permit
150.136.21.199 permit
150.171.109.178 permit
150.171.109.183 permit
150.230.98.160 permit
151.145.38.14 permit
152.67.105.195 permit
@@ -1573,7 +1573,6 @@
158.247.100.0/25 permit
159.13.4.0/25 permit
159.13.33.181 permit
159.26.176.0/20 permit
159.92.154.0/24 permit
159.92.155.0/24 permit
159.92.157.0/24 permit
@@ -1707,8 +1706,6 @@
175.41.215.51 permit
176.32.105.0/24 permit
176.32.127.0/24 permit
176.94.34.32/27 permit
176.94.34.64/27 permit
178.236.10.128/26 permit
182.50.76.0/22 permit
182.50.78.64/28 permit
@@ -2228,7 +2225,7 @@
2001:748:400:3301::4 permit
2404:6800:4000::/36 permit
2404:6800:4864::/56 permit
2603:1061:14:1d0::1 permit
2603:1061:14:102::1 permit
2607:13c0:0001:0000:0000:0000:0000:7000/116 permit
2607:13c0:0002:0000:0000:0000:0000:1000/116 permit
2607:13c0:0004:0000:0000:0000:0000:0000/116 permit
+4 -6
View File
@@ -856,8 +856,6 @@ rspamd_config:register_symbol({
local seen_cte
local newline_s = newline(task)
-- use the CTE add_text_footer actually applied
local new_cte = rewrite.new_cte or 'quoted-printable'
local function rewrite_ct_cb(name, hdr)
if rewrite.need_rewrite_ct then
@@ -879,13 +877,13 @@ rspamd_config:register_symbol({
return
elseif name:lower() == 'content-transfer-encoding' then
out[#out + 1] = string.format('%s: %s',
'Content-Transfer-Encoding', new_cte)
'Content-Transfer-Encoding', 'quoted-printable')
-- update Content-Transfer-Encoding header
task:set_milter_reply({
remove_headers = {['Content-Transfer-Encoding'] = 0},
})
task:set_milter_reply({
add_headers = {['Content-Transfer-Encoding'] = new_cte}
add_headers = {['Content-Transfer-Encoding'] = 'quoted-printable'}
})
seen_cte = true
return
@@ -897,7 +895,7 @@ rspamd_config:register_symbol({
task:headers_foreach(rewrite_ct_cb, {full = true})
if not seen_cte and rewrite.need_rewrite_ct then
out[#out + 1] = string.format('%s: %s', 'Content-Transfer-Encoding', new_cte)
out[#out + 1] = string.format('%s: %s', 'Content-Transfer-Encoding', 'quoted-printable')
end
-- End of headers
@@ -916,7 +914,7 @@ rspamd_config:register_symbol({
out_parts[#out_parts + 1] = o
out_parts[#out_parts + 1] = newline_s
else
local removePrefix = "--\x0D\x0AContent-"
local removePrefix = "--\x0D\x0AContent-Type"
if string.lower(string.sub(tostring(o[1]), 1, string.len(removePrefix))) == string.lower(removePrefix) then
o[1] = string.sub(tostring(o[1]), string.len("--\x0D\x0A") + 1)
end
+7 -7
View File
@@ -153,12 +153,12 @@ foreach ($rcpts as $rcpt) {
// Loop through all found gotos
foreach ($gotos_array as $index => &$goto) {
error_log("RCPT RESOLVER: http pipe: query " . $goto . " as username from mailbox" . PHP_EOL);
error_log("RCPT RESOVLER: http pipe: query " . $goto . " as username from mailbox" . PHP_EOL);
$stmt = $pdo->prepare("SELECT `username` FROM `mailbox` WHERE `username` = :goto AND (`active`= '1' OR `active`= '2');");
$stmt->execute(array(':goto' => $goto));
$username = $stmt->fetch(PDO::FETCH_ASSOC)['username'];
if (!empty($username)) {
error_log("RCPT RESOLVER: http pipe: mailbox found: " . $username . PHP_EOL);
error_log("RCPT RESOVLER: http pipe: mailbox found: " . $username . PHP_EOL);
// Current goto is a mailbox, save to rcpt_final_mailboxes if not a duplicate
if (!in_array($username, $rcpt_final_mailboxes)) {
$rcpt_final_mailboxes[] = $username;
@@ -167,21 +167,21 @@ foreach ($rcpts as $rcpt) {
else {
$parsed_goto = parse_email($goto);
if (!$redis->hGet('DOMAIN_MAP', $parsed_goto['domain'])) {
error_log("RCPT RESOLVER:" . $goto . " is not a mailcow handled mailbox or alias address" . PHP_EOL);
error_log("RCPT RESOVLER:" . $goto . " is not a mailcow handled mailbox or alias address" . PHP_EOL);
}
else {
$stmt = $pdo->prepare("SELECT `goto` FROM `alias` WHERE `address` = :goto AND `active` = '1'");
$stmt->execute(array(':goto' => $goto));
$goto_branch = $stmt->fetch(PDO::FETCH_ASSOC)['goto'];
if ($goto_branch) {
error_log("RCPT RESOLVER: http pipe: goto address " . $goto . " is an alias branch for " . $goto_branch . PHP_EOL);
error_log("RCPT RESOVLER: http pipe: goto address " . $goto . " is an alias branch for " . $goto_branch . PHP_EOL);
$goto_branch_array = explode(',', $goto_branch);
} else {
$stmt = $pdo->prepare("SELECT `target_domain` FROM `alias_domain` WHERE `alias_domain` = :domain AND `active` = '1'");
$stmt->execute(array(':domain' => $parsed_goto['domain']));
$goto_branch = $stmt->fetch(PDO::FETCH_ASSOC)['target_domain'];
if ($goto_branch) {
error_log("RCPT RESOLVER: http pipe: goto domain " . $parsed_goto['domain'] . " is a domain alias branch for " . $goto_branch . PHP_EOL);
error_log("RCPT RESOVLER: http pipe: goto domain " . $parsed_goto['domain'] . " is a domain alias branch for " . $goto_branch . PHP_EOL);
$goto_branch_array = array($parsed_goto['local'] . '@' . $goto_branch);
}
}
@@ -203,11 +203,11 @@ foreach ($rcpts as $rcpt) {
// Force exit if loop cannot be solved
// Postfix does not allow for alias loops, so this should never happen.
$loop_c++;
error_log("RCPT RESOLVER: http pipe: goto array count on loop #". $loop_c . " is " . count($gotos_array) . PHP_EOL);
error_log("RCPT RESOVLER: http pipe: goto array count on loop #". $loop_c . " is " . count($gotos_array) . PHP_EOL);
}
}
catch (PDOException $e) {
error_log("RCPT RESOLVER: " . $e->getMessage() . PHP_EOL);
error_log("RCPT RESOVLER: " . $e->getMessage() . PHP_EOL);
http_response_code(502);
exit;
}
+7 -7
View File
@@ -139,12 +139,12 @@ foreach ($rcpts as $rcpt) {
// Loop through all found gotos
foreach ($gotos_array as $index => &$goto) {
error_log("RCPT RESOLVER: http pipe: query " . $goto . " as username from mailbox" . PHP_EOL);
error_log("RCPT RESOVLER: http pipe: query " . $goto . " as username from mailbox" . PHP_EOL);
$stmt = $pdo->prepare("SELECT `username` FROM `mailbox` WHERE `username` = :goto AND (`active`= '1' OR `active`= '2');");
$stmt->execute(array(':goto' => $goto));
$username = $stmt->fetch(PDO::FETCH_ASSOC)['username'];
if (!empty($username)) {
error_log("RCPT RESOLVER: http pipe: mailbox found: " . $username . PHP_EOL);
error_log("RCPT RESOVLER: http pipe: mailbox found: " . $username . PHP_EOL);
// Current goto is a mailbox, save to rcpt_final_mailboxes if not a duplicate
if (!in_array($username, $rcpt_final_mailboxes)) {
$rcpt_final_mailboxes[] = $username;
@@ -153,21 +153,21 @@ foreach ($rcpts as $rcpt) {
else {
$parsed_goto = parse_email($goto);
if (!$redis->hGet('DOMAIN_MAP', $parsed_goto['domain'])) {
error_log("RCPT RESOLVER:" . $goto . " is not a mailcow handled mailbox or alias address" . PHP_EOL);
error_log("RCPT RESOVLER:" . $goto . " is not a mailcow handled mailbox or alias address" . PHP_EOL);
}
else {
$stmt = $pdo->prepare("SELECT `goto` FROM `alias` WHERE `address` = :goto AND `active` = '1'");
$stmt->execute(array(':goto' => $goto));
$goto_branch = $stmt->fetch(PDO::FETCH_ASSOC)['goto'];
if ($goto_branch) {
error_log("RCPT RESOLVER: http pipe: goto address " . $goto . " is an alias branch for " . $goto_branch . PHP_EOL);
error_log("RCPT RESOVLER: http pipe: goto address " . $goto . " is an alias branch for " . $goto_branch . PHP_EOL);
$goto_branch_array = explode(',', $goto_branch);
} else {
$stmt = $pdo->prepare("SELECT `target_domain` FROM `alias_domain` WHERE `alias_domain` = :domain AND `active` = '1'");
$stmt->execute(array(':domain' => $parsed_goto['domain']));
$goto_branch = $stmt->fetch(PDO::FETCH_ASSOC)['target_domain'];
if ($goto_branch) {
error_log("RCPT RESOLVER: http pipe: goto domain " . $parsed_goto['domain'] . " is a domain alias branch for " . $goto_branch . PHP_EOL);
error_log("RCPT RESOVLER: http pipe: goto domain " . $parsed_goto['domain'] . " is a domain alias branch for " . $goto_branch . PHP_EOL);
$goto_branch_array = array($parsed_goto['local'] . '@' . $goto_branch);
}
}
@@ -189,11 +189,11 @@ foreach ($rcpts as $rcpt) {
// Force exit if loop cannot be solved
// Postfix does not allow for alias loops, so this should never happen.
$loop_c++;
error_log("RCPT RESOLVER: http pipe: goto array count on loop #". $loop_c . " is " . count($gotos_array) . PHP_EOL);
error_log("RCPT RESOVLER: http pipe: goto array count on loop #". $loop_c . " is " . count($gotos_array) . PHP_EOL);
}
}
catch (PDOException $e) {
error_log("RCPT RESOLVER: " . $e->getMessage() . PHP_EOL);
error_log("RCPT RESOVLER: " . $e->getMessage() . PHP_EOL);
http_response_code(502);
exit;
}
-2
View File
@@ -9,7 +9,6 @@ $_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');
@@ -43,7 +42,6 @@ $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';
-3
View File
@@ -115,10 +115,7 @@ $template_data = [
'logo_dark_specs' => customize('get', 'main_logo_dark_specs'),
'ip_check' => customize('get', 'ip_check'),
'custom_login' => customize('get', 'custom_login'),
'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',
+157 -1003
View File
File diff suppressed because it is too large. Load diff
+9 -8
View File
@@ -1,10 +1,10 @@
/*!
* Bootstrap-select v1.14.0-beta3 (https://developer.snapappointments.com/bootstrap-select)
*
* Copyright 2012-2022 SnapAppointments, LLC
* Licensed under MIT (https://github.com/snapappointments/bootstrap-select/blob/master/LICENSE)
*/
/*!
* Bootstrap-select v1.14.0-beta2 (https://developer.snapappointments.com/bootstrap-select)
*
* Copyright 2012-2021 SnapAppointments, LLC
* Licensed under MIT (https://github.com/snapappointments/bootstrap-select/blob/master/LICENSE)
*/
@-webkit-keyframes bs-notify-fadeOut {
0% {
opacity: 0.9;
@@ -35,7 +35,8 @@ select.selectpicker {
display: none !important;
}
.bootstrap-select {
width: 220px;
width: 220px \0;
/*IE9 and below*/
vertical-align: middle;
}
.bootstrap-select > .dropdown-toggle {
+5 -5
View File
@@ -17,15 +17,15 @@ table.dataTable>tbody>tr.child ul.dtr-details>li {
}
table.dataTable.dtr-inline.collapsed>tbody>tr>td.dtr-control:before:hover,
table.dataTable.dtr-inline.collapsed>tbody>tr>th.dtr-control:before:hover {
background-color: var(--mc-dtr-hover-bg);
background-color: #5e5e5e;
}
table.dataTable.dtr-inline.collapsed>tbody>tr>td.dtr-control:before,
table.dataTable.dtr-inline.collapsed>tbody>tr>th.dtr-control:before,
table.dataTable td.dt-control:before {
background-color: var(--mc-dtr-bg) !important;
border: 1.5px solid var(--mc-dtr-border) !important;
background-color: #979797 !important;
border: 1.5px solid #616161 !important;
border-radius: 2px !important;
color: var(--mc-dtr-color);
color: #fff;
height: 1em;
width: 1em;
line-height: 1.25em;
@@ -37,7 +37,7 @@ table.dataTable td.dt-control:before {
table.dataTable.dtr-inline.collapsed>tbody>tr.parent>td.dtr-control:before,
table.dataTable.dtr-inline.collapsed>tbody>tr.parent>th.dtr-control:before,
table.dataTable td.dt-control:before {
background-color: var(--mc-dtr-active-bg) !important;
background-color: #979797 !important;
}
table.dataTable.table-striped>tbody>tr>td {
vertical-align: middle;
+32 -82
View File
@@ -34,44 +34,11 @@
url('/fonts/noto-sans-v12-latin_greek_cyrillic-700italic.woff') format('woff');
}
/* theme tokens, override in css/themes/<name>/theme.css */
:root {
--mc-font-family: "Noto Sans","Helvetica Neue",Helvetica,Arial,sans-serif;
--mc-font-size: 10.5pt;
--mc-body-bg: #fbfbfb;
--mc-primary: #0F688D;
--mc-primary-border: #0d526d;
--mc-info: #148DBC;
--mc-info-border: #127ea8;
--mc-border: #dfdfdf;
--mc-surface: #fff;
--mc-surface-alt: #f7f7f7;
--mc-muted: #959595;
--mc-hr: #ccc;
--mc-progress-bg: #d5d5d5;
--mc-placeholder: rgb(137 137 137);
--mc-btn-outline-color: #000000;
--mc-btn-outline-border: #cfcfcf;
--mc-btn-outline-hover-bg: #f0f0f0;
--mc-btn-outline-active-bg: #f0f0f0;
--mc-btn-light-active-color: #fff;
--mc-btn-light-active-bg: #555;
--mc-btn-light-active-border: #4d4d4d;
--mc-dtr-bg: #979797;
--mc-dtr-border: #616161;
--mc-dtr-color: #fff;
--mc-dtr-hover-bg: #5e5e5e;
--mc-dtr-active-bg: #979797;
--mc-login-user: #28b62c;
--mc-login-domainadmin: #ffc107;
--mc-login-admin: #ff4136;
}
body {
min-height: 100vh;
display: flex;
flex-direction: column;
background-color: var(--mc-body-bg);
background-color: #fbfbfb;
}
#maxmsgsize { min-width: 80px; }
#slider1 .slider-selection {
@@ -119,17 +86,17 @@ body {
max-height: 1000px
}
.nav-tabs .nav-link, .nav-tabs .nav-link.disabled, .nav-tabs .nav-link.disabled:hover, .nav-tabs .nav-link.disabled:focus {
border-color: var(--mc-border);
border-color: #dfdfdf;
}
.nav-tabs .nav-link.active, .nav-tabs .nav-item.show .nav-link {
border-color: var(--mc-border);
border-bottom: 1px solid var(--mc-surface);
border-color: #dfdfdf;
border-bottom: 1px solid #ffffff;
}
.nav-tabs .nav-link:hover, .nav-tabs .nav-link:focus {
border-color: var(--mc-border);
border-color: #dfdfdf;
}
.nav-tabs {
border-bottom: 1px solid var(--mc-border);
border-bottom: 1px solid #dfdfdf;
}
.bi {
display: inline-block;
@@ -197,13 +164,13 @@ body.modal-open {
padding-right: inherit !important;
}
body {
font-family: var(--mc-font-family);
font-size: var(--mc-font-size);
font-family: "Noto Sans","Helvetica Neue",Helvetica,Arial,sans-serif;
font-size: 10.5pt;
line-height: 1.5;
}
html {
font-family: var(--mc-font-family);
font-size: var(--mc-font-size);
font-family: "Noto Sans","Helvetica Neue",Helvetica,Arial,sans-serif;
font-size: 10.5pt;
line-height: 1.5;
}
#mailcow-alert {
@@ -239,7 +206,7 @@ legend {
cursor: not-allowed;
}
.overlay {
background: var(--mc-surface);
background: #fff;
position: absolute;
z-index: 10000;
top: 0; right: 0; bottom: 0; left: 0;
@@ -248,6 +215,9 @@ legend {
.bootstrap-select.btn-group .no-results {
display: none;
}
.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-secondary {
color: rgb(197, 197, 197) !important;
}
.haveibeenpwned {
cursor: pointer;
-webkit-user-select: none;
@@ -269,7 +239,7 @@ legend {
.footer {
margin-top: 27px;
margin-bottom: 20px;
color: var(--mc-muted);
color: #959595;
display: flex;
flex-direction: column;
}
@@ -367,7 +337,7 @@ code {
overflow-x: auto!important;
}
.well {
border: 1px solid var(--mc-border);
border: 1px solid #dfdfdf;
background-color: #f9f9f9;
padding: 10px;
}
@@ -386,47 +356,47 @@ button[aria-expanded='true'] > .caret {
}
.list-group-details {
background: var(--mc-surface);
background: #fff;
}
.list-group-header {
background: var(--mc-surface-alt);
background: #f7f7f7;
}
.bg-primary, .alert-primary, .btn-primary {
background-color: var(--mc-primary) !important;
border-color: var(--mc-primary-border) !important;
background-color: #0F688D !important;
border-color: #0d526d !important;
}
.bg-info, .alert-info, .btn-info {
background-color: var(--mc-info) !important;
border-color: var(--mc-info-border) !important;
background-color: #148DBC !important;
border-color: #127ea8 !important;
}
.bootstrap-select>.dropdown-toggle.bs-placeholder.btn-secondary {
color: var(--mc-placeholder) !important;
color: rgb(137 137 137)!important;
}
.progress {
height: 16px;
background-color: var(--mc-progress-bg);
background-color: #d5d5d5;
}
.btn-outline-secondary:hover {
background-color: var(--mc-btn-outline-hover-bg);
background-color: #f0f0f0;
}
.btn.btn-outline-secondary {
color: var(--mc-btn-outline-color) !important;
border-color: var(--mc-btn-outline-border) !important;
color: #000000 !important;
border-color: #cfcfcf !important;
}
.btn-check:checked+.btn-outline-secondary, .btn-check:active+.btn-outline-secondary, .btn-outline-secondary:active, .btn-outline-secondary.active, .btn-outline-secondary.dropdown-toggle.show {
background-color: var(--mc-btn-outline-active-bg) !important;
background-color: #f0f0f0 !important;
}
.btn-check:checked+.btn-light, .btn-check:active+.btn-light, .btn-light:active, .btn-light.active, .show>.btn-light.dropdown-toggle {
color: var(--mc-btn-light-active-color);
background-color: var(--mc-btn-light-active-bg);
color: #fff;
background-color: #555;
background-image: none;
border-color: var(--mc-btn-light-active-border);
border-color: #4d4d4d;
}
.btn-check:checked+.btn-light:focus, .btn-check:active+.btn-light:focus, .btn-light:active:focus, .btn-light.active:focus, .show>.btn-light.dropdown-toggle:focus,
.btn-check:focus+.btn-light, .btn-light:focus {
@@ -442,26 +412,6 @@ button[aria-expanded='true'] > .caret {
text-decoration: none;
}
/* role marker on login pages, header icons differ too (color blindness) */
.login-card::before {
content: "";
display: block;
height: 5px;
background: var(--mc-login-color);
border-radius: inherit;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.login-user {
--mc-login-color: var(--mc-login-user);
}
.login-domainadmin {
--mc-login-color: var(--mc-login-domainadmin);
}
.login-admin {
--mc-login-color: var(--mc-login-admin);
}
.hr-title {
display: flex;
align-items: center;
@@ -473,7 +423,7 @@ button[aria-expanded='true'] > .caret {
.hr-title::after {
content: "";
flex: 1;
border-bottom: 1px solid var(--mc-hr);
border-bottom: 1px solid #ccc;
}
.hr-title:not(:empty)::before {
File diff suppressed because it is too large. Load diff
+114 -155
View File
@@ -1,103 +1,62 @@
/* theme tokens, override in css/themes/<name>/dark.css */
:root {
--mc-body-bg: #1c1c1e;
--mc-body-color: #f2f2f7;
--mc-surface: #2c2c2e;
--mc-surface-alt: #282828;
--mc-text: #ccc;
--mc-text-soft: #bbb;
--mc-muted: #8e8e93;
--mc-border: #555;
--mc-tab-border: #444444;
--mc-link: #6fc7e9;
--mc-link-hover: #3daedb;
--mc-btn-bg: #5e5e5e;
--mc-btn-border: #4c4c4e;
--mc-input-bg: #424242;
--mc-input-color: #e2e2e2;
--mc-input-border: #999;
--mc-dropdown-bg: #424242;
--mc-table-bg: #3a3a3a;
--mc-table-stripe: #282828;
--mc-table-stripe-row: #424242;
--mc-table-hover: #343434;
--mc-table-border: #5c5c5c;
--mc-progress-bg: #555;
--mc-placeholder: #d4d4d4;
--mc-btn-outline-color: #e0e0e0;
--mc-btn-outline-border: #7a7a7a;
--mc-btn-outline-hover-bg: #5c5c5c;
--mc-btn-outline-active-bg: #7a7a7a;
--mc-btn-light-active-color: #f2f2f7;
--mc-btn-light-active-bg: #242424;
--mc-btn-light-active-border: #1c1c1e;
--mc-btn-dark-bg: #242424;
--mc-input-disabled-bg: #6e6e6e;
--mc-input-disabled-color: #a8a8a8;
--mc-list-header-bg: #444;
--mc-list-details-bg: #555;
--mc-chip-bg: #444;
--mc-table-child-bg: #414141;
--mc-dtr-bg: #7a7a7a;
--mc-dtr-border: #5c5c5c;
--mc-dtr-color: #e0e0e0;
--mc-dtr-hover-bg: #7a7a7a;
--mc-dtr-active-bg: #949494;
}
body {
background-color: var(--mc-body-bg);
color: var(--mc-body-color);
background-color: #1c1c1e;
color: #f2f2f7;
}
.card {
border: 1px solid var(--mc-surface);
background-color: var(--mc-surface);
border: 1px solid #2c2c2e;
background-color: #2c2c2e;
}
legend {
color: var(--mc-body-color);
color: #f2f2f7;
}
.card-header {
color: var(--mc-muted);
background-color: var(--mc-body-bg);
color: #8e8e93;
background-color: #1c1c1e;
border-color: transparent;
}
.card-body {
--bs-card-color: var(--mc-text-soft);
--bs-card-color: #bbb;
}
.btn-secondary, .paginate_button, .page-link, .btn-light {
color: var(--mc-body-color) !important;
background-color: var(--mc-btn-bg) !important;
border-color: var(--mc-btn-border) !important;
color: #f2f2f7 !important;
background-color: #5e5e5e !important;
border-color: #4c4c4e !important;
}
.btn-dark {
color: var(--mc-body-color) !important;
background-color: var(--mc-btn-dark-bg) !important;
border-color: var(--mc-body-bg) !important;
color: #f2f2f7 !important;
background-color: #242424 !important;
border-color: #1c1c1e !important;
}
.btn-secondary:focus, .btn-secondary:hover, .btn-group.open .dropdown-toggle.btn-secondary {
background-color: #444444;
border-color: #4c4c4e !important;
color: #f2f2f7;
}
.btn-check:checked+.btn-secondary, .btn-check:active+.btn-secondary, .btn-secondary:active, .btn-secondary.active, .show>.btn-secondary.dropdown-toggle {
border-color: var(--mc-btn-bg) !important;
border-color: #5e5e5e !important;
}
.alert-secondary {
color: var(--mc-body-color) !important;
background-color: var(--mc-btn-bg) !important;
border-color: var(--mc-btn-border) !important;
color: #f2f2f7 !important;
background-color: #5e5e5e !important;
border-color: #4c4c4e !important;
}
.bg-secondary {
color: var(--mc-body-color) !important;
background-color: var(--mc-btn-bg) !important;
color: #f2f2f7 !important;
background-color: #5e5e5e !important;
}
.alert-secondary, .alert-secondary a, .alert-secondary .alert-link {
color: var(--mc-body-color);
color: #f2f2f7;
}
.page-item.active .page-link {
@@ -106,18 +65,18 @@ legend {
}
.btn-secondary:focus, .btn-secondary:hover, .btn-group.open .dropdown-toggle.btn-secondary {
background-color: var(--mc-btn-bg);
border-color: var(--mc-btn-border) !important;
color: var(--mc-body-color);
background-color: #5e5e5e;
border-color: #4c4c4e !important;
color: #f2f2f7;
}
.btn-secondary:disabled, .btn-secondary.disabled {
border-color: var(--mc-btn-bg) !important;
border-color: #5e5e5e !important;
}
.modal-content {
--bs-modal-color: var(--mc-text-soft);
background-color: var(--mc-surface);
--bs-modal-color: #bbb;
background-color: #2c2c2e;
}
.modal-header {
@@ -125,7 +84,7 @@ legend {
}
.modal-title {
color: var(--mc-text-soft);
color: #bbb;
}
.modal .btn-close {
@@ -133,12 +92,12 @@ legend {
}
.navbar.bg-light {
background-color: var(--mc-body-bg) !important;
border-color: var(--mc-surface);
background-color: #1c1c1e !important;
border-color: #2c2c2e;
}
.nav-link {
color: var(--mc-muted) !important;
color: #8e8e93 !important;
}
.nav-tabs .nav-link.active, .nav-tabs .nav-item.show .nav-link {
@@ -146,23 +105,23 @@ legend {
}
.nav-tabs, .nav-tabs .nav-link {
border-color: var(--mc-tab-border) !important;
border-color: #444444 !important;
}
.nav-tabs .nav-link:not(.disabled):hover, .nav-tabs .nav-link:not(.disabled):focus, .nav-tabs .nav-link.active {
border-bottom-color: var(--mc-body-bg) !important;
border-bottom-color: #1c1c1e !important;
}
.card .nav-tabs .nav-link:not(.disabled):hover, .card .nav-tabs .nav-link:not(.disabled):focus, .card .nav-tabs .nav-link.active {
border-bottom-color: var(--mc-surface) !important;
border-bottom-color: #2c2c2e !important;
}
.table, .table-striped>tbody>tr:nth-of-type(odd)>*, tbody tr {
color: var(--mc-body-color) !important;
color: #f2f2f7 !important;
}
.dropdown-menu {
background-color: var(--mc-dropdown-bg);
background-color: #424242;
border: 1px solid #282828;
}
.dropdown-menu>li>a:focus, .dropdown-menu>li>a:hover {
@@ -173,79 +132,79 @@ legend {
color: #fff;
}
.bootstrap-select>.dropdown-toggle.bs-placeholder, .bootstrap-select>.dropdown-toggle.bs-placeholder.btn-secondary {
color: var(--mc-placeholder) !important;
color: #d4d4d4 !important;
}
tbody tr {
color: var(--mc-text);
color: #ccc;
}
.navbar-default .navbar-nav>.open>a, .navbar-default .navbar-nav>.open>a:focus, .navbar-default .navbar-nav>.open>a:hover {
color: var(--mc-text);
color: #ccc;
}
.navbar-default .navbar-nav>.active>a, .navbar-default .navbar-nav>.active>a:focus, .navbar-default .navbar-nav>.active>a:hover {
color: var(--mc-text);
color: #ccc;
}
.list-group-item {
background-color: var(--mc-surface-alt);
border: 1px solid var(--mc-border);
background-color: #282828;
border: 1px solid #555;
}
.table-striped>tbody>tr:nth-of-type(odd) {
background-color: var(--mc-table-stripe-row);
background-color: #424242;
}
table.dataTable>tbody>tr.child ul.dtr-details>li {
border-bottom: 1px solid rgba(255, 255, 255, 0.13);
}
.label.label-last-login {
color: var(--mc-text) !important;
color: #ccc !important;
background-color: #555 !important;
}
.progress {
background-color: var(--mc-progress-bg);
background-color: #555;
}
.h1, .h2, .h3, .h4, .h5, .h6, h1, h2, h3, h4, h5, h6 {
color: var(--mc-text);
color: #ccc;
}
div.numberedtextarea-number {
color: #999;
}
.well {
border: 1px solid var(--mc-border);
background-color: var(--mc-surface-alt);
border: 1px solid #555;
background-color: #282828;
}
pre {
color: var(--mc-text);
background-color: var(--mc-surface-alt);
border: 1px solid var(--mc-border);
color: #ccc;
background-color: #282828;
border: 1px solid #555;
}
.form-control {
background-color: transparent;
}
input.form-control, textarea.form-control {
color: var(--mc-input-color) !important;
background-color: var(--mc-input-bg) !important;
border: 1px solid var(--mc-input-border);
color: #e2e2e2 !important;
background-color: #424242 !important;
border: 1px solid #999;
}
input.form-control:focus, textarea.form-control {
background-color: var(--mc-input-bg) !important;
background-color: #424242 !important;
}
input.form-control:disabled, textarea.form-disabled {
color: var(--mc-input-disabled-color) !important;
background-color: var(--mc-input-disabled-bg) !important;
color: #a8a8a8 !important;
background-color: #6e6e6e !important;
}
.input-group-addon {
color: var(--mc-text);
background-color: var(--mc-input-bg) !important;
border: 1px solid var(--mc-input-border);
color: #ccc;
background-color: #424242 !important;
border: 1px solid #999;
}
.input-group-text {
color: var(--mc-text);
color: #ccc;
background-color: #1c1c1c;
}
.list-group-item {
color: var(--mc-text);
color: #ccc;
}
.dropdown-item {
color: var(--mc-text);
color: #ccc;
}
.dropdown-item:hover {
color: #616161 !important;
@@ -255,9 +214,9 @@ input.form-control:disabled, textarea.form-disabled {
background-color: #007aff;
}
.form-select {
color: var(--mc-input-color)!important;
background-color: var(--mc-input-bg)!important;
border: 1px solid var(--mc-input-border);
color: #e2e2e2!important;
background-color: #424242!important;
border: 1px solid #999;
}
.responsive-tabs .card-header button[data-bs-toggle="collapse"] {
@@ -281,15 +240,15 @@ input.form-control:disabled, textarea.form-disabled {
a {
color: var(--mc-link);
color: #6fc7e9;
text-decoration: underline;
}
a:hover {
color: var(--mc-link-hover);
color: #3daedb;
}
.breadcrumb-item.active {
color: var(--mc-text);
color: #ccc;
}
@@ -314,11 +273,11 @@ a:hover {
background-color: #3a3a3a;
}
.accordion-button {
color: var(--mc-text-soft);
color: #bbb;
background-color: #2c2c2c;
}
.accordion-button:not(.collapsed) {
color: var(--mc-link);
color: #6fc7e9;
background-color: #2c2c2c;
}
.accordion-button::after {
@@ -341,15 +300,15 @@ a:hover {
}
.tag-box {
background-color: var(--mc-surface-alt);
border: 1px solid var(--mc-border);
background-color: #282828;
border: 1px solid #555;
}
.tag-input {
color: #fff;
background-color: var(--mc-surface-alt);
background-color: #282828;
}
.tag-add {
color: var(--mc-text);
color: #ccc;
}
.tag-add:hover {
color: #d1d1d1;
@@ -359,19 +318,19 @@ a:hover {
color: #fff;
}
.btn-outline-secondary:hover {
background-color: var(--mc-btn-outline-hover-bg);
background-color: #5c5c5c;
}
.btn.btn-outline-secondary {
color: var(--mc-btn-outline-color) !important;
border-color: var(--mc-btn-outline-border) !important;
color: #e0e0e0 !important;
border-color: #7a7a7a !important;
}
.btn-check:checked+.btn-outline-secondary, .btn-check:active+.btn-outline-secondary, .btn-outline-secondary:active, .btn-outline-secondary.active, .btn-outline-secondary.dropdown-toggle.show {
background-color: var(--mc-btn-outline-active-bg) !important;
background-color: #7a7a7a !important;
}
.btn-check:checked+.btn-light, .btn-check:active+.btn-light, .btn-light:active, .btn-light.active, .show>.btn-light.dropdown-toggle {
color: var(--mc-btn-light-active-color) !important;
background-color: var(--mc-btn-light-active-bg) !important;
border-color: var(--mc-btn-light-active-border) !important;
color: #f2f2f7 !important;
background-color: #242424 !important;
border-color: #1c1c1e !important;
}
.btn-input-missing,
.btn-input-missing:hover,
@@ -389,16 +348,16 @@ a:hover {
}
.list-group-details {
background: var(--mc-list-details-bg);
background: #555;
}
.list-group-header {
background: var(--mc-list-header-bg);
background: #444;
}
span.mail-address-item {
background-color: var(--mc-chip-bg);
background-color: #444;
border-radius: 4px;
border: 1px solid var(--mc-border);
border: 1px solid #555;
padding: 2px 7px;
display: inline-block;
margin: 2px 6px 2px 0;
@@ -406,52 +365,52 @@ span.mail-address-item {
table.dataTable.dtr-inline.collapsed>tbody>tr>td.dtr-control:before:hover,
table.dataTable.dtr-inline.collapsed>tbody>tr>th.dtr-control:before:hover {
background-color: var(--mc-dtr-hover-bg) !important;
background-color: #7a7a7a !important;
}
table.dataTable.dtr-inline.collapsed>tbody>tr>td.dtr-control:before,
table.dataTable.dtr-inline.collapsed>tbody>tr>th.dtr-control:before {
background-color: var(--mc-dtr-bg) !important;
border: 1.5px solid var(--mc-dtr-border) !important;
color: var(--mc-dtr-color) !important;
background-color: #7a7a7a !important;
border: 1.5px solid #5c5c5c !important;
color: #e0e0e0 !important;
}
table.dataTable.dtr-inline.collapsed>tbody>tr.parent>td.dtr-control:before,
table.dataTable.dtr-inline.collapsed>tbody>tr.parent>th.dtr-control:before {
background-color: var(--mc-dtr-active-bg);
background-color: #949494;
}
table.dataTable.dtr-inline.collapsed>tbody>tr>td.child,
table.dataTable.dtr-inline.collapsed>tbody>tr>th.child,
table.dataTable.dtr-inline.collapsed>tbody>tr>td.dataTables_empty {
background-color: var(--mc-table-child-bg);
background-color: #414141;
}
table.table, .table-striped>tbody>tr:nth-of-type(odd)>*, tbody tr {
color: var(--mc-text) !important;
color: #ccc !important;
}
.table-secondary {
--bs-table-bg: #282828;
--bs-table-striped-bg: #343434;
--bs-table-striped-color: var(--mc-body-color);
--bs-table-striped-color: #f2f2f7;
--bs-table-active-bg: #4c4c4c;
--bs-table-active-color: var(--mc-body-color);
--bs-table-active-color: #f2f2f7;
--bs-table-hover-bg: #3a3a3a;
--bs-table-hover-color: var(--mc-body-color);
color: var(--mc-text);
--bs-table-hover-color: #f2f2f7;
color: #ccc;
border-color: #3a3a3a;
}
.table-light {
--bs-table-bg: #3a3a3a;
--bs-table-striped-bg: #444444;
--bs-table-striped-color: var(--mc-body-color);
--bs-table-striped-color: #f2f2f7;
--bs-table-active-bg: #5c5c5c;
--bs-table-active-color: var(--mc-body-color);
--bs-table-active-color: #f2f2f7;
--bs-table-hover-bg: #4c4c4c;
--bs-table-hover-color: var(--mc-body-color);
color: var(--mc-text);
--bs-table-hover-color: #f2f2f7;
color: #ccc;
border-color: #4c4c4c;
}
@@ -471,23 +430,23 @@ table.table, .table-striped>tbody>tr:nth-of-type(odd)>*, tbody tr {
.table-striped>tbody>tr:nth-of-type(odd)>td,
.table-striped>tbody>tr:nth-of-type(odd)>th {
background-color: var(--mc-table-stripe);
background-color: #282828;
}
.table-hover>tbody>tr:hover {
background-color: var(--mc-table-hover);
background-color: #343434;
}
.table>:not(caption)>*>* {
border-color: var(--mc-table-border);
--bs-table-color-state:var(--mc-text-soft);
--bs-table-bg: var(--mc-table-bg);
border-color: #5c5c5c;
--bs-table-color-state:#bbb;
--bs-table-bg: #3a3a3a;
}
.text-muted {
--bs-secondary-color: var(--mc-muted);
--bs-secondary-color: #8e8e93;
}
input::placeholder {
color: var(--mc-muted) !important;
color: #8e8e93 !important;
}
.form-select {
-212
View File
@@ -1,212 +0,0 @@
/* servercow theme, dark mode, bundled after mailcow core dark mode */
:root {
--sc-brown-hover: #806b55;
--sc-brown-active: #8a745c;
--sc-brown-soft: rgba(201, 180, 154, .18);
--sc-brown-tint: rgba(201, 180, 154, .08);
--sc-link: #c9b49a;
--sc-danger-text: #ff8a8a;
--sc-ink: #ece7e1;
--sc-muted: #a89f94;
--sc-placeholder: #8a8177;
--sc-bg: #171513;
--sc-surface: #211e1b;
--sc-cap: #2a2622;
--sc-addon: #2a2622;
--sc-line: #463f38;
--sc-divider: #34302b;
--sc-cap-line: #3d3732;
--sc-stripe: rgba(255, 255, 255, .025);
--sc-hover: rgba(201, 180, 154, .08);
--sc-focus-border: #8a7864;
--sc-focus: rgba(201, 180, 154, .25);
--sc-shadow: 0 0 10px 0 rgba(0, 0, 0, .5);
--bs-body-color: #ece7e1;
--bs-body-bg: #171513;
/* core dark tokens, see css/themes/mailcow-darkmode.css */
--mc-body-bg: var(--sc-bg);
--mc-body-color: var(--sc-ink);
--mc-surface: var(--sc-surface);
--mc-surface-alt: var(--sc-cap);
--mc-text: var(--sc-ink);
--mc-text-soft: var(--sc-ink);
--mc-muted: var(--sc-muted);
--mc-border: var(--sc-divider);
--mc-tab-border: var(--sc-divider);
--mc-link: var(--sc-link);
--mc-link-hover: #e0cfba;
--mc-btn-bg: var(--sc-cap);
--mc-btn-border: var(--sc-line);
--mc-input-bg: var(--sc-bg);
--mc-input-color: var(--sc-ink);
--mc-input-border: var(--sc-line);
--mc-dropdown-bg: var(--sc-surface);
--mc-table-bg: transparent;
--mc-table-stripe: var(--sc-stripe);
--mc-table-stripe-row: transparent;
--mc-table-hover: var(--sc-hover);
--mc-table-border: var(--sc-divider);
--mc-progress-bg: var(--sc-cap);
--mc-placeholder: var(--sc-placeholder);
--mc-hr: var(--sc-divider);
--mc-btn-outline-color: var(--sc-ink);
--mc-btn-outline-border: var(--sc-line);
--mc-btn-outline-hover-bg: var(--sc-brown-tint);
--mc-btn-outline-active-bg: var(--sc-brown);
--mc-btn-light-active-color: #fff;
--mc-btn-light-active-bg: var(--sc-brown);
--mc-btn-light-active-border: var(--sc-brown);
--mc-btn-dark-bg: var(--sc-brown);
--mc-input-disabled-bg: var(--sc-cap);
--mc-input-disabled-color: var(--sc-muted);
--mc-list-header-bg: var(--sc-cap);
--mc-list-details-bg: var(--sc-surface);
--mc-chip-bg: var(--sc-cap);
--mc-table-child-bg: var(--sc-surface);
--mc-dtr-bg: var(--sc-cap);
--mc-dtr-border: var(--sc-line);
--mc-dtr-color: var(--sc-link);
--mc-dtr-hover-bg: var(--sc-hover);
--mc-dtr-active-bg: var(--sc-brown-soft);
}
/* navbar */
.navbar.bg-light {
background-color: var(--sc-surface) !important;
border-bottom-color: var(--sc-green);
}
.navbar-light .navbar-nav .nav-link {
color: var(--sc-ink) !important;
}
.navbar-light .navbar-nav .nav-link:hover,
.navbar-light .navbar-nav .nav-link:focus,
.navbar-light .navbar-nav .nav-link.active,
.navbar-light .navbar-nav .show > .nav-link {
color: var(--sc-link) !important;
}
.dropdown-menu {
border-color: var(--sc-cap-line);
}
.dropdown-item:hover, .dropdown-item:focus {
color: var(--sc-link) !important;
}
/* cards and modals */
.card-header {
color: var(--sc-ink);
background-color: var(--sc-cap);
border-color: var(--sc-cap-line);
}
.modal-content {
background-color: var(--sc-surface);
}
.modal-header {
border-color: var(--sc-cap-line);
}
.input-group-text {
background-color: var(--sc-addon);
}
.form-control::file-selector-button {
color: var(--sc-ink);
background-color: var(--sc-cap);
border-color: var(--sc-line);
}
.form-control:hover:not(:disabled):not([readonly])::file-selector-button {
color: var(--sc-link);
background-color: var(--sc-line);
}
.form-check-input:not(:checked) {
background-color: var(--sc-bg);
border-color: var(--sc-line);
}
kbd {
color: var(--sc-ink);
background-color: var(--sc-cap);
}
/* buttons */
.btn-secondary:hover, .btn-secondary:focus, .btn-light:hover, .btn-light:focus, .page-link:hover {
color: var(--sc-link) !important;
background-color: var(--sc-line) !important;
}
.btn-check:checked + .btn-secondary, .btn-secondary.active, .show > .btn-secondary.dropdown-toggle,
.page-item.active .page-link {
color: #fff !important;
background-color: var(--sc-brown) !important;
border-color: var(--sc-brown) !important;
}
/* tabs */
.nav-tabs .nav-link {
color: var(--sc-ink) !important;
border-bottom-color: transparent !important;
}
.nav-tabs .nav-link:not(.disabled):hover, .nav-tabs .nav-link:not(.disabled):focus,
.card .nav-tabs .nav-link:not(.disabled):hover, .card .nav-tabs .nav-link:not(.disabled):focus {
color: var(--sc-link) !important;
border-bottom-color: transparent !important;
}
.nav-tabs .nav-link.active, .card .nav-tabs .nav-link.active {
color: var(--sc-link) !important;
border-bottom-color: var(--sc-link) !important;
}
/* semantic text */
.text-success {
color: var(--sc-green) !important;
}
.text-warning {
color: var(--sc-warning) !important;
}
/* alerts, opaque dark tints (also used for floating notifications) */
.alert-primary {
color: #e6d9c9 !important;
background-color: #3c342b !important;
border-color: #5e4f3e !important;
}
.alert-info {
color: #cfe9f6 !important;
background-color: #2f3334 !important;
border-color: #44535a !important;
}
.alert-success {
color: #cdeea0;
background-color: #2e3419;
border-color: #405515;
}
.alert-danger {
color: #f9c4c4;
background-color: #402a28;
border-color: #693a38;
}
.alert-warning {
color: #f5d496;
background-color: #3c2f19;
border-color: #654815;
}
.alert-secondary, .alert-light {
color: var(--sc-ink) !important;
background-color: var(--sc-cap) !important;
border-color: var(--sc-divider) !important;
}
.alert .btn-close {
filter: invert(1) grayscale(100%) brightness(200%);
}
/* slider */
.noUi-target {
background: var(--sc-bg);
border-color: var(--sc-line);
box-shadow: none;
}
.noUi-handle {
background: var(--sc-cap);
border-color: var(--sc-line);
box-shadow: none;
}
.noUi-handle::before, .noUi-handle::after {
background: var(--sc-line);
}
@@ -1,202 +0,0 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@@ -1,93 +0,0 @@
Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
-584
View File
@@ -1,584 +0,0 @@
/* servercow theme for mailcow, based on the servercow customer portal */
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 400;
font-display: swap;
src: local(''),
url('/css/themes/servercow/fonts/poppins-v24-latin_latin-ext-regular.woff2') format('woff2'),
url('/css/themes/servercow/fonts/poppins-v24-latin_latin-ext-regular.woff') format('woff');
}
@font-face {
font-family: 'Poppins';
font-style: italic;
font-weight: 400;
font-display: swap;
src: local(''),
url('/css/themes/servercow/fonts/poppins-v24-latin_latin-ext-italic.woff2') format('woff2'),
url('/css/themes/servercow/fonts/poppins-v24-latin_latin-ext-italic.woff') format('woff');
}
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 500;
font-display: swap;
src: local(''),
url('/css/themes/servercow/fonts/poppins-v24-latin_latin-ext-500.woff2') format('woff2'),
url('/css/themes/servercow/fonts/poppins-v24-latin_latin-ext-500.woff') format('woff');
}
@font-face {
font-family: 'Poppins';
font-style: normal;
font-weight: 600;
font-display: swap;
src: local(''),
url('/css/themes/servercow/fonts/poppins-v24-latin_latin-ext-600.woff2') format('woff2'),
url('/css/themes/servercow/fonts/poppins-v24-latin_latin-ext-600.woff') format('woff');
}
@font-face {
font-family: 'Roboto Slab';
font-style: normal;
font-weight: 400;
font-display: swap;
src: local(''),
url('/css/themes/servercow/fonts/roboto-slab-v36-cyrillic_greek_latin-regular.woff2') format('woff2'),
url('/css/themes/servercow/fonts/roboto-slab-v36-cyrillic_greek_latin-regular.woff') format('woff');
}
@font-face {
font-family: 'Roboto Slab';
font-style: normal;
font-weight: 500;
font-display: swap;
src: local(''),
url('/css/themes/servercow/fonts/roboto-slab-v36-cyrillic_greek_latin-500.woff2') format('woff2'),
url('/css/themes/servercow/fonts/roboto-slab-v36-cyrillic_greek_latin-500.woff') format('woff');
}
/* tokens */
:root {
--sc-brown: #6F5D49;
--sc-brown-hover: #5e4f3e;
--sc-brown-active: #594a3a;
--sc-brown-soft: rgba(111, 93, 73, .2);
--sc-brown-tint: rgba(111, 93, 73, .08);
--sc-link: #6F5D49;
--sc-green: #8AD608;
--sc-blue: #96D0EC;
--sc-warning: #e3950b;
--sc-danger: #ef6f6f;
--sc-danger-hover: #e85e5e;
--sc-danger-text: #c43c3c;
--sc-ink: #212529;
--sc-muted: #6c757d;
--sc-placeholder: #6c757d;
--sc-bg: #F6F8F8;
--sc-surface: #ffffff;
--sc-cap: #f7f7f7;
--sc-addon: #e9ecef;
--sc-line: #ced4da;
--sc-divider: #dee2e6;
--sc-cap-line: rgba(0, 0, 0, .175);
--sc-stripe: rgba(111, 93, 73, .04);
--sc-hover: rgba(111, 93, 73, .08);
--sc-focus-border: #b7aea4;
--sc-focus: rgba(111, 93, 73, .25);
--sc-shadow: 0 0 10px 0 rgba(134, 134, 134, .3);
--sc-font: 'Poppins', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
--sc-font-head: 'Roboto Slab', Georgia, serif;
--bs-body-color: #212529;
--bs-body-bg: #F6F8F8;
--bs-body-font-family: var(--sc-font);
/* core tokens, see css/build/014-mailcow.css */
--mc-font-family: var(--sc-font);
--mc-font-size: 10pt;
--mc-body-bg: var(--sc-bg);
--mc-primary: var(--sc-brown);
--mc-primary-border: var(--sc-brown);
--mc-info: var(--sc-blue);
--mc-info-border: var(--sc-blue);
--mc-border: var(--sc-divider);
--mc-surface: var(--sc-surface);
--mc-surface-alt: var(--sc-cap);
--mc-muted: var(--sc-muted);
--mc-hr: var(--sc-divider);
--mc-progress-bg: var(--sc-addon);
--mc-placeholder: var(--sc-placeholder);
--mc-btn-outline-color: var(--sc-ink);
--mc-btn-outline-border: var(--sc-line);
--mc-btn-outline-hover-bg: var(--sc-brown-tint);
--mc-btn-outline-active-bg: var(--sc-brown);
--mc-btn-light-active-color: #fff;
--mc-btn-light-active-bg: var(--sc-brown);
--mc-btn-light-active-border: var(--sc-brown);
--mc-dtr-bg: var(--sc-surface);
--mc-dtr-border: var(--sc-line);
--mc-dtr-color: var(--sc-brown);
--mc-dtr-hover-bg: var(--sc-brown-tint);
--mc-dtr-active-bg: var(--sc-brown-soft);
--mc-login-user: var(--sc-green);
--mc-login-domainadmin: #f5c400;
--mc-login-admin: var(--sc-danger);
}
/* base */
body {
color: var(--sc-ink);
-webkit-font-smoothing: antialiased;
}
h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6, legend, .modal-title {
font-family: var(--sc-font-head);
font-weight: 500;
color: var(--sc-ink);
}
a {
color: var(--sc-link);
}
a:hover {
color: var(--sc-brown-hover);
}
.text-muted, .text-secondary {
color: var(--sc-muted) !important;
}
::selection {
background: var(--sc-brown-soft);
}
hr {
border-color: var(--sc-divider);
opacity: 1;
}
::placeholder {
color: var(--sc-placeholder) !important;
}
/* navbar, white with green line */
.navbar.bg-light {
background-color: var(--sc-surface) !important;
border: 0;
border-bottom: 5px solid var(--sc-green);
}
.navbar-light .navbar-nav .nav-link {
color: var(--sc-ink);
}
.navbar-light .navbar-nav .nav-link:hover,
.navbar-light .navbar-nav .nav-link:focus,
.navbar-light .navbar-nav .nav-link.active,
.navbar-light .navbar-nav .show > .nav-link {
color: var(--sc-brown);
}
@media (min-width: 992px) {
.navbar-nav .nav-link {
height: 60px;
}
}
.dropdown-menu {
border: 1px solid var(--sc-cap-line);
border-radius: 0;
box-shadow: var(--sc-shadow);
}
.dropdown-item {
color: var(--sc-ink);
}
.dropdown-item:hover, .dropdown-item:focus {
color: var(--sc-brown) !important;
background-color: var(--sc-brown-soft);
}
.dropdown-item.active, .dropdown-item:active {
color: #fff;
background-color: var(--sc-brown);
}
/* cards, white with soft shadow */
.card {
border: 0 !important;
border-radius: 0;
background-color: var(--sc-surface);
box-shadow: var(--sc-shadow);
}
.card-header, .card-header:first-child {
font-family: var(--sc-font-head);
font-weight: 500;
font-size: 1.1rem;
color: var(--sc-ink);
background-color: var(--sc-cap);
border-bottom: 1px solid var(--sc-cap-line);
border-radius: 0;
padding: .5rem 1rem;
}
.card-header .btn, .card-header .form-control, .card-header .form-select {
font-family: var(--sc-font);
font-size: .875rem;
}
.card-footer {
background-color: var(--sc-cap);
border-top: 1px solid var(--sc-cap-line);
border-radius: 0;
}
.card .card {
box-shadow: none;
border: 1px solid var(--sc-divider) !important;
}
/* colored headers become a top line */
.card:has(> .card-header.bg-danger) {
border-top: 4px solid var(--sc-danger) !important;
}
.card-header.bg-danger, .card-header.bg-success, .card-header.bg-primary, .card-header.bg-info {
color: var(--sc-ink) !important;
background-color: var(--sc-cap) !important;
}
.list-group-item {
border-color: var(--sc-divider);
}
/* modals */
.modal-content {
border: 0;
border-radius: 0;
box-shadow: 0 8px 40px rgba(0, 0, 0, .2);
}
.modal-header {
background-color: var(--sc-cap);
border-bottom: 1px solid var(--sc-cap-line);
border-radius: 0;
}
.modal-footer {
border-top: 1px solid var(--sc-divider);
}
/* buttons, flat and square */
.btn {
border-width: 1px;
border-radius: 0;
font-weight: 400;
transition: none;
}
.btn:not(.disabled):hover,
.btn:not(.disabled):active {
margin-top: 0;
border-bottom-width: 1px;
}
.btn:focus, .btn-check:focus + .btn {
box-shadow: 0 0 0 .2rem var(--sc-focus);
}
.btn-primary, .btn-primary:hover, .btn-primary:focus, .btn-primary:active {
color: #fff;
}
.btn-primary:hover, .btn-primary:focus, .btn-primary:active {
background-color: var(--sc-brown-hover) !important;
border-color: var(--sc-brown-active) !important;
}
/* main call to action, portal primary */
.btn-success, .btn-success:not(.disabled):hover, .btn-success:not(.disabled):active, .btn-success:focus,
.btn-success:disabled, .btn-success.disabled {
color: #fff;
background-color: var(--sc-brown);
border-color: var(--sc-brown);
}
.btn-success:hover, .btn-success:active, .btn-success:focus {
background-color: var(--sc-brown-hover);
border-color: var(--sc-brown-active);
}
.btn-danger, .btn-danger:not(.disabled):hover, .btn-danger:not(.disabled):active, .btn-danger:focus,
.btn-danger:disabled, .btn-danger.disabled {
color: #000;
background-color: var(--sc-danger);
border-color: var(--sc-danger);
}
.btn-danger:hover, .btn-danger:active, .btn-danger:focus {
background-color: var(--sc-danger-hover);
border-color: var(--sc-danger-hover);
}
.btn-warning, .btn-warning:not(.disabled):hover, .btn-warning:not(.disabled):active, .btn-warning:focus {
color: #000;
background-color: var(--sc-warning);
border-color: var(--sc-warning);
}
.bg-info, .alert-info, .btn-info, .badge.bg-info > a {
color: #000 !important;
}
.btn-secondary, .btn-secondary:not(.disabled):hover, .btn-secondary:not(.disabled):active, .btn-secondary:focus,
.btn-light, .btn-light:not(.disabled):hover, .btn-light:not(.disabled):active, .btn-light:focus {
color: var(--sc-ink);
background-color: var(--sc-surface);
border-color: var(--sc-line);
}
.btn-secondary:hover, .btn-secondary:active, .btn-secondary:focus,
.btn-light:hover, .btn-light:focus {
color: var(--sc-brown);
background-color: var(--sc-brown-tint);
border-color: var(--sc-line);
}
.btn-check:checked + .btn-secondary, .btn-secondary.active, .show > .btn-secondary.dropdown-toggle {
color: #fff;
background-color: var(--sc-brown);
border-color: var(--sc-brown);
}
.btn-check:checked + .btn-outline-secondary, .btn-outline-secondary.active {
color: #fff !important;
border-color: var(--sc-brown) !important;
}
.btn-outline-primary {
color: var(--sc-brown);
border-color: var(--sc-brown);
}
.btn-outline-primary:hover {
color: #fff;
background-color: var(--sc-brown);
border-color: var(--sc-brown);
}
/* active toggle, e.g. tagging options */
.btn-dark, .btn-dark:hover, .btn-dark:focus, .btn-dark:active {
color: #fff;
background-color: var(--sc-brown);
border-color: var(--sc-brown);
}
.btn-xs, .btn-group-xs > .btn {
border-radius: 0;
}
/* forms */
.form-control, .form-select {
border-color: var(--sc-line);
border-radius: 0;
box-shadow: none;
color: var(--sc-ink);
background-color: var(--sc-surface);
}
.form-control:focus, .form-select:focus {
border-color: var(--sc-focus-border);
box-shadow: 0 0 0 .25rem var(--sc-focus);
}
.form-control::file-selector-button {
color: var(--sc-ink);
background-color: var(--sc-addon);
border-color: var(--sc-line);
}
.form-control:hover:not(:disabled):not([readonly])::file-selector-button {
color: var(--sc-brown);
background-color: var(--sc-brown-soft);
}
.input-group-text {
color: var(--sc-ink);
background-color: var(--sc-addon);
border-color: var(--sc-line);
border-radius: 0;
}
.input-group > :not(:first-child), .input-group > :not(:last-child) {
border-radius: 0;
}
.form-check-input {
border-color: var(--sc-line);
}
.form-check-input:checked {
background-color: var(--sc-brown);
border-color: var(--sc-brown);
}
.form-check-input:focus {
border-color: var(--sc-focus-border);
box-shadow: 0 0 0 .25rem var(--sc-focus);
}
.bootstrap-select > .dropdown-toggle {
border-radius: 0;
}
.bootstrap-select > .dropdown-toggle.bs-placeholder,
.bootstrap-select > .dropdown-toggle.bs-placeholder:hover,
.bootstrap-select > .dropdown-toggle.bs-placeholder:focus,
.bootstrap-select > .dropdown-toggle.bs-placeholder:active {
color: var(--mc-placeholder);
}
.bootstrap-select .dropdown-menu li a.active, .bootstrap-select .dropdown-menu li a:active {
color: #fff;
background-color: var(--sc-brown);
}
div.numberedtextarea-number {
color: var(--sc-muted);
}
/* nav tabs */
.nav-tabs {
border-bottom: 1px solid var(--sc-divider);
}
.nav-tabs .nav-link,
.nav-tabs .nav-link.disabled, .nav-tabs .nav-link.disabled:hover, .nav-tabs .nav-link.disabled:focus {
margin-top: 0;
color: var(--sc-ink);
border: 0;
border-bottom: 3px solid transparent;
border-radius: 0;
background: none;
}
.nav-tabs .nav-link:not(.disabled):hover, .nav-tabs .nav-link:not(.disabled):focus {
padding-bottom: .5rem;
color: var(--sc-brown);
}
.nav-tabs .nav-link.active, .nav-tabs .nav-item.show .nav-link,
.nav-tabs .nav-link:not(.disabled).active {
padding-bottom: .5rem;
color: var(--sc-brown);
background: none;
border-bottom: 3px solid var(--sc-brown);
}
.nav-pills .nav-link {
border-radius: 0;
color: var(--sc-ink);
}
.nav-pills .nav-link.active, .nav-pills .show > .nav-link {
background-color: var(--sc-brown);
}
/* tables */
.table {
--bs-table-bg: transparent;
--bs-table-striped-bg: var(--sc-stripe);
--bs-table-striped-color: var(--sc-ink);
--bs-table-hover-color: var(--sc-ink);
--bs-table-hover-bg: var(--sc-hover);
color: var(--sc-ink);
border-color: var(--sc-divider);
}
.table > thead > tr > th, table.dataTable thead th {
color: var(--sc-ink);
font-weight: 600;
border-bottom: 1px solid var(--sc-line);
}
table.dataTable.table-striped > tbody > tr.odd > * {
box-shadow: inset 0 0 0 9999px var(--sc-stripe);
}
table.dataTable.table-hover > tbody > tr:hover > * {
box-shadow: inset 0 0 0 9999px var(--sc-hover);
}
.table > :not(:first-child) {
border-top: 0;
}
.dataTables_wrapper .dataTables_filter input,
.dataTables_wrapper .dataTables_length select {
border: 1px solid var(--sc-line);
border-radius: 0;
}
.pagination > li > a, .pagination > li > span, .pagination .page-link,
.pagination > li > a:hover, .pagination > li > a:focus, .pagination > li > a:active {
top: 0;
color: var(--sc-brown);
font-weight: 400;
text-transform: none;
background-color: var(--sc-surface);
border: 1px solid var(--sc-divider);
border-radius: 0 !important;
}
.pagination > li > a:hover, .pagination > li > a:focus {
background-color: var(--sc-brown-tint);
}
.pagination > .disabled > a, .pagination > .disabled > a:hover, .pagination > .disabled > a:active,
.page-item.disabled .page-link {
color: var(--sc-muted);
background-color: var(--sc-surface);
border-width: 1px;
border-color: var(--sc-divider);
}
.page-item.active .page-link {
color: #fff;
background-color: var(--sc-brown);
border-color: var(--sc-brown);
}
.breadcrumb {
border: 1px solid var(--sc-divider);
border-radius: 0;
}
/* alerts like the portal */
.alert {
border: 1px solid;
border-radius: 0;
}
.alert-primary {
color: #43382c !important;
background-color: #e2dfdb !important;
border-color: #d4cec8 !important;
}
.alert-info {
color: #3c535e !important;
background-color: #eaf6fb !important;
border-color: #e0f1f9 !important;
}
.alert-success {
color: #375603;
background-color: #e8f7ce;
border-color: #dcf3b5;
}
.alert-danger {
color: #8f4343;
background-color: #fce2e2;
border-color: #fad4d4;
}
.alert-warning {
color: #885907;
background-color: #f9eace;
border-color: #f7dfb6;
}
.alert-secondary, .alert-light {
color: var(--sc-ink);
background-color: var(--sc-cap);
border-color: var(--sc-divider);
}
.alert .alert-link {
color: inherit;
}
.alert .btn-close {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e");
opacity: .5;
}
/* badges and semantic colors, readable text on top */
.badge {
font-weight: 500;
border-radius: 0;
}
.bg-success {
color: #000 !important;
background-color: var(--sc-green) !important;
}
.bg-warning {
color: #000 !important;
background-color: var(--sc-warning) !important;
}
.bg-danger {
color: #000 !important;
background-color: var(--sc-danger) !important;
}
.badge.bg-secondary, .badge.bg-light {
color: var(--sc-ink);
background-color: var(--sc-addon) !important;
}
.text-success {
color: #4f8a0b !important;
}
.text-danger {
color: var(--sc-danger-text) !important;
}
.text-warning {
color: #9a6100 !important;
}
.progress {
border-radius: 0;
}
.progress-bar {
color: #000;
background-color: var(--sc-green);
}
.progress-bar.bg-danger, .progress-bar-danger {
color: #000;
background-color: var(--sc-danger) !important;
}
.progress-bar-warning {
color: #000;
background-color: var(--sc-warning) !important;
}
/* footer */
.footer, .footer a {
color: var(--sc-muted);
}
.footer hr {
border-color: var(--sc-divider);
}
-4
View File
@@ -1,4 +0,0 @@
{
"name": "servercow",
"description": "Based on the servercow theme"
}
-2
View File
@@ -9,7 +9,6 @@ $_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');
@@ -43,7 +42,6 @@ $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';
+1 -9
View File
@@ -204,15 +204,9 @@ if (isset($_SESSION['mailcow_cc_role'])) {
if (isset($_GET['syncjob']) &&
is_numeric($_GET['syncjob'])) {
$id = $_GET["syncjob"];
$result = \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getJob($id);
$result = mailbox('get', 'syncjob_details', $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"];
@@ -231,7 +225,6 @@ 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');
@@ -240,6 +233,5 @@ $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';
+1 -1
View File
@@ -6,7 +6,7 @@ if (!isset($_SESSION['mailcow_cc_role'])) {
}
if (isset($_GET['id']) && is_numeric($_GET['id'])) {
if ($details = \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getJob(intval($_GET['id']))) {
if ($details = mailbox('get', 'syncjob_details', intval($_GET['id']))) {
echo (empty($details['log'])) ? '-' : $details['log'];
}
}
+2 -3
View File
@@ -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,7 +72,6 @@ $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'],
+1 -5
View File
@@ -103,7 +103,7 @@ function admin($_action, $_data = null) {
continue;
}
}
if (!ctype_alnum(str_replace(array('_', '.', '-'), '', $username_new)) || $username_new == 'API') {
if (!ctype_alnum(str_replace(array('_', '.', '-'), '', $username_new))) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_data_log),
@@ -179,9 +179,6 @@ 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',
@@ -214,7 +211,6 @@ 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,
-68
View File
@@ -233,38 +233,6 @@ function customize($_action, $_item, $_data = null) {
'msg' => 'custom_login_modified'
);
break;
case 'ui_theme':
$ui_theme = is_array($_data['ui_theme']) ? (string)reset($_data['ui_theme']) : (string)$_data['ui_theme'];
if ($ui_theme !== '' && !array_key_exists($ui_theme, customize('get', 'ui_themes'))) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_item, $_data),
'msg' => 'ui_theme_invalid'
);
return false;
}
try {
if ($ui_theme === '') {
$redis->del('UI_THEME');
}
else {
$redis->set('UI_THEME', $ui_theme);
}
}
catch (RedisException $e) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_item, $_data),
'msg' => array('redis_error', $e)
);
return false;
}
$_SESSION['return'][] = array(
'type' => 'success',
'log' => array(__FUNCTION__, $_action, $_item, $_data),
'msg' => 'ui_theme_modified'
);
break;
}
break;
case 'delete':
@@ -434,42 +402,6 @@ function customize($_action, $_item, $_data = null) {
return false;
}
break;
case 'ui_themes':
// theme folders in css/themes/<name>/ with a theme.css
$themes = array();
foreach (glob('/web/css/themes/*/theme.css') as $theme_css) {
$theme_dir = dirname($theme_css);
$theme = basename($theme_dir);
if (!preg_match('/^[a-zA-Z0-9_-]+$/', $theme)) {
continue;
}
$meta = json_decode(@file_get_contents($theme_dir . '/theme.json'), true);
$themes[$theme] = array(
'name' => (!empty($meta['name'])) ? $meta['name'] : $theme,
'description' => (!empty($meta['description'])) ? $meta['description'] : '',
'dark' => file_exists($theme_dir . '/dark.css')
);
}
ksort($themes);
return $themes;
break;
case 'ui_theme':
try {
$ui_theme = $redis->get('UI_THEME');
}
catch (RedisException $e) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_item, $_data),
'msg' => array('redis_error', $e)
);
return false;
}
if (empty($ui_theme) || !preg_match('/^[a-zA-Z0-9_-]+$/', $ui_theme) || !file_exists('/web/css/themes/' . $ui_theme . '/theme.css')) {
return '';
}
return $ui_theme;
break;
}
break;
}
+1 -6
View File
@@ -156,7 +156,7 @@ function domain_admin($_action, $_data = null) {
}
}
}
if (!ctype_alnum(str_replace(array('_', '.', '-'), '', $username_new)) || $username_new == 'API') {
if (!ctype_alnum(str_replace(array('_', '.', '-'), '', $username_new))) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_action, $_data_log),
@@ -183,9 +183,6 @@ 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`)
@@ -334,8 +331,6 @@ 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),
+484 -9
View File
@@ -302,6 +302,192 @@ 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(
@@ -2094,6 +2280,202 @@ 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(
@@ -3270,9 +3652,6 @@ 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([
@@ -4224,6 +4603,68 @@ 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');
@@ -5053,6 +5494,46 @@ 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();
@@ -5283,10 +5764,6 @@ 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,
@@ -5594,8 +6071,6 @@ 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
-15
View File
@@ -22,9 +22,6 @@ if (preg_match("/debug/i", $_SERVER['REQUEST_URI'])) {
if ($_SERVER['REQUEST_URI'] == '/') {
$css_minifier->add('/web/css/site/index.css');
}
if (file_exists('/web/css/themes/'.$UI_THEME.'/theme.css')) {
$css_minifier->add('/web/css/themes/'.$UI_THEME.'/theme.css');
}
$hash = $css_minifier->getDataHash();
$CSSPath = '/tmp/' . $hash . '.css';
@@ -33,17 +30,6 @@ if(!file_exists($CSSPath)) {
cleanupCSS($hash);
}
// dark mode bundle, core dark mode + theme dark.css
$dark_minifier = new CSSminifierExtended('/web/css/themes/mailcow-darkmode.css');
if (file_exists('/web/css/themes/'.$UI_THEME.'/dark.css')) {
$dark_minifier->add('/web/css/themes/'.$UI_THEME.'/dark.css');
}
$DarkCSSPath = '/tmp/' . $hash . '-' . $dark_minifier->getDataHash() . '.css';
if(!file_exists($DarkCSSPath)) {
$dark_minifier->minify($DarkCSSPath);
}
$dark_css_path = '/cache/' . basename($DarkCSSPath);
$mailcow_apps_processed = $MAILCOW_APPS;
$app_links = customize('get', 'app_links');
$app_links_processed = $app_links;
@@ -91,7 +77,6 @@ $globalVariables = [
'dual_login' => @$_SESSION['dual-login'],
'ui_texts' => $UI_TEXTS,
'css_path' => '/cache/'.basename($CSSPath),
'dark_css_path' => $dark_css_path,
'logo' => customize('get', 'main_logo'),
'logo_dark' => customize('get', 'main_logo_dark'),
'available_languages' => $AVAILABLE_LANGUAGES,
+19 -293
View File
@@ -4,7 +4,7 @@ function init_db_schema()
try {
global $pdo;
$db_version = "16102026_1200";
$db_version = "18082026_1200";
$stmt = $pdo->query("SHOW TABLES LIKE 'versions'");
$num_results = count($stmt->fetchAll(PDO::FETCH_ASSOC));
@@ -755,153 +755,35 @@ 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",
"source_id" => "INT NOT NULL",
"host1" => "VARCHAR(255) NOT NULL",
"authmech1" => "ENUM('PLAIN','LOGIN','CRAM-MD5') DEFAULT 'PLAIN'",
"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 DEFAULT ''",
"password1" => "VARCHAR(255) NOT NULL",
"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" => "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",
"custom_params" => "VARCHAR(512) NOT NULL DEFAULT ''",
"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",
@@ -913,32 +795,6 @@ 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"
@@ -1328,128 +1184,11 @@ 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_schema` = DATABASE() AND `table_name` = :table;");
WHERE `constraint_type` = 'FOREIGN KEY' AND `table_name` = :table;");
$stmt->execute(array(':table' => $table));
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
while ($row = array_shift($rows)) {
@@ -1610,29 +1349,20 @@ function init_db_schema()
AND (JSON_CONTAINS_PATH(`call`, 'one', '$[2].password')
OR JSON_CONTAINS_PATH(`call`, 'one', '$[2].password2'));");
// 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) . ");");
// 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%';");
// 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));
@@ -1814,10 +1544,6 @@ 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();
@@ -1835,7 +1561,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_SCHEMA = DATABASE() AND (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_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
-5
View File
@@ -13,10 +13,5 @@
"stevenmaguire/oauth2-keycloak": "^4.0",
"league/oauth2-client": "^2.7",
"bacon/bacon-qr-code": "^2.0"
},
"autoload": {
"psr-4": {
"Mailcow\\": "mailcow/"
}
}
}
@@ -1,16 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Core;
/**
* The caller may not do this (or may not see the object). Always shown as `access_denied`.
*/
class AccessDenied extends UserError
{
public function __construct()
{
parent::__construct('access_denied');
}
}
@@ -1,70 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Core;
/**
* Collects the messages of one API call and writes them to $_SESSION['return'] in the mailcow format
* (`type`, `log`, `msg`), which json_api.php and the UI read. This is the only class that touches the session.
*
* Values of the configured secret keys are replaced by '*' inside every array of the `log` part,
* so passwords or client secrets never reach the mailcow log or an API response.
*/
class ApiResponse
{
private array $messages = [];
public function __construct(private array $secretKeys = [])
{
}
/** Returns true, so callers can `return $response->success(...)` */
public function success(string|array $msg, array $log): bool
{
$this->add('success', $msg, $log);
return true;
}
/** Returns false, so callers can `return $response->danger(...)` */
public function danger(string|array $msg, array $log): bool
{
$this->add('danger', $msg, $log);
return false;
}
public function error(UserError $error, array $log): bool
{
return $this->danger($error->msg(), $log);
}
/** Messages collected since the last flush() */
public function messages(): array
{
return $this->messages;
}
/** Appends the collected messages to $_SESSION['return'] and starts over */
public function flush(): void
{
foreach ($this->messages as $message) {
$_SESSION['return'][] = $message;
}
$this->messages = [];
}
private function add(string $type, string|array $msg, array $log): void
{
foreach ($log as $i => $part) {
if (!is_array($part)) {
continue;
}
foreach ($this->secretKeys as $key) {
if (isset($part[$key]) && $part[$key] !== '') {
$log[$i][$key] = '*';
}
}
}
$this->messages[] = ['type' => $type, 'log' => $log, 'msg' => $msg];
}
}
-76
View File
@@ -1,76 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Core;
/**
* Who is calling: role, username, ACL flags and the access-all exception of the session.
*
* Services get a Context instead of reading $_SESSION. Build it once per request in the entry point:
* `Context::fromSession($_SESSION)` for web requests, `Context::system()` for cron/CLI code paths
* that do no permission checks.
*/
class Context
{
/**
* @param array $acl ACL flags of the session (user_acl / da_acl, always set for admins)
* @param bool $accessAll the session may ignore object ownership (`access_all_exception`)
*/
public function __construct(
public readonly string $role,
public readonly ?string $username,
private readonly array $acl = [],
public readonly bool $accessAll = false
) {
}
public static function fromSession(array $session): self
{
$username = $session['mailcow_cc_username'] ?? null;
return new self(
(string)($session['mailcow_cc_role'] ?? ''),
is_scalar($username) ? (string)$username : null,
is_array($session['acl'] ?? null) ? $session['acl'] : [],
($session['access_all_exception'] ?? null) == '1'
);
}
/** Caller for cron and CLI code: not a logged-in account, so every permission check fails */
public static function system(): self
{
return new self('system', null);
}
public function isAdmin(): bool
{
return $this->role === 'admin';
}
public function isDomainAdmin(): bool
{
return $this->role === 'domainadmin';
}
public function isUser(): bool
{
return $this->role === 'user';
}
public function hasAcl(string $name): bool
{
return isset($this->acl[$name]) && $this->acl[$name] == '1';
}
/** Bridge to the legacy hasMailboxObjectAccess() until functions.inc.php is converted */
public function canAccessMailbox(string $mailbox): bool
{
return (bool)hasMailboxObjectAccess($this->username, $this->role, $mailbox);
}
/** Bridge to the legacy hasDomainAccess() until functions.inc.php is converted */
public function canAccessDomain(string $domain): bool
{
return (bool)hasDomainAccess($this->username, $this->role, $domain);
}
}
-35
View File
@@ -1,35 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Core;
/**
* Helpers for loosely typed request values. Form values arrive as strings, JSON bodies as any type,
* and 011-api.js turns JSON-looking strings into arrays, so services must not trust the PHP type.
*/
class Input
{
/**
* Scalar value as trimmed string; '' for null; null for arrays/objects (= invalid).
*/
public static function text(mixed $value): ?string
{
if ($value === null) {
return '';
}
if (!is_scalar($value)) {
return null;
}
return trim((string)$value);
}
/**
* Integer within [$min, $max] (filter_var rules: "12" and 12 pass, "12abc" and "1.5" do not), else null.
*/
public static function intInRange(mixed $value, int $min, int $max): ?int
{
$int = filter_var($value, FILTER_VALIDATE_INT, ['options' => ['min_range' => $min, 'max_range' => $max]]);
return ($int === false) ? null : $int;
}
}
@@ -1,65 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Core;
use PDO;
use PDOStatement;
use Throwable;
/**
* Base class of the repositories: the PDO plus the helpers every repository needs.
* Named parameters are passed without the colon (['id' => 5]).
*/
class Repository
{
public function __construct(protected PDO $pdo)
{
}
/** Prepares and executes one statement */
protected function query(string $sql, array $params = []): PDOStatement
{
$stmt = $this->pdo->prepare($sql);
$stmt->execute($params);
return $stmt;
}
/** Inserts one row (column => value) and returns the new id */
protected function insertRow(string $table, array $values): int
{
$columns = array_keys($values);
$this->query("INSERT INTO `$table` (`" . implode('`, `', $columns) . "`) VALUES (:" . implode(', :', $columns) . ")", $values);
return (int)$this->pdo->lastInsertId();
}
/** Updates the columns (column => value) of the row with the given `id` */
protected function updateRow(string $table, int $id, array $values): void
{
$set = implode(', ', array_map(fn(string $column): string => "`$column` = :$column", array_keys($values)));
$this->query("UPDATE `$table` SET $set WHERE `id` = :id", $values + ['id' => $id]);
}
/**
* Runs $work in a transaction and returns its result. Inside a transaction of the caller it just runs,
* so the caller keeps control over commit and rollback.
*/
protected function transaction(callable $work): mixed
{
if ($this->pdo->inTransaction()) {
return $work();
}
$this->pdo->beginTransaction();
try {
$result = $work();
$this->pdo->commit();
return $result;
} catch (Throwable $e) {
if ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $e;
}
}
}
@@ -1,28 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Core;
use RuntimeException;
/**
* An expected error that is shown to the user (validation failed, not found, not allowed).
*
* Carries a lang key of the `danger` section plus optional message arguments, e.g.
* `throw new UserError('object_exists', [$name]);`
* Controllers turn it into an ApiResponse message; anything else that is thrown is a real failure.
*/
class UserError extends RuntimeException
{
public function __construct(public readonly string $key, public readonly array $args = [])
{
parent::__construct($key);
}
/** The `msg` value of a mailcow message: the lang key alone, or [key, arg1, ...] when there are arguments */
public function msg(): string|array
{
return empty($this->args) ? $this->key : array_merge([$this->key], array_values($this->args));
}
}
@@ -1,252 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Controller;
use Mailcow\Core\AccessDenied;
use Mailcow\Core\ApiResponse;
use Mailcow\Core\UserError;
use Mailcow\Imapsync\Service\JobService;
use Mailcow\Imapsync\Service\SourceService;
use PDOException;
/**
* HTTP API adapter for json_api.php (and the pages that show the same data).
*
* Write methods take the decoded request ($items = ids, $attr = attributes), loop over batch ids, turn
* UserError into danger messages and write all messages to $_SESSION['return'] via ApiResponse.
* They return what json_api passes to process_*_return(): false when the whole call was rejected.
* A database error stops the call with one `mysql_error` message, like the rest of mailcow.
* Read methods return the API data (an empty array when there is nothing the caller may see).
* Messages are logged as ['syncjob', action, type, data] like before the conversion.
*/
class ImapsyncApiController
{
public function __construct(private SourceService $sources, private JobService $jobs, private ApiResponse $response)
{
}
/** POST add/syncjob */
public function addJob(array $attr): bool
{
return $this->handle('add', 'job', $attr,
fn(array $log): bool => $this->response->success(['mailbox_modified', $this->jobs->create($attr)->user2], $log));
}
/** POST add/syncjob_source */
public function addSource(array $attr): bool
{
return $this->handle('add', 'source', $attr,
fn(array $log): bool => $this->response->success(['imapsync_source_added', $this->sources->create($attr)->name], $log));
}
/** POST edit/syncjob: one message per id; `id` in $attr wins over $items (array_merge like before) */
public function editJobs(?array $items, array $attr): bool
{
$data = array_merge(['id' => $items], $attr);
return $this->handle('edit', 'job', $data, function (array $log) use ($data): bool {
$this->jobs->requireAcl();
return $this->batch((array)($data['id'] ?? []), $log,
fn(int $id): array => ['mailbox_modified', $this->jobs->update($id, $data)->user2]);
});
}
/** POST edit/syncjob/run_next (admin): false when no job was moved */
public function runNext(?array $items): bool
{
return $this->handle('edit', 'run_next', ['id' => $items], function (array $log) use ($items): bool {
$outcome = $this->jobs->runNext(array_map('intval', array_values(array_filter((array)$items, 'is_numeric'))));
foreach ($outcome['running'] as $id) {
$this->response->danger(['imapsync_job_running', $id], $log);
}
return ($outcome['moved'] > 0) ? $this->response->success(['imapsync_run_next_done', $outcome['moved']], $log) : false;
});
}
/** POST edit/syncjob_source: one message per id, non-numeric ids are skipped */
public function editSources(?array $items, array $attr): bool
{
$data = array_merge(['id' => $items], $attr);
return $this->handle('edit', 'source', $data, function (array $log) use ($data): bool {
$this->sources->requireEditor();
return $this->batch(array_filter((array)($data['id'] ?? []), 'is_numeric'), $log,
fn(int $id): array => ['imapsync_source_modified', $this->sources->update($id, $data)->name]);
});
}
/** POST edit/syncjob_source/refresh_token: the first id only */
public function refreshSourceToken(?array $items): bool
{
return $this->handle('edit', 'refresh_token', ['id' => $items], function (array $log) use ($items): bool {
$source = $this->sources->refreshToken($this->requireId(is_array($items) ? reset($items) : null));
return $this->response->success(['imapsync_source_token_refreshed', $source->name], $log);
});
}
/** POST edit/imapsync_settings (admin) */
public function editSettings(array $attr): bool
{
return $this->handle('edit', 'settings', $attr, function (array $log) use ($attr): bool {
$this->jobs->saveSettings($attr);
return $this->response->success('max_parallel_saved', $log);
});
}
/** POST delete/syncjob: one message per id (with the id as requested) */
public function deleteJobs(array $items): bool
{
return $this->handle('delete', 'job', ['id' => $items], function (array $log) use ($items): bool {
$this->jobs->requireAcl();
return $this->batch($items, $log, function (int $id, mixed $requested): array {
$this->jobs->delete($id);
return ['deleted_syncjob', $requested];
});
});
}
/** POST delete/syncjob_source: one message per id, non-numeric ids are skipped */
public function deleteSources(array $items): bool
{
return $this->handle('delete', 'source', ['id' => $items], function (array $log) use ($items): bool {
$this->sources->requireAcl();
return $this->batch(array_filter($items, 'is_numeric'), $log,
fn(int $id): array => ['imapsync_source_deleted', $this->sources->delete($id)->name]);
});
}
/** GET syncjob_options */
public function getOptions(): array
{
return $this->jobs->customParamOptions();
}
/** GET imapsync_settings (admin), also admin/system.php */
public function getSettings(): array
{
try {
return $this->jobs->getSettings()->toArray();
} catch (UserError) {
return [];
}
}
/** GET syncjob_source/all */
public function getSources(): array
{
return array_values(array_filter(array_map(fn(int $id): array => $this->getSource($id), $this->sources->visibleIds())));
}
/** GET syncjob_source/<id>, also edit.php */
public function getSource(mixed $id): array
{
$id = $this->toId($id);
$source = ($id === null) ? null : $this->sources->get($id);
return ($source === null) ? [] : $this->sources->toArray($source);
}
/**
* GET syncjobs/all. Admins get every job (incl. orphaned ones). Others get the jobs of $mailboxes,
* the mailboxes the caller manages (json_api collects them with the legacy mailbox() functions
* until functions.mailbox.inc.php is converted), in queue order.
*/
public function getAllJobs(?string $extra, array $mailboxes): array
{
if ($this->jobs->canListAll()) {
return $this->jobRows($this->jobs->allIds(), $extra);
}
$ids = [];
foreach ($mailboxes as $mailbox) {
$ids = array_merge($ids, $this->jobs->idsForMailbox((string)$mailbox));
}
$rows = $this->jobRows($ids, $extra);
// Queue order (C4): prio desc, never-run first, least recently run, id
usort($rows, fn(array $a, array $b): int => [$b['prio'], !empty($a['last_run']), (string)$a['last_run'], (int)$a['id']]
<=> [$a['prio'], !empty($b['last_run']), (string)$b['last_run'], (int)$b['id']]);
return $rows;
}
/** GET syncjobs/<mailbox>; without a valid mailbox the caller's own jobs */
public function getJobs(?string $mailbox, ?string $extra): array
{
return $this->jobRows($this->jobs->idsForMailbox($mailbox), $extra);
}
/** One job (edit.php, inc/ajax/syncjob_logs.php); [] when missing or not accessible. `no_log` in $extra skips the log. */
public function getJob(mixed $id, ?string $extra = null): array
{
$id = $this->toId($id);
$withLog = ($extra === null || !in_array('no_log', explode(',', $extra), true));
$job = ($id === null) ? null : $this->jobs->get($id, $withLog);
return ($job === null) ? [] : $job->toArray($withLog);
}
/**
* Account rename ($new) or delete ($new = null) from legacy account code that runs outside a transaction
* (admin, domain admin, mailbox delete): a database error becomes a `mysql_error` message.
* Inside a transaction (mailbox rename) call $module->sources->onAccountRenamed() directly instead,
* so the caller's catch can roll back.
*/
public function accountChanged(string $old, ?string $new = null): void
{
try {
if ($new === null) {
$this->sources->onAccountDeleted($old);
} else {
$this->sources->onAccountRenamed($old, $new);
}
} catch (PDOException $e) {
$this->response->danger(['mysql_error', $e->getMessage()], ['imapsync_account_changed', $old, $new]);
}
$this->response->flush();
}
/**
* Runs one write call: $work gets the log entry and returns the result for process_*_return().
* A UserError becomes a danger message, a database error stops the call with one `mysql_error` message.
*/
private function handle(string $action, string $type, mixed $data, callable $work): bool
{
$log = ['syncjob', $action, $type, $data];
try {
$result = $work($log);
} catch (UserError $e) {
$result = $this->response->error($e, $log);
} catch (PDOException $e) {
$result = $this->response->danger(['mysql_error', $e->getMessage()], $log);
}
$this->response->flush();
return $result;
}
/** One message per id: the success message returned by $work(id, requested id), or the UserError */
private function batch(array $ids, array $log, callable $work): bool
{
foreach ($ids as $id) {
try {
$this->response->success($work($this->requireId($id), $id), $log);
} catch (UserError $e) {
$this->response->error($e, $log);
}
}
return true;
}
/** @return array[] API rows of the jobs the caller may access */
private function jobRows(array $ids, ?string $extra): array
{
return array_values(array_filter(array_map(fn(int $id): array => $this->getJob($id, $extra), $ids)));
}
/** Integer id from request input ("12", 12); null for anything else, also "1.5" (matches no row) */
private function toId(mixed $id): ?int
{
return (is_numeric($id) && (float)$id == (int)(float)$id) ? (int)(float)$id : null;
}
/** @throws AccessDenied for ids that cannot exist */
private function requireId(mixed $id): int
{
return $this->toId($id) ?? throw new AccessDenied();
}
}
@@ -1,172 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Http;
/**
* HTTPS requests to OAuth provider endpoints with an address policy against SSRF (C5).
*
* Every endpoint must be https://, without userinfo, with a valid hostname. In strict mode (endpoints of
* sources not created by an admin) every resolved address must be public; curl is then pinned to the checked
* addresses (CURLOPT_RESOLVE), so a DNS change between check and request does not help an attacker.
*
* resolve() and send() are the only I/O and protected, so tests can replace them.
*/
class OAuthClient
{
private const MAX_URL_LENGTH = 500;
private const MAX_ERROR_LENGTH = 200;
public function isAllowed(string $url, bool $strict): bool
{
return $this->pin($url, $strict) !== null;
}
/**
* Sends one request (POST form when $post is given, otherwise GET) and returns the decoded JSON object.
*
* @throws OAuthException with a sanitised message
*/
public function request(string $url, bool $strict, ?array $post = null, array $headers = []): array
{
$pin = $this->pin($url, $strict);
if ($pin === null) {
throw new OAuthException('endpoint not allowed');
}
$options = [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_PROXY => '',
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS,
CURLOPT_CONNECTTIMEOUT => 10,
CURLOPT_TIMEOUT => 20,
CURLOPT_MAXFILESIZE => 1048576,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
CURLOPT_HTTPHEADER => array_merge(['Accept: application/json'], $headers),
];
if (!empty($pin)) {
$options[CURLOPT_RESOLVE] = $pin;
}
if ($post !== null) {
$options[CURLOPT_POST] = true;
$options[CURLOPT_POSTFIELDS] = http_build_query($post);
}
[$body, $code] = $this->send($options);
if ($body === false) {
throw new OAuthException('connection failed');
}
$data = json_decode($body, true);
$ok = ($code >= 200 && $code < 300);
if ($ok && is_array($data)) {
return $data;
}
throw new OAuthException($this->errorText($code, $ok, $data));
}
/**
* IPv4 and IPv6 addresses of a host.
*
* @return string[]
*/
protected function resolve(string $host): array
{
$ips = @gethostbynamel($host) ?: [];
foreach (@dns_get_record($host, DNS_AAAA) ?: [] as $record) {
$ips[] = $record['ipv6'] ?? '';
}
return $ips;
}
/**
* Runs curl with the given options.
*
* @return array{0: string|false, 1: int} response body (false on connection errors) and HTTP status
*/
protected function send(array $options): array
{
$ch = curl_init();
curl_setopt_array($ch, $options);
$body = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return [is_string($body) ? $body : false, $code];
}
/**
* Applies the endpoint policy. Returns null when the URL is not allowed, otherwise the CURLOPT_RESOLVE
* pin (empty when not strict).
*/
private function pin(string $url, bool $strict): ?array
{
if (strlen($url) > self::MAX_URL_LENGTH || preg_match('/[\x00-\x20\x7f]/', $url)) {
return null;
}
$parts = parse_url($url);
if (!$parts || strtolower($parts['scheme'] ?? '') !== 'https' || isset($parts['user']) || isset($parts['pass'])) {
return null;
}
$host = strtolower($parts['host'] ?? '');
if (!filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) || !filter_var($url, FILTER_VALIDATE_URL)) {
return null;
}
if (!$strict) {
return [];
}
$ips = array_values(array_unique($this->resolve($host)));
if (empty($ips)) {
return null;
}
$pinned = [];
foreach ($ips as $ip) {
if (!$this->isPublic($ip)) {
return null;
}
$pinned[] = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? '[' . $ip . ']' : $ip;
}
return [$host . ':' . (int)($parts['port'] ?? 443) . ':' . implode(',', $pinned)];
}
/**
* Public unicast address only: no private, loopback, link-local or reserved ranges, no CGNAT (100.64/10),
* benchmarking (198.18/15) or multicast/reserved (224/3); IPv6 only global unicast 2000::/3 without
* 6to4 (2002::/16), Teredo (2001::/32) and documentation (2001:db8::/32).
*/
private function isPublic(string $ip): bool
{
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return false;
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$long = ip2long($ip);
return ($long & 0xFFC00000) != 0x64400000
&& ($long & 0xFFFE0000) != 0xC6120000
&& ($long & 0xE0000000) != 0xE0000000;
}
$hex = bin2hex((string)inet_pton($ip));
return ($hex[0] === '2' || $hex[0] === '3')
&& substr($hex, 0, 4) !== '2002'
&& !in_array(substr($hex, 0, 8), ['20010000', '20010db8'], true);
}
/**
* Error text that is safe to store: "HTTP <code>" plus the provider's error / error_description.
*/
private function errorText(int $code, bool $ok, mixed $data): string
{
$text = 'HTTP ' . $code . ($ok ? ': malformed response' : '');
$detail = [];
foreach (['error', 'error_description'] as $key) {
if (is_array($data) && isset($data[$key]) && is_scalar($data[$key]) && (string)$data[$key] !== '') {
$detail[] = (string)$data[$key];
}
}
if (!empty($detail)) {
$text .= ': ' . implode(' - ', $detail);
}
return mb_substr(trim((string)preg_replace('/[\x00-\x1f\x7f]+/', ' ', strip_tags($text))), 0, self::MAX_ERROR_LENGTH);
}
}
@@ -1,15 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Http;
use RuntimeException;
/**
* A failed OAuth request. The message is safe to store and show: "HTTP <code>" plus the provider's
* error fields, or a fixed text; never a raw response body or curl details.
*/
class OAuthException extends RuntimeException
{
}
@@ -1,73 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync;
use Mailcow\Core\ApiResponse;
use Mailcow\Core\Context;
use Mailcow\Imapsync\Controller\ImapsyncApiController;
use Mailcow\Imapsync\Http\OAuthClient;
use Mailcow\Imapsync\Model\CustomParams;
use Mailcow\Imapsync\Repository\JobRepository;
use Mailcow\Imapsync\Repository\SettingsRepository;
use Mailcow\Imapsync\Repository\SourceRepository;
use Mailcow\Imapsync\Repository\TokenRepository;
use Mailcow\Imapsync\Service\JobService;
use Mailcow\Imapsync\Service\OAuthService;
use Mailcow\Imapsync\Service\SourceService;
use PDO;
/**
* Composition root of the sync job module: wires repositories, services and the API controller.
*
* $imapsync = ImapsyncModule::create($pdo, Context::fromSession($_SESSION), [
* 'allowlist' => CustomParams::allowlist($IMAPSYNC_OPTIONS, $IMAPSYNC_OPTIONS_DEFAULT),
* 'hostname' => (string)getenv('MAILCOW_HOSTNAME'),
* ]);
* process_add_return($imapsync->api->addJob($attr));
*
* Procedural legacy code (json_api.php, pages, functions.*.inc.php) uses fromGlobals() instead.
*/
class ImapsyncModule
{
/** Request keys whose values never reach a log */
private const SECRET_KEYS = ['password1', 'password2', 'oauth_client_secret', 'oauth_extra_params'];
public function __construct(
public readonly SourceService $sources,
public readonly JobService $jobs,
public readonly OAuthService $oauth,
public readonly ImapsyncApiController $api
) {
}
/**
* @param array $config 'allowlist' => effective custom param allowlist (CustomParams::allowlist()),
* 'hostname' => MAILCOW_HOSTNAME (OAuth redirect URI); both optional
*/
public static function create(PDO $pdo, Context $context, array $config = []): self
{
$sourceRepository = new SourceRepository($pdo);
$tokenRepository = new TokenRepository($pdo);
$oauth = new OAuthService($sourceRepository, $tokenRepository, new OAuthClient(), (string)($config['hostname'] ?? ''));
$sources = new SourceService($sourceRepository, $tokenRepository, $oauth, $context);
$jobs = new JobService(new JobRepository($pdo), $sources, $tokenRepository, new SettingsRepository($pdo),
$context, (array)($config['allowlist'] ?? []));
return new self($sources, $jobs, $oauth, new ImapsyncApiController($sources, $jobs, new ApiResponse(self::SECRET_KEYS)));
}
/**
* Legacy entry point for procedural callers: the one place that reads the globals ($pdo, $_SESSION,
* $IMAPSYNC_OPTIONS / $IMAPSYNC_OPTIONS_DEFAULT from vars(.local).inc.php, MAILCOW_HOSTNAME)
* and passes them to create(). $context overrides the session caller (e.g. Context::system()).
*/
public static function fromGlobals(?Context $context = null): self
{
return self::create($GLOBALS['pdo'], $context ?? Context::fromSession($_SESSION ?? []), [
'allowlist' => CustomParams::allowlist((array)($GLOBALS['IMAPSYNC_OPTIONS'] ?? []),
(array)($GLOBALS['IMAPSYNC_OPTIONS_DEFAULT'] ?? [])),
'hostname' => (string)getenv('MAILCOW_HOSTNAME'),
]);
}
}
@@ -1,97 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Model;
use Mailcow\Core\UserError;
/**
* Rules for the extra imapsync options of a sync job: a list of {o: option, v: value} pairs, stored as JSON
* (`imapsync`.`custom_params`, read by the runner). Stored values are passed through unchanged.
*
* Allowlist rules (option name lowercase, without dashes):
* true option requires a non-empty value
* false flag, must not have a value
* string PCRE (no delimiters) the whole non-empty value must match
*/
class CustomParams
{
private const MAX_PAIRS = 100;
private const MAX_VALUE_LENGTH = 1000;
private static bool $warnedOldFormat = false;
/**
* Validates request input (list of {o, v}) against the allowlist and returns the JSON to store.
* Empty input means no options.
*
* @throws UserError imapsync_custom_params_format, imapsync_custom_param_invalid, imapsync_custom_param_value
*/
public static function toJson(mixed $input, array $allowlist): string
{
$input = ($input === null || $input === '') ? [] : $input;
if (!is_array($input)) {
throw new UserError('imapsync_custom_params_format');
}
$pairs = [];
foreach ($input as $pair) {
$option = is_array($pair) ? ($pair['o'] ?? '') : null;
$value = is_array($pair) ? ($pair['v'] ?? '') : null;
if (!is_scalar($option) || !is_scalar($value)) {
throw new UserError('imapsync_custom_params_format');
}
$option = ltrim(strtolower(trim((string)$option)), '-');
$value = str_replace("\0", '', (string)$value);
if ($option === '' && trim($value) === '') {
continue; // empty UI row
}
$value = self::checkValue($option, $value, $allowlist);
if (strlen($value) > self::MAX_VALUE_LENGTH || count($pairs) >= self::MAX_PAIRS) {
throw new UserError('imapsync_custom_params_format');
}
$pairs[] = ['o' => $option, 'v' => $value];
}
return json_encode($pairs, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
/**
* The effective allowlist: $IMAPSYNC_OPTIONS (after vars.local.inc.php) without the old
* 'whitelist' key and malformed entries; the defaults when nothing valid is left.
*/
public static function allowlist(array $configured, array $defaults): array
{
$allowlist = [];
foreach ($configured as $option => $rule) {
$option = strtolower(ltrim(trim((string)$option), '-'));
if ($option !== 'whitelist' && preg_match('/^[a-z][a-z0-9]*$/', $option)
&& (is_bool($rule) || (is_string($rule) && $rule !== ''))) {
$allowlist[$option] = $rule;
}
}
if (!self::$warnedOldFormat && isset($configured['whitelist'])) {
self::$warnedOldFormat = true;
error_log("mailcow: \$IMAPSYNC_OPTIONS['whitelist'] (old format) is ignored, use 'option' => true|false|'regex' (see data/web/inc/vars.inc.php)");
}
return empty($allowlist) ? $defaults : $allowlist;
}
/** The value to store for one option; throws when the allowlist rule is violated */
private static function checkValue(string $option, string $value, array $allowlist): string
{
$rule = $allowlist[$option] ?? null;
if ($option === '' || !(is_bool($rule) || is_string($rule))) {
throw new UserError('imapsync_custom_param_invalid', [htmlspecialchars($option)]);
}
$empty = (trim($value) === '');
$valid = match (true) {
$rule === false => $empty,
$rule === true => !$empty,
default => !$empty && preg_match('~^(?:' . str_replace('~', '\~', $rule) . ')$~D', $value) === 1,
};
if (!$valid) {
throw new UserError('imapsync_custom_param_value', [$option]);
}
return ($rule === false) ? '' : $value;
}
}
@@ -1,154 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Model;
/**
* A sync job (table `imapsync`): copies the remote account user1 of a source into the local mailbox user2.
*
* Loaded jobs also carry display data of their source and of the per-user token ($source / $token,
* filled from the JOIN; no secrets). Switches default to off, because unchecked form checkboxes are not sent.
*/
class Job
{
public ?int $id = null;
public string $user2 = '';
public ?string $regextrans2 = '';
public int $authmd51 = 0;
public string $domain2 = '';
public string $subfolder2 = '';
public string $user1 = '';
/** Remote password, never returned by toArray() */
public string $password1 = '';
public string $exclude = '';
public int $maxage = 0;
public int $minsInterval = 0;
/** Bytes/s limit, stored as text */
public string $maxBytesPerSecond = '0';
public int $delete2duplicates = 0;
public int $delete1 = 0;
public int $delete2 = 0;
public int $automap = 0;
public int $skipcrossduplicates = 0;
/** JSON list of {o, v} pairs (see CustomParams), old rows may hold other text */
public ?string $customParams = '[]';
public ?string $customParamsLegacy = null;
public int $timeout1 = 600;
public int $timeout2 = 600;
public int $subscribeall = 0;
public int $dry = 0;
public int $isRunning = 0;
public ?string $returnedText = null;
public ?string $lastRun = null;
public ?int $success = null;
public ?string $exitStatus = null;
public ?string $created = null;
public ?string $modified = null;
public int $active = 0;
public int $sourceId = 0;
public int $prio = 0;
public ?Source $source = null;
public ?OAuthToken $token = null;
/**
* Builds a job from an `imapsync` row, optionally joined with `source_<column>` (columns of the source)
* and `user_token_*` columns.
*/
public static function fromRow(array $row): self
{
$job = new self();
$job->id = isset($row['id']) ? (int)$row['id'] : null;
foreach (['authmd51', 'maxage', 'delete2duplicates', 'delete1', 'delete2', 'automap', 'skipcrossduplicates', 'timeout1',
'timeout2', 'subscribeall', 'dry', 'active', 'prio'] as $column) {
$job->$column = (int)($row[$column] ?? 0);
}
foreach (['user2', 'domain2', 'subfolder2', 'user1', 'password1', 'exclude'] as $column) {
$job->$column = (string)($row[$column] ?? '');
}
$job->minsInterval = (int)($row['mins_interval'] ?? 0);
$job->maxBytesPerSecond = (string)($row['maxbytespersecond'] ?? '0');
$job->isRunning = (int)($row['is_running'] ?? 0);
$job->success = isset($row['success']) ? (int)$row['success'] : null;
// Nullable text columns (column => property)
foreach (['regextrans2' => 'regextrans2', 'custom_params' => 'customParams', 'custom_params_legacy' => 'customParamsLegacy',
'returned_text' => 'returnedText', 'last_run' => 'lastRun', 'exit_status' => 'exitStatus', 'created' => 'created',
'modified' => 'modified'] as $column => $property) {
$job->$property = isset($row[$column]) ? (string)$row[$column] : null;
}
$job->sourceId = (int)($row['source_id'] ?? 0);
if (isset($row['source_name'])) {
// source_id is the job's foreign key, i.e. the id of the source
$source = [];
foreach ($row as $key => $value) {
if (str_starts_with($key, 'source_')) {
$source[substr($key, 7)] = $value;
}
}
$job->source = Source::fromRow($source);
}
if (!empty($row['user_token_exists'])) {
$job->token = OAuthToken::fromRow(['source_id' => $job->sourceId, 'owner' => $job->user2, 'username' => $job->user1,
'token_expires' => $row['user_token_expires'] ?? null, 'last_refresh_error' => $row['user_token_error'] ?? null]);
}
return $job;
}
/**
* API representation (GET syncjobs, edit page, log view). Never contains password1.
* Without $withLog the `log` field is empty (UI tables request `no_log`).
*/
public function toArray(bool $withLog = true): array
{
$source = $this->source;
return [
'id' => $this->id,
'user2' => $this->user2,
'regextrans2' => $this->regextrans2,
'authmd51' => $this->authmd51,
'domain2' => $this->domain2,
'subfolder2' => $this->subfolder2,
'user1' => $this->user1,
'exclude' => $this->exclude,
'maxage' => $this->maxage,
'mins_interval' => $this->minsInterval,
'maxbytespersecond' => $this->maxBytesPerSecond,
'delete2duplicates' => $this->delete2duplicates,
'delete1' => $this->delete1,
'delete2' => $this->delete2,
'automap' => $this->automap,
'skipcrossduplicates' => $this->skipcrossduplicates,
'custom_params' => $this->customParams,
'timeout1' => $this->timeout1,
'timeout2' => $this->timeout2,
'subscribeall' => $this->subscribeall,
'dry' => $this->dry,
'is_running' => $this->isRunning,
'last_run' => $this->lastRun,
'success' => $this->success,
'exit_status' => $this->exitStatus,
'created' => $this->created,
'modified' => $this->modified,
'active' => $this->active,
'source_id' => $this->sourceId,
'custom_params_legacy' => $this->customParamsLegacy,
'prio' => $this->prio,
'source_name' => $source?->name,
'source_description' => $source?->description,
'source_host' => $source?->host,
'source_port' => $source?->port,
'source_enc' => $source?->enc,
'source_auth_type' => $source?->authType,
'source_created_by' => $source?->createdBy,
'source_scope' => $source?->scope,
'source_oauth_flow' => $source?->oauthFlow,
'source_active' => $source?->active,
'source_oauth_token_expires' => $source?->tokenExpires,
'source_oauth_last_refresh_error' => $source?->lastRefreshError,
'user_token_expires' => $this->token?->tokenExpires,
'user_token_error' => $this->token?->lastRefreshError,
'user_token_exists' => $this->token === null ? 0 : 1,
'log' => ($withLog && !empty($this->returnedText)) ? $this->returnedText : '',
];
}
}
@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Model;
/**
* Per-user OAuth token of an authorization_code source (table `imapsync_source_oauth_token`).
*
* Bound to (source, owner, username): owner = local mailcow mailbox (job user2),
* username = authenticated remote identity (job user1).
*/
class OAuthToken
{
public int $sourceId = 0;
public string $owner = '';
public string $username = '';
public ?string $accessToken = null;
public ?string $refreshToken = null;
public ?int $tokenExpires = null;
public ?string $lastRefreshError = null;
/** Seconds since the last refresh attempt by the DB clock; only filled by the refresh queries */
public ?int $refreshAttemptAge = null;
public static function fromRow(array $row): self
{
$token = new self();
$token->sourceId = (int)($row['source_id'] ?? 0);
$token->owner = (string)($row['owner'] ?? '');
$token->username = (string)($row['username'] ?? '');
$token->accessToken = isset($row['access_token']) ? (string)$row['access_token'] : null;
$token->refreshToken = isset($row['refresh_token']) ? (string)$row['refresh_token'] : null;
$token->tokenExpires = isset($row['token_expires']) ? (int)$row['token_expires'] : null;
$token->lastRefreshError = isset($row['last_refresh_error']) ? (string)$row['last_refresh_error'] : null;
$token->refreshAttemptAge = isset($row['refresh_attempt_age']) ? (int)$row['refresh_attempt_age'] : null;
return $token;
}
}
@@ -1,27 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Model;
/**
* Global sync job settings (table `imapsync_settings`), read by the runner.
*/
class Settings
{
public int $maxParallel = 1;
/** Global bandwidth limit in bytes/s, 0 = unlimited */
public int $maxBytesPerSecond = 0;
/**
* API representation (GET imapsync_settings); the UI edits the limit in KB/s.
*/
public function toArray(): array
{
return [
'max_parallel' => $this->maxParallel,
'max_bytes_per_second' => $this->maxBytesPerSecond,
'max_kb_per_second' => intdiv($this->maxBytesPerSecond, 1024),
];
}
}
@@ -1,143 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Model;
/**
* A reusable IMAP source (table `imapsync_source` plus its scope targets).
*
* Visibility is driven by `scope` and the target lists:
* 'all' visible to everyone (admin-created only)
* 'domain' visible to the listed domains (their domain admins and users)
* 'user' visible to the listed users (and their domain admins); no targets = private
* `createdBy` records the creator ('' = handed over to the admins).
*
* The object holds secrets (client secret, access token); toArray() never returns them.
*/
class Source
{
public const AUTH_XOAUTH2 = 'XOAUTH2';
public const FLOW_CLIENT_CREDENTIALS = 'client_credentials';
public const FLOW_AUTHORIZATION_CODE = 'authorization_code';
public ?int $id = null;
public string $name = '';
public string $description = '';
public string $createdBy = '';
public string $scope = 'user';
public string $host = '';
public int $port = 0;
public ?string $enc = 'TLS';
public ?string $authType = 'PLAIN';
public string $oauthFlow = self::FLOW_CLIENT_CREDENTIALS;
public ?string $tokenEndpoint = null;
public ?string $authorizeEndpoint = null;
public ?string $userinfoEndpoint = null;
public ?string $clientId = null;
public ?string $clientSecret = null;
public ?string $oauthScope = null;
/** Extra token request parameters, JSON object text */
public ?string $extraParams = null;
/** Shared token of a client_credentials source */
public ?string $accessToken = null;
public ?int $tokenExpires = null;
public ?string $lastRefreshError = null;
public ?string $lastRefreshAttempt = null;
/** Seconds since lastRefreshAttempt by the DB clock; only filled by the refresh queries */
public ?int $refreshAttemptAge = null;
public ?string $created = null;
public ?string $modified = null;
public int $active = 1;
/** @var string[] */
public array $domains = [];
/** @var string[] */
public array $users = [];
/** Builds a source from an `imapsync_source` row (column names as in the table) */
public static function fromRow(array $row): self
{
$source = new self();
$source->id = isset($row['id']) ? (int)$row['id'] : null;
$source->name = (string)($row['name'] ?? '');
$source->description = (string)($row['description'] ?? '');
$source->createdBy = (string)($row['created_by'] ?? '');
$source->scope = (string)($row['scope'] ?? 'user');
$source->host = (string)($row['host1'] ?? '');
$source->port = (int)($row['port1'] ?? 0);
$source->oauthFlow = (string)($row['oauth_flow'] ?? self::FLOW_CLIENT_CREDENTIALS);
$source->tokenExpires = isset($row['oauth_token_expires']) ? (int)$row['oauth_token_expires'] : null;
$source->refreshAttemptAge = isset($row['refresh_attempt_age']) ? (int)$row['refresh_attempt_age'] : null;
// Nullable text columns (column => property)
foreach (['enc1' => 'enc', 'auth_type' => 'authType', 'oauth_token_endpoint' => 'tokenEndpoint',
'oauth_authorize_endpoint' => 'authorizeEndpoint', 'oauth_userinfo_endpoint' => 'userinfoEndpoint',
'oauth_client_id' => 'clientId', 'oauth_client_secret' => 'clientSecret', 'oauth_scope' => 'oauthScope',
'oauth_extra_params' => 'extraParams', 'oauth_access_token' => 'accessToken', 'oauth_last_refresh_error' => 'lastRefreshError',
'oauth_last_refresh_attempt' => 'lastRefreshAttempt', 'created' => 'created', 'modified' => 'modified'] as $column => $property) {
$source->$property = isset($row[$column]) ? (string)$row[$column] : null;
}
$source->active = (int)($row['active'] ?? 1);
return $source;
}
public function isXOAuth2(): bool
{
return $this->authType === self::AUTH_XOAUTH2;
}
/** XOAUTH2 with one app-wide token that reaches every mailbox of the tenant */
public function usesClientCredentials(): bool
{
return $this->isXOAuth2() && $this->oauthFlow === self::FLOW_CLIENT_CREDENTIALS;
}
/** XOAUTH2 with a per-user token from the browser sign-in */
public function usesAuthorizationCode(): bool
{
return $this->isXOAuth2() && $this->oauthFlow === self::FLOW_AUTHORIZATION_CODE;
}
/**
* API representation (GET syncjob_source). Secrets and tokens are replaced by has_* flags;
* extra params are only shown to callers who may edit the source.
*/
public function toArray(bool $canEdit, string $redirectUri): array
{
$data = [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'created_by' => $this->createdBy,
'scope' => $this->scope,
'host1' => $this->host,
'port1' => $this->port,
'enc1' => $this->enc,
'auth_type' => $this->authType,
'oauth_flow' => $this->oauthFlow,
'oauth_token_endpoint' => $this->tokenEndpoint,
'oauth_authorize_endpoint' => $this->authorizeEndpoint,
'oauth_userinfo_endpoint' => $this->userinfoEndpoint,
'oauth_client_id' => $this->clientId,
'oauth_scope' => $this->oauthScope,
'oauth_extra_params' => $canEdit ? $this->extraParams : null,
'oauth_token_expires' => $this->tokenExpires,
'oauth_last_refresh_error' => $this->lastRefreshError,
'oauth_last_refresh_attempt' => $this->lastRefreshAttempt,
'created' => $this->created,
'modified' => $this->modified,
'active' => $this->active,
'domains' => $this->domains,
'users' => $this->users,
'can_edit' => $canEdit,
'host' => $this->host,
'port' => $this->port,
'enc' => $this->enc,
'has_client_secret' => !empty($this->clientSecret),
'has_access_token' => !empty($this->accessToken),
];
if ($this->usesAuthorizationCode()) {
$data['oauth_redirect_uri'] = $redirectUri;
}
return $data;
}
}
@@ -1,128 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Repository;
use Mailcow\Core\Repository;
use Mailcow\Imapsync\Model\Job;
use PDO;
/**
* SQL for sync jobs (table `imapsync`).
*/
class JobRepository extends Repository
{
/** Job queue order (C4): prio desc, never-run first, then least recently run */
private const QUEUE_ORDER = "ORDER BY `prio` DESC, `last_run` IS NOT NULL, `last_run` ASC, `id` ASC";
/** Every column except returned_text (the log, loaded on demand) */
private const COLUMNS = ['id', 'user2', 'regextrans2', 'authmd51', 'domain2', 'subfolder2', 'user1', 'password1',
'exclude', 'maxage', 'mins_interval', 'maxbytespersecond', 'delete2duplicates', 'delete1', 'delete2', 'automap',
'skipcrossduplicates', 'custom_params', 'timeout1', 'timeout2', 'subscribeall', 'dry', 'is_running',
'last_run', 'success', 'exit_status', 'created', 'modified', 'active', 'source_id', 'custom_params_legacy', 'prio'];
/**
* The job with display data of its source and per-user token (C1: source, owner = user2, username = user1).
* $withLog also loads returned_text.
*/
public function find(int $id, bool $withLog = true): ?Job
{
$columns = $withLog ? [...self::COLUMNS, 'returned_text'] : self::COLUMNS;
$row = $this->query("SELECT i.`" . implode("`, i.`", $columns) . "`,
s.`name` AS source_name, s.`description` AS source_description, s.`host1` AS source_host1,
s.`port1` AS source_port1, s.`enc1` AS source_enc1, s.`auth_type` AS source_auth_type,
s.`created_by` AS source_created_by, s.`scope` AS source_scope, s.`oauth_flow` AS source_oauth_flow,
s.`active` AS source_active, s.`oauth_token_expires` AS source_oauth_token_expires,
s.`oauth_last_refresh_error` AS source_oauth_last_refresh_error,
t.`token_expires` AS user_token_expires, t.`last_refresh_error` AS user_token_error,
CASE WHEN t.`username` IS NULL THEN 0 ELSE 1 END AS user_token_exists
FROM `imapsync` i
LEFT JOIN `imapsync_source` s ON i.`source_id` = s.`id`
LEFT JOIN `imapsync_source_oauth_token` t
ON t.`source_id` = i.`source_id` AND t.`owner` = i.`user2` AND t.`username` = i.`user1`
WHERE i.`id` = :id", ['id' => $id])->fetch(PDO::FETCH_ASSOC);
return $row ? Job::fromRow($row) : null;
}
/** @return int[] job ids of a local mailbox in queue order */
public function idsForMailbox(string $user2): array
{
return array_map('intval', $this->query("SELECT `id` FROM `imapsync` WHERE `user2` = :user2 " . self::QUEUE_ORDER,
['user2' => $user2])->fetchAll(PDO::FETCH_COLUMN));
}
/** @return int[] all job ids (incl. orphaned ones) in queue order */
public function allIds(): array
{
return array_map('intval', $this->query("SELECT `id` FROM `imapsync` " . self::QUEUE_ORDER)->fetchAll(PDO::FETCH_COLUMN));
}
/** True when another job (not $exceptId) already syncs (source, user1) into user2 */
public function tupleExists(int $sourceId, string $user2, string $user1, ?int $exceptId = null): bool
{
return (bool)$this->query("SELECT 1 FROM `imapsync`
WHERE `user2` = :user2 AND `user1` = :user1 AND `source_id` = :source_id AND `id` != :id",
['user2' => $user2, 'user1' => $user1, 'source_id' => $sourceId, 'id' => $exceptId ?? 0])->fetchColumn();
}
public function insert(Job $job): int
{
return $job->id = $this->insertRow('imapsync', $this->values($job) + ['user2' => $job->user2]);
}
public function update(Job $job): void
{
$this->updateRow('imapsync', (int)$job->id,
$this->values($job) + ['last_run' => $job->lastRun, 'success' => $job->success, 'prio' => $job->prio]);
}
public function delete(int $id): void
{
$this->query("DELETE FROM `imapsync` WHERE `id` = :id", ['id' => $id]);
}
/**
* Moves idle jobs to the front of the queue: each gets a prio above the current global max
* (lowest current prio first) and last_run/success are cleared. The rows are locked first, so the
* runner cannot claim a job in between (its claim UPDATE waits for this transaction).
*
* @param int[] $ids
* @return array{running: int[], moved: int} ids that are running (left alone) and the number of moved jobs
*/
public function moveToFront(array $ids): array
{
return $this->transaction(function () use ($ids): array {
$rows = $this->query("SELECT `id`, `is_running` FROM `imapsync` WHERE `id` IN (" . self::placeholders($ids) . ") FOR UPDATE",
$ids)->fetchAll(PDO::FETCH_KEY_PAIR);
$running = array_keys(array_filter($rows, fn($isRunning): bool => (int)$isRunning === 1));
$idle = array_values(array_diff(array_keys($rows), $running));
$moved = 0;
if (!empty($idle)) {
$this->pdo->exec("SET @m := (SELECT COALESCE(MAX(`prio`), 0) FROM `imapsync`)");
$this->pdo->exec("SET @r := 0");
$moved = $this->query("UPDATE `imapsync` SET `prio` = @m + (@r := @r + 1), `last_run` = NULL, `success` = NULL
WHERE `id` IN (" . self::placeholders($idle) . ") AND `is_running` = 0 ORDER BY `prio` ASC, `id` ASC", $idle)->rowCount();
}
return ['running' => $running, 'moved' => $moved];
});
}
/** Values of the columns a user can edit (shared by insert and update) */
private function values(Job $job): array
{
$values = ['mins_interval' => $job->minsInterval, 'maxbytespersecond' => $job->maxBytesPerSecond,
'custom_params' => $job->customParams, 'source_id' => $job->sourceId];
// Columns with a property of the same name
foreach (['user1', 'password1', 'subfolder2', 'exclude', 'maxage', 'timeout1', 'timeout2', 'active', 'delete1', 'delete2',
'delete2duplicates', 'automap', 'skipcrossduplicates', 'subscribeall', 'dry'] as $column) {
$values[$column] = $job->$column;
}
return $values;
}
private static function placeholders(array $values): string
{
return implode(',', array_fill(0, count($values), '?'));
}
}
@@ -1,32 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Repository;
use Mailcow\Core\Repository;
use Mailcow\Imapsync\Model\Settings;
use PDO;
/**
* SQL for the global sync job settings (table `imapsync_settings`, name => value).
*/
class SettingsRepository extends Repository
{
/** Stored settings; keys that are not stored yet keep the defaults of the model */
public function load(): Settings
{
$rows = $this->query("SELECT `name`, `value` FROM `imapsync_settings`")->fetchAll(PDO::FETCH_KEY_PAIR);
$settings = new Settings();
$settings->maxParallel = (int)($rows['max_parallel'] ?? $settings->maxParallel);
$settings->maxBytesPerSecond = (int)($rows['max_bytes_per_second'] ?? $settings->maxBytesPerSecond);
return $settings;
}
public function save(Settings $settings): void
{
$sql = "INSERT INTO `imapsync_settings` (`name`, `value`) VALUES (:name, :value) ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)";
$this->query($sql, ['name' => 'max_parallel', 'value' => (string)$settings->maxParallel]);
$this->query($sql, ['name' => 'max_bytes_per_second', 'value' => (string)$settings->maxBytesPerSecond]);
}
}
@@ -1,215 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Repository;
use Mailcow\Core\Context;
use Mailcow\Core\Repository;
use Mailcow\Imapsync\Model\Source;
use PDO;
/**
* SQL for `imapsync_source` and its target tables `imapsync_source_domain` / `imapsync_source_user`.
* No permission decisions here except the visibility filter, which is pure SQL (see visibility()).
*/
class SourceRepository extends Repository
{
/** Full source incl. secrets and targets, without visibility filter */
public function find(int $id): ?Source
{
return $this->fetch('1=1', ['id' => $id]);
}
/** Like find(), but only when the source is visible to the caller */
public function findVisible(int $id, Context $context): ?Source
{
[$visible, $params] = $this->visibility($context);
return $this->fetch($visible, $params + ['id' => $id]);
}
/** @return int[] ids of all sources visible to the caller, ordered by name */
public function visibleIds(Context $context): array
{
[$visible, $params] = $this->visibility($context);
return array_map('intval', $this->query("SELECT s.`id` FROM `imapsync_source` s WHERE $visible ORDER BY s.`name`, s.`id`",
$params)->fetchAll(PDO::FETCH_COLUMN));
}
public function nameExists(string $name, string $createdBy, ?int $exceptId = null): bool
{
return (bool)$this->query("SELECT 1 FROM `imapsync_source` WHERE `name` = :name AND `created_by` = :created_by AND `id` != :id",
['name' => $name, 'created_by' => $createdBy, 'id' => $exceptId ?? 0])->fetchColumn();
}
/** True while a sync job references the source */
public function isInUse(int $id): bool
{
return (bool)$this->query("SELECT 1 FROM `imapsync` WHERE `source_id` = :id LIMIT 1", ['id' => $id])->fetchColumn();
}
public function isSuperAdmin(string $username): bool
{
return (bool)$this->query("SELECT 1 FROM `admin` WHERE `username` = :username AND `superadmin` = '1'",
['username' => $username])->fetchColumn();
}
/** Inserts the source with its targets and returns the new id */
public function insert(Source $source): int
{
return $this->transaction(function () use ($source): int {
$source->id = $this->insertRow('imapsync_source',
$this->values($source) + ['created_by' => $source->createdBy, 'scope' => $source->scope]);
$this->writeTargets($source);
return $source->id;
});
}
/**
* Updates the source. $writeTargets also rewrites scope + target lists, $resetTokens drops the shared
* token and all per-user tokens of the source. All in one transaction.
*/
public function update(Source $source, bool $writeTargets, bool $resetTokens): void
{
$this->transaction(function () use ($source, $writeTargets, $resetTokens): void {
$id = ['id' => $source->id];
$this->updateRow('imapsync_source', (int)$source->id, $this->values($source));
if ($writeTargets) {
$this->query("DELETE FROM `imapsync_source_domain` WHERE `source_id` = :id", $id);
$this->query("DELETE FROM `imapsync_source_user` WHERE `source_id` = :id", $id);
$this->writeTargets($source);
}
if ($resetTokens) {
$this->query("UPDATE `imapsync_source` SET `oauth_access_token` = NULL, `oauth_token_expires` = NULL,
`oauth_last_refresh_error` = NULL, `oauth_last_refresh_attempt` = NULL WHERE `id` = :id", $id);
$this->query("DELETE FROM `imapsync_source_oauth_token` WHERE `source_id` = :id", $id);
}
});
}
/** Deletes the source; targets and per-user tokens go via FK ON DELETE CASCADE */
public function delete(int $id): void
{
$this->query("DELETE FROM `imapsync_source` WHERE `id` = :id", ['id' => $id]);
}
/** Deletes the source and its child rows explicitly, for callers running with FOREIGN_KEY_CHECKS = 0 (mailbox rename) */
public function deleteWithChildren(int $id): void
{
foreach (['imapsync_source_oauth_token', 'imapsync_source_user', 'imapsync_source_domain'] as $table) {
$this->query("DELETE FROM `$table` WHERE `source_id` = :id", ['id' => $id]);
}
$this->delete($id);
}
/** @return Source[] sources created by an account (id and name only) */
public function listCreatedBy(string $username): array
{
return array_map([Source::class, 'fromRow'], $this->query("SELECT `id`, `name` FROM `imapsync_source` WHERE `created_by` = :username",
['username' => $username])->fetchAll(PDO::FETCH_ASSOC));
}
/** Hands a source over to the admins (created_by '') under the given name */
public function handOver(int $id, string $name): void
{
$this->query("UPDATE `imapsync_source` SET `created_by` = '', `name` = :name WHERE `id` = :id", ['name' => $name, 'id' => $id]);
}
/** Follows an account rename: creator and user targets */
public function renameAccount(string $old, string $new): void
{
$this->query("UPDATE `imapsync_source` SET `created_by` = :new WHERE `created_by` = :old", ['new' => $new, 'old' => $old]);
$this->query("UPDATE `imapsync_source_user` SET `username` = :new WHERE `username` = :old", ['new' => $new, 'old' => $old]);
}
/** Removes a deleted account from all user target lists */
public function deleteUserTarget(string $username): void
{
$this->query("DELETE FROM `imapsync_source_user` WHERE `username` = :username", ['username' => $username]);
}
private function fetch(string $where, array $params): ?Source
{
$row = $this->query("SELECT s.* FROM `imapsync_source` s WHERE s.`id` = :id AND $where", $params)->fetch(PDO::FETCH_ASSOC);
if (!$row) {
return null;
}
$source = Source::fromRow($row);
$id = ['id' => $source->id];
$source->domains = $this->query("SELECT `domain` FROM `imapsync_source_domain` WHERE `source_id` = :id ORDER BY `domain`",
$id)->fetchAll(PDO::FETCH_COLUMN);
$source->users = $this->query("SELECT `username` FROM `imapsync_source_user` WHERE `source_id` = :id ORDER BY `username`",
$id)->fetchAll(PDO::FETCH_COLUMN);
return $source;
}
/**
* WHERE fragment over `imapsync_source s` that limits rows to what the caller may see, and its parameters.
* admin everything
* domainadmin own + 'all' + sources targeting his domains or their mailboxes + sources created by them
* user own + 'all' + sources targeting him or his domain
*
* @return array{0: string, 1: array}
*/
private function visibility(Context $context): array
{
$username = $context->username;
if ($context->isAdmin()) {
return ['1=1', []];
}
// Native prepares: a placeholder may appear only once, so the username is bound under one name per use
$managed = "SELECT `domain` FROM `domain_admins` WHERE `active` = '1' AND `username` = ";
if ($context->isDomainAdmin()) {
return ["(s.`created_by` = :vis_self OR s.`scope` = 'all'
OR (s.`scope` = 'domain' AND EXISTS (SELECT 1 FROM `imapsync_source_domain` t
WHERE t.`source_id` = s.`id` AND t.`domain` IN ($managed :vis_d1)))
OR (s.`scope` = 'user' AND EXISTS (SELECT 1 FROM `imapsync_source_user` t JOIN `mailbox` mb ON mb.`username` = t.`username`
WHERE t.`source_id` = s.`id` AND mb.`domain` IN ($managed :vis_d2)))
OR s.`created_by` IN (SELECT `username` FROM `mailbox` WHERE `domain` IN ($managed :vis_d3)))",
['vis_self' => $username, 'vis_d1' => $username, 'vis_d2' => $username, 'vis_d3' => $username]];
}
if ($context->isUser()) {
return ["(s.`created_by` = :vis_self OR s.`scope` = 'all'
OR (s.`scope` = 'user' AND EXISTS (SELECT 1 FROM `imapsync_source_user` t
WHERE t.`source_id` = s.`id` AND t.`username` = :vis_u1))
OR (s.`scope` = 'domain' AND EXISTS (SELECT 1 FROM `imapsync_source_domain` t JOIN `mailbox` mb ON mb.`domain` = t.`domain`
WHERE t.`source_id` = s.`id` AND mb.`username` = :vis_u2)))",
['vis_self' => $username, 'vis_u1' => $username, 'vis_u2' => $username]];
}
return ['0=1', []];
}
private function writeTargets(Source $source): void
{
foreach ($source->domains as $domain) {
$this->query("INSERT INTO `imapsync_source_domain` (`source_id`, `domain`) VALUES (:id, :domain)",
['id' => $source->id, 'domain' => $domain]);
}
foreach ($source->users as $username) {
$this->query("INSERT INTO `imapsync_source_user` (`source_id`, `username`) VALUES (:id, :username)",
['id' => $source->id, 'username' => $username]);
}
$this->query("UPDATE `imapsync_source` SET `scope` = :scope WHERE `id` = :id", ['scope' => $source->scope, 'id' => $source->id]);
}
private function values(Source $source): array
{
return [
'name' => $source->name,
'description' => $source->description,
'host1' => $source->host,
'port1' => $source->port,
'enc1' => $source->enc,
'auth_type' => $source->authType,
'oauth_flow' => $source->oauthFlow,
'oauth_token_endpoint' => $source->tokenEndpoint,
'oauth_authorize_endpoint' => $source->authorizeEndpoint,
'oauth_userinfo_endpoint' => $source->userinfoEndpoint,
'oauth_client_id' => $source->clientId,
'oauth_client_secret' => $source->clientSecret,
'oauth_scope' => $source->oauthScope,
'oauth_extra_params' => $source->extraParams,
'active' => $source->active,
];
}
}
@@ -1,123 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Repository;
use Mailcow\Core\Repository;
use Mailcow\Imapsync\Model\OAuthToken;
use Mailcow\Imapsync\Model\Source;
use PDO;
/**
* SQL for OAuth tokens: the shared token of a client_credentials source (columns of `imapsync_source`)
* and the per-user tokens of authorization_code sources (`imapsync_source_oauth_token`).
*/
class TokenRepository extends Repository
{
private const USER_TOKEN = "`source_id` = :sid AND `owner` = :owner AND `username` = :user";
public function exists(int $sourceId, string $owner, string $username): bool
{
return (bool)$this->query("SELECT 1 FROM `imapsync_source_oauth_token` WHERE " . self::USER_TOKEN,
['sid' => $sourceId, 'owner' => $owner, 'user' => $username])->fetchColumn();
}
/**
* Stores the token of a finished browser sign-in. An existing refresh token is kept when the
* provider sent none (Microsoft only sends one with offline_access).
*/
public function saveUserToken(OAuthToken $token): void
{
$this->query("INSERT INTO `imapsync_source_oauth_token`
(`source_id`, `owner`, `username`, `access_token`, `refresh_token`, `token_expires`, `last_refresh_error`, `oauth_last_refresh_attempt`)
VALUES (:sid, :owner, :user, :acc, :ref, :exp, NULL, NOW())
ON DUPLICATE KEY UPDATE
`access_token` = VALUES(`access_token`),
`refresh_token` = IF(VALUES(`refresh_token`) = '', `refresh_token`, VALUES(`refresh_token`)),
`token_expires` = VALUES(`token_expires`),
`last_refresh_error` = NULL,
`oauth_last_refresh_attempt` = NOW()",
$this->key($token) + ['acc' => $token->accessToken, 'ref' => (string)$token->refreshToken, 'exp' => $token->tokenExpires]);
}
/** Stores a refreshed per-user token and clears its last error */
public function storeUserRefresh(OAuthToken $token): void
{
$this->query("UPDATE `imapsync_source_oauth_token` SET `access_token` = :acc, `refresh_token` = :ref,
`token_expires` = :exp, `last_refresh_error` = NULL, `oauth_last_refresh_attempt` = NOW() WHERE " . self::USER_TOKEN,
$this->key($token) + ['acc' => $token->accessToken, 'ref' => $token->refreshToken, 'exp' => $token->tokenExpires]);
}
public function storeUserError(OAuthToken $token, string $error): void
{
$this->query("UPDATE `imapsync_source_oauth_token` SET `last_refresh_error` = :err, `oauth_last_refresh_attempt` = NOW()
WHERE " . self::USER_TOKEN, $this->key($token) + ['err' => $error]);
}
public function storeSourceToken(int $sourceId, string $accessToken, int $expires): void
{
$this->query("UPDATE `imapsync_source` SET `oauth_access_token` = :tok, `oauth_token_expires` = :exp,
`oauth_last_refresh_error` = NULL, `oauth_last_refresh_attempt` = NOW() WHERE `id` = :id",
['tok' => $accessToken, 'exp' => $expires, 'id' => $sourceId]);
}
public function storeSourceError(int $sourceId, string $error): void
{
$this->query("UPDATE `imapsync_source` SET `oauth_last_refresh_error` = :err, `oauth_last_refresh_attempt` = NOW()
WHERE `id` = :id", ['err' => $error, 'id' => $sourceId]);
}
/** @return Source[] active client_credentials sources (incl. secrets), each with refreshAttemptAge */
public function refreshableSources(): array
{
return array_map([Source::class, 'fromRow'], $this->query("SELECT *,
TIMESTAMPDIFF(SECOND, `oauth_last_refresh_attempt`, NOW()) AS `refresh_attempt_age`
FROM `imapsync_source`
WHERE `active` = 1 AND `auth_type` = 'XOAUTH2' AND `oauth_flow` = 'client_credentials'
ORDER BY `id`")->fetchAll(PDO::FETCH_ASSOC));
}
/** @return OAuthToken[] per-user tokens with a refresh token on active authorization_code sources, with refreshAttemptAge */
public function refreshableUserTokens(): array
{
return array_map([OAuthToken::class, 'fromRow'], $this->query("SELECT t.*,
TIMESTAMPDIFF(SECOND, t.`oauth_last_refresh_attempt`, NOW()) AS `refresh_attempt_age`
FROM `imapsync_source_oauth_token` t
JOIN `imapsync_source` s ON s.`id` = t.`source_id`
WHERE s.`active` = 1 AND s.`auth_type` = 'XOAUTH2' AND s.`oauth_flow` = 'authorization_code'
AND t.`refresh_token` <> ''
ORDER BY t.`source_id`, t.`owner`, t.`username`")->fetchAll(PDO::FETCH_ASSOC));
}
/** Drops a per-user token once no job uses (source, owner, username) anymore */
public function deleteIfUnused(int $sourceId, string $owner, string $username): void
{
$this->query("DELETE FROM `imapsync_source_oauth_token` WHERE " . self::USER_TOKEN . "
AND NOT EXISTS (SELECT 1 FROM `imapsync` WHERE `source_id` = :sid2 AND `user2` = :owner2 AND `user1` = :user2)",
['sid' => $sourceId, 'owner' => $owner, 'user' => $username, 'sid2' => $sourceId, 'owner2' => $owner, 'user2' => $username]);
}
/** Drops per-user tokens that no job uses and that are older than $seconds. Returns the number of rows. */
public function deleteUnusedOlderThan(int $seconds): int
{
return $this->query("DELETE t FROM `imapsync_source_oauth_token` t
LEFT JOIN `imapsync` i ON i.`source_id` = t.`source_id` AND i.`user2` = t.`owner` AND i.`user1` = t.`username`
WHERE i.`id` IS NULL AND t.`created` < NOW() - INTERVAL :seconds SECOND", ['seconds' => $seconds])->rowCount();
}
public function deleteByOwner(string $owner): void
{
$this->query("DELETE FROM `imapsync_source_oauth_token` WHERE `owner` = :owner", ['owner' => $owner]);
}
public function renameOwner(string $old, string $new): void
{
$this->query("UPDATE `imapsync_source_oauth_token` SET `owner` = :new WHERE `owner` = :old", ['new' => $new, 'old' => $old]);
}
private function key(OAuthToken $token): array
{
return ['sid' => $token->sourceId, 'owner' => $token->owner, 'user' => $token->username];
}
}
@@ -1,286 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Service;
use Mailcow\Core\AccessDenied;
use Mailcow\Core\Context;
use Mailcow\Core\Input;
use Mailcow\Core\UserError;
use Mailcow\Imapsync\Model\CustomParams;
use Mailcow\Imapsync\Model\Job;
use Mailcow\Imapsync\Model\Settings;
use Mailcow\Imapsync\Model\Source;
use Mailcow\Imapsync\Repository\JobRepository;
use Mailcow\Imapsync\Repository\SettingsRepository;
use Mailcow\Imapsync\Repository\TokenRepository;
/**
* Use cases and rules for sync jobs and the global sync settings.
*
* A job belongs to its local mailbox (user2): whoever may access that mailbox may see and manage it
* (admins also orphaned jobs). Writing needs the `syncjobs` ACL. Jobs on an authorization_code source
* need the per-user token of (source, owner = user2, username = user1) first (C1).
*/
class JobService
{
private const FLAGS = ['active', 'delete2duplicates', 'subscribeall', 'dry', 'delete1', 'delete2', 'automap', 'skipcrossduplicates'];
public function __construct(private JobRepository $jobs, private SourceService $sources, private TokenRepository $tokens,
private SettingsRepository $settings, private Context $context, private array $allowlist)
{
}
/** @throws AccessDenied without the syncjobs ACL */
public function requireAcl(): void
{
if (!$this->context->hasAcl('syncjobs')) {
throw new AccessDenied();
}
}
/** The allowed custom params (option => rule), for the job form */
public function customParamOptions(): array
{
return $this->allowlist;
}
/**
* The job when the caller may access its mailbox (incl. password1: never pass the object to the UI, use toArray()).
* Admins may also manage orphaned jobs whose local mailbox no longer exists.
*/
public function get(int $id, bool $withLog = true): ?Job
{
$job = $this->jobs->find($id, $withLog);
return ($job !== null && ($this->context->isAdmin() || $this->context->canAccessMailbox($job->user2))) ? $job : null;
}
/** @return int[] job ids of a mailbox (default: the caller's own) in queue order; [] without access */
public function idsForMailbox(mixed $mailbox): array
{
if ($mailbox !== null && filter_var($mailbox, FILTER_VALIDATE_EMAIL)) {
if (!$this->context->canAccessMailbox((string)$mailbox)) {
return [];
}
} else {
$mailbox = $this->context->username;
}
return ($mailbox === null) ? [] : $this->jobs->idsForMailbox((string)$mailbox);
}
/** Only admins list all jobs at once (incl. orphaned ones) */
public function canListAll(): bool
{
return $this->context->isAdmin();
}
/** @return int[] all job ids in queue order; [] for non-admins */
public function allIds(): array
{
return $this->canListAll() ? $this->jobs->allIds() : [];
}
/** @throws UserError */
public function create(array $input): Job
{
$this->requireAcl();
$job = new Job();
$job->user2 = $this->ownerMailbox($input);
// Add keeps its legacy parsing of these attributes (e.g. maxage '1.5' means none, on edit 1); then the edit rules apply
$input['source_id'] = (int)($input['source_id'] ?? 0);
$input['maxage'] = Input::intInRange($input['maxage'] ?? null, 1, 32000) ?? 0;
$input['maxbytespersecond'] = Input::intInRange($input['maxbytespersecond'] ?? null, 1, 125000000) ?? 0;
$this->save($job, null, $input + ['custom_params' => '']);
return $job;
}
/** @throws UserError */
public function update(int $id, array $input): Job
{
$this->requireAcl();
$current = $this->get($id, false) ?? throw new AccessDenied();
$job = clone $current;
$this->save($job, $current, $input);
return $job;
}
/** @throws UserError access_denied when the job does not exist or the caller may not access it */
public function delete(int $id): void
{
$this->requireAcl();
$job = $this->get($id, false) ?? throw new AccessDenied();
// prio is a sparse sort key (ties allowed), so no gap needs closing
$this->jobs->delete($id);
$this->tokens->deleteIfUnused($job->sourceId, $job->user2, $job->user1);
}
/**
* Admin only (C4): moves jobs to the front of the queue. Running jobs are left alone; the runner never resets prio.
*
* @param int[] $ids
* @return array{running: int[], moved: int}
*/
public function runNext(array $ids): array
{
$ids = array_values(array_unique($ids));
if (!$this->context->isAdmin() || empty($ids)) {
throw new AccessDenied();
}
return $this->jobs->moveToFront($ids);
}
/** @throws AccessDenied for non-admins */
public function getSettings(): Settings
{
if (!$this->context->isAdmin()) {
throw new AccessDenied();
}
return $this->settings->load();
}
/**
* Saves the global settings (admin only). The UI sends the bandwidth limit in KB/s; stored as bytes/s
* like the per-job limit.
*
* @throws UserError
*/
public function saveSettings(array $input): Settings
{
if (!$this->context->isAdmin()) {
throw new AccessDenied();
}
$maxParallel = Input::intInRange($input['max_parallel'] ?? null, 1, 32);
$kb = $input['max_kb_per_second'] ?? 0;
if ($maxParallel === null) {
throw new UserError('imapsync_max_parallel_invalid');
}
// 0 (unlimited) .. 10 GiB/s
$kb = Input::intInRange(($kb === '') ? 0 : $kb, 0, 10485760) ?? throw new UserError('imapsync_max_kb_per_second_invalid');
$settings = new Settings();
$settings->maxParallel = $maxParallel;
$settings->maxBytesPerSecond = $kb * 1024;
$this->settings->save($settings);
return $settings;
}
/**
* Applies an add ($current = null) or edit request to $job and stores it. Attributes missing in $input keep
* the value of $job, an empty password keeps the stored one. On edit the source is only checked when the job
* moves to another source, so a domain admin or user can still edit or deactivate a job bound to a source
* he cannot see.
*
* @throws UserError
*/
private function save(Job $job, ?Job $current, array $input): void
{
foreach (self::FLAGS as $flag) {
if (isset($input[$flag])) {
$job->$flag = (int)$input[$flag];
}
}
foreach (['maxage', 'timeout1', 'timeout2'] as $number) {
if (isset($input[$number]) && $input[$number] != '') {
$job->$number = (int)$input[$number];
}
}
if (isset($input['maxbytespersecond']) && $input['maxbytespersecond'] != '') {
$job->maxBytesPerSecond = (string)(int)$input['maxbytespersecond'];
}
// last_run/success are reset when present; prio is a global-queue concern that only admins may change
$job->lastRun = isset($input['last_run']) ? null : $job->lastRun;
$job->success = isset($input['success']) ? null : $job->success;
if (isset($input['prio']) && is_numeric($input['prio']) && $this->context->isAdmin()) {
$job->prio = (int)$input['prio'];
}
// An empty user1 keeps the stored one on edit; a new job needs one
$user1 = (isset($input['user1']) && Input::text($input['user1']) !== '') ? Input::text($input['user1']) : $job->user1;
if ($user1 === null || mb_strlen($user1) > 255 || ($current === null && $user1 === '')) {
throw new UserError('imapsync_user1_invalid');
}
$password = (isset($input['password1']) && is_scalar($input['password1']) && (string)$input['password1'] !== '')
? (string)$input['password1'] : $job->password1;
$sourceId = (!empty($input['source_id']) && is_scalar($input['source_id'])) ? (int)$input['source_id'] : $job->sourceId;
$sourceChanged = ($current === null || $sourceId !== $current->sourceId);
$tupleChanged = $sourceChanged || $user1 !== $current->user1;
$job->user1 = $user1;
$job->sourceId = $sourceId;
$source = $sourceChanged ? $this->sources->get($sourceId) : $current->source;
if ($source === null || ($sourceChanged && $source->active !== 1)) {
throw new UserError('imapsync_source_unavailable');
}
if ($source->usesClientCredentials() && $tupleChanged && !$sourceChanged && !$this->mayUse($source)) {
// The app token reaches every mailbox of the tenant, so only callers that may use the source
// may pick another remote mailbox (user1). Other fields stay editable.
throw new AccessDenied();
}
$job->password1 = $this->checkAuth($source, $job, $password, $tupleChanged);
if (array_key_exists('custom_params', $input)) {
$job->customParams = CustomParams::toJson($input['custom_params'], $this->allowlist);
}
$job->maxage = Input::intInRange($job->maxage, 1, 32000) ?? 0;
$job->timeout1 = Input::intInRange($job->timeout1, 1, 32000) ?? 600;
$job->timeout2 = Input::intInRange($job->timeout2, 1, 32000) ?? 600;
$job->maxBytesPerSecond = (string)(Input::intInRange($job->maxBytesPerSecond, 1, 125000000) ?? 0);
$exclude = isset($input['exclude']) ? Input::text($input['exclude']) : $job->exclude;
$subfolder2 = isset($input['subfolder2']) ? Input::text($input['subfolder2']) : $job->subfolder2;
$minsInterval = Input::intInRange(!empty($input['mins_interval']) ? $input['mins_interval'] : $job->minsInterval, 1, 43800);
// exclude must be a valid regex, the interval 1..43800 minutes
if ($exclude === null || $subfolder2 === null || $minsInterval === null || @preg_match("/" . $exclude . "/", '') === false) {
throw new AccessDenied();
}
$job->exclude = $exclude;
$job->subfolder2 = $subfolder2;
$job->minsInterval = $minsInterval;
if ($tupleChanged && $this->jobs->tupleExists($job->sourceId, $job->user2, $job->user1, $current?->id)) {
throw new UserError('object_exists', [htmlspecialchars($source->name . ' / ' . $job->user1)]);
}
if ($current === null) {
$this->jobs->insert($job);
return;
}
$this->jobs->update($job);
// The previous (source, owner, remote user) token is dropped once no job uses it (C1)
if ($tupleChanged) {
$this->tokens->deleteIfUnused($current->sourceId, $current->user2, $current->user1);
}
}
/** Whether the caller may use the source for new jobs (sees it or may edit it) */
private function mayUse(Source $source): bool
{
return $this->sources->get((int)$source->id) !== null || $this->sources->canEdit($source);
}
/**
* Local mailbox of a new job: `username` (needs access), else the caller's own mailbox (users only).
*
* @throws UserError access_denied, no_user_defined
*/
private function ownerMailbox(array $input): string
{
$username = $input['username'] ?? null;
if (is_string($username) && filter_var($username, FILTER_VALIDATE_EMAIL)) {
return $this->context->canAccessMailbox($username) ? $username : throw new AccessDenied();
}
return $this->context->isUser() ? (string)$this->context->username : throw new UserError('no_user_defined');
}
/**
* Returns the password to store. XOAUTH2 sources carry no password (the runner uses an access token);
* an authorization_code source needs the per-user token of (source, owner, remote user) when it is new.
*
* @throws UserError imapsync_source_oauth_connect_required, password_empty
*/
private function checkAuth(Source $source, Job $job, string $password, bool $tupleChanged): string
{
if (!$source->isXOAuth2()) {
return ($password !== '') ? $password : throw new UserError('password_empty');
}
if ($source->usesAuthorizationCode() && $tupleChanged && !$this->tokens->exists($job->sourceId, $job->user2, $job->user1)) {
throw new UserError('imapsync_source_oauth_connect_required');
}
return '';
}
}
@@ -1,321 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Service;
use Exception;
use Mailcow\Core\Input;
use Mailcow\Core\UserError;
use Mailcow\Imapsync\Http\OAuthClient;
use Mailcow\Imapsync\Http\OAuthException;
use Mailcow\Imapsync\Model\OAuthToken;
use Mailcow\Imapsync\Model\Source;
use Mailcow\Imapsync\Repository\SourceRepository;
use Mailcow\Imapsync\Repository\TokenRepository;
use PDOException;
/**
* OAuth2 for XOAUTH2 sources: their OAuth settings, token requests for every grant type, the browser sign-in
* (authorization_code) and token refresh. Needs no Context, so the refresh cron can use it; callers check
* permissions first.
*/
class OAuthService
{
/** Tokens expiring within this many seconds are refreshed */
private const REFRESH_AHEAD = 300;
/** A token whose last refresh failed is retried after this many seconds */
private const RETRY_AFTER = 900;
/** Per-user tokens that no job uses are kept this long (time to save the job after signing in) */
private const UNUSED_TOKEN_GRACE = 86400;
private string $hostname;
public function __construct(private SourceRepository $sources, private TokenRepository $tokens, private OAuthClient $client,
string $hostname)
{
$this->hostname = strtolower(trim($hostname));
}
/**
* Redirect URI to register at the provider. Only MAILCOW_HOSTNAME is supported: the popup callback
* needs the session cookie and opener origin of the UI, so the UI must be used on that host.
*/
public function redirectUri(): string
{
return ($this->hostname === '') ? '' : 'https://' . $this->hostname . '/syncjob-oauth';
}
/**
* Sets the OAuth settings of a source from request attributes: cleared for password sources, read and
* validated for XOAUTH2 (incl. the endpoint policy C5). Missing attributes keep the value of $source,
* an empty client secret keeps the stored one. Permission checks are the caller's job.
*
* @throws UserError
*/
public function applySettings(Source $source, array $input): void
{
if (!$source->isXOAuth2()) {
$source->oauthFlow = Source::FLOW_CLIENT_CREDENTIALS;
$source->tokenEndpoint = $source->authorizeEndpoint = $source->userinfoEndpoint = null;
$source->clientId = $source->clientSecret = $source->oauthScope = $source->extraParams = null;
return;
}
$source->oauthFlow = (($input['oauth_flow'] ?? $source->oauthFlow) === Source::FLOW_AUTHORIZATION_CODE)
? Source::FLOW_AUTHORIZATION_CODE : Source::FLOW_CLIENT_CREDENTIALS;
$source->tokenEndpoint = $this->oauthText($input, 'oauth_token_endpoint', $source->tokenEndpoint);
$authorizeEndpoint = $this->oauthText($input, 'oauth_authorize_endpoint', $source->authorizeEndpoint);
$userinfoEndpoint = $this->oauthText($input, 'oauth_userinfo_endpoint', $source->userinfoEndpoint);
$source->clientId = $this->oauthText($input, 'oauth_client_id', $source->clientId);
$source->oauthScope = $this->oauthText($input, 'oauth_scope', $source->oauthScope);
$secret = $input['oauth_client_secret'] ?? '';
if (!is_scalar($secret)) {
throw new UserError('imapsync_source_oauth_invalid');
}
$source->clientSecret = ((string)$secret !== '') ? (string)$secret : (string)$source->clientSecret;
$source->extraParams = $this->extraParams(array_key_exists('oauth_extra_params', $input) ? $input['oauth_extra_params'] : $source->extraParams);
if ($source->tokenEndpoint === '' || $source->clientId === '' || $source->clientSecret === '' || $source->oauthScope === '') {
throw new UserError('imapsync_source_oauth_required');
}
if (mb_strlen($source->clientId) > 500 || mb_strlen($source->clientSecret) > 500 || mb_strlen($source->oauthScope) > 500) {
throw new UserError('imapsync_source_oauth_invalid');
}
// C5, checked again before every request. The authorize endpoint is only opened in the browser,
// but must not point users to internal hosts either.
$endpoints = [$source->tokenEndpoint];
if ($source->usesAuthorizationCode()) {
$endpoints[] = $authorizeEndpoint;
}
if ($userinfoEndpoint !== '') {
$endpoints[] = $userinfoEndpoint;
}
foreach ($endpoints as $endpoint) {
if (!$this->endpointAllowed($endpoint, $source->createdBy)) {
throw new UserError('imapsync_source_oauth_endpoint_invalid');
}
}
$source->authorizeEndpoint = ($authorizeEndpoint === '') ? null : $authorizeEndpoint;
$source->userinfoEndpoint = ($userinfoEndpoint === '') ? null : $userinfoEndpoint;
}
/** Provider consent URL for the browser sign-in, or null when the authorize endpoint is not allowed */
public function authorizeUrl(Source $source, string $state): ?string
{
$endpoint = (string)$source->authorizeEndpoint;
if (!$this->endpointAllowed($endpoint, $source->createdBy)) {
return null;
}
return $endpoint . (str_contains($endpoint, '?') ? '&' : '?') . http_build_query(['client_id' => $source->clientId,
'response_type' => 'code', 'redirect_uri' => $this->redirectUri(), 'response_mode' => 'query',
'scope' => $source->oauthScope, 'state' => $state]);
}
/**
* Finishes the browser sign-in: exchanges the code, finds the remote identity and stores the token
* for the local mailbox $owner (C1). Returns the remote identity (the job's user1).
*
* @throws UserError token_failed (args: detail for the server log) or no_identity
*/
public function connectUser(Source $source, string $owner, string $code): string
{
try {
$token = $this->requestToken($source, ['grant_type' => 'authorization_code', 'code' => $code, 'redirect_uri' => $this->redirectUri()]);
} catch (OAuthException $e) {
throw new UserError('token_failed', ['token exchange failed for source ' . $source->id . ': ' . $e->getMessage()]);
}
$identity = $this->identity($source, $token);
if ($identity === null) {
throw new UserError('no_identity');
}
try {
$this->tokens->saveUserToken(OAuthToken::fromRow(['source_id' => $source->id, 'owner' => $owner, 'username' => $identity,
'access_token' => $token['access_token'], 'refresh_token' => $token['refresh_token'] ?? '',
'token_expires' => time() + (int)$token['expires_in']]));
} catch (PDOException $e) {
throw new UserError('token_failed', ['storing token failed for source ' . $source->id . ': ' . $e->getMessage()]);
}
return $identity;
}
/** Fetches and stores a new shared token of a client_credentials source (or stores the error); returns the error or null */
public function refreshSourceToken(Source $source): ?string
{
if (!$source->usesClientCredentials()) {
return 'not refreshable';
}
try {
$token = $this->requestToken($source, ['grant_type' => 'client_credentials']);
} catch (OAuthException $e) {
$this->tokens->storeSourceError((int)$source->id, $e->getMessage());
return $e->getMessage();
}
$this->tokens->storeSourceToken((int)$source->id, $token['access_token'], time() + (int)$token['expires_in']);
return null;
}
/** Refreshes and stores a per-user token via its refresh token (or stores the error); returns the error or null */
public function refreshUserToken(Source $source, OAuthToken $token): ?string
{
if (!$source->usesAuthorizationCode() || (string)$token->refreshToken === '') {
return 'not refreshable';
}
try {
$new = $this->requestToken($source, ['grant_type' => 'refresh_token', 'refresh_token' => $token->refreshToken]);
} catch (OAuthException $e) {
$this->tokens->storeUserError($token, $e->getMessage());
return $e->getMessage();
}
$token->accessToken = $new['access_token'];
// Some providers omit a new refresh token on refresh: keep the existing one then
$token->refreshToken = empty($new['refresh_token']) ? $token->refreshToken : $new['refresh_token'];
$token->tokenExpires = time() + (int)$new['expires_in'];
$this->tokens->storeUserRefresh($token);
return null;
}
/**
* Cron run: refreshes due source tokens, drops unused per-user tokens, refreshes due per-user tokens.
*
* @return array<int, array{priority: string, message: string}> log entries for CRON_LOG
*/
public function refreshDue(): array
{
$log = [];
foreach ($this->tokens->refreshableSources() as $source) {
if ($this->isDue($source->tokenExpires, $source->lastRefreshError, $source->refreshAttemptAge)) {
try {
$error = $this->refreshSourceToken($source);
} catch (Exception $e) {
$error = get_class($e);
}
$label = "source '" . $source->name . "' (id=" . $source->id . ")";
$log[] = $this->logEntry($error, "Refreshed token for " . $label, "Refresh failed for " . $label);
}
}
$removed = $this->tokens->deleteUnusedOlderThan(self::UNUSED_TOKEN_GRACE);
if ($removed > 0) {
$log[] = ['priority' => 'info', 'message' => "Removed " . $removed . " unused user token(s)"];
}
foreach ($this->tokens->refreshableUserTokens() as $token) {
if ($this->isDue($token->tokenExpires, $token->lastRefreshError, $token->refreshAttemptAge)) {
$source = null;
try {
$source = $this->sources->find($token->sourceId);
$error = ($source === null) ? 'not refreshable' : $this->refreshUserToken($source, $token);
} catch (Exception $e) {
$error = get_class($e);
}
$label = "user token '" . $token->username . "' (mailbox " . $token->owner . ") on source '"
. ($source->name ?? '') . "' (id=" . $token->sourceId . ")";
$log[] = $this->logEntry($error, "Refreshed " . $label, "Refresh failed for " . $label);
}
}
return $log;
}
/** Due when the token expires soon (or is missing) and the last failed attempt is old enough */
private function isDue(?int $expires, ?string $lastError, ?int $attemptAge): bool
{
return ($expires === null || $expires < time() + self::REFRESH_AHEAD)
&& ($lastError === null || $attemptAge === null || $attemptAge > self::RETRY_AFTER);
}
private function logEntry(?string $error, string $success, string $failure): array
{
return ($error === null) ? ['priority' => 'info', 'message' => $success]
: ['priority' => 'warning', 'message' => $failure . ": " . $error];
}
/**
* Endpoint policy (C5). Endpoints of sources not created by an admin must resolve to public addresses;
* admin sources have no address restriction (API key sessions run as admin under the reserved name 'API').
*/
private function endpointAllowed(string $url, string $createdBy): bool
{
return $this->client->isAllowed($url, $this->isStrict($createdBy));
}
private function isStrict(string $createdBy): bool
{
return !($createdBy === 'API' || $this->sources->isSuperAdmin($createdBy));
}
/** One OAuth text attribute from the request, or the stored value when the attribute is missing */
private function oauthText(array $input, string $key, ?string $stored): string
{
$value = array_key_exists($key, $input) ? Input::text($input[$key]) : (string)$stored;
return $value ?? throw new UserError('imapsync_source_oauth_invalid');
}
/**
* Extra token request parameters: a JSON object with scalar values, as string or as array
* (after 011-api.js auto-parsing). Returns the JSON to store, null for none.
*/
private function extraParams(mixed $extra): ?string
{
if ($extra === null || (is_string($extra) && trim($extra) === '')) {
$extra = [];
} elseif (is_string($extra)) {
$extra = json_decode($extra, true);
}
$bad = !is_array($extra);
foreach ((array)$extra as $key => $value) {
$bad = $bad || !is_string($key) || $key === '' || !(is_scalar($value) || $value === null);
}
$json = empty($extra) ? null : json_encode($extra, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
if ($bad || strlen((string)$json) > 4000) {
throw new UserError('imapsync_source_oauth_extra_invalid');
}
return $json;
}
/**
* Token endpoint request for every grant type. Stored extra params never override the client and grant fields.
*
* @throws OAuthException
*/
private function requestToken(Source $source, array $grant): array
{
$extra = json_decode((string)$source->extraParams, true);
$post = array_merge(is_array($extra) ? $extra : [],
['client_id' => $source->clientId, 'client_secret' => $source->clientSecret, 'scope' => $source->oauthScope], $grant);
$token = $this->client->request((string)$source->tokenEndpoint, $this->isStrict($source->createdBy), $post);
if (empty($token['access_token']) || !is_string($token['access_token'])) {
throw new OAuthException('malformed token response');
}
if (!isset($token['expires_in']) || !is_numeric($token['expires_in'])) {
$token['expires_in'] = 3600;
}
if (isset($token['refresh_token']) && !is_string($token['refresh_token'])) {
unset($token['refresh_token']);
}
return $token;
}
/** Remote identity: id_token claims (received directly from the token endpoint over TLS), else the userinfo endpoint */
private function identity(Source $source, array $token): ?string
{
$parts = explode('.', is_string($token['id_token'] ?? null) ? $token['id_token'] : '');
$email = (count($parts) === 3) ? $this->emailClaim(json_decode((string)base64_decode(strtr($parts[1], '-_', '+/')), true)) : null;
if ($email !== null || empty($source->userinfoEndpoint)) {
return $email;
}
try {
return $this->emailClaim($this->client->request($source->userinfoEndpoint, $this->isStrict($source->createdBy), null,
['Authorization: Bearer ' . $token['access_token']]));
} catch (OAuthException $e) {
return null;
}
}
private function emailClaim(mixed $claims): ?string
{
foreach (['email', 'preferred_username', 'upn', 'unique_name'] as $key) {
if (is_array($claims) && !empty($claims[$key]) && is_string($claims[$key]) && filter_var($claims[$key], FILTER_VALIDATE_EMAIL)) {
return strtolower($claims[$key]);
}
}
return null;
}
}
@@ -1,347 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Imapsync\Service;
use Mailcow\Core\AccessDenied;
use Mailcow\Core\Context;
use Mailcow\Core\Input;
use Mailcow\Core\UserError;
use Mailcow\Imapsync\Model\Source;
use Mailcow\Imapsync\Repository\SourceRepository;
use Mailcow\Imapsync\Repository\TokenRepository;
/**
* Use cases and rules for sync sources.
*
* Read: admin all; domain admin own + 'all' + sources targeting his domains/mailboxes + sources created by
* his mailboxes; user own + 'all' + sources targeting him or his domain (see SourceRepository).
* Edit: admin all; creator own; domain admin sources created by his mailboxes.
* XOAUTH2 sources are created and edited by admins and domain admins only (users may use them).
* Users and domain admins need the `syncjobs` ACL for everything.
*/
class SourceService
{
private const ENCRYPTIONS = ['TLS', 'SSL', 'PLAIN'];
private const AUTH_TYPES = ['PLAIN', 'LOGIN', 'CRAM-MD5', 'XOAUTH2'];
private const SCOPES = ['all', 'domain', 'user'];
public function __construct(private SourceRepository $repo, private TokenRepository $tokens, private OAuthService $oauth,
private Context $context)
{
}
/** @throws AccessDenied without the syncjobs ACL */
public function requireAcl(): void
{
if (!$this->context->hasAcl('syncjobs')) {
throw new AccessDenied();
}
}
/** Add/edit: a logged-in admin, domain admin or user with the syncjobs ACL */
public function requireEditor(): void
{
if (!($this->context->isAdmin() || $this->context->isDomainAdmin() || $this->context->isUser())) {
throw new AccessDenied();
}
$this->requireAcl();
}
/**
* The source when the caller may see it (incl. secrets: never pass the object to the UI, use toArray()).
* Users and domain admins without the syncjobs ACL see no sources.
*/
public function get(int $id): ?Source
{
return $this->canRead() ? $this->repo->findVisible($id, $this->context) : null;
}
/** @return int[] ids of the sources the caller may see, ordered by name */
public function visibleIds(): array
{
return $this->canRead() ? $this->repo->visibleIds($this->context) : [];
}
public function canEdit(Source $source): bool
{
if ($this->context->accessAll || $this->context->isAdmin() || $source->createdBy === $this->context->username) {
return true;
}
// A domain admin may manage sources created by mailboxes of his domains
return $this->context->isDomainAdmin() && $source->createdBy !== '' && $this->context->canAccessMailbox($source->createdBy);
}
/** API representation for the caller (can_edit, extra params only for editors, redirect URI) */
public function toArray(Source $source): array
{
return $source->toArray($this->canEdit($source), $this->oauth->redirectUri());
}
/**
* Creates a source from request attributes (C9 names; host1/port1/enc1 are accepted as aliases).
*
* @throws UserError
*/
public function create(array $input): Source
{
$this->requireEditor();
$source = new Source();
$source->createdBy = (string)$this->context->username;
$this->applyBasics($source, $input);
$this->applyOAuth($source, $input);
// Scope and targets are validated before anything is written
$this->applyScope($source, $input['scope'] ?? 'user', $input['domains'] ?? [], $input['users'] ?? []);
if ($this->repo->nameExists($source->name, $source->createdBy)) {
throw new UserError('object_exists', [htmlspecialchars($source->name)]);
}
$this->repo->insert($source);
return $source;
}
/**
* Updates a source; attributes missing in $input keep their value.
*
* @throws UserError
*/
public function update(int $id, array $input): Source
{
$this->requireEditor();
$current = $this->editable($id);
$source = clone $current;
$this->applyBasics($source, $input);
if ($source->name !== $current->name && $this->repo->nameExists($source->name, $source->createdBy, $id)) {
throw new UserError('object_exists', [htmlspecialchars($source->name)]);
}
$this->applyOAuth($source, $input);
// Targets only change when the request carries `scope`; client_credentials sources are always private
$writeTargets = isset($input['scope']) || $source->usesClientCredentials();
if ($writeTargets) {
$this->applyScope($source, $input['scope'] ?? $current->scope, $input['domains'] ?? [], $input['users'] ?? []);
}
$this->repo->update($source, $writeTargets, self::connection($current) !== self::connection($source));
return $source;
}
/**
* Deletes an unused source and returns it.
*
* @throws UserError access_denied, imapsync_source_in_use
*/
public function delete(int $id): Source
{
$this->requireAcl();
$source = $this->editable($id);
// FK ON DELETE RESTRICT prevents removal while sync jobs reference it
if ($this->repo->isInUse($id)) {
throw new UserError('imapsync_source_in_use', [$source->name]);
}
$this->repo->delete($id);
return $source;
}
/**
* Fetches a new shared token for a client_credentials source now.
*
* @throws UserError access_denied, imapsync_source_refresh_unsupported, imapsync_source_token_refresh_failed
*/
public function refreshToken(int $id): Source
{
$this->requireAcl();
$source = $this->editable($id);
if (!$source->usesClientCredentials()) {
throw new UserError('imapsync_source_refresh_unsupported');
}
$error = $this->oauth->refreshSourceToken($source);
if ($error !== null) {
throw new UserError('imapsync_source_token_refresh_failed', [$source->name . ' (' . $error . ')']);
}
return $source;
}
/** A visible, active authorization_code source the caller may sign in to (OAuth popup), else null */
public function findConnectable(int $id): ?Source
{
$source = $this->get($id);
return ($source !== null && $source->active === 1 && $source->usesAuthorizationCode()) ? $source : null;
}
/**
* Account rename (mailbox, admin, domain admin): sources, user targets and token owners follow.
* Leftover sources of an earlier account named $new are released first (see releaseSourcesOf()).
* Runs on the caller's PDO; a PDOException is not caught, so a caller transaction can roll back.
*/
public function onAccountRenamed(string $old, string $new): void
{
if ($old === '' || $old === $new) {
return;
}
$this->releaseSourcesOf($new);
$this->repo->renameAccount($old, $new);
$this->tokens->renameOwner($old, $new);
}
/** Account deleted (after its jobs were deleted): sources released, tokens and user targets removed */
public function onAccountDeleted(string $old): void
{
if ($old === '') {
return;
}
$this->releaseSourcesOf($old);
$this->tokens->deleteByOwner($old);
$this->repo->deleteUserTarget($old);
}
private function canRead(): bool
{
return $this->context->hasAcl('syncjobs') || $this->context->isAdmin();
}
/** @throws AccessDenied when the caller cannot see or edit the source */
private function editable(int $id): Source
{
$source = $this->get($id);
if ($source === null || !$this->canEdit($source)) {
throw new AccessDenied();
}
return $source;
}
/**
* Sources of an account are deleted when unused, otherwise handed over to the admins
* (created_by ''), so a re-created account inherits nothing.
*/
private function releaseSourcesOf(string $username): void
{
foreach ($this->repo->listCreatedBy($username) as $source) {
$id = (int)$source->id;
if (!$this->repo->isInUse($id)) {
// Explicit child deletes: the mailbox rename runs with FOREIGN_KEY_CHECKS = 0 (no cascade)
$this->repo->deleteWithChildren($id);
continue;
}
// Keep (created_by, name) unique when handing over
$name = $this->repo->nameExists($source->name, '') ? mb_substr($source->name, 0, 80) . ' (#' . $id . ')' : $source->name;
$this->repo->handOver($id, $name);
}
}
/**
* Name, description, connection and active from the request; missing attributes keep the value of $source.
* On edit, stored host/port/enc that do not change are not validated again, so migrated legacy sources
* stay editable (e.g. host 'imap_server.lan'). A host that only differs in case keeps the stored spelling.
*
* @throws UserError
*/
private function applyBasics(Source $source, array $input): void
{
$stored = ($source->id !== null);
$name = isset($input['name']) ? Input::text($input['name']) : $source->name;
$description = isset($input['description']) ? Input::text($input['description']) : $source->description;
$host = $input['host'] ?? $input['host1'] ?? null;
$host = ($host === null) ? $source->host : Input::text($host);
if ($host !== null) {
$host = (strtolower($host) === strtolower($source->host)) ? $source->host : strtolower($host);
}
$port = $input['port'] ?? $input['port1'] ?? $source->port;
$port = is_scalar($port) ? (int)$port : 0;
$enc = $input['enc'] ?? $input['enc1'] ?? $source->enc;
$authType = $input['auth_type'] ?? $source->authType;
if ($name === null || $name === '' || mb_strlen($name) > 100) {
throw new UserError('imapsync_source_name_invalid');
}
if ($description === null || mb_strlen($description) > 255) {
throw new UserError('imapsync_source_description_invalid');
}
if (!($stored && $host === $source->host) && !$this->isValidHost($host)) {
throw new UserError('imapsync_source_host_invalid');
}
if (!($stored && $port === $source->port) && ($port < 1 || $port > 65535)) {
throw new UserError('imapsync_source_port_invalid');
}
if (!($stored && $enc === $source->enc) && !in_array($enc, self::ENCRYPTIONS, true)) {
throw new UserError('imapsync_source_enc_invalid');
}
if (!in_array($authType, self::AUTH_TYPES, true)) {
throw new UserError('imapsync_source_auth_invalid');
}
$source->name = $name;
$source->description = $description;
$source->host = $host;
$source->port = $port;
$source->enc = $enc;
$source->authType = $authType;
$source->active = isset($input['active']) ? (int)$input['active'] : $source->active;
}
private function isValidHost(?string $host): bool
{
return $host !== null && $host !== '' && strlen($host) <= 253
&& (filter_var($host, FILTER_VALIDATE_IP) || filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME));
}
/** @throws UserError */
private function applyOAuth(Source $source, array $input): void
{
// XOAUTH2 sources are managed by admins and domain admins only
if ($source->isXOAuth2() && $this->context->isUser()) {
throw new AccessDenied();
}
$this->oauth->applySettings($source, $input);
}
/**
* Validates scope and targets for the caller and sets them; nothing is written here.
* Users' sources and client_credentials sources (one app-wide token that reaches every mailbox) are always private.
*
* @throws UserError access_denied, imapsync_source_scope_empty
*/
private function applyScope(Source $source, mixed $scope, mixed $domains, mixed $users): void
{
if ($source->usesClientCredentials() || $this->context->isUser()) {
[$source->scope, $source->domains, $source->users] = ['user', [], []];
return;
}
// Only admins may create globally visible sources
if (!is_string($scope) || !in_array($scope, self::SCOPES, true) || ($scope === 'all' && !$this->context->isAdmin())) {
throw new AccessDenied();
}
$domains = ($scope === 'domain') ? $this->cleanList($domains) : [];
$users = ($scope === 'user') ? $this->cleanList($users) : [];
if ($scope === 'domain' && empty($domains)) {
throw new UserError('imapsync_source_scope_empty');
}
foreach ($domains as $domain) {
if (!$this->context->canAccessDomain($domain)) {
throw new AccessDenied();
}
}
foreach ($users as $user) {
if (!$this->context->canAccessMailbox($user)) {
throw new AccessDenied();
}
}
[$source->scope, $source->domains, $source->users] = [$scope, $domains, $users];
}
/** @return string[] trimmed, lowercase, unique, non-empty scalar values */
private function cleanList(mixed $list): array
{
$clean = [];
foreach ((array)$list as $value) {
if (is_scalar($value) && trim((string)$value) !== '') {
$clean[] = strtolower(trim((string)$value));
}
}
return array_values(array_unique($clean));
}
/** Tokens never follow a source to another OAuth client or IMAP server, so these values reset them */
private static function connection(Source $source): array
{
return [(string)$source->authType, $source->host, $source->port, (string)$source->enc, $source->oauthFlow,
(string)$source->tokenEndpoint, (string)$source->clientId];
}
}
-97
View File
@@ -1,97 +0,0 @@
# mailcow PHP classes (`Mailcow\`)
New backend code of the mailcow UI/API lives here as plain PHP classes, one directory per module.
It replaces the procedural `data/web/inc/functions.*.inc.php` files step by step. There is no framework,
no DI container and no ORM: just small classes, constructor injection and PDO.
The directory sits below `inc/lib/`, which nginx never serves. Classes are loaded by Composer's PSR-4
autoloader (`"Mailcow\\": "mailcow/"` in `inc/lib/composer.json`), which every entry point already
includes (`prerequisites.inc.php`, crons, `mailcowauth.php`).
## Layout
```
Core/ shared by all modules
Context.php who is calling: role, username, ACL, access-all; fromSession(), system()
UserError.php expected error with a lang key (+ args), shown as a danger message
AccessDenied.php UserError 'access_denied'
ApiResponse.php collects messages, writes $_SESSION['return'] (the only class that does)
Input.php helpers for loosely typed request values
Repository.php base class of all repositories: PDO, query(), insertRow(), updateRow(), transaction()
<Module>/
<Module>Module.php composition root: create(PDO, Context, array $config) wires everything;
fromGlobals() is the legacy entry point for procedural callers
Model/ plain typed objects (fromRow(), toArray() for the API shape), no DB
Repository/ SQL only (prepared statements); takes and returns models
Service/ use cases, business rules, permissions (Context), transactions via repositories
Http/ outbound I/O (e.g. OAuth requests)
Controller/ request in, messages out: adapter for json_api.php and pages
```
Example: `Imapsync/` (sync jobs, sync sources, OAuth). Use it as the template for new modules.
## Layers and rules
- **Model:** typed public properties, `fromRow()` from a DB row, `toArray()` for the API format (keys and
order are the API contract). Secrets stay in the model but never in `toArray()`. No getters/setters.
- **Repository:** extends `Core\Repository`; the only place with SQL (prepared statements via `query()`).
No permission decisions (a visibility filter that is pure SQL may take the `Context`). A method that writes
several tables wraps them in `transaction()`, which joins a transaction the caller already runs.
- **Service:** one use case per public method, working on single objects. Checks permissions with the
`Context` and throws `UserError` / `AccessDenied` with a lang key. No `$_SESSION`, no `global`, no output.
When add and edit share their rules, use one private routine for both (e.g. `JobService::save()`,
`SourceService::applyBasics()`): missing attributes keep the current value, a new object starts with defaults.
- **Controller:** normalises request input (batch ids, attribute merging), loops over batch ids, calls
services, turns `UserError` into messages via `ApiResponse` and returns what `json_api.php` passes to
`process_*_return()`. The API format (routes, JSON, message keys and order) stays as it was. Each write
method is a few lines on top of two private helpers: `handle()` (log entry, error mapping, flush) and
`batch()` (one message per id).
- **View:** Twig templates, unchanged.
## Conventions
- `declare(strict_types=1);` in every file; typed properties, parameters and return types; constructor
property promotion; `public readonly` properties instead of getters for values that never change.
- Class files contain only declarations (no code at file level), one class per file.
- Constructor injection. Global state (`$pdo`, `$_SESSION`, config arrays, `getenv()`) is only read at the
entry points and passed to `<Module>Module::create()`. Procedural legacy callers (`json_api.php`, pages,
`functions.*.inc.php`) use `<Module>Module::fromGlobals(?Context $context = null)`, the one documented
place in the module that reads the globals, instead of repeating the config lines; entry points that build
their own state (crons, CLI) call `create()` directly, e.g. with `Context::system()`.
`ApiResponse::flush()` is the one class that writes `$_SESSION['return']`.
- No interfaces until there are two implementations; no closures as injected dependencies (local callbacks
such as `transaction(fn ...)` or the controller's `handle()` are fine); small methods.
- Docblocks only where they add information (rules, API contract, non-obvious behaviour), not to repeat
the signature.
- Classes are not `final`, so PHPUnit can mock them. I/O that tests must replace sits in protected methods
(see `Imapsync/Http/OAuthClient::send()`).
- Messages: services throw, controllers report. The `msg` is a lang key of the matching section
(`success` / `danger`), optionally with arguments: `new UserError('object_exists', [$name])`.
- Logs: the `log` part of a message is `[<legacy function>, <action>, <type>, <data>]` as before; request
keys holding secrets are masked by `ApiResponse` (configure them in the module).
- A database error (`PDOException`) stops the call with one `mysql_error` message, like the legacy code.
- Naming: `Mailcow\<Module>\{Model,Repository,Service,Http,Controller}\<Class>`.
## Converting a legacy `functions.X.inc.php`
1. Capture the current API behaviour (E2E runs, raw JSON responses, DB state).
2. Build the module as above: models, repositories, services, controller, `<Module>Module::create()`.
3. Switch the callers (`json_api.php`, pages, crons, other function files) to the module.
4. Delete the function file and its `require` lines.
5. Compare the API output with step 1; the format must stay the same.
Large files (e.g. `functions.mailbox.inc.php`) are converted one area at a time (aliases, domains, ...),
each as its own module. Until `functions.inc.php` is converted, `Context::canAccessMailbox()` /
`canAccessDomain()` call the legacy `hasMailboxObjectAccess()` / `hasDomainAccess()`.
## Development
- Unit tests: `tests/php/<Module>/...` (dev-only, never deployed), run with
`helper-scripts/dev_tests/run_php_unit_tests.sh`. Services are tested with mocked repositories;
repositories are covered by the E2E suites (MariaDB-specific SQL).
- Autoloader: after adding the PSR-4 mapping nothing needs regenerating for new classes. If you ever run
`composer dump-autoload` (inside the php-fpm container, `-w /web/inc/lib`), never use `-o`, `-a` or
`--apcu`, and commit only the changed `autoload_*.php` files.
- No dev packages in `inc/lib/composer.json`.
- opcache runs with `validate_timestamps=0`: restart php-fpm after changing PHP files
(`docker compose restart php-fpm-mailcow`).
-1
View File
@@ -25,7 +25,6 @@ return array(
'PHPMailer\\PHPMailer\\' => array($vendorDir . '/phpmailer/phpmailer/src'),
'MatthiasMullie\\PathConverter\\' => array($vendorDir . '/matthiasmullie/path-converter/src'),
'MatthiasMullie\\Minify\\' => array($vendorDir . '/matthiasmullie/minify/src'),
'Mailcow\\' => array($baseDir . '/mailcow'),
'League\\OAuth2\\Client\\' => array($vendorDir . '/league/oauth2-client/src'),
'LdapRecord\\' => array($vendorDir . '/directorytree/ldaprecord/src'),
'Illuminate\\Support\\' => array($vendorDir . '/illuminate/collections', $vendorDir . '/illuminate/macroable', $vendorDir . '/illuminate/conditionable'),
-5
View File
@@ -58,7 +58,6 @@ class ComposerStaticInit873464e4bd965a3168f133248b1b218b
array (
'MatthiasMullie\\PathConverter\\' => 29,
'MatthiasMullie\\Minify\\' => 22,
'Mailcow\\' => 8,
),
'L' =>
array (
@@ -178,10 +177,6 @@ class ComposerStaticInit873464e4bd965a3168f133248b1b218b
array (
0 => __DIR__ . '/..' . '/matthiasmullie/minify/src',
),
'Mailcow\\' =>
array (
0 => __DIR__ . '/../..' . '/mailcow',
),
'League\\OAuth2\\Client\\' =>
array (
0 => __DIR__ . '/..' . '/league/oauth2-client/src',
+1 -7
View File
@@ -319,16 +319,10 @@ $css_minifier = new CSSminifierExtended();
$css_dir = array_diff(scandir('/web/css/build'), array('..', '.'));
// get customized ui data
$UI_TEXTS = customize('get', 'ui_texts');
// theme selected in admin UI overrides $UI_THEME
if ($ui_theme_selected = customize('get', 'ui_theme')) {
$UI_THEME = $ui_theme_selected;
}
// minify bootstrap theme
if (file_exists('/web/css/themes/'.$UI_THEME.'/bootstrap.css'))
$css_minifier->add('/web/css/themes/'.$UI_THEME.'/bootstrap.css');
elseif (file_exists('/web/css/themes/'.$UI_THEME.'-bootstrap.css'))
if (file_exists('/web/css/themes/'.$UI_THEME.'-bootstrap.css'))
$css_minifier->add('/web/css/themes/'.$UI_THEME.'-bootstrap.css');
else
$css_minifier->add('/web/css/themes/lumen-bootstrap.css');
+127 -96
View File
@@ -121,8 +121,6 @@ $AVAILABLE_LANGUAGES = array(
// default theme is lumen
// additional themes can be found here: https://bootswatch.com/
// copy them to data/web/css/themes/{THEME-NAME}-bootstrap.css
// or use a theme folder data/web/css/themes/{THEME-NAME}/ (theme.css, optional dark.css, bootstrap.css, theme.json)
// theme folders can be selected in System > Configuration > Customize, which overrides $UI_THEME
$UI_THEME = "lumen";
// Show DKIM private keys - false by default
@@ -287,98 +285,131 @@ $RSPAMD_MAPS = array(
);
// 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 = 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'
)
);
$IMAPSYNC_OPTIONS = $IMAPSYNC_OPTIONS_DEFAULT;
+3 -3
View File
@@ -1,7 +1,7 @@
/*!
* Bootstrap v5.3.8 (https://getbootstrap.com/)
* Copyright 2011-2025 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
* Bootstrap v5.1.3 (https://getbootstrap.com/)
* Copyright 2011-2021 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
*/
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e()}(this,function(){"use strict";const t=new Map,e={set(e,i,n){t.has(e)||t.set(e,new Map);const s=t.get(e);s.has(i)||0===s.size?s.set(i,n):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(s.keys())[0]}.`)},get:(e,i)=>t.has(e)&&t.get(e).get(i)||null,remove(e,i){if(!t.has(e))return;const n=t.get(e);n.delete(i),0===n.size&&t.delete(e)}},i="transitionend",n=t=>(t&&window.CSS&&window.CSS.escape&&(t=t.replace(/#([^\s"#']+)/g,(t,e)=>`#${CSS.escape(e)}`)),t),s=t=>null==t?`${t}`:Object.prototype.toString.call(t).match(/\s([a-z]+)/i)[1].toLowerCase(),o=t=>{t.dispatchEvent(new Event(i))},r=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),a=t=>r(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(n(t)):null,l=t=>{if(!r(t)||0===t.getClientRects().length)return!1;const e="visible"===getComputedStyle(t).getPropertyValue("visibility"),i=t.closest("details:not([open])");if(!i)return e;if(i!==t){const e=t.closest("summary");if(e&&e.parentNode!==i)return!1;if(null===e)return!1}return e},c=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),h=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?h(t.parentNode):null},d=()=>{},u=t=>{t.offsetHeight},f=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,p=[],m=()=>"rtl"===document.documentElement.dir,g=t=>{var e;e=()=>{const e=f();if(e){const i=t.NAME,n=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=n,t.jQueryInterface)}},"loading"===document.readyState?(p.length||document.addEventListener("DOMContentLoaded",()=>{for(const t of p)t()}),p.push(e)):e()},_=(t,e=[],i=t)=>"function"==typeof t?t.call(...e):i,b=(t,e,n=!0)=>{if(!n)return void _(t);const s=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const n=Number.parseFloat(e),s=Number.parseFloat(i);return n||s?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(e)+5;let r=!1;const a=({target:n})=>{n===e&&(r=!0,e.removeEventListener(i,a),_(t))};e.addEventListener(i,a),setTimeout(()=>{r||o(e)},s)},v=(t,e,i,n)=>{const s=t.length;let o=t.indexOf(e);return-1===o?!i&&n?t[s-1]:t[0]:(o+=i?1:-1,n&&(o=(o+s)%s),t[Math.max(0,Math.min(o,s-1))])},y=/[^.]*(?=\..*)\.|.*/,w=/\..*/,A=/::\d+$/,E={};let T=1;const C={mouseenter:"mouseover",mouseleave:"mouseout"},O=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function x(t,e){return e&&`${e}::${T++}`||t.uidEvent||T++}function k(t){const e=x(t);return t.uidEvent=e,E[e]=E[e]||{},E[e]}function L(t,e,i=null){return Object.values(t).find(t=>t.callable===e&&t.delegationSelector===i)}function S(t,e,i){const n="string"==typeof e,s=n?i:e||i;let o=N(t);return O.has(o)||(o=t),[n,s,o]}function D(t,e,i,n,s){if("string"!=typeof e||!t)return;let[o,r,a]=S(e,i,n);if(e in C){const t=t=>function(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};r=t(r)}const l=k(t),c=l[a]||(l[a]={}),h=L(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&s);const d=x(r,e.replace(y,"")),u=o?function(t,e,i){return function n(s){const o=t.querySelectorAll(e);for(let{target:r}=s;r&&r!==this;r=r.parentNode)for(const a of o)if(a===r)return j(s,{delegateTarget:r}),n.oneOff&&P.off(t,s.type,e,i),i.apply(r,[s])}}(t,i,r):function(t,e){return function i(n){return j(n,{delegateTarget:t}),i.oneOff&&P.off(t,n.type,e),e.apply(t,[n])}}(t,r);u.delegationSelector=o?i:null,u.callable=r,u.oneOff=s,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function $(t,e,i,n,s){const o=L(e[i],n,s);o&&(t.removeEventListener(i,o,Boolean(s)),delete e[i][o.uidEvent])}function I(t,e,i,n){const s=e[i]||{};for(const[o,r]of Object.entries(s))o.includes(n)&&$(t,e,i,r.callable,r.delegationSelector)}function N(t){return t=t.replace(w,""),C[t]||t}const P={on(t,e,i,n){D(t,e,i,n,!1)},one(t,e,i,n){D(t,e,i,n,!0)},off(t,e,i,n){if("string"!=typeof e||!t)return;const[s,o,r]=S(e,i,n),a=r!==e,l=k(t),c=l[rLine truncated
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e()}(this,(function(){"use strict";const t="transitionend",e=t=>{let e=t.getAttribute("data-bs-target");if(!e||"#"===e){let i=t.getAttribute("href");if(!i||!i.includes("#")&&!i.startsWith("."))return null;i.includes("#")&&!i.startsWith("#")&&(i=`#${i.split("#")[1]}`),e=i&&"#"!==i?i.trim():null}return e},i=t=>{const i=e(t);return i&&document.querySelector(i)?i:null},n=t=>{const i=e(t);return i?document.querySelector(i):null},s=e=>{e.dispatchEvent(new Event(t))},o=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),r=t=>o(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(t):null,a=(t,e,i)=>{Object.keys(i).forEach((n=>{const s=i[n],r=e[n],a=r&&o(r)?"element":null==(l=r)?`${l}`:{}.toString.call(l).match(/\s([a-z]+)/i)[1].toLowerCase();var l;if(!new RegExp(s).test(a))throw new TypeError(`${t.toUpperCase()}: Option "${n}" provided type "${a}" but expected type "${s}".`)}))},l=t=>!(!o(t)||0===t.getClientRects().length)&&"visible"===getComputedStyle(t).getPropertyValue("visibility"),c=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),h=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?h(t.parentNode):null},d=()=>{},u=t=>{t.offsetHeight},f=()=>{const{jQuery:t}=window;return t&&!document.body.hasAttribute("data-bs-no-jquery")?t:null},p=[],m=()=>"rtl"===document.documentElement.dir,g=t=>{var e;e=()=>{const e=f();if(e){const i=t.NAME,n=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=n,t.jQueryInterface)}},"loading"===document.readyState?(p.length||document.addEventListener("DOMContentLoaded",(()=>{p.forEach((t=>t()))})),p.push(e)):e()},_=t=>{"function"==typeof t&&t()},b=(e,i,n=!0)=>{if(!n)return void _(e);const o=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const n=Number.parseFloat(e),s=Number.parseFloat(i);return n||s?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(i)+5;let r=!1;const a=({target:n})=>{n===i&&(r=!0,i.removeEventListener(t,a),_(e))};i.addEventListener(t,a),setTimeout((()=>{r||s(i)}),o)},v=(t,e,i,n)=>{let s=t.indexOf(e);if(-1===s)return t[!i&&n?t.length-1:0];const o=t.length;return s+=i?1:-1,n&&(s=(s+o)%o),t[Math.max(0,Math.min(s,o-1))]},y=/[^.]*(?=\..*)\.|.*/,w=/\..*/,E=/::\d+$/,A={};let T=1;const O={mouseenter:"mouseover",mouseleave:"mouseout"},C=/^(mouseenter|mouseleave)/i,k=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function L(t,e){return e&&`${e}::${T++}`||t.uidEvent||T++}function x(t){const e=L(t);return t.uidEvent=e,A[e]=A[e]||{},A[e]}function D(t,e,i=null){const n=Object.keys(t);for(let s=0,o=n.length;s<o;s++){const o=t[n[s]];if(o.originalHandler===e&&o.delegationSelector===i)return o}return null}function S(t,e,i){const n="string"==typeof e,s=n?i:e;let o=P(t);return k.has(o)||(o=t),[n,s,o]}function N(t,e,i,n,s){if("string"!=typeof e||!t)return;if(i||(i=n,n=null),C.test(e)){const t=t=>function(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};n?n=t(n):i=t(i)}const[o,r,a]=S(e,i,n),l=x(t),c=l[a]||(l[a]={}),h=D(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&s);const d=L(r,e.replace(y,"")),u=o?function(t,e,i){return function n(s){const o=t.querySelectorAll(e);for(let{target:r}=s;r&&r!==this;r=r.parentNode)for(let a=o.length;a--;)if(o[a]===r)return s.delegateTarget=r,n.oneOff&&j.off(t,s.type,e,i),i.apply(r,[s]);return null}}(t,i,n):function(t,e){return function i(n){return n.delegateTarget=t,i.oneOff&&j.off(t,n.type,e),e.apply(t,[n])}}(t,i);u.delegationSelector=o?i:null,u.originalHandler=r,u.oneOff=s,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function I(t,e,i,n,s){const o=D(e[i],n,s);o&&(t.removeEventListener(i,o,Boolean(s)),delete e[i][o.uidEvent])}function P(t){return t=t.replace(w,""),O[t]||t}const j={on(t,e,i,n){N(t,e,i,n,!1)},one(t,e,i,n){N(t,e,i,n,!0)},off(t,e,i,n){if("string"!=typeof e||!t)return;const[s,o,r]=S(e,i,n),a=r!==e,l=x(t),c=e.startsWith(".");if(void 0!==o){if(!Line truncated
//# sourceMappingURL=bootstrap.bundle.min.js.map
+3 -12
View File
@@ -73,8 +73,7 @@ $(document).ready(function() {
}
// form cache
// 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])']});
$('[data-cached-form="true"]').formcache({key: $(this).data('id')});
// tooltips
$(function () {
@@ -114,8 +113,6 @@ $(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) {
@@ -127,14 +124,8 @@ $(document).ready(function() {
var lastTab = localStorage.getItem(key);
if (lastTab) {
$('[data-bs-target="#' + lastTab + '"]').click();
var dropdown = $('[data-bs-target="#' + lastTab + '"]').closest('.dropdown');
dropdown.find('.dropdown-menu').removeClass('show');
dropdown.find('.dropdown-toggle').attr('aria-expanded', 'false');
var tab = $('[id^="' + lastTab + '"]');
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');
$(tab).find('.card-body.collapse:first').collapse('show');
}
});
})();
@@ -356,7 +347,7 @@ $(document).ready(function() {
if ($('#rspamd_logo_sm').length) $('#rspamd_logo_sm').attr('src', '/img/rspamd_logo_dark.png');
localStorage.setItem('mailcow_theme', 'light');
}else{
$('head').append('<link id="dark-mode-theme" rel="stylesheet" type="text/css" href="' + mailcow_dark_css + '">');
$('head').append('<link id="dark-mode-theme" rel="stylesheet" type="text/css" href="/css/themes/mailcow-darkmode.css">');
$('#dark-mode-toggle').prop('checked', true);
$('.main-logo').addClass('d-none');
$('.main-logo-dark').removeClass('d-none');
-707
View File
@@ -1,707 +0,0 @@
// 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);
+33 -26
View File
@@ -2169,11 +2169,7 @@ 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,
order: [[2, 'desc']],
initComplete: function(){
hideTableExpandCollapseBtn('#tab-syncjobs', '#sync_job_table');
},
@@ -2181,21 +2177,40 @@ 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>';
}
mailcowImapsync.prepareJobRow(item);
item.user2 = mailcowImapsync.wrapCell(escapeHtml(item.user2));
item.server_w_port = escapeHtml(item.user1) + '@' + escapeHtml(item.host1) + ':' + escapeHtml(item.port1);
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;
@@ -2220,46 +2235,40 @@ jQuery(function($){
responsivePriority: 2
},
{
title: mailcowImapsync.L('id'),
title: 'ID',
data: 'id',
responsivePriority: 3,
defaultContent: ''
},
{
title: lang.syncjobs.prio,
data: 'prio',
visible: role === 'admin',
responsivePriority: 3,
defaultContent: ''
},
{
title: lang.syncjobs.user2_label,
title: lang.owner,
data: 'user2',
responsivePriority: 4,
defaultContent: ''
},
{
title: lang.syncjobs.source_server,
title: 'Server',
data: 'server_w_port',
responsivePriority: 6,
defaultContent: ''
},
{
title: lang.last_run,
data: 'last_run',
responsivePriority: 9,
defaultContent: ''
},
{
title: lang.syncjobs.syncjob_last_run_result,
title: lang.syncjob_last_run_result,
data: 'exit_status',
responsivePriority: 8,
defaultContent: ''
},
{
title: 'Log',
data: 'log',
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>';
@@ -2268,7 +2277,6 @@ jQuery(function($){
{
title: lang.status,
data: 'is_running',
responsivePriority: 7,
defaultContent: ''
},
{
@@ -2478,7 +2486,6 @@ 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());
+44 -18
View File
@@ -272,27 +272,22 @@ 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>';
}
mailcowImapsync.prepareJobRow(item);
item.server_w_port = escapeHtml(item.user1 + '@' + item.host1 + ':' + item.port1);
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 + '" />';
@@ -301,6 +296,26 @@ 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;
@@ -325,34 +340,41 @@ jQuery(function($){
responsivePriority: 2
},
{
title: mailcowImapsync.L('id'),
title: 'ID',
data: 'id',
defaultContent: '',
responsivePriority: 3
},
{
title: lang.syncjobs.source_server,
title: 'Server',
data: 'server_w_port',
defaultContent: ''
},
{
title: lang.username,
data: 'user1',
defaultContent: '',
responsivePriority: 4
responsivePriority: 3
},
{
title: lang.last_run,
data: 'last_run',
defaultContent: '',
responsivePriority: 7
defaultContent: ''
},
{
title: lang.syncjobs.syncjob_last_run_result,
title: lang.syncjob_last_run_result,
data: 'exit_status',
defaultContent: '',
responsivePriority: 6
defaultContent: ''
},
{
title: 'Log',
data: 'log',
defaultContent: ''
},
{
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>'
}
@@ -363,6 +385,11 @@ jQuery(function($){
defaultContent: '',
responsivePriority: 5
},
{
title: lang.encryption,
data: 'enc1',
defaultContent: ''
},
{
title: lang.excludes,
data: 'exclude',
@@ -672,7 +699,6 @@ 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'));
});
+51 -56
View File
@@ -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|secret|oauth_extra_params/i", $key)) {
if(preg_match("/pass/i", $key)) {
$val = '*';
}
}
@@ -317,10 +317,7 @@ if (isset($_GET['query'])) {
process_add_return(admin('add', $attr));
break;
case "syncjob":
process_add_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->addJob($attr));
break;
case "syncjob_source":
process_add_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->addSource($attr));
process_add_return(mailbox('add', 'syncjob', $attr));
break;
case "bcc":
process_add_return(bcc('add', $attr));
@@ -1049,37 +1046,61 @@ 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":
// 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());
$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;
}
}
}
}
}
}
process_get_return($data);
}
else {
echo '{}';
}
process_get_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getAllJobs($extra, $mailboxes));
break;
default:
process_get_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getJobs($object, $extra));
$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);
break;
}
break;
@@ -1721,10 +1742,7 @@ if (isset($_GET['query'])) {
process_delete_return(rsettings('delete', array('id' => $items)));
break;
case "syncjob":
process_delete_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->deleteJobs($items));
break;
case "syncjob_source":
process_delete_return(\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->deleteSources($items));
process_delete_return(mailbox('delete', 'syncjob', array('id' => $items)));
break;
case "filter":
process_delete_return(mailbox('delete', 'filter', array('id' => $items)));
@@ -1950,27 +1968,7 @@ if (isset($_GET['query'])) {
}
break;
case "syncjob":
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;
}
process_edit_return(mailbox('edit', 'syncjob', array_merge(array('id' => $items), $attr)));
break;
case "filter":
process_edit_return(mailbox('edit', 'filter', array_merge(array('id' => $items), $attr)));
@@ -2040,9 +2038,6 @@ if (isset($_GET['query'])) {
case "custom_login":
process_edit_return(customize('edit', 'custom_login', $attr));
break;
case "ui_theme":
process_edit_return(customize('edit', 'ui_theme', $attr));
break;
case "self":
if ($_SESSION['mailcow_cc_role'] == "domainadmin") {
process_edit_return(domain_admin('edit', $attr));
+49 -31
View File
@@ -44,16 +44,24 @@
"app_name": "Име на приложението",
"app_password": "Добавяне на парола за приложение",
"app_passwd_protocols": "Разрешени протоколи за паролата на приложението",
"automap": "Опит за автоматично картографиране на папки (\"Изпратени\", \"Изпратени\" => \"Изпратени\" и т.н.)",
"backup_mx_options": "Опции за реле",
"bcc_dest_format": "BCC дестинацията трябва да бъде един валиден имейл адрес.<br>Ако имате нужда да изпратите копие до множество адреси, създайте псевдоним и го използвайте тук.",
"comment_info": "Частен коментар не е видим за потребителя, докато публичен коментар се показва като подсказка при преминаване с мишката върху него в прегледа на потребителя",
"custom_params": "Персонализирани параметри",
"custom_params_hint": "Дясно: --param=xy, грешно: --param xy",
"delete1": "Изтриване от източника след завършване",
"delete2": "Изтриване на съобщенията в дестинацията, които не са в източника",
"delete2duplicates": "Изтриване на дубликати в дестинацията",
"description": "Описание",
"destination": "Дестинация",
"disable_login": "Забраняване на вход (входящите съобщения все още се приемат)",
"domain": "Домейн",
"domain_matches_hostname": "Домейнът %s съвпада с името на хоста",
"domain_quota_m": "Обща квота на домейна (MiB)",
"dry": "Симулиране на синхронизация",
"enc_method": "Метод на криптиране",
"exclude": "Изключване на обекти (regex)",
"full_name": "Пълно име",
"gal": "Глобален адресен списък",
"gal_info": "Глобалният адресен списък съдържа всички обекти на домейна и не може да бъде редактиран от нито един потребител. Липсва информация за заетост/свободно време в SOGo, ако е деактивирано! <b>Рестартирайте SOGo, за да приложите промените.</b>",
@@ -69,6 +77,7 @@
"mailbox_username": "Потребителско име (лява част на имейл адрес)",
"max_aliases": "Макс. възможни псевдоними",
"max_mailboxes": "Макс. възможни пощенски кутии",
"mins_interval": "Интервал за проверка (минути)",
"multiple_bookings": "Множествени резервации",
"nexthop": "Следващ хоп",
"password": "Парола",
@@ -88,10 +97,16 @@
"select_domain": "Моля, изберете първо домейн",
"sieve_desc": "Кратко описание",
"sieve_type": "Тип на филтър",
"skipcrossduplicates": "Пропускане на дублирани съобщения между папки (първи дошъл, първи обслужен)",
"subscribeall": "Абониране за всички папки",
"syncjob": "Добавяне на синхронизираща задача",
"syncjob_hint": "Имайте предвид, че паролите трябва да бъдат запазени като обикновен текст!",
"tags": "Тагове",
"target_address": "Адреси за пренасочване",
"target_address_info": "<small>Пълни имейл адреси (разделени с запетая).</small>",
"target_domain": "Целеви домейн",
"timeout1": "Таймаут за връзка с отдалечен хост",
"timeout2": "Таймаут за връзка с локален хост",
"username": "Потребителско име",
"validate": "Валидиране",
"validation_success": "Успешно валидиране"
@@ -615,6 +630,7 @@
"app_name": "Име на приложението",
"app_passwd": "Парола за приложение",
"app_passwd_protocols": "Разрешени протоколи за паролата на приложението",
"automap": "Опит за автоматично картографиране на папки (\"Изпратени\", \"Изпратени\" => \"Изпратени\" и т.н.)",
"backup_mx_options": "Опции за реле",
"bcc_dest_format": "BCC дестинацията трябва да бъде един валиден имейл адрес.<br>Ако имате нужда да изпратите копие до множество адреси, създайте псевдоним и го използвайте тук.",
"client_id": "Идентификатор на клиента",
@@ -622,6 +638,9 @@
"comment_info": "Частен коментар не е видим за потребителя, докато публичен коментар се показва като подсказка при преминаване с мишката върху него в прегледа на потребителя",
"created_on": "Създаден на",
"custom_attributes": "Персонализирани атрибути",
"delete1": "Изтриване от източника след завършване",
"delete2": "Изтриване на съобщенията в дестинацията, които не са в източника",
"delete2duplicates": "Изтриване на дубликати в дестинацията",
"delete_ays": "Моля, потвърдете процеса на изтриване.",
"description": "Описание",
"disable_login": "Забраняване на вход (входящите съобщения все още се приемат)",
@@ -645,6 +664,7 @@
"dont_check_sender_acl": "Деактивиране на проверката на изпращач за домейн %s (+ домейни на псевдоними)",
"edit_alias_domain": "Редактиране на домейн на псевдоним",
"encryption": "Криптиране",
"exclude": "Изключване на обекти (regex)",
"extended_sender_acl": "Външни адреси на изпращач",
"extended_sender_acl_info": "Трябва да бъде импортиран DKIM домейн ключ, ако е наличен.<br>\r\n Помнете да добавите този сървър към съответния SPF TXT запис.<br>\r\n Когато домейн или домейн на псевдоним бъде добавен към този сървър, който се припокрива с външен адрес, външният адрес ще бъде премахнат.<br>\r\n Използвайте @domain.tld, за да разрешите изпращане като *@domain.tld.",
"footer_exclude": "Изключване от подвал",
@@ -671,7 +691,10 @@
"max_aliases": "Макс. псевдоними",
"max_mailboxes": "Макс. възможни пощенски кутии",
"max_quota": "Макс. квота за пощенска кутия (MiB)",
"maxage": "Максимална възраст на съобщенията в дни, които ще бъдат проверени от отдалечен<br><small>(0 = игнорирай възраст)</small>",
"maxbytespersecond": "Макс. байтове за секунда <br><small>(0 = неограничен)</small>",
"mbox_rl_info": "Това ограничение на скоростта се прилага върху SASL името за вход, то съвпада с всеки \"от\" адрес, използван от влезлия потребител. Ограничението на скоростта на пощенска кутия пренарежда ограничението на скоростта за домейн.",
"mins_interval": "Интервал (мин)",
"multiple_bookings": "Множествени резервации",
"nexthop": "Следващ хоп",
"none_inherit": "Без / Наследяване",
@@ -712,6 +735,7 @@
"sender_acl_info": "Ако потребителят на пощенска кутия A е разрешен да изпраща като потребителя на пощенска кутия B, адресът на изпращач не се показва автоматично като избираем \"от\" поле в SOGo.<br>\r\n Потребителят на пощенска кутия B трябва да създаде делегация в SOGo, за да разреши на потребителя на пощенска кутия A да избира техния адрес като изпращач. За да делегирате пощенска кутия в SOGo, използвайте менюто (три точки) вдясно от името на пощенската кутия в горния ляв ъгъл, докато сте в режим на поща. Това поведение не се прилага за адреси на псевдоними.",
"sieve_desc": "Кратко описание",
"sieve_type": "Тип на филтър",
"skipcrossduplicates": "Пропускане на дублирани съобщения между папки (първи дошъл, първи обслужен)",
"sogo_access": "Директно препращане към SOGo",
"sogo_access_info": "След влизане, потребителят се пренасочва автоматично към SOGo.",
"sogo_visible": "Псевдонимът е видим в SOGo",
@@ -720,8 +744,12 @@
"spam_filter": "Филтър за спам",
"spam_policy": "Добавяне или премахване на елементи към черния/бял списък",
"spam_score": "Задаване на персонализиран резултат за спам",
"subfolder2": "Синхронизиране в подпапка в дестинацията<br><small>(празно = не използвай подпапка)</small>",
"syncjob": "Редактиране на синхронизираща задача",
"target_address": "Адрес/и за пренасочване <small>(разделени с запетая)</small>",
"target_domain": "Целеви домейн",
"timeout1": "Таймаут за връзка с отдалечен хост",
"timeout2": "Таймаут за връзка с локален хост",
"title": "Редактиране на обект",
"unchanged_if_empty": "Ако не е променено, оставете празно",
"username": "Потребителско име",
@@ -936,6 +964,16 @@
"spam_aliases": "Времеви псевдоними",
"stats": "Статистика",
"status": "Статус",
"sync_jobs": "Синхронизиращи задачи",
"syncjob_check_log": "Проверка на лога",
"syncjob_last_run_result": "Резултат от последното изпълнение",
"syncjob_EX_OK": "Успех",
"syncjob_EXIT_CONNECTION_FAILURE": "Проблем с връзката",
"syncjob_EXIT_TLS_FAILURE": "Проблем с криптираната връзка",
"syncjob_EXIT_AUTHENTICATION_FAILURE": "Проблем с удостоверяването",
"syncjob_EXIT_OVERQUOTA": "Целевата пощенска кутия е над квотата",
"syncjob_EXIT_CONNECTION_FAILURE_HOST1": "Не може да се свърже с отдалечен сървър",
"syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Грешно потребителско име или парола",
"table_size": "Размер на таблицата",
"table_size_show_n": "Показване на %s елемента",
"target_address": "Адрес за пренасочване",
@@ -1166,37 +1204,6 @@
"waiting_usb_register": "<i>Изчакване на USB устройство...</i><br><br>Моля, въведете паролата си по-горе и потвърдете регистрацията си, като докоснете бутона на вашето USB устройство.",
"yubi_otp": "Удостоверяване с Yubico OTP"
},
"syncjobs": {
"sync_jobs": "Синхронизиращи задачи",
"syncjob_check_log": "Проверка на лога",
"syncjob_last_run_result": "Резултат от последното изпълнение",
"syncjob_EX_OK": "Успех",
"syncjob_EXIT_CONNECTION_FAILURE": "Проблем с връзката",
"syncjob_EXIT_TLS_FAILURE": "Проблем с криптираната връзка",
"syncjob_EXIT_AUTHENTICATION_FAILURE": "Проблем с удостоверяването",
"syncjob_EXIT_OVERQUOTA": "Целевата пощенска кутия е над квотата",
"syncjob_EXIT_CONNECTION_FAILURE_HOST1": "Не може да се свърже с отдалечен сървър",
"syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Грешно потребителско име или парола",
"add": "Добавяне на синхронизираща задача",
"add_hint": "Имайте предвид, че паролите трябва да бъдат запазени като обикновен текст!",
"edit": "Редактиране на синхронизираща задача",
"create": "Създаване на нова синхронизираща задача",
"dry": "Симулиране на синхронизация",
"subfolder2": "Синхронизиране в подпапка в дестинацията<br><small>(празно = не използвай подпапка)</small>",
"mins_interval": "Интервал за проверка (минути)",
"maxage": "Максимална възраст на съобщенията в дни, които ще бъдат проверени от отдалечен<br><small>(0 = игнорирай възраст)</small>",
"exclude": "Изключване на обекти (regex)",
"automap": "Опит за автоматично картографиране на папки (\"Изпратени\", \"Изпратени\" => \"Изпратени\" и т.н.)",
"delete1": "Изтриване от източника след завършване",
"delete2": "Изтриване на съобщенията в дестинацията, които не са в източника",
"delete2duplicates": "Изтриване на дубликати в дестинацията",
"skipcrossduplicates": "Пропускане на дублирани съобщения между папки (първи дошъл, първи обслужен)",
"subscribeall": "Абониране за всички папки",
"timeout1": "Таймаут за връзка с отдалечен хост",
"timeout2": "Таймаут за връзка с локален хост",
"maxbytespersecond": "Макс. байтове за секунда <br><small>(0 = неограничен)</small>",
"custom_params": "Персонализирани параметри"
},
"user": {
"action": "Действие",
"active": "Активен",
@@ -1227,6 +1234,7 @@
"clear_recent_successful_connections": "Изчистване на видимите успешни връзки",
"client_configuration": "Показване на ръководства за настройка на пощенски клиенти и смартфони",
"create_app_passwd": "Създаване на парола за приложение",
"create_syncjob": "Създаване на нова синхронизираща задача",
"created_on": "Създаден на",
"daily": "Ежедневно",
"day": "ден",
@@ -1332,6 +1340,16 @@
"spamfilter_wl_desc": "Адресите в белия списък <b>никога</b> ще бъдат класифицирани като спам. Поддържат се уайлдкардове.<br>Съобщенията, които не са класифицирани като спам, ще бъдат доставени в папката за нежелана поща, ако резултатът за спам е над стойността за висок резултат за спам.",
"spamfilter_yellow": "Жълто: това съобщение може да е спам, ще бъде маркирано като спам и преместено в папката за нежелана поща",
"status": "Статус",
"sync_jobs": "Синхронизиращи задачи",
"syncjob_EXIT_AUTHENTICATION_FAILURE": "Проблем с удостоверяването",
"syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Грешно потребителско име или парола",
"syncjob_EXIT_CONNECTION_FAILURE": "Проблем с връзката",
"syncjob_EXIT_CONNECTION_FAILURE_HOST1": "Не може да се свърже с отдалечен сървър",
"syncjob_EXIT_OVERQUOTA": "Целевата пощенска кутия е над квотата",
"syncjob_EXIT_TLS_FAILURE": "Проблем с криптираната връзка",
"syncjob_EX_OK": "Успех",
"syncjob_check_log": "Проверка на лога",
"syncjob_last_run_result": "Резултат от последното изпълнение",
"tag_handling": "Настройка за тагове",
"tag_help_example": "Пример за тагован имейл адрес: me<b>+Facebook</b>@example.org",
"tag_help_explain": "В подпапка: ще бъде създадена нова подпапка под INBOX (\"INBOX/Facebook\").<br>\r\nВ тема: първото име на тага ще бъде добавено към темата на съобщението, например: \"[Facebook] Моите новини\".",
+26 -19
View File
@@ -40,11 +40,16 @@
"alias_address_info": "<small>Adreces de correu completes, o @exemple.com per a atrapar tots els missatges per a un domini (separats per coma). <b>Només dominis interns</b>.</small>",
"alias_domain": "Domini àlies",
"alias_domain_info": "<small>Només noms de domini vàlids (separats per coma).</small>",
"automap": "Provar de mapejar automàticament (\"Sent items\", \"Sent\" => \"Sent\" etc.)",
"backup_mx_options": "Opcions backup MX:",
"delete1": "Esborrar de l'origen un cop s'han copiat",
"delete2": "Esborrar els missatges a destí que no son al origen",
"delete2duplicates": "Eliminar els duplicats al destí",
"description": "Descripció:",
"domain": "Domini",
"domain_quota_m": "Quota total del domini (MiB):",
"enc_method": "Mètode de xifrat",
"exclude": "Excloure els objectes (regex)",
"full_name": "Nom complet:",
"goto_null": "Descartar mail silenciosament",
"hostname": "Hostname",
@@ -53,6 +58,7 @@
"mailbox_username": "Nom d'usuari (part de l'esquerra de l'adreça @):",
"max_aliases": "Màx. àlies possibles:",
"max_mailboxes": "Màx. bústies possibles:",
"mins_interval": "Intèrval (minuts)",
"multiple_bookings": "Reserves múltiples",
"password": "Constrasenya:",
"password_repeat": "Confirmació de contrasenya (repetir):",
@@ -66,6 +72,9 @@
"select_domain": "Si et plau, primer selecciona un domini",
"sieve_desc": "Short description",
"sieve_type": "Filter type",
"skipcrossduplicates": "Saltar missatges duplicats entre carpetes (només es desa el primer)",
"syncjob": "Afegir sync job",
"syncjob_hint": "Tingues en compte que les contrasenyes es desen sense xifrar!",
"target_address": "Adreces goto:",
"target_address_info": "<small>Adreces de corru completes (separades per coma).</small>",
"target_domain": "Domini destí:",
@@ -77,9 +86,12 @@
"app_passwd_protocols": "Protocols autoritzats per la contrasenya de l'aplicació",
"bcc_dest_format": "La destinació c/o ha de ser una única adreça de correu vàlida.<br>Si necessiteu enviar una còpia a diverses adreces, creeu un àlies i utilitzeu-lo aquí.",
"comment_info": "Els comentaris privats no són visibles per l'usuari, mentre que els comentaris públics apareixen com una descripció emergent a la informació de l'usuari",
"custom_params": "Paràmetres personalitzats",
"custom_params_hint": "Correcte: --param=xy, incorrecte: --param xy",
"destination": "Destí",
"disable_login": "No permetre l'inici de sessió (els missatges entrants continuen sent acceptats)",
"domain_matches_hostname": "El domini %s coincideix amb el nom del servidor",
"dry": "Simular la sincronització",
"gal": "Llista d'adreces global",
"generate": "genereu",
"inactive": "Inactiu",
@@ -237,7 +249,11 @@
"edit": {
"active": "Actiu",
"alias": "Editar àlies",
"automap": "Provar de mapejar automàticament (\"Sent items\", \"Sent\" => \"Sent\" etc.)",
"backup_mx_options": "Opcions backup MX:",
"delete1": "Esborrar de l'origen un cop s'han copiat",
"delete2": "Esborrar els missatges a destí que no son al origen",
"delete2duplicates": "Eliminar els duplicats al destí",
"description": "Descripció:",
"domain": "Editar domini",
"domain_admin": "Editar administrador del domini",
@@ -246,6 +262,7 @@
"dont_check_sender_acl": "No verifiques remitente para el dominio %s",
"edit_alias_domain": "Editar àlies de domini",
"encryption": "Xifrat",
"exclude": "Excloure els objectes (regex)",
"force_pw_update": "Forçar l'actualització de la contrassenya al proper login",
"force_pw_update_info": "Aquest usuari només podrà accedir a la interfície de gestió.",
"full_name": "Nom complet",
@@ -255,6 +272,9 @@
"max_aliases": "Màx. àlies:",
"max_mailboxes": "Màx. bústies possibles:",
"max_quota": "Màx. quota per bústia (MiB):",
"maxage": "Salta missatges més vells del número de dies<br><small>(0 = no salta res)</small>",
"maxbytespersecond": "Màx. bytes per segon<br><small>(0 = il.limitat)</small>",
"mins_interval": "Intèrval (min)",
"multiple_bookings": "Reserves múltiples",
"password": "Contrasenya:",
"password_repeat": "Confirmació de contrasenya (repetir):",
@@ -269,9 +289,12 @@
"sender_acl": "Permetre enviar com a",
"sieve_desc": "Short description",
"sieve_type": "Filter type",
"skipcrossduplicates": "Saltar missatges duplicats entre carpetes (només es desa el primer)",
"spam_alias": "Crear o canviar alies temporals limitats per temps",
"spam_policy": "Afegeix o treu elementes a la white-/blacklist",
"spam_score": "Especifica una puntuació de spam",
"subfolder2": "Sincronitza a la subcarpeta destí<br><small>(buit = no usar subcarpeta)</small>",
"syncjob": "Sync job",
"target_address": "Direcció/ns goto <small>(separades per coma)</small>:",
"target_domain": "Domini destí:",
"title": "Editar l'objecte",
@@ -382,6 +405,7 @@
"sieve_info": "Podeu emmagatzemar diversos filtres per usuari, però només un filtre previ i un filtre posterior poden estar actius al mateix temps.<br>\r\nCada filtre es processarà en l'ordre descrit. Ni un script que falli, ni un \"keep\" emès farà que es deixin de processar els altres scripts.<br>\r\nFiltre previ → Filtre d'usuari → Filtre posterior → <a href=\"https://github.com/mailcow/mailcow-dockerized/blob/master/data/conf/dovecot/sieve_after\" target=\"_blank\">Filtre global</a>",
"spam_aliases": "Temp. àlies",
"status": "Estat",
"sync_jobs": "Sync jobs",
"target_address": "Direcció Goto",
"target_domain": "Domini destí",
"tls_enforce_in": "Forçar TLS al rebre",
@@ -476,25 +500,6 @@
"waiting_usb_register": "<i>Esperant el dispositiu USB...</i><br><br>Posa el teu password i confirma el registre del teu WebAuthn apretant el botó del teu dispositiiu USB WebAuthn.",
"yubi_otp": "Autenticació OTP de Yubico"
},
"syncjobs": {
"sync_jobs": "Feines de sincronització",
"add": "Afegir sync job",
"add_hint": "Tingues en compte que les contrasenyes es desen sense xifrar!",
"edit": "Sync job",
"create": "Afegir treball de sincronitzaió",
"dry": "Simular la sincronització",
"subfolder2": "Sincronitza a la subcarpeta destí<br><small>(buit = no usar subcarpeta)</small>",
"mins_interval": "Intèrval (minuts)",
"maxage": "Salta missatges més vells del número de dies<br><small>(0 = no salta res)</small>",
"exclude": "Excloure els objectes (regex)",
"automap": "Provar de mapejar automàticament (\"Sent items\", \"Sent\" => \"Sent\" etc.)",
"delete1": "Esborrar de l'origen un cop s'han copiat",
"delete2": "Esborrar els missatges a destí que no son al origen",
"delete2duplicates": "Eliminar els duplicats al destí",
"skipcrossduplicates": "Saltar missatges duplicats entre carpetes (només es desa el primer)",
"maxbytespersecond": "Màx. bytes per segon<br><small>(0 = il.limitat)</small>",
"custom_params": "Paràmetres personalitzats"
},
"user": {
"action": "Acció",
"active": "Actiu",
@@ -511,6 +516,7 @@
"aliases_send_as_all": "Al enviar no es verificarà l'adreça remitent per aquests dominis:",
"change_password": "Canviar la contrasenya",
"client_configuration": "Guies de configuració per als clients de correu més habituals",
"create_syncjob": "Afegir treball de sincronitzaió",
"day": "Dia",
"description": "Descripció",
"direct_aliases": "Adreces àlies directes",
@@ -561,6 +567,7 @@
"spamfilter_wl_desc": "Les adreces de la lista blanca <b>mai</b> es clasificaran como a spam. Es pot fer serivir *@exemple.org.",
"spamfilter_yellow": "Groc: el missatge pot ser spam, s'etiquetarà com a spam i es mourà a la carpeta de correu brossa",
"status": "Estat",
"sync_jobs": "Feines de sincronització",
"tag_handling": "Al rebre un missatge etiquetat",
"tag_help_example": "Ejemplo de una dirección email etiquetada: mi<b>+Facebook</b>@ejemplo.org",
"tag_help_explain": "Moure a subcarpeta: es crearà una subcarpeta anomenada com la etiqueta a INBOX (\"INBOX/Facebook\") i es mourà el correu allà.<br>\r\nMarcar al assumpte: s'afegirà el nom de la etiqueta al assumpte del missatge, per exemple: \"[Facebook] Les meves notícies\".",
+49 -31
View File
@@ -44,9 +44,15 @@
"app_name": "Název aplikace",
"app_passwd_protocols": "Povolené protokoly pro hesla aplikací",
"app_password": "Přidat heslo aplikace",
"automap": "Pokusit se automaticky mapovat složky (\"Sent items\", \"Sent\" => \"Sent\" atd.)",
"backup_mx_options": "Možnosti záložního MX",
"bcc_dest_format": "Cíl kopie musí být jedna platná email adresa. Pokud potřebujete posílat kopie na více adres, vytvořte Alias a použijte jej zde.",
"comment_info": "Soukromý komentář se nezobrazí uživateli; veřejný komentář se zobrazí jako nápověda při zastavení se kurzorem v přehledu uživatelů",
"custom_params": "Vlastní parametry",
"custom_params_hint": "Správně: --param=xy, špatně: --param xy",
"delete1": "Odstranit ze zdrojové schránky, po dokončení přenosu",
"delete2": "Odstranit zprávy v cílové schránce, pokud nejsou ve zdrojové",
"delete2duplicates": "Odstranit duplicity v cílové schránce",
"description": "Popis",
"destination": "Cíl",
"disable_login": "Zakázat přihlášení (ale stále přijímat poštu)",
@@ -54,6 +60,7 @@
"domain_matches_hostname": "Doména %s se shoduje s názvem hostitele",
"domain_quota_m": "Celková kvóta domény (MiB)",
"enc_method": "Způsob šifrování",
"exclude": "Vyloučené objekty (regex)",
"full_name": "Celé jméno",
"gal": "Globální seznam adres",
"gal_info": "Globální seznam adres obsahuje všechny objekty v doméně a uživatel jej nemůže měnit. Pokud je vypnut, budou v SOGo chybět informace o obsazenosti! <b>Při změně nutno restartovat SOGo.</b>",
@@ -69,6 +76,7 @@
"mailbox_username": "Uživatelské jméno (levá část email adresy)",
"max_aliases": "Max. počet aliasů",
"max_mailboxes": "Max. počet mailových schránek",
"mins_interval": "Interval kontroly (minuty)",
"multiple_bookings": "Vícenásobné rezervace",
"nexthop": "Další skok",
"password": "Heslo",
@@ -88,13 +96,20 @@
"select_domain": "Nejdříve vyberte doménu",
"sieve_desc": "Krátký popis",
"sieve_type": "Typ filtru",
"skipcrossduplicates": "Přeskočit duplicitní zprávy (\"první přijde, první mele\")",
"subscribeall": "Synchronizovat všechny složky",
"syncjob": "Přidat synchronizační úlohu",
"syncjob_hint": "Upozornění: Heslo bude uloženo jako prostý text!",
"target_address": "Cílové adresy",
"target_address_info": "<small>Kompletní email adresa/y (oddělené čárkami).</small>",
"target_domain": "Cílová doména",
"timeout1": "Časový limit pro připojení ke vzdálenému serveru",
"timeout2": "Časový limit pro připojení k lokálnímu serveru",
"username": "Uživatelské jméno",
"validate": "Ověřit",
"validation_success": "Úspěšně ověřeno",
"tags": "Štítky",
"dry": "Simulovat synchronizaci",
"internal": "Interní",
"internal_info": "Interní aliasy jsou přístupné jen z vlastních domén nebo jejich aliasů.",
"sender_allowed": "Povolit jako alias pro odesílání"
@@ -630,12 +645,16 @@
"app_name": "Název aplikace",
"app_passwd": "Heslo aplikace",
"app_passwd_protocols": "Povolené protokoly pro hesla aplikací",
"automap": "Pokusit se automaticky mapovat složky (\"Sent items\", \"Sent\" => \"Sent\" atd.)",
"backup_mx_options": "Možnosti záložního MX",
"bcc_dest_format": "Cíl kopie musí být jedna platná email adresa. Pokud potřebujete posílat kopie na více adres, vytvořte Alias a použijte jej zde.",
"client_id": "ID klienta",
"client_secret": "Tajný klíč klienta",
"created_on": "Vytvoreno",
"comment_info": "Soukromý komentář se nezobrazí uživateli; veřejný komentář se zobrazí jako nápověda při zastavení se kurzorem v přehledu uživatelů",
"delete1": "Odstranit ze zdrojové schránky, po dokončení přenosu",
"delete2": "Odstranit zprávy v cílové schránce, pokud nejsou ve zdrojové",
"delete2duplicates": "Odstranit duplicity v cílové schránce",
"delete_ays": "Potvrďte odstranění.",
"description": "Popis",
"disable_login": "Zakázat přihlášení (ale stále přijímat poštu)",
@@ -646,6 +665,7 @@
"dont_check_sender_acl": "Vypnout kontrolu odesílatele pro doménu %s (+ doménové aliasy)",
"edit_alias_domain": "Upravit doménový alias",
"encryption": "Šifrování",
"exclude": "Vyloučit objekty (regex)",
"extended_sender_acl": "Externí adresy odesílatele",
"extended_sender_acl_info": "Je dobré importovat DKIM klíč domény, pokud existuje.<br>\r\n Nezapomeňte přidat tento server do příslušného záznamu SPF TXT.<br>\r\n Je-li na tomto serveru vytvořena doména nebo doménový alias, který se shoduje s externí adresou, je tato externí adresa smazána.<br>\r\n Použije formát @domain.tld, chcete-li odesílat jako *@domain.tld.",
"force_pw_update": "Vynutit změnu hesla při příštím přihlášení",
@@ -666,7 +686,10 @@
"max_aliases": "Max. počet aliasů",
"max_mailboxes": "Max. počet mailových schránek",
"max_quota": "Max. kvóta mailové schránky (MiB)",
"maxage": "Maximální stáří stahovaných zpráv, ve dnech<br><small>(0 = ignorovat stáří)</small>",
"maxbytespersecond": "Max. bajtů za sekundu <br><small>(0 = neomezeno)</small>",
"mbox_rl_info": "Toto omezení provozu se vyhodnocuje podle přihlašovacího jména SASL, porovnává se s jakoukoliv adresou \"od\" použitou přihlášeným uživatelem. Omezení provozu mailové schránky má prioritu před omezením provozu domény.",
"mins_interval": "Interval (min)",
"multiple_bookings": "Vícenásobné rezervace",
"nexthop": "Další skok",
"none_inherit": "Žádné / Podědit",
@@ -704,6 +727,7 @@
"sender_acl_info": "Má-li uživatel schránky A dovoleno odesílat jako uživatel schránky B, nezobrazuje se adresa odesílatele B v seznamu \"Od\" v SOGo automaticky.<br>\r\n Uživatel schránky A musí nejdříve v SOGo vytvořit pověření, jež umožní uživateli B vybrat svou adresu jako odesílatele. Tento mechanismus neplatí pro aliasy.",
"sieve_desc": "Krátký popis",
"sieve_type": "Typ filtru",
"skipcrossduplicates": "Přeskočit duplicitní zprávy (\"první přijde, první mele\")",
"sogo_access": "Přímé předání na SOGo",
"sogo_access_info": "Po přihlášení je uživatel automaticky přesměrován do služby SOGo.",
"sogo_visible": "Alias dostupný v SOGo",
@@ -712,8 +736,12 @@
"spam_filter": "Spam filtr",
"spam_policy": "Přidat nebo odebrat položky seznamu",
"spam_score": "Nastavte vlastní skóre spamu",
"subfolder2": "Synchronizace do podsložky v cílovém umístění<br><small>(prázdné = nepoužívat podsložku)</small>",
"syncjob": "Upravit synchronizační úlohu",
"target_address": "Cílová adresa/y <small>(oddělte čárkou)</small>",
"target_domain": "Cílová doména",
"timeout1": "Časový limit pro připojení ke vzdálenému serveru",
"timeout2": "Časový limit pro připojení k lokálnímu serveru",
"title": "Úprava objektu",
"unchanged_if_empty": "Pokud se nemění, ponechte prázdné",
"username": "Uživatelské jméno",
@@ -969,6 +997,16 @@
"spam_aliases": "Dočasný alias",
"stats": "Statistika",
"status": "Stav",
"sync_jobs": "Synchronizační úlohy",
"syncjob_check_log": "Zkontrolujte záznam",
"syncjob_last_run_result": "Výsledek posledního spuštění",
"syncjob_EX_OK": "Úspěch",
"syncjob_EXIT_CONNECTION_FAILURE": "Problém se spojením",
"syncjob_EXIT_TLS_FAILURE": "Problém se šifrovaným spojením",
"syncjob_EXIT_AUTHENTICATION_FAILURE": "Problém s autentifikací",
"syncjob_EXIT_OVERQUOTA": "Cílová schránka je plná",
"syncjob_EXIT_CONNECTION_FAILURE_HOST1": "Nelze se připojit ke vzdálenému serveru",
"syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Chybné uživatelské jméno nebo heslo",
"table_size": "Velikost tabulky",
"table_size_show_n": "Zobrazit %s položek",
"target_address": "Cílová adresa",
@@ -1203,37 +1241,6 @@
"authenticators": "Autentifikátory",
"u2f_deprecated_important": "Registrujte svůj klíč novou metodou WebAuthn ve správě správců."
},
"syncjobs": {
"sync_jobs": "Synchronizační úlohy",
"syncjob_check_log": "Zkontrolujte záznam",
"syncjob_last_run_result": "Výsledek posledního spuštění",
"syncjob_EX_OK": "Úspěch",
"syncjob_EXIT_CONNECTION_FAILURE": "Problém se spojením",
"syncjob_EXIT_TLS_FAILURE": "Problém se šifrovaným spojením",
"syncjob_EXIT_AUTHENTICATION_FAILURE": "Problém s autentifikací",
"syncjob_EXIT_OVERQUOTA": "Cílová schránka je plná",
"syncjob_EXIT_CONNECTION_FAILURE_HOST1": "Nelze se připojit ke vzdálenému serveru",
"syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Chybné uživatelské jméno nebo heslo",
"add": "Přidat synchronizační úlohu",
"add_hint": "Upozornění: Heslo bude uloženo jako prostý text!",
"edit": "Upravit synchronizační úlohu",
"create": "Vytvořit novou synchronizační úlohu",
"dry": "Simulovat synchronizaci",
"subfolder2": "Synchronizace do podsložky v cílovém umístění<br><small>(prázdné = nepoužívat podsložku)</small>",
"mins_interval": "Interval kontroly (minuty)",
"maxage": "Maximální stáří stahovaných zpráv, ve dnech<br><small>(0 = ignorovat stáří)</small>",
"exclude": "Vyloučené objekty (regex)",
"automap": "Pokusit se automaticky mapovat složky (\"Sent items\", \"Sent\" => \"Sent\" atd.)",
"delete1": "Odstranit ze zdrojové schránky, po dokončení přenosu",
"delete2": "Odstranit zprávy v cílové schránce, pokud nejsou ve zdrojové",
"delete2duplicates": "Odstranit duplicity v cílové schránce",
"skipcrossduplicates": "Přeskočit duplicitní zprávy (\"první přijde, první mele\")",
"subscribeall": "Synchronizovat všechny složky",
"timeout1": "Časový limit pro připojení ke vzdálenému serveru",
"timeout2": "Časový limit pro připojení k lokálnímu serveru",
"maxbytespersecond": "Max. bajtů za sekundu <br><small>(0 = neomezeno)</small>",
"custom_params": "Vlastní parametry"
},
"user": {
"action": "Akce",
"active": "Aktivní",
@@ -1262,6 +1269,7 @@
"clear_recent_successful_connections": "Vymazat nedávné úspěšné přihlášení",
"client_configuration": "Zobrazit průvodce nastavením e-mailových klientů a smartphonů",
"create_app_passwd": "Vytvořit heslo aplikace",
"create_syncjob": "Vytvořit novou synchronizační úlohu",
"created_on": "Vytvoreno",
"daily": "Každý den",
"day": "den",
@@ -1362,6 +1370,16 @@
"spamfilter_wl_desc": "Povolené emailové adresy <b>nebudou nikdy klasifikovány jako spam</b>. Lze použít zástupné znaky (*). Filtr se použije pouze na přímé aliasy (s jednou cílovou mailovou schránkou), s výjimkou doménových košů a samotné mailové schránky.",
"spamfilter_yellow": "Žlutá: tato zpráva může být spam, bude označena jako spam a přesunuta do složky nevyžádané pošty",
"status": "Stav",
"sync_jobs": "Synchronizační úlohy",
"syncjob_EXIT_AUTHENTICATION_FAILURE": "Problém s autentifikací",
"syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Chybné uživatelské jméno nebo heslo",
"syncjob_EXIT_CONNECTION_FAILURE": "Problém se spojením",
"syncjob_EXIT_CONNECTION_FAILURE_HOST1": "Nelze se připojit ke vzdálenému serveru",
"syncjob_EXIT_OVERQUOTA": "Cílová schránka je plná",
"syncjob_EXIT_TLS_FAILURE": "Problém se šifrovaným spojením",
"syncjob_EX_OK": "Úspěch",
"syncjob_check_log": "Zkontrolujte záznam",
"syncjob_last_run_result": "Výsledek posledního spuštění",
"tag_handling": "Zacházení s označkovanou poštou",
"tag_help_example": "Příklad e-mailové adresy se značkou: me<b>+Facebook</b>@example.org",
"tag_help_explain": "V podsložce: v doručené poště bude vytvořena nová podsložka pojmenovaná po značce zprávy (\"INBOX / Facebook\").<br>\r\nV předmětu: název značky bude přidáván k předmětu mailu, například: \"[Facebook] Moje zprávy\".",
+30 -21
View File
@@ -42,8 +42,14 @@
"alias_domain_info": "<small>Kun gyldige domænenavne (kommasepareret).</small>",
"app_name": "App navn",
"app_password": "Tilføj app-adgangskode",
"automap": "Prøv at automatisere mapper (\"Sendte emner\", \"Sendt\" => \"Sendt\" etc.)",
"backup_mx_options": "Relæindstillinger",
"comment_info": "En privat kommentar er ikke synlig for brugeren, mens en offentlig kommentar vises som et værktøjstip, når den svæver i brugeroversigten",
"custom_params": "Brugerdefinerede parametre",
"custom_params_hint": "Rigtigt: --param=xy, forkert: --param xy",
"delete1": "Slet fra kilden, når den er afsluttet",
"delete2": "Slet meddelelser på destinationen, der ikke er fra kilden",
"delete2duplicates": "Slet duplikater på destinationen",
"description": "Beskrivelse",
"destination": "Bestemmelsessted",
"disable_login": "Tillad ikke login (indgående mail accepteres stadig)",
@@ -51,6 +57,7 @@
"domain_matches_hostname": "Domæne %s matcher værtsnavn",
"domain_quota_m": "Samlet domænekvote (Mb)",
"enc_method": "Krypteringsmetode",
"exclude": "Ekskluder objekter (regex)",
"full_name": "Fulde navn",
"gal": "Global adresseliste",
"gal_info": "GAL indeholder alle objekter i et domæne og kan ikke redigeres af nogen bruger. Information om ledig / optaget i SOGo mangler, hvis deaktiveret! <b> Genstart SOGo for at anvende ændringer. </b>",
@@ -66,6 +73,7 @@
"mailbox_username": "Brugernavn (venstre del af en e-mail-adressen)",
"max_aliases": "Maks. mulige aliasser",
"max_mailboxes": "Maks. mulige postkasser",
"mins_interval": "Afstemningsinterval (minuter)",
"multiple_bookings": "Flere bookinger",
"nexthop": "Næste hop",
"password": "Kodeord",
@@ -85,9 +93,15 @@
"select_domain": "Vælg først et domæne",
"sieve_desc": "Kort beskrivelse",
"sieve_type": "Filtertype",
"skipcrossduplicates": "Spring duplikatbeskeder over mapper (først til mølle)",
"subscribeall": "Abonner på alle mapper",
"syncjob": "Tilføj synkroniseringsjob",
"syncjob_hint": "Vær opmærksom på, at adgangskoder skal gemmes som almindelig tekst!",
"target_address": "Gå til adresser",
"target_address_info": "<small>Fuld e-mail-adresser (komma-adskilt).</small>",
"target_domain": "Måldomæne",
"timeout1": "Timeout for forbindelse til ekstern vært",
"timeout2": "Timeout for forbindelse til lokal vært",
"username": "Brugernavn",
"validate": "Bekræft",
"validation_success": "Valideret med succes",
@@ -458,11 +472,15 @@
"allowed_protocols": "Tilladte protokoller",
"app_name": "Appens navn",
"app_passwd": "App-adgangskode",
"automap": "Prøv at automatisere mapper (\"Sent items\", \"Sent\" => \"Sent\" etc.)",
"backup_mx_options": "Relæindstillinger",
"bcc_dest_format": "BCC-destination skal være en enkelt gyldig e-mail-adresse.",
"client_id": "Klient-id",
"client_secret": "Klienthemmelighed",
"comment_info": "En privat kommentar er ikke synlig for brugeren, mens en offentlig kommentar vises som værktøjstip, når den svæver i en brugeroversigt",
"delete1": "Slet fra kilden, når den er afsluttet",
"delete2": "Slet meddelelser på destinationen, der ikke er fra kilden",
"delete2duplicates": "Slet duplikater på destinationen",
"delete_ays": "Bekræft sletningsprocessen.",
"description": "Beskrivelse",
"disable_login": "Tillad ikke login (indgående mail accepteres stadig)",
@@ -473,6 +491,7 @@
"dont_check_sender_acl": "Deaktiver afsenderkontrol for domæne %s (+ alias-domæner)",
"edit_alias_domain": "Rediger Alias-domæne",
"encryption": "Kryptering",
"exclude": "Ekskluder objekter (regex)",
"extended_sender_acl": "Eksterne afsenderadresser",
"extended_sender_acl_info": "En DKIM-domænenøgle skal importeres, hvis den er tilgængelig.<br>\r\n Husk at tilføje denne server til den tilsvarende SPF TXT-post.<br>\r\n Når et domæne eller aliasdomæne føjes til denne server, der overlapper en ekstern adresse, fjernes den eksterne adresse.<br>\r\n Brug @domain.tld til at tillade at sende som *@domæne.tld.",
"force_pw_update": "Tving adgangskodeopdatering til næste login",
@@ -490,7 +509,10 @@
"max_aliases": "Maks. aliaser",
"max_mailboxes": "Maks. mulige postkasser",
"max_quota": "Maks. kvote pr. postkasse (MiB)",
"maxage": "Maksimumalder for beskeder i dage, der vil blive pollet fra fjernbetjeningen<br><small>(0 = ignorere alder)</small>",
"maxbytespersecond": "Maks. bytes pr. sekund <br><small>(0 = ubegrænset)</small>",
"mbox_rl_info": "Denne satsgrænse anvendes på SASL-loginnavnet, den matcher ethvert \"from\" adresse, der bruges af den indloggede bruger. En grænse for sats for postkasse tilsidesætter en satsgrænse for hele domænet.",
"mins_interval": "Interval (min)",
"multiple_bookings": "Flere bookinger",
"nexthop": "Næste hop",
"password": "Adgangskode",
@@ -524,13 +546,18 @@
"sender_acl_info": "Hvis postkassebruger A har tilladelse til at sende som postkassebruger B, vises afsenderadressen ikke automatisk som valgbar \"from\" felt i SOGo.<br>\r\n Postkassebruger B skal oprette en delegation i SOGo for at tillade postkassebruger A at vælge deres adresse som afsender. For at delegere en postkasse i SOGo skal du bruge menuen (tre prikker) til højre for dit postkassens navn øverst til venstre, mens du er i postvisningen. Denne adfærd gælder ikke for aliasadresser.",
"sieve_desc": "Kort beskrivelse",
"sieve_type": "Filtertype",
"skipcrossduplicates": "Spring duplikatbeskeder over mapper (først til mølle)",
"sogo_visible": "Alias er synligt i SOGo",
"sogo_visible_info": "Denne indstilling påvirker kun objekter, der kan vises i SOGo (delte eller ikke-delte aliasadresser, der peger på mindst en lokal postkasse). Hvis skjult, vises et alias ikke som valgbar afsender i SOGo.",
"spam_alias": "Opret eller skift tidsbegrænsede aliasadresser",
"spam_policy": "Tilføj eller fjern genstande til hvid- / sortliste",
"spam_score": "Indstil en brugerdefineret spam-score",
"subfolder2": "Synkroniser til undermappe på destinationen<br><small>(tom = brug ikke undermappe)</small>",
"syncjob": "Rediger synkroniseringsjob",
"target_address": "Gå til adresse (r) <small>(komma separeret)</small>",
"target_domain": "Måldomæne",
"timeout1": "Timeout for forbindelse til ekstern vært",
"timeout2": "Timeout for forbindelse til lokal vært",
"title": "Rediger objekt",
"unchanged_if_empty": "Lad være tomt, hvis uændret",
"username": "Brugernavn",
@@ -704,6 +731,7 @@
"spam_aliases": "Temp. alias",
"stats": "Statistikker",
"status": "Status",
"sync_jobs": "Synkroniser job",
"table_size": "Bordstørrelse",
"table_size_show_n": "Vis %s genstande",
"target_address": "Gå til adresse",
@@ -911,27 +939,6 @@
"fido2_success": "Enheden blev registreret",
"fido2_validation_failed": "Validering mislykkedes"
},
"syncjobs": {
"sync_jobs": "Sync jobs",
"add": "Tilføj synkroniseringsjob",
"add_hint": "Vær opmærksom på, at adgangskoder skal gemmes som almindelig tekst!",
"edit": "Rediger synkroniseringsjob",
"create": "Opret nyt sync job",
"subfolder2": "Synkroniser til undermappe på destinationen<br><small>(tom = brug ikke undermappe)</small>",
"mins_interval": "Afstemningsinterval (minuter)",
"maxage": "Maksimumalder for beskeder i dage, der vil blive pollet fra fjernbetjeningen<br><small>(0 = ignorere alder)</small>",
"exclude": "Ekskluder objekter (regex)",
"automap": "Prøv at automatisere mapper (\"Sendte emner\", \"Sendt\" => \"Sendt\" etc.)",
"delete1": "Slet fra kilden, når den er afsluttet",
"delete2": "Slet meddelelser på destinationen, der ikke er fra kilden",
"delete2duplicates": "Slet duplikater på destinationen",
"skipcrossduplicates": "Spring duplikatbeskeder over mapper (først til mølle)",
"subscribeall": "Abonner på alle mapper",
"timeout1": "Timeout for forbindelse til ekstern vært",
"timeout2": "Timeout for forbindelse til lokal vært",
"maxbytespersecond": "Maks. bytes pr. sekund <br><small>(0 = ubegrænset)</small>",
"custom_params": "Brugerdefinerede parametre"
},
"user": {
"action": "Handling",
"active": "Aktiv",
@@ -956,6 +963,7 @@
"change_password": "Skift kodeord",
"client_configuration": "Vis konfigurationsvejledning for email klinter og mobiltelefoner",
"create_app_passwd": "Opret app kodeord",
"create_syncjob": "Opret nyt sync job",
"daily": "Dagligt",
"day": "dag",
"description": "Beskrivelse",
@@ -1040,6 +1048,7 @@
"spamfilter_wl_desc": "Hvidlistede e-mail-adresser til <b>aldrig</b> at klassificeres som spam. Wildcards kan bruges. Et filter anvendes kun på direkte aliaser (aliaser med en enkelt målpostkasse) eksklusive catch-aliaser og selve en postkasse.",
"spamfilter_yellow": "Gul: denne besked kan være spam, vil blive tagget som spam og flyttes til din junk-mappe",
"status": "Status",
"sync_jobs": "Sync jobs",
"tag_handling": "Set handling for taggede mails",
"tag_help_example": "Eksempel på en tagget e-mail-adresse : me<b>+Tacaly</b>@example.org",
"tag_help_explain": "I undermappe: En ny undermappe opkaldt efter tagget oprettes under INBOX (\"INBOX/Tacaly\").<br>\r\nI emne: navnene på tags bliver præpenderet til mailsemnet, eksempel: \"[Tacaly] Mine Nyhedder\".",
+65 -178
View File
@@ -14,7 +14,7 @@
"mailbox_relayhost": "Relayhost für eine Mailbox setzen",
"prohibited": "Untersagt durch Richtlinie",
"protocol_access": "Ändern der erlaubten Protokolle",
"pushover": "Pushover-Berechtigung",
"pushover": "Pushover",
"pw_reset": "Verwalten der E-Mail zur Passwortwiederherstellung erlauben",
"quarantine": "Quarantäne-Aktionen",
"quarantine_attachments": "Anhänge aus Quarantäne",
@@ -28,7 +28,7 @@
"spam_alias": "Temporäre E-Mail-Aliasse",
"spam_policy": "Deny/Allowlist",
"spam_score": "Spam-Bewertung",
"syncjobs": "Sync-Jobs",
"syncjobs": "Sync Jobs",
"tls_policy": "Verschlüsselungsrichtlinie",
"unlimited_quota": "Unendliche Quota für Mailboxen"
},
@@ -45,16 +45,24 @@
"app_name": "App-Name",
"app_password": "App-Passwort hinzufügen",
"app_passwd_protocols": "Zugelassene Protokolle für App-Passwort",
"automap": "Ordner automatisch mappen (\"Sent items\", \"Sent\" => \"Sent\" etc.)",
"backup_mx_options": "Relay-Optionen",
"bcc_dest_format": "BCC-Ziel muss eine gültige E-Mail-Adresse sein.",
"comment_info": "Ein privater Kommentar ist für den Benutzer nicht einsehbar. Ein öffentlicher Kommentar wird als Tooltip im Interface des Benutzers angezeigt.",
"custom_params": "Eigene Parameter",
"custom_params_hint": "Richtig: --param=xy, falsch: --param xy",
"delete1": "Lösche Nachricht nach Übertragung vom Quell-Server.",
"delete2": "Lösche Nachrichten von Ziel-Server, die nicht auf dem Quell-Server vorhanden sind.",
"delete2duplicates": "Lösche Duplikate im Ziel",
"description": "Beschreibung",
"destination": "Ziel",
"disable_login": "Login verbieten (Mails werden weiterhin angenommen)",
"domain": "Domain",
"domain_matches_hostname": "Domain %s darf nicht dem Hostnamen entsprechen",
"domain_quota_m": "Domain-Speicherplatz gesamt (MiB)",
"dry": "Synchronisation simulieren",
"enc_method": "Verschlüsselung",
"exclude": "Elemente ausschließen (Regex)",
"full_name": "Vor- und Nachname",
"gal": "Globales Adressbuch",
"gal_info": "Das globale Adressbuch enthält alle Objekte einer Domain und kann durch keinen Benutzer geändert werden. Die Verfügbarkeitsinformation in SOGo ist nur bei eingeschaltetem globalen Adressbuch ersichtlich! <b>Zum Anwenden einer Änderung muss SOGo neugestartet werden.</b>",
@@ -73,6 +81,7 @@
"mailbox_username": "Benutzername (linker Teil der E-Mail-Adresse)",
"max_aliases": "Max. mögliche Aliasse",
"max_mailboxes": "Max. mögliche Mailboxen",
"mins_interval": "Abrufintervall (Minuten)",
"multiple_bookings": "Mehrfaches Buchen möglich",
"nexthop": "Next Hop",
"password": "Passwort",
@@ -88,13 +97,19 @@
"relay_transport_info": "<div class=\"badge fs-6 bg-info\">Info</div> Transport-Maps können erstellt werden, um individuelle Ziele für eine Relay-Domain zu definieren.",
"relay_unknown_only": "Nur nicht-lokale Mailboxen relayen. Existente Mailboxen werden weiterhin lokal zugestellt.",
"relayhost_wrapped_tls_info": "Bitte <b>keine</b> \"TLS-wrapped Ports\" verwenden (etwa SMTPS via Port 465/tcp).<br>\r\nDer Transport wird stattdessen STARTTLS anfordern, um TLS zu verwenden. TLS kann unter \"TLS Policy Maps\" erzwungen werden.",
"select": "Bitte auswählen...",
"select": "Bitte auswählen",
"select_domain": "Bitte zuerst eine Domain auswählen",
"sieve_desc": "Kurze Beschreibung",
"sieve_type": "Filtertyp",
"skipcrossduplicates": "Duplikate auch über Ordner hinweg überspringen (\"first come, first serve\")",
"subscribeall": "Alle synchronisierten Ordner abonnieren",
"syncjob": "Sync-Job hinzufügen",
"syncjob_hint": "Passwörter werden unverschlüsselt abgelegt!",
"target_address": "Ziel-Adresse(n)",
"target_address_info": "<small>Vollständige E-Mail-Adresse(n). Getrennt durch Komma.</small>",
"target_domain": "Ziel-Domain",
"timeout1": "Timeout für Verbindung zum Remote-Host",
"timeout2": "Timeout für Verbindung zum lokalen Host",
"username": "Benutzername",
"validate": "Validieren",
"validation_success": "Erfolgreich validiert",
@@ -380,11 +395,6 @@
"ui_header_announcement_type_info": "Info",
"ui_header_announcement_type_warning": "Wichtig",
"ui_texts": "UI-Label und Texte",
"ui_theme": "Theme",
"ui_theme_dark": "Enthält eine Darkmode-Variante",
"ui_theme_default": "Standard",
"ui_theme_info": "Themes sind Ordner in <code>data/web/css/themes/&lt;name&gt;/</code> mit folgenden Dateien:<ul class=\"mb-0\"><li><code>theme.css</code> (erforderlich)</li><li><code>dark.css</code> (optional, Darkmode)</li><li><code>bootstrap.css</code> (optional, ersetzt das Bootstrap-Basistheme)</li><li><code>theme.json</code> (optional, <code>name</code> und <code>description</code>)</li></ul>",
"ui_theme_none": "Keine Themes gefunden.",
"unban_pending": "ausstehend",
"unchanged_if_empty": "Unverändert, wenn leer",
"upload": "Hochladen",
@@ -403,12 +413,7 @@
"logo_dark_label": "Invertiert für den Darkmode",
"logo_normal_label": "Normal",
"user_link": "Nutzer-Link",
"filter": "Filter",
"syncjobs": "Sync-Jobs",
"syncjob_max_parallel": "Max. parallele Sync-Prozesse",
"syncjob_max_parallel_info": "Wie viele Sync-Jobs gleichzeitig laufen dürfen (1-32).",
"syncjob_max_kb_per_second": "Bandbreiten-Limit pro Prozess (KB/s)",
"syncjob_max_kb_per_second_info": "0 = unbegrenzt. Begrenzt jeden Sync-Prozess in KB/s; ein Job-Limit (Bytes/s) wird hierdurch zusätzlich begrenzt. Gesamt ≈ dieser Wert × parallele Prozesse."
"filter": "Filter"
},
"danger": {
"access_denied": "Zugriff verweigert oder unvollständige/ungültige Daten",
@@ -427,7 +432,7 @@
"comment_too_long": "Kommentarfeld darf maximal 160 Zeichen enthalten",
"cors_invalid_method": "Allow-Methods enthält eine ungültige Methode",
"cors_invalid_origin": "Allow-Origins enthält eine ungültige Origin",
"defquota_empty": "Standard-Quota darf nicht 0 sein.",
"defquota_empty": "Standard-Quota darf nicht 0 sein",
"demo_mode_enabled": "Demo Mode ist aktiviert",
"description_invalid": "Ressourcenbeschreibung für %s ist ungültig",
"dkim_domain_or_sel_exists": "Ein DKIM-Key für die Domain \"%s\" existiert und wird nicht überschrieben",
@@ -452,13 +457,13 @@
"global_map_write_error": "Kann globale Map ID %s nicht schreiben: %s",
"goto_empty": "Eine Alias-Adresse muss auf mindestens eine gültige Ziel-Adresse zeigen",
"goto_invalid": "Ziel-Adresse %s ist ungültig",
"ham_learn_error": "Ham-Lernfehler: %s",
"ham_learn_error": "Ham Lernfehler: %s",
"iam_test_connection": "Verbindung fehlgeschlagen",
"imagick_exception": "Fataler Bildverarbeitungsfehler",
"img_dimensions_exceeded": "Grafik überschreitet die maximale Bildgröße",
"img_invalid": "Grafik konnte nicht validiert werden",
"img_size_exceeded": "Grafik überschreitet die maximale Dateigröße",
"img_tmp_missing": "Grafik konnte nicht validiert werden: Temporärer Datei nicht gefunden.",
"img_tmp_missing": "Grafik konnte nicht validiert werden: Erstellung temporärer Datei fehlgeschlagen.",
"invalid_bcc_map_type": "Ungültiger BCC-Map-Typ",
"invalid_destination": "Ziel-Format \"%s\" ist ungültig",
"invalid_filter_type": "Ungültiger Filtertyp",
@@ -471,7 +476,7 @@
"invalid_reset_token": "Ungültiger Rücksetz-Token",
"ip_list_empty": "Liste erlaubter IPs darf nicht leer sein",
"is_alias": "%s lautet bereits eine Alias-Adresse",
"is_alias_or_mailbox": "Eine Mailbox, ein Alias oder eine sich aus einer Alias-Domain ergebende Adresse mit dem Namen %s ist bereits vorhanden.",
"is_alias_or_mailbox": "Eine Mailbox, ein Alias oder eine sich aus einer Alias-Domain ergebende Adresse mit dem Namen %s ist bereits vorhanden",
"is_spam_alias": "%s lautet bereits eine temporäre Alias-Adresse (Spam-Alias-Adresse)",
"last_key": "Letzter Key kann nicht gelöscht werden, bitte stattdessen die 2FA deaktivieren.",
"login_failed": "Anmeldung fehlgeschlagen",
@@ -545,7 +550,6 @@
"webauthn_authenticator_failed": "Der ausgewählte Authenticator wurde nicht gefunden",
"webauthn_publickey_failed": "Zu dem ausgewählten Authenticator wurde kein Publickey hinterlegt",
"webauthn_username_failed": "Der ausgewählte Authenticator gehört zu einem anderen Konto",
"ui_theme_invalid": "Theme nicht gefunden",
"unknown": "Ein unbekannter Fehler trat auf",
"unknown_tfa_method": "Unbekannte TFA-Methode",
"unlimited_quota_acl": "Unendliche Quota untersagt durch ACL",
@@ -557,30 +561,7 @@
"template_exists": "Vorlage %s existiert bereits",
"template_id_invalid": "Vorlagen-ID %s ungültig",
"template_name_invalid": "Name der Vorlage ungültig",
"required_data_missing": "Die benötigten Daten: %s fehlen",
"imapsync_source_in_use": "Sync-Quelle %s wird noch von einem Sync-Job verwendet und kann nicht gelöscht werden",
"imapsync_source_token_refresh_failed": "OAuth-Token-Erneuerung für %s fehlgeschlagen",
"imapsync_source_scope_empty": "Für die gewählte Sichtbarkeit mindestens eine Domain wählen",
"imapsync_source_unavailable": "Die gewählte Sync-Quelle ist nicht verfügbar",
"imapsync_source_oauth_connect_required": "Vor dem Speichern dieses Sync-Jobs beim Provider anmelden",
"imapsync_source_name_invalid": "Ungültiger Name der Sync-Quelle",
"imapsync_source_host_invalid": "Ungültiger Hostname oder ungültige IP-Adresse",
"imapsync_source_port_invalid": "Ungültiger Port",
"imapsync_source_enc_invalid": "Ungültige Verschlüsselungsmethode",
"imapsync_source_auth_invalid": "Ungültige Authentifizierungsmethode",
"imapsync_custom_param_invalid": "Eigene Parameter enthalten eine nicht erlaubte imapsync-Option: %s",
"imapsync_source_oauth_required": "OAuth2 erfordert Token-Endpunkt, Client-ID, Client-Secret und Scope",
"imapsync_source_oauth_endpoint_invalid": "Ungültiger OAuth2-Endpunkt: Erforderlich ist eine https://-URL mit gültigem Hostnamen (Quellen, die nicht von einem Administrator angelegt wurden, brauchen einen öffentlich erreichbaren Host)",
"imapsync_source_oauth_extra_invalid": "OAuth2-Extra-Parameter müssen ein JSON-Objekt mit einfachen Werten sein, z. B. {\"resource\": \"https://example.com\"}",
"imapsync_source_oauth_invalid": "Ungültige OAuth2-Konfiguration",
"imapsync_max_parallel_invalid": "Die Anzahl paralleler Prozesse muss zwischen 1 und 32 liegen",
"imapsync_source_description_invalid": "Die Beschreibung darf höchstens 255 Zeichen lang sein",
"imapsync_source_refresh_unsupported": "Die manuelle Token-Erneuerung ist nur für Quellen mit Client-Credentials verfügbar",
"imapsync_custom_params_format": "Eigene Parameter müssen als Liste von Option/Wert-Paaren übermittelt werden",
"imapsync_custom_param_value": "Ungültiger Wert für die imapsync-Option %s (Wert fehlt, Wert bei einem Schalter oder nicht erlaubter Wert)",
"imapsync_job_running": "Sync-Job %s läuft gerade und kann nicht als Nächstes eingeplant werden",
"imapsync_user1_invalid": "Ungültiger Benutzername an der Quelle",
"imapsync_max_kb_per_second_invalid": "Das Bandbreitenlimit muss zwischen 0 und 10485760 KB/s liegen"
"required_data_missing": "Die benötigten Daten: %s fehlen"
},
"datatables": {
"collapse_all": "Alle Einklappen",
@@ -669,6 +650,7 @@
"app_name": "App-Name",
"app_passwd": "App-Passwörter",
"app_passwd_protocols": "Zugelassene Protokolle für App-Passwort",
"automap": "Ordner automatisch mappen (\"Sent items\", \"Sent\" => \"Sent\" etc.)",
"backup_mx_options": "Relay-Optionen",
"bcc_dest_format": "BCC-Ziel muss eine gültige E-Mail-Adresse sein.",
"client_id": "Client-ID",
@@ -676,7 +658,10 @@
"comment_info": "Ein privater Kommentar ist für den Benutzer nicht einsehbar. Ein öffentlicher Kommentar wird als Tooltip im Interface des Benutzers angezeigt.",
"created_on": "Erstellt am",
"custom_attributes": "benutzerdefinierte Attribute",
"delete_ays": "Bitte den Löschvorgang bestätigen.",
"delete1": "Lösche Nachricht nach Übertragung vom Quell-Server",
"delete2": "Lösche Nachrichten von Ziel-Server, die nicht auf Quell-Server vorhanden sind",
"delete2duplicates": "Lösche Duplikate im Ziel",
"delete_ays": "Soll der Löschvorgang wirklich ausgeführt werden?",
"description": "Beschreibung",
"disable_login": "Login verbieten (Mails werden weiterhin angenommen)",
"domain": "Domain bearbeiten",
@@ -699,6 +684,7 @@
"dont_check_sender_acl": "Absender für Domain %s u. Alias-Domain nicht prüfen",
"edit_alias_domain": "Alias-Domain bearbeiten",
"encryption": "Verschlüsselung",
"exclude": "Elemente ausschließen (Regex)",
"extended_sender_acl": "Externe Absenderadressen",
"extended_sender_acl_info": "Der DKIM-Domainkey der externen Absenderdomain sollte in diesen Server importiert werden, falls vorhanden.<br>\r\n Wird SPF verwendet, muss diesem Server der Versand gestattet werden.<br>\r\n Wird eine Domain oder Alias-Domain zu diesem Server hinzugefügt, die sich mit der externen Absenderadresse überschneidet, wird der externe Absender hier entfernt.<br>\r\n Ein Eintrag @domain.tld erlaubt den Versand als *@domain.tld",
"force_pw_update": "Erzwinge Passwortänderung bei nächstem Login",
@@ -729,7 +715,10 @@
"max_aliases": "Max. Aliasse",
"max_mailboxes": "Max. Mailboxanzahl",
"max_quota": "Max. Größe per Mailbox (MiB)",
"maxage": "Maximales Alter in Tagen einer Nachricht, die kopiert werden soll<br><small>(0 = alle Nachrichten kopieren)</small>",
"maxbytespersecond": "Max. Übertragungsrate in Bytes/s (0 für unlimitiert)",
"mbox_rl_info": "Dieses Limit wird auf den SASL Loginnamen angewendet und betrifft daher alle Absenderadressen, die der eingeloggte Benutzer verwendet. Bei Mailbox Ratelimit überwiegt ein Domain-weites Ratelimit.",
"mins_interval": "Intervall (min)",
"mta_sts": "MTA-STS",
"mta_sts_info": "<a href='https://de.wikipedia.org/wiki/STARTTLS#MTA-STS' target='_blank'>MTA-STS</a> ist ein Standard, der den E-Mail-Versand zwischen Mailservern zwingt, TLS mit gültigen Zertifikaten zu verwenden. <br>Er wird verwendet, wenn <a target='_blank' href='https://de.wikipedia.org/wiki/DNS-based_Authentication_of_Named_Entities'>DANE</a> aufgrund fehlender oder nicht unterstützter DNSSEC nicht möglich ist.<br><b>Hinweis</b>: Wenn die empfangende Domain DANE mit DNSSEC unterstützt, wird DANE <b>immer</b> bevorzugt – MTA-STS fungiert nur als Fallback.",
"mta_sts_version": "Version",
@@ -781,6 +770,7 @@
"sender_acl_info": "Wird einem Mailbox-Benutzer A der Versand als Mailbox-Benutzer B gestattet, so erscheint der Absender <b>nicht</b> automatisch in SOGo zur Auswahl.<br>\r\n In SOGo muss zusätzlich eine Delegation eingerichtet werden. Dieses Verhalten trifft nicht auf Alias-Adressen zu.",
"sieve_desc": "Kurze Beschreibung",
"sieve_type": "Filtertyp",
"skipcrossduplicates": "Duplikate auch über Ordner hinweg überspringen (\"first come, first serve\")",
"sogo_access": "Direktes weiterleiten an SOGo",
"sogo_access_info": "Nach dem Einloggen wird der Benutzer automatisch an SOGo weitergeleitet.",
"sogo_visible": "Alias in SOGo sichtbar",
@@ -789,8 +779,12 @@
"spam_filter": "Spamfilter",
"spam_policy": "Hinzufügen und Entfernen von Einträgen in Allow- und Denylisten",
"spam_score": "Einen benutzerdefiniterten Spam-Score festlegen",
"target_address": "Ziel-Adresse(n)<small>(kommasepariert)</small>",
"subfolder2": "Ziel-Ordner<br><small>(leer = kein Unterordner)</small>",
"syncjob": "Sync-Job bearbeiten",
"target_address": "Ziel-Adresse(n)",
"target_domain": "Ziel-Domain",
"timeout1": "Timeout für Verbindung zum Remote-Host",
"timeout2": "Timeout für Verbindung zum lokalen Host",
"title": "Objekt bearbeiten",
"unchanged_if_empty": "Unverändert, wenn leer",
"username": "Benutzername",
@@ -969,7 +963,7 @@
"private_comment": "Privater Kommentar",
"public_comment": "Öffentlicher Kommentar",
"q_add_header": "bei Mail in Junk-Ordner",
"q_all": " bei Reject und Mail in Junk-Ordner",
"q_all": "bei Reject und Mail in Junk-Ordner",
"q_reject": "bei Reject",
"quarantine_category": "Quarantäne-Benachrichtigungskategorie",
"quarantine_notification": "Quarantäne-Benachrichtigung",
@@ -1004,6 +998,7 @@
"spam_aliases": "Spam-Alias",
"stats": "Statistik",
"status": "Status",
"sync_jobs": "Synchronisationen",
"table_size": "Tabellengröße",
"table_size_show_n": "Zeige %s Einträge",
"target_address": "Ziel-Adresse",
@@ -1031,10 +1026,18 @@
"open_logs": "Öffne Logs",
"recipient": "Empfänger",
"sender": "Sender",
"syncjob_EX_OK": "Erfolg",
"syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Falscher Benutzername oder Passwort",
"syncjob_check_log": "Logs überprüfen",
"all_domains": "Alle Domains",
"catch_all": "Catch-All",
"iam": "Identitätsanbieter",
"force_tfa": "2-Faktor-Authentifizierung"
"syncjob_last_run_result": "Letztes Ausführungsergebnis",
"syncjob_EXIT_CONNECTION_FAILURE": "Verbindungsproblem",
"syncjob_EXIT_TLS_FAILURE": "Problem mit verschlüsselter Verbindung",
"syncjob_EXIT_AUTHENTICATION_FAILURE": "Authentifizierungsproblem",
"syncjob_EXIT_OVERQUOTA": "Ziel Mailbox ist über dem Limit",
"syncjob_EXIT_CONNECTION_FAILURE_HOST1": "Kann keine Verbindung zum Zielserver herstellen",
"iam": "Identitätsanbieter"
},
"oauth2": {
"access_denied": "Bitte als Mailbox-Nutzer einloggen, um den Zugriff via OAuth2 zu erlauben.",
@@ -1203,18 +1206,11 @@
"tls_policy_map_entry_deleted": "TLS-Richtlinie mit der ID %s wurde gelöscht",
"tls_policy_map_entry_saved": "TLS-Richtlinieneintrag \"%s\" wurde gespeichert",
"ui_texts": "Änderungen an UI-Texten",
"ui_theme_modified": "Theme wurde erfolgreich gespeichert",
"upload_success": "Datei wurde erfolgreich hochgeladen",
"verified_fido2_login": "FIDO2-Anmeldung verifiziert",
"verified_totp_login": "TOTP-Anmeldung verifiziert",
"verified_webauthn_login": "WebAuthn-Anmeldung verifiziert",
"verified_yotp_login": "Yubico-OTP-Anmeldung verifiziert",
"imapsync_source_added": "Sync-Quelle %s wurde hinzugefügt",
"imapsync_source_modified": "Sync-Quelle %s wurde gespeichert",
"imapsync_source_deleted": "Sync-Quelle %s wurde gelöscht",
"imapsync_source_token_refreshed": "OAuth-Token für %s wurde erneuert",
"max_parallel_saved": "Sync-Job-Einstellungen gespeichert",
"imapsync_run_next_done": "%s Sync-Job(s) an den Anfang der Warteschlange gesetzt"
"verified_yotp_login": "Yubico-OTP-Anmeldung verifiziert"
},
"tfa": {
"authenticators": "Authentikatoren",
@@ -1235,7 +1231,7 @@
"set_tfa": "Konfiguriere Zwei-Faktor-Authentifizierungsmethode",
"start_webauthn_validation": "Starte Validierung",
"tfa": "Zwei-Faktor-Authentifizierung",
"tfa_token_invalid": "2FA-Token ungültig",
"tfa_token_invalid": "TFA-Token ungültig!",
"totp": "Time-based-OTP (Google Authenticator etc.)",
"u2f_deprecated": "Es sieht so aus als wurde der Schlüssel mit der alten U2F Methode registriert. Wir werden die Zwei-Faktor-Authentifizierung deaktivieren und deinen Schlüssel löschen.",
"u2f_deprecated_important": "Bitte registriere den Schlüssel im Adminbereich mit der neuen WebAuthn Methode.",
@@ -1249,126 +1245,6 @@
"setup_required": "Ihr Konto erfordert Zwei-Faktor-Authentifizierung. Bitte richten Sie eine 2FA-Methode ein, um fortzufahren.",
"cancel_setup": "Abbrechen und abmelden"
},
"syncjobs": {
"sources_panel": "Sync-Quellen",
"sources_hint": "Sync-Quellen definieren IMAP-Server (mit ihrer Authentifizierungsmethode), die von Sync-Jobs wiederverwendet werden können. Jede Quelle wird einmal angelegt und dann beim Anlegen eines Sync-Jobs aus dem Dropdown ausgewählt.",
"source_add": "Quelle hinzufügen",
"source": "Quelle",
"source_name": "Name",
"source_description": "Beschreibung",
"source_scope": "Sichtbarkeit",
"source_scope_all": "Alle Benutzer",
"source_scope_domain": "Bestimmte Domains",
"source_scope_user": "Bestimmte Benutzer",
"source_domains": "Domains",
"source_users": "Benutzer",
"source_select": "Quelle wählen",
"source_auth_type": "Authentifizierung",
"source_oauth_block": "OAuth2-Konfiguration",
"source_token_status": "Token gültig bis",
"source_token_error": "Letzter Refresh-Fehler",
"source_refresh_now": "Token jetzt erneuern",
"source_secret_unchanged_hint": "Leer lassen, um das gespeicherte Secret beizubehalten.",
"source_oauth_flow": "OAuth2-Flow",
"source_oauth_flow_app": "App (Client Credentials)",
"source_oauth_flow_user": "Benutzer-Login (Authorization Code)",
"source_flow_badge_app": "Client Credentials",
"source_flow_badge_user": "Benutzer-Login",
"token_state_issued": "Token ausgestellt",
"token_state_pending": "Anmeldung ausstehend",
"token_state_failed": "Token fehlgeschlagen",
"source_authorize_endpoint": "Autorisierungs-Endpunkt",
"source_userinfo_endpoint": "Userinfo-Endpunkt",
"source_redirect_uri": "Redirect-URI",
"source_redirect_uri_hint": "Diese URI in der Provider-App (z. B. Azure) eintragen. Die Anmeldung beim Provider funktioniert nur, wenn mailcow über diesen Hostnamen aufgerufen wird.",
"syncjob_oauth_connect": "Beim Provider anmelden",
"syncjob_oauth_connected": "Verbunden",
"sync_jobs": "Sync-Jobs",
"syncjob_check_log": "Logs überprüfen",
"syncjob_last_run_result": "Letztes Ausführungsergebnis",
"syncjob_EX_OK": "Erfolg",
"syncjob_EXIT_CONNECTION_FAILURE": "Verbindungsproblem",
"syncjob_EXIT_TLS_FAILURE": "Problem mit verschlüsselter Verbindung",
"syncjob_EXIT_AUTHENTICATION_FAILURE": "Authentifizierungsproblem",
"syncjob_EXIT_OVERQUOTA": "Ziel Mailbox ist über dem Limit",
"syncjob_EXIT_CONNECTION_FAILURE_HOST1": "Keine Verbindung zum Quellserver möglich",
"syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Falscher Benutzername oder Passwort",
"syncjob_OAUTH_TOKEN_NOT_READY": "OAuth-Token nicht bereit (erneut anmelden oder auf die Token-Erneuerung warten)",
"add": "Sync-Job hinzufügen",
"add_hint": "Passwörter werden unverschlüsselt abgelegt!",
"edit": "Sync-Job bearbeiten",
"create": "Neuen Sync-Job erstellen",
"dry": "Synchronisation simulieren",
"subfolder2": "Ziel-Ordner<br><small>(leer = kein Unterordner)</small>",
"mins_interval": "Abrufintervall (Minuten)",
"maxage": "Maximales Alter in Tagen einer Nachricht, die kopiert werden soll<br><small>(0 = alle Nachrichten kopieren)</small>",
"exclude": "Elemente ausschließen (Regex)",
"automap": "Ordner automatisch mappen (\"Sent items\", \"Sent\" => \"Sent\" etc.)",
"delete1": "Nachrichten nach der Übertragung auf dem Quellserver löschen",
"delete2": "Nachrichten im Ziel löschen, die auf dem Quellserver nicht vorhanden sind",
"delete2duplicates": "Lösche Duplikate im Ziel",
"skipcrossduplicates": "Duplikate auch über Ordner hinweg überspringen (\"first come, first serve\")",
"subscribeall": "Alle synchronisierten Ordner abonnieren",
"timeout1": "Timeout für die Verbindung zum Quellserver",
"timeout2": "Timeout für die Verbindung zum lokalen Server",
"maxbytespersecond": "Max. Übertragungsrate in Bytes/s<br><small>(0 = unbegrenzt, zusätzlich begrenzt durch das globale Limit pro Prozess in KB/s)</small>",
"custom_params": "Eigene Parameter",
"custom_params_hint": "Pro Zeile eine imapsync-Option. Es werden nur erlaubte Optionen akzeptiert; der Wert darf beliebige Zeichen enthalten (Leerzeichen, Regex, …) und wird als ein einzelnes Argument übergeben.",
"custom_param_option": "Option",
"custom_param_value": "Wert",
"custom_param_no_value": "kein Wert nötig",
"custom_param_add": "Parameter hinzufügen",
"prio": "Priorität",
"prio_hint": "Jobs mit höherer Priorität laufen zuerst. Bei gleicher Priorität kommen noch nie gelaufene Jobs zuerst, danach die am längsten nicht gelaufenen (0 = normal).",
"oauth_popup_title": "Beim Provider anmelden",
"oauth_popup_success": "Verbunden. Dieses Fenster kann geschlossen werden.",
"oauth_popup_failed": "Anmeldung fehlgeschlagen. Dieses Fenster kann geschlossen werden.",
"oauth_error_access_denied": "Sie dürfen keine Sync-Jobs verwalten",
"oauth_error_mailbox_denied": "Kein Zugriff auf die gewählte lokale Mailbox",
"oauth_error_source_unavailable": "Die Sync-Quelle ist nicht verfügbar oder nicht für die Anmeldung beim Provider eingerichtet",
"oauth_error_provider_denied": "Der Provider hat die Anmeldung abgelehnt",
"oauth_error_invalid_state": "Die Anmeldesitzung ist abgelaufen oder ungültig, bitte erneut versuchen",
"oauth_error_token_failed": "Der Provider hat kein Token ausgestellt (Client-ID, Secret und Redirect-URI prüfen)",
"oauth_error_no_identity": "Der Provider hat keine E-Mail-Adresse für das angemeldete Konto geliefert",
"user2_label": "Lokale Mailbox (Ziel)",
"user1_label": "Benutzername an der Quelle",
"password1_label": "Passwort an der Quelle",
"no_sources": "Keine Sync-Quelle verfügbar. Bitte zuerst eine Sync-Quelle anlegen.",
"source_list_error": "Sync-Quellen konnten nicht geladen werden",
"source_not_visible": "(nicht sichtbar)",
"oauth_waiting": "Warte auf den Provider…",
"oauth_popup_blocked": "Das Anmeldefenster wurde blockiert. Bitte Pop-ups für diese Seite erlauben und erneut versuchen.",
"oauth_select_mailbox": "Bitte zuerst die lokale Mailbox wählen",
"oauth_error_unknown": "Anmeldung fehlgeschlagen",
"oauth_hint": "Der Benutzername an der Quelle wird aus dem Konto übernommen, mit dem die Anmeldung beim Provider erfolgt.",
"token_state_none": "Token ausstehend",
"custom_params_legacy": "Alt-Parameter (nicht übernommen)",
"custom_params_legacy_hint": "Diese eigenen Parameter des ursprünglichen Sync-Jobs konnten nicht übernommen werden und werden ignoriert. Noch benötigte Optionen bitte oben im Parameter-Editor neu anlegen.",
"custom_params_legacy_badge": "Alt-Parameter",
"custom_param_remove": "Parameter entfernen",
"never_run": "Noch nie",
"status_inactive": "Inaktiv",
"source_server": "Quellserver",
"id": "ID",
"source_created_by": "Erstellt von",
"source_oauth_token_endpoint": "Token-Endpunkt",
"source_oauth_client_id": "Client-ID",
"source_oauth_client_secret": "Client-Secret",
"source_oauth_scope": "Scope",
"source_oauth_extra_params": "Zusätzliche Parameter (JSON)",
"source_oauth_extra_params_hint": "Optionales JSON-Objekt mit zusätzlichen Parametern für die Token-Anfrage, z. B. {\"resource\": \"https://provider.example.com\"}",
"source_scope_cc_hint": "Quellen mit Client-Credentials-Flow sind immer nur für ihren Ersteller sichtbar.",
"source_readonly_hint": "Diese Sync-Quelle ist freigegeben und kann nur angesehen werden.",
"copy": "Kopieren",
"copied": "In die Zwischenablage kopiert",
"source_view": "Anzeigen",
"custom_params_options_error": "Die Liste der erlaubten imapsync-Optionen konnte nicht geladen werden",
"source_missing": "Diesem Sync-Job ist keine Sync-Quelle zugeordnet. Bitte eine Quelle wählen.",
"oauth_window_closed": "Das Anmeldefenster wurde geschlossen, bevor ein Ergebnis ankam. Falls die Anmeldung abgeschlossen wurde, die Seite neu laden oder es erneut versuchen.",
"custom_params_legacy_badge_title": "Nicht übernommene alte Parameter werden ignoriert. Details auf der Bearbeiten-Seite.",
"status_waiting": "Wartend",
"status_running": "Wird ausgeführt"
},
"user": {
"action": "Aktion",
"active": "Aktiv",
@@ -1399,6 +1275,7 @@
"clear_recent_successful_connections": "Alle erfolgreichen Verbindungen bereinigen",
"client_configuration": "Konfigurationsanleitungen für E-Mail-Programme und Smartphones anzeigen",
"create_app_passwd": "Erstelle App-Passwort",
"create_syncjob": "Neuen Sync-Job erstellen",
"created_on": "Erstellt am",
"daily": "Täglich",
"day": "Tag",
@@ -1483,7 +1360,7 @@
"shared_aliases_desc": "Geteilte Alias-Adressen werden nicht bei benutzerdefinierten Einstellungen, wie die des Spam-Filters oder der Verschlüsselungsrichtlinie, berücksichtigt. Entsprechende Spam-Filter können lediglich von einem Administrator vorgenommen werden.",
"show_sieve_filters": "Zeige aktiven Filter des Benutzers",
"sogo_profile_reset": "SOGo-Profil zurücksetzen",
"sogo_profile_reset_help": "Das Profil wird inklusive <b>aller Kalender- und Kontaktdaten unwiederbringlich gelöscht</b>.",
"sogo_profile_reset_help": "Das Profil wird inklusive <b>aller</b> Kalender- und Kontaktdaten <b>unwiederbringlich gelöscht</b>.",
"sogo_profile_reset_now": "Profil jetzt zurücksetzen",
"spam_aliases": "Spam E-Mail-Aliasse",
"spam_aliases_info": "Ein Spam-Alias ist eine temporäre E-Mailadresse, die benutzt werden kann, um eine echte E-Mail Adressen zu schützen. <br>Optional kann eine Ablaufzeit gesetzt werden, sodass der Alias nach dem definierten Zeitraum automatisch deaktiviert wird, was missbrauchte oder geleakte Adressen effektiv entsorgt.",
@@ -1506,6 +1383,7 @@
"spamfilter_wl_desc": "Für E-Mail-Adressen, die vom Spamfilter <b>nicht</b> erfasst werden sollen. Die Verwendung von Wildcards ist gestattet. Ein Filter funktioniert lediglich für direkte Nicht-„Catch-All“-Alias-Adressen (Alias-Adressen mit lediglich einer Mailbox als Ziel-Adresse) sowie die Mailbox-Adresse selbst.",
"spamfilter_yellow": "Gelb: Die Nachricht ist vielleicht Spam, wird als Spam markiert und in den Junk-Ordner verschoben",
"status": "Status",
"sync_jobs": "Sync Jobs",
"tag_handling": "Umgang mit getaggten E-Mails steuern",
"tag_help_example": "Beispiel für eine getaggte E-Mail-Adresse: ich<b>+Facebook</b>@example.org",
"tag_help_explain": "Als Unterordner: Es wird ein Ordner mit dem Namen des Tags unterhalb der Inbox erstellt (\"INBOX/Facebook\").<br>\r\nIn Betreff: Der Name des Tags wird dem Betreff angefügt, etwa \"[Facebook] Meine Neuigkeiten\".",
@@ -1530,7 +1408,16 @@
"with_app_password": "mit App-Passwort",
"year": "Jahr",
"years": "Jahren",
"syncjob_EX_OK": "Erfolg",
"open_logs": "Öffne Logs",
"syncjob_check_log": "Logs überprüfen",
"syncjob_EXIT_CONNECTION_FAILURE_HOST1": "Kann keine Verbindung zum Zielserver herstellen",
"syncjob_EXIT_OVERQUOTA": "Ziel Mailbox ist über dem Limit",
"syncjob_last_run_result": "Letztes Ausführungsergebnis",
"syncjob_EXIT_CONNECTION_FAILURE": "Verbindungsproblem",
"syncjob_EXIT_TLS_FAILURE": "Problem mit verschlüsselter Verbindung",
"syncjob_EXIT_AUTHENTICATION_FAILURE": "Authentifizierungsproblem",
"syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Falscher Benutzername oder Passwort",
"pushover_sound": "Ton"
},
"warning": {
+52 -164
View File
@@ -45,16 +45,24 @@
"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>",
@@ -73,6 +81,7 @@
"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",
@@ -92,10 +101,16 @@
"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"
@@ -390,11 +405,6 @@
"ui_header_announcement_type_info": "Info",
"ui_header_announcement_type_warning": "Important",
"ui_texts": "UI labels and texts",
"ui_theme": "Theme",
"ui_theme_dark": "Includes a dark mode variant",
"ui_theme_default": "Default",
"ui_theme_info": "Themes are folders in <code>data/web/css/themes/&lt;name&gt;/</code> with the following files:<ul class=\"mb-0\"><li><code>theme.css</code> (required)</li><li><code>dark.css</code> (optional, dark mode)</li><li><code>bootstrap.css</code> (optional, replaces the Bootstrap base theme)</li><li><code>theme.json</code> (optional, <code>name</code> and <code>description</code>)</li></ul>",
"ui_theme_none": "No themes found.",
"unban_pending": "unban pending",
"unchanged_if_empty": "If unchanged leave blank",
"upload": "Upload",
@@ -403,12 +413,7 @@
"user_quicklink": "Hide Quicklink to User Login Page",
"validate_license_now": "Validate GUID against license server",
"verify": "Verify",
"yes": "&#10003;",
"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."
"yes": "&#10003;"
},
"danger": {
"access_denied": "Access denied or invalid form data",
@@ -549,7 +554,6 @@
"webauthn_authenticator_failed": "The selected authenticator was not found",
"webauthn_publickey_failed": "No public key was stored for the selected authenticator",
"webauthn_username_failed": "The selected authenticator belongs to another account",
"ui_theme_invalid": "Theme not found",
"unknown": "An unknown error occurred",
"unknown_tfa_method": "Unknown TFA method",
"unlimited_quota_acl": "Unlimited quota prohibited by ACL",
@@ -557,30 +561,7 @@
"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",
"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"
"yotp_verification_failed": "Yubico OTP verification failed: %s"
},
"datatables": {
"collapse_all": "Collapse All",
@@ -669,6 +650,7 @@
"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",
@@ -676,6 +658,9 @@
"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)",
@@ -699,6 +684,7 @@
"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",
@@ -729,7 +715,10 @@
"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",
@@ -782,6 +771,7 @@
"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",
@@ -790,8 +780,12 @@
"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,6 +1007,16 @@
"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",
@@ -1210,18 +1214,11 @@
"tls_policy_map_entry_deleted": "TLS policy map ID %s has been deleted",
"tls_policy_map_entry_saved": "TLS policy map entry \"%s\" has been saved",
"ui_texts": "Saved changes to UI texts",
"ui_theme_modified": "Theme was saved successfully",
"upload_success": "File uploaded successfully",
"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",
"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"
"verified_yotp_login": "Verified Yubico OTP login"
},
"tfa": {
"authenticators": "Authenticators",
@@ -1256,126 +1253,6 @@
"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",
@@ -1406,6 +1283,7 @@
"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",
@@ -1515,6 +1393,16 @@
"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\".",
+40 -31
View File
@@ -40,12 +40,19 @@
"alias_address_info": "<small>Dirección(es) de correo completa(s) ó @dominio.com, para atrapar todos los mensajes para un dominio (separado por coma). <b>Dominios que existan en mailcow solamente</b>.</small>",
"alias_domain": "Dominio alias",
"alias_domain_info": "<small>Nombres de dominio válidos solamente (separado por coma).</small>",
"automap": "Intentar enlazar carpetas (\"Sent items\", \"Sent\" => \"Sent\" etc.)",
"backup_mx_options": "Opciones del respaldo MX:",
"custom_params": "Parámetros personalizados",
"custom_params_hint": "Correcto: --param=xy, Incorrecto: --param xy",
"delete1": "Eliminar de la fuente cuando se complete",
"delete2": "Eliminar mensajes en el destino que no están en la fuente",
"delete2duplicates": "Eliminar duplicados en el destino",
"description": "Descripción",
"destination": "Destino",
"domain": "Dominio",
"domain_quota_m": "Cuota total del dominio (MiB)",
"enc_method": "Método de cifrado",
"exclude": "Excluir objectos (regex)",
"full_name": "Nombre completo",
"gal": "Lista global de direcciones (GAL)",
"gal_info": "El GAL contiene todos los objetos de un dominio y no puede ser editado por ningún usuario. Falta información de disponibilidad en SOGo, si está desactivada. <b>Reinicia SOGo para aplicar los cambios.</b>",
@@ -60,6 +67,7 @@
"mailbox_username": "Nombre de usuario (parte izquierda de una dirección de correo)",
"max_aliases": "Máx. alias posibles",
"max_mailboxes": "Máx. buzones posibles",
"mins_interval": "Intervalo de sondeo (minutos)",
"multiple_bookings": "Múltiples reservas",
"nexthop": "Siguiente destino",
"password": "Contraseña",
@@ -74,9 +82,15 @@
"select_domain": "Por favor elige un dominio primero",
"sieve_desc": "Descripción",
"sieve_type": "Tipo de filtro",
"skipcrossduplicates": "Omitir mensajes duplicados en carpetas (orden de llegada)",
"subscribeall": "Suscribirse a todas las carpetas",
"syncjob": "Añadir trabajo de sincronización",
"syncjob_hint": "Tenga en cuenta que las contraseñas deben guardarse en texto plano sin cifrar",
"target_address": "Direcciones destino:",
"target_address_info": "<small>Dirección(es) de correo completa(s) (separado por coma).</small>",
"target_domain": "Dominio destino:",
"timeout1": "Tiempo de espera para la conexión al host remoto",
"timeout2": "Tiempo de espera para la conexión al host local",
"username": "Usuario",
"validate": "Validar",
"validation_success": "Validado exitosamente",
@@ -86,6 +100,7 @@
"public_comment": "Comentarios públicos",
"disable_login": "Desactivar login (el correo entrante seguirá activo)",
"comment_info": "Los comentarios privados no son visibles al usuario, mientras que los comentarios públicos aparecerán sobre la información general del usuario",
"dry": "Simular la sincronización",
"private_comment": "Comentario privado",
"app_passwd_protocols": "Protocolos autorizados para la contraseña de la aplicación",
"relay_transport_info": "<div class=\"label label-info\">Información</div> Puede definir mapas de transporte para un destino personalizado para este dominio. En caso de no definirlo, se realizará una búsqueda MX.",
@@ -580,10 +595,14 @@
"edit": {
"active": "Activo",
"alias": "Editar alias",
"automap": "Intentar enlazar carpetas (\"Sent items\", \"Sent\" => \"Sent\" etc.)",
"backup_mx_options": "Opciones del respaldo MX:",
"bcc_dest_format": "El destino BCC debe ser una única dirección de correo electrónico válida",
"client_id": "ID de Cliente",
"client_secret": "Secreto de cliente",
"delete1": "Eliminar de la fuente cuando se complete",
"delete2": "Eliminar mensajes en el destino que no están en la fuente",
"delete2duplicates": "Eliminar duplicados en el destino",
"description": "Descripción:",
"domain": "Editar dominio",
"domain_admin": "Editar administrador del dominio",
@@ -592,6 +611,7 @@
"dont_check_sender_acl": "No verificar remitente para el dominio %s",
"edit_alias_domain": "Editar alias de dominio",
"encryption": "Cifrado",
"exclude": "Excluir objectos (regex)",
"force_pw_update": "Forzar cambio de contraseña en el próximo inicio de sesión",
"force_pw_update_info": "Este usuario solo podrá iniciar sesión en la interfaz de usuario.",
"full_name": "Nombre completo",
@@ -607,6 +627,9 @@
"max_aliases": "Máx. alias:",
"max_mailboxes": "Máx. buzones posibles:",
"max_quota": "Máx. cuota por buzón (MiB):",
"maxage": "Antigüedad máxima de los mensajes en días que se migrarán<br><small>(0 = ignorar edad)</small>",
"maxbytespersecond": "Max. bytes por segundo <br><small>(0 = ilimitado)</small>",
"mins_interval": "Intervalo (min)",
"multiple_bookings": "Múltiples reservas",
"nexthop": "Siguiente destino",
"password": "Contraseña:",
@@ -625,8 +648,13 @@
"sender_acl": "Permitir envío como:",
"sieve_desc": "Descripción",
"sieve_type": "Tipo de filtro",
"skipcrossduplicates": "Omitir mensajes duplicados en carpetas (orden de llegada)",
"subfolder2": "Sincronizar en subcarpeta en destino<br><small>(vacío = no usar subcarpeta)</small>",
"syncjob": "Editar trabajo de sincronización",
"target_address": "Dirección/es destino <small>(separadas por coma)</small>:",
"target_domain": "Dominio destino:",
"timeout1": "Tiempo de espera para la conexión al host remoto",
"timeout2": "Tiempo de espera para la conexión al host local",
"title": "Editar objeto",
"unchanged_if_empty": "Si no hay cambios dejalo en blanco",
"username": "Nombre de usuario",
@@ -838,6 +866,7 @@
"set_prefilter": "Marcar como prefiltro",
"sieve_info": "Puedes almacenar múltiples filtros por usuario, pero solo un prefiltro y un postfiltro pueden estar activos al mismo tiempo.<br>\r\nCada filtro será procesado en el orden descrito. Ni un script fallido ni un \"keep;\" emitido detendrá el procesamiento de otros scripts.<br>\r\n<a href=\"https://github.com/mailcow/mailcow-dockerized/blob/master/data/conf/dovecot/global_sieve_before\" target=\"_blank\">Prefiltro sieve global</a> → Prefiltro → Scripts del usuario → Posfiltro → <a href=\"https://github.com/mailcow/mailcow-dockerized/blob/master/data/conf/dovecot/global_sieve_after\" target=\"_blank\">Posfiltro sieve global</a>",
"status": "Status",
"sync_jobs": "Trabajos de sincronización",
"target_address": "Dirección destino",
"target_domain": "Dominio destino",
"tls_enforce_in": "Forzar TLS entrance",
@@ -857,6 +886,7 @@
"sieve_preset_4": "Colocar en bandeja de entrada, omitir procesamiento posterior en filtros de sieve",
"goto_spam": "Aprender como <b>correo no deseado</b>",
"sieve_preset_header": "Vea los preajustes de ejemplo más abajo. Para más detalles, consulte <a href=\"https://en.wikipedia.org/wiki/Sieve_(mail_filtering_language)\" target=\"_blank\">Wikipedia</a>.",
"syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Nombre de usuario o contraseña incorrectos",
"tls_policy_maps_enforced_tls": "Estas políticas tendrán precedencia también para aquellos usuarios de buzones en los que sea obligatoria una conexión TLS. Si no se indica ninguna política a continuación, dichos usuarios aplicarán los valores predeterminados que se especifiquen en <code>smtp_tls_mandatory_protocols</code> y <code>smtp_tls_mandatory_ciphers</code>.",
"alias_domain_alias_hint": "Los alias <b>no</b> se aplican a dominios de alias automáticamente. Una dirección alias <code>mi-alias@dominio</code> <b>no cubre</b> la dirección <code>mi-alias@dominio-alias</code> (donde \"dominio-alias\" es un hipotético alias para el dominio \"dominio\").<br>Utilice un filtro sieve para redirigir el correo a un buzón externo (ver la pestaña \"Filtros\" o utilice SOGo -> Desvío). Utilice \"expandir alias a dominio de alias\" para agregar automáticamente los alias que falten.",
"domain_templates": "Plantillas de dominio",
@@ -900,6 +930,14 @@
"sogo_visible": "Alias visible en SOGo",
"sogo_visible_n": "Ocultar alias en SOGo",
"stats": "Estadísticas",
"syncjob_check_log": "Comprobar registros",
"syncjob_last_run_result": "Resultado de la última ejecución",
"syncjob_EX_OK": "Éxito",
"syncjob_EXIT_CONNECTION_FAILURE": "Problema de conexión",
"syncjob_EXIT_TLS_FAILURE": "Problema con la conexión cifrada",
"syncjob_EXIT_AUTHENTICATION_FAILURE": "Problema de autenticación",
"syncjob_EXIT_OVERQUOTA": "El buzón de destino ha superado la cuota",
"syncjob_EXIT_CONNECTION_FAILURE_HOST1": "No es posible conectar con el servidor remoto",
"table_size": "Tamaño de la tabla",
"table_size_show_n": "Mostrar %s elementos",
"templates": "Plantillas",
@@ -1031,37 +1069,6 @@
"waiting_usb_register": "<i>Esperando al dispositivo USB....</i><br><br>Ingrese su contraseña arriba y confirme su registro WebAuthn tocando el botón en su dispositivo USB WebAuthn.",
"yubi_otp": "Yubico OTP"
},
"syncjobs": {
"sync_jobs": "Trabajos de sincronización",
"syncjob_check_log": "Comprobar registros",
"syncjob_last_run_result": "Resultado de la última ejecución",
"syncjob_EX_OK": "Éxito",
"syncjob_EXIT_CONNECTION_FAILURE": "Problema de conexión",
"syncjob_EXIT_TLS_FAILURE": "Problema con la conexión cifrada",
"syncjob_EXIT_AUTHENTICATION_FAILURE": "Problema de autenticación",
"syncjob_EXIT_OVERQUOTA": "El buzón de destino ha superado la cuota",
"syncjob_EXIT_CONNECTION_FAILURE_HOST1": "No es posible conectar con el servidor remoto",
"syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Nombre de usuario o contraseña incorrectos",
"add": "Añadir trabajo de sincronización",
"add_hint": "Tenga en cuenta que las contraseñas deben guardarse en texto plano sin cifrar",
"edit": "Editar trabajo de sincronización",
"create": "Crear nuevo trabajo de sincronización",
"dry": "Simular la sincronización",
"subfolder2": "Sincronizar en subcarpeta en destino<br><small>(vacío = no usar subcarpeta)</small>",
"mins_interval": "Intervalo de sondeo (minutos)",
"maxage": "Antigüedad máxima de los mensajes en días que se migrarán<br><small>(0 = ignorar edad)</small>",
"exclude": "Excluir objectos (regex)",
"automap": "Intentar enlazar carpetas (\"Sent items\", \"Sent\" => \"Sent\" etc.)",
"delete1": "Eliminar de la fuente cuando se complete",
"delete2": "Eliminar mensajes en el destino que no están en la fuente",
"delete2duplicates": "Eliminar duplicados en el destino",
"skipcrossduplicates": "Omitir mensajes duplicados en carpetas (orden de llegada)",
"subscribeall": "Suscribirse a todas las carpetas",
"timeout1": "Tiempo de espera para la conexión al host remoto",
"timeout2": "Tiempo de espera para la conexión al host local",
"maxbytespersecond": "Max. bytes por segundo <br><small>(0 = ilimitado)</small>",
"custom_params": "Parámetros personalizados"
},
"user": {
"action": "Acción",
"active": "Activo",
@@ -1076,6 +1083,7 @@
"aliases_also_send_as": "También permitido enviar como",
"aliases_send_as_all": "No verificar permisos del remitente para los siguientes dominios (y sus aliases)",
"change_password": "Cambiar contraseña",
"create_syncjob": "Crear nuevo trabajo de sincronización",
"created_on": "Creado",
"daily": "Cada día",
"day": "Día",
@@ -1130,6 +1138,7 @@
"spamfilter_wl_desc": "Direcciones en la lista blanca <b>nunca</b> clasificarán como spam. Se puede usar comodines. Filtro solo aplica a los alias directos (alias con un solo buzón de destino) excluyendo los alias catch-all.",
"spamfilter_yellow": "Amarillo: éste mensaje puede ser spam, será etiquetado como spam y trasladado a tu carpeta de correo no deseado",
"status": "Status",
"sync_jobs": "Trabajos de sincronización",
"tag_handling": "Establecer manejo para el correo etiquetado",
"tag_help_example": "Ejemplo de una dirección email etiquetada: mi<b>+Facebook</b>@ejemplo.org",
"tag_help_explain": "En subcarpeta: una nueva subcarpeta llamada como la etiqueta será creada debajo de INBOX (\"INBOX/Facebook\").<br>\r\nEn asunto: los nombres de las etiquetas serán añadidos al asunto de los correos, ejemplo: \"[Facebook] Mis Noticias\".",
+30 -21
View File
@@ -32,14 +32,21 @@
"alias_address_info": "<small>Koko sähköpostiosoite tai @ esimerkki.com, jotta kaikki verkkotunnuksen viestit saadaan (pilkuilla erotetut). <b>mailcow vain verkkotunnukset</b>.</small>",
"alias_domain": "Alias-verkkotunnus",
"alias_domain_info": "<small>Vain kelvolliset verkkotunnukset (pilkuin erotetut).</small>",
"automap": "Yritä yhdistää kansioita automaattisesti(\"Lähetetyt kohteet\", \"Lähetti\" => \"Lähetti\" etc.)",
"backup_mx_options": "Varmuuskopioi MX-asetukset",
"comment_info": "Yksityinen kommentti ei ole näkyvissä käyttäjälle, kun taas julkinen kommentti näytetään työkaluvihjeenä siirtäessäsi sitä käyttäjän katsaukseen",
"custom_params": "Muokatut parametrit",
"custom_params_hint": "Oikea: --param=xy, väärä: --param xy",
"delete1": "Poista lähteestä, kun se on valmis",
"delete2": "Poista määränpään viestit, joita ei ole lähteessä",
"delete2duplicates": "Poista päällekkäisyydet määräpaikasta",
"description": "Kuvaus",
"destination": "Määränpää",
"domain": "Verkkotunnus",
"domain_matches_hostname": "Verkkotunnus %s vastaa isäntänimeä",
"domain_quota_m": "Verkkotunnuksen kokonaiskiintiö (MiB)",
"enc_method": "Salausmenetelmä",
"exclude": "Sulje pois objects (regex)",
"full_name": "Koko nimi",
"gal": "Yleinen osoite luettelo",
"gal_info": "GAL ( kalenteri ) sisältää kaikki verkkotunnus alueen objektit, eikä yksikään käyttäjä voi muokata sitä. Vapaat ja varatut ajat-tiedot SOGosta puuttuu, jos se on poistettu käytöstä! <b>Uudelleen käynnistä SOGo ja ota muutokset käyttöön.</b>",
@@ -54,6 +61,7 @@
"mailbox_username": "Käyttäjätunnus (sähköpostiosoitteen osa)",
"max_aliases": "Max. mahdolliset aliakset",
"max_mailboxes": "Max. mahdolliset sähkö postilaatikot",
"mins_interval": "Äänestysväli (minuutit)",
"multiple_bookings": "Useita varauksia",
"nexthop": "Seuraava pysäkki",
"password": "Salasana",
@@ -71,9 +79,15 @@
"select_domain": "Valitse ensin verkkotunnus",
"sieve_desc": "Lyhyt kuvaus",
"sieve_type": "Suodattimen tyyppi",
"skipcrossduplicates": "Ohita päällekkäiset viestit kansioiden välillä (ensimmäinen tulee, ensimmäinen palvella)",
"subscribeall": "Tilaa kaikki kansiot",
"syncjob": "Lisää synkronointityö",
"syncjob_hint": "Huomaa, että salasanat on tallennettava pelkkänä tekstinä!",
"target_address": "Siirry osoitteisiin",
"target_address_info": "<small>Koko sähköpostiosoite (pilkuilla erotettu).</small>",
"target_domain": "Kohdetunnus",
"timeout1": "Yhteyden aikakatkaisu etäisäntään",
"timeout2": "Aikakatkaisu yhteyden muodostamiseen paikalliseen isäntään",
"username": "Käyttäjätunnus",
"validate": "Vahvista",
"validation_success": "Vahvistettu onnistuneesti"
@@ -391,11 +405,15 @@
"edit": {
"active": "Aktiivinen",
"alias": "Muokkaa aliasta",
"automap": "Yritä yhdistää kansioita automaattisesti(\"Lähetetyt kohteet\", \"Lähetti\" => \"Lähetti\" etc.)",
"backup_mx_options": "Varmuus kopioinnin MX-asetukset",
"bcc_dest_format": "BCC-piilo-kopio kohteen on oltava yksittäinen kelvollinen sähköposti osoite.",
"client_id": "Asiakastunnus ID",
"client_secret": "Asiakas salaisuus",
"comment_info": "Yksityinen kommentti ei ole näkyvissä käyttäjälle, kun taas julkinen kommentti näytetään työkaluvihjeenä siirtäessäsi sitä käyttäjän katsaukseen",
"delete1": "Poista lähteestä, kun se on valmis",
"delete2": "Poista määränpään viestit, joita ei ole lähteessä",
"delete2duplicates": "Poista päällekkäisyydet määräpaikasta",
"description": "Kuvaus",
"domain": "Muokkaa verkkotunnus aluetta",
"domain_admin": "Muokkaa verkkotunnus alueen järjestelmänvalvojaa",
@@ -404,6 +422,7 @@
"dont_check_sender_acl": "Poista lähettäjän verkkotunnus alueen tarkistus käytöstä %s (+ alias-verkkotunnukset)",
"edit_alias_domain": "Alias-verkkotunnus alueen muokkaaminen",
"encryption": "Salaus",
"exclude": "Objektien jättäminen pois (regex)",
"extended_sender_acl": "Ulkoisten lähettäjien osoitteet",
"extended_sender_acl_info": "DKIM-verkkotunnuksen avain tulisi tuoda, jos se on käytettävissä.<br>\r\nMuista lisätä tämä palvelin vastaavaan SPF TXT-tietueeseen.<br>\r\nAina kun verkkotunnus tai aliaksen verkkotunnus lisätään tähän palvelimeen, joka on päällekkäinen ulkoisen osoitteen kanssa, ulkoinen osoite poistetaan.<br>\r\nKäytä @ domain.tld antaaksesi lähettää muodossa *@domain.tld.",
"force_pw_update": "Pakota salasanan vaihto seuraavan sisään kirjautumisen jälkeen",
@@ -421,7 +440,10 @@
"max_aliases": "Maks. Aliaksia",
"max_mailboxes": "Maksimi. mahdolliset posti laatikot",
"max_quota": "Maks. Kiintiö sähköposti laatikkoa kohden (MiB)",
"maxage": "Viestien enimmäisikä päivinä, jotka lähetetään kyselyllä etänä<br><small>(0 = ignore age)</small>",
"maxbytespersecond": "Maksimi. tavua sekunnissa <br><small>(0 = rajoittamaton)</small>",
"mbox_rl_info": "Tätä nopeutta käytetään SASL-Kirjautumisnimessä, se vastaa mitä tahansa kirjautuneen käyttäjän käyttämän osoitteen \"from\"-osoitetta.Sähköpostin nopeus rajoitus ohittaa toimialuelaajuisen kappale rajoituksen.",
"mins_interval": "Aikaväli (min)",
"multiple_bookings": "Useita varauksia",
"nexthop": "Seuraava pysäkki",
"password": "Salasana",
@@ -444,13 +466,18 @@
"sender_acl_info": "Jos postilaatikon käyttäjän A sallitaan lähettävän postilaatikon käyttäjäksi B, lähettäjän osoitetta ei näytetä automaattisesti valittavana \"alkaen\" -kentässä SOGossa.<br>\r\nSähkö postilaatikon käyttäjän A on luotava valtuutus SOGoon, jotta sähkö postilaatikon käyttäjä b voi valita osoitteen lähettäjäksi. Tämä käyttäytyminen ei koske alias-osoitteita",
"sieve_desc": "Lyhyt kuvaus",
"sieve_type": "Suodattimen tyyppi",
"skipcrossduplicates": "Ohita päällekkäiset viestit kansioiden välillä (ensimmäinen tulee, ensimmäinen palvella)",
"sogo_visible": "Alias näkyy SOGo-kohteessa",
"sogo_visible_info": "Tämä asetus vaikuttaa vain objekteihin, jotka voidaan näyttää SOGo (jaetut tai ei-jaetut alias-osoitteet, jotka viittaavat vähintään yhteen paikalliseen posti laatikkoon).",
"spam_alias": "Luo tai muuta aika rajoitettuja aliaksen osoitteita",
"spam_policy": "Lisää tai Poista kohteita sallituista-/mustalta listalta",
"spam_score": "Mukautetun roska posti arvon asettaminen",
"subfolder2": "Synkronoi kohteen alikansioon<br><small>(tyhjä = älä käytä alikansioon)</small>",
"syncjob": "Muokkaa synkronointi työtä",
"target_address": "Siirry osoitteeseen/es <small>(pilkulla-erotettu)</small>",
"target_domain": "Kohde verkkotunnus alue",
"timeout1": "Yhteyden aikakatkaisu etäisäntään",
"timeout2": "Aikakatkaisu yhteyden muodostamiseen paikalliseen isäntään",
"title": "Muokkaa objektia",
"unchanged_if_empty": "Jos muuttumaton jätä tyhjäksi",
"username": "Käyttäjätunnus",
@@ -596,6 +623,7 @@
"sogo_visible_y": "Näytä alias SOGo-kohteessa",
"spam_aliases": "Temp. alias",
"status": "Tila",
"sync_jobs": "Synkronoi työt",
"target_address": "Siiretty osoitteseen",
"target_domain": "Kohde verkkotunnus alue",
"tls_enforce_in": "Pakota TLS saapuva",
@@ -758,27 +786,6 @@
"waiting_usb_register": "<i>Odotetaan USB-laitetta...</i><br><br>Anna salasanasi yltä ja vahvista WebAuthn-rekisteröinti napauttamalla painiketta WebAuthn USB-laitteessa.",
"yubi_otp": "Yubico OTP-todennus"
},
"syncjobs": {
"sync_jobs": "Synkronoi työt",
"add": "Lisää synkronointityö",
"add_hint": "Huomaa, että salasanat on tallennettava pelkkänä tekstinä!",
"edit": "Muokkaa synkronointi työtä",
"create": "Luo uusi synkronointi työ",
"subfolder2": "Synkronoi kohteen alikansioon<br><small>(tyhjä = älä käytä alikansioon)</small>",
"mins_interval": "Äänestysväli (minuutit)",
"maxage": "Viestien enimmäisikä päivinä, jotka lähetetään kyselyllä etänä<br><small>(0 = ignore age)</small>",
"exclude": "Sulje pois objects (regex)",
"automap": "Yritä yhdistää kansioita automaattisesti(\"Lähetetyt kohteet\", \"Lähetti\" => \"Lähetti\" etc.)",
"delete1": "Poista lähteestä, kun se on valmis",
"delete2": "Poista määränpään viestit, joita ei ole lähteessä",
"delete2duplicates": "Poista päällekkäisyydet määräpaikasta",
"skipcrossduplicates": "Ohita päällekkäiset viestit kansioiden välillä (ensimmäinen tulee, ensimmäinen palvella)",
"subscribeall": "Tilaa kaikki kansiot",
"timeout1": "Yhteyden aikakatkaisu etäisäntään",
"timeout2": "Aikakatkaisu yhteyden muodostamiseen paikalliseen isäntään",
"maxbytespersecond": "Maksimi. tavua sekunnissa <br><small>(0 = rajoittamaton)</small>",
"custom_params": "Muokatut parametrit"
},
"user": {
"action": "Toiminnot",
"active": "Aktiivinen",
@@ -795,6 +802,7 @@
"aliases_send_as_all": "Älä tarkista lähettäjän käyttö oikeuksia seuraaville verkkotunnus alueille ja sen alias-verkko tunnuksille",
"change_password": "Vaihda salasana",
"client_configuration": "Näytä sähköposti ohjelmien ja älypuhelimien konfigurointi oppaat",
"create_syncjob": "Luo uusi synkronointi työ",
"daily": "Päivittäin",
"day": "Päivä",
"description": "Kuvaus",
@@ -856,6 +864,7 @@
"spamfilter_wl_desc": "Sallittujen sähköposti osoitteita<b> ei koskaan</b> luokitella roska postiksi. Yleismerkkejä, voidaan käyttää. Suodatinta käytetään vain suora aliaksia (aliaksia, joilla on yksi kohde posti laatikko), lukuun ottamatta ( catch-all )saalistaa-kaikki aliaksia ja itse posti laatikkoa..",
"spamfilter_yellow": "Keltainen: Tämä viesti voi olla roska postia, merkitään roska postiksi ja siirretään roska posti kansioon",
"status": "Tila",
"sync_jobs": "Synkronoi työt",
"tag_handling": "Merkittyjen Sähkö posti viestien käsittelyn määrittäminen",
"tag_help_example": "Esimerkki merkitylle Sähkö posti osoitteeksi: me<b>+Facebook</b>@example.org",
"tag_help_explain": "Alikansio: uusi tunnisteen jälkeen nimetty alikansio luodaan Saapuneet-kansion alapuolelle (\"Saapuneet/Facebook\").<br>\r\nAihe: tunnisteiden nimi lisätään sähkö postit-aiheeseen, esimerkiksi: \"[Facebook] My News\".",
+49 -31
View File
@@ -43,8 +43,14 @@
"alias_domain_info": "<small>Seulement des noms de domaines valides (séparés par des virgules).</small>",
"app_name": "Nom de l'application",
"app_password": "Mot de passe de l'application",
"automap": "Tenter de cibler automatiquement les dossiers (« Sent items », « Sent » => « Sent » etc.)",
"backup_mx_options": "Options de MX secondaire",
"comment_info": "Un commentaire privé n'est pas visible pour l'utilisateur, tandis qu'un commentaire public est affiché sous forme d'info-bulle lorsqu'on le survole dans un aperçu des utilisateurs",
"custom_params": "Paramètres personnalisés",
"custom_params_hint": "Correct : --param=xy, incorrect : --param xy",
"delete1": "Supprimer sur la source lorsque la synchronisation est terminée",
"delete2": "Supprimer les messages sur la destination qui ne sont pas présents sur la source",
"delete2duplicates": "Supprimer les doublons sur la destination",
"description": "Description",
"destination": "Destination",
"disable_login": "Désactiver l'authentification (les courriels entrants resteront acceptés)",
@@ -52,6 +58,7 @@
"domain_matches_hostname": "Le domaine %s correspond à la machine (hostname)",
"domain_quota_m": "Quota total du domaine (Mio)",
"enc_method": "Méthode de chiffrement",
"exclude": "Exclure des objets (expression régulière - regex)",
"full_name": "Nom complet",
"gal": "Carnet d'Adresses Global (GAL)",
"gal_info": "La liste d'adresse globale (GAL) contient tous les objets d'un domaine et ne peut être modifié par aucun utilisateur. Si elles sont désactivées,les informations libres/occupées dans SOGo sont cachées, ! <b>Redémarrez SOGo pour appliquer les changements.</b>",
@@ -67,6 +74,7 @@
"mailbox_username": "Identifiant (partie gauche d'une adresse de courriel)",
"max_aliases": "Nombre maximal d'alias",
"max_mailboxes": "Nombre maximal de boîtes de réception",
"mins_interval": "Période de relève (minutes)",
"multiple_bookings": "Inscriptions multiples",
"nexthop": "Suivant",
"password": "Mot de passe",
@@ -86,15 +94,22 @@
"select_domain": "Sélectionner d'abord un domaine",
"sieve_desc": "Description courte",
"sieve_type": "Type de filtre",
"skipcrossduplicates": "Ignorer les messages en double dans les dossiers (premier arrivé, premier servi)",
"subscribeall": "S'abonner à tous les dossiers",
"syncjob": "Ajouter une tâche de synchronisation",
"syncjob_hint": "Sachez que les mots de passe seront enregistrés en clair !",
"target_address": "Aller à l'adresse",
"target_address_info": "<small>Adresse(s) de courriel complète(s) (séparées par des virgules).</small>",
"target_domain": "Domaine cible",
"timeout1": "Délai d'expiration pour la connexion à l'hôte distant",
"timeout2": "Délai d'expiration pour la connexion à l'hôte local",
"username": "Nom d'utilisateur",
"validate": "Valider",
"validation_success": "Validation réussie",
"bcc_dest_format": "La destination Cci doit être une seule adresse de courriel valide.<br>Si vous avez besoin d'envoyer une copie à plusieurs adresses, créez un alias et utilisez-le ici.",
"tags": "Etiquettes",
"app_passwd_protocols": "Protocoles autorisés pour le mot de passe de l'application",
"dry": "Simuler la synchronisation",
"internal": "Interne",
"internal_info": "Les alias internes sont accessibles uniquement depuis le domaine ou les alias du domaine.",
"sender_allowed": "Autoriser l'envoi sous cet alias"
@@ -604,11 +619,15 @@
"allowed_protocols": "Protocoles autorisés pour l'accès direct de l'utilisateur (n'affecte pas les protocoles de mot de passe de l'application)",
"app_name": "Nom de l'application",
"app_passwd": "Mot de passe de l'application",
"automap": "Tenter de cibler automatiquement les dossiers (« Sent items », « Sent » => « Sent » etc.)",
"backup_mx_options": "Options Backup MX",
"bcc_dest_format": "La destination BCC doit être une seule adresse de courriel valide.",
"client_id": "ID client",
"client_secret": "Secret client",
"comment_info": "Un commentaire privé n’est pas visible pour l’utilisateur, tandis qu’un commentaire public est affiché comme infobulle lorsque vous le placez dans un aperçu des utilisateurs",
"delete1": "Supprimer de la source une fois terminé",
"delete2": "Supprimer les messages sur la destination qui ne sont pas sur la source",
"delete2duplicates": "Supprimer les doublons sur la destination",
"delete_ays": "Veuillez confirmer le processus de suppression.",
"description": "Description",
"disable_login": "Refuser l’ouverture de session (le courrier entrant est toujours accepté)",
@@ -619,6 +638,7 @@
"dont_check_sender_acl": "Désactiver la vérification de l’expéditeur pour le domaine %s (+ alias de domaines)",
"edit_alias_domain": "Édition des alias de domaine",
"encryption": "Chiffrement",
"exclude": "Exclure des objets (regex)",
"extended_sender_acl": "Adresses de l’expéditeur externe",
"extended_sender_acl_info": "Une clé de domaine DKIM doit être importée, si disponible.<br>\r\n N’oubliez pas d’ajouter ce serveur à l’enregistrement TXT SPF correspondant.<br>\r\n Chaque fois qu’un domaine ou un alias de domaine est ajouté à ce serveur, et qui chevauche une adresse externe, l’adresse externe est supprimée.<br>\r\n Utiliser @domain.tld pour permettre l'envoi comme *@domain.tld.",
"force_pw_update": "Forcer la mise à jour du mot de passe à la prochaine ouverture de session",
@@ -637,7 +657,10 @@
"max_aliases": "Nombre max. d'alias",
"max_mailboxes": "Nombre max. de boîte de réception possibles",
"max_quota": "Quota max. par boîte de réception (Mio)",
"maxage": "Âge maximal en jours des messages qui seront consultés à distance<br><small>(0 = ignorer la durée)</small>",
"maxbytespersecond": "Octets max. par seconde <br><small>(0 = pas de limite)</small>",
"mbox_rl_info": "Cette limite d'envoi est appliquée au nom de connexion SASL, elle correspond à toute adresse « from » utilisée par l’utilisateur connecté. Une limite d'envoi pour les boîtes de réception remplace une limite d'envoi pour l’ensemble du domaine.",
"mins_interval": "Intervalle (min)",
"multiple_bookings": "Réservations multiples",
"nexthop": "Saut suivant",
"password": "Mot de passe",
@@ -672,14 +695,19 @@
"sender_acl_info": "Si l’utilisateur de la boîte de réception A est autorisé à envoyer en tant qu’utilisateur de la boîte de réception B, l’adresse de l’expéditeur n’est pas automatiquement affichée comme sélectionnable du champ « de » dans SOGo.<br>\n L’utilisateur B de la boîte de réception doit créer une délégation dans SOGo pour permettre à l’utilisateur A de la boîte de réception de sélectionner son adresse comme expéditeur. Pour déléguer une boîte de réception dans SOGo, utilisez le menu (trois points) à droite du nom de votre boîte dans le coin supérieur gauche dans la vue de courrier. Ce comportement ne s’applique pas aux adresses alias.",
"sieve_desc": "Description courte",
"sieve_type": "Type de filtre",
"skipcrossduplicates": "Ignorer les messages en double dans les dossiers (premier arrivé, premier servi)",
"sogo_visible": "Alias visible dans SOGo",
"sogo_visible_info": "Cette option affecte uniquement les objets qui peuvent être affichés dans SOGo (adresses alias partagées ou non partagées pointant vers au moins une boîte de réception locale). Si caché, un alias n’apparaîtra pas comme expéditeur sélectionnable dans SOGo.",
"spam_alias": "Créer ou modifier des adresses alias limitées dans le temps",
"spam_filter": "Filtre spam",
"spam_policy": "Ajouter ou supprimer des éléments à la liste blanche/noire",
"spam_score": "Définir un score spam personnalisé",
"subfolder2": "Synchronisation dans le sous-dossier sur la destination<br><small>(vide = ne pas utiliser de sous-dossier)</small>",
"syncjob": "Modifier la tâche de synchronisation",
"target_address": "Adresse(s) Goto <small>(séparé(s) par des virgules)</small>",
"target_domain": "Domaine cible",
"timeout1": "Délai de connexion à l’hôte distant",
"timeout2": "Délai de connexion à l’hôte local",
"title": "Éditer l'objet",
"unchanged_if_empty": "Si non modifié, laisser en blanc",
"username": "Nom d'utilisateur",
@@ -918,6 +946,7 @@
"spam_aliases": "Alias temporaire",
"stats": "Statistiques",
"status": "Statut",
"sync_jobs": "Tâches de synchronisation",
"table_size": "Taille de la table",
"table_size_show_n": "Montrer %s articles",
"target_address": "Goto adresse",
@@ -947,11 +976,20 @@
"mailbox_templates": "Modèle de boîte de réception",
"relay_unknown": "Relayer les boîtes de réception inconnues",
"all_domains": "Tous les domaines",
"syncjob_EXIT_OVERQUOTA": "Quota dépassé de la boîte de réception cible",
"domain_templates": "Modèles de domaine",
"syncjob_last_run_result": "Résultat de la dernière exécution",
"sender": "Expéditeur",
"add_template": "Ajouter un modèle",
"syncjob_EX_OK": "Succès",
"syncjob_EXIT_CONNECTION_FAILURE": "Problème de connexion",
"syncjob_EXIT_TLS_FAILURE": "Problème avec le chiffrement de la connexion",
"syncjob_EXIT_AUTHENTICATION_FAILURE": "Problème d'authentification",
"syncjob_EXIT_CONNECTION_FAILURE_HOST1": "Impossible de se connecter au serveur distant",
"syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Utilisateur ou mot de passe erroné",
"templates": "Modèles",
"template": "Modèle",
"syncjob_check_log": "Vérifier le journal",
"recipient": "Destinataire",
"open_logs": "Afficher les journaux",
"iam": "Fournisseur d'identité",
@@ -1178,37 +1216,6 @@
"fido2_validation_failed": "La validation a échoué",
"set_fido2_touchid": "Enregistrer Touch ID sur Apple M1"
},
"syncjobs": {
"sync_jobs": "Tâches de synchronisation",
"syncjob_check_log": "Vérifier le journal",
"syncjob_last_run_result": "Résultat du dernier lancement",
"syncjob_EX_OK": "Succès",
"syncjob_EXIT_CONNECTION_FAILURE": "Problème de connexion",
"syncjob_EXIT_TLS_FAILURE": "Problème de connexion chiffrée",
"syncjob_EXIT_AUTHENTICATION_FAILURE": "Problème d'authentification",
"syncjob_EXIT_OVERQUOTA": "La boîte de réception cible a dépassé le quota",
"syncjob_EXIT_CONNECTION_FAILURE_HOST1": "Impossible de se connecter au serveur distant",
"syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Nom d'utilisateur ou mot de passe incorrect",
"add": "Ajouter une tâche de synchronisation",
"add_hint": "Sachez que les mots de passe seront enregistrés en clair !",
"edit": "Modifier la tâche de synchronisation",
"create": "Créer une tâche de synchronisation",
"dry": "Simuler la synchronisation",
"subfolder2": "Synchronisation dans le sous-dossier sur la destination<br><small>(vide = ne pas utiliser de sous-dossier)</small>",
"mins_interval": "Période de relève (minutes)",
"maxage": "Âge maximal en jours des messages qui seront consultés à distance<br><small>(0 = ignorer la durée)</small>",
"exclude": "Exclure des objets (expression régulière - regex)",
"automap": "Tenter de cibler automatiquement les dossiers (« Sent items », « Sent » => « Sent » etc.)",
"delete1": "Supprimer sur la source lorsque la synchronisation est terminée",
"delete2": "Supprimer les messages sur la destination qui ne sont pas présents sur la source",
"delete2duplicates": "Supprimer les doublons sur la destination",
"skipcrossduplicates": "Ignorer les messages en double dans les dossiers (premier arrivé, premier servi)",
"subscribeall": "S'abonner à tous les dossiers",
"timeout1": "Délai d'expiration pour la connexion à l'hôte distant",
"timeout2": "Délai d'expiration pour la connexion à l'hôte local",
"maxbytespersecond": "Octets max. par seconde <br><small>(0 = pas de limite)</small>",
"custom_params": "Paramètres personnalisés"
},
"user": {
"action": "Action",
"active": "Actif",
@@ -1233,6 +1240,7 @@
"change_password": "Changer le mot de passe",
"client_configuration": "Afficher les guides de configuration pour les clients de messagerie et les smartphones",
"create_app_passwd": "Créer un mot de passe application",
"create_syncjob": "Créer une tâche de synchronisation",
"daily": "Quotidien",
"day": "jour",
"description": "Description",
@@ -1317,6 +1325,7 @@
"spamfilter_wl_desc": "La liste blanche est programmée pour <b> ne jamais</b> classer comme spam les adresses de courriel qu'elle contient. Des caractères génériques peuvent être utilisés. Un filtre n’est appliqué qu’aux alias directs (alias avec une seule boîte de réception cible), à l’exclusion des alias attrape-tout et d’une boîte de réception.",
"spamfilter_yellow": "Jaune : ce message est peut-être un spam, il sera étiqueté comme spam et déplacé vers votre dossier Pourriel",
"status": "Statut",
"sync_jobs": "Tâches de synchronisation",
"tag_handling": "Régler la manipulation du courrier étiqueté",
"tag_help_example": "Exemple pour une adresse de courriel étiquetée : me<b>+Facebook</b>@example.org",
"tag_help_explain": "Dans un sous-dossier : un nouveau sous-dossier nommé selon l'étiquette sera créé sous INBOX (« INBOX/Facebook »).<br>\nDans le sujet : le nom des balises sera ajouté au début du sujet de l'e-mail, exemple : « [Facebook] My News ».",
@@ -1345,11 +1354,20 @@
"direct_protocol_access": "Cet utilisateur de la boîte aux lettres dispose d'un <b>accès externe direct</b> aux protocoles et applications suivants. Votre administrateur contrôle ce paramètre. Il est possible de créer des mots de passe d'application pour accorder l'accès à des protocoles et des applications individuels.<br>Le bouton « Connexion au Webmail » permet une connexion unique à SOGo et est toujours disponible.",
"open_webmail_sso": "Connexion au Webmail",
"recent_successful_connections": "Voir les connexions réussies",
"syncjob_EXIT_TLS_FAILURE": "Problème de connexion chiffrée",
"syncjob_EXIT_AUTHENTICATION_FAILURE": "Problème d'authentification",
"syncjob_EXIT_OVERQUOTA": "La boîte de réception cible a dépassé le quota",
"login_history": "Historique des connexions",
"change_password_hint_app_passwords": "Votre compte a %d mots de passe d'application qui ne seront pas modifiés. Pour les gérer, allez dans l'onglet Mots de passe de l'application.",
"clear_recent_successful_connections": "Vider l'historique des connexions réussies",
"created_on": "Créé le",
"last_ui_login": "Dernière connexion sur l'interface",
"syncjob_check_log": "Vérifier le journal",
"syncjob_last_run_result": "Résultat du dernier lancement",
"syncjob_EX_OK": "Succès",
"syncjob_EXIT_CONNECTION_FAILURE": "Problème de connexion",
"syncjob_EXIT_CONNECTION_FAILURE_HOST1": "Impossible de se connecter au serveur distant",
"syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Nom d'utilisateur ou mot de passe incorrect",
"value": "Valeur",
"allowed_protocols": "Protocoles autorisés",
"mailbox": "Boîte de réception",
+9 -9
View File
@@ -1,13 +1,4 @@
{
"syncjobs": {
"dry": "Προσομοίωση συγχρονισμού",
"exclude": "Εξαίρεση αντικειμένων (regex)",
"automap": "Αυτόματη αντιστοίχηση φακέλων (\"Απεσταλμένα μηνύματα\", \"Απεσταλμένα\" => \"Στάλθηκαν\" κ.τ.λ.)",
"delete1": "Διαγραφή όταν ολοκληρωθεί",
"delete2": "Διαγραφή μηνυμάτων στον προορισμό που δεν βρίσκονται στην πηγή",
"delete2duplicates": "Διαγραφή διπλότυπων στον προορισμό",
"custom_params": "Προσαρμοσμένες παράμετροι"
},
"user": {
"verify": "Επαλήθευση",
"week": "εβδομάδα",
@@ -72,16 +63,24 @@
"app_name": "Όνομα εφαρμογής",
"app_password": "Προσθήκη κωδικού εφαρμογής",
"app_passwd_protocols": "Επιτρεπόμενα πρωτόκολλα για κωδικούς εφαρμογών",
"automap": "Αυτόματη αντιστοίχηση φακέλων (\"Απεσταλμένα μηνύματα\", \"Απεσταλμένα\" => \"Στάλθηκαν\" κ.τ.λ.)",
"backup_mx_options": "Επιλογές αναμετάδοσης",
"bcc_dest_format": "Η BCC διεύθυνση πρέπει να είναι μία και έγκυρη διεύθυνση e-mail.<br>Αν θέλετε να στείλετε αντίγραφα σε πολλούς παραλήπτες, δημιουργήστε ένα ψευδόνυμο για όλους και χρησιμοποιήστε το εδώ.",
"comment_info": "Τα προσωπικά σχόλια δεν είναι ορατά στον χρήστη. Τα δημόσια σχόλια εμφανίζονται ως tooltips.",
"custom_params": "Προσαρμοσμένες παράμετροι",
"custom_params_hint": "Σωστή σύνταξη: --param=xy, λάθος σύνταξη: --param xy",
"delete1": "Διαγραφή όταν ολοκληρωθεί",
"delete2": "Διαγραφή μηνυμάτων στον προορισμό που δεν βρίσκονται στην πηγή",
"delete2duplicates": "Διαγραφή διπλότυπων στον προορισμό",
"description": "Περιγραφή",
"destination": "Προορισμός",
"disable_login": "Απαγόρευση εισόδου (η εισερχόμενη αλληλογραφία εξακολουθεί να γίνεται δεκτή)",
"domain": "Τομέας",
"domain_matches_hostname": "Ο τομέας %s είναι ο ίδιος με το όνομα του διακομιστή",
"domain_quota_m": "Συνολικό όριο τομέα (MiB)",
"dry": "Προσομοίωση συγχρονισμού",
"enc_method": "Μέθοδος κρυπτογράφησης",
"exclude": "Εξαίρεση αντικειμένων (regex)",
"full_name": "Πλήρες όνομα",
"gal": "Κοινόχρηστη λίστα διευθύνσεων"
},
@@ -181,6 +180,7 @@
"app_name": "Όνομα εφαρμογής",
"app_passwd": "Κωδικός πρόσβασης εφαρμογής",
"app_passwd_protocols": "Επιτρέπομενα πρωτόκολλα για τον κωδικό εφαρμογής",
"automap": "Αυτόματη αντιστοίχηση φακέλων (\"Απεσταλμένα μηνύματα\", \"Απεσταλμένα\" => \"Στάλθηκαν\" κ.τ.λ.)",
"backup_mx_options": "Επιλογές αναμετάδοσης"
}
}
+49 -31
View File
@@ -318,10 +318,14 @@
"acl": "ACL (Engedélyek)",
"admin": "Adminisztrátor szerkesztése",
"app_passwd_protocols": "Engedélyezett protokollok az alkalmazás jelszavához",
"automap": "Próbálja automatikusan feltérképezni a mappákat (\"Elküldött elemek\", \"Elküldött\" => \"Elküldött\" stb.)",
"bcc_dest_format": "A BCC-célpontnak egyetlen érvényes e-mail címnek kell lennie.<br>Ha több címre kell másolatot küldenie, hozzon létre egy aliast, és használja azt itt.",
"comment_info": "A privát megjegyzés nem látható a felhasználó számára, míg a nyilvános megjegyzés tooltip-ként jelenik meg, amikor a felhasználó áttekintésében a megjegyzésre mutat.",
"created_on": "Létrehozva",
"custom_attributes": "Egyéni attribútumok",
"delete1": "Törlés a forrásból, ha befejeződött",
"delete2": "Üzenetek törlése a célállomáson, amelyek nincsenek a forráson",
"delete2duplicates": "Duplikáltak törlése a célállomáson",
"delete_ays": "Erősítse meg a törlési folyamatot.",
"disable_login": "Bejelentkezés letiltása (a bejövő leveleket továbbra is elfogadja)",
"domain": "Domain szerkesztése",
@@ -341,6 +345,7 @@
"domain_footer_skip_replies": "Lábléc figyelmen kívül hagyása válasz e-maileknél",
"dont_check_sender_acl": "Küldő ellenőrzés letiltása a(z) %s tartományhoz (+ alias tartományok)",
"encryption": "Titkosítás",
"exclude": "Objektumok kizárása (regex)",
"extended_sender_acl": "Külső feladói címek",
"extended_sender_acl_info": "Importálni kell egy DKIM tartomány kulcsot, ha elérhető.<br>\r\n Ne felejtse el hozzáadni ezt a szervert a megfelelő SPF TXT rekordhoz.<br>\r\n Amikor egy tartományt vagy alias tartományt ad hozzá ehhez a szerverhez, amely átfedésben van egy külső címmel, a külső cím eltávolításra kerül.<br>\r\n Használja a @domain.tld címet, hogy engedélyezze a küldést *@domain.tld néven.",
"force_pw_update": "Jelszófrissítés kényszerítése a következő bejelentkezéskor",
@@ -367,7 +372,10 @@
"max_aliases": "Max. aliasok",
"max_mailboxes": "Max. lehetséges postafiókok",
"max_quota": "Max. kvóta postafiókonként (MiB)",
"maxage": "Üzenetek maximális életkora napokban, amelyek lekérdezésre kerülnek a távolról<br><small>(0 = figyelmen kívül hagyás)</small>",
"maxbytespersecond": "Max. bájt/másodperc <br><small>(0 = korlátlan)</small>",
"mbox_rl_info": "Ez a sebességkorlátozás a SASL bejelentkezési névre vonatkozik, és illeszkedik minden, a bejelentkezett felhasználó által használt \"from\" címre. Egy postafiók sebességkorlátozása felülírja a tartományszintű sebességkorlátozást.",
"mins_interval": "Időköz (perc)",
"multiple_bookings": "Több foglalás",
"none_inherit": "Nincs / Örökölt",
"nexthop": "Következő ugrás",
@@ -408,6 +416,7 @@
"sender_acl_info": "Ha az A postafiók felhasználója küldhet a B postafiók felhasználójaként, a feladói cím nem jelenik meg automatikusan választható \"from\" mezőként a SOGo-ban.<br>\r\n A B postafiók felhasználójának delegálást kell létrehoznia a SOGo-ban, hogy az A postafiók felhasználója kiválaszthassa a címét feladóként. A SOGo-ban egy postafiók delegálásához használja a menüt (három pont) a postafiók neve jobb oldalán a bal felső sarokban, a levélnézetben. Ez a viselkedés nem vonatkozik az alias címekre.",
"sieve_desc": "Rövid leírás",
"sieve_type": "Szűrő típusa",
"skipcrossduplicates": "Átugrani a duplikált üzeneteket a mappák között (aki előbb jön, előbb kapja)",
"sogo_access": "Közvetlen továbbítás a SOGo-ra",
"sogo_access_info": "A bejelentkezés után a felhasználó automatikusan átirányításra kerül a SOGo-ra.",
"sogo_visible": "Alias látható a SOGo-ban",
@@ -416,8 +425,12 @@
"spam_filter": "Spam szűrő",
"spam_policy": "Elemek hozzáadása vagy eltávolítása a tiltó- vagy engedélyezési listán",
"spam_score": "Egyéni spam pontszám beállítása",
"subfolder2": "Szinkronizálás a célállomáson egy almappába<br><small>(üres = ne használjon almappát)</small>",
"syncjob": "Szinkronizálási feladat szerkesztése",
"target_address": "Célcím(ek) <small>(vesszővel elválasztva)</small>",
"target_domain": "Cél domain",
"timeout1": "Időtúllépés a távoli állomáshoz való csatlakozáskor",
"timeout2": "Időtúllépés a helyi állomáshoz való csatlakozáskor",
"title": "Objektum szerkesztése",
"unchanged_if_empty": "Ha nem változik, hagyja üresen",
"username": "Felhasználónév",
@@ -557,6 +570,7 @@
"spam_aliases": "Ideiglenes alias",
"stats": "Statisztika",
"status": "Státusz",
"sync_jobs": "Szinkronizáló feladatok",
"table_size": "Tábla mérete",
"table_size_show_n": "%s sor mutatása",
"target_address": "Címhez ugrás",
@@ -634,6 +648,15 @@
"sogo_visible": "Alias látható a SOGo-ban",
"sogo_visible_n": "Alias elrejtése a SOGo-ban",
"sogo_visible_y": "Alias megjelenítése a SOGo-ban",
"syncjob_check_log": "Napló ellenőrzése",
"syncjob_last_run_result": "Utolsó futás eredménye",
"syncjob_EX_OK": "Siker",
"syncjob_EXIT_CONNECTION_FAILURE": "Kapcsolati probléma",
"syncjob_EXIT_TLS_FAILURE": "Probléma a titkosított kapcsolattal",
"syncjob_EXIT_AUTHENTICATION_FAILURE": "Hitelesítési probléma",
"syncjob_EXIT_OVERQUOTA": "A cél postafiók túllépte a kvótát",
"syncjob_EXIT_CONNECTION_FAILURE_HOST1": "Nem lehet csatlakozni a távoli szerverhez",
"syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Hibás felhasználónév vagy jelszó",
"templates": "Sablonok",
"template": "Sablon",
"tls_map_policy": "Irányelv",
@@ -816,37 +839,6 @@
"verified_yotp_login": "Yubico OTP bejelentkezés ellenőrizve",
"mailbox_renamed": "A postafiók átnevezve %s-ről %s-re"
},
"syncjobs": {
"sync_jobs": "Szinkronizáló feladatok",
"syncjob_check_log": "Napló ellenőrzése",
"syncjob_last_run_result": "Utolsó futás eredménye",
"syncjob_EX_OK": "Siker",
"syncjob_EXIT_CONNECTION_FAILURE": "Kapcsolati probléma",
"syncjob_EXIT_TLS_FAILURE": "Probléma a titkosított kapcsolattal",
"syncjob_EXIT_AUTHENTICATION_FAILURE": "Hitelesítési probléma",
"syncjob_EXIT_OVERQUOTA": "A cél postafiók túllépte a kvótát",
"syncjob_EXIT_CONNECTION_FAILURE_HOST1": "Nem lehet csatlakozni a távoli szerverhez",
"syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Hibás felhasználónév vagy jelszó",
"add": "Szinkronizálási feladat hozzáadása",
"add_hint": "Ne feledje, hogy a jelszavakat egyszerű szöveges formában kell elmenteni!",
"edit": "Szinkronizálási feladat szerkesztése",
"create": "Új szinkronizációs művelet létrehozása",
"dry": "Szinkronizálás szimulálása",
"subfolder2": "Szinkronizálás a célállomáson egy almappába<br><small>(üres = ne használjon almappát)</small>",
"mins_interval": "A lekérdezési időköz (perc)",
"maxage": "Üzenetek maximális életkora napokban, amelyek lekérdezésre kerülnek a távolról<br><small>(0 = figyelmen kívül hagyás)</small>",
"exclude": "Objektumok kizárása (regex)",
"automap": "Próbálja automatikusan feltérképezni a mappákat (\"Elküldött elemek\", \"Elküldött\" => \"Elküldött\" stb.)",
"delete1": "Törlés a forrásból, ha befejeződött",
"delete2": "A forráson kívüli üzenetek törlése a célállomáson",
"delete2duplicates": "Duplikáltak törlése a célállomáson",
"skipcrossduplicates": "Duplikált üzenetek átugrása mappák között (érkezési sorrendben)",
"subscribeall": "Feliratkozás minden mappára",
"timeout1": "Időtúllépés a távoli állomáshoz való csatlakozáskor",
"timeout2": "Időtúllépés a helyi állomáshoz való csatlakozáskor",
"maxbytespersecond": "Max. bájt/másodperc <br><small>(0 = korlátlan)</small>",
"custom_params": "Egyéni paraméterek"
},
"user": {
"action": "Művelet",
"active": "Aktív",
@@ -866,6 +858,7 @@
"change_password": "Jelszó cseréje",
"client_configuration": "Beállítások megjelenítése email-kliensekhez",
"create_app_passwd": "Applikáció jelszó létrehozása",
"create_syncjob": "Új szinkronizációs művelet létrehozása",
"daily": "Napi",
"day": "nap",
"description": "Leírás",
@@ -927,6 +920,7 @@
"spamfilter_wl_desc": "Az engedélyezőlistán szereplő email címek soha nem kerülnek spam-be. Joker karakter használható. A szűrő csak közvetlen aliasokra vonatkozik (alias egyetlen cél-postafiókkal), magára a postafiókra, illetve mindent elkapó aliasokra nem.",
"spamfilter_yellow": "Sárga: ez az üzenet spam-gyanús, spam-nek lesz címezve és elérhető lesz a levélszemét mappában",
"status": "Státusz",
"sync_jobs": "Szinkronizáló feladatok",
"tag_handling": "Címkézett üzenetek kezelése",
"tag_help_example": "Példa címkézett email címre: en<b>+Facebook</b>@pelda.hu",
"tag_help_explain": "Almappában: új almappa készül a címke nevét viselve (pl.: \"Bejövő/Facebook\").<br>\r\nTárgyban: a címke a tárgy elé lesz fűzve, pl.: \"[Facebook] Hírek\".",
@@ -998,6 +992,15 @@
"sogo_profile_reset_help": "Ez tönkreteszi a felhasználó SOGo profilját, és <b>visszavonhatatlanul törli az összes névjegy- és naptáradatot</b>.",
"sogo_profile_reset_now": "Profil visszaállítása most",
"spamfilter_table_domain_policy": "n/a (tartományi szabályzat)",
"syncjob_check_log": "Napló ellenőrzése",
"syncjob_last_run_result": "Utolsó futás eredménye",
"syncjob_EX_OK": "Siker",
"syncjob_EXIT_CONNECTION_FAILURE": "Kapcsolati probléma",
"syncjob_EXIT_TLS_FAILURE": "Probléma a titkosított kapcsolattal",
"syncjob_EXIT_AUTHENTICATION_FAILURE": "Hitelesítési probléma",
"syncjob_EXIT_OVERQUOTA": "A cél postafiók túllépte a kvótát",
"syncjob_EXIT_CONNECTION_FAILURE_HOST1": "Nem lehet csatlakozni a távoli szerverhez",
"syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Hibás felhasználónév vagy jelszó",
"tfa_info": "A kétlépcsős hitelesítés segít megvédeni a fiókodat. Ha engedélyezed, alkalmazás jelszavakra lesz szükséged, hogy bejelentkezz olyan alkalmazásokba vagy szolgáltatásokba, amelyek nem támogatják a kétlépcsős hitelesítést (pl. levelező kliensek).",
"tls_policy_warning": "<strong>Figyelem:</strong> Ha titkosított levélátvitelt kényszerítesz ki, elveszíthetsz e-maileket.<br>Azok az üzenetek, amelyek nem felelnek meg a szabályzatnak, kemény hibával visszapattannak a levelezőrendszerből.<br>Ez az opció a fő e-mail címedre (bejelentkezési név), az alias tartományokból származó összes címre, valamint az alias címekre vonatkozik, amelyeknek <b>csak ez az egy postafiók</b> a célja.",
"value": "Érték",
@@ -1067,16 +1070,20 @@
"mailbox_quota_def": "Alapértelmezett postafiók kvóta",
"password_repeat": "Megerősítő jelszó (ismétlés)",
"post_domain_add": "A \"sogo-mailcow\" SOGo konténert újra kell indítani egy új tartomány hozzáadása után!<br><br>Kiegészítésképpen a tartományok DNS-konfigurációját is felül kell vizsgálni. A DNS-konfiguráció jóváhagyása után indítsa újra az \"acme-mailcow\"-t, hogy automatikusan generáljon tanúsítványokat az új tartományhoz (autoconfig.&lt;domain&gt;, autodiscover.&lt;domain&gt;).<br>Ez a lépés opcionális, és 24 óránként megismétlődik.",
"dry": "Szinkronizálás szimulálása",
"inactive": "Inaktív",
"kind": "Típus",
"mailbox_quota_m": "Maximális kvóta postafiókonként (MiB)",
"mailbox_username": "Felhasználónév (az e-mail cím bal oldali része)",
"max_aliases": "Max. lehetséges álnevek",
"max_mailboxes": "Max. lehetséges postafiókok",
"mins_interval": "A lekérdezési időköz (perc)",
"password": "Jelszó",
"port": "Port",
"public_comment": "Nyilvános megjegyzés",
"target_domain": "Céltartomány",
"timeout1": "Időtúllépés a távoli állomáshoz való csatlakozáskor",
"timeout2": "Időtúllépés a helyi állomáshoz való csatlakozáskor",
"validate": "Érvényesítsd",
"description": "Leírás",
"destination": "Célállomás",
@@ -1084,18 +1091,25 @@
"domain_matches_hostname": "Domain %s megegyezik a hostnévvel",
"domain_quota_m": "Teljes tartományi kvóta (MiB)",
"enc_method": "Titkosítási módszer",
"exclude": "Objektumok kizárása (regex)",
"full_name": "Teljes név",
"gal": "Globális címlista",
"goto_ham": "Tanítás <span class=\"text-success\"><b>valódi</b></span> levélként",
"goto_null": "Leveleket csendben eldobni",
"goto_spam": "Tanítás <span class=\"text-danger\"><b>spam</b></span>ként",
"syncjob_hint": "Ne feledje, hogy a jelszavakat egyszerű szöveges formában kell elmenteni!",
"target_address": "Továbbítási címek",
"target_address_info": "<small>Teljes e-mail cím(ek) (vesszővel elválasztva).</small>",
"bcc_dest_format": "A BCC-célpontnak egyetlen érvényes e-mail címnek kell lennie.<br>Ha több címre kell másolatot küldenie, hozzon létre egy aliast, és használja azt.",
"comment_info": "A privát megjegyzés nem látható a felhasználó számára, míg a nyilvános megjegyzés tooltip-ként jelenik meg, amikor a felhasználó áttekintésében a megjegyzésre mutat.",
"custom_params": "Egyéni paraméterek",
"gal_info": "A GAL tartalmazza a tartomány összes objektumát, és egyetlen felhasználó sem szerkesztheti. A SOGo-ban a Szabad/Elfoglalt információ hiányzik, ha ki van kapcsolva! <b>Indítsa újra a SOGo-t a változások alkalmazásához.</b>",
"hostname": "Hoszt",
"backup_mx_options": "Továbbítási opciók",
"custom_params_hint": "Megfelelő: --param=xy, Rossz: --param xy",
"delete1": "Törlés a forrásból, ha befejeződött",
"delete2": "A forráson kívüli üzenetek törlése a célállomáson",
"delete2duplicates": "Duplikáltak törlése a célállomáson",
"domain": "Domain",
"nexthop": "Következő hop",
"tags": "Címkék",
@@ -1113,6 +1127,7 @@
"alias_domain_info": "<small>Csak érvényes tartománynevek (vesszővel elválasztva).</small>",
"app_name": "Alkalmazás neve",
"app_passwd_protocols": "Engedélyezett protokollok az alkalmazás jelszavához",
"automap": "Próbálja automatikusan feltérképezni a mappákat (\"Elküldött elemek\", \"Elküldött\" => \"Elküldött\" stb.)",
"multiple_bookings": "Több foglalás",
"quota_mb": "Kvóta (MiB)",
"relay_all": "Az összes címzett továbbítása",
@@ -1125,6 +1140,9 @@
"select_domain": "Kérjük, először válasszon egy domaint",
"sieve_desc": "Rövid leírás",
"sieve_type": "Szűrő típusa",
"skipcrossduplicates": "Duplikált üzenetek átugrása mappák között (érkezési sorrendben)",
"subscribeall": "Feliratkozás minden mappára",
"syncjob": "Szinkronizálási feladat hozzáadása",
"internal": "Belső",
"internal_info": "Belső álnevek csak a saját domain vagy domain álnév számára elérhető.",
"sender_allowed": "Küldés engedélyezése ezzel az aliasszal"
+48 -31
View File
@@ -43,8 +43,14 @@
"alias_domain_info": "<small>Solo nomi di dominio validi (separati da virgola).</small>",
"app_name": "Nome app",
"app_password": "Aggiungi la password dell'app",
"automap": "Prova a mappare automaticamente le cartelle (\"Sent items\", \"Sent\" => \"Posta inviata\" ecc.)",
"backup_mx_options": "Opzioni di inoltro",
"comment_info": "Un commento privato non è visibile all'utente, mentre un commento pubblico viene mostrato come suggerimento quando si passa con il mouse nella panoramica di un utente",
"custom_params": "Parametri personalizzati",
"custom_params_hint": "Corretto: --param=xy, errato: --param xy",
"delete1": "Elimina dalla sorgente al termine",
"delete2": "Elimina i messaggi nella casella di destinazione che non sono presenti nell'origine",
"delete2duplicates": "Elimina duplicati nella destinazione",
"description": "Descrizione",
"destination": "Destinazione",
"disable_login": "Disabilita l'accesso (la posta in arrivo viene correttamente recapitata)",
@@ -52,6 +58,7 @@
"domain_matches_hostname": "Il dominio %s corrisponde al'hostname",
"domain_quota_m": "Spazio totale dominio (MiB)",
"enc_method": "Metodo di crittografia",
"exclude": "Escludi oggetti (regex)",
"full_name": "Nome completo",
"gal": "Global Address List",
"gal_info": "La GAL contiene tutti gli indirizzi del dominio e non può essere modificata da nessun utente. Le infomazioni sulla disponibilità di ogni utente non sono presenti in SOGo, se sono state disabilitate! <b>Riavvia SOGo per applicare le modifiche.</b>",
@@ -67,6 +74,7 @@
"mailbox_username": "Username (parte a sinistra della @)",
"max_aliases": "Numero massimo alias",
"max_mailboxes": "Numero massimo caselle di posta",
"mins_interval": "intervallo (minuti)",
"multiple_bookings": "Prenotazioni multiple",
"nexthop": "Hop successivo",
"password": "Password",
@@ -86,9 +94,15 @@
"select_domain": "Seleziona prima un dominio",
"sieve_desc": "Descrizione breve",
"sieve_type": "Tipologia di filtro",
"skipcrossduplicates": "Salta i messaggi duplicati tra le cartelle (precedenza al primo arrivato)",
"subscribeall": "Iscriviti a tutte le cartelle",
"syncjob": "Aggiungi sincronizzazione",
"syncjob_hint": "Ricordati che le password vanno salvate in testo semplice!",
"target_address": "Inoltra all'indirizzo",
"target_address_info": "<small>Indirizzo e-mail completo/i (separati da virgole).</small>",
"target_domain": "Dominio di destinazione",
"timeout1": "Timeout per la connessione all'host remoto",
"timeout2": "Timeout per la connessione all'host locale",
"username": "Nome utente",
"validate": "Convalida",
"validation_success": "Convalidato con successo",
@@ -561,12 +575,16 @@
"allowed_protocols": "Protocolli consentiti",
"app_name": "App name",
"app_passwd": "App password",
"automap": "Try to automap folders (\"Sent items\", \"Sent\" => \"Sent\" etc.)",
"backup_mx_options": "Backup MX options",
"bcc_dest_format": "BCC destination must be a single valid email address.",
"client_id": "Client ID",
"client_secret": "Client secret",
"created_on": "Creato il",
"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",
"delete1": "Elimina dalla sorgente al termine",
"delete2": "Delete messages on destination that are not on source",
"delete2duplicates": "Elimina duplicati nella destinazione",
"delete_ays": "Si prega di confermare il processo di eliminazione.",
"description": "Descrizione",
"disable_login": "Disabilita l'accesso (la posta in arrivo viene correttamente recapitata)",
@@ -577,6 +595,7 @@
"dont_check_sender_acl": "Disattiva il controllo del mittente per il dominio %s + alias di dominio",
"edit_alias_domain": "Modifica alias di dominio",
"encryption": "Crittografia",
"exclude": "Escludi oggetti (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": "Forza l'aggiornamento della password al prossimo accesso",
@@ -596,7 +615,10 @@
"max_aliases": "Numero massimo alias",
"max_mailboxes": "Caselle di posta massime",
"max_quota": "Massimo spazio per casella (MiB)",
"maxage": "Massima età dei messaggi che verranno scaricati dal server remoto<br /><small>(0 = ignora età)</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": "Intervallo (min)",
"multiple_bookings": "Prenotazioni multiple",
"nexthop": "Prossimo hop",
"password": "Password",
@@ -633,14 +655,19 @@
"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": "Breve descrizione",
"sieve_type": "Filter type",
"skipcrossduplicates": "Skip duplicate messages across folders (first come, first serve)",
"sogo_visible": "L'alias è visibile in SOGo",
"sogo_visible_info": "This option only affects objects, that can be displayed in SOGo (shared or non-shared alias addresses pointing to at least one local mailbox). If hidden, an alias will not appear as selectable sender in SOGo.",
"spam_alias": "Create or change time limited alias addresses",
"spam_filter": "Spam filter",
"spam_policy": "Aggiungi o rimuovi elementi dalla whitelist/blacklist",
"spam_score": "Imposta un punteggio spam personalizzato",
"subfolder2": "Sincronizza in una sottocartella<br /><small>(vuoto = non sincronizzare in sottocartella)</small>",
"syncjob": "Modifica sincronizzazione",
"target_address": "Vai all'indirizzo/i <small>(separato da virgola)</small>",
"target_domain": "Target dominio",
"timeout1": "Timeout per la connessione all'host remoto",
"timeout2": "Timeout per la connessione all'host remoto",
"title": "Modifica oggetto",
"unchanged_if_empty": "Se immutato lasciare vuoto",
"username": "Nome utente",
@@ -858,6 +885,7 @@
"spam_aliases": "Alias temporanei",
"stats": "Statistiche",
"status": "Stato",
"sync_jobs": "Processi di sync",
"table_size": "Dimensioni della tabella",
"table_size_show_n": "Mostra %s elementi",
"target_address": "Vai agli indirizzi",
@@ -878,13 +906,22 @@
"waiting": "In attesa",
"weekly": "Settimanale",
"yes": "&#10003;",
"syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Nome utente o password errati",
"goto_ham": "Apprendi come <b>ham</b>",
"goto_spam": "Apprendi come <b>spam</b>",
"open_logs": "Apri i log",
"syncjob_check_log": "Controlla il log",
"syncjob_last_run_result": "Risultato dell'ultima esecuzione",
"syncjob_EXIT_TLS_FAILURE": "Problema con la connessione criptata",
"syncjob_EXIT_AUTHENTICATION_FAILURE": "Problema di autenticazione",
"syncjob_EXIT_OVERQUOTA": "La casella di posta del destinatario ha superato la quota",
"syncjob_EXIT_CONNECTION_FAILURE_HOST1": "Impossibile connettersi al server remoto",
"syncjob_EXIT_CONNECTION_FAILURE": "Problema di connessione",
"catch_all": "Catch-All",
"sender": "Mittente",
"all_domains": "Tutti i domini",
"recipient": "Destinatario",
"syncjob_EX_OK": "Successo",
"add_template": "Aggiungi template",
"force_pw_update": "Forza il cambio della password al prossimo accesso",
"relay_unknown": "Inoltra a caselle di posta sconosciute",
@@ -1092,37 +1129,6 @@
"u2f_deprecated_important": "Registra la tua chiave nel pannello di amministrazione con il nuovo metodo WebAuthn.",
"authenticators": "Autenticatori"
},
"syncjobs": {
"sync_jobs": "Processi di sync",
"syncjob_check_log": "Controlla i log",
"syncjob_last_run_result": "Risultato dell'ultima esecuzione",
"syncjob_EX_OK": "Successo",
"syncjob_EXIT_CONNECTION_FAILURE": "Problema di connessione",
"syncjob_EXIT_TLS_FAILURE": "Problema con la connessione criptata",
"syncjob_EXIT_AUTHENTICATION_FAILURE": "Problema di autenticazione",
"syncjob_EXIT_OVERQUOTA": "La casella di posta del destinatario ha superato la quota",
"syncjob_EXIT_CONNECTION_FAILURE_HOST1": "Impossibile connettersi al server remoto",
"syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Nome utente o password errati",
"add": "Aggiungi sincronizzazione",
"add_hint": "Ricordati che le password vanno salvate in testo semplice!",
"edit": "Modifica sincronizzazione",
"create": "Crea un azione di sync",
"dry": "Simula sincronizzazione",
"subfolder2": "Sincronizza in una sottocartella<br /><small>(vuoto = non sincronizzare in sottocartella)</small>",
"mins_interval": "intervallo (minuti)",
"maxage": "Massima età dei messaggi che verranno scaricati dal server remoto<br /><small>(0 = ignora età)</small>",
"exclude": "Escludi oggetti (regex)",
"automap": "Prova a mappare automaticamente le cartelle (\"Sent items\", \"Sent\" => \"Posta inviata\" ecc.)",
"delete1": "Elimina dalla sorgente al termine",
"delete2": "Elimina i messaggi nella casella di destinazione che non sono presenti nell'origine",
"delete2duplicates": "Elimina duplicati nella destinazione",
"skipcrossduplicates": "Salta i messaggi duplicati tra le cartelle (precedenza al primo arrivato)",
"subscribeall": "Iscriviti a tutte le cartelle",
"timeout1": "Timeout per la connessione all'host remoto",
"timeout2": "Timeout per la connessione all'host locale",
"maxbytespersecond": "Max. bytes per second <br><small>(0 = unlimited)</small>",
"custom_params": "Parametri personalizzati"
},
"user": {
"action": "Azione",
"active": "Attiva",
@@ -1148,6 +1154,7 @@
"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": "Crea un azione di sync",
"created_on": "Creato il",
"daily": "Giornaliero",
"day": "giorno",
@@ -1246,6 +1253,7 @@
"spamfilter_wl_desc": "Email inserita nella whitelist per <b>non essere mai</b> riconosciuta come spam. Si possono usare le wildcards. Un filtro viene applicato solo agli alias diretti (alias con una singola casella postale di destinazione) esclusi gli alias catch-all e la casella postale stessa.",
"spamfilter_yellow": "Giallo: Questo messaggio può essere spam, verrà segnalato come spam e spostato nella cartella spazzatura",
"status": "Stato",
"sync_jobs": "Processi di sync",
"tag_handling": "Imposta le gestione della mail evidenziate",
"tag_help_example": "Esempio di mail con tag: ich<b>+Facebook</b>@example.org",
"tag_help_explain": "In sottocartelle: Una nuova cartella con il nome del tag verrà creata sotto INBOX (\"INBOX/Facebook\").<br />\r\nNell'oggetto: Il nome del tag verrà aggiunto all'inizio dell'oggetto della mail, esempio: \"[Facebook] Le mie notizie\".",
@@ -1268,9 +1276,18 @@
"year": "anno",
"years": "anni",
"change_password_hint_app_passwords": "Il tuo account ha %d password delle app che non verranno modificate. Per gestirle, vai alla scheda App passwords.",
"syncjob_check_log": "Controlla i log",
"syncjob_last_run_result": "Risultato dell'ultima esecuzione",
"open_logs": "Apri i log",
"apple_connection_profile_with_app_password": "Una nuova password dell'app è stata generata ed aggiunta al profilo così che non sia più necessario inserire alcuna password quando si imposta il dispositivo. Si prega di non condividere il file in quanto concede l'accesso completo alla tua casella di posta elettronica.",
"allowed_protocols": "Protocolli consentiti",
"syncjob_EX_OK": "Successo",
"syncjob_EXIT_CONNECTION_FAILURE": "Problema di connessione",
"syncjob_EXIT_TLS_FAILURE": "Problema con la connessione criptata",
"syncjob_EXIT_AUTHENTICATION_FAILURE": "Problema di autenticazione",
"syncjob_EXIT_OVERQUOTA": "La casella di posta del destinatario ha superato la quota",
"syncjob_EXIT_CONNECTION_FAILURE_HOST1": "Impossibile connettersi al server remoto",
"syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Nome utente o password errati",
"with_app_password": "con password dell'app",
"direct_protocol_access": "Questo utente della mailbox ha <b>accesso diretto ed esterno</b> ai seguenti protocolli e applicazioni. Questa impostazione è controllata dal tuo amministratore. Le password delle applicazioni possono essere create per garantire l'accesso ai singoli protocolli e applicazioni.<br>Il pulsante \"Accedi alla webmail\" fornisce un singolo accesso a SOGo ed è sempre disponibile.",
"pushover_sound": "Suono",
+49 -31
View File
@@ -44,16 +44,24 @@
"app_name": "アプリ名",
"app_password": "アプリパスワードを追加",
"app_passwd_protocols": "アプリパスワードの許可プロトコル",
"automap": "フォルダーを自動マッピングする試み(例:「送信済みアイテム」、「送信済み」=>「送信済み」)",
"backup_mx_options": "リレーオプション",
"bcc_dest_format": "BCC送信先は単一の有効なメールアドレスである必要があります。<br>複数のアドレスにコピーを送信する必要がある場合は、エイリアスを作成し、ここで使用してください。",
"comment_info": "プライベートコメントはユーザーには表示されませんが、パブリックコメントはユーザーの概要でホバーするとツールチップとして表示されます。",
"custom_params": "カスタムパラメータ",
"custom_params_hint": "正: --param=xy, 誤: --param xy",
"delete1": "完了時にソースから削除",
"delete2": "ソースに存在しないメッセージを送信先で削除",
"delete2duplicates": "送信先で重複を削除",
"description": "説明",
"destination": "送信先",
"disable_login": "ログインを禁止(受信メールは許可)",
"domain": "ドメイン",
"domain_matches_hostname": "ドメイン %s はホスト名と一致します",
"domain_quota_m": "ドメインの総クォータ(MiB)",
"dry": "同期をシミュレーション",
"enc_method": "暗号化方式",
"exclude": "オブジェクトを除外(正規表現)",
"full_name": "フルネーム",
"gal": "グローバルアドレスリスト",
"gal_info": "GALにはドメインのすべてのオブジェクトが含まれ、どのユーザーも編集できません。無効にすると、SOGoの空き/取り込み情報が表示されなくなります!<b>変更を適用するにはSOGoを再起動してください。</b>",
@@ -69,6 +77,7 @@
"mailbox_username": "ユーザー名(メールアドレスの左部分)",
"max_aliases": "作成可能なエイリアスの最大数",
"max_mailboxes": "作成可能なメールボックスの最大数",
"mins_interval": "ポーリング間隔(分)",
"multiple_bookings": "複数の予約",
"nexthop": "次のホップ",
"password": "パスワード",
@@ -88,10 +97,16 @@
"select_domain": "最初にドメインを選択してください",
"sieve_desc": "簡単な説明",
"sieve_type": "フィルタータイプ",
"skipcrossduplicates": "フォルダー間で重複するメッセージをスキップ(先着順)",
"subscribeall": "すべてのフォルダーを購読",
"syncjob": "同期ジョブを追加",
"syncjob_hint": "パスワードはプレーンテキストで保存される必要があることに注意してください!",
"tags": "タグ",
"target_address": "宛先アドレス",
"target_address_info": "<small>完全なメールアドレス(カンマ区切り)。</small>",
"target_domain": "ターゲットドメイン",
"timeout1": "リモートホストへの接続タイムアウト",
"timeout2": "ローカルホストへの接続タイムアウト",
"username": "ユーザー名",
"validate": "検証",
"validation_success": "正常に検証されました"
@@ -570,6 +585,7 @@
"app_name": "アプリ名",
"app_passwd": "アプリパスワード",
"app_passwd_protocols": "アプリパスワードで許可されるプロトコル",
"automap": "フォルダーを自動マッピングする(例:\"Sent items\", \"Sent\" => \"Sent\" など)",
"backup_mx_options": "リレーオプション",
"bcc_dest_format": "BCC送信先は1つの有効なメールアドレスである必要があります。<br>複数のアドレスにコピーを送信する必要がある場合は、エイリアスを作成してここで使用してください。",
"client_id": "クライアントID",
@@ -577,6 +593,9 @@
"comment_info": "プライベートコメントはユーザーには表示されませんが、パブリックコメントはユーザー概要でホバーするとツールチップとして表示されます。",
"created_on": "作成日時",
"custom_attributes": "カスタム属性",
"delete1": "完了時にソースから削除",
"delete2": "ソースに存在しないメッセージを送信先で削除",
"delete2duplicates": "送信先で重複を削除",
"delete_ays": "削除プロセスを確認してください。",
"description": "説明",
"disable_login": "ログインを禁止(受信メールは許可)",
@@ -600,6 +619,7 @@
"dont_check_sender_acl": "ドメイン %s(およびエイリアスドメイン)の送信者チェックを無効化",
"edit_alias_domain": "エイリアスドメインを編集",
"encryption": "暗号化",
"exclude": "オブジェクトを除外(正規表現)",
"extended_sender_acl": "外部送信者アドレス",
"extended_sender_acl_info": "可能であればDKIMドメインキーをインポートしてください。<br>\r\n このサーバーを対応するSPF TXTレコードに追加することを忘れないでください。<br>\r\n このサーバーに外部アドレスと重複するドメインまたはエイリアスドメインを追加するたびに、外部アドレスは削除されます。<br>\r\n *@domain.tld として送信を許可するには @domain.tld を使用してください。",
"force_pw_update": "次回ログイン時にパスワードの更新を強制",
@@ -626,7 +646,10 @@
"max_aliases": "最大エイリアス数",
"max_mailboxes": "作成可能な最大メールボックス数",
"max_quota": "メールボックスごとの最大クォータ(MiB)",
"maxage": "リモートから取得するメッセージの最大日数<br><small>(0 = 日数を無視)</small>",
"maxbytespersecond": "毎秒の最大バイト数<br><small>(0 = 無制限)</small>",
"mbox_rl_info": "このレート制限はSASLログイン名に適用され、ログインしたユーザーによって使用されるすべての「送信者」アドレスに一致します。メールボックスレート制限は、ドメイン全体のレート制限を上書きします。",
"mins_interval": "間隔(分)",
"multiple_bookings": "複数予約",
"none_inherit": "なし / 継承",
"nexthop": "次のホップ",
@@ -667,6 +690,7 @@
"sender_acl_info": "メールボックスユーザーAがメールボックスユーザーBとして送信することを許可されている場合、SOGoで送信者アドレスが自動的に選択可能な「送信者」フィールドとして表示されることはありません。<br>\r\n メールボックスユーザーBは、SOGoでメールボックスを委任する必要があります。この委任は、メールビューの左上にあるメールボックス名の右にあるメニュー(三点ボタン)を使用して行います。この動作はエイリアスアドレスには適用されません。",
"sieve_desc": "簡単な説明",
"sieve_type": "フィルタータイプ",
"skipcrossduplicates": "フォルダー間で重複するメッセージをスキップ(先着順)",
"sogo_access": "SOGoへの直接ログインアクセスを許可",
"sogo_access_info": "メールUI内からのシングルサインオンは引き続き動作します。この設定は他のすべてのサービスへのアクセスには影響しません。また、ユーザーの既存のSOGoプロファイルを削除または変更するものでもありません。",
"sogo_visible": "SOGoにエイリアスが表示される",
@@ -675,8 +699,12 @@
"spam_filter": "スパムフィルター",
"spam_policy": "ホワイトリスト/ブラックリストにアイテムを追加または削除",
"spam_score": "カスタムスパムスコアを設定",
"subfolder2": "送信先でサブフォルダーに同期<br><small>(空欄 = サブフォルダーを使用しない)</small>",
"syncjob": "同期ジョブを編集",
"target_address": "転送先アドレス<small>(カンマ区切り)</small>",
"target_domain": "ターゲットドメイン",
"timeout1": "リモートホストへの接続タイムアウト",
"timeout2": "ローカルホストへの接続タイムアウト",
"title": "オブジェクトを編集",
"unchanged_if_empty": "変更がない場合は空欄にしてください",
"username": "ユーザー名",
@@ -887,6 +915,16 @@
"spam_aliases": "一時的なエイリアス",
"stats": "統計",
"status": "ステータス",
"sync_jobs": "同期ジョブ",
"syncjob_check_log": "ログを確認",
"syncjob_last_run_result": "最後の実行結果",
"syncjob_EX_OK": "成功",
"syncjob_EXIT_CONNECTION_FAILURE": "接続問題",
"syncjob_EXIT_TLS_FAILURE": "暗号化接続の問題",
"syncjob_EXIT_AUTHENTICATION_FAILURE": "認証問題",
"syncjob_EXIT_OVERQUOTA": "宛先メールボックスがクォータを超過",
"syncjob_EXIT_CONNECTION_FAILURE_HOST1": "リモートサーバーに接続できません",
"syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "ユーザー名またはパスワードが間違っています",
"table_size": "テーブルサイズ",
"table_size_show_n": "%s 件を表示",
"target_address": "転送先アドレス",
@@ -1116,37 +1154,6 @@
"waiting_usb_register": "<i>USBデバイスを待っています...</i><br><br>上記にパスワードを入力し、USBデバイスのボタンを押して登録を確認してください。",
"yubi_otp": "Yubico OTP認証"
},
"syncjobs": {
"sync_jobs": "同期ジョブ",
"syncjob_check_log": "ログを確認",
"syncjob_last_run_result": "最後の実行結果",
"syncjob_EX_OK": "成功",
"syncjob_EXIT_CONNECTION_FAILURE": "接続問題",
"syncjob_EXIT_TLS_FAILURE": "暗号化接続の問題",
"syncjob_EXIT_AUTHENTICATION_FAILURE": "認証問題",
"syncjob_EXIT_OVERQUOTA": "宛先メールボックスがクォータ超過",
"syncjob_EXIT_CONNECTION_FAILURE_HOST1": "リモートサーバーに接続できません",
"syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "ユーザー名またはパスワードが間違っています",
"add": "同期ジョブを追加",
"add_hint": "パスワードはプレーンテキストで保存される必要があることに注意してください!",
"edit": "同期ジョブを編集",
"create": "新しい同期ジョブを作成",
"dry": "同期をシミュレーション",
"subfolder2": "送信先でサブフォルダーに同期<br><small>(空欄 = サブフォルダーを使用しない)</small>",
"mins_interval": "ポーリング間隔(分)",
"maxage": "リモートから取得するメッセージの最大日数<br><small>(0 = 日数を無視)</small>",
"exclude": "オブジェクトを除外(正規表現)",
"automap": "フォルダーを自動マッピングする試み(例:「送信済みアイテム」、「送信済み」=>「送信済み」)",
"delete1": "完了時にソースから削除",
"delete2": "ソースに存在しないメッセージを送信先で削除",
"delete2duplicates": "送信先で重複を削除",
"skipcrossduplicates": "フォルダー間で重複するメッセージをスキップ(先着順)",
"subscribeall": "すべてのフォルダーを購読",
"timeout1": "リモートホストへの接続タイムアウト",
"timeout2": "ローカルホストへの接続タイムアウト",
"maxbytespersecond": "毎秒の最大バイト数<br><small>(0 = 無制限)</small>",
"custom_params": "カスタムパラメータ"
},
"user": {
"action": "アクション",
"active": "有効",
@@ -1176,6 +1183,7 @@
"clear_recent_successful_connections": "最近の成功した接続をクリア",
"client_configuration": "メールクライアントとスマートフォンの設定ガイドを表示",
"create_app_passwd": "アプリパスワードを作成",
"create_syncjob": "新しい同期ジョブを作成",
"created_on": "作成日",
"daily": "毎日",
"day": "日",
@@ -1281,6 +1289,16 @@
"spamfilter_wl_desc": "ホワイトリストに登録されたメールアドレスは<b>絶対に</b>スパムとして分類されません。ワイルドカードを使用できます。フィルターは直接エイリアス(単一ターゲットメールボックスを持つエイリアス)にのみ適用され、キャッチオールエイリアスやメールボックス自体には適用されません。",
"spamfilter_yellow": "黄: このメッセージはスパムの可能性があり、スパムとしてタグ付けされ、迷惑メールフォルダに移動されます",
"status": "ステータス",
"sync_jobs": "同期ジョブ",
"syncjob_check_log": "ログを確認",
"syncjob_last_run_result": "最後の実行結果",
"syncjob_EX_OK": "成功",
"syncjob_EXIT_CONNECTION_FAILURE": "接続問題",
"syncjob_EXIT_TLS_FAILURE": "暗号化接続の問題",
"syncjob_EXIT_AUTHENTICATION_FAILURE": "認証問題",
"syncjob_EXIT_OVERQUOTA": "宛先メールボックスがクォータ超過",
"syncjob_EXIT_CONNECTION_FAILURE_HOST1": "リモートサーバーに接続できません",
"syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "ユーザー名またはパスワードが間違っています",
"tag_handling": "タグ付きメールの処理を設定",
"tag_help_example": "タグ付きメールアドレスの例: me<b>+Facebook</b>@example.org",
"tag_help_explain": "サブフォルダー: タグ名に基づいて新しいサブフォルダーがINBOXの下に作成されます(例: \"INBOX/Facebook\")。<br>\r\n件名: タグ名がメールの件名に追加されます(例: \"[Facebook] My News\")。",
+30 -21
View File
@@ -43,8 +43,14 @@
"alias_domain_info": "<small>유효한 도메인 명만 입력 (쉼표로 구분).</small>",
"app_name": "앱 이름",
"app_password": "앱 비밀번호 추가",
"automap": "자동으로 폴더 연결 시도하기 (\"Sent items\", \"Sent\" => \"Sent\" 등등)",
"backup_mx_options": "예비 MX 설정",
"comment_info": "비공개 메모는 사용자에게 보이지 않지만, 공개 메모는 사용자 개요에서 툴팁 형태로 볼 수 있습니다.",
"custom_params": "추가 변수",
"custom_params_hint": "올바른 설정: --param=xy, 올바르지 않은 설정: --param xy",
"delete1": "완료하면 원래 있던 곳에서 삭제",
"delete2": "원래 있는 곳에 있지 않는 메일을 도착지에서 삭제",
"delete2duplicates": "도착지에 있는 중복 메일 삭제",
"description": "설명",
"destination": "목적지",
"disable_login": "로그인 비활성화 (메일은 계속 받습니다.)",
@@ -52,6 +58,7 @@
"domain_matches_hostname": "Domain %s matches hostname",
"domain_quota_m": "도메인에 할당할 디스크 크기 (MiB)",
"enc_method": "암호화 방식",
"exclude": "제외할 항목 (정규식)",
"full_name": "이름",
"gal": "글로벌 주소 리스트",
"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>",
@@ -67,6 +74,7 @@
"mailbox_username": "사용자 이름 (이메일의 @ 왼쪽 부분)",
"max_aliases": "최대 별칭 주소",
"max_mailboxes": "최대 메일함 수",
"mins_interval": "폴링 간격 (단위: 분)",
"multiple_bookings": "Multiple bookings",
"nexthop": "Next hop",
"password": "비밀번호",
@@ -86,9 +94,15 @@
"select_domain": "도메인을 먼저 선택해주세요.",
"sieve_desc": "짧은 설명",
"sieve_type": "필터 종류",
"skipcrossduplicates": "폴더들의 중복 메세지 건너뛰기 (first come, first serve)",
"subscribeall": "모든 폴더 구독",
"syncjob": "동기화 작업 추가",
"syncjob_hint": "비밀번호가 일반 텍스트로 저장됨을 주의해주세요.",
"target_address": "타깃 주소",
"target_address_info": "<small>완전한 이메일 주소 (쉼표로 구분).</small>",
"target_domain": "타깃 도메인",
"timeout1": "원격 호스트 연결 시간 초과",
"timeout2": "로컬 호스트 연결 시간 초과",
"username": "사용자명",
"validate": "확인하기",
"validation_success": "성공적으로 확인됨",
@@ -530,11 +544,15 @@
"alias": "Edit alias",
"app_name": "App name",
"app_passwd": "App password",
"automap": "Try to automap folders (\"Sent items\", \"Sent\" => \"Sent\" etc.)",
"backup_mx_options": "Backup MX options",
"bcc_dest_format": "BCC destination must be a single valid email address.",
"client_id": "Client ID",
"client_secret": "Client secret",
"comment_info": "A private comment is not visible to the user, while a public comment is shown as tooltip when hovering it in a users overview",
"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)",
@@ -545,6 +563,7 @@
"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",
@@ -563,7 +582,10 @@
"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)",
"multiple_bookings": "Multiple bookings",
"nexthop": "Next hop",
"password": "Password",
@@ -597,13 +619,18 @@
"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_visible": "Alias is visible in SOGo",
"sogo_visible_info": "This option only affects objects, that can be displayed in SOGo (shared or non-shared alias addresses pointing to at least one local mailbox). If hidden, an alias will not appear as selectable sender in SOGo.",
"spam_alias": "Create or change time limited alias addresses",
"spam_policy": "Add or remove items to white-/blacklist",
"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",
@@ -771,6 +798,7 @@
"spam_aliases": "Temp. alias",
"stats": "Statistics",
"status": "Status",
"sync_jobs": "Sync jobs",
"table_size": "Table size",
"table_size_show_n": "Show %s items",
"target_address": "Goto address",
@@ -950,27 +978,6 @@
"waiting_usb_register": "<i>Waiting for USB device...</i><br><br>Please enter your password above and confirm your WebAuthn registration by tapping the button on your WebAuthn USB device.",
"yubi_otp": "Yubico OTP authentication"
},
"syncjobs": {
"sync_jobs": "동기화 작업",
"add": "동기화 작업 추가",
"add_hint": "비밀번호가 일반 텍스트로 저장됨을 주의해주세요.",
"edit": "Edit sync job",
"create": "새 동기화 작업 생성",
"subfolder2": "Sync into subfolder on destination<br><small>(empty = do not use subfolder)</small>",
"mins_interval": "폴링 간격 (단위: 분)",
"maxage": "Maximum age of messages in days that will be polled from remote<br><small>(0 = ignore age)</small>",
"exclude": "제외할 항목 (정규식)",
"automap": "자동으로 폴더 연결 시도하기 (\"Sent items\", \"Sent\" => \"Sent\" 등등)",
"delete1": "완료하면 원래 있던 곳에서 삭제",
"delete2": "원래 있는 곳에 있지 않는 메일을 도착지에서 삭제",
"delete2duplicates": "도착지에 있는 중복 메일 삭제",
"skipcrossduplicates": "폴더들의 중복 메세지 건너뛰기 (first come, first serve)",
"subscribeall": "모든 폴더 구독",
"timeout1": "원격 호스트 연결 시간 초과",
"timeout2": "로컬 호스트 연결 시간 초과",
"maxbytespersecond": "Max. bytes per second <br><small>(0 = unlimited)</small>",
"custom_params": "추가 변수"
},
"user": {
"action": "조치",
"active": "활성화됨",
@@ -995,6 +1002,7 @@
"change_password": "비밀번호 변경",
"client_configuration": "이메일 클라이언트와 스마트폰을 위한 계정 설정 가이드 보기",
"create_app_passwd": "앱 비밀번호 생성하기",
"create_syncjob": "새 동기화 작업 생성",
"daily": "매일",
"day": "일",
"description": "Description",
@@ -1074,6 +1082,7 @@
"spamfilter_wl_desc": "화이트리스트에 등록된 메일로부터 온 메세지들은 <b>절대로</b> 스팸으로 분류되지 않습니다. 와일드카드를 사용할 수 있습니다. 필터는 1대1로 대응하는 이메일 별칭에만 적용되며, catch-all 형식의 별칭과 메일함 자체는 제외합니다.",
"spamfilter_yellow": "노랑: 이 메세지는 스팸일 수 있으며, 메일이 스팸함으로 이동합니다.",
"status": "상태",
"sync_jobs": "동기화 작업",
"tag_handling": "태그가 있는 메일 처리 방식 설정",
"tag_help_example": "태그가 있는 이메일 주소의 예시: me<b>+Facebook</b>@example.org",
"tag_help_explain": "하위 폴더로 이동: INBOX 아래에 태그와 같은 이름을 가진 새 하위 폴더를 생성합니다. (\"INBOX/Facebook\").<br>\r\n제목에 추가: 태그가 메일 제목 앞에 추가됩니다. 예시: \"[Facebook] 새 소식\".",
+8 -9
View File
@@ -38,11 +38,15 @@
"app_name": "Programėlės pavadinimas",
"app_password": "Pridėti programėlės slaptažodį",
"app_passwd_protocols": "Prieinamieji programėlės slaptažodžio protokolai",
"custom_params": "Pasirinktiniai parametrai",
"delete1": "Ištrinti iš šaltinio, kai baigta",
"activate_filter_warn": "Visi kiti filtrai bus deaktivuoti, kai „aktyvus“ yra pražymėtas.",
"custom_params_hint": "Teisingai: --param=xy, neteisingai: --param xy",
"description": "Aprašymas",
"destination": "Gavėjas",
"domain": "Domenas",
"domain_quota_m": "Viso domeno kvota (MiB)",
"dry": "Simuliuoti sinchronizaciją",
"enc_method": "Šifravimo metodas",
"full_name": "Prilnas vardas",
"generate": "generuoti",
@@ -60,9 +64,13 @@
"alias_address": "Pakaitiniai/is adresai/as",
"alias_address_info": "<small>Pilnas el. pašto adresas (-ai) arba @example.com, kad gautumėte visus pranešimus iš domeno (atskirtus kableliais). <b>Tik \"mailcow\" domenai</b>.</small>",
"alias_domain": "Pakaitinis domenas",
"automap": "Stenktis automatiškai susieti aplankus (\"Išsiųsti\", \"Išsiųsti\" => \"išsiųsti\" ir tt.)",
"backup_mx_options": "Perdavimo nustatymai",
"bcc_dest_format": "BCC gavimo adresas turi būti vienas galiojantis el. pašto adresas.<br>Jei turite siųsti kopiją į kelis adresus, sukurkite slapyvardį ir naudokite jį čia.",
"delete2": "Ištrinti žinutes gavimo vietoje, kurios nėra šaltinyje.",
"delete2duplicates": "Ištrinti dublikatus gavimo vietoje",
"domain_matches_hostname": "Domenas %s atitinka serverio vardą",
"exclude": "Išskirti objektus (regex)",
"gal": "Visuotinis adresų sąrašas",
"gal_info": "GAL (Global Address List) apima visus domeno objektus ir jų negali redaguoti joks vartotojas. SOGo trūksta laisvosios/užimtosios informacijos, jei tai išjungta! <b>Perkraukite SOGo, kad pritaikytumėte pakeitimus.</b>"
},
@@ -342,14 +350,5 @@
"dovecot_restart_success": "„Doveccot“ perkrautas",
"domain_added": "Pridėtas domenas %s",
"domain_admin_modified": "Pakeitimai domeno adminustratoriui %s išsaugoti"
},
"syncjobs": {
"dry": "Simuliuoti sinchronizaciją",
"exclude": "Išskirti objektus (regex)",
"automap": "Stenktis automatiškai susieti aplankus (\"Išsiųsti\", \"Išsiųsti\" => \"išsiųsti\" ir tt.)",
"delete1": "Ištrinti iš šaltinio, kai baigta",
"delete2": "Ištrinti žinutes gavimo vietoje, kurios nėra šaltinyje.",
"delete2duplicates": "Ištrinti dublikatus gavimo vietoje",
"custom_params": "Pasirinktiniai parametrai"
}
}
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff. Show more