mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-11-08 18:13:37 +00:00
Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a64ee13670 | ||
|
|
46c9ec90b0 | ||
|
|
4484cfcd42 | ||
|
|
329bca1d46 | ||
|
|
6a83b9e0f0 | ||
|
|
3ffecc505e | ||
|
|
e8f12cd061 | ||
|
|
3a4e479424 | ||
|
|
f1e51c0a40 | ||
|
|
ddcba178af | ||
|
|
1752dd6ddf | ||
|
|
adb1c74ce3 | ||
|
|
716f67844d | ||
|
|
a2ab4301e7 | ||
|
|
52f28025a5 | ||
|
|
3c03f8e567 | ||
|
|
72613e7e2b | ||
|
|
9293e4ce43 | ||
|
|
a8d1121f0e | ||
|
|
a4b60ae26f | ||
|
|
e96fb24a67 | ||
|
|
5c762d9949 | ||
|
|
06cbdc4409 | ||
|
|
7c73199bde | ||
|
|
4b7ebab8f3 | ||
|
|
5eb3148bd5 | ||
|
|
41467dc47f | ||
|
|
a1cbd51d70 | ||
|
|
1cb0656924 | ||
|
|
510674ee67 | ||
|
|
a21604cd7b | ||
|
|
4c7e2ec036 | ||
|
|
085d10118d | ||
|
|
1b6fec6a89 | ||
|
|
9eb3a79f3b | ||
|
|
d5e3de3ae8 | ||
|
|
e986803980 | ||
|
|
44429fa0d0 |
No files matched your search
@@ -192,6 +192,7 @@ detect_major_update() {
|
|||||||
"2025-02"
|
"2025-02"
|
||||||
"2025-03"
|
"2025-03"
|
||||||
"2025-09"
|
"2025-09"
|
||||||
|
"2026-11"
|
||||||
)
|
)
|
||||||
|
|
||||||
current_version=""
|
current_version=""
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ adapt_new_options() {
|
|||||||
"ACME_DNS_CHALLENGE"
|
"ACME_DNS_CHALLENGE"
|
||||||
"ACME_DNS_PROVIDER"
|
"ACME_DNS_PROVIDER"
|
||||||
"ACME_ACCOUNT_EMAIL"
|
"ACME_ACCOUNT_EMAIL"
|
||||||
|
"NGINX_MAX_BODY_SIZE"
|
||||||
)
|
)
|
||||||
|
|
||||||
sed -i --follow-symlinks '$a\' mailcow.conf
|
sed -i --follow-symlinks '$a\' mailcow.conf
|
||||||
@@ -309,6 +310,11 @@ adapt_new_options() {
|
|||||||
echo '# Account email for ACME DNS-01 challenge registration' >> mailcow.conf
|
echo '# Account email for ACME DNS-01 challenge registration' >> mailcow.conf
|
||||||
echo 'ACME_ACCOUNT_EMAIL=me@example.com' >> mailcow.conf
|
echo 'ACME_ACCOUNT_EMAIL=me@example.com' >> mailcow.conf
|
||||||
;;
|
;;
|
||||||
|
NGINX_MAX_BODY_SIZE)
|
||||||
|
echo '# Maximum request size for SOGo webmail, CalDAV/CardDAV and ActiveSync, e.g. 128m or 1g - 0 means unlimited' >> mailcow.conf
|
||||||
|
echo '# Keep it above the message size limit of Postfix (100 MB by default). The mailcow UI and API stay limited to 64m.' >> mailcow.conf
|
||||||
|
echo 'NGINX_MAX_BODY_SIZE=128m' >> mailcow.conf
|
||||||
|
;;
|
||||||
*)
|
*)
|
||||||
echo "${option}=" >> mailcow.conf
|
echo "${option}=" >> mailcow.conf
|
||||||
;;
|
;;
|
||||||
|
|||||||
@@ -318,7 +318,7 @@ class DockerApi:
|
|||||||
|
|
||||||
if 'username' in request_json and 'script_name' in request_json:
|
if 'username' in request_json and 'script_name' in request_json:
|
||||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||||
cmd = ["/bin/bash", "-c", "/usr/bin/doveadm sieve get -u '" + request_json['username'].replace("'", "'\\''") + "' '" + request_json['script_name'].replace("'", "'\\''") + "'"]
|
cmd = ["/bin/bash", "-c", "/usr/bin/doveadm sieve get -u '" + request_json['username'].replace("'", "'\\''") + "' -- '" + request_json['script_name'].replace("'", "'\\''") + "'"]
|
||||||
sieve_return = container.exec_run(cmd)
|
sieve_return = container.exec_run(cmd)
|
||||||
return self.exec_run_handler('utf8_text_only', sieve_return)
|
return self.exec_run_handler('utf8_text_only', sieve_return)
|
||||||
# api call: container_post - post_action: exec - cmd: maildir - task: cleanup
|
# api call: container_post - post_action: exec - cmd: maildir - task: cleanup
|
||||||
@@ -491,7 +491,7 @@ class DockerApi:
|
|||||||
if mailbox in mailbox_seen:
|
if mailbox in mailbox_seen:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
acls = container.exec_run(["/bin/bash", "-c", f"doveadm acl get -u '{id}' '{mailbox}'"])
|
acls = container.exec_run(["/bin/bash", "-c", f"doveadm acl get -u '{id}' -- '{mailbox}'"])
|
||||||
acls = acls.output.decode('utf-8').strip().splitlines()
|
acls = acls.output.decode('utf-8').strip().splitlines()
|
||||||
if len(acls) >= 2:
|
if len(acls) >= 2:
|
||||||
for acl in acls[1:]:
|
for acl in acls[1:]:
|
||||||
@@ -510,7 +510,7 @@ class DockerApi:
|
|||||||
if mailbox in mailbox_seen:
|
if mailbox in mailbox_seen:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
acls = container.exec_run(["/bin/bash", "-c", f"doveadm acl get -u '{user}' '{mailbox}'"])
|
acls = container.exec_run(["/bin/bash", "-c", f"doveadm acl get -u '{user}' -- '{mailbox}'"])
|
||||||
acls = acls.output.decode('utf-8').strip().splitlines()
|
acls = acls.output.decode('utf-8').strip().splitlines()
|
||||||
if len(acls) >= 2:
|
if len(acls) >= 2:
|
||||||
for acl in acls[1:]:
|
for acl in acls[1:]:
|
||||||
@@ -534,7 +534,7 @@ class DockerApi:
|
|||||||
id = request_json['id'].replace("'", "'\\''")
|
id = request_json['id'].replace("'", "'\\''")
|
||||||
|
|
||||||
if user and mailbox and id:
|
if user and mailbox and id:
|
||||||
acl_delete_return = container.exec_run(["/bin/bash", "-c", f"doveadm acl delete -u '{user}' '{mailbox}' '{self.acl_identifier(id)}'"])
|
acl_delete_return = container.exec_run(["/bin/bash", "-c", f"doveadm acl delete -u '{user}' -- '{mailbox}' '{self.acl_identifier(id)}'"])
|
||||||
return self.exec_run_handler('generic', acl_delete_return)
|
return self.exec_run_handler('generic', acl_delete_return)
|
||||||
# api call: container_post - post_action: exec - cmd: doveadm - task: set_acl
|
# api call: container_post - post_action: exec - cmd: doveadm - task: set_acl
|
||||||
def container_post__exec__doveadm__set_acl(self, request_json, **kwargs):
|
def container_post__exec__doveadm__set_acl(self, request_json, **kwargs):
|
||||||
@@ -568,7 +568,7 @@ class DockerApi:
|
|||||||
rights += right + " "
|
rights += right + " "
|
||||||
|
|
||||||
if user and mailbox and id and rights:
|
if user and mailbox and id and rights:
|
||||||
acl_set_return = container.exec_run(["/bin/bash", "-c", f"doveadm acl set -u '{user}' '{mailbox}' '{self.acl_identifier(id)}' {rights}"])
|
acl_set_return = container.exec_run(["/bin/bash", "-c", f"doveadm acl set -u '{user}' -- '{mailbox}' '{self.acl_identifier(id)}' {rights}"])
|
||||||
return self.exec_run_handler('generic', acl_set_return)
|
return self.exec_run_handler('generic', acl_set_return)
|
||||||
# api call: container_post - post_action: exec - cmd: doveadm - task: set_acl_all
|
# api call: container_post - post_action: exec - cmd: doveadm - task: set_acl_all
|
||||||
# Sets the rights of an identifier on every folder of a mailbox, no rights = delete; values are passed as
|
# Sets the rights of an identifier on every folder of a mailbox, no rights = delete; values are passed as
|
||||||
@@ -585,7 +585,7 @@ class DockerApi:
|
|||||||
rights = [right.lower() for right in request_json.get('rights', []) if right.lower() in self.acl_rights]
|
rights = [right.lower() for right in request_json.get('rights', []) if right.lower() in self.acl_rights]
|
||||||
if not user or id in ('user=', 'group='):
|
if not user or id in ('user=', 'group='):
|
||||||
return Response(content=json.dumps({ 'type': 'danger', 'msg': 'user and id required' }, indent=4), media_type="application/json")
|
return Response(content=json.dumps({ 'type': 'danger', 'msg': 'user and id required' }, indent=4), media_type="application/json")
|
||||||
action = 'doveadm acl set -u "$ACL_USER" "$folder" "$ACL_ID" $ACL_RIGHTS' if rights else 'doveadm acl delete -u "$ACL_USER" "$folder" "$ACL_ID"'
|
action = 'doveadm acl set -u "$ACL_USER" -- "$folder" "$ACL_ID" $ACL_RIGHTS' if rights else 'doveadm acl delete -u "$ACL_USER" -- "$folder" "$ACL_ID"'
|
||||||
script = ('set -o pipefail; doveadm mailbox list -u "$ACL_USER" | while IFS= read -r folder; do '
|
script = ('set -o pipefail; doveadm mailbox list -u "$ACL_USER" | while IFS= read -r folder; do '
|
||||||
'case "$folder" in Shared/*) continue;; esac; ' + action + ' || exit 1; done && doveadm acl recalc -u "$ACL_USER"')
|
'case "$folder" in Shared/*) continue;; esac; ' + action + ' || exit 1; done && doveadm acl recalc -u "$ACL_USER"')
|
||||||
acl_return = container.exec_run(["/bin/bash", "-c", script],
|
acl_return = container.exec_run(["/bin/bash", "-c", script],
|
||||||
@@ -601,7 +601,7 @@ class DockerApi:
|
|||||||
|
|
||||||
for container in self.sync_docker_client.containers.list(filters=filters):
|
for container in self.sync_docker_client.containers.list(filters=filters):
|
||||||
users = [str(user) for user in request_json.get('users', []) if str(user)]
|
users = [str(user) for user in request_json.get('users', []) if str(user)]
|
||||||
flush_return = container.exec_run(["doveadm", "auth", "cache", "flush"] + users)
|
flush_return = container.exec_run(["doveadm", "auth", "cache", "flush", "--"] + users)
|
||||||
return self.exec_run_handler('generic', flush_return)
|
return self.exec_run_handler('generic', flush_return)
|
||||||
|
|
||||||
# Rights doveadm acl set accepts from the API
|
# Rights doveadm acl set accepts from the API
|
||||||
|
|||||||
@@ -150,6 +150,7 @@ 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 syslog-ng-redis_slave.conf /etc/syslog-ng/syslog-ng-redis_slave.conf
|
||||||
COPY imapsync /usr/local/bin/imapsync
|
COPY imapsync /usr/local/bin/imapsync
|
||||||
COPY imapsync_runner.py /usr/local/bin/imapsync_runner.py
|
COPY imapsync_runner.py /usr/local/bin/imapsync_runner.py
|
||||||
|
COPY retention_runner.py /usr/local/bin/retention_runner.py
|
||||||
COPY report-spam.sieve /usr/lib/dovecot/sieve/report-spam.sieve
|
COPY report-spam.sieve /usr/lib/dovecot/sieve/report-spam.sieve
|
||||||
COPY report-ham.sieve /usr/lib/dovecot/sieve/report-ham.sieve
|
COPY report-ham.sieve /usr/lib/dovecot/sieve/report-ham.sieve
|
||||||
COPY rspamd-pipe-ham /usr/lib/dovecot/sieve/rspamd-pipe-ham
|
COPY rspamd-pipe-ham /usr/lib/dovecot/sieve/rspamd-pipe-ham
|
||||||
|
|||||||
@@ -281,6 +281,7 @@ chown root:tty /dev/console
|
|||||||
chmod +x /usr/lib/dovecot/sieve/rspamd-pipe-ham \
|
chmod +x /usr/lib/dovecot/sieve/rspamd-pipe-ham \
|
||||||
/usr/lib/dovecot/sieve/rspamd-pipe-spam \
|
/usr/lib/dovecot/sieve/rspamd-pipe-spam \
|
||||||
/usr/local/bin/imapsync_runner.py \
|
/usr/local/bin/imapsync_runner.py \
|
||||||
|
/usr/local/bin/retention_runner.py \
|
||||||
/usr/local/bin/imapsync \
|
/usr/local/bin/imapsync \
|
||||||
/usr/local/bin/trim_logs.sh \
|
/usr/local/bin/trim_logs.sh \
|
||||||
/usr/local/bin/sa-rules.sh \
|
/usr/local/bin/sa-rules.sh \
|
||||||
@@ -306,13 +307,20 @@ IMAPSYNC_TABLE=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DB
|
|||||||
echo "$(envsubst < /usr/local/bin/maildir_gc.sh)" > /usr/local/bin/maildir_gc.sh
|
echo "$(envsubst < /usr/local/bin/maildir_gc.sh)" > /usr/local/bin/maildir_gc.sh
|
||||||
|
|
||||||
# Wait for updated schema (DB_VERSION from docker-compose.yml), the userdb query reads tables the init_db of php-fpm creates on updates
|
# Wait for updated schema (DB_VERSION from docker-compose.yml), the userdb query reads tables the init_db of php-fpm creates on updates
|
||||||
DBV_NOW=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN)
|
# No error while the versions table is missing (fresh install, set -e)
|
||||||
while [[ "${DBV_NOW}" != "${DB_VERSION}" ]]; do
|
DBV_NOW=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN 2>/dev/null || true)
|
||||||
|
DBV_WAIT=0
|
||||||
|
while [[ "${DBV_NOW}" != "${DB_VERSION}" ]] && [[ ${DBV_WAIT} -lt 60 ]]; do
|
||||||
echo "Waiting for schema update..."
|
echo "Waiting for schema update..."
|
||||||
sleep 5
|
sleep 5
|
||||||
DBV_NOW=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN)
|
DBV_WAIT=$((DBV_WAIT + 1))
|
||||||
|
DBV_NOW=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN 2>/dev/null || true)
|
||||||
done
|
done
|
||||||
echo "DB schema is ${DBV_NOW}"
|
if [[ "${DBV_NOW}" != "${DB_VERSION}" ]]; then
|
||||||
|
echo -e "\e[31mDB schema is ${DBV_NOW}, expected ${DB_VERSION} - starting anyway, check the php-fpm log (init_db)\e[0m"
|
||||||
|
else
|
||||||
|
echo "DB schema is ${DBV_NOW}"
|
||||||
|
fi
|
||||||
|
|
||||||
# GUID generation
|
# GUID generation
|
||||||
while [[ ${VERSIONS_OK} != 'OK' ]]; do
|
while [[ ${VERSIONS_OK} != 'OK' ]]; do
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
#!/usr/bin/python3
|
#!/usr/bin/python3
|
||||||
|
|
||||||
import fcntl
|
import fcntl
|
||||||
|
import ipaddress
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import signal
|
import signal
|
||||||
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -18,6 +20,15 @@ max_output = 16 * 1024 * 1024
|
|||||||
lock_file = run_dir + '/imapsync_busy.lock'
|
lock_file = run_dir + '/imapsync_busy.lock'
|
||||||
# An OAuth run needs a token valid this long at start (half the lifetime for short-lived tokens)
|
# An OAuth run needs a token valid this long at start (half the lifetime for short-lived tokens)
|
||||||
token_min_valid = 900
|
token_min_valid = 900
|
||||||
|
# Custom params never passed, also when stored before (same list as CustomParams::BLOCKED in the web UI)
|
||||||
|
blocked_params = {'abort', 'delete2foldersbutnot', 'delete2foldersonly', 'logdir', 'logfile', 'maxlinelengthcmd',
|
||||||
|
'oauthrefreshcmd1', 'oauthrefreshcmd2', 'passfile1', 'passfile2', 'pidfile', 'pipemess', 'regexflag', 'regexmess',
|
||||||
|
'regextrans2', 'showpasswords', 'skipmess', 'tests', 'testsdebug', 'testslive', 'testslive6', 'testsunit', 'tmpdir'}
|
||||||
|
# imapsync only gets these variables, not the secrets of the container environment
|
||||||
|
job_env_keys = ('PATH', 'LANG', 'LC_ALL', 'TZ')
|
||||||
|
# A source must not point to mailcow itself (internal Docker network), host1 is checked before each run
|
||||||
|
internal_nets = [ipaddress.ip_network(os.environ.get('IPV4_NETWORK', '172.22.1') + '.0/24', strict=False),
|
||||||
|
ipaddress.ip_network(os.environ.get('IPV6_NETWORK', 'fd4d:6169:6c63:6f77::/64'), strict=False)]
|
||||||
# Refreshed tokens are written to the token files of running jobs this often, imapsync re-reads them on reconnect
|
# Refreshed tokens are written to the token files of running jobs this often, imapsync re-reads them on reconnect
|
||||||
token_sync_interval = 60
|
token_sync_interval = 60
|
||||||
|
|
||||||
@@ -25,7 +36,7 @@ token_sync_interval = 60
|
|||||||
next_sql = """SELECT i.id, i.user1, i.user2, i.password1, i.exclude, i.delete2duplicates, i.maxage,
|
next_sql = """SELECT i.id, i.user1, i.user2, i.password1, i.exclude, i.delete2duplicates, i.maxage,
|
||||||
i.subfolder2, i.delete1, i.delete2, i.automap, i.skipcrossduplicates, i.maxbytespersecond, i.custom_params,
|
i.subfolder2, i.delete1, i.delete2, i.automap, i.skipcrossduplicates, i.maxbytespersecond, i.custom_params,
|
||||||
i.subscribeall, i.timeout1, i.timeout2, i.dry, i.source_id,
|
i.subscribeall, i.timeout1, i.timeout2, i.dry, i.source_id,
|
||||||
s.host1, s.port1, s.enc1, s.auth_type, s.oauth_flow
|
s.host1, s.port1, s.enc1, s.verify_cert, s.auth_type, s.oauth_flow
|
||||||
FROM imapsync i JOIN imapsync_source s ON i.source_id = s.id
|
FROM imapsync i JOIN imapsync_source s ON i.source_id = s.id
|
||||||
WHERE i.active = 1
|
WHERE i.active = 1
|
||||||
AND s.active = 1
|
AND s.active = 1
|
||||||
@@ -106,6 +117,28 @@ def defer_job(job_id, text, exit_status):
|
|||||||
(text, exit_status, job_id))
|
(text, exit_status, job_id))
|
||||||
deferred.add(job_id)
|
deferred.add(job_id)
|
||||||
|
|
||||||
|
# True when host resolves to loopback, link-local or the internal network; unknown hosts are left to imapsync
|
||||||
|
def internal_host(host):
|
||||||
|
try:
|
||||||
|
addrs = {a[4][0] for a in socket.getaddrinfo(host, None)}
|
||||||
|
except (socket.gaierror, UnicodeError):
|
||||||
|
return False
|
||||||
|
for addr in addrs:
|
||||||
|
ip = ipaddress.ip_address(addr.split('%')[0])
|
||||||
|
if ip.version == 6 and ip.ipv4_mapped:
|
||||||
|
ip = ip.ipv4_mapped
|
||||||
|
if ip.is_loopback or ip.is_unspecified or ip.is_link_local or any(ip in net for net in internal_nets):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
# SNI takes host names only
|
||||||
|
def ip_literal(host):
|
||||||
|
try:
|
||||||
|
ipaddress.ip_address(host)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
def oauth_token(j):
|
def oauth_token(j):
|
||||||
if j['oauth_flow'] == 'authorization_code':
|
if j['oauth_flow'] == 'authorization_code':
|
||||||
return query(user_token_sql, (j['source_id'], j['user2'], j['user1'])).fetchone() or {}
|
return query(user_token_sql, (j['source_id'], j['user2'], j['user1'])).fetchone() or {}
|
||||||
@@ -150,6 +183,11 @@ def prepare_job(j, global_bw, files):
|
|||||||
f.write(content)
|
f.write(content)
|
||||||
return path
|
return path
|
||||||
|
|
||||||
|
if internal_host(j['host1']):
|
||||||
|
finish_job(j['id'], 'Host1 %s is an address of mailcow itself, the sync job does not connect to it\n' % j['host1'], 0,
|
||||||
|
'EXIT_CONNECTION_FAILURE_HOST1')
|
||||||
|
return None
|
||||||
|
|
||||||
auth_type = j['auth_type'] or 'PLAIN'
|
auth_type = j['auth_type'] or 'PLAIN'
|
||||||
oauth = None
|
oauth = None
|
||||||
if auth_type == 'XOAUTH2':
|
if auth_type == 'XOAUTH2':
|
||||||
@@ -174,7 +212,11 @@ def prepare_job(j, global_bw, files):
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
pairs = []
|
pairs = []
|
||||||
for p in pairs if isinstance(pairs, list) else []:
|
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']):
|
if not isinstance(p, dict) or not isinstance(p.get('o'), str) or not re.fullmatch(r'[a-z0-9]+', p['o']) \
|
||||||
|
or p['o'] in blocked_params:
|
||||||
|
continue
|
||||||
|
# The certificate check protects the OAuth token of the source, a job must not turn it off
|
||||||
|
if oauth and p['o'] == 'sslargs1':
|
||||||
continue
|
continue
|
||||||
value = '' if p.get('v') is None else str(p['v'])
|
value = '' if p.get('v') is None else str(p['v'])
|
||||||
custom_params.append('--' + p['o'] if value == '' else '--' + p['o'] + '=' + value)
|
custom_params.append('--' + p['o'] if value == '' else '--' + p['o'] + '=' + value)
|
||||||
@@ -205,7 +247,13 @@ def prepare_job(j, global_bw, files):
|
|||||||
cmd.append(opt)
|
cmd.append(opt)
|
||||||
enc1 = {'TLS': '--tls1', 'SSL': '--ssl1'}.get(j['enc1'])
|
enc1 = {'TLS': '--tls1', 'SSL': '--ssl1'}.get(j['enc1'])
|
||||||
if enc1:
|
if enc1:
|
||||||
cmd.append(enc1)
|
# Mail::IMAPClient starts TLS without a host name: check the certificate against host1, not its address
|
||||||
|
cmd += [enc1, '--sslargs1', 'SSL_verifycn_scheme=imap', '--sslargs1', 'SSL_verifycn_name=' + j['host1']]
|
||||||
|
if not ip_literal(j['host1']):
|
||||||
|
cmd += ['--sslargs1', 'SSL_hostname=' + j['host1']]
|
||||||
|
# imapsync does not check certificates by default; custom sslargs1 of password jobs can still change it
|
||||||
|
if j['verify_cert'] == 1:
|
||||||
|
cmd += ['--sslargs1', 'SSL_verify_mode=1']
|
||||||
cmd += ['--host1', j['host1'], '--user1', j['user1']] + auth_args + ['--port1', str(j['port1'])]
|
cmd += ['--host1', j['host1'], '--user1', j['user1']] + auth_args + ['--port1', str(j['port1'])]
|
||||||
cmd += ['--host2', 'localhost', '--user2', j['user2'] + '*' + master_user, '--passfile2', tmpfile(master_pass + '\n')]
|
cmd += ['--host2', 'localhost', '--user2', j['user2'] + '*' + master_user, '--passfile2', tmpfile(master_pass + '\n')]
|
||||||
if j['dry'] == 1:
|
if j['dry'] == 1:
|
||||||
@@ -216,8 +264,10 @@ def prepare_job(j, global_bw, files):
|
|||||||
|
|
||||||
# Worker: imapsync with output to a file, no inherited fds (DB socket, lock)
|
# Worker: imapsync with output to a file, no inherited fds (DB socket, lock)
|
||||||
def start_job(job):
|
def start_job(job):
|
||||||
|
env = {k: os.environ[k] for k in job_env_keys if k in os.environ}
|
||||||
|
env['HOME'] = run_dir
|
||||||
with open(job['outfile'], 'wb') as out:
|
with open(job['outfile'], 'wb') as out:
|
||||||
return subprocess.Popen(job['cmd'], stdin=subprocess.DEVNULL, stdout=out, stderr=subprocess.STDOUT)
|
return subprocess.Popen(job['cmd'], stdin=subprocess.DEVNULL, stdout=out, stderr=subprocess.STDOUT, env=env)
|
||||||
|
|
||||||
# Store the result of a finished worker and remove its temp files
|
# Store the result of a finished worker and remove its temp files
|
||||||
def reap_job(job):
|
def reap_job(job):
|
||||||
|
|||||||
@@ -0,0 +1,249 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# Nightly run of the retention rules (ofelia, dovecot container): gets the plan page by page from the mailcow UI
|
||||||
|
# (nginx:8081/retention.php), gives every folder of a mailbox to the first entry of its plan that matches it, counts
|
||||||
|
# the mail saved in the folders before the period of the rule with doveadm, deletes it unless the rule only
|
||||||
|
# simulates, and reports the results back.
|
||||||
|
|
||||||
|
import fcntl
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
plan_url = 'http://nginx:8081/retention.php'
|
||||||
|
lock_file = '/tmp/retention_runner.lock'
|
||||||
|
# folder keys of the rules and the special-use flag of their folders in the Dovecot configuration
|
||||||
|
special_use = {'trash': '\\Trash', 'junk': '\\Junk', 'sent': '\\Sent', 'drafts': '\\Drafts', 'archive': '\\Archive'}
|
||||||
|
own_folder = 'folder:'
|
||||||
|
# mailboxes per page of the plan and results per report, a large instance has many mailboxes
|
||||||
|
plan_page = 200
|
||||||
|
report_batch = 200
|
||||||
|
# a request is tried this often, waiting longer each time
|
||||||
|
tries = 3
|
||||||
|
|
||||||
|
token = hmac.new(os.environ.get('DBPASS', '').encode(), b'retention', hashlib.sha256).hexdigest()
|
||||||
|
|
||||||
|
def request(url, body=None):
|
||||||
|
data = None if body is None else json.dumps(body).encode()
|
||||||
|
req = urllib.request.Request(url, data=data, headers={'Content-Type': 'application/json', 'X-Retention-Token': token},
|
||||||
|
method='GET' if body is None else 'POST')
|
||||||
|
with urllib.request.urlopen(req, timeout=300) as response:
|
||||||
|
return json.load(response)
|
||||||
|
|
||||||
|
def retried(url, body=None):
|
||||||
|
for attempt in range(1, tries + 1):
|
||||||
|
try:
|
||||||
|
return request(url, body)
|
||||||
|
except Exception as e:
|
||||||
|
if attempt == tries:
|
||||||
|
raise
|
||||||
|
print('retention: request failed, trying again: %s' % e, file=sys.stderr)
|
||||||
|
time.sleep(30 * attempt)
|
||||||
|
|
||||||
|
def plan():
|
||||||
|
# one page after the other, so memory stays small; rule changes apply to the pages fetched after them
|
||||||
|
after = ''
|
||||||
|
while after is not None:
|
||||||
|
page = retried(plan_url + '?' + urllib.parse.urlencode({'after': after, 'limit': plan_page}))
|
||||||
|
yield from page['mailboxes']
|
||||||
|
after = page['next']
|
||||||
|
|
||||||
|
def post(body):
|
||||||
|
# False if the report could not be sent
|
||||||
|
try:
|
||||||
|
retried(plan_url, body)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
print('retention: report failed: %s' % e, file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
class Reports:
|
||||||
|
# results go back in parts; once a part cannot be sent, the rest waits for the end of the run
|
||||||
|
def __init__(self):
|
||||||
|
self.pending, self.failed = [], False
|
||||||
|
|
||||||
|
def add(self, result):
|
||||||
|
self.pending.append(result)
|
||||||
|
if len(self.pending) >= report_batch and not self.failed:
|
||||||
|
self.failed = not self.send()
|
||||||
|
|
||||||
|
def send(self, summary=None, run=None):
|
||||||
|
# full parts, at the end all results with the summary and the run in the last part
|
||||||
|
final = summary is not None
|
||||||
|
while len(self.pending) >= report_batch or final:
|
||||||
|
body = {'results': self.pending[:report_batch]}
|
||||||
|
if final and len(self.pending) <= report_batch:
|
||||||
|
body.update(summary=summary, run=run)
|
||||||
|
if not post(body):
|
||||||
|
return False
|
||||||
|
del self.pending[:report_batch]
|
||||||
|
if 'summary' in body:
|
||||||
|
break
|
||||||
|
return True
|
||||||
|
|
||||||
|
def doveadm(*args, partial=False):
|
||||||
|
result = subprocess.run(['doveadm', *args], capture_output=True, text=True, timeout=3600)
|
||||||
|
# mailbox status also fails for single folders that cannot be opened; the others are still listed
|
||||||
|
if result.returncode != 0 and not (partial and result.stdout):
|
||||||
|
raise RuntimeError((result.stderr or result.stdout).strip()[:500] or 'doveadm exit %d' % result.returncode)
|
||||||
|
return result.stdout
|
||||||
|
|
||||||
|
def configuration():
|
||||||
|
# names of the special folders per key, and the prefixes of the shared and public namespaces (not the user's)
|
||||||
|
names = {key: set() for key in special_use}
|
||||||
|
prefixes = ['Shared/']
|
||||||
|
namespace, mailbox = None, None
|
||||||
|
# as vmail doveconf cannot read every included file (mail_crypt keys) and fails, but prints the namespaces
|
||||||
|
for line in subprocess.run(['doveconf', '-n'], capture_output=True, text=True).stdout.splitlines():
|
||||||
|
stripped = line.strip()
|
||||||
|
if line.startswith('namespace ') and line.endswith('{'):
|
||||||
|
namespace = {}
|
||||||
|
elif line == '}':
|
||||||
|
if namespace and namespace.get('type') in ('shared', 'public') and namespace.get('prefix'):
|
||||||
|
prefixes.append(namespace['prefix'].split('%')[0])
|
||||||
|
namespace = None
|
||||||
|
elif namespace is None:
|
||||||
|
continue
|
||||||
|
elif re.match(r'mailbox .+ \{$', stripped):
|
||||||
|
mailbox = stripped[len('mailbox '):-2].strip().strip('"')
|
||||||
|
elif stripped == '}':
|
||||||
|
mailbox = None
|
||||||
|
elif '=' in stripped:
|
||||||
|
key, value = (part.strip() for part in stripped.split('=', 1))
|
||||||
|
if mailbox is not None and key == 'special_use':
|
||||||
|
for folder, flag in special_use.items():
|
||||||
|
if flag in value.split():
|
||||||
|
names[folder].add(mailbox)
|
||||||
|
elif mailbox is None and key in ('type', 'prefix'):
|
||||||
|
namespace[key] = value
|
||||||
|
if not names['trash']:
|
||||||
|
raise RuntimeError('no special folders in the output of doveconf -n')
|
||||||
|
return names, [prefix for prefix in prefixes if prefix]
|
||||||
|
|
||||||
|
def own_folders(user, prefixes):
|
||||||
|
# folder name => guid of the user's own folders; listed first, so folders others share are not opened
|
||||||
|
names = set(name for name in doveadm('-f', 'tab', 'mailbox', 'list', '-u', user).splitlines()[1:]
|
||||||
|
if name and not any((name + '/').startswith(prefix) for prefix in prefixes))
|
||||||
|
folders = {}
|
||||||
|
if not names:
|
||||||
|
return folders
|
||||||
|
for line in doveadm('-f', 'tab', 'mailbox', 'status', '-u', user, 'guid', *sorted(names), partial=True).splitlines()[1:]:
|
||||||
|
name, _, guid = line.rpartition('\t')
|
||||||
|
if name in names and re.fullmatch(r'[0-9a-f]{32}', guid):
|
||||||
|
folders[name] = guid
|
||||||
|
return folders
|
||||||
|
|
||||||
|
def canonical(name):
|
||||||
|
# Dovecot writes INBOX in capitals, also as the parent of folders
|
||||||
|
return 'INBOX' + name[5:] if name[:5].upper() == 'INBOX' and name[5:6] in ('', '/') else name
|
||||||
|
|
||||||
|
def matches(action, name, names):
|
||||||
|
# match: the folder itself, an own folder name with *, the subfolders of a special folder or the inbox, all folders
|
||||||
|
key, match = action['folder'], action['match']
|
||||||
|
if match == 'all':
|
||||||
|
return True
|
||||||
|
if key.startswith(own_folder):
|
||||||
|
# * is the only wildcard of own folder names
|
||||||
|
pattern = '.*'.join(re.escape(part) for part in canonical(key[len(own_folder):]).split('*'))
|
||||||
|
return re.fullmatch(pattern, name) is not None
|
||||||
|
folders = ['INBOX'] if key == 'inbox' else names.get(key, ())
|
||||||
|
if match == 'subfolders':
|
||||||
|
return any(name.startswith(folder + '/') for folder in folders)
|
||||||
|
return name in folders
|
||||||
|
|
||||||
|
def decide(actions, folders, names):
|
||||||
|
# rule id => (its action, the folders it decides about): every folder goes to the first action that matches it
|
||||||
|
decided = {}
|
||||||
|
for name in sorted(folders):
|
||||||
|
for action in actions:
|
||||||
|
if matches(action, canonical(name), names):
|
||||||
|
decided.setdefault(action['rule_id'], (action, []))[1].append(name)
|
||||||
|
break
|
||||||
|
return decided
|
||||||
|
|
||||||
|
def found(user, query):
|
||||||
|
return len(doveadm('search', '-u', user, *query).splitlines())
|
||||||
|
|
||||||
|
def clean(user, action, selected, folders, result):
|
||||||
|
# name and guid together: doveadm then only opens these folders
|
||||||
|
query = []
|
||||||
|
for name in selected:
|
||||||
|
query += (['OR'] if query else []) + ['(', 'mailbox', name, 'mailbox-guid', folders[name], ')']
|
||||||
|
query = ['('] + query + [')', 'savedbefore', '%dd' % int(action['days'])]
|
||||||
|
if action['keep_flagged']:
|
||||||
|
query += ['NOT', 'FLAGGED']
|
||||||
|
result['matched'] = found(user, query)
|
||||||
|
if not result['matched'] or action['mode'] != 'delete':
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
doveadm('expunge', '-u', user, *query)
|
||||||
|
result['deleted'] = result['matched']
|
||||||
|
except Exception as e:
|
||||||
|
# part of the mail may be gone: what is left is counted
|
||||||
|
result['error'] = str(e)[:1000]
|
||||||
|
try:
|
||||||
|
result['deleted'] = max(0, result['matched'] - found(user, query))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def main():
|
||||||
|
# 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("retention: can't lock " + lock_file)
|
||||||
|
reports = Reports()
|
||||||
|
run = {'started': int(time.time()), 'mailboxes': 0, 'matched': 0, 'deleted': 0, 'errors': 0, 'error': ''}
|
||||||
|
summary = {}
|
||||||
|
try:
|
||||||
|
names, prefixes = configuration()
|
||||||
|
for mailbox in plan():
|
||||||
|
user = mailbox['username']
|
||||||
|
run['mailboxes'] += 1
|
||||||
|
try:
|
||||||
|
folders, error = own_folders(user, prefixes), ''
|
||||||
|
decided = decide(mailbox['actions'], folders, names)
|
||||||
|
except Exception as e:
|
||||||
|
# the folders are unknown: an error for every rule of the mailbox
|
||||||
|
folders, error, decided = {}, str(e)[:1000], {}
|
||||||
|
for action in mailbox['actions']:
|
||||||
|
decided.setdefault(action['rule_id'], (action, []))
|
||||||
|
for rule_id, (action, selected) in decided.items():
|
||||||
|
rule = summary.setdefault(rule_id, {'rule_id': rule_id, 'mailboxes': 0, 'matched': 0, 'deleted': 0, 'errors': 0,
|
||||||
|
'simulated': int(action['mode'] != 'delete')})
|
||||||
|
result = {'username': user, 'rule_id': rule_id, 'folder': '\n'.join(selected), 'days': action['days'], 'matched': 0,
|
||||||
|
'deleted': 0, 'simulated': rule['simulated'], 'error': error}
|
||||||
|
if not error:
|
||||||
|
try:
|
||||||
|
clean(user, action, selected, folders, result)
|
||||||
|
except Exception as e:
|
||||||
|
result['error'] = str(e)[:1000]
|
||||||
|
for counter in (rule, run):
|
||||||
|
counter['matched'] += result['matched']
|
||||||
|
counter['deleted'] += result['deleted']
|
||||||
|
counter['errors'] += int(result['error'] != '')
|
||||||
|
rule['mailboxes'] += 1
|
||||||
|
# only mailboxes with found mail or an error are logged, every rule gets a summary
|
||||||
|
if result['matched'] or result['error']:
|
||||||
|
reports.add(result)
|
||||||
|
except Exception as e:
|
||||||
|
run['error'] = str(e)[:1000]
|
||||||
|
reported = reports.send(list(summary.values()), run)
|
||||||
|
print('retention: %d mailboxes, %d mails deleted, %d found by simulating rules, %d errors' % (run['mailboxes'],
|
||||||
|
run['deleted'], sum(rule['matched'] for rule in summary.values() if rule['simulated']), run['errors']))
|
||||||
|
if run['error']:
|
||||||
|
print('retention: %s' % run['error'], file=sys.stderr)
|
||||||
|
if not reported:
|
||||||
|
print('retention: the results could not be reported', file=sys.stderr)
|
||||||
|
if run['error'] or not reported:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -7,9 +7,6 @@ class BanManager:
|
|||||||
and the allowlist. Every firewall change runs under lock, which also guards the other firewall work of netfilter
|
and the allowlist. Every firewall change runs under lock, which also guards the other firewall work of netfilter
|
||||||
(chains, SNAT), because the backends must not run in parallel."""
|
(chains, SNAT), because the backends must not run in parallel."""
|
||||||
|
|
||||||
# Seconds failed logins of an address are not counted after the auth backend was unavailable for it
|
|
||||||
EXCUSE_TIME = 60
|
|
||||||
|
|
||||||
def __init__(self, r, tables, options, logger, lock, clock=time.time):
|
def __init__(self, r, tables, options, logger, lock, clock=time.time):
|
||||||
self.r = r
|
self.r = r
|
||||||
self.tables = tables
|
self.tables = tables
|
||||||
@@ -21,16 +18,6 @@ class BanManager:
|
|||||||
self.banned = set()
|
self.banned = set()
|
||||||
self.denied = {}
|
self.denied = {}
|
||||||
self.allowlist = []
|
self.allowlist = []
|
||||||
self.excused = {}
|
|
||||||
|
|
||||||
def excuse(self, ip):
|
|
||||||
"""The auth backend could not check a login of ip: its failed logins are not counted for EXCUSE_TIME"""
|
|
||||||
with self.lock:
|
|
||||||
now = self.clock()
|
|
||||||
if self.excused.get(ip, 0) <= now:
|
|
||||||
self.logger.logWarn('Auth backend unavailable for %s, its failed logins of the next %d seconds are not counted' % (
|
|
||||||
ip, self.EXCUSE_TIME))
|
|
||||||
self.excused[ip] = now + self.EXCUSE_TIME
|
|
||||||
|
|
||||||
def attempt(self, ip):
|
def attempt(self, ip):
|
||||||
"""Counts a failed attempt of ip and bans its network after max_attempts within retry_window"""
|
"""Counts a failed attempt of ip and bans its network after max_attempts within retry_window"""
|
||||||
@@ -43,9 +30,6 @@ class BanManager:
|
|||||||
if allowed is not None:
|
if allowed is not None:
|
||||||
self.logger.logInfo('Address %s is allowlisted by rule %s' % (ip, allowed))
|
self.logger.logInfo('Address %s is allowlisted by rule %s' % (ip, allowed))
|
||||||
return
|
return
|
||||||
if self.excused.get(ip, 0) > now:
|
|
||||||
self.logger.logInfo('Not counting the failed login of %s, the auth backend was unavailable' % ip)
|
|
||||||
return
|
|
||||||
if net in self.banned:
|
if net in self.banned:
|
||||||
# log lines of connections from before the ban
|
# log lines of connections from before the ban
|
||||||
return
|
return
|
||||||
@@ -208,8 +192,6 @@ class BanManager:
|
|||||||
for net in [net for net, counter in self.counters.items()
|
for net in [net for net, counter in self.counters.items()
|
||||||
if net not in self.banned and counter['ban_counter'] == 0 and now - counter['last_attempt'] > window]:
|
if net not in self.banned and counter['ban_counter'] == 0 and now - counter['last_attempt'] > window]:
|
||||||
del self.counters[net]
|
del self.counters[net]
|
||||||
for ip in [ip for ip, until in self.excused.items() if until <= now]:
|
|
||||||
del self.excused[ip]
|
|
||||||
|
|
||||||
def apply(self, nets):
|
def apply(self, nets):
|
||||||
for net in self.tables.ban(nets):
|
for net in self.tables.ban(nets):
|
||||||
|
|||||||
@@ -109,12 +109,6 @@ class Netfilter:
|
|||||||
def handle_line(self, line):
|
def handle_line(self, line):
|
||||||
# a broken line must not end the watcher
|
# a broken line must not end the watcher
|
||||||
try:
|
try:
|
||||||
excused = Regexes.temporary_failure(line)
|
|
||||||
if excused is not None:
|
|
||||||
ip = Regexes.parse_ip(excused)
|
|
||||||
if self.external(ip):
|
|
||||||
self.bans.excuse(ip)
|
|
||||||
return
|
|
||||||
result = self.regexes.match(line)
|
result = self.regexes.match(line)
|
||||||
if result is None:
|
if result is None:
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -6,20 +6,43 @@ import re
|
|||||||
class Regexes:
|
class Regexes:
|
||||||
"""Rules for log lines (Redis F2B_REGEX): rule id => regex whose first group captures the client address"""
|
"""Rules for log lines (Redis F2B_REGEX): rule id => regex whose first group captures the client address"""
|
||||||
|
|
||||||
|
# Anchored to the line start: text clients control in other lines (login names, SMTP commands) must not match.
|
||||||
|
# Failed IMAP, POP3, Sieve and SMTP logins are reported by mailcowauth.php (rule 3), not by Dovecot or Postfix.
|
||||||
|
# Only the master passdb is checked by Dovecot itself (rule 8); the last address is the one Dovecot added.
|
||||||
DEFAULTS = {
|
DEFAULTS = {
|
||||||
1: r'mailcow UI: Invalid password for .+ by ([0-9a-f\.:]+)',
|
1: r'^mailcow UI: Invalid password for .+ by ([0-9a-f\.:]+)$',
|
||||||
2: r'Rspamd UI: Invalid password by ([0-9a-f\.:]+)',
|
2: r'^Rspamd UI: Invalid password by ([0-9a-f\.:]+)$',
|
||||||
3: r'warning: .*\[([0-9a-f\.:]+)\]: SASL .+ authentication failed: (?!.*Connection lost to authentication server).+',
|
3: r'^MAILCOWAUTH: Login failed for user .+ with service \w* from IP ([0-9a-f\.:]+)$',
|
||||||
4: r'warning: non-SMTP command from .*\[([0-9a-f\.:]+)]:.+',
|
4: r'^warning: non-SMTP command from [^\[\]\s]*\[([0-9a-f\.:]+)\]:.+',
|
||||||
5: r'NOQUEUE: reject: RCPT from \[([0-9a-f\.:]+)].+Protocol error.+',
|
5: r'^NOQUEUE: reject: RCPT from \[([0-9a-f\.:]+)].+Protocol error.+',
|
||||||
6: r'\w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): Password mismatch \(SHA1 of given password: [a-f0-9]+\)',
|
6: r'^(?:\[\d+\]: )?SOGo\w* Login from \'(?:[0-9a-f\.:]+, )*([0-9a-f\.:]+)\' for user .+ might not have worked',
|
||||||
7: r'\w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): unknown user \(SHA1 of given password: [a-f0-9]+\)',
|
7: r'([0-9a-f\.:]+) \"GET \/SOGo\/.* HTTP.+\" 403 .+',
|
||||||
8: r'SOGo.+ Login from \'([0-9a-f\.:]+)\' for user .+ might not have worked',
|
8: r'^auth: passwd-file\(.*,([0-9a-f\.:]+),master(?:,<[^<>]*>)?\): (?:Password mismatch|unknown user)',
|
||||||
9: r'([0-9a-f\.:]+) \"GET \/SOGo\/.* HTTP.+\" 403 .+',
|
|
||||||
}
|
}
|
||||||
# Dovecot could not ask the auth backend (identity provider down, mailcowauth.php unreachable). Postfix logs
|
# Former default rules (with any rule id) => rule id of their replacement, None: the rule is removed.
|
||||||
# such a temporary failure like a wrong password, so failed logins of the address are not counted for a while.
|
# Rules the admin changed are kept.
|
||||||
TEMPORARY_FAILURE = re.compile(r'lua\([^,]+,([0-9a-f\.:]+)(?:,<[^>]+>)?\): passdb-lua: Upstream (?:unavailable|unreachable)')
|
FORMER_DEFAULTS = {
|
||||||
|
r'mailcow UI: Invalid password for .+ by ([0-9a-f\.:]+)': 1,
|
||||||
|
r'Rspamd UI: Invalid password by ([0-9a-f\.:]+)': 2,
|
||||||
|
r'warning: .*\[([0-9a-f\.:]+)\]: SASL .+ authentication failed': 3,
|
||||||
|
r'warning: .*\[([0-9a-f\.:]+)\]: SASL .+ authentication failed: (?!.*Connection lost to authentication server).+': 3,
|
||||||
|
r'warning: non-SMTP command from .*\[([0-9a-f\.:]+)]:.+': 4,
|
||||||
|
r'NOQUEUE: reject: RCPT from \[([0-9a-f\.:]+)].+Protocol error.+': 5,
|
||||||
|
r"SOGo.+ Login from '([0-9a-f\.:]+)' for user .+ might not have worked": 6,
|
||||||
|
r'SOGo.+ Login from \'([0-9a-f\.:]+)\' for user .+ might not have worked': 6,
|
||||||
|
r'-login: Disconnected \(auth failed, .+\): user=.*, method=.+, rip=([0-9a-f\.:]+),': None,
|
||||||
|
r'-login: Disconnected.+ \(auth failed, .+\): user=.*, method=.+, rip=([0-9a-f\.:]+),': None,
|
||||||
|
r'-login: Aborted login \(auth failed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+': None,
|
||||||
|
r'-login: Aborted login.+ \(auth failed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+': None,
|
||||||
|
r'-login: Aborted login \(tried to use disallowed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+': None,
|
||||||
|
r'-login: Aborted login.+ \(tried to use disallowed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+': None,
|
||||||
|
r'auth: static\([^,]+,([0-9a-f\.:]+),<[^>]+>\): Password mismatch \(SHA1 of given password: [a-f0-9]+\)': 8,
|
||||||
|
r'auth: \w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): Password mismatch \(SHA1 of given password: [a-f0-9]+\)': 8,
|
||||||
|
r'\w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): Password mismatch \(SHA1 of given password: [a-f0-9]+\)': 8,
|
||||||
|
r'\w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): unknown user \(SHA1 of given password: [a-f0-9]+\)': None,
|
||||||
|
}
|
||||||
|
# Lines of the web UI are not sanitized like syslog lines: a newline in a login name must not hide the address
|
||||||
|
CONTROL_CHARS = re.compile(r'[\x00-\x1f\x7f]')
|
||||||
|
|
||||||
def __init__(self, logger):
|
def __init__(self, logger):
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
@@ -42,6 +65,10 @@ class Regexes:
|
|||||||
if not isinstance(stored, dict):
|
if not isinstance(stored, dict):
|
||||||
self.logger.logWarn('F2B_REGEX is no JSON object, keeping the current rules')
|
self.logger.logWarn('F2B_REGEX is no JSON object, keeping the current rules')
|
||||||
return
|
return
|
||||||
|
migrated = self.migrate(stored)
|
||||||
|
if migrated != stored:
|
||||||
|
self.store_migrated(r)
|
||||||
|
stored = migrated
|
||||||
|
|
||||||
rules = []
|
rules = []
|
||||||
for rule_id, regex in sorted(stored.items(), key=lambda item: self.sort_key(item[0])):
|
for rule_id, regex in sorted(stored.items(), key=lambda item: self.sort_key(item[0])):
|
||||||
@@ -54,6 +81,7 @@ class Regexes:
|
|||||||
|
|
||||||
def match(self, line):
|
def match(self, line):
|
||||||
"""(rule id, captured address) of the first rule that matches the line, None if no rule matches"""
|
"""(rule id, captured address) of the first rule that matches the line, None if no rule matches"""
|
||||||
|
line = self.CONTROL_CHARS.sub('_', line)
|
||||||
for rule_id, regex in self.rules:
|
for rule_id, regex in self.rules:
|
||||||
result = regex.search(line)
|
result = regex.search(line)
|
||||||
if result and result.lastindex and result.group(1):
|
if result and result.lastindex and result.group(1):
|
||||||
@@ -61,10 +89,35 @@ class Regexes:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def temporary_failure(cls, line):
|
def migrate(cls, stored):
|
||||||
"""Address of a login that failed because the auth backend was unavailable, None for other lines"""
|
"""The stored rules with former default rules replaced by the current ones (or removed)"""
|
||||||
result = cls.TEMPORARY_FAILURE.search(line)
|
migrated = {}
|
||||||
return result.group(1) if result else None
|
for rule_id, regex in stored.items():
|
||||||
|
if isinstance(regex, str) and regex in cls.FORMER_DEFAULTS:
|
||||||
|
replacement = cls.FORMER_DEFAULTS[regex]
|
||||||
|
if replacement is None:
|
||||||
|
continue
|
||||||
|
regex = cls.DEFAULTS[replacement]
|
||||||
|
migrated[rule_id] = regex
|
||||||
|
return migrated
|
||||||
|
|
||||||
|
def store_migrated(self, r):
|
||||||
|
# WATCH keeps a save of the UI between reading and writing
|
||||||
|
def replace(pipe):
|
||||||
|
current = json.loads(pipe.get('F2B_REGEX') or '{}')
|
||||||
|
if not isinstance(current, dict):
|
||||||
|
return
|
||||||
|
migrated = self.migrate(current)
|
||||||
|
if migrated == current:
|
||||||
|
return
|
||||||
|
pipe.multi()
|
||||||
|
pipe.set('F2B_REGEX', json.dumps(migrated, ensure_ascii=False))
|
||||||
|
|
||||||
|
try:
|
||||||
|
r.transaction(replace, 'F2B_REGEX')
|
||||||
|
self.logger.logInfo('Replaced former default rules in F2B_REGEX')
|
||||||
|
except Exception as ex:
|
||||||
|
self.logger.logWarn('Could not replace former default rules in F2B_REGEX: %s' % ex)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def parse_ip(text):
|
def parse_ip(text):
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
from jinja2 import Environment, FileSystemLoader
|
from jinja2 import Environment, FileSystemLoader
|
||||||
|
|
||||||
@@ -44,6 +45,11 @@ def prepare_template_vars():
|
|||||||
ipv4_network = os.getenv("IPV4_NETWORK", "172.22.1")
|
ipv4_network = os.getenv("IPV4_NETWORK", "172.22.1")
|
||||||
additional_server_names = os.getenv("ADDITIONAL_SERVER_NAMES", "")
|
additional_server_names = os.getenv("ADDITIONAL_SERVER_NAMES", "")
|
||||||
trusted_proxies = os.getenv("TRUSTED_PROXIES", "")
|
trusted_proxies = os.getenv("TRUSTED_PROXIES", "")
|
||||||
|
# nginx size syntax, invalid values fall back to the default
|
||||||
|
max_body_size = os.getenv("NGINX_MAX_BODY_SIZE", "128m").strip()
|
||||||
|
if not re.fullmatch(r'[0-9]+[kKmMgG]?', max_body_size):
|
||||||
|
print(f"Invalid NGINX_MAX_BODY_SIZE '{max_body_size}', using 128m")
|
||||||
|
max_body_size = "128m"
|
||||||
|
|
||||||
template_vars = {
|
template_vars = {
|
||||||
'IPV4_NETWORK': ipv4_network,
|
'IPV4_NETWORK': ipv4_network,
|
||||||
@@ -60,6 +66,7 @@ def prepare_template_vars():
|
|||||||
'PHPFPMHOST': os.getenv("PHPFPMHOST", "php-fpm-mailcow"),
|
'PHPFPMHOST': os.getenv("PHPFPMHOST", "php-fpm-mailcow"),
|
||||||
'ENABLE_IPV6': os.getenv("ENABLE_IPV6", "true").lower() != "false",
|
'ENABLE_IPV6': os.getenv("ENABLE_IPV6", "true").lower() != "false",
|
||||||
'HTTP_REDIRECT': os.getenv("HTTP_REDIRECT", "n").lower() in ("y", "yes"),
|
'HTTP_REDIRECT': os.getenv("HTTP_REDIRECT", "n").lower() in ("y", "yes"),
|
||||||
|
'NGINX_MAX_BODY_SIZE': max_body_size,
|
||||||
}
|
}
|
||||||
|
|
||||||
ssl_dir = '/etc/ssl/mail/'
|
ssl_dir = '/etc/ssl/mail/'
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ RUN groupadd -g 102 postfix \
|
|||||||
postfix \
|
postfix \
|
||||||
postfix-mysql \
|
postfix-mysql \
|
||||||
postfix-pcre \
|
postfix-pcre \
|
||||||
|
python3 \
|
||||||
redis-tools \
|
redis-tools \
|
||||||
sasl2-bin \
|
sasl2-bin \
|
||||||
sudo \
|
sudo \
|
||||||
@@ -46,6 +47,7 @@ COPY postfix.sh /opt/postfix.sh
|
|||||||
COPY rspamd-pipe-ham /usr/local/bin/rspamd-pipe-ham
|
COPY rspamd-pipe-ham /usr/local/bin/rspamd-pipe-ham
|
||||||
COPY rspamd-pipe-spam /usr/local/bin/rspamd-pipe-spam
|
COPY rspamd-pipe-spam /usr/local/bin/rspamd-pipe-spam
|
||||||
COPY whitelist_forwardinghosts.sh /usr/local/bin/whitelist_forwardinghosts.sh
|
COPY whitelist_forwardinghosts.sh /usr/local/bin/whitelist_forwardinghosts.sh
|
||||||
|
COPY mailcow-policy.py /usr/local/bin/mailcow-policy.py
|
||||||
COPY stop-supervisor.sh /usr/local/sbin/stop-supervisor.sh
|
COPY stop-supervisor.sh /usr/local/sbin/stop-supervisor.sh
|
||||||
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
COPY docker-entrypoint.sh /docker-entrypoint.sh
|
||||||
|
|
||||||
@@ -53,6 +55,7 @@ RUN chmod +x /opt/postfix.sh \
|
|||||||
/usr/local/bin/rspamd-pipe-ham \
|
/usr/local/bin/rspamd-pipe-ham \
|
||||||
/usr/local/bin/rspamd-pipe-spam \
|
/usr/local/bin/rspamd-pipe-spam \
|
||||||
/usr/local/bin/whitelist_forwardinghosts.sh \
|
/usr/local/bin/whitelist_forwardinghosts.sh \
|
||||||
|
/usr/local/bin/mailcow-policy.py \
|
||||||
/usr/local/sbin/stop-supervisor.sh
|
/usr/local/sbin/stop-supervisor.sh
|
||||||
RUN rm -rf /tmp/* /var/tmp/*
|
RUN rm -rf /tmp/* /var/tmp/*
|
||||||
|
|
||||||
|
|||||||
Executable
+54
@@ -0,0 +1,54 @@
|
|||||||
|
#!/usr/bin/python3
|
||||||
|
# Policy service of the mail policies (mailcow-policy in master.cf, spawned by Postfix): reads the policy requests
|
||||||
|
# of smtpd and asks the mailcow UI for the action (nginx:8081/mailpolicy.php). Without an answer the recipient is
|
||||||
|
# deferred; smtpd only asks while policies or internal aliases exist (mysql_mail_policy_maps.cf).
|
||||||
|
|
||||||
|
import http.client
|
||||||
|
import re
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
|
||||||
|
# one line: DUNNO or a rejection with code and text
|
||||||
|
valid_action = re.compile(rb'DUNNO|[45]\d\d [45]\.\d{1,3}\.\d{1,3} [\x20-\x7e]{1,200}')
|
||||||
|
unavailable = b'DEFER_IF_PERMIT Mail policy check temporarily unavailable'
|
||||||
|
# address of nginx, resolved once per process (a lookup costs more than the check)
|
||||||
|
nginx = None
|
||||||
|
|
||||||
|
def check(request):
|
||||||
|
global nginx
|
||||||
|
body = urlencode(sorted(request.items())).encode()
|
||||||
|
try:
|
||||||
|
if nginx is None:
|
||||||
|
nginx = socket.gethostbyname('nginx')
|
||||||
|
# http.client sets TCP_NODELAY, so the body is not held back until the headers are acknowledged
|
||||||
|
# below the timeout of Postfix (main.cf), so an answer always arrives
|
||||||
|
conn = http.client.HTTPConnection(nginx, 8081, timeout=8)
|
||||||
|
try:
|
||||||
|
conn.request('POST', '/mailpolicy.php', body, {'Host': 'nginx', 'Content-Type': 'application/x-www-form-urlencoded'})
|
||||||
|
response = conn.getresponse()
|
||||||
|
action = response.read()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except (OSError, http.client.HTTPException):
|
||||||
|
# nginx may have a new address after a restart
|
||||||
|
nginx = None
|
||||||
|
return unavailable
|
||||||
|
return action if response.status == 200 and valid_action.fullmatch(action) else unavailable
|
||||||
|
|
||||||
|
def main():
|
||||||
|
request = {}
|
||||||
|
# attributes as bytes: addresses may contain any 8-bit characters
|
||||||
|
for line in sys.stdin.buffer:
|
||||||
|
line = line.rstrip(b'\r\n')
|
||||||
|
if line:
|
||||||
|
name, separator, value = line.partition(b'=')
|
||||||
|
if separator:
|
||||||
|
request[name] = value
|
||||||
|
continue
|
||||||
|
sys.stdout.buffer.write(b'action=' + check(request) + b'\n\n')
|
||||||
|
sys.stdout.buffer.flush()
|
||||||
|
request = {}
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -15,6 +15,21 @@ until dig +short mailcow.email > /dev/null; do
|
|||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Wait for updated schema (DB_VERSION from docker-compose.yml), the maps read tables the init_db of php-fpm creates on updates
|
||||||
|
DBV_NOW=$(mariadb --ssl=false --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN 2>/dev/null)
|
||||||
|
DBV_WAIT=0
|
||||||
|
while [[ "${DBV_NOW}" != "${DB_VERSION}" ]] && [[ ${DBV_WAIT} -lt 60 ]]; do
|
||||||
|
echo "Waiting for schema update..."
|
||||||
|
sleep 5
|
||||||
|
DBV_WAIT=$((DBV_WAIT + 1))
|
||||||
|
DBV_NOW=$(mariadb --ssl=false --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN 2>/dev/null)
|
||||||
|
done
|
||||||
|
if [[ "${DBV_NOW}" != "${DB_VERSION}" ]]; then
|
||||||
|
echo -e "\e[31mDB schema is ${DBV_NOW}, expected ${DB_VERSION} - starting anyway, check the php-fpm log (init_db)\e[0m"
|
||||||
|
else
|
||||||
|
echo "DB schema is ${DBV_NOW}"
|
||||||
|
fi
|
||||||
|
|
||||||
cat <<EOF > /etc/aliases
|
cat <<EOF > /etc/aliases
|
||||||
# Autogenerated by mailcow
|
# Autogenerated by mailcow
|
||||||
null: /dev/null
|
null: /dev/null
|
||||||
@@ -224,6 +239,19 @@ query = SELECT CONCAT_WS(':', username, password) AS auth_data FROM transports
|
|||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
# Mail policies: while policies or internal aliases exist, smtpd asks the policy service (mailcow_mail_policy in main.cf)
|
||||||
|
cat <<EOF > /opt/postfix/conf/sql/mysql_mail_policy_maps.cf
|
||||||
|
# Autogenerated by mailcow
|
||||||
|
user = ${DBUSER}
|
||||||
|
password = ${DBPASS}
|
||||||
|
hosts = unix:/var/run/mysqld/mysqld.sock
|
||||||
|
dbname = ${DBNAME}
|
||||||
|
query = SELECT 'mailcow_mail_policy' FROM DUAL WHERE '%s' != ''
|
||||||
|
AND (EXISTS (SELECT 1 FROM mail_policy_member JOIN mail_policy ON mail_policy.id = mail_policy_member.policy_id
|
||||||
|
WHERE mail_policy.active = '1')
|
||||||
|
OR EXISTS (SELECT 1 FROM alias WHERE internal = '1' AND active IN ('1', '2')));
|
||||||
|
EOF
|
||||||
|
|
||||||
cat <<EOF > /opt/postfix/conf/sql/mysql_virtual_alias_domain_maps.cf
|
cat <<EOF > /opt/postfix/conf/sql/mysql_virtual_alias_domain_maps.cf
|
||||||
# Autogenerated by mailcow
|
# Autogenerated by mailcow
|
||||||
user = ${DBUSER}
|
user = ${DBUSER}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# SOGo built from source to enable security patch application
|
# SOGo built from source to enable security patch application
|
||||||
# Repository: https://github.com/Alinto/sogo
|
# Repository: https://github.com/Alinto/sogo
|
||||||
# Version: SOGo-5.12.12
|
# Version: SOGo-5.12.13
|
||||||
#
|
#
|
||||||
# Applied security patches:
|
# Applied security patches:
|
||||||
# -
|
# -
|
||||||
@@ -12,8 +12,8 @@ FROM debian:bookworm
|
|||||||
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
|
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
|
||||||
|
|
||||||
ARG DEBIAN_FRONTEND=noninteractive
|
ARG DEBIAN_FRONTEND=noninteractive
|
||||||
ARG SOGO_VERSION=SOGo-5.12.12
|
ARG SOGO_VERSION=SOGo-5.12.13
|
||||||
ARG SOPE_VERSION=SOPE-5.12.12
|
ARG SOPE_VERSION=SOPE-5.12.13
|
||||||
# Security patches to apply (space-separated commit hashes)
|
# Security patches to apply (space-separated commit hashes)
|
||||||
ARG SOGO_SECURITY_PATCHES=""
|
ARG SOGO_SECURITY_PATCHES=""
|
||||||
# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?<version>.*)$
|
# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?<version>.*)$
|
||||||
|
|||||||
@@ -43,6 +43,19 @@ while ! mariadb-admin status --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u$
|
|||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
||||||
|
# Wait for the schema update (DB_VERSION) at startup, do not restart them meanwhile.
|
||||||
|
DBV_NOW=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN)
|
||||||
|
DBV_WAIT=0
|
||||||
|
while [[ "${DBV_NOW}" != "${DB_VERSION}" ]] && [[ ${DBV_WAIT} -lt 60 ]]; do
|
||||||
|
echo "Waiting for schema update..."
|
||||||
|
sleep 5
|
||||||
|
DBV_WAIT=$((DBV_WAIT + 1))
|
||||||
|
DBV_NOW=$(mariadb --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN)
|
||||||
|
done
|
||||||
|
if [[ "${DBV_NOW}" != "${DB_VERSION}" ]]; then
|
||||||
|
echo -e "\e[31m$(date) - DB schema is ${DBV_NOW}, expected ${DB_VERSION} - starting checks anyway, check the php-fpm log (init_db)\e[0m"
|
||||||
|
fi
|
||||||
|
|
||||||
# Do not attempt to write to slave
|
# Do not attempt to write to slave
|
||||||
if [[ ! -z ${REDIS_SLAVEOF_IP} ]]; then
|
if [[ ! -z ${REDIS_SLAVEOF_IP} ]]; then
|
||||||
REDIS_CMDLINE="redis-cli -h ${REDIS_SLAVEOF_IP} -p ${REDIS_SLAVEOF_PORT} -a ${REDISPASS} --no-auth-warning"
|
REDIS_CMDLINE="redis-cli -h ${REDIS_SLAVEOF_IP} -p ${REDIS_SLAVEOF_PORT} -a ${REDISPASS} --no-auth-warning"
|
||||||
|
|||||||
@@ -68,13 +68,17 @@ require_once 'functions.acl.inc.php';
|
|||||||
|
|
||||||
|
|
||||||
$isSOGoRequest = $post['real_rip'] == getenv('IPV4_NETWORK') . '.248';
|
$isSOGoRequest = $post['real_rip'] == getenv('IPV4_NETWORK') . '.248';
|
||||||
|
// real_rip is the address that connected to Dovecot (for proxied connections the proxy), client_ip the client (for logs)
|
||||||
|
$client_ip = filter_var($post['client_ip'] ?? '', FILTER_VALIDATE_IP) ? $post['client_ip'] : $post['real_rip'];
|
||||||
$result = false;
|
$result = false;
|
||||||
|
// set when the password is right, but the account does not allow this login
|
||||||
|
$denied = false;
|
||||||
if ($isSOGoRequest) {
|
if ($isSOGoRequest) {
|
||||||
// This is a SOGo Auth request. First check for SSO password.
|
// This is a SOGo Auth request. First check for SSO password.
|
||||||
$sogo_sso_pass = file_get_contents("/etc/sogo-sso/sogo-sso.pass");
|
$sogo_sso_pass = file_get_contents("/etc/sogo-sso/sogo-sso.pass");
|
||||||
if ($sogo_sso_pass === $post['password']){
|
if ($sogo_sso_pass === $post['password']){
|
||||||
error_log('MAILCOWAUTH: SOGo SSO auth for user ' . $post['username']);
|
error_log('MAILCOWAUTH: SOGo SSO auth for user ' . $post['username']);
|
||||||
set_sasl_log($post['username'], $post['real_rip'], "SOGO");
|
set_sasl_log($post['username'], $client_ip, "SOGO");
|
||||||
$result = true;
|
$result = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -91,10 +95,10 @@ if ($result === false){
|
|||||||
'service' => $post['service'],
|
'service' => $post['service'],
|
||||||
'is_internal' => true,
|
'is_internal' => true,
|
||||||
'remote_addr' => $post['real_rip']
|
'remote_addr' => $post['real_rip']
|
||||||
));
|
), $denied);
|
||||||
if ($result) {
|
if ($result) {
|
||||||
error_log('MAILCOWAUTH: App auth for user ' . $post['username'] . " with service " . $service . " from IP " . $post['real_rip']);
|
error_log('MAILCOWAUTH: App auth for user ' . $post['username'] . " with service " . $service . " from IP " . $client_ip);
|
||||||
set_sasl_log($post['username'], $post['real_rip'], $service);
|
set_sasl_log($post['username'], $client_ip, $service);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($result === false){
|
if ($result === false){
|
||||||
@@ -102,26 +106,37 @@ if ($result === false){
|
|||||||
$iam_provider = identity_provider('init');
|
$iam_provider = identity_provider('init');
|
||||||
$iam_settings = identity_provider('get');
|
$iam_settings = identity_provider('get');
|
||||||
try {
|
try {
|
||||||
$result = user_login($post['username'], $post['password'], array('is_internal' => true, 'service' => $post['service']));
|
$result = user_login($post['username'], $post['password'], array('is_internal' => true, 'service' => $post['service']), $denied);
|
||||||
} catch (\Mailcow\Auth\IdpUnavailable $e) {
|
} catch (\Mailcow\Auth\IdpUnavailable $e) {
|
||||||
// temporary failure for Dovecot, no failed login
|
// temporary failure for Dovecot, no failed login
|
||||||
error_log("MAILCOWAUTH: Identity provider unavailable for user " . $post['username'] . " with service " . $post['service'] . " from IP " . $post['real_rip'] . ": " . $e->getMessage());
|
error_log("MAILCOWAUTH: Identity provider unavailable for user " . $post['username'] . " with service " . $post['service'] . " from IP " . $client_ip . ": " . $e->getMessage());
|
||||||
http_response_code(503); // Service Unavailable
|
http_response_code(503); // Service Unavailable
|
||||||
echo json_encode($return);
|
echo json_encode($return);
|
||||||
session_destroy();
|
session_destroy();
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
if ($result) {
|
if ($result) {
|
||||||
error_log('MAILCOWAUTH: User auth for user ' . $post['username'] . " with service " . $post['service'] . " from IP " . $post['real_rip']);
|
error_log('MAILCOWAUTH: User auth for user ' . $post['username'] . " with service " . $post['service'] . " from IP " . $client_ip);
|
||||||
set_sasl_log($post['username'], $post['real_rip'], $post['service']);
|
set_sasl_log($post['username'], $client_ip, $post['service']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($result) {
|
if ($result) {
|
||||||
http_response_code(200); // OK
|
http_response_code(200); // OK
|
||||||
$return['success'] = true;
|
$return['success'] = true;
|
||||||
|
} elseif ($denied) {
|
||||||
|
// Logged, but no failed login for netfilter: e.g. a client of a shared mailbox or without IMAP access
|
||||||
|
error_log("MAILCOWAUTH: Login denied for user " . $post['username'] . " with service " . $service . " from IP " . $client_ip);
|
||||||
|
http_response_code(401); // Unauthorized
|
||||||
} else {
|
} else {
|
||||||
error_log("MAILCOWAUTH: Login failed for user " . $post['username'] . " with service " . $post['service'] . " from IP " . $post['real_rip']);
|
// Same line for the log and netfilter, which counts it for IMAP, POP3, Sieve and SMTP (Postfix asks Dovecot);
|
||||||
|
// SOGo logins have their own rule
|
||||||
|
$failed = "MAILCOWAUTH: Login failed for user " . $post['username'] . " with service " .
|
||||||
|
preg_replace('/[^a-z0-9]/', '', strtolower((string)$service)) . " from IP " . $client_ip;
|
||||||
|
error_log($failed);
|
||||||
|
if (!$isSOGoRequest) {
|
||||||
|
$redis->publish("F2B_CHANNEL", $failed);
|
||||||
|
}
|
||||||
http_response_code(401); // Unauthorized
|
http_response_code(401); // Unauthorized
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
function auth_password_verify(request, password)
|
function auth_password_verify(request, password)
|
||||||
if request.domain == nil then
|
|
||||||
return dovecot.auth.PASSDB_RESULT_USER_UNKNOWN, "No such user"
|
|
||||||
end
|
|
||||||
|
|
||||||
local json = require "cjson"
|
local json = require "cjson"
|
||||||
local ltn12 = require "ltn12"
|
local ltn12 = require "ltn12"
|
||||||
local https = require "ssl.https"
|
local https = require "ssl.https"
|
||||||
@@ -11,7 +7,10 @@ function auth_password_verify(request, password)
|
|||||||
local req = {
|
local req = {
|
||||||
username = request.user,
|
username = request.user,
|
||||||
password = password,
|
password = password,
|
||||||
|
-- address that connected to Dovecot, for proxied connections the proxy
|
||||||
real_rip = request.real_rip,
|
real_rip = request.real_rip,
|
||||||
|
-- address of the client, also for proxied connections
|
||||||
|
client_ip = request.rip,
|
||||||
service = request.service
|
service = request.service
|
||||||
}
|
}
|
||||||
local req_json = json.encode(req)
|
local req_json = json.encode(req)
|
||||||
|
|||||||
@@ -51,13 +51,6 @@ recipient_delimiter = +
|
|||||||
auth_master_user_separator = *
|
auth_master_user_separator = *
|
||||||
mail_shared_explicit_inbox = yes
|
mail_shared_explicit_inbox = yes
|
||||||
mail_prefetch_count = 30
|
mail_prefetch_count = 30
|
||||||
passdb {
|
|
||||||
driver = lua
|
|
||||||
args = file=/etc/dovecot/auth/passwd-verify.lua blocking=yes cache_key=%s:%u:%w
|
|
||||||
result_success = return-ok
|
|
||||||
result_failure = continue
|
|
||||||
result_internalfail = continue
|
|
||||||
}
|
|
||||||
# try a master passwd
|
# try a master passwd
|
||||||
passdb {
|
passdb {
|
||||||
driver = passwd-file
|
driver = passwd-file
|
||||||
@@ -67,8 +60,9 @@ passdb {
|
|||||||
}
|
}
|
||||||
# check for regular password - if empty (e.g. force-passwd-reset), previous pass=yes passdbs also fail
|
# check for regular password - if empty (e.g. force-passwd-reset), previous pass=yes passdbs also fail
|
||||||
# a return of the following passdb is mandatory
|
# a return of the following passdb is mandatory
|
||||||
|
# each check asks mailcowauth.php, which reports a failed login to netfilter once
|
||||||
# cache_key with password: the lua default (%u) would let a temporary failure fall back to the negative entry
|
# cache_key with password: the lua default (%u) would let a temporary failure fall back to the negative entry
|
||||||
# of a passdb lookup (master logins) and log "unknown user", which netfilter counts as failed login
|
# of a passdb lookup (master logins) and reject the login as unknown user
|
||||||
passdb {
|
passdb {
|
||||||
driver = lua
|
driver = lua
|
||||||
args = file=/etc/dovecot/auth/passwd-verify.lua blocking=yes cache_key=%s:%u:%w
|
args = file=/etc/dovecot/auth/passwd-verify.lua blocking=yes cache_key=%s:%u:%w
|
||||||
@@ -205,6 +199,8 @@ plugin {
|
|||||||
sieve_plugins = sieve_imapsieve sieve_extprograms
|
sieve_plugins = sieve_imapsieve sieve_extprograms
|
||||||
sieve_vacation_send_from_recipient = yes
|
sieve_vacation_send_from_recipient = yes
|
||||||
sieve_redirect_envelope_from = recipient
|
sieve_redirect_envelope_from = recipient
|
||||||
|
# Notifications are sent with the address of the mailbox (not :from or postmaster), so mail policies apply
|
||||||
|
sieve_notify_mailto_envelope_from = recipient
|
||||||
# From elsewhere to Spam folder
|
# From elsewhere to Spam folder
|
||||||
imapsieve_mailbox1_name = Junk
|
imapsieve_mailbox1_name = Junk
|
||||||
imapsieve_mailbox1_causes = COPY
|
imapsieve_mailbox1_causes = COPY
|
||||||
|
|||||||
@@ -47,6 +47,13 @@ http {
|
|||||||
default "";
|
default "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Client address for PHP: on the internal sogo-auth listener nginx itself is the client,
|
||||||
|
# the real address comes from the X-Real-IP header set by /sogo-auth-verify
|
||||||
|
map $sogo_auth_internal $mailcow_client_ip {
|
||||||
|
"1" $http_x_real_ip;
|
||||||
|
default $remote_addr;
|
||||||
|
}
|
||||||
|
|
||||||
{% if HTTP_REDIRECT %}
|
{% if HTTP_REDIRECT %}
|
||||||
# HTTP to HTTPS redirect
|
# HTTP to HTTPS redirect
|
||||||
server {
|
server {
|
||||||
|
|||||||
@@ -22,7 +22,8 @@ add_header Referrer-Policy strict-origin;
|
|||||||
|
|
||||||
index index.php index.html;
|
index index.php index.html;
|
||||||
|
|
||||||
client_max_body_size 0;
|
# PHP accepts no larger requests (post_max_size), SOGo webmail, CalDAV/CardDAV and ActiveSync use NGINX_MAX_BODY_SIZE
|
||||||
|
client_max_body_size 64m;
|
||||||
|
|
||||||
gzip on;
|
gzip on;
|
||||||
gzip_disable "msie6";
|
gzip_disable "msie6";
|
||||||
@@ -116,7 +117,8 @@ location ~ \.php$ {
|
|||||||
include /etc/nginx/fastcgi_params;
|
include /etc/nginx/fastcgi_params;
|
||||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
fastcgi_param PATH_INFO $fastcgi_path_info;
|
fastcgi_param PATH_INFO $fastcgi_path_info;
|
||||||
fastcgi_param HTTP_X_REAL_IP $remote_addr;
|
fastcgi_param REMOTE_ADDR $mailcow_client_ip;
|
||||||
|
fastcgi_param HTTP_X_REAL_IP $mailcow_client_ip;
|
||||||
# trusted internal-auth marker; empty for external clients (see nginx.conf map)
|
# trusted internal-auth marker; empty for external clients (see nginx.conf map)
|
||||||
fastcgi_param SOGO_AUTH_INTERNAL $sogo_auth_internal;
|
fastcgi_param SOGO_AUTH_INTERNAL $sogo_auth_internal;
|
||||||
fastcgi_read_timeout 3600;
|
fastcgi_read_timeout 3600;
|
||||||
@@ -181,6 +183,7 @@ location /sogo-auth-verify {
|
|||||||
proxy_set_header Content-Length "";
|
proxy_set_header Content-Length "";
|
||||||
proxy_pass http://127.0.0.1:65510/sogo-auth;
|
proxy_pass http://127.0.0.1:65510/sogo-auth;
|
||||||
proxy_pass_request_body off;
|
proxy_pass_request_body off;
|
||||||
|
client_max_body_size {{ NGINX_MAX_BODY_SIZE }};
|
||||||
}
|
}
|
||||||
|
|
||||||
location ^~ /Microsoft-Server-ActiveSync {
|
location ^~ /Microsoft-Server-ActiveSync {
|
||||||
@@ -204,7 +207,7 @@ location ^~ /Microsoft-Server-ActiveSync {
|
|||||||
proxy_busy_buffers_size 512k;
|
proxy_busy_buffers_size 512k;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
client_body_buffer_size 512k;
|
client_body_buffer_size 512k;
|
||||||
client_max_body_size 0;
|
client_max_body_size {{ NGINX_MAX_BODY_SIZE }};
|
||||||
}
|
}
|
||||||
|
|
||||||
location ^~ /SOGo {
|
location ^~ /SOGo {
|
||||||
@@ -255,7 +258,7 @@ location ^~ /SOGo {
|
|||||||
proxy_send_timeout 3600;
|
proxy_send_timeout 3600;
|
||||||
proxy_read_timeout 3600;
|
proxy_read_timeout 3600;
|
||||||
client_body_buffer_size 128k;
|
client_body_buffer_size 128k;
|
||||||
client_max_body_size 0;
|
client_max_body_size {{ NGINX_MAX_BODY_SIZE }};
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ postscreen_greet_wait = 3s
|
|||||||
postscreen_non_smtp_command_enable = no
|
postscreen_non_smtp_command_enable = no
|
||||||
postscreen_pipelining_enable = no
|
postscreen_pipelining_enable = no
|
||||||
proxy_read_maps = proxy:mysql:/opt/postfix/conf/sql/mysql_sasl_passwd_maps_transport_maps.cf,
|
proxy_read_maps = proxy:mysql:/opt/postfix/conf/sql/mysql_sasl_passwd_maps_transport_maps.cf,
|
||||||
|
proxy:mysql:/opt/postfix/conf/sql/mysql_mail_policy_maps.cf,
|
||||||
proxy:mysql:/opt/postfix/conf/sql/mysql_mbr_access_maps.cf,
|
proxy:mysql:/opt/postfix/conf/sql/mysql_mbr_access_maps.cf,
|
||||||
proxy:mysql:/opt/postfix/conf/sql/mysql_tls_enforce_in_policy.cf,
|
proxy:mysql:/opt/postfix/conf/sql/mysql_tls_enforce_in_policy.cf,
|
||||||
$sender_dependent_default_transport_maps,
|
$sender_dependent_default_transport_maps,
|
||||||
@@ -90,6 +91,7 @@ smtpd_hard_error_limit = ${stress?1}${stress:5}
|
|||||||
smtpd_helo_required = yes
|
smtpd_helo_required = yes
|
||||||
smtpd_proxy_timeout = 600s
|
smtpd_proxy_timeout = 600s
|
||||||
smtpd_recipient_restrictions = check_recipient_mx_access proxy:mysql:/opt/postfix/conf/sql/mysql_mbr_access_maps.cf,
|
smtpd_recipient_restrictions = check_recipient_mx_access proxy:mysql:/opt/postfix/conf/sql/mysql_mbr_access_maps.cf,
|
||||||
|
check_recipient_access proxy:mysql:/opt/postfix/conf/sql/mysql_mail_policy_maps.cf,
|
||||||
permit_sasl_authenticated,
|
permit_sasl_authenticated,
|
||||||
permit_mynetworks,
|
permit_mynetworks,
|
||||||
check_recipient_access proxy:mysql:/opt/postfix/conf/sql/mysql_tls_enforce_in_policy.cf,
|
check_recipient_access proxy:mysql:/opt/postfix/conf/sql/mysql_tls_enforce_in_policy.cf,
|
||||||
@@ -170,5 +172,11 @@ parent_domain_matches_subdomains = debug_peer_list,fast_flush_domains,mynetworks
|
|||||||
# This Option is added to correctly set the X-Original-To Header when mails are send to lmtp (dovecot)
|
# This Option is added to correctly set the X-Original-To Header when mails are send to lmtp (dovecot)
|
||||||
lmtp_destination_recipient_limit=1
|
lmtp_destination_recipient_limit=1
|
||||||
|
|
||||||
|
# Mail policies: while policies or internal aliases exist, mysql_mail_policy_maps.cf returns this class and smtpd
|
||||||
|
# asks the policy service for every recipient (mailcow-policy in master.cf); no answer defers the recipient
|
||||||
|
smtpd_restriction_classes = mailcow_mail_policy
|
||||||
|
mailcow_mail_policy = check_policy_service { unix:private/mailcow-policy, timeout=10s, try_limit=1, default_action=DEFER_IF_PERMIT }
|
||||||
|
mailcow-policy_time_limit = 3600s
|
||||||
|
|
||||||
# DO NOT EDIT ANYTHING BELOW #
|
# DO NOT EDIT ANYTHING BELOW #
|
||||||
# Overrides #
|
# Overrides #
|
||||||
@@ -117,6 +117,9 @@ smtp_sender_cleanup unix n - y - 0 cleanup
|
|||||||
127.0.0.1:10027 inet n n n - 0 spawn user=nobody argv=/usr/local/bin/whitelist_forwardinghosts.sh
|
127.0.0.1:10027 inet n n n - 0 spawn user=nobody argv=/usr/local/bin/whitelist_forwardinghosts.sh
|
||||||
# end whitelist_fwd
|
# end whitelist_fwd
|
||||||
|
|
||||||
|
# mail policies: policy service of smtpd, asks the mailcow UI (mailcow_mail_policy in main.cf)
|
||||||
|
mailcow-policy unix - n n - 0 spawn user=nobody argv=/usr/local/bin/mailcow-policy.py
|
||||||
|
|
||||||
# start watchdog-specific
|
# start watchdog-specific
|
||||||
# logs to local7 (hidden)
|
# logs to local7 (hidden)
|
||||||
589 inet n - n - - smtpd
|
589 inet n - n - - smtpd
|
||||||
|
|||||||
@@ -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 Sun Nov 1 00:23:09 UTC 2026
|
||||||
# https://github.com/stevejenkins/postwhite/
|
# https://github.com/stevejenkins/postwhite/
|
||||||
# 2246 total rules
|
# 2237 total rules
|
||||||
2a00:1450:4000::/36 permit
|
2a00:1450:4000::/36 permit
|
||||||
2a00:1450:4864::/56 permit
|
2a00:1450:4864::/56 permit
|
||||||
2a01:111:f400::/48 permit
|
2a01:111:f400::/48 permit
|
||||||
@@ -197,6 +197,9 @@
|
|||||||
44.246.68.102 permit
|
44.246.68.102 permit
|
||||||
44.246.77.92 permit
|
44.246.77.92 permit
|
||||||
45.14.148.0/22 permit
|
45.14.148.0/22 permit
|
||||||
|
45.128.172.192/27 permit
|
||||||
|
45.128.172.224/28 permit
|
||||||
|
45.128.174.0/25 permit
|
||||||
45.143.132.0/24 permit
|
45.143.132.0/24 permit
|
||||||
45.143.133.0/24 permit
|
45.143.133.0/24 permit
|
||||||
45.143.134.0/24 permit
|
45.143.134.0/24 permit
|
||||||
@@ -672,7 +675,6 @@
|
|||||||
85.215.255.47 permit
|
85.215.255.47 permit
|
||||||
85.215.255.48 permit
|
85.215.255.48 permit
|
||||||
85.215.255.49 permit
|
85.215.255.49 permit
|
||||||
86.61.88.25 permit
|
|
||||||
87.238.80.0/21 permit
|
87.238.80.0/21 permit
|
||||||
87.248.103.12 permit
|
87.248.103.12 permit
|
||||||
87.248.103.21 permit
|
87.248.103.21 permit
|
||||||
@@ -1345,11 +1347,11 @@
|
|||||||
107.20.210.250 permit
|
107.20.210.250 permit
|
||||||
107.22.191.150 permit
|
107.22.191.150 permit
|
||||||
108.174.0.0/24 permit
|
108.174.0.0/24 permit
|
||||||
108.174.0.215 permit
|
108.174.0.93 permit
|
||||||
108.174.3.0/24 permit
|
108.174.3.0/24 permit
|
||||||
108.174.3.215 permit
|
108.174.3.93 permit
|
||||||
108.174.6.0/24 permit
|
108.174.6.0/24 permit
|
||||||
108.174.6.215 permit
|
108.174.6.93 permit
|
||||||
108.175.18.45 permit
|
108.175.18.45 permit
|
||||||
108.175.30.45 permit
|
108.175.30.45 permit
|
||||||
108.179.144.0/20 permit
|
108.179.144.0/20 permit
|
||||||
@@ -1532,11 +1534,6 @@
|
|||||||
147.154.63.0/24 permit
|
147.154.63.0/24 permit
|
||||||
147.154.126.0/24 permit
|
147.154.126.0/24 permit
|
||||||
147.154.191.0/24 permit
|
147.154.191.0/24 permit
|
||||||
147.243.1.47 permit
|
|
||||||
147.243.1.48 permit
|
|
||||||
147.243.1.153 permit
|
|
||||||
147.243.128.24 permit
|
|
||||||
147.243.128.26 permit
|
|
||||||
148.105.0.0/16 permit
|
148.105.0.0/16 permit
|
||||||
148.105.8.0/21 permit
|
148.105.8.0/21 permit
|
||||||
148.116.32.128/25 permit
|
148.116.32.128/25 permit
|
||||||
@@ -1546,7 +1543,7 @@
|
|||||||
149.97.173.180 permit
|
149.97.173.180 permit
|
||||||
149.118.160.128/25 permit
|
149.118.160.128/25 permit
|
||||||
150.136.21.199 permit
|
150.136.21.199 permit
|
||||||
150.171.109.178 permit
|
150.171.109.66 permit
|
||||||
150.230.98.160 permit
|
150.230.98.160 permit
|
||||||
151.145.38.14 permit
|
151.145.38.14 permit
|
||||||
152.67.105.195 permit
|
152.67.105.195 permit
|
||||||
@@ -1562,7 +1559,6 @@
|
|||||||
155.248.237.141 permit
|
155.248.237.141 permit
|
||||||
157.56.120.128/26 permit
|
157.56.120.128/26 permit
|
||||||
157.58.30.128/25 permit
|
157.58.30.128/25 permit
|
||||||
157.58.196.96/29 permit
|
|
||||||
157.58.249.3 permit
|
157.58.249.3 permit
|
||||||
157.137.32.128/25 permit
|
157.137.32.128/25 permit
|
||||||
157.137.96.128/25 permit
|
157.137.96.128/25 permit
|
||||||
@@ -1651,7 +1647,6 @@
|
|||||||
167.89.75.126 permit
|
167.89.75.126 permit
|
||||||
167.89.75.136 permit
|
167.89.75.136 permit
|
||||||
167.89.75.164 permit
|
167.89.75.164 permit
|
||||||
167.220.67.232/29 permit
|
|
||||||
168.107.248.128/25 permit
|
168.107.248.128/25 permit
|
||||||
168.110.160.128/25 permit
|
168.110.160.128/25 permit
|
||||||
168.110.248.128/25 permit
|
168.110.248.128/25 permit
|
||||||
@@ -1979,8 +1974,6 @@
|
|||||||
207.46.22.35 permit
|
207.46.22.35 permit
|
||||||
207.46.50.72 permit
|
207.46.50.72 permit
|
||||||
207.46.50.82 permit
|
207.46.50.82 permit
|
||||||
207.46.52.71 permit
|
|
||||||
207.46.52.79 permit
|
|
||||||
207.67.38.0/24 permit
|
207.67.38.0/24 permit
|
||||||
207.67.98.192/27 permit
|
207.67.98.192/27 permit
|
||||||
207.97.204.96/29 permit
|
207.97.204.96/29 permit
|
||||||
@@ -2138,8 +2131,6 @@
|
|||||||
212.227.126.227 permit
|
212.227.126.227 permit
|
||||||
213.199.128.139 permit
|
213.199.128.139 permit
|
||||||
213.199.128.145 permit
|
213.199.128.145 permit
|
||||||
213.199.138.181 permit
|
|
||||||
213.199.138.191 permit
|
|
||||||
216.17.150.242 permit
|
216.17.150.242 permit
|
||||||
216.17.150.251 permit
|
216.17.150.251 permit
|
||||||
216.24.224.0/20 permit
|
216.24.224.0/20 permit
|
||||||
@@ -2228,23 +2219,23 @@
|
|||||||
2001:748:400:3301::4 permit
|
2001:748:400:3301::4 permit
|
||||||
2404:6800:4000::/36 permit
|
2404:6800:4000::/36 permit
|
||||||
2404:6800:4864::/56 permit
|
2404:6800:4864::/56 permit
|
||||||
2603:1061:14:1d0::1 permit
|
2603:1061:14:41::1 permit
|
||||||
2607:13c0:0001:0000:0000:0000:0000:7000/116 permit
|
2607:13c0:0001:0000:0000:0000:0000:7000/116 permit
|
||||||
2607:13c0:0002:0000:0000:0000:0000:1000/116 permit
|
2607:13c0:0002:0000:0000:0000:0000:1000/116 permit
|
||||||
2607:13c0:0004:0000:0000:0000:0000:0000/116 permit
|
2607:13c0:0004:0000:0000:0000:0000:0000/116 permit
|
||||||
2607:f8b0:4000::/36 permit
|
2607:f8b0:4000::/36 permit
|
||||||
2607:f8b0:4864::/56 permit
|
2607:f8b0:4864::/56 permit
|
||||||
2620:109:c003:104::/64 permit
|
2620:109:c003:104::/64 permit
|
||||||
2620:109:c003:104::215 permit
|
2620:109:c003:104::5d permit
|
||||||
2620:109:c006:104::/64 permit
|
2620:109:c006:104::/64 permit
|
||||||
2620:109:c006:104::215 permit
|
2620:109:c006:104::5d permit
|
||||||
2620:109:c00d:104::/64 permit
|
2620:109:c00d:104::/64 permit
|
||||||
2620:10d:c090:400::8:1 permit
|
2620:10d:c090:400::8:1 permit
|
||||||
2620:10d:c091:400::8:1 permit
|
2620:10d:c091:400::8:1 permit
|
||||||
2620:10d:c09b:400::8:1 permit
|
2620:10d:c09b:400::8:1 permit
|
||||||
2620:10d:c09c:400::8:1 permit
|
2620:10d:c09c:400::8:1 permit
|
||||||
2620:119:50c0:207::/64 permit
|
2620:119:50c0:207::/64 permit
|
||||||
2620:119:50c0:207::215 permit
|
2620:119:50c0:207::5d permit
|
||||||
2800:3f0:4000::/36 permit
|
2800:3f0:4000::/36 permit
|
||||||
2800:3f0:4864::/56 permit
|
2800:3f0:4864::/56 permit
|
||||||
49.12.4.251 permit # checks.mailcow.email
|
49.12.4.251 permit # checks.mailcow.email
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
// Mail policy check for one recipient, asked by the Postfix policy service (mailcow-policy in master.cf)
|
||||||
|
// Request: POST with the attributes of the Postfix policy request; answer: the action (DUNNO or the rejection)
|
||||||
|
header('Content-Type: text/plain');
|
||||||
|
require_once "vars.inc.php";
|
||||||
|
require_once __DIR__ . '/../web/inc/lib/vendor/autoload.php';
|
||||||
|
// Do not show errors, we log to using error_log
|
||||||
|
ini_set('error_reporting', 0);
|
||||||
|
// Init database
|
||||||
|
$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) {
|
||||||
|
error_log("MAILPOLICY: " . $e . PHP_EOL);
|
||||||
|
http_response_code(501);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isset($_POST['request']) || $_POST['request'] !== 'smtpd_access_policy') {
|
||||||
|
http_response_code(400);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
$value = fn(string $key): string => is_string($_POST[$key] ?? null) ? $_POST[$key] : '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$policy = \Mailcow\MailPolicy\MailPolicyModule::create($pdo, \Mailcow\Core\Context::system());
|
||||||
|
echo $policy->api->check($value('sender'), $value('recipient'), $value('sasl_username'), (int)$value('server_port'), $value('helo_name'));
|
||||||
|
}
|
||||||
|
catch (Throwable $e) {
|
||||||
|
// the policy service answers with a temporary error
|
||||||
|
error_log("MAILPOLICY: " . $e->getMessage() . PHP_EOL);
|
||||||
|
http_response_code(500);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
// Retention rules for the nightly run in the dovecot container (retention_runner.py)
|
||||||
|
// GET ?after=<username>&limit=<n>: a page of the plan, per mailbox the folders to clean up (JSON)
|
||||||
|
// POST (JSON {"results": [...], "summary": [...], "run": {...}}): the results
|
||||||
|
// Both need the header X-Retention-Token: HMAC-SHA256 of "retention" with the database password as key
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
require_once "vars.inc.php";
|
||||||
|
require_once __DIR__ . '/../web/inc/lib/vendor/autoload.php';
|
||||||
|
// Do not show errors, we log to using error_log
|
||||||
|
ini_set('error_reporting', 0);
|
||||||
|
if (empty($database_pass) || !hash_equals(hash_hmac('sha256', 'retention', $database_pass), (string)($_SERVER['HTTP_X_RETENTION_TOKEN'] ?? ''))) {
|
||||||
|
http_response_code(403);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
// Init database
|
||||||
|
$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) {
|
||||||
|
error_log("RETENTION: " . $e . PHP_EOL);
|
||||||
|
http_response_code(501);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$retention = \Mailcow\Retention\RetentionModule::create($pdo, \Mailcow\Core\Context::system());
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
|
||||||
|
$body = json_decode((string)file_get_contents('php://input'), true);
|
||||||
|
if (!is_array($body) || !is_array($body['results'] ?? null)) {
|
||||||
|
http_response_code(400);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
echo json_encode(['stored' => $retention->api->report($body['results'], is_array($body['summary'] ?? null) ? $body['summary'] : [],
|
||||||
|
is_array($body['run'] ?? null) ? $body['run'] : null)]);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
echo json_encode($retention->api->plan((string)($_GET['after'] ?? ''), (int)($_GET['limit'] ?? 0)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (\Mailcow\Core\UserError $e) {
|
||||||
|
http_response_code(400);
|
||||||
|
}
|
||||||
|
catch (Throwable $e) {
|
||||||
|
error_log("RETENTION: " . $e->getMessage() . PHP_EOL);
|
||||||
|
http_response_code(500);
|
||||||
|
}
|
||||||
@@ -469,35 +469,5 @@ while ($row = array_shift($rows)) {
|
|||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
|
|
||||||
<?php
|
|
||||||
// Start internal aliases
|
|
||||||
|
|
||||||
$stmt = $pdo->query("SELECT `id`, `address`, `domain` FROM `alias` WHERE `active` = '1' AND `internal` = '1'");
|
|
||||||
$aliases = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|
||||||
while ($alias = array_shift($aliases)) {
|
|
||||||
// build allowed_domains regex and add target domain and alias domains
|
|
||||||
$stmt = $pdo->prepare("SELECT `alias_domain` FROM `alias_domain` WHERE `active` = '1' AND `target_domain` = :target_domain");
|
|
||||||
$stmt->execute(array(':target_domain' => $alias['domain']));
|
|
||||||
$allowed_domains = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
|
||||||
$allowed_domains = array_map(function($item) {
|
|
||||||
return str_replace('.', '\.', $item['alias_domain']);
|
|
||||||
}, $allowed_domains);
|
|
||||||
$allowed_domains[] = str_replace('.', '\.', $alias['domain']);
|
|
||||||
$allowed_domains = implode('|', $allowed_domains);
|
|
||||||
?>
|
|
||||||
internal_alias_<?=$alias['id'];?> {
|
|
||||||
priority = 10;
|
|
||||||
rcpt = "<?=$alias['address'];?>";
|
|
||||||
from = "/^((?!.*@(<?=$allowed_domains;?>)).)*$/";
|
|
||||||
apply "default" {
|
|
||||||
MAILCOW_INTERNAL_ALIAS = 9999.0;
|
|
||||||
}
|
|
||||||
symbols [
|
|
||||||
"MAILCOW_INTERNAL_ALIAS"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
<?php
|
|
||||||
}
|
|
||||||
?>
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -765,13 +765,22 @@ rspamd_config:register_symbol({
|
|||||||
end
|
end
|
||||||
})
|
})
|
||||||
|
|
||||||
|
-- Expand a domain footer: replace only "{= key =}" placeholders with their values.
|
||||||
|
-- Unknown placeholders render empty, matching the previous engine behaviour.
|
||||||
|
local function expand_footer(template, replacements)
|
||||||
|
return (template:gsub('{=%s*([%w_]+)%s*=}', function(key)
|
||||||
|
local value = replacements[key]
|
||||||
|
if value == nil then return '' end
|
||||||
|
return tostring(value)
|
||||||
|
end))
|
||||||
|
end
|
||||||
|
|
||||||
rspamd_config:register_symbol({
|
rspamd_config:register_symbol({
|
||||||
name = 'MOO_FOOTER',
|
name = 'MOO_FOOTER',
|
||||||
type = 'prefilter',
|
type = 'prefilter',
|
||||||
callback = function(task)
|
callback = function(task)
|
||||||
local cjson = require "cjson"
|
local cjson = require "cjson"
|
||||||
local lua_mime = require "lua_mime"
|
local lua_mime = require "lua_mime"
|
||||||
local lua_util = require "lua_util"
|
|
||||||
local rspamd_logger = require "rspamd_logger"
|
local rspamd_logger = require "rspamd_logger"
|
||||||
local rspamd_http = require "rspamd_http"
|
local rspamd_http = require "rspamd_http"
|
||||||
local envfrom = task:get_from(1)
|
local envfrom = task:get_from(1)
|
||||||
@@ -844,10 +853,10 @@ rspamd_config:register_symbol({
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
if footer.html and footer.html ~= "" then
|
if footer.html and footer.html ~= "" then
|
||||||
footer.html = lua_util.jinja_template(footer.html, replacements, true)
|
footer.html = expand_footer(footer.html, replacements)
|
||||||
end
|
end
|
||||||
if footer.plain and footer.plain ~= "" then
|
if footer.plain and footer.plain ~= "" then
|
||||||
footer.plain = lua_util.jinja_template(footer.plain, replacements, true)
|
footer.plain = expand_footer(footer.plain, replacements)
|
||||||
end
|
end
|
||||||
|
|
||||||
-- add footer
|
-- add footer
|
||||||
|
|||||||
@@ -216,8 +216,9 @@ foreach ($rcpts as $rcpt) {
|
|||||||
foreach ($rcpt_final_mailboxes as $rcpt_final) {
|
foreach ($rcpt_final_mailboxes as $rcpt_final) {
|
||||||
error_log("QUARANTINE: quarantine pipe: processing quarantine message for rcpt " . $rcpt_final . PHP_EOL);
|
error_log("QUARANTINE: quarantine pipe: processing quarantine message for rcpt " . $rcpt_final . PHP_EOL);
|
||||||
try {
|
try {
|
||||||
$stmt = $pdo->prepare("INSERT INTO `quarantine` (`qid`, `subject`, `score`, `sender`, `rcpt`, `symbols`, `user`, `ip`, `msg`, `action`, `fuzzy_hashes`)
|
// qhash is the secret of the release/delete links in notifications, so it must not be derivable
|
||||||
VALUES (:qid, :subject, :score, :sender, :rcpt, :symbols, :user, :ip, :msg, :action, :fuzzy_hashes)");
|
$stmt = $pdo->prepare("INSERT INTO `quarantine` (`qid`, `subject`, `score`, `sender`, `rcpt`, `symbols`, `user`, `ip`, `msg`, `action`, `fuzzy_hashes`, `qhash`)
|
||||||
|
VALUES (:qid, :subject, :score, :sender, :rcpt, :symbols, :user, :ip, :msg, :action, :fuzzy_hashes, :qhash)");
|
||||||
$stmt->execute(array(
|
$stmt->execute(array(
|
||||||
':qid' => $qid,
|
':qid' => $qid,
|
||||||
':subject' => $subject,
|
':subject' => $subject,
|
||||||
@@ -229,11 +230,9 @@ foreach ($rcpt_final_mailboxes as $rcpt_final) {
|
|||||||
':ip' => $ip,
|
':ip' => $ip,
|
||||||
':msg' => $raw_data,
|
':msg' => $raw_data,
|
||||||
':action' => $action,
|
':action' => $action,
|
||||||
':fuzzy_hashes' => $fuzzy
|
':fuzzy_hashes' => $fuzzy,
|
||||||
|
':qhash' => bin2hex(random_bytes(32))
|
||||||
));
|
));
|
||||||
$lastId = $pdo->lastInsertId();
|
|
||||||
$stmt_update = $pdo->prepare("UPDATE `quarantine` SET `qhash` = SHA2(CONCAT(`id`, `qid`), 256) WHERE `id` = :id");
|
|
||||||
$stmt_update->execute(array(':id' => $lastId));
|
|
||||||
$stmt = $pdo->prepare('DELETE FROM `quarantine` WHERE `rcpt` = :rcpt AND `id` NOT IN (
|
$stmt = $pdo->prepare('DELETE FROM `quarantine` WHERE `rcpt` = :rcpt AND `id` NOT IN (
|
||||||
SELECT `id`
|
SELECT `id`
|
||||||
FROM (
|
FROM (
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ $_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
|
|||||||
|
|
||||||
$js_minifier->add('/web/js/site/imapsync.js');
|
$js_minifier->add('/web/js/site/imapsync.js');
|
||||||
$js_minifier->add('/web/js/site/groups.js');
|
$js_minifier->add('/web/js/site/groups.js');
|
||||||
|
$js_minifier->add('/web/js/site/mail-policy.js');
|
||||||
|
$js_minifier->add('/web/js/site/retention.js');
|
||||||
$js_minifier->add('/web/js/site/mailbox.js');
|
$js_minifier->add('/web/js/site/mailbox.js');
|
||||||
$js_minifier->add('/web/js/presets/sieveMailbox.js');
|
$js_minifier->add('/web/js/presets/sieveMailbox.js');
|
||||||
$js_minifier->add('/web/js/site/pwgen.js');
|
$js_minifier->add('/web/js/site/pwgen.js');
|
||||||
@@ -46,6 +48,8 @@ $template_data = [
|
|||||||
'lang_edit' => json_encode($lang['edit']),
|
'lang_edit' => json_encode($lang['edit']),
|
||||||
'lang_datatables' => json_encode($lang['datatables']),
|
'lang_datatables' => json_encode($lang['datatables']),
|
||||||
'imapsync_redirect_uri' => \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->oauth->redirectUri(),
|
'imapsync_redirect_uri' => \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->oauth->redirectUri(),
|
||||||
|
'mail_policy_groups' => \Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->policies->groupChoices($domains),
|
||||||
|
'retention_groups' => \Mailcow\Retention\RetentionModule::fromGlobals()->rules->groupChoices($domains),
|
||||||
];
|
];
|
||||||
|
|
||||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
|
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
|
||||||
+826
-2
@@ -1365,7 +1365,7 @@ paths:
|
|||||||
description: show the group address as SOGo identity (needs sender_allowed)
|
description: show the group address as SOGo identity (needs sender_allowed)
|
||||||
type: boolean
|
type: boolean
|
||||||
internal:
|
internal:
|
||||||
description: only accept mail from senders of the domain
|
description: only accept mail from logged-in senders of the domain
|
||||||
type: boolean
|
type: boolean
|
||||||
template_id:
|
template_id:
|
||||||
description: mailbox template applied to the members (admin only), empty = none
|
description: mailbox template applied to the members (admin only), empty = none
|
||||||
@@ -1377,6 +1377,226 @@ paths:
|
|||||||
type: boolean
|
type: boolean
|
||||||
type: object
|
type: object
|
||||||
summary: Create group
|
summary: Create group
|
||||||
|
/api/v1/add/mail-policy:
|
||||||
|
post:
|
||||||
|
responses:
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
examples:
|
||||||
|
response:
|
||||||
|
value:
|
||||||
|
- log:
|
||||||
|
- mail_policy
|
||||||
|
- add
|
||||||
|
- policy
|
||||||
|
- domain: example.org
|
||||||
|
name: Trainees
|
||||||
|
send_mode: internal
|
||||||
|
groups:
|
||||||
|
- "7"
|
||||||
|
msg:
|
||||||
|
- mail_policy_added
|
||||||
|
- Trainees
|
||||||
|
type: success
|
||||||
|
schema:
|
||||||
|
properties:
|
||||||
|
log:
|
||||||
|
description: contains request object
|
||||||
|
items: {}
|
||||||
|
type: array
|
||||||
|
msg:
|
||||||
|
items: {}
|
||||||
|
type: array
|
||||||
|
type:
|
||||||
|
enum:
|
||||||
|
- success
|
||||||
|
- danger
|
||||||
|
- warning
|
||||||
|
- error
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
description: OK
|
||||||
|
headers: {}
|
||||||
|
tags:
|
||||||
|
- Mail policies
|
||||||
|
description: >-
|
||||||
|
Create a mail policy (admins; domain admins with the "mail_policies"
|
||||||
|
ACL for their domains). Postfix checks the policies for every
|
||||||
|
recipient: every policy of the sending mailbox and of the addressed
|
||||||
|
mailbox or group must allow the mail, otherwise the recipient is
|
||||||
|
refused with 550 5.7.1.
|
||||||
|
operationId: Create mail policy
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
example:
|
||||||
|
domain: example.org
|
||||||
|
name: Trainees
|
||||||
|
send_mode: internal
|
||||||
|
send_allow:
|
||||||
|
- partner.example
|
||||||
|
receive_mode: all
|
||||||
|
receive_block:
|
||||||
|
- group:8
|
||||||
|
groups:
|
||||||
|
- "7"
|
||||||
|
active: "1"
|
||||||
|
properties:
|
||||||
|
domain:
|
||||||
|
description: domain of the policy (required)
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
description: unique per domain, max. 100 characters
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
description: max. 255 characters
|
||||||
|
type: string
|
||||||
|
send_mode:
|
||||||
|
description: all; internal = recipients in the domain of the policy and its alias domains plus send_allow; list = send_allow only
|
||||||
|
type: string
|
||||||
|
send_allow:
|
||||||
|
description: addresses, domains or group:<id> of a group of the policy's domain (array or list separated by commas, spaces or newlines), max. 500
|
||||||
|
type: array
|
||||||
|
send_block:
|
||||||
|
description: recipients that are always refused, entries as send_allow
|
||||||
|
type: array
|
||||||
|
receive_mode:
|
||||||
|
description: all; internal = senders logged in with a mailbox of the domain plus receive_allow; list = receive_allow only
|
||||||
|
type: string
|
||||||
|
receive_allow:
|
||||||
|
description: entries as send_allow; addresses and domains of this mailcow count for logged-in senders only
|
||||||
|
type: array
|
||||||
|
receive_block:
|
||||||
|
description: senders that are always refused, entries as receive_allow
|
||||||
|
type: array
|
||||||
|
reject_text:
|
||||||
|
description: text of the 550 5.7.1 rejection, printable ASCII, max. 200 characters, empty = default text
|
||||||
|
type: string
|
||||||
|
mailboxes:
|
||||||
|
description: mailboxes of the domain the policy applies to (replaces the list), max. 500
|
||||||
|
type: array
|
||||||
|
groups:
|
||||||
|
description: group IDs of the domain the policy applies to (its members of that domain and mail to the group address), max. 500; domain admins need the mailbox_groups ACL, without it group IDs and group entries stay unchanged
|
||||||
|
type: array
|
||||||
|
active:
|
||||||
|
type: boolean
|
||||||
|
type: object
|
||||||
|
summary: Create mail policy
|
||||||
|
/api/v1/add/retention-rule:
|
||||||
|
post:
|
||||||
|
responses:
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
examples:
|
||||||
|
response:
|
||||||
|
value:
|
||||||
|
- log:
|
||||||
|
- retention
|
||||||
|
- add
|
||||||
|
- rule
|
||||||
|
- domain: example.org
|
||||||
|
name: Trash
|
||||||
|
folders:
|
||||||
|
- trash
|
||||||
|
days: 30
|
||||||
|
msg:
|
||||||
|
- retention_rule_added
|
||||||
|
- Trash
|
||||||
|
type: success
|
||||||
|
schema:
|
||||||
|
properties:
|
||||||
|
log:
|
||||||
|
description: contains request object
|
||||||
|
items: {}
|
||||||
|
type: array
|
||||||
|
msg:
|
||||||
|
items: {}
|
||||||
|
type: array
|
||||||
|
type:
|
||||||
|
enum:
|
||||||
|
- success
|
||||||
|
- danger
|
||||||
|
- warning
|
||||||
|
- error
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
description: OK
|
||||||
|
headers: {}
|
||||||
|
tags:
|
||||||
|
- Retention rules
|
||||||
|
description: >-
|
||||||
|
Create a retention rule (admins; domain admins with the "retention" ACL
|
||||||
|
for their domains). Every night mail is deleted from the folders of the
|
||||||
|
rule once it was saved there more than "days" days ago. If several
|
||||||
|
rules apply to the same folder of a mailbox, the most specific one
|
||||||
|
wins, per folder: mailbox before group before domain, then the folder
|
||||||
|
itself (special folder, inbox, own name) before an own name with *,
|
||||||
|
before the subfolders of a special folder or the inbox, before all
|
||||||
|
folders, then the longer period. New rules simulate by default. Rules
|
||||||
|
of inactive domains do not run.
|
||||||
|
operationId: Create retention rule
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
example:
|
||||||
|
domain: example.org
|
||||||
|
name: Trash
|
||||||
|
folders:
|
||||||
|
- trash
|
||||||
|
- junk
|
||||||
|
days: 30
|
||||||
|
keep_flagged: "1"
|
||||||
|
mode: simulate
|
||||||
|
all_mailboxes: "1"
|
||||||
|
active: "1"
|
||||||
|
properties:
|
||||||
|
domain:
|
||||||
|
description: domain of the rule (required)
|
||||||
|
type: string
|
||||||
|
name:
|
||||||
|
description: unique per domain, max. 100 characters
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
description: max. 255 characters
|
||||||
|
type: string
|
||||||
|
folders:
|
||||||
|
description: trash, junk, sent, drafts, archive, inbox (each with its subfolders), all (folders no other rule decides about) and folder:<name> (exactly this own folder, / for subfolders, * as wildcard), max. 50; replaces the list
|
||||||
|
type: array
|
||||||
|
own_folders:
|
||||||
|
description: own folder names, one per line or as a list, replacing the folder:<name> entries (the UI sends them apart)
|
||||||
|
oneOf:
|
||||||
|
- type: string
|
||||||
|
- type: array
|
||||||
|
days:
|
||||||
|
description: delete mail saved in the folder more than this many days ago, 1 to 36500
|
||||||
|
type: number
|
||||||
|
keep_flagged:
|
||||||
|
description: flagged mail stays
|
||||||
|
type: boolean
|
||||||
|
mode:
|
||||||
|
description: simulate (the nightly run only counts, default) or delete
|
||||||
|
type: string
|
||||||
|
all_mailboxes:
|
||||||
|
description: applies to all mailboxes of the domain
|
||||||
|
type: boolean
|
||||||
|
mailboxes:
|
||||||
|
description: mailboxes of the domain the rule applies to (replaces the list), max. 500
|
||||||
|
type: array
|
||||||
|
groups:
|
||||||
|
description: group IDs of the domain (their members of that domain), max. 500; domain admins need the mailbox_groups ACL, without it the groups stay unchanged
|
||||||
|
type: array
|
||||||
|
active:
|
||||||
|
type: boolean
|
||||||
|
type: object
|
||||||
|
summary: Create retention rule
|
||||||
/api/v1/add/oauth2-client:
|
/api/v1/add/oauth2-client:
|
||||||
post:
|
post:
|
||||||
responses:
|
responses:
|
||||||
@@ -1936,6 +2156,14 @@ paths:
|
|||||||
- TLS
|
- TLS
|
||||||
- SSL
|
- SSL
|
||||||
- PLAIN
|
- PLAIN
|
||||||
|
verify_cert:
|
||||||
|
description: >-
|
||||||
|
1 = check the server certificate (SSL and STARTTLS, default), 0 = do not check
|
||||||
|
it (self-signed, expired or not matching the host).
|
||||||
|
type: integer
|
||||||
|
enum:
|
||||||
|
- 0
|
||||||
|
- 1
|
||||||
auth_type:
|
auth_type:
|
||||||
description: >-
|
description: >-
|
||||||
authentication mechanism; PLAIN/LOGIN/CRAM-MD5 use the per-job
|
authentication mechanism; PLAIN/LOGIN/CRAM-MD5 use the per-job
|
||||||
@@ -2845,6 +3073,119 @@ paths:
|
|||||||
type: object
|
type: object
|
||||||
type: object
|
type: object
|
||||||
summary: Delete groups
|
summary: Delete groups
|
||||||
|
/api/v1/delete/mail-policy:
|
||||||
|
post:
|
||||||
|
responses:
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
examples:
|
||||||
|
response:
|
||||||
|
value:
|
||||||
|
- log:
|
||||||
|
- mail_policy
|
||||||
|
- delete
|
||||||
|
- policy
|
||||||
|
- id:
|
||||||
|
- "3"
|
||||||
|
msg:
|
||||||
|
- mail_policy_removed
|
||||||
|
- Trainees
|
||||||
|
type: success
|
||||||
|
schema:
|
||||||
|
properties:
|
||||||
|
log:
|
||||||
|
description: contains request object
|
||||||
|
items: {}
|
||||||
|
type: array
|
||||||
|
msg:
|
||||||
|
items: {}
|
||||||
|
type: array
|
||||||
|
type:
|
||||||
|
enum:
|
||||||
|
- success
|
||||||
|
- danger
|
||||||
|
- warning
|
||||||
|
- error
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
description: OK
|
||||||
|
headers: {}
|
||||||
|
tags:
|
||||||
|
- Mail policies
|
||||||
|
description: Delete mail policies by ID.
|
||||||
|
operationId: Delete mail policies
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
example:
|
||||||
|
- "3"
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
description: contains list of policy IDs you want to delete
|
||||||
|
type: object
|
||||||
|
type: object
|
||||||
|
summary: Delete mail policies
|
||||||
|
/api/v1/delete/retention-rule:
|
||||||
|
post:
|
||||||
|
responses:
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
examples:
|
||||||
|
response:
|
||||||
|
value:
|
||||||
|
- log:
|
||||||
|
- retention
|
||||||
|
- delete
|
||||||
|
- rule
|
||||||
|
- id:
|
||||||
|
- "3"
|
||||||
|
msg:
|
||||||
|
- retention_rule_removed
|
||||||
|
- Trash
|
||||||
|
type: success
|
||||||
|
schema:
|
||||||
|
properties:
|
||||||
|
log:
|
||||||
|
description: contains request object
|
||||||
|
items: {}
|
||||||
|
type: array
|
||||||
|
msg:
|
||||||
|
items: {}
|
||||||
|
type: array
|
||||||
|
type:
|
||||||
|
enum:
|
||||||
|
- success
|
||||||
|
- danger
|
||||||
|
- warning
|
||||||
|
- error
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
description: OK
|
||||||
|
headers: {}
|
||||||
|
tags:
|
||||||
|
- Retention rules
|
||||||
|
description: >-
|
||||||
|
Delete retention rules by ID.
|
||||||
|
operationId: Delete retention rules
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
example:
|
||||||
|
- "3"
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
description: contains list of rule IDs you want to delete
|
||||||
|
type: object
|
||||||
|
type: object
|
||||||
|
summary: Delete retention rules
|
||||||
/api/v1/delete/identity-link:
|
/api/v1/delete/identity-link:
|
||||||
post:
|
post:
|
||||||
responses:
|
responses:
|
||||||
@@ -4414,7 +4755,7 @@ paths:
|
|||||||
description: show the group address as SOGo identity (needs sender_allowed)
|
description: show the group address as SOGo identity (needs sender_allowed)
|
||||||
type: boolean
|
type: boolean
|
||||||
internal:
|
internal:
|
||||||
description: only accept mail from senders of the domain
|
description: only accept mail from logged-in senders of the domain
|
||||||
type: boolean
|
type: boolean
|
||||||
template_id:
|
template_id:
|
||||||
description: mailbox template applied to the members (admin only), empty = none
|
description: mailbox template applied to the members (admin only), empty = none
|
||||||
@@ -4489,6 +4830,205 @@ paths:
|
|||||||
type: object
|
type: object
|
||||||
type: object
|
type: object
|
||||||
summary: Update groups
|
summary: Update groups
|
||||||
|
/api/v1/edit/mail-policy:
|
||||||
|
post:
|
||||||
|
responses:
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
examples:
|
||||||
|
response:
|
||||||
|
value:
|
||||||
|
- log:
|
||||||
|
- mail_policy
|
||||||
|
- edit
|
||||||
|
- policy
|
||||||
|
- id:
|
||||||
|
- "3"
|
||||||
|
receive_mode: internal
|
||||||
|
msg:
|
||||||
|
- mail_policy_modified
|
||||||
|
- Trainees
|
||||||
|
type: success
|
||||||
|
schema:
|
||||||
|
properties:
|
||||||
|
log:
|
||||||
|
description: contains request object
|
||||||
|
items: {}
|
||||||
|
type: array
|
||||||
|
msg:
|
||||||
|
items: {}
|
||||||
|
type: array
|
||||||
|
type:
|
||||||
|
enum:
|
||||||
|
- success
|
||||||
|
- danger
|
||||||
|
- warning
|
||||||
|
- error
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
description: OK
|
||||||
|
headers: {}
|
||||||
|
tags:
|
||||||
|
- Mail policies
|
||||||
|
description: >-
|
||||||
|
Update mail policies. Attributes you do not send keep their value. The
|
||||||
|
domain cannot be changed.
|
||||||
|
operationId: Update mail policies
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
example:
|
||||||
|
items:
|
||||||
|
- "3"
|
||||||
|
attr:
|
||||||
|
receive_mode: internal
|
||||||
|
properties:
|
||||||
|
attr:
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
description: unique per domain, max. 100 characters
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
description: max. 255 characters
|
||||||
|
type: string
|
||||||
|
send_mode:
|
||||||
|
description: all; internal = recipients in the domain of the policy and its alias domains plus send_allow; list = send_allow only
|
||||||
|
type: string
|
||||||
|
send_allow:
|
||||||
|
description: addresses, domains or group:<id> of a group of the policy's domain (array or list separated by commas, spaces or newlines), max. 500
|
||||||
|
type: array
|
||||||
|
send_block:
|
||||||
|
description: recipients that are always refused, entries as send_allow
|
||||||
|
type: array
|
||||||
|
receive_mode:
|
||||||
|
description: all; internal = senders logged in with a mailbox of the domain plus receive_allow; list = receive_allow only
|
||||||
|
type: string
|
||||||
|
receive_allow:
|
||||||
|
description: entries as send_allow; addresses and domains of this mailcow count for logged-in senders only
|
||||||
|
type: array
|
||||||
|
receive_block:
|
||||||
|
description: senders that are always refused, entries as receive_allow
|
||||||
|
type: array
|
||||||
|
reject_text:
|
||||||
|
description: text of the 550 5.7.1 rejection, printable ASCII, max. 200 characters, empty = default text
|
||||||
|
type: string
|
||||||
|
mailboxes:
|
||||||
|
description: mailboxes of the domain the policy applies to (replaces the list), max. 500
|
||||||
|
type: array
|
||||||
|
groups:
|
||||||
|
description: group IDs of the domain the policy applies to (its members of that domain and mail to the group address), max. 500; domain admins need the mailbox_groups ACL, without it group IDs and group entries stay unchanged
|
||||||
|
type: array
|
||||||
|
active:
|
||||||
|
type: boolean
|
||||||
|
type: object
|
||||||
|
items:
|
||||||
|
description: contains list of policy IDs you want to update
|
||||||
|
type: object
|
||||||
|
type: object
|
||||||
|
summary: Update mail policies
|
||||||
|
/api/v1/edit/retention-rule:
|
||||||
|
post:
|
||||||
|
responses:
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
examples:
|
||||||
|
response:
|
||||||
|
value:
|
||||||
|
- log:
|
||||||
|
- retention
|
||||||
|
- edit
|
||||||
|
- rule
|
||||||
|
- id:
|
||||||
|
- "3"
|
||||||
|
mode: delete
|
||||||
|
msg:
|
||||||
|
- retention_rule_modified
|
||||||
|
- Trash
|
||||||
|
type: success
|
||||||
|
schema:
|
||||||
|
properties:
|
||||||
|
log:
|
||||||
|
description: contains request object
|
||||||
|
items: {}
|
||||||
|
type: array
|
||||||
|
msg:
|
||||||
|
items: {}
|
||||||
|
type: array
|
||||||
|
type:
|
||||||
|
enum:
|
||||||
|
- success
|
||||||
|
- danger
|
||||||
|
- warning
|
||||||
|
- error
|
||||||
|
type: string
|
||||||
|
type: object
|
||||||
|
description: OK
|
||||||
|
headers: {}
|
||||||
|
tags:
|
||||||
|
- Retention rules
|
||||||
|
description: >-
|
||||||
|
Update retention rules. Attributes you do not send keep their value.
|
||||||
|
The domain cannot be changed.
|
||||||
|
operationId: Update retention rules
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
example:
|
||||||
|
items:
|
||||||
|
- "3"
|
||||||
|
attr:
|
||||||
|
mode: delete
|
||||||
|
properties:
|
||||||
|
attr:
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
description: unique per domain, max. 100 characters
|
||||||
|
type: string
|
||||||
|
description:
|
||||||
|
description: max. 255 characters
|
||||||
|
type: string
|
||||||
|
folders:
|
||||||
|
description: trash, junk, sent, drafts, archive, inbox (each with its subfolders), all (folders no other rule decides about) and folder:<name> (exactly this own folder, / for subfolders, * as wildcard), max. 50; replaces the list
|
||||||
|
type: array
|
||||||
|
own_folders:
|
||||||
|
description: own folder names, one per line or as a list, replacing the folder:<name> entries (the UI sends them apart)
|
||||||
|
oneOf:
|
||||||
|
- type: string
|
||||||
|
- type: array
|
||||||
|
days:
|
||||||
|
description: delete mail saved in the folder more than this many days ago, 1 to 36500
|
||||||
|
type: number
|
||||||
|
keep_flagged:
|
||||||
|
description: flagged mail stays
|
||||||
|
type: boolean
|
||||||
|
mode:
|
||||||
|
description: simulate (the nightly run only counts, default) or delete
|
||||||
|
type: string
|
||||||
|
all_mailboxes:
|
||||||
|
description: applies to all mailboxes of the domain
|
||||||
|
type: boolean
|
||||||
|
mailboxes:
|
||||||
|
description: mailboxes of the domain the rule applies to (replaces the list), max. 500
|
||||||
|
type: array
|
||||||
|
groups:
|
||||||
|
description: group IDs of the domain (their members of that domain), max. 500; domain admins need the mailbox_groups ACL, without it the groups stay unchanged
|
||||||
|
type: array
|
||||||
|
active:
|
||||||
|
type: boolean
|
||||||
|
type: object
|
||||||
|
items:
|
||||||
|
description: contains list of rule IDs you want to update
|
||||||
|
type: object
|
||||||
|
type: object
|
||||||
|
summary: Update retention rules
|
||||||
/api/v1/edit/identity-provider-sync:
|
/api/v1/edit/identity-provider-sync:
|
||||||
post:
|
post:
|
||||||
responses:
|
responses:
|
||||||
@@ -5080,6 +5620,15 @@ paths:
|
|||||||
- TLS
|
- TLS
|
||||||
- SSL
|
- SSL
|
||||||
- PLAIN
|
- PLAIN
|
||||||
|
verify_cert:
|
||||||
|
description: >-
|
||||||
|
1 = check the server certificate (SSL and STARTTLS, default), 0 = do not check
|
||||||
|
it (self-signed, expired or not matching the host). Turning the check off
|
||||||
|
needs access to all sync jobs of the source, like changing the server.
|
||||||
|
type: integer
|
||||||
|
enum:
|
||||||
|
- 0
|
||||||
|
- 1
|
||||||
auth_type:
|
auth_type:
|
||||||
description: >-
|
description: >-
|
||||||
authentication mechanism; PLAIN/LOGIN/CRAM-MD5 use the per-job
|
authentication mechanism; PLAIN/LOGIN/CRAM-MD5 use the per-job
|
||||||
@@ -6210,6 +6759,70 @@ paths:
|
|||||||
the end of the api url.
|
the end of the api url.
|
||||||
operationId: Get SOGo logs
|
operationId: Get SOGo logs
|
||||||
summary: Get SOGo logs
|
summary: Get SOGo logs
|
||||||
|
"/api/v1/get/logs/retention/{count}":
|
||||||
|
get:
|
||||||
|
parameters:
|
||||||
|
- description: Number of logs to return (default 1000)
|
||||||
|
in: path
|
||||||
|
name: count
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: number
|
||||||
|
- description: e.g. api-key-string
|
||||||
|
example: api-key-string
|
||||||
|
in: header
|
||||||
|
name: X-API-Key
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
examples:
|
||||||
|
response:
|
||||||
|
value:
|
||||||
|
- time: "1794368412"
|
||||||
|
domain: example.org
|
||||||
|
username: user@example.org
|
||||||
|
rule_id: 3
|
||||||
|
rule_name: Trash
|
||||||
|
folder: "Trash\nTrash/Old"
|
||||||
|
days: 30
|
||||||
|
mailboxes: 0
|
||||||
|
matched: 12
|
||||||
|
deleted: 12
|
||||||
|
errors: 0
|
||||||
|
simulated: 0
|
||||||
|
error: null
|
||||||
|
- time: "1794368400"
|
||||||
|
domain: ""
|
||||||
|
username: ""
|
||||||
|
rule_id: null
|
||||||
|
rule_name: ""
|
||||||
|
folder: null
|
||||||
|
days: 0
|
||||||
|
mailboxes: 120
|
||||||
|
matched: 4211
|
||||||
|
deleted: 312
|
||||||
|
errors: 0
|
||||||
|
simulated: 0
|
||||||
|
error: null
|
||||||
|
description: OK
|
||||||
|
headers: {}
|
||||||
|
tags:
|
||||||
|
- Logs
|
||||||
|
description: >-
|
||||||
|
Results of the nightly retention runs of the last 90 days, newest
|
||||||
|
first: mailboxes with found mail or an error, per mailbox and rule
|
||||||
|
(folder: the folder names separated by line breaks); of simulating
|
||||||
|
rules only the last run. Admins see all domains and one row per run
|
||||||
|
(rule_id null, username empty, error if the run failed), domain admins
|
||||||
|
with the "retention" ACL their domains.
|
||||||
|
operationId: Get retention logs
|
||||||
|
summary: Get retention logs
|
||||||
"/api/v1/get/logs/watchdog/{count}":
|
"/api/v1/get/logs/watchdog/{count}":
|
||||||
get:
|
get:
|
||||||
parameters:
|
parameters:
|
||||||
@@ -6438,6 +7051,212 @@ paths:
|
|||||||
may send as through groups.
|
may send as through groups.
|
||||||
operationId: Get group memberships
|
operationId: Get group memberships
|
||||||
summary: Get group memberships
|
summary: Get group memberships
|
||||||
|
"/api/v1/get/mail-policy/{id}":
|
||||||
|
get:
|
||||||
|
parameters:
|
||||||
|
- description: id of the policy, or "all" (optionally followed by /domain1,domain2)
|
||||||
|
example: all
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- description: e.g. api-key-string
|
||||||
|
example: api-key-string
|
||||||
|
in: header
|
||||||
|
name: X-API-Key
|
||||||
|
required: false
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
examples:
|
||||||
|
response:
|
||||||
|
value:
|
||||||
|
- id: 3
|
||||||
|
domain: example.org
|
||||||
|
name: Trainees
|
||||||
|
description: Trainees only send internally
|
||||||
|
send_mode: internal
|
||||||
|
send_allow:
|
||||||
|
- partner.example
|
||||||
|
send_block: []
|
||||||
|
receive_mode: all
|
||||||
|
receive_allow: []
|
||||||
|
receive_block:
|
||||||
|
- group:8
|
||||||
|
reject_text: ""
|
||||||
|
active: 1
|
||||||
|
mailboxes: []
|
||||||
|
groups:
|
||||||
|
- 7
|
||||||
|
created: "2026-11-07 12:00:00"
|
||||||
|
modified: null
|
||||||
|
can_edit: 1
|
||||||
|
description: OK
|
||||||
|
headers: {}
|
||||||
|
tags:
|
||||||
|
- Mail policies
|
||||||
|
description: >-
|
||||||
|
Mail policies visible to the caller (admins all, domain admins with the
|
||||||
|
"mail_policies" ACL their domains).
|
||||||
|
operationId: Get mail policies
|
||||||
|
summary: Get mail policies
|
||||||
|
"/api/v1/get/mail-policy/test/{sender}/{recipient}":
|
||||||
|
get:
|
||||||
|
parameters:
|
||||||
|
- description: envelope sender, "-" for the null sender (bounces)
|
||||||
|
example: trainee@example.org
|
||||||
|
in: path
|
||||||
|
name: sender
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
- description: recipient; append /login to test a sender logged in with its mailbox
|
||||||
|
example: someone@external.example
|
||||||
|
in: path
|
||||||
|
name: recipient
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
examples:
|
||||||
|
response:
|
||||||
|
value:
|
||||||
|
allowed: 0
|
||||||
|
side: send
|
||||||
|
policy: Trainees
|
||||||
|
internal_alias: 0
|
||||||
|
logged_in: 1
|
||||||
|
text: Sending to this recipient is not allowed by the mail policy of the sender
|
||||||
|
description: OK
|
||||||
|
headers: {}
|
||||||
|
tags:
|
||||||
|
- Mail policies
|
||||||
|
description: >-
|
||||||
|
Would Postfix accept the mail for this recipient? side is "send",
|
||||||
|
"receive" or "address" (the recipient routes through another address),
|
||||||
|
policy is empty for a policy of another domain (its text is then the
|
||||||
|
default text), internal_alias = refused by the internal flag of an
|
||||||
|
alias or group. Domain admins test addresses of their domains; a sender
|
||||||
|
of another domain is tested as not logged in. Empty answer for invalid
|
||||||
|
input.
|
||||||
|
operationId: Test mail policies
|
||||||
|
summary: Test mail policies
|
||||||
|
"/api/v1/get/retention-rule/{id}":
|
||||||
|
get:
|
||||||
|
parameters:
|
||||||
|
- description: id of the rule, or "all" (optionally followed by /domain1,domain2)
|
||||||
|
example: all
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
examples:
|
||||||
|
response:
|
||||||
|
value:
|
||||||
|
- id: 3
|
||||||
|
domain: example.org
|
||||||
|
name: Trash
|
||||||
|
description: ""
|
||||||
|
folders:
|
||||||
|
- trash
|
||||||
|
days: 30
|
||||||
|
keep_flagged: 0
|
||||||
|
mode: delete
|
||||||
|
all_mailboxes: 1
|
||||||
|
active: 1
|
||||||
|
mailboxes: []
|
||||||
|
groups: []
|
||||||
|
created: "2026-11-07 12:00:00"
|
||||||
|
modified: null
|
||||||
|
can_edit: 1
|
||||||
|
last_run:
|
||||||
|
created: "2026-11-08 03:30:12"
|
||||||
|
mailboxes: 120
|
||||||
|
matched: 4211
|
||||||
|
deleted: 4211
|
||||||
|
errors: 0
|
||||||
|
simulated: 0
|
||||||
|
description: OK
|
||||||
|
headers: {}
|
||||||
|
tags:
|
||||||
|
- Retention rules
|
||||||
|
description: >-
|
||||||
|
Retention rules visible to the caller (admins all, domain admins with
|
||||||
|
the "retention" ACL their domains), with the summary of their last
|
||||||
|
nightly run (null = not run yet).
|
||||||
|
operationId: Get retention rules
|
||||||
|
summary: Get retention rules
|
||||||
|
"/api/v1/get/retention-rule/effective/{username}":
|
||||||
|
get:
|
||||||
|
parameters:
|
||||||
|
- description: mailbox
|
||||||
|
example: user@example.org
|
||||||
|
in: path
|
||||||
|
name: username
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
responses:
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
examples:
|
||||||
|
response:
|
||||||
|
value:
|
||||||
|
- folder: trash
|
||||||
|
match: folder
|
||||||
|
days: 30
|
||||||
|
keep_flagged: 0
|
||||||
|
mode: delete
|
||||||
|
rule_id: 3
|
||||||
|
rule_name: Trash
|
||||||
|
- folder: trash
|
||||||
|
match: subfolders
|
||||||
|
days: 30
|
||||||
|
keep_flagged: 0
|
||||||
|
mode: delete
|
||||||
|
rule_id: 3
|
||||||
|
rule_name: Trash
|
||||||
|
- folder: all
|
||||||
|
match: all
|
||||||
|
days: 365
|
||||||
|
keep_flagged: 1
|
||||||
|
mode: simulate
|
||||||
|
rule_id: 4
|
||||||
|
rule_name: One year
|
||||||
|
description: OK
|
||||||
|
headers: {}
|
||||||
|
tags:
|
||||||
|
- Retention rules
|
||||||
|
description: >-
|
||||||
|
What the rules delete in a mailbox, in order of "most specific wins":
|
||||||
|
each folder of the mailbox goes to the first entry that matches it.
|
||||||
|
match: folder (the folder itself), pattern (own name with *),
|
||||||
|
subfolders (of a special folder or the inbox), all. Mailbox users ask for their own
|
||||||
|
mailbox, admins and domain admins with the "retention" ACL for
|
||||||
|
mailboxes they manage.
|
||||||
|
operationId: Get effective retention rules
|
||||||
|
summary: Get effective retention rules
|
||||||
/api/v1/get/identity-provider/sync-status:
|
/api/v1/get/identity-provider/sync-status:
|
||||||
get:
|
get:
|
||||||
responses:
|
responses:
|
||||||
@@ -7313,6 +8132,7 @@ paths:
|
|||||||
host1: imap.example.org
|
host1: imap.example.org
|
||||||
port1: 993
|
port1: 993
|
||||||
enc1: SSL
|
enc1: SSL
|
||||||
|
verify_cert: 1
|
||||||
auth_type: XOAUTH2
|
auth_type: XOAUTH2
|
||||||
oauth_flow: authorization_code
|
oauth_flow: authorization_code
|
||||||
oauth_token_endpoint: https://login.example.org/token
|
oauth_token_endpoint: https://login.example.org/token
|
||||||
@@ -7993,6 +8813,10 @@ tags:
|
|||||||
description: Manage API keys
|
description: Manage API keys
|
||||||
- name: Groups
|
- name: Groups
|
||||||
description: Mailbox groups (distribution lists, send-as rights, templates)
|
description: Mailbox groups (distribution lists, send-as rights, templates)
|
||||||
|
- name: Mail policies
|
||||||
|
description: Whom mailboxes and groups may send to and receive from
|
||||||
|
- name: Retention rules
|
||||||
|
description: Delete mail from folders after a number of days
|
||||||
- name: Routing
|
- name: Routing
|
||||||
description: Define your own email routes
|
description: Define your own email routes
|
||||||
- name: Resources
|
- name: Resources
|
||||||
|
|||||||
+47
-48
@@ -144,6 +144,38 @@ try {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$username = trim((string)$email);
|
$username = trim((string)$email);
|
||||||
|
if (!filter_var($username, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
try {
|
||||||
|
$json = json_encode(
|
||||||
|
array(
|
||||||
|
"time" => time(),
|
||||||
|
"ua" => $_SERVER['HTTP_USER_AGENT'],
|
||||||
|
"user" => "none",
|
||||||
|
"ip" => $_SERVER['REMOTE_ADDR'],
|
||||||
|
"service" => "Error: invalid email address in request"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
$redis->lPush('AUTODISCOVER_LOG', $json);
|
||||||
|
$redis->lTrim('AUTODISCOVER_LOG', 0, 100);
|
||||||
|
$redis->publish("F2B_CHANNEL", "Autodiscover: Invalid mailbox attempt by " . $_SERVER['REMOTE_ADDR']);
|
||||||
|
error_log("Autodiscover: Invalid mailbox attempt by " . $_SERVER['REMOTE_ADDR']);
|
||||||
|
}
|
||||||
|
catch (RedisException $e) {
|
||||||
|
// Silently fail
|
||||||
|
}
|
||||||
|
list($usec, $sec) = explode(' ', microtime());
|
||||||
|
?>
|
||||||
|
<Response>
|
||||||
|
<Error Time="<?=date('H:i:s', $sec) . substr($usec, 0, strlen($usec) - 2);?>" Id="<?=rand(1000000000, 9999999999);?>">
|
||||||
|
<ErrorCode>600</ErrorCode>
|
||||||
|
<Message>Invalid Request</Message>
|
||||||
|
<DebugData />
|
||||||
|
</Error>
|
||||||
|
</Response>
|
||||||
|
</Autodiscover>
|
||||||
|
<?php
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
$stmt = $pdo->prepare("SELECT `mailbox`.`name`, `mailbox`.`active` FROM `mailbox`
|
$stmt = $pdo->prepare("SELECT `mailbox`.`name`, `mailbox`.`active` FROM `mailbox`
|
||||||
INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain`
|
INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain`
|
||||||
@@ -169,46 +201,8 @@ catch(PDOException $e) {
|
|||||||
exit(0);
|
exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mailbox not found or not active - return generic error to prevent user enumeration
|
$email = $username;
|
||||||
if (empty($MailboxData)) {
|
$displayname = $email;
|
||||||
try {
|
|
||||||
$json = json_encode(
|
|
||||||
array(
|
|
||||||
"time" => time(),
|
|
||||||
"ua" => $_SERVER['HTTP_USER_AGENT'],
|
|
||||||
"user" => $email,
|
|
||||||
"ip" => $_SERVER['REMOTE_ADDR'],
|
|
||||||
"service" => "Error: mailbox not found or inactive"
|
|
||||||
)
|
|
||||||
);
|
|
||||||
$redis->lPush('AUTODISCOVER_LOG', $json);
|
|
||||||
$redis->lTrim('AUTODISCOVER_LOG', 0, 100);
|
|
||||||
$redis->publish("F2B_CHANNEL", "Autodiscover: Invalid mailbox attempt by " . $_SERVER['REMOTE_ADDR']);
|
|
||||||
error_log("Autodiscover: Invalid mailbox attempt by " . $_SERVER['REMOTE_ADDR']);
|
|
||||||
}
|
|
||||||
catch (RedisException $e) {
|
|
||||||
// Silently fail
|
|
||||||
}
|
|
||||||
list($usec, $sec) = explode(' ', microtime());
|
|
||||||
?>
|
|
||||||
<Response>
|
|
||||||
<Error Time="<?=date('H:i:s', $sec) . substr($usec, 0, strlen($usec) - 2);?>" Id="<?=rand(1000000000, 9999999999);?>">
|
|
||||||
<ErrorCode>600</ErrorCode>
|
|
||||||
<Message>Invalid Request</Message>
|
|
||||||
<DebugData />
|
|
||||||
</Error>
|
|
||||||
</Response>
|
|
||||||
</Autodiscover>
|
|
||||||
<?php
|
|
||||||
exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!empty($MailboxData['name'])) {
|
|
||||||
$displayname = $MailboxData['name'];
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
$displayname = $email;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
$json = json_encode(
|
$json = json_encode(
|
||||||
array(
|
array(
|
||||||
@@ -216,11 +210,16 @@ try {
|
|||||||
"ua" => $_SERVER['HTTP_USER_AGENT'],
|
"ua" => $_SERVER['HTTP_USER_AGENT'],
|
||||||
"user" => $email,
|
"user" => $email,
|
||||||
"ip" => $_SERVER['REMOTE_ADDR'],
|
"ip" => $_SERVER['REMOTE_ADDR'],
|
||||||
"service" => $autodiscover_config['autodiscoverType']
|
// The answer is the same, only the log tells unknown or inactive mailboxes apart
|
||||||
|
"service" => (empty($MailboxData)) ? "Error: mailbox not found or inactive" : $autodiscover_config['autodiscoverType']
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
$redis->lPush('AUTODISCOVER_LOG', $json);
|
$redis->lPush('AUTODISCOVER_LOG', $json);
|
||||||
$redis->lTrim('AUTODISCOVER_LOG', 0, 100);
|
$redis->lTrim('AUTODISCOVER_LOG', 0, 100);
|
||||||
|
if (empty($MailboxData)) {
|
||||||
|
$redis->publish("F2B_CHANNEL", "Autodiscover: Invalid mailbox attempt by " . $_SERVER['REMOTE_ADDR']);
|
||||||
|
error_log("Autodiscover: Invalid mailbox attempt by " . $_SERVER['REMOTE_ADDR']);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (RedisException $e) {
|
catch (RedisException $e) {
|
||||||
$_SESSION['return'][] = array(
|
$_SESSION['return'][] = array(
|
||||||
@@ -243,7 +242,7 @@ if ($autodiscover_config['autodiscoverType'] == 'imap') {
|
|||||||
<Server><?=$autodiscover_config['imap']['server'];?></Server>
|
<Server><?=$autodiscover_config['imap']['server'];?></Server>
|
||||||
<Port><?=$autodiscover_config['imap']['port'];?></Port>
|
<Port><?=$autodiscover_config['imap']['port'];?></Port>
|
||||||
<DomainRequired>off</DomainRequired>
|
<DomainRequired>off</DomainRequired>
|
||||||
<LoginName><?=$email;?></LoginName>
|
<LoginName><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></LoginName>
|
||||||
<SPA>off</SPA>
|
<SPA>off</SPA>
|
||||||
<SSL>on</SSL>
|
<SSL>on</SSL>
|
||||||
<AuthRequired>on</AuthRequired>
|
<AuthRequired>on</AuthRequired>
|
||||||
@@ -253,7 +252,7 @@ if ($autodiscover_config['autodiscoverType'] == 'imap') {
|
|||||||
<Server><?=$autodiscover_config['smtp']['server'];?></Server>
|
<Server><?=$autodiscover_config['smtp']['server'];?></Server>
|
||||||
<Port><?=$autodiscover_config['smtp']['port'];?></Port>
|
<Port><?=$autodiscover_config['smtp']['port'];?></Port>
|
||||||
<DomainRequired>off</DomainRequired>
|
<DomainRequired>off</DomainRequired>
|
||||||
<LoginName><?=$email;?></LoginName>
|
<LoginName><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></LoginName>
|
||||||
<SPA>off</SPA>
|
<SPA>off</SPA>
|
||||||
<SSL>on</SSL>
|
<SSL>on</SSL>
|
||||||
<AuthRequired>on</AuthRequired>
|
<AuthRequired>on</AuthRequired>
|
||||||
@@ -265,15 +264,15 @@ if ($autodiscover_config['autodiscoverType'] == 'imap') {
|
|||||||
?>
|
?>
|
||||||
<Protocol>
|
<Protocol>
|
||||||
<Type>CalDAV</Type>
|
<Type>CalDAV</Type>
|
||||||
<Server>https://<?=$autodiscover_config['caldav']['server'];?><?php if ($autodiscover_config['caldav']['port'] != 443) echo ':'.$autodiscover_config['caldav']['port']; ?>/SOGo/dav/<?=$email;?>/</Server>
|
<Server>https://<?=$autodiscover_config['caldav']['server'];?><?php if ($autodiscover_config['caldav']['port'] != 443) echo ':'.$autodiscover_config['caldav']['port']; ?>/SOGo/dav/<?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?>/</Server>
|
||||||
<DomainRequired>off</DomainRequired>
|
<DomainRequired>off</DomainRequired>
|
||||||
<LoginName><?=$email;?></LoginName>
|
<LoginName><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></LoginName>
|
||||||
</Protocol>
|
</Protocol>
|
||||||
<Protocol>
|
<Protocol>
|
||||||
<Type>CardDAV</Type>
|
<Type>CardDAV</Type>
|
||||||
<Server>https://<?=$autodiscover_config['carddav']['server'];?><?php if ($autodiscover_config['caldav']['port'] != 443) echo ':'.$autodiscover_config['carddav']['port']; ?>/SOGo/dav/<?=$email;?>/</Server>
|
<Server>https://<?=$autodiscover_config['carddav']['server'];?><?php if ($autodiscover_config['caldav']['port'] != 443) echo ':'.$autodiscover_config['carddav']['port']; ?>/SOGo/dav/<?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?>/</Server>
|
||||||
<DomainRequired>off</DomainRequired>
|
<DomainRequired>off</DomainRequired>
|
||||||
<LoginName><?=$email;?></LoginName>
|
<LoginName><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></LoginName>
|
||||||
</Protocol>
|
</Protocol>
|
||||||
<?php
|
<?php
|
||||||
}
|
}
|
||||||
@@ -288,7 +287,7 @@ if ($autodiscover_config['autodiscoverType'] == 'imap') {
|
|||||||
<Culture>en:en</Culture>
|
<Culture>en:en</Culture>
|
||||||
<User>
|
<User>
|
||||||
<DisplayName><?=htmlspecialchars($displayname, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></DisplayName>
|
<DisplayName><?=htmlspecialchars($displayname, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></DisplayName>
|
||||||
<EMailAddress><?=$email;?></EMailAddress>
|
<EMailAddress><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></EMailAddress>
|
||||||
</User>
|
</User>
|
||||||
<Action>
|
<Action>
|
||||||
<Settings>
|
<Settings>
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ $_SESSION['return_to'] = $_SERVER['REQUEST_URI'];
|
|||||||
|
|
||||||
$js_minifier->add('/web/js/site/imapsync.js');
|
$js_minifier->add('/web/js/site/imapsync.js');
|
||||||
$js_minifier->add('/web/js/site/groups.js');
|
$js_minifier->add('/web/js/site/groups.js');
|
||||||
|
$js_minifier->add('/web/js/site/mail-policy.js');
|
||||||
|
$js_minifier->add('/web/js/site/retention.js');
|
||||||
$js_minifier->add('/web/js/site/mailbox.js');
|
$js_minifier->add('/web/js/site/mailbox.js');
|
||||||
$js_minifier->add('/web/js/presets/sieveMailbox.js');
|
$js_minifier->add('/web/js/presets/sieveMailbox.js');
|
||||||
$js_minifier->add('/web/js/site/pwgen.js');
|
$js_minifier->add('/web/js/site/pwgen.js');
|
||||||
@@ -45,6 +47,8 @@ $template_data = [
|
|||||||
'lang_edit' => json_encode($lang['edit']),
|
'lang_edit' => json_encode($lang['edit']),
|
||||||
'lang_datatables' => json_encode($lang['datatables']),
|
'lang_datatables' => json_encode($lang['datatables']),
|
||||||
'imapsync_redirect_uri' => \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->oauth->redirectUri(),
|
'imapsync_redirect_uri' => \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->oauth->redirectUri(),
|
||||||
|
'mail_policy_groups' => \Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->policies->groupChoices($domains),
|
||||||
|
'retention_groups' => \Mailcow\Retention\RetentionModule::fromGlobals()->rules->groupChoices($domains),
|
||||||
];
|
];
|
||||||
|
|
||||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
|
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
|
||||||
+24
-1
@@ -115,6 +115,28 @@ if (isset($_SESSION['mailcow_cc_role'])) {
|
|||||||
'mbox_templates' => ($_SESSION['mailcow_cc_role'] == 'admin') ? mailbox('get', 'mailbox_templates') : [],
|
'mbox_templates' => ($_SESSION['mailcow_cc_role'] == 'admin') ? mailbox('get', 'mailbox_templates') : [],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
elseif (isset($_GET['mail-policy']) &&
|
||||||
|
is_numeric($_GET['mail-policy'])) {
|
||||||
|
$mail_policy = \Mailcow\MailPolicy\MailPolicyModule::fromGlobals();
|
||||||
|
$result = $mail_policy->api->getPolicy($_GET['mail-policy']);
|
||||||
|
$template = 'edit/mail-policy.twig';
|
||||||
|
// Choices: mailboxes and groups of the domain of the policy
|
||||||
|
$template_data = [
|
||||||
|
'mailboxes' => !empty($result) ? mailbox('get', 'mailboxes', $result['domain']) : [],
|
||||||
|
'mail_policy_groups' => !empty($result) ? $mail_policy->policies->groupChoices([$result['domain']]) : [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
elseif (isset($_GET['retention-rule']) &&
|
||||||
|
is_numeric($_GET['retention-rule'])) {
|
||||||
|
$retention = \Mailcow\Retention\RetentionModule::fromGlobals();
|
||||||
|
$result = $retention->api->getRule($_GET['retention-rule']);
|
||||||
|
$template = 'edit/retention-rule.twig';
|
||||||
|
// Choices: mailboxes and groups of the domain of the rule
|
||||||
|
$template_data = [
|
||||||
|
'mailboxes' => !empty($result) ? mailbox('get', 'mailboxes', $result['domain']) : [],
|
||||||
|
'retention_groups' => !empty($result) ? $retention->rules->groupChoices([$result['domain']]) : [],
|
||||||
|
];
|
||||||
|
}
|
||||||
elseif (isset($_GET['api-key']) &&
|
elseif (isset($_GET['api-key']) &&
|
||||||
is_numeric($_GET['api-key'])) {
|
is_numeric($_GET['api-key'])) {
|
||||||
$result = \Mailcow\ApiKey\ApiKeyModule::fromGlobals()->api->getKey($_GET['api-key']);
|
$result = \Mailcow\ApiKey\ApiKeyModule::fromGlobals()->api->getKey($_GET['api-key']);
|
||||||
@@ -227,7 +249,8 @@ if (isset($_SESSION['mailcow_cc_role'])) {
|
|||||||
if (isset($_GET['syncjob']) &&
|
if (isset($_GET['syncjob']) &&
|
||||||
is_numeric($_GET['syncjob'])) {
|
is_numeric($_GET['syncjob'])) {
|
||||||
$id = $_GET["syncjob"];
|
$id = $_GET["syncjob"];
|
||||||
$result = \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getJob($id);
|
// Without the syncjobs ACL a job cannot be saved, so do not show the form
|
||||||
|
$result = (($_SESSION['acl']['syncjobs'] ?? '0') == '1') ? \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getJob($id) : [];
|
||||||
$template = 'edit/syncjob.twig';
|
$template = 'edit/syncjob.twig';
|
||||||
}
|
}
|
||||||
elseif (isset($_GET['syncjob_source']) &&
|
elseif (isset($_GET['syncjob_source']) &&
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ $globalVariables = [
|
|||||||
'lang_danger' => json_encode($lang['danger']),
|
'lang_danger' => json_encode($lang['danger']),
|
||||||
'lang_syncjobs' => json_encode($lang['syncjobs']),
|
'lang_syncjobs' => json_encode($lang['syncjobs']),
|
||||||
'lang_groups' => json_encode($lang['groups']),
|
'lang_groups' => json_encode($lang['groups']),
|
||||||
|
'lang_mail_policy' => json_encode($lang['mail_policy'] ?? []),
|
||||||
|
'lang_retention' => json_encode($lang['retention'] ?? []),
|
||||||
'docker_timeout' => $DOCKER_TIMEOUT,
|
'docker_timeout' => $DOCKER_TIMEOUT,
|
||||||
'session_lifetime' => (int)$SESSION_LIFETIME,
|
'session_lifetime' => (int)$SESSION_LIFETIME,
|
||||||
'csrf_token' => $_SESSION['CSRF']['TOKEN'],
|
'csrf_token' => $_SESSION['CSRF']['TOKEN'],
|
||||||
|
|||||||
@@ -74,6 +74,15 @@ function admin($_action, $_data = null) {
|
|||||||
$usernames = $_data['username'];
|
$usernames = $_data['username'];
|
||||||
}
|
}
|
||||||
foreach ($usernames as $username) {
|
foreach ($usernames as $username) {
|
||||||
|
// A pending password update only allows changing the own password
|
||||||
|
if (!empty($_SESSION['pending_pw_update']) && $username != $_SESSION['mailcow_cc_username']) {
|
||||||
|
$_SESSION['return'][] = array(
|
||||||
|
'type' => 'danger',
|
||||||
|
'log' => array(__FUNCTION__, $_action, $_data_log),
|
||||||
|
'msg' => 'access_denied'
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$is_now = admin('details', $username);
|
$is_now = admin('details', $username);
|
||||||
if (!empty($is_now)) {
|
if (!empty($is_now)) {
|
||||||
$active = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active'];
|
$active = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active'];
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ function app_passwd($_action, $_data = null) {
|
|||||||
$_data_log = $_data;
|
$_data_log = $_data;
|
||||||
!isset($_data_log['app_passwd']) ?: $_data_log['app_passwd'] = '*';
|
!isset($_data_log['app_passwd']) ?: $_data_log['app_passwd'] = '*';
|
||||||
!isset($_data_log['app_passwd2']) ?: $_data_log['app_passwd2'] = '*';
|
!isset($_data_log['app_passwd2']) ?: $_data_log['app_passwd2'] = '*';
|
||||||
|
if (in_array($_action, array('add', 'edit', 'delete')) &&
|
||||||
|
(!isset($_SESSION['acl']['app_passwds']) || $_SESSION['acl']['app_passwds'] != "1")) {
|
||||||
|
$_SESSION['return'][] = array(
|
||||||
|
'type' => 'danger',
|
||||||
|
'log' => array(__FUNCTION__, $_action, $_data_log),
|
||||||
|
'msg' => 'access_denied'
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (isset($_data['username']) && filter_var($_data['username'], FILTER_VALIDATE_EMAIL)) {
|
if (isset($_data['username']) && filter_var($_data['username'], FILTER_VALIDATE_EMAIL)) {
|
||||||
if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $_data['username'])) {
|
if (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $_data['username'])) {
|
||||||
$_SESSION['return'][] = array(
|
$_SESSION['return'][] = array(
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ function check_login($user, $pass, $extra = null) {
|
|||||||
$is_internal = $extra['is_internal'];
|
$is_internal = $extra['is_internal'];
|
||||||
$role = $extra['role'];
|
$role = $extra['role'];
|
||||||
$extra['service'] = !isset($extra['service']) ? 'NONE' : $extra['service'];
|
$extra['service'] = !isset($extra['service']) ? 'NONE' : $extra['service'];
|
||||||
|
// set when the password is right, but the account does not allow this login
|
||||||
|
$denied = false;
|
||||||
|
|
||||||
// Try validate admin
|
// Try validate admin
|
||||||
if (!isset($role) || $role == "admin") {
|
if (!isset($role) || $role == "admin") {
|
||||||
@@ -26,7 +28,7 @@ function check_login($user, $pass, $extra = null) {
|
|||||||
|
|
||||||
// Try validate app password
|
// Try validate app password
|
||||||
if (!isset($role) || $role == "app") {
|
if (!isset($role) || $role == "app") {
|
||||||
$result = apppass_login($user, $pass, $extra);
|
$result = apppass_login($user, $pass, $extra, $denied);
|
||||||
if ($result !== false) {
|
if ($result !== false) {
|
||||||
$real_rip = ($_SERVER['HTTP_X_REAL_IP'] ?? $_SERVER['REMOTE_ADDR']);
|
$real_rip = ($_SERVER['HTTP_X_REAL_IP'] ?? $_SERVER['REMOTE_ADDR']);
|
||||||
set_sasl_log($user, $real_rip, $extra['service'], $pass);
|
set_sasl_log($user, $real_rip, $extra['service'], $pass);
|
||||||
@@ -37,7 +39,7 @@ function check_login($user, $pass, $extra = null) {
|
|||||||
// Try validate user
|
// Try validate user
|
||||||
if (!isset($role) || $role == "user") {
|
if (!isset($role) || $role == "user") {
|
||||||
try {
|
try {
|
||||||
$result = user_login($user, $pass, $extra);
|
$result = user_login($user, $pass, $extra, $denied);
|
||||||
} catch (\Mailcow\Auth\IdpUnavailable $e) {
|
} catch (\Mailcow\Auth\IdpUnavailable $e) {
|
||||||
// no failed login, netfilter must not count it
|
// no failed login, netfilter must not count it
|
||||||
error_log("mailcow UI: Identity provider unavailable for " . $user . " by " . $_SERVER['REMOTE_ADDR'] . ": " . $e->getMessage());
|
error_log("mailcow UI: Identity provider unavailable for " . $user . " by " . $_SERVER['REMOTE_ADDR'] . ": " . $e->getMessage());
|
||||||
@@ -54,19 +56,20 @@ function check_login($user, $pass, $extra = null) {
|
|||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// skip log and only return false if it's an internal request
|
// skip log and only return false if it's an internal request
|
||||||
if ($is_internal == true) return false;
|
if ($is_internal == true) return false;
|
||||||
|
|
||||||
|
// A denied login is logged, but not counted by netfilter (e.g. EAS/DAV clients of accounts without access)
|
||||||
|
$msg = ($denied ? "mailcow UI: Login denied for " : "mailcow UI: Invalid password for ") . $user . " by " . $_SERVER['REMOTE_ADDR'];
|
||||||
if (!isset($_SESSION['ldelay'])) {
|
if (!isset($_SESSION['ldelay'])) {
|
||||||
$_SESSION['ldelay'] = "0";
|
$_SESSION['ldelay'] = "0";
|
||||||
$redis->publish("F2B_CHANNEL", "mailcow UI: Invalid password for " . $user . " by " . $_SERVER['REMOTE_ADDR']);
|
if (!$denied) $redis->publish("F2B_CHANNEL", $msg);
|
||||||
error_log("mailcow UI: Invalid password for " . $user . " by " . $_SERVER['REMOTE_ADDR']);
|
error_log($msg);
|
||||||
}
|
}
|
||||||
elseif (!isset($_SESSION['mailcow_cc_username'])) {
|
elseif (!isset($_SESSION['mailcow_cc_username'])) {
|
||||||
$_SESSION['ldelay'] = $_SESSION['ldelay']+0.5;
|
$_SESSION['ldelay'] = $_SESSION['ldelay']+0.5;
|
||||||
$redis->publish("F2B_CHANNEL", "mailcow UI: Invalid password for " . $user . " by " . $_SERVER['REMOTE_ADDR']);
|
if (!$denied) $redis->publish("F2B_CHANNEL", $msg);
|
||||||
error_log("mailcow UI: Invalid password for " . $user . " by " . $_SERVER['REMOTE_ADDR']);
|
error_log($msg);
|
||||||
}
|
}
|
||||||
$_SESSION['return'][] = array(
|
$_SESSION['return'][] = array(
|
||||||
'type' => 'danger',
|
'type' => 'danger',
|
||||||
@@ -207,7 +210,7 @@ function domainadmin_login($user, $pass){
|
|||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
function user_login($user, $pass, $extra = null){
|
function user_login($user, $pass, $extra = null, &$denied = false){
|
||||||
global $pdo;
|
global $pdo;
|
||||||
global $iam_provider;
|
global $iam_provider;
|
||||||
global $iam_settings;
|
global $iam_settings;
|
||||||
@@ -261,11 +264,14 @@ function user_login($user, $pass, $extra = null){
|
|||||||
if ($extra['service'] != 'NONE') {
|
if ($extra['service'] != 'NONE') {
|
||||||
$key = strtolower($extra['service']) . "_access";
|
$key = strtolower($extra['service']) . "_access";
|
||||||
if (isset($row['attributes'][$key]) && $row['attributes'][$key] != '1') {
|
if (isset($row['attributes'][$key]) && $row['attributes'][$key] != '1') {
|
||||||
|
$denied = true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
// the IdP accepted the password, the mailbox is inactive
|
||||||
|
$denied = true;
|
||||||
}
|
}
|
||||||
clear_session();
|
clear_session();
|
||||||
return false;
|
return false;
|
||||||
@@ -273,18 +279,16 @@ function user_login($user, $pass, $extra = null){
|
|||||||
|
|
||||||
// check if user has access to service (imap, smtp, pop3, sieve) if service is set
|
// check if user has access to service (imap, smtp, pop3, sieve) if service is set
|
||||||
$row['attributes'] = json_decode($row['attributes'], true);
|
$row['attributes'] = json_decode($row['attributes'], true);
|
||||||
// Shared mailboxes have no login of their own, members open them through group shares
|
$key = strtolower($extra['service']) . "_access";
|
||||||
if (($row['attributes']['shared'] ?? '0') == '1') {
|
// Shared mailboxes have no login of their own, members open them through group shares.
|
||||||
return false;
|
// Checked before asking an IdP (no template side effects).
|
||||||
}
|
if (($row['attributes']['shared'] ?? '0') == '1'
|
||||||
if ($extra['service'] != 'NONE') {
|
|| ($extra['service'] != 'NONE' && isset($row['attributes'][$key]) && $row['attributes'][$key] != '1')
|
||||||
$key = strtolower($extra['service']) . "_access";
|
|| $row['active'] != '1' || $row['d_active'] != '1') {
|
||||||
if (isset($row['attributes'][$key]) && $row['attributes'][$key] != '1') {
|
// Denied, not failed, unless the password is wrong; IdP passwords are not checked for that
|
||||||
return false;
|
if ($row['authsource'] != 'mailcow' || verify_hash($row['password'], $pass) !== false) {
|
||||||
|
$denied = true;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// IdP accounts: refuse inactive mailboxes and domains before asking the IdP (no template side effects)
|
|
||||||
if ($row['authsource'] != 'mailcow' && ($row['active'] != '1' || $row['d_active'] != '1')) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
switch ($row['authsource']) {
|
switch ($row['authsource']) {
|
||||||
@@ -303,6 +307,7 @@ function user_login($user, $pass, $extra = null){
|
|||||||
$stmt->execute(array(':user' => $user));
|
$stmt->execute(array(':user' => $user));
|
||||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
if (empty($row)) {
|
if (empty($row)) {
|
||||||
|
$denied = true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -361,6 +366,7 @@ function user_login($user, $pass, $extra = null){
|
|||||||
$stmt->execute(array(':user' => $user));
|
$stmt->execute(array(':user' => $user));
|
||||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
if (empty($row)) {
|
if (empty($row)) {
|
||||||
|
$denied = true;
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,9 +409,6 @@ function user_login($user, $pass, $extra = null){
|
|||||||
return $result;
|
return $result;
|
||||||
break;
|
break;
|
||||||
case 'mailcow':
|
case 'mailcow':
|
||||||
if ($row['active'] != 1 || $row['d_active'] != 1) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// verify password
|
// verify password
|
||||||
if (verify_hash($row['password'], $pass) !== false) {
|
if (verify_hash($row['password'], $pass) !== false) {
|
||||||
|
|
||||||
@@ -446,13 +449,15 @@ function user_login($user, $pass, $extra = null){
|
|||||||
}
|
}
|
||||||
return "user";
|
return "user";
|
||||||
}
|
}
|
||||||
|
// TFA accounts use app passwords in mail clients
|
||||||
|
$denied = true;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
function apppass_login($user, $pass, $extra = null){
|
function apppass_login($user, $pass, $extra = null, &$denied = false){
|
||||||
global $pdo;
|
global $pdo;
|
||||||
|
|
||||||
$is_internal = $extra['is_internal'];
|
$is_internal = $extra['is_internal'];
|
||||||
@@ -473,15 +478,12 @@ function apppass_login($user, $pass, $extra = null){
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetch app password data
|
// fetch app password data, inactive ones too: their right password is denied, not failed
|
||||||
$stmt = $pdo->prepare("SELECT `app_passwd`.*, `app_passwd`.`password` as `password`, `app_passwd`.`id` as `app_passwd_id` FROM `app_passwd`
|
$stmt = $pdo->prepare("SELECT `app_passwd`.*, `app_passwd`.`password` as `password`, `app_passwd`.`id` as `app_passwd_id`,
|
||||||
|
`mailbox`.`active` as `mailbox_active`, `domain`.`active` as `domain_active`, `mailbox`.`attributes` as `mailbox_attributes` FROM `app_passwd`
|
||||||
INNER JOIN `mailbox` ON `mailbox`.`username` = `app_passwd`.`mailbox`
|
INNER JOIN `mailbox` ON `mailbox`.`username` = `app_passwd`.`mailbox`
|
||||||
INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain`
|
INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain`
|
||||||
WHERE `mailbox`.`kind` NOT REGEXP 'location|thing|group'
|
WHERE `mailbox`.`kind` NOT REGEXP 'location|thing|group'
|
||||||
AND `mailbox`.`active` = '1'
|
|
||||||
AND `domain`.`active` = '1'
|
|
||||||
AND `app_passwd`.`active` = '1'
|
|
||||||
AND COALESCE(JSON_UNQUOTE(JSON_VALUE(`mailbox`.`attributes`, '$.shared')), '0') != '1'
|
|
||||||
AND `app_passwd`.`mailbox` = :user"
|
AND `app_passwd`.`mailbox` = :user"
|
||||||
);
|
);
|
||||||
// fetch password data
|
// fetch password data
|
||||||
@@ -491,16 +493,25 @@ function apppass_login($user, $pass, $extra = null){
|
|||||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
if ($extra['service'] != 'NONE' && $row[strtolower($extra['service']) . '_access'] != '1'){
|
// verify password
|
||||||
|
if (verify_hash($row['password'], $pass) === false) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// verify password
|
$key = strtolower($extra['service']) . '_access';
|
||||||
if (verify_hash($row['password'], $pass) !== false) {
|
$mailbox_attributes = json_decode($row['mailbox_attributes'], true);
|
||||||
$_SESSION['app_passwd_id'] = $row['app_passwd_id'];
|
// the protocols of the mailbox only limit direct user access, app passwords have their own
|
||||||
unset($_SESSION['ldelay']);
|
if ($row['active'] != '1' || $row['mailbox_active'] != '1' || $row['domain_active'] != '1'
|
||||||
return "user";
|
|| ($mailbox_attributes['shared'] ?? '0') == '1'
|
||||||
|
|| ($extra['service'] != 'NONE' && $row[$key] != '1')) {
|
||||||
|
// right password, but the app password does not allow this login
|
||||||
|
$denied = true;
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$_SESSION['app_passwd_id'] = $row['app_passwd_id'];
|
||||||
|
unset($_SESSION['ldelay']);
|
||||||
|
return "user";
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
@@ -538,7 +549,8 @@ function keycloak_mbox_login_rest($user, $pass, $extra = null){
|
|||||||
|
|
||||||
// get the mailcow_password attribute from keycloak user
|
// get the mailcow_password attribute from keycloak user
|
||||||
$url = "{$iam_settings['server_url']}/admin/realms/{$iam_settings['realm']}/users";
|
$url = "{$iam_settings['server_url']}/admin/realms/{$iam_settings['realm']}/users";
|
||||||
$queryParams = array('email' => $user, 'exact' => true);
|
// as string, http_build_query() would send exact=1 and Keycloak would search for substrings
|
||||||
|
$queryParams = array('email' => $user, 'exact' => 'true');
|
||||||
$queryString = http_build_query($queryParams);
|
$queryString = http_build_query($queryParams);
|
||||||
$curl = curl_init();
|
$curl = curl_init();
|
||||||
curl_setopt($curl, CURLOPT_TIMEOUT, 7);
|
curl_setopt($curl, CURLOPT_TIMEOUT, 7);
|
||||||
@@ -559,7 +571,10 @@ function keycloak_mbox_login_rest($user, $pass, $extra = null){
|
|||||||
if ($code != 200) {
|
if ($code != 200) {
|
||||||
throw new \Mailcow\Auth\IdpUnavailable('Keycloak returned HTTP ' . $code);
|
throw new \Mailcow\Auth\IdpUnavailable('Keycloak returned HTTP ' . $code);
|
||||||
}
|
}
|
||||||
// exact=true can still return several users with that email
|
// exact=true can still return several users with that email, only the exact address counts
|
||||||
|
if (is_array($users)) {
|
||||||
|
$users = array_values(array_filter($users, fn($u) => is_array($u) && strcasecmp((string)($u['email'] ?? ''), $user) === 0));
|
||||||
|
}
|
||||||
if (!is_array($users) || count($users) != 1) {
|
if (!is_array($users) || count($users) != 1) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -710,12 +725,15 @@ function ldap_mbox_login($user, $pass, $extra = null){
|
|||||||
->select(array_values(array_filter(array_merge([$iam_settings['username_field'], $iam_settings['attribute_field'], 'displayname', 'distinguishedname', 'dn'],
|
->select(array_values(array_filter(array_merge([$iam_settings['username_field'], $iam_settings['attribute_field'], 'displayname', 'distinguishedname', 'dn'],
|
||||||
\Mailcow\Identity\Directory\LdapDirectory::idAttributes($iam_settings['id_attribute'] ?? '')))));
|
\Mailcow\Identity\Directory\LdapDirectory::idAttributes($iam_settings['id_attribute'] ?? '')))));
|
||||||
|
|
||||||
$user_res = $ldap_query->firstOrFail();
|
$entries = $ldap_query->get();
|
||||||
} catch (\LdapRecord\Query\ObjectNotFoundException $e) {
|
|
||||||
return false;
|
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
throw new \Mailcow\Auth\IdpUnavailable('LDAP search failed: ' . $e->getMessage(), 0, $e);
|
throw new \Mailcow\Auth\IdpUnavailable('LDAP search failed: ' . $e->getMessage(), 0, $e);
|
||||||
}
|
}
|
||||||
|
// the login name must identify exactly one entry, several matches are ambiguous
|
||||||
|
if (!is_array($entries) || count($entries) != 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$user_res = reset($entries);
|
||||||
$idp_login = \Mailcow\Auth\AuthModule::fromGlobals()->idp;
|
$idp_login = \Mailcow\Auth\AuthModule::fromGlobals()->idp;
|
||||||
if (!$idp_login->verifyLdapPassword($iam_provider, (string)$user_res['dn'], (string)$pass)) {
|
if (!$idp_login->verifyLdapPassword($iam_provider, (string)$user_res['dn'], (string)$pass)) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -145,6 +145,28 @@ function fail2ban($_action, $_data = null, $_extra = null) {
|
|||||||
);
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
// Queue all automatic bans for unbanning, the denylist stays
|
||||||
|
elseif ($_data['action'] == "unban-all") {
|
||||||
|
try {
|
||||||
|
foreach ($redis->hKeys('F2B_ACTIVE_BANS') as $network) {
|
||||||
|
$redis->hSet('F2B_QUEUE_UNBAN', $network, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (RedisException $e) {
|
||||||
|
$_SESSION['return'][] = array(
|
||||||
|
'type' => 'danger',
|
||||||
|
'log' => array(__FUNCTION__, $_action, $_data_log),
|
||||||
|
'msg' => array('redis_error', $e)
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$_SESSION['return'][] = array(
|
||||||
|
'type' => 'success',
|
||||||
|
'log' => array(__FUNCTION__, $_action, $_data_log),
|
||||||
|
'msg' => 'f2b_modified'
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Start actions in dependency of network
|
// Start actions in dependency of network
|
||||||
if (!empty($_data['network'])) {
|
if (!empty($_data['network'])) {
|
||||||
|
|||||||
@@ -1857,6 +1857,40 @@ function get_tfa($username = null, $id = null) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function verify_tfa_login($username, $_data) {
|
function verify_tfa_login($username, $_data) {
|
||||||
|
global $redis;
|
||||||
|
global $TFA_MAX_FAILS;
|
||||||
|
global $TFA_FAIL_WINDOW;
|
||||||
|
|
||||||
|
if (empty($username)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
// Count failed second factors per account, so codes cannot be guessed from many addresses
|
||||||
|
$fails_key = 'TFA_FAILS_' . $username;
|
||||||
|
if (intval($redis->get($fails_key)) >= $TFA_MAX_FAILS) {
|
||||||
|
$_SESSION['return'][] = array(
|
||||||
|
'type' => 'danger',
|
||||||
|
'log' => array(__FUNCTION__, $username, '*'),
|
||||||
|
'msg' => 'tfa_login_locked'
|
||||||
|
);
|
||||||
|
$verified = false;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
$verified = verify_tfa_token($username, $_data);
|
||||||
|
}
|
||||||
|
if ($verified !== true) {
|
||||||
|
// The window starts with the first failure and is not extended by further attempts
|
||||||
|
if ($redis->incr($fails_key) == 1) {
|
||||||
|
$redis->expire($fails_key, $TFA_FAIL_WINDOW);
|
||||||
|
}
|
||||||
|
// Same message format as failed passwords, so the default fail2ban rule counts it
|
||||||
|
error_log("mailcow UI: Invalid password for " . $username . " (second factor) by " . $_SERVER['REMOTE_ADDR']);
|
||||||
|
$redis->publish("F2B_CHANNEL", "mailcow UI: Invalid password for " . $username . " (second factor) by " . $_SERVER['REMOTE_ADDR']);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$redis->del($fails_key);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
function verify_tfa_token($username, $_data) {
|
||||||
global $pdo;
|
global $pdo;
|
||||||
global $yubi;
|
global $yubi;
|
||||||
global $u2f;
|
global $u2f;
|
||||||
@@ -3013,6 +3047,18 @@ function identity_provider($_action = null, $_data = null, $_extra = null) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A refresh keeps the logged-in mailbox: a changed IdP email or another IdP user ends the session
|
||||||
|
if (strtolower($info['email']) !== strtolower($_SESSION['mailcow_cc_username'] ?? '') ||
|
||||||
|
!\Mailcow\Auth\AuthModule::fromGlobals()->idp->verifyLink($iam_settings['authsource'], $info['email'], (string)($info['sub'] ?? ''))) {
|
||||||
|
clear_session();
|
||||||
|
$_SESSION['return'][] = array(
|
||||||
|
'type' => 'danger',
|
||||||
|
'log' => array(__FUNCTION__, $info['email'], 'The IdP user does not match the logged-in mailbox'),
|
||||||
|
'msg' => 'refresh_login_failed'
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// The mailbox may have been deleted or deactivated since the login
|
// The mailbox may have been deleted or deactivated since the login
|
||||||
$stmt = $pdo->prepare("SELECT `mailbox`.`authsource` FROM `mailbox`
|
$stmt = $pdo->prepare("SELECT `mailbox`.`authsource` FROM `mailbox`
|
||||||
INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain`
|
INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain`
|
||||||
|
|||||||
@@ -2432,6 +2432,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
if ($address != $is_now['address']) {
|
if ($address != $is_now['address']) {
|
||||||
// Send-as grants of groups follow the address
|
// Send-as grants of groups follow the address
|
||||||
\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->addressRenamed($is_now['address'], $address);
|
\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->addressRenamed($is_now['address'], $address);
|
||||||
|
\Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->api->addressRenamed($is_now['address'], $address);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$_SESSION['return'][] = array(
|
$_SESSION['return'][] = array(
|
||||||
@@ -3295,7 +3296,9 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
'mailbox_group_imap_grant' => ['owner'],
|
'mailbox_group_imap_grant' => ['owner'],
|
||||||
'mailbox_group_sogo_grant' => ['owner'],
|
'mailbox_group_sogo_grant' => ['owner'],
|
||||||
'mailbox_group_sogo_acl' => ['c_uid'],
|
'mailbox_group_sogo_acl' => ['c_uid'],
|
||||||
'mailbox_group_subscription' => ['username', 'owner']
|
'mailbox_group_subscription' => ['username', 'owner'],
|
||||||
|
'mail_policy_member' => ['username'],
|
||||||
|
'retention_rule_member' => ['username']
|
||||||
];
|
];
|
||||||
foreach ($tables as $table => $columns) {
|
foreach ($tables as $table => $columns) {
|
||||||
foreach ($columns as $column) {
|
foreach ($columns as $column) {
|
||||||
@@ -3311,6 +3314,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->sources->onAccountRenamed($old_username, $new_username);
|
\Mailcow\Imapsync\ImapsyncModule::fromGlobals()->sources->onAccountRenamed($old_username, $new_username);
|
||||||
// Group memberships and send-as grants follow, group aliases are rebuilt
|
// Group memberships and send-as grants follow, group aliases are rebuilt
|
||||||
\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->hooks->onMailboxRenamed($old_username, $new_username);
|
\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->hooks->onMailboxRenamed($old_username, $new_username);
|
||||||
|
// Address entries of mail policies follow
|
||||||
|
\Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->api->addressRenamed($old_username, $new_username);
|
||||||
|
|
||||||
// Update c_uid, c_name and mail in _sogo_static_view table
|
// 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")
|
$pdo->prepare("UPDATE _sogo_static_view SET c_uid = :new_username, c_name = :new_username2, mail = :new_username3 WHERE c_uid = :old_username")
|
||||||
@@ -5348,6 +5353,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
':domain' => $domain,
|
':domain' => $domain,
|
||||||
));
|
));
|
||||||
\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->domainDeleted($domain);
|
\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->domainDeleted($domain);
|
||||||
|
\Mailcow\Retention\RetentionModule::fromGlobals()->api->domainDeleted($domain);
|
||||||
$stmt = $pdo->prepare("DELETE FROM `imapsync_source_domain` WHERE `domain` = :domain");
|
$stmt = $pdo->prepare("DELETE FROM `imapsync_source_domain` WHERE `domain` = :domain");
|
||||||
$stmt->execute(array(
|
$stmt->execute(array(
|
||||||
':domain' => $domain,
|
':domain' => $domain,
|
||||||
|
|||||||
@@ -767,8 +767,9 @@ function quarantine($_action, $_data = null) {
|
|||||||
else {
|
else {
|
||||||
$domains = array_merge(mailbox('get', 'domains'), mailbox('get', 'alias_domains'));
|
$domains = array_merge(mailbox('get', 'domains'), mailbox('get', 'alias_domains'));
|
||||||
foreach ($domains as $domain) {
|
foreach ($domains as $domain) {
|
||||||
$stmt = $pdo->prepare('SELECT `id`, `qid`, `subject`, LOCATE("VIRUS_FOUND", `symbols`) AS `virus_flag`, `score`, `rcpt`, `sender`, `action`, UNIX_TIMESTAMP(`created`) AS `created`, `notified` FROM `quarantine` WHERE `rcpt` REGEXP :domain');
|
// Compare the domain part exactly, a regex would treat dots in the domain as wildcards
|
||||||
$stmt->execute(array(':domain' => '@' . $domain . '$'));
|
$stmt = $pdo->prepare('SELECT `id`, `qid`, `subject`, LOCATE("VIRUS_FOUND", `symbols`) AS `virus_flag`, `score`, `rcpt`, `sender`, `action`, UNIX_TIMESTAMP(`created`) AS `created`, `notified` FROM `quarantine` WHERE SUBSTRING_INDEX(`rcpt`, "@", -1) = :domain');
|
||||||
|
$stmt->execute(array(':domain' => $domain));
|
||||||
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
while($row = array_shift($rows)) {
|
while($row = array_shift($rows)) {
|
||||||
$q_meta[] = $row;
|
$q_meta[] = $row;
|
||||||
|
|||||||
+218
-14
@@ -491,6 +491,196 @@ function init_db_schema()
|
|||||||
),
|
),
|
||||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||||
),
|
),
|
||||||
|
// Mail policies: whom the assigned mailboxes and groups may send to and receive from (Mailcow\MailPolicy)
|
||||||
|
"mail_policy" => array(
|
||||||
|
"cols" => array(
|
||||||
|
"id" => "INT NOT NULL AUTO_INCREMENT",
|
||||||
|
"domain" => "VARCHAR(255) NOT NULL",
|
||||||
|
"name" => "VARCHAR(100) NOT NULL",
|
||||||
|
"description" => "VARCHAR(255) NOT NULL DEFAULT ''",
|
||||||
|
// all, internal or list; the entries are JSON lists of addresses, domains and group:<id>
|
||||||
|
"send_mode" => "VARCHAR(16) NOT NULL DEFAULT 'all'",
|
||||||
|
"send_allow" => "MEDIUMTEXT NOT NULL",
|
||||||
|
"send_block" => "MEDIUMTEXT NOT NULL",
|
||||||
|
"receive_mode" => "VARCHAR(16) NOT NULL DEFAULT 'all'",
|
||||||
|
"receive_allow" => "MEDIUMTEXT NOT NULL",
|
||||||
|
"receive_block" => "MEDIUMTEXT NOT NULL",
|
||||||
|
"reject_text" => "VARCHAR(255) NOT NULL DEFAULT ''",
|
||||||
|
"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(
|
||||||
|
"domain_name" => array("domain", "name")
|
||||||
|
),
|
||||||
|
"fkey" => array(
|
||||||
|
"fk_mail_policy_domain" => array(
|
||||||
|
"col" => "domain",
|
||||||
|
"ref" => "domain.domain",
|
||||||
|
"delete" => "CASCADE",
|
||||||
|
"update" => "NO ACTION"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||||
|
),
|
||||||
|
// a mailbox or a group (its members and its address) a policy is assigned to
|
||||||
|
"mail_policy_member" => array(
|
||||||
|
"cols" => array(
|
||||||
|
"id" => "INT NOT NULL AUTO_INCREMENT",
|
||||||
|
"policy_id" => "INT NOT NULL",
|
||||||
|
"username" => "VARCHAR(255) NULL DEFAULT NULL",
|
||||||
|
"group_id" => "INT NULL DEFAULT NULL"
|
||||||
|
),
|
||||||
|
"keys" => array(
|
||||||
|
"primary" => array(
|
||||||
|
"" => array("id")
|
||||||
|
),
|
||||||
|
"unique" => array(
|
||||||
|
"policy_mailbox" => array("policy_id", "username"),
|
||||||
|
"policy_group" => array("policy_id", "group_id")
|
||||||
|
),
|
||||||
|
"key" => array(
|
||||||
|
"username" => array("username"),
|
||||||
|
"group_id" => array("group_id")
|
||||||
|
),
|
||||||
|
"fkey" => array(
|
||||||
|
"fk_mail_policy_member_policy" => array(
|
||||||
|
"col" => "policy_id",
|
||||||
|
"ref" => "mail_policy.id",
|
||||||
|
"delete" => "CASCADE",
|
||||||
|
"update" => "NO ACTION"
|
||||||
|
),
|
||||||
|
"fk_mail_policy_member_mailbox" => array(
|
||||||
|
"col" => "username",
|
||||||
|
"ref" => "mailbox.username",
|
||||||
|
"delete" => "CASCADE",
|
||||||
|
"update" => "NO ACTION"
|
||||||
|
),
|
||||||
|
"fk_mail_policy_member_group" => array(
|
||||||
|
"col" => "group_id",
|
||||||
|
"ref" => "mailbox_group.id",
|
||||||
|
"delete" => "CASCADE",
|
||||||
|
"update" => "NO ACTION"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||||
|
),
|
||||||
|
"retention_rule" => array(
|
||||||
|
"cols" => array(
|
||||||
|
"id" => "INT NOT NULL AUTO_INCREMENT",
|
||||||
|
"domain" => "VARCHAR(255) NOT NULL",
|
||||||
|
"name" => "VARCHAR(100) NOT NULL",
|
||||||
|
"description" => "VARCHAR(255) NOT NULL DEFAULT ''",
|
||||||
|
// JSON list: trash, junk, sent, drafts, archive, inbox, all, folder:<name>
|
||||||
|
"folders" => "TEXT NOT NULL",
|
||||||
|
"days" => "INT NOT NULL DEFAULT '30'",
|
||||||
|
"keep_flagged" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||||
|
// simulate (count only) or delete
|
||||||
|
"mode" => "VARCHAR(16) NOT NULL DEFAULT 'simulate'",
|
||||||
|
"all_mailboxes" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||||
|
"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(
|
||||||
|
"domain_name" => array("domain", "name")
|
||||||
|
),
|
||||||
|
"fkey" => array(
|
||||||
|
"fk_retention_rule_domain" => array(
|
||||||
|
"col" => "domain",
|
||||||
|
"ref" => "domain.domain",
|
||||||
|
"delete" => "CASCADE",
|
||||||
|
"update" => "NO ACTION"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||||
|
),
|
||||||
|
// a mailbox or a group (its members of the rule's domain) a retention rule is assigned to
|
||||||
|
"retention_rule_member" => array(
|
||||||
|
"cols" => array(
|
||||||
|
"id" => "INT NOT NULL AUTO_INCREMENT",
|
||||||
|
"rule_id" => "INT NOT NULL",
|
||||||
|
"username" => "VARCHAR(255) NULL DEFAULT NULL",
|
||||||
|
"group_id" => "INT NULL DEFAULT NULL"
|
||||||
|
),
|
||||||
|
"keys" => array(
|
||||||
|
"primary" => array(
|
||||||
|
"" => array("id")
|
||||||
|
),
|
||||||
|
"unique" => array(
|
||||||
|
"rule_mailbox" => array("rule_id", "username"),
|
||||||
|
"rule_group" => array("rule_id", "group_id")
|
||||||
|
),
|
||||||
|
"key" => array(
|
||||||
|
"username" => array("username"),
|
||||||
|
"group_id" => array("group_id")
|
||||||
|
),
|
||||||
|
"fkey" => array(
|
||||||
|
"fk_retention_rule_member_rule" => array(
|
||||||
|
"col" => "rule_id",
|
||||||
|
"ref" => "retention_rule.id",
|
||||||
|
"delete" => "CASCADE",
|
||||||
|
"update" => "NO ACTION"
|
||||||
|
),
|
||||||
|
"fk_retention_rule_member_mailbox" => array(
|
||||||
|
"col" => "username",
|
||||||
|
"ref" => "mailbox.username",
|
||||||
|
"delete" => "CASCADE",
|
||||||
|
"update" => "NO ACTION"
|
||||||
|
),
|
||||||
|
"fk_retention_rule_member_group" => array(
|
||||||
|
"col" => "group_id",
|
||||||
|
"ref" => "mailbox_group.id",
|
||||||
|
"delete" => "CASCADE",
|
||||||
|
"update" => "NO ACTION"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||||
|
),
|
||||||
|
// results of the nightly run: per mailbox and rule with found mail or an error (folder: the folder names), per
|
||||||
|
// rule a summary (username '') and the whole run (username '', rule_id NULL); kept 90 days, of simulating
|
||||||
|
// rules only the last run
|
||||||
|
"retention_log" => array(
|
||||||
|
"cols" => array(
|
||||||
|
"id" => "INT NOT NULL AUTO_INCREMENT",
|
||||||
|
"domain" => "VARCHAR(255) NOT NULL",
|
||||||
|
"username" => "VARCHAR(255) NOT NULL DEFAULT ''",
|
||||||
|
"rule_id" => "INT NULL DEFAULT NULL",
|
||||||
|
"rule_name" => "VARCHAR(100) NOT NULL DEFAULT ''",
|
||||||
|
"folder" => "TEXT NULL DEFAULT NULL",
|
||||||
|
"days" => "INT NOT NULL DEFAULT '0'",
|
||||||
|
"mailboxes" => "INT NOT NULL DEFAULT '0'",
|
||||||
|
"matched" => "INT NOT NULL DEFAULT '0'",
|
||||||
|
"deleted" => "INT NOT NULL DEFAULT '0'",
|
||||||
|
"errors" => "INT NOT NULL DEFAULT '0'",
|
||||||
|
"simulated" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||||
|
"error" => "TEXT NULL DEFAULT NULL",
|
||||||
|
"created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)"
|
||||||
|
),
|
||||||
|
"keys" => array(
|
||||||
|
"primary" => array(
|
||||||
|
"" => array("id")
|
||||||
|
),
|
||||||
|
"key" => array(
|
||||||
|
"domain_created" => array("domain", "created"),
|
||||||
|
"rule_created" => array("rule_id", "username", "created"),
|
||||||
|
"created" => array("created")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||||
|
),
|
||||||
"mailbox_group_imap_grant" => array(
|
"mailbox_group_imap_grant" => array(
|
||||||
"cols" => array(
|
"cols" => array(
|
||||||
"id" => "INT NOT NULL AUTO_INCREMENT",
|
"id" => "INT NOT NULL AUTO_INCREMENT",
|
||||||
@@ -703,7 +893,8 @@ function init_db_schema()
|
|||||||
"group_id" => array("group_id")
|
"group_id" => array("group_id")
|
||||||
),
|
),
|
||||||
"key" => array(
|
"key" => array(
|
||||||
"domain" => array("domain")
|
"domain" => array("domain"),
|
||||||
|
"internal" => array("internal", "active")
|
||||||
),
|
),
|
||||||
"fkey" => array(
|
"fkey" => array(
|
||||||
"fk_alias_mailbox_group" => array(
|
"fk_alias_mailbox_group" => array(
|
||||||
@@ -1053,7 +1244,9 @@ function init_db_schema()
|
|||||||
"domain_relayhost" => "TINYINT(1) NOT NULL DEFAULT '1'",
|
"domain_relayhost" => "TINYINT(1) NOT NULL DEFAULT '1'",
|
||||||
"domain_desc" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
"domain_desc" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||||
"alias_external_goto" => "TINYINT(1) NOT NULL DEFAULT '1'",
|
"alias_external_goto" => "TINYINT(1) NOT NULL DEFAULT '1'",
|
||||||
"mailbox_groups" => "TINYINT(1) NOT NULL DEFAULT '1'"
|
"mailbox_groups" => "TINYINT(1) NOT NULL DEFAULT '1'",
|
||||||
|
"mail_policies" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||||
|
"retention" => "TINYINT(1) NOT NULL DEFAULT '0'"
|
||||||
),
|
),
|
||||||
"keys" => array(
|
"keys" => array(
|
||||||
"primary" => array(
|
"primary" => array(
|
||||||
@@ -1098,6 +1291,7 @@ function init_db_schema()
|
|||||||
"host1" => "VARCHAR(255) NOT NULL",
|
"host1" => "VARCHAR(255) NOT NULL",
|
||||||
"port1" => "SMALLINT UNSIGNED NOT NULL",
|
"port1" => "SMALLINT UNSIGNED NOT NULL",
|
||||||
"enc1" => "ENUM('TLS','SSL','PLAIN') DEFAULT 'TLS'",
|
"enc1" => "ENUM('TLS','SSL','PLAIN') DEFAULT 'TLS'",
|
||||||
|
"verify_cert" => "TINYINT(1) NOT NULL DEFAULT '1'",
|
||||||
"auth_type" => "ENUM('PLAIN','LOGIN','CRAM-MD5','XOAUTH2') DEFAULT 'PLAIN'",
|
"auth_type" => "ENUM('PLAIN','LOGIN','CRAM-MD5','XOAUTH2') DEFAULT 'PLAIN'",
|
||||||
"oauth_flow" => "ENUM('client_credentials','authorization_code') NOT NULL DEFAULT 'client_credentials'",
|
"oauth_flow" => "ENUM('client_credentials','authorization_code') NOT NULL DEFAULT 'client_credentials'",
|
||||||
"oauth_token_endpoint" => "VARCHAR(500) DEFAULT NULL",
|
"oauth_token_endpoint" => "VARCHAR(500) DEFAULT NULL",
|
||||||
@@ -1679,9 +1873,15 @@ function init_db_schema()
|
|||||||
// Converted JSON may not fit into the old VARCHAR(512)
|
// Converted JSON may not fit into the old VARCHAR(512)
|
||||||
$pdo->exec("ALTER TABLE `imapsync` MODIFY COLUMN `custom_params` TEXT");
|
$pdo->exec("ALTER TABLE `imapsync` MODIFY COLUMN `custom_params` TEXT");
|
||||||
$imapsync_allow = \Mailcow\Imapsync\Model\CustomParams::allowlist($GLOBALS['IMAPSYNC_OPTIONS'] ?? [], $GLOBALS['IMAPSYNC_OPTIONS_DEFAULT'] ?? []);
|
$imapsync_allow = \Mailcow\Imapsync\Model\CustomParams::allowlist($GLOBALS['IMAPSYNC_OPTIONS'] ?? [], $GLOBALS['IMAPSYNC_OPTIONS_DEFAULT'] ?? []);
|
||||||
$source_sel = $pdo->prepare("SELECT `id` FROM `imapsync_source` WHERE `created_by` = '' AND `name` = :name");
|
// A legacy source is found by its connection and its only target domain (none for jobs without domain)
|
||||||
$source_ins = $pdo->prepare("INSERT IGNORE INTO `imapsync_source` (`name`, `description`, `created_by`, `scope`, `host1`, `port1`, `enc1`, `auth_type`, `active`)
|
$source_sel = $pdo->prepare("SELECT `id` FROM `imapsync_source` s WHERE `created_by` = '' AND `host1` = :host AND `port1` = :port
|
||||||
VALUES (:name, '', '', 'domain', :host, :port, :enc, :auth, 1)");
|
AND `enc1` = :enc AND `auth_type` = :auth
|
||||||
|
AND (SELECT GROUP_CONCAT(`domain`) FROM `imapsync_source_domain` d WHERE d.`source_id` = s.`id`) <=> NULLIF(:domain, '')
|
||||||
|
ORDER BY `id` LIMIT 1");
|
||||||
|
$source_name_sel = $pdo->prepare("SELECT COUNT(*) FROM `imapsync_source` WHERE `created_by` = '' AND `name` = :name");
|
||||||
|
// The old runner never checked certificates, legacy sources keep that
|
||||||
|
$source_ins = $pdo->prepare("INSERT IGNORE INTO `imapsync_source` (`name`, `description`, `created_by`, `scope`, `host1`, `port1`, `enc1`, `verify_cert`, `auth_type`, `active`)
|
||||||
|
VALUES (:name, 'Migrated sync jobs', '', 'domain', :host, :port, :enc, 0, :auth, 1)");
|
||||||
$source_domain_ins = $pdo->prepare("INSERT IGNORE INTO `imapsync_source_domain` (`source_id`, `domain`) VALUES (:id, :domain)");
|
$source_domain_ins = $pdo->prepare("INSERT IGNORE INTO `imapsync_source_domain` (`source_id`, `domain`) VALUES (:id, :domain)");
|
||||||
$job_upd = $pdo->prepare("UPDATE `imapsync` SET `source_id` = :source_id, `custom_params` = :cp, `custom_params_legacy` = :cp_legacy,
|
$job_upd = $pdo->prepare("UPDATE `imapsync` SET `source_id` = :source_id, `custom_params` = :cp, `custom_params_legacy` = :cp_legacy,
|
||||||
`dry` = GREATEST(`dry`, :dry) WHERE `id` = :id AND `source_id` IS NULL");
|
`dry` = GREATEST(`dry`, :dry) WHERE `id` = :id AND `source_id` IS NULL");
|
||||||
@@ -1741,16 +1941,20 @@ function init_db_schema()
|
|||||||
$enc = in_array($row['enc1'], array('TLS', 'SSL', 'PLAIN'), true) ? $row['enc1'] : 'TLS';
|
$enc = in_array($row['enc1'], array('TLS', 'SSL', 'PLAIN'), true) ? $row['enc1'] : 'TLS';
|
||||||
$at = strrchr((string)$row['user2'], '@');
|
$at = strrchr((string)$row['user2'], '@');
|
||||||
$domain = ($at === false) ? '' : strtolower(substr($at, 1));
|
$domain = ($at === false) ? '' : strtolower(substr($at, 1));
|
||||||
// One source per (host, port, enc, auth, domain), name always unique and <= 100 chars
|
// One source per (host, port, enc, auth, domain), named "host (domain)" plus " 2", " 3"... when taken (<= 100 chars)
|
||||||
$name = 'legacy-' . mb_substr($host, 0, 60) . '-' . $port . '-' . substr(sha1($host . '|' . $port . '|' . $enc . '|' . $auth . '|' . $domain), 0, 8);
|
$conn = array(':host' => $host, ':port' => $port, ':enc' => $enc, ':auth' => $auth);
|
||||||
$source_sel->execute(array(':name' => $name));
|
$source_sel->execute($conn + array(':domain' => $domain));
|
||||||
if (($source_id = $source_sel->fetchColumn()) === false) {
|
if (($source_id = $source_sel->fetchColumn()) === false) {
|
||||||
$source_ins->execute(array(':name' => $name, ':host' => $host, ':port' => $port, ':enc' => $enc, ':auth' => $auth));
|
$base = mb_substr($host . ($domain !== '' ? ' (' . $domain . ')' : ''), 0, 94);
|
||||||
$source_sel->execute(array(':name' => $name));
|
for ($i = 1, $name = $base; $i < 1000; $name = $base . ' ' . ++$i) {
|
||||||
if (($source_id = $source_sel->fetchColumn()) === false) continue;
|
$source_name_sel->execute(array(':name' => $name));
|
||||||
}
|
if ($source_name_sel->fetchColumn() == 0) break;
|
||||||
if ($domain !== '') {
|
}
|
||||||
$source_domain_ins->execute(array(':id' => $source_id, ':domain' => $domain));
|
$source_ins->execute($conn + array(':name' => $name));
|
||||||
|
if (($source_id = $pdo->lastInsertId()) == 0) continue;
|
||||||
|
if ($domain !== '') {
|
||||||
|
$source_domain_ins->execute(array(':id' => $source_id, ':domain' => $domain));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
$job_upd->execute(array(
|
$job_upd->execute(array(
|
||||||
':source_id' => $source_id,
|
':source_id' => $source_id,
|
||||||
|
|||||||
@@ -44,6 +44,8 @@ class SyncPlanner
|
|||||||
/** @var array<string, DirectoryUser> $candidates email => user to create, template still open */
|
/** @var array<string, DirectoryUser> $candidates email => user to create, template still open */
|
||||||
$candidates = [];
|
$candidates = [];
|
||||||
$duplicates = [];
|
$duplicates = [];
|
||||||
|
/** @var array<string, true> $ambiguous unlinked mailboxes whose email several IdP users have, left as they are */
|
||||||
|
$ambiguous = [];
|
||||||
foreach ($listing->users as $user) {
|
foreach ($listing->users as $user) {
|
||||||
if (isset($owners[$user->id])) {
|
if (isset($owners[$user->id])) {
|
||||||
$username = $owners[$user->id];
|
$username = $owners[$user->id];
|
||||||
@@ -55,10 +57,19 @@ class SyncPlanner
|
|||||||
$plan->skipped++;
|
$plan->skipped++;
|
||||||
} elseif (isset($state->accounts[$user->email])) {
|
} elseif (isset($state->accounts[$user->email])) {
|
||||||
$account = $state->accounts[$user->email];
|
$account = $state->accounts[$user->email];
|
||||||
if ($account->externalId !== null || isset($plan->links[$user->email])) {
|
if ($account->externalId !== null) {
|
||||||
self::conflict($plan, $user->email . ' is linked to another IdP user');
|
self::conflict($plan, $user->email . ' is linked to another IdP user');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (isset($plan->links[$user->email]) || isset($ambiguous[$user->email])) {
|
||||||
|
// Which IdP user owns the mailbox is unclear: link none of them
|
||||||
|
if (!isset($ambiguous[$user->email])) {
|
||||||
|
self::conflict($plan, 'Several IdP users have the email ' . $user->email);
|
||||||
|
}
|
||||||
|
unset($plan->links[$user->email], $matched[$user->email]);
|
||||||
|
$ambiguous[$user->email] = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$plan->links[$user->email] = $user->id;
|
$plan->links[$user->email] = $user->id;
|
||||||
$matched[$user->email] = $user;
|
$matched[$user->email] = $user;
|
||||||
} elseif (isset($state->mailboxes[$user->email]) || !$settings->importUsers || !$user->enabled
|
} elseif (isset($state->mailboxes[$user->email]) || !$settings->importUsers || !$user->enabled
|
||||||
@@ -85,7 +96,7 @@ class SyncPlanner
|
|||||||
if ($groups !== null) {
|
if ($groups !== null) {
|
||||||
$this->planGroups($settings, $groups, $state, $usernames, $matched, $plan);
|
$this->planGroups($settings, $groups, $state, $usernames, $matched, $plan);
|
||||||
}
|
}
|
||||||
$this->planStates($settings, $state, $matched, $plan);
|
$this->planStates($settings, $state, $matched, $ambiguous, $plan);
|
||||||
if ($settings->periodicSync) {
|
if ($settings->periodicSync) {
|
||||||
$this->planUpdates($settings, $state, $matched, $plan);
|
$this->planUpdates($settings, $state, $matched, $plan);
|
||||||
}
|
}
|
||||||
@@ -211,10 +222,13 @@ class SyncPlanner
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string, DirectoryUser> $matched */
|
/** @param array<string, DirectoryUser> $matched */
|
||||||
private function planStates(Settings $settings, State $state, array $matched, SyncPlan $plan): void
|
private function planStates(Settings $settings, State $state, array $matched, array $ambiguous, SyncPlan $plan): void
|
||||||
{
|
{
|
||||||
foreach ($state->accounts as $username => $account) {
|
foreach ($state->accounts as $username => $account) {
|
||||||
$username = (string)$username;
|
$username = (string)$username;
|
||||||
|
if (isset($ambiguous[$username])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
$user = $matched[$username] ?? null;
|
$user = $matched[$username] ?? null;
|
||||||
if ($user === null && $account->missingSince === null) {
|
if ($user === null && $account->missingSince === null) {
|
||||||
$plan->missing[] = $username;
|
$plan->missing[] = $username;
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ class CustomParams
|
|||||||
{
|
{
|
||||||
private const MAX_PAIRS = 100;
|
private const MAX_PAIRS = 100;
|
||||||
private const MAX_VALUE_LENGTH = 1000;
|
private const MAX_VALUE_LENGTH = 1000;
|
||||||
|
/**
|
||||||
|
* Never allowed, even when configured: code or command execution, local files, the environment
|
||||||
|
* of the container (tests*), passwords in the output and stopping other runs (abort).
|
||||||
|
* The runner (dovecot/imapsync_runner.py) skips the same options.
|
||||||
|
*/
|
||||||
|
public const BLOCKED = ['abort', 'delete2foldersbutnot', 'delete2foldersonly', 'logdir', 'logfile', 'maxlinelengthcmd',
|
||||||
|
'oauthrefreshcmd1', 'oauthrefreshcmd2', 'passfile1', 'passfile2', 'pidfile', 'pipemess', 'regexflag', 'regexmess',
|
||||||
|
'regextrans2', 'showpasswords', 'skipmess', 'tests', 'testsdebug', 'testslive', 'testslive6', 'testsunit', 'tmpdir'];
|
||||||
|
|
||||||
private static bool $warnedOldFormat = false;
|
private static bool $warnedOldFormat = false;
|
||||||
|
|
||||||
@@ -57,7 +65,7 @@ class CustomParams
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The effective allowlist: $IMAPSYNC_OPTIONS (after vars.local.inc.php) without the old
|
* 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.
|
* 'whitelist' key, malformed entries and BLOCKED options; the defaults when nothing valid is left.
|
||||||
*/
|
*/
|
||||||
public static function allowlist(array $configured, array $defaults): array
|
public static function allowlist(array $configured, array $defaults): array
|
||||||
{
|
{
|
||||||
@@ -73,7 +81,7 @@ class CustomParams
|
|||||||
self::$warnedOldFormat = true;
|
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)");
|
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;
|
return array_diff_key(empty($allowlist) ? $defaults : $allowlist, array_flip(self::BLOCKED));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The value to store for one option; throws when the allowlist rule is violated */
|
/** The value to store for one option; throws when the allowlist rule is violated */
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class Source
|
|||||||
public string $host = '';
|
public string $host = '';
|
||||||
public int $port = 0;
|
public int $port = 0;
|
||||||
public ?string $enc = 'TLS';
|
public ?string $enc = 'TLS';
|
||||||
|
public int $verifyCert = 1;
|
||||||
public ?string $authType = 'PLAIN';
|
public ?string $authType = 'PLAIN';
|
||||||
public string $oauthFlow = self::FLOW_CLIENT_CREDENTIALS;
|
public string $oauthFlow = self::FLOW_CLIENT_CREDENTIALS;
|
||||||
public ?string $tokenEndpoint = null;
|
public ?string $tokenEndpoint = null;
|
||||||
@@ -65,6 +66,7 @@ class Source
|
|||||||
$source->scope = (string)($row['scope'] ?? 'user');
|
$source->scope = (string)($row['scope'] ?? 'user');
|
||||||
$source->host = (string)($row['host1'] ?? '');
|
$source->host = (string)($row['host1'] ?? '');
|
||||||
$source->port = (int)($row['port1'] ?? 0);
|
$source->port = (int)($row['port1'] ?? 0);
|
||||||
|
$source->verifyCert = (int)($row['verify_cert'] ?? 1);
|
||||||
$source->oauthFlow = (string)($row['oauth_flow'] ?? self::FLOW_CLIENT_CREDENTIALS);
|
$source->oauthFlow = (string)($row['oauth_flow'] ?? self::FLOW_CLIENT_CREDENTIALS);
|
||||||
$source->tokenExpires = isset($row['oauth_token_expires']) ? (int)$row['oauth_token_expires'] : null;
|
$source->tokenExpires = isset($row['oauth_token_expires']) ? (int)$row['oauth_token_expires'] : null;
|
||||||
$source->refreshAttemptAge = isset($row['refresh_attempt_age']) ? (int)$row['refresh_attempt_age'] : null;
|
$source->refreshAttemptAge = isset($row['refresh_attempt_age']) ? (int)$row['refresh_attempt_age'] : null;
|
||||||
@@ -112,6 +114,7 @@ class Source
|
|||||||
'host1' => $this->host,
|
'host1' => $this->host,
|
||||||
'port1' => $this->port,
|
'port1' => $this->port,
|
||||||
'enc1' => $this->enc,
|
'enc1' => $this->enc,
|
||||||
|
'verify_cert' => $this->verifyCert,
|
||||||
'auth_type' => $this->authType,
|
'auth_type' => $this->authType,
|
||||||
'oauth_flow' => $this->oauthFlow,
|
'oauth_flow' => $this->oauthFlow,
|
||||||
'oauth_token_endpoint' => $this->tokenEndpoint,
|
'oauth_token_endpoint' => $this->tokenEndpoint,
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ class SourceRepository extends Repository
|
|||||||
return (bool)$this->query("SELECT 1 FROM `imapsync` WHERE `source_id` = :id LIMIT 1", ['id' => $id])->fetchColumn();
|
return (bool)$this->query("SELECT 1 FROM `imapsync` WHERE `source_id` = :id LIMIT 1", ['id' => $id])->fetchColumn();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return string[] mailboxes whose sync jobs use the source */
|
||||||
|
public function jobMailboxes(int $id): array
|
||||||
|
{
|
||||||
|
return $this->query("SELECT DISTINCT `user2` FROM `imapsync` WHERE `source_id` = :id", ['id' => $id])->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
}
|
||||||
|
|
||||||
public function isSuperAdmin(string $username): bool
|
public function isSuperAdmin(string $username): bool
|
||||||
{
|
{
|
||||||
return (bool)$this->query("SELECT 1 FROM `admin` WHERE `username` = :username AND `superadmin` = '1'",
|
return (bool)$this->query("SELECT 1 FROM `admin` WHERE `username` = :username AND `superadmin` = '1'",
|
||||||
@@ -200,6 +206,7 @@ class SourceRepository extends Repository
|
|||||||
'host1' => $source->host,
|
'host1' => $source->host,
|
||||||
'port1' => $source->port,
|
'port1' => $source->port,
|
||||||
'enc1' => $source->enc,
|
'enc1' => $source->enc,
|
||||||
|
'verify_cert' => $source->verifyCert,
|
||||||
'auth_type' => $source->authType,
|
'auth_type' => $source->authType,
|
||||||
'oauth_flow' => $source->oauthFlow,
|
'oauth_flow' => $source->oauthFlow,
|
||||||
'oauth_token_endpoint' => $source->tokenEndpoint,
|
'oauth_token_endpoint' => $source->tokenEndpoint,
|
||||||
|
|||||||
@@ -121,7 +121,11 @@ class SourceService
|
|||||||
if ($writeTargets) {
|
if ($writeTargets) {
|
||||||
$this->applyScope($source, $input['scope'] ?? $current->scope, $input['domains'] ?? [], $input['users'] ?? []);
|
$this->applyScope($source, $input['scope'] ?? $current->scope, $input['domains'] ?? [], $input['users'] ?? []);
|
||||||
}
|
}
|
||||||
$this->repo->update($source, $writeTargets, self::connection($current) !== self::connection($source));
|
$connectionChanged = self::connection($current) !== self::connection($source);
|
||||||
|
if ($connectionChanged || ($current->verifyCert && !$source->verifyCert)) {
|
||||||
|
$this->requireAllJobsAccessible($source);
|
||||||
|
}
|
||||||
|
$this->repo->update($source, $writeTargets, $connectionChanged);
|
||||||
$this->fetchToken($source);
|
$this->fetchToken($source);
|
||||||
return $source;
|
return $source;
|
||||||
}
|
}
|
||||||
@@ -200,6 +204,24 @@ class SourceService
|
|||||||
return $this->context->hasAcl('syncjobs') || $this->context->isAdmin();
|
return $this->context->hasAcl('syncjobs') || $this->context->isAdmin();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The connection of a source only changes when the caller may access every mailbox whose sync jobs use it,
|
||||||
|
* their stored passwords would go to the new server.
|
||||||
|
*
|
||||||
|
* @throws UserError imapsync_source_used_by_others
|
||||||
|
*/
|
||||||
|
private function requireAllJobsAccessible(Source $source): void
|
||||||
|
{
|
||||||
|
if ($this->context->hasAdminRights()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach ($this->repo->jobMailboxes((int)$source->id) as $mailbox) {
|
||||||
|
if (!$this->context->canAccessMailbox($mailbox)) {
|
||||||
|
throw new UserError('imapsync_source_used_by_others', [htmlspecialchars($source->name)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @throws AccessDenied when the caller cannot see or edit the source */
|
/** @throws AccessDenied when the caller cannot see or edit the source */
|
||||||
private function editable(int $id): Source
|
private function editable(int $id): Source
|
||||||
{
|
{
|
||||||
@@ -274,6 +296,7 @@ class SourceService
|
|||||||
$source->host = $host;
|
$source->host = $host;
|
||||||
$source->port = $port;
|
$source->port = $port;
|
||||||
$source->enc = $enc;
|
$source->enc = $enc;
|
||||||
|
$source->verifyCert = isset($input['verify_cert']) ? (int)!empty($input['verify_cert']) : $source->verifyCert;
|
||||||
$source->authType = $authType;
|
$source->authType = $authType;
|
||||||
$source->active = isset($input['active']) ? (int)$input['active'] : $source->active;
|
$source->active = isset($input['active']) ? (int)$input['active'] : $source->active;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\MailPolicy\Controller;
|
||||||
|
|
||||||
|
use Mailcow\Core\AccessDenied;
|
||||||
|
use Mailcow\Core\ApiResponse;
|
||||||
|
use Mailcow\Core\UserError;
|
||||||
|
use Mailcow\MailPolicy\Service\PolicyEngine;
|
||||||
|
use Mailcow\MailPolicy\Service\PolicyService;
|
||||||
|
use PDOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP API adapter for json_api.php and edit.php (routes `mail-policy`), and the answer to the Postfix policy
|
||||||
|
* service (data/conf/rspamd/dynmaps/mailpolicy.php). Messages are logged as ['mail_policy', action, 'policy', data].
|
||||||
|
*/
|
||||||
|
class MailPolicyApiController
|
||||||
|
{
|
||||||
|
public function __construct(private PolicyService $policies, private PolicyEngine $engine, private ApiResponse $response)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST add/mail-policy */
|
||||||
|
public function addPolicy(array $attr): bool
|
||||||
|
{
|
||||||
|
return $this->handle('add', $attr, function (array $log) use ($attr): bool {
|
||||||
|
$policy = $this->policies->create($attr);
|
||||||
|
return $this->response->success(['mail_policy_added', htmlspecialchars($policy->name)], $log);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST edit/mail-policy: one message per id */
|
||||||
|
public function editPolicies(?array $items, array $attr): bool
|
||||||
|
{
|
||||||
|
$data = array_merge(['id' => $items], $attr);
|
||||||
|
return $this->handle('edit', $data, function (array $log) use ($data, $attr): bool {
|
||||||
|
$this->policies->requireEditor();
|
||||||
|
return $this->batch((array)($data['id'] ?? []), $log,
|
||||||
|
fn(int $id): array => ['mail_policy_modified', htmlspecialchars($this->policies->update($id, $attr)->name)]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST delete/mail-policy: one message per id */
|
||||||
|
public function deletePolicies(array $items): bool
|
||||||
|
{
|
||||||
|
return $this->handle('delete', ['id' => $items], function (array $log) use ($items): bool {
|
||||||
|
$this->policies->requireEditor();
|
||||||
|
return $this->batch($items, $log, fn(int $id): array => ['mail_policy_removed', htmlspecialchars($this->policies->delete($id)->name)]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET mail-policy/all[/<domain,domain>] */
|
||||||
|
public function getPolicies(?string $domains = null): array
|
||||||
|
{
|
||||||
|
$list = array_values(array_filter(array_map('trim', explode(',', (string)$domains)), 'strlen'));
|
||||||
|
return array_values(array_filter(array_map(fn(int $id): array => $this->getPolicy($id), $this->policies->visibleIds($list))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET mail-policy/<id>, also edit.php */
|
||||||
|
public function getPolicy(mixed $id): array
|
||||||
|
{
|
||||||
|
$id = $this->toId($id);
|
||||||
|
$policy = ($id === null) ? null : $this->policies->get($id);
|
||||||
|
return ($policy === null) ? [] : $this->policies->toArray($policy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET mail-policy/test/<sender>/<recipient>[/login]: would the mail be accepted? Sender "-" = null sender; [] when not allowed to ask */
|
||||||
|
public function test(string $sender, string $recipient, bool $loggedIn): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $this->policies->test(($sender === '-') ? '' : $sender, $recipient, $loggedIn);
|
||||||
|
} catch (UserError) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** functions.mailbox.inc.php: a mailbox or alias was renamed */
|
||||||
|
public function addressRenamed(string $old, string $new): void
|
||||||
|
{
|
||||||
|
$this->policies->addressRenamed($old, $new);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Answer for the Postfix policy service: DUNNO or the rejection */
|
||||||
|
public function check(string $sender, string $recipient, string $login, int $port, string $helo = ''): string
|
||||||
|
{
|
||||||
|
return $this->engine->check($sender, $recipient, $login, $port, $helo)->action();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, mixed $data, callable $work): bool
|
||||||
|
{
|
||||||
|
$log = ['mail_policy', $action, 'policy', $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), 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)), $log);
|
||||||
|
} catch (UserError $e) {
|
||||||
|
$this->response->error($e, $log);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Integer id from request input ("12", 12); null for anything else */
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\MailPolicy;
|
||||||
|
|
||||||
|
use Mailcow\Core\ApiResponse;
|
||||||
|
use Mailcow\Core\Context;
|
||||||
|
use Mailcow\MailPolicy\Controller\MailPolicyApiController;
|
||||||
|
use Mailcow\MailPolicy\Repository\LookupRepository;
|
||||||
|
use Mailcow\MailPolicy\Repository\PolicyRepository;
|
||||||
|
use Mailcow\MailPolicy\Service\PolicyEngine;
|
||||||
|
use Mailcow\MailPolicy\Service\PolicyService;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composition root of the mail policies: whom mailboxes and groups may send to and receive from, checked by
|
||||||
|
* Postfix at RCPT TO through the policy service (mailcow-policy in master.cf, data/conf/rspamd/dynmaps/mailpolicy.php).
|
||||||
|
*
|
||||||
|
* process_get_return(MailPolicyModule::fromGlobals()->api->getPolicies());
|
||||||
|
* echo MailPolicyModule::create($pdo, Context::system())->api->check($sender, $recipient, $login, $port); // policy service
|
||||||
|
*/
|
||||||
|
class MailPolicyModule
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly PolicyService $policies,
|
||||||
|
public readonly PolicyEngine $engine,
|
||||||
|
public readonly MailPolicyApiController $api
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function create(PDO $pdo, Context $context): self
|
||||||
|
{
|
||||||
|
$engine = new PolicyEngine(new LookupRepository($pdo));
|
||||||
|
$policies = new PolicyService(new PolicyRepository($pdo), $engine, $context);
|
||||||
|
return new self($policies, $engine, new MailPolicyApiController($policies, $engine, new ApiResponse()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy entry point for procedural callers: reads $pdo and $_SESSION */
|
||||||
|
public static function fromGlobals(?Context $context = null): self
|
||||||
|
{
|
||||||
|
return self::create($GLOBALS['pdo'], $context ?? Context::fromSession($_SESSION ?? []));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\MailPolicy\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of the policy check for one recipient: allowed, refused by the send or receive part of a policy, or refused
|
||||||
|
* because the recipient is written in a form the check cannot follow (routing through another address).
|
||||||
|
*/
|
||||||
|
class Decision
|
||||||
|
{
|
||||||
|
private const TEXT = [
|
||||||
|
'send' => 'Sending to this recipient is not allowed by the mail policy of the sender',
|
||||||
|
'receive' => 'This recipient does not accept mail from this sender',
|
||||||
|
'address' => 'This form of the recipient address is not allowed by the mail policy',
|
||||||
|
];
|
||||||
|
|
||||||
|
private function __construct(public readonly bool $allowed, public readonly string $side = '', public readonly ?Policy $policy = null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function allow(): self
|
||||||
|
{
|
||||||
|
return new self(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** $side send or receive; without $policy the default text is used */
|
||||||
|
public static function deny(string $side, ?Policy $policy = null): self
|
||||||
|
{
|
||||||
|
return new self(false, $side, $policy);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The recipient routes through another address (user%domain, domain!user, a second @) */
|
||||||
|
public static function routedAddress(): self
|
||||||
|
{
|
||||||
|
return new self(false, 'address');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Text of the rejection: the text of the policy or the default */
|
||||||
|
public function text(): string
|
||||||
|
{
|
||||||
|
return ($this->policy !== null && $this->policy->rejectText !== '') ? $this->policy->rejectText : self::defaultText($this->side);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function defaultText(string $side): string
|
||||||
|
{
|
||||||
|
return self::TEXT[$side] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Answer for the Postfix policy protocol */
|
||||||
|
public function action(): string
|
||||||
|
{
|
||||||
|
return $this->allowed ? 'DUNNO' : '550 5.7.1 ' . $this->text();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\MailPolicy\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A mail policy (table `mail_policy`): whom its mailboxes and groups may send to and receive from.
|
||||||
|
*
|
||||||
|
* Each direction has a mode: `all` (only the blocked entries are refused), `internal` (the own domain and its
|
||||||
|
* alias domains, plus the allowed entries) or `list` (only the allowed entries). Blocked entries are always
|
||||||
|
* refused. Entries are addresses, domains or `group:<id>`. A sender counts as internal only when it is logged in
|
||||||
|
* to this mailcow (SASL) with a mailbox of the domain, never because of its address.
|
||||||
|
*/
|
||||||
|
class Policy
|
||||||
|
{
|
||||||
|
public const MODES = ['all', 'internal', 'list'];
|
||||||
|
|
||||||
|
public ?int $id = null;
|
||||||
|
public string $domain = '';
|
||||||
|
public string $name = '';
|
||||||
|
public string $description = '';
|
||||||
|
public string $sendMode = 'all';
|
||||||
|
/** @var string[] */
|
||||||
|
public array $sendAllow = [];
|
||||||
|
/** @var string[] */
|
||||||
|
public array $sendBlock = [];
|
||||||
|
public string $receiveMode = 'all';
|
||||||
|
/** @var string[] */
|
||||||
|
public array $receiveAllow = [];
|
||||||
|
/** @var string[] */
|
||||||
|
public array $receiveBlock = [];
|
||||||
|
/** Text of the rejection, '' = default text */
|
||||||
|
public string $rejectText = '';
|
||||||
|
public int $active = 1;
|
||||||
|
/** @var string[] usernames the policy is assigned to */
|
||||||
|
public array $mailboxes = [];
|
||||||
|
/** @var int[] groups the policy is assigned to: their members and their address */
|
||||||
|
public array $groups = [];
|
||||||
|
public ?string $created = null;
|
||||||
|
public ?string $modified = null;
|
||||||
|
|
||||||
|
/** Row of `mail_policy`; assignments are set by the repository */
|
||||||
|
public static function fromRow(array $row): self
|
||||||
|
{
|
||||||
|
$policy = new self();
|
||||||
|
$policy->id = (int)$row['id'];
|
||||||
|
$policy->domain = (string)$row['domain'];
|
||||||
|
$policy->name = (string)$row['name'];
|
||||||
|
$policy->description = (string)($row['description'] ?? '');
|
||||||
|
$policy->sendMode = (string)$row['send_mode'];
|
||||||
|
$policy->sendAllow = self::list($row['send_allow'] ?? null);
|
||||||
|
$policy->sendBlock = self::list($row['send_block'] ?? null);
|
||||||
|
$policy->receiveMode = (string)$row['receive_mode'];
|
||||||
|
$policy->receiveAllow = self::list($row['receive_allow'] ?? null);
|
||||||
|
$policy->receiveBlock = self::list($row['receive_block'] ?? null);
|
||||||
|
$policy->rejectText = (string)($row['reject_text'] ?? '');
|
||||||
|
$policy->active = (int)$row['active'];
|
||||||
|
$policy->created = $row['created'] ?? null;
|
||||||
|
$policy->modified = $row['modified'] ?? null;
|
||||||
|
return $policy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Column values for `mail_policy` */
|
||||||
|
public function toRow(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'domain' => $this->domain,
|
||||||
|
'name' => $this->name,
|
||||||
|
'description' => $this->description,
|
||||||
|
'send_mode' => $this->sendMode,
|
||||||
|
'send_allow' => json_encode($this->sendAllow),
|
||||||
|
'send_block' => json_encode($this->sendBlock),
|
||||||
|
'receive_mode' => $this->receiveMode,
|
||||||
|
'receive_allow' => json_encode($this->receiveAllow),
|
||||||
|
'receive_block' => json_encode($this->receiveBlock),
|
||||||
|
'reject_text' => $this->rejectText,
|
||||||
|
'active' => $this->active,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entry of the API and the edit page */
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'domain' => $this->domain,
|
||||||
|
'name' => $this->name,
|
||||||
|
'description' => $this->description,
|
||||||
|
'send_mode' => $this->sendMode,
|
||||||
|
'send_allow' => $this->sendAllow,
|
||||||
|
'send_block' => $this->sendBlock,
|
||||||
|
'receive_mode' => $this->receiveMode,
|
||||||
|
'receive_allow' => $this->receiveAllow,
|
||||||
|
'receive_block' => $this->receiveBlock,
|
||||||
|
'reject_text' => $this->rejectText,
|
||||||
|
'active' => $this->active,
|
||||||
|
'mailboxes' => $this->mailboxes,
|
||||||
|
'groups' => $this->groups,
|
||||||
|
'created' => $this->created,
|
||||||
|
'modified' => $this->modified,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return string[] entries of a JSON list column */
|
||||||
|
private static function list(mixed $json): array
|
||||||
|
{
|
||||||
|
$list = is_string($json) ? json_decode($json, true) : null;
|
||||||
|
return is_array($list) ? array_values(array_filter($list, 'is_string')) : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\MailPolicy\Repository;
|
||||||
|
|
||||||
|
use Mailcow\Core\Repository;
|
||||||
|
use Mailcow\MailPolicy\Model\Policy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only lookups of the policy check: mailboxes, aliases, alias domains, groups and the active policies of a
|
||||||
|
* mailbox or group. Addresses are passed in lower case.
|
||||||
|
*/
|
||||||
|
class LookupRepository extends Repository
|
||||||
|
{
|
||||||
|
/** @return array{username: string, domain: string}|null a regular mailbox that is not disabled */
|
||||||
|
public function mailbox(string $username): ?array
|
||||||
|
{
|
||||||
|
$row = $this->query("SELECT `username`, `domain` FROM `mailbox` WHERE `username` = :username AND `kind` = '' AND `active` != '0'",
|
||||||
|
['username' => $username])->fetch();
|
||||||
|
return is_array($row) ? ['username' => strtolower((string)$row['username']), 'domain' => strtolower((string)$row['domain'])] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Target domain of an active alias domain */
|
||||||
|
public function aliasDomainTarget(string $domain): ?string
|
||||||
|
{
|
||||||
|
$target = $this->query("SELECT `target_domain` FROM `alias_domain` WHERE `alias_domain` = :domain AND `active` = '1'",
|
||||||
|
['domain' => $domain])->fetchColumn();
|
||||||
|
return is_string($target) ? strtolower($target) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A domain or alias domain Postfix delivers itself (as mysql_virtual_domains_maps.cf: active, no backup MX) */
|
||||||
|
public function isLocalDomain(string $domain): bool
|
||||||
|
{
|
||||||
|
return (bool)$this->query("SELECT 1 FROM `alias_domain` WHERE `alias_domain` = :d1 AND `active` = '1'
|
||||||
|
UNION SELECT 1 FROM `domain` WHERE `domain` = :d2 AND `active` = '1' AND `backupmx` = '0'",
|
||||||
|
['d1' => $domain, 'd2' => $domain])->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stored spelling of a mailbox, alias or temporary alias address. MySQL compares without case and accents, so
|
||||||
|
* Postfix delivers mail for chéf@ to chef@.
|
||||||
|
*/
|
||||||
|
public function storedAddress(string $address): ?string
|
||||||
|
{
|
||||||
|
$stored = $this->query("SELECT `username` FROM `mailbox` WHERE `username` = :a1 UNION SELECT `address` FROM `alias` WHERE `address` = :a2
|
||||||
|
UNION SELECT `address` FROM `spamalias` WHERE `address` = :a3 LIMIT 1", ['a1' => $address, 'a2' => $address, 'a3' => $address])->fetchColumn();
|
||||||
|
return is_string($stored) ? strtolower($stored) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stored spelling of a domain or alias domain, see storedAddress() */
|
||||||
|
public function storedDomain(string $domain): ?string
|
||||||
|
{
|
||||||
|
$stored = $this->query("SELECT `domain` FROM `domain` WHERE `domain` = :d1 UNION SELECT `alias_domain` FROM `alias_domain` WHERE `alias_domain` = :d2 LIMIT 1",
|
||||||
|
['d1' => $domain, 'd2' => $domain])->fetchColumn();
|
||||||
|
return is_string($stored) ? strtolower($stored) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Active group with this distribution address */
|
||||||
|
public function groupByAddress(string $address): ?int
|
||||||
|
{
|
||||||
|
$id = $this->query("SELECT `id` FROM `mailbox_group` WHERE `address` = :address AND `active` = '1'", ['address' => $address])->fetchColumn();
|
||||||
|
return ($id === false) ? null : (int)$id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function groupAddress(int $id): ?string
|
||||||
|
{
|
||||||
|
$address = $this->query("SELECT `address` FROM `mailbox_group` WHERE `id` = :id AND `active` = '1'", ['id' => $id])->fetchColumn();
|
||||||
|
return is_string($address) && $address !== '' ? strtolower($address) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Member of an active group */
|
||||||
|
public function isGroupMember(int $groupId, string $username): bool
|
||||||
|
{
|
||||||
|
return (bool)$this->query("SELECT 1 FROM `mailbox_group_member` `gm` JOIN `mailbox_group` `g` ON `g`.`id` = `gm`.`group_id`
|
||||||
|
WHERE `gm`.`group_id` = :id AND `gm`.`username` = :username AND `g`.`active` = '1'",
|
||||||
|
['id' => $groupId, 'username' => $username])->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What Postfix' virtual alias maps find for this key, in their order: an alias (also the own alias of a mailbox
|
||||||
|
* and the alias of a group address) or a temporary alias.
|
||||||
|
*
|
||||||
|
* @return array{kind: string, domain?: string, internal?: bool, owner?: string}|null
|
||||||
|
*/
|
||||||
|
public function aliasTarget(string $address): ?array
|
||||||
|
{
|
||||||
|
$alias = $this->query("SELECT `domain`, `internal` FROM `alias` WHERE `address` = :address AND `active` IN ('1', '2')",
|
||||||
|
['address' => $address])->fetch();
|
||||||
|
if (is_array($alias)) {
|
||||||
|
return ['kind' => 'alias', 'domain' => strtolower((string)$alias['domain']), 'internal' => $alias['internal'] == '1'];
|
||||||
|
}
|
||||||
|
$owner = $this->query("SELECT `goto` FROM `spamalias` WHERE `address` = :address AND (`validity` >= UNIX_TIMESTAMP() OR `permanent` != 0)",
|
||||||
|
['address' => $address])->fetchColumn();
|
||||||
|
return is_string($owner) ? ['kind' => 'temporary', 'owner' => strtolower($owner)] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An active catch-all alias of the domain has the internal flag (applies to the whole domain) */
|
||||||
|
public function internalCatchAll(string $domain): bool
|
||||||
|
{
|
||||||
|
return (bool)$this->query("SELECT 1 FROM `alias` WHERE `address` = :address AND `internal` = '1' AND `active` IN ('1', '2')",
|
||||||
|
['address' => '@' . $domain])->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An active policy with members restricts sending */
|
||||||
|
public function hasSendRules(): bool
|
||||||
|
{
|
||||||
|
return (bool)$this->query("SELECT 1 FROM `mail_policy` `p` JOIN `mail_policy_member` `pm` ON `pm`.`policy_id` = `p`.`id`
|
||||||
|
WHERE `p`.`active` = '1' AND (`p`.`send_mode` != 'all' OR `p`.`send_block` != '[]') LIMIT 1")->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Policy[] active policies of the mailbox: assigned to it or to an active group it is a member of; only
|
||||||
|
* policies of its own domain, so a group with members of other domains cannot restrict them
|
||||||
|
*/
|
||||||
|
public function policiesForMailbox(string $username): array
|
||||||
|
{
|
||||||
|
return $this->policies("SELECT DISTINCT `p`.* FROM `mail_policy` `p`
|
||||||
|
JOIN `mail_policy_member` `pm` ON `pm`.`policy_id` = `p`.`id`
|
||||||
|
JOIN `mailbox` `m` ON `m`.`username` = :u1 AND `m`.`domain` = `p`.`domain`
|
||||||
|
WHERE `p`.`active` = '1' AND (`pm`.`username` = :u2 OR `pm`.`group_id` IN (
|
||||||
|
SELECT `gm`.`group_id` FROM `mailbox_group_member` `gm` JOIN `mailbox_group` `g` ON `g`.`id` = `gm`.`group_id`
|
||||||
|
WHERE `gm`.`username` = :u3 AND `g`.`active` = '1'))
|
||||||
|
ORDER BY `p`.`id`", ['u1' => $username, 'u2' => $username, 'u3' => $username]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Policy[] active policies assigned to the group */
|
||||||
|
public function policiesForGroup(int $groupId): array
|
||||||
|
{
|
||||||
|
return $this->policies("SELECT DISTINCT `p`.* FROM `mail_policy` `p`
|
||||||
|
JOIN `mail_policy_member` `pm` ON `pm`.`policy_id` = `p`.`id`
|
||||||
|
WHERE `p`.`active` = '1' AND `pm`.`group_id` = :id
|
||||||
|
ORDER BY `p`.`id`", ['id' => $groupId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Policy[] */
|
||||||
|
private function policies(string $sql, array $params): array
|
||||||
|
{
|
||||||
|
return array_map(fn(array $row): Policy => Policy::fromRow($row), $this->query($sql, $params)->fetchAll());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\MailPolicy\Repository;
|
||||||
|
|
||||||
|
use Mailcow\Core\Context;
|
||||||
|
use Mailcow\Core\Repository;
|
||||||
|
use Mailcow\MailPolicy\Model\Policy;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tables `mail_policy` and `mail_policy_member` (the mailboxes and groups a policy is assigned to), plus the
|
||||||
|
* lookups the editing needs. Assignments go with their mailbox, group or domain (foreign keys, ON DELETE CASCADE).
|
||||||
|
*/
|
||||||
|
class PolicyRepository extends Repository
|
||||||
|
{
|
||||||
|
public function find(int $id): ?Policy
|
||||||
|
{
|
||||||
|
$row = $this->query("SELECT * FROM `mail_policy` WHERE `id` = :id", ['id' => $id])->fetch();
|
||||||
|
return is_array($row) ? $this->withMembers(Policy::fromRow($row)) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $domains limit to these domains, [] = all visible
|
||||||
|
* @return int[] ids of the policies visible to the caller, ordered by domain and name
|
||||||
|
*/
|
||||||
|
public function visibleIds(Context $context, array $domains = []): array
|
||||||
|
{
|
||||||
|
if ($context->hasAdminRights()) {
|
||||||
|
[$visible, $params] = ['1=1', []];
|
||||||
|
} elseif ($context->isDomainAdmin()) {
|
||||||
|
[$visible, $params] = ["`domain` IN (SELECT `domain` FROM `domain_admins` WHERE `active` = '1' AND `username` = :vis_da)",
|
||||||
|
['vis_da' => $context->username]];
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$sql = "SELECT `id` FROM `mail_policy` WHERE $visible";
|
||||||
|
if (!empty($domains)) {
|
||||||
|
$names = [];
|
||||||
|
foreach (array_values($domains) as $i => $domain) {
|
||||||
|
$names[] = ':dom' . $i;
|
||||||
|
$params['dom' . $i] = $domain;
|
||||||
|
}
|
||||||
|
$sql .= " AND `domain` IN (" . implode(', ', $names) . ")";
|
||||||
|
}
|
||||||
|
return array_map('intval', $this->query($sql . " ORDER BY `domain`, `name`", $params)->fetchAll(PDO::FETCH_COLUMN));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function nameExists(string $domain, string $name, ?int $exceptId = null): bool
|
||||||
|
{
|
||||||
|
return (bool)$this->query("SELECT 1 FROM `mail_policy` WHERE `domain` = :domain AND `name` = :name AND `id` != :id",
|
||||||
|
['domain' => $domain, 'name' => $name, 'id' => $exceptId ?? 0])->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inserts or updates the policy with its assignments; returns its id */
|
||||||
|
public function save(Policy $policy): int
|
||||||
|
{
|
||||||
|
return $this->transaction(function () use ($policy): int {
|
||||||
|
if ($policy->id === null) {
|
||||||
|
$policy->id = $this->insertRow('mail_policy', $policy->toRow());
|
||||||
|
} else {
|
||||||
|
$this->updateRow('mail_policy', $policy->id, $policy->toRow());
|
||||||
|
$this->query("DELETE FROM `mail_policy_member` WHERE `policy_id` = :id", ['id' => $policy->id]);
|
||||||
|
}
|
||||||
|
foreach ($policy->mailboxes as $username) {
|
||||||
|
$this->insertRow('mail_policy_member', ['policy_id' => $policy->id, 'username' => $username]);
|
||||||
|
}
|
||||||
|
foreach ($policy->groups as $groupId) {
|
||||||
|
$this->insertRow('mail_policy_member', ['policy_id' => $policy->id, 'group_id' => $groupId]);
|
||||||
|
}
|
||||||
|
return $policy->id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(int $id): void
|
||||||
|
{
|
||||||
|
$this->query("DELETE FROM `mail_policy` WHERE `id` = :id", ['id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Address entries of all lists follow a renamed mailbox or alias */
|
||||||
|
public function renameAddress(string $old, string $new): void
|
||||||
|
{
|
||||||
|
$this->transaction(function () use ($old, $new): void {
|
||||||
|
$columns = ['send_allow', 'send_block', 'receive_allow', 'receive_block'];
|
||||||
|
foreach ($this->query("SELECT `id`, `" . implode('`, `', $columns) . "` FROM `mail_policy`")->fetchAll() as $row) {
|
||||||
|
$changed = [];
|
||||||
|
foreach ($columns as $column) {
|
||||||
|
$entries = json_decode((string)$row[$column], true);
|
||||||
|
if (is_array($entries) && in_array($old, $entries, true)) {
|
||||||
|
$changed[$column] = json_encode(array_values(array_unique(array_map(fn(string $entry): string => ($entry === $old) ? $new : $entry, $entries))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($changed !== []) {
|
||||||
|
$this->updateRow('mail_policy', (int)$row['id'], $changed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A primary domain (policies never belong to alias domains) */
|
||||||
|
public function isDomain(string $domain): bool
|
||||||
|
{
|
||||||
|
return (bool)$this->query("SELECT 1 FROM `domain` WHERE `domain` = :domain", ['domain' => $domain])->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Domain of a regular mailbox, null if there is none */
|
||||||
|
public function mailboxDomain(string $username): ?string
|
||||||
|
{
|
||||||
|
$domain = $this->query("SELECT `domain` FROM `mailbox` WHERE `username` = :username AND `kind` = ''", ['username' => $username])->fetchColumn();
|
||||||
|
return is_string($domain) ? $domain : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function groupDomain(int $id): ?string
|
||||||
|
{
|
||||||
|
$domain = $this->query("SELECT `domain` FROM `mailbox_group` WHERE `id` = :id", ['id' => $id])->fetchColumn();
|
||||||
|
return is_string($domain) ? $domain : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array[] id, name, domain, address of the groups in these domains (choices of the forms) */
|
||||||
|
public function groupsIn(array $domains): array
|
||||||
|
{
|
||||||
|
if (empty($domains)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$names = [];
|
||||||
|
$params = [];
|
||||||
|
foreach (array_values($domains) as $i => $domain) {
|
||||||
|
$names[] = ':dom' . $i;
|
||||||
|
$params['dom' . $i] = $domain;
|
||||||
|
}
|
||||||
|
return $this->query("SELECT `id`, `name`, `domain`, `address` FROM `mailbox_group` WHERE `domain` IN (" . implode(', ', $names) . ")
|
||||||
|
ORDER BY `domain`, `name`", $params)->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function withMembers(Policy $policy): Policy
|
||||||
|
{
|
||||||
|
foreach ($this->query("SELECT `username`, `group_id` FROM `mail_policy_member` WHERE `policy_id` = :id ORDER BY `username`, `group_id`",
|
||||||
|
['id' => $policy->id])->fetchAll() as $row) {
|
||||||
|
if ($row['username'] !== null) {
|
||||||
|
$policy->mailboxes[] = (string)$row['username'];
|
||||||
|
} elseif ($row['group_id'] !== null) {
|
||||||
|
$policy->groups[] = (int)$row['group_id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $policy;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\MailPolicy\Service;
|
||||||
|
|
||||||
|
use Mailcow\MailPolicy\Model\Decision;
|
||||||
|
use Mailcow\MailPolicy\Model\Policy;
|
||||||
|
use Mailcow\MailPolicy\Repository\LookupRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Policy check of one recipient, asked by Postfix at RCPT TO (data/conf/rspamd/dynmaps/mailpolicy.php).
|
||||||
|
*
|
||||||
|
* Send part: the policies of the logged-in mailbox (assigned to it or to its groups) and of the mailbox the
|
||||||
|
* sender address belongs to. Receive part: what Postfix delivers the recipient to, looked up in the order of its
|
||||||
|
* virtual alias maps (address, address without +tag, mailbox of an alias domain): the policies of the mailbox, of
|
||||||
|
* the group address or of the owner of a temporary alias, and the `internal` flag of the alias (a catch-all alias
|
||||||
|
* with the flag covers its whole domain). Mail sent to an alias or group reaches its members without their own
|
||||||
|
* policies. Every policy must allow the mail.
|
||||||
|
*
|
||||||
|
* Internal: a recipient in the domain of the policy or one of its alias domains; a sender logged in with a
|
||||||
|
* mailbox of that domain. Entries of a local domain or address count for logged-in senders only, so they cannot
|
||||||
|
* be forged; block entries also match the envelope sender. External entries are compared with the envelope sender.
|
||||||
|
* Addresses are compared the way Postfix finds them: lower case, without trailing dot, in their stored spelling
|
||||||
|
* (MySQL ignores accents). Recipients routed through localhost (user%domain@localhost, domain!user@localhost,
|
||||||
|
* user@domain@localhost) are refused, Postfix would deliver them to the routed address.
|
||||||
|
*
|
||||||
|
* Dovecot submits Sieve redirects, vacation replies and notifications on port 588 without login, SOGo sends
|
||||||
|
* reminders with a service login: mail with the address of a mailbox counts as sent by that mailbox. Other mail of
|
||||||
|
* these services (bounces redirected with the null sender, Sieve rejects) has no known sender: it is checked like
|
||||||
|
* mail from outside and does not leave the mailcow while any policy restricts sending. Quota warnings pass.
|
||||||
|
*/
|
||||||
|
class PolicyEngine
|
||||||
|
{
|
||||||
|
/** Ports of mailcow's own deliveries: quarantine release (590), BCC and archive copies (591) */
|
||||||
|
private const INTERNAL_PORTS = [590, 591];
|
||||||
|
/** Submission of SOGo and Dovecot, without login reachable from mailcow's own network only */
|
||||||
|
private const SERVICE_PORT = 588;
|
||||||
|
/** Domain of the Dovecot master login of mailcow's services (SOGo reminders) */
|
||||||
|
private const SERVICE_LOGIN_DOMAIN = 'mailcow.local';
|
||||||
|
/** HELO of the quota warning copies of Dovecot (quota_notify.py), configured by admins */
|
||||||
|
private const QUOTA_HELO = 'quotanotification';
|
||||||
|
/** Recipient without domain every server accepts (RFC 5321) */
|
||||||
|
private const POSTMASTER = 'postmaster';
|
||||||
|
/** Postfix delivers these destinations itself (mydestination), user%domain and domain!user route from there */
|
||||||
|
private const LOCAL_DESTINATIONS = ['localhost', 'localhost.localdomain'];
|
||||||
|
|
||||||
|
/** Lookups of the current check */
|
||||||
|
private array $cache = [];
|
||||||
|
|
||||||
|
public function __construct(private LookupRepository $lookup)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/** $login: SASL username, '' if not logged in; $port: smtpd port of the request, 0 if unknown; $helo: HELO name */
|
||||||
|
public function check(string $sender, string $recipient, string $login, int $port = 0, string $helo = ''): Decision
|
||||||
|
{
|
||||||
|
$this->cache = [];
|
||||||
|
$recipient = self::address($recipient);
|
||||||
|
if (in_array($port, self::INTERNAL_PORTS, true) || $recipient === '' || $recipient === self::POSTMASTER) {
|
||||||
|
return Decision::allow();
|
||||||
|
}
|
||||||
|
if (self::isRouted($recipient)) {
|
||||||
|
return Decision::routedAddress();
|
||||||
|
}
|
||||||
|
$recipient = $this->stored($recipient);
|
||||||
|
$sender = $this->stored(self::address($sender));
|
||||||
|
$login = self::address($login);
|
||||||
|
$serviceLogin = self::domainOf($login) === self::SERVICE_LOGIN_DOMAIN;
|
||||||
|
$account = null;
|
||||||
|
if ($login !== '' && !$serviceLogin) {
|
||||||
|
$account = $this->cached('mailbox', $login);
|
||||||
|
} elseif ($serviceLogin || $port === self::SERVICE_PORT) {
|
||||||
|
if ($port === self::SERVICE_PORT && !$serviceLogin && strtolower($helo) === self::QUOTA_HELO) {
|
||||||
|
return Decision::allow();
|
||||||
|
}
|
||||||
|
// without a mailbox sender the sending mailbox is unknown
|
||||||
|
$account = $this->senderMailbox($sender);
|
||||||
|
if ($account === null && !$this->cached('isLocalDomain', self::domainOf($recipient)) && $this->lookup->hasSendRules()) {
|
||||||
|
return Decision::deny('send');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($this->sendingPolicies($account, $sender) as $policy) {
|
||||||
|
if (!$this->allows($policy->sendMode, $policy->sendAllow, $policy->sendBlock, $this->isInternalRecipient($recipient, $policy->domain),
|
||||||
|
fn(string $entry): bool => $this->recipientMatches($entry, $recipient))) {
|
||||||
|
return Decision::deny('send', $policy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($this->receivingPolicies($recipient) as $policy) {
|
||||||
|
if (!$this->allows($policy->receiveMode, $policy->receiveAllow, $policy->receiveBlock,
|
||||||
|
$account !== null && $account['domain'] === $policy->domain,
|
||||||
|
fn(string $entry, bool $block): bool => $this->senderMatches($entry, $sender, $account, $block))) {
|
||||||
|
return Decision::deny('receive', $policy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Decision::allow();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Blocked entries first; then the mode decides. $matches($entry, $block) */
|
||||||
|
private function allows(string $mode, array $allow, array $block, bool $internal, callable $matches): bool
|
||||||
|
{
|
||||||
|
foreach ($block as $entry) {
|
||||||
|
if ($matches($entry, true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return match ($mode) {
|
||||||
|
'internal' => $internal || $this->any($allow, $matches),
|
||||||
|
'list' => $this->any($allow, $matches),
|
||||||
|
default => true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function any(array $entries, callable $matches): bool
|
||||||
|
{
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if ($matches($entry, false)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{username: string, domain: string}|null mailbox of the sender address */
|
||||||
|
private function senderMailbox(string $sender): ?array
|
||||||
|
{
|
||||||
|
return str_contains($sender, '@') ? $this->cached('mailbox', $this->resolve(self::untagged($sender))) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Policy[] of the logged-in mailbox and of the mailbox it sends as */
|
||||||
|
private function sendingPolicies(?array $account, string $sender): array
|
||||||
|
{
|
||||||
|
if ($account === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$policies = $this->lookup->policiesForMailbox($account['username']);
|
||||||
|
$sentAs = $this->senderMailbox($sender);
|
||||||
|
if ($sentAs !== null && $sentAs['username'] !== $account['username']) {
|
||||||
|
$policies = array_merge($policies, $this->lookup->policiesForMailbox($sentAs['username']));
|
||||||
|
}
|
||||||
|
return self::unique($policies);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Policy[] of what Postfix delivers the recipient to, see the class comment */
|
||||||
|
private function receivingPolicies(string $recipient): array
|
||||||
|
{
|
||||||
|
// the address, then without +tag; an alias domain maps to the mailbox in its target domain and Postfix
|
||||||
|
// looks up the result again with the +tag
|
||||||
|
[$address, $target] = $this->aliasTarget([$recipient, self::untagged($recipient)]);
|
||||||
|
$resolved = $this->resolve($recipient);
|
||||||
|
if ($target === null && $resolved !== $recipient) {
|
||||||
|
[$address, $target] = $this->aliasTarget([$resolved, self::untagged($resolved)]);
|
||||||
|
}
|
||||||
|
$address ??= self::untagged($resolved);
|
||||||
|
if (($target['kind'] ?? '') === 'temporary') {
|
||||||
|
$address = $target['owner'];
|
||||||
|
}
|
||||||
|
$policies = ($this->cached('mailbox', $address) !== null) ? $this->lookup->policiesForMailbox($address) : [];
|
||||||
|
$group = $this->lookup->groupByAddress($address);
|
||||||
|
if ($group !== null) {
|
||||||
|
$policies = array_merge($policies, $this->lookup->policiesForGroup($group));
|
||||||
|
}
|
||||||
|
// the `internal` flag of an alias or group: only senders of its domain
|
||||||
|
if (!empty($target['internal'])) {
|
||||||
|
$policies[] = $this->internalRule($target['domain']);
|
||||||
|
}
|
||||||
|
foreach (array_unique([self::domainOf($recipient), $this->resolveDomain(self::domainOf($recipient))]) as $domain) {
|
||||||
|
if ($this->lookup->internalCatchAll($domain)) {
|
||||||
|
$policies[] = $this->internalRule($domain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return self::unique($policies);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{0: ?string, 1: ?array} the first key Postfix' virtual alias maps find, with what they find */
|
||||||
|
private function aliasTarget(array $keys): array
|
||||||
|
{
|
||||||
|
foreach (array_unique($keys) as $key) {
|
||||||
|
$target = $this->lookup->aliasTarget($key);
|
||||||
|
if ($target !== null) {
|
||||||
|
return [$key, $target];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [null, null];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Receive rule of the `internal` flag */
|
||||||
|
private function internalRule(string $domain): Policy
|
||||||
|
{
|
||||||
|
$policy = new Policy();
|
||||||
|
$policy->domain = $this->resolveDomain($domain);
|
||||||
|
$policy->receiveMode = 'internal';
|
||||||
|
return $policy;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isInternalRecipient(string $recipient, string $domain): bool
|
||||||
|
{
|
||||||
|
return $this->resolveDomain(self::domainOf($recipient)) === $domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recipientMatches(string $entry, string $recipient): bool
|
||||||
|
{
|
||||||
|
$address = $this->resolve(self::untagged($recipient));
|
||||||
|
if (str_starts_with($entry, 'group:')) {
|
||||||
|
$id = (int)substr($entry, 6);
|
||||||
|
return $this->cached('groupAddress', $id) === $address
|
||||||
|
|| ($this->cached('mailbox', $address) !== null && $this->cached('isGroupMember', $id, $address));
|
||||||
|
}
|
||||||
|
if (str_contains($entry, '@')) {
|
||||||
|
return $this->resolve($entry) === $address;
|
||||||
|
}
|
||||||
|
return $this->resolveDomain(self::domainOf($address)) === $this->resolveDomain($entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** $block: a block entry, which also matches the envelope sender (refusing a forged sender is no risk) */
|
||||||
|
private function senderMatches(string $entry, string $sender, ?array $account, bool $block): bool
|
||||||
|
{
|
||||||
|
$address = $this->resolve(self::untagged($sender));
|
||||||
|
// the sender address of a local entry only counts when logged in: Postfix checked that the login may use it
|
||||||
|
$useAddress = $account !== null || $block;
|
||||||
|
if (str_starts_with($entry, 'group:')) {
|
||||||
|
$id = (int)substr($entry, 6);
|
||||||
|
return ($account !== null && $this->cached('isGroupMember', $id, $account['username']))
|
||||||
|
|| ($useAddress && ($this->cached('groupAddress', $id) === $address
|
||||||
|
|| ($this->cached('mailbox', $address) !== null && $this->cached('isGroupMember', $id, $address))));
|
||||||
|
}
|
||||||
|
if (str_contains($entry, '@')) {
|
||||||
|
return $this->cached('isLocalDomain', self::domainOf($entry))
|
||||||
|
? ($account !== null && $account['username'] === $this->resolve($entry)) || ($useAddress && $address === $this->resolve($entry))
|
||||||
|
: self::untagged($sender) === $entry;
|
||||||
|
}
|
||||||
|
return $this->cached('isLocalDomain', $entry)
|
||||||
|
? ($account !== null && $account['domain'] === $this->resolveDomain($entry))
|
||||||
|
|| ($useAddress && $this->resolveDomain(self::domainOf($address)) === $this->resolveDomain($entry))
|
||||||
|
: self::domainOf($sender) === $entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stored spelling of an address of this mailcow: Postfix finds chéf@ as chef@ (MySQL ignores accents). Only
|
||||||
|
* addresses with non-ASCII characters can differ, as they are already in lower case.
|
||||||
|
*/
|
||||||
|
private function stored(string $address): string
|
||||||
|
{
|
||||||
|
if (!preg_match('/[\x80-\xff]/', $address) || !str_contains($address, '@')) {
|
||||||
|
return $address;
|
||||||
|
}
|
||||||
|
foreach (array_unique([$address, self::untagged($address)]) as $key) {
|
||||||
|
$stored = $this->cached('storedAddress', $key);
|
||||||
|
if ($stored !== null) {
|
||||||
|
return $stored;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$domain = $this->cached('storedDomain', self::domainOf($address));
|
||||||
|
return ($domain === null) ? $address : substr($address, 0, strrpos($address, '@') + 1) . $domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Address in an alias domain as the address in its target domain */
|
||||||
|
private function resolve(string $address): string
|
||||||
|
{
|
||||||
|
$domain = self::domainOf($address);
|
||||||
|
$target = ($domain !== '') ? $this->cached('aliasDomainTarget', $domain) : null;
|
||||||
|
return ($target === null) ? $address : substr($address, 0, strrpos($address, '@') + 1) . $target;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveDomain(string $domain): string
|
||||||
|
{
|
||||||
|
return ($domain === '') ? '' : ($this->cached('aliasDomainTarget', $domain) ?? $domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A lookup, done once per check: long lists ask the same domains and groups again */
|
||||||
|
private function cached(string $method, int|string ...$args): mixed
|
||||||
|
{
|
||||||
|
$key = $method . "\0" . implode("\0", $args);
|
||||||
|
if (!array_key_exists($key, $this->cache)) {
|
||||||
|
$this->cache[$key] = $this->lookup->$method(...$args);
|
||||||
|
}
|
||||||
|
return $this->cache[$key];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param Policy[] $policies @return Policy[] without repeated policies */
|
||||||
|
private static function unique(array $policies): array
|
||||||
|
{
|
||||||
|
$unique = [];
|
||||||
|
foreach ($policies as $policy) {
|
||||||
|
$unique[$policy->id ?? 'internal:' . $policy->domain] = $policy;
|
||||||
|
}
|
||||||
|
return array_values($unique);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lower case, without angle brackets, quotes and trailing dot of the domain, as Postfix resolves it; '' = null sender */
|
||||||
|
private static function address(string $value): string
|
||||||
|
{
|
||||||
|
$value = strtolower(trim($value, " \t\r\n<>"));
|
||||||
|
$at = strrpos($value, '@');
|
||||||
|
return ($at === false) ? $value : str_replace('"', '', substr($value, 0, $at)) . '@' . rtrim(substr($value, $at + 1), '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Postfix would deliver the recipient elsewhere: no domain, or routing through a local destination
|
||||||
|
* (localhost, the IP of the server) with %, ! or a second @ in the local part
|
||||||
|
*/
|
||||||
|
private static function isRouted(string $address): bool
|
||||||
|
{
|
||||||
|
$at = strrpos($address, '@');
|
||||||
|
if ($at === false || $at === 0 || $at === strlen($address) - 1) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$domain = substr($address, $at + 1);
|
||||||
|
return (in_array($domain, self::LOCAL_DESTINATIONS, true) || str_starts_with($domain, '['))
|
||||||
|
&& strpbrk(substr($address, 0, $at), '@%!') !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function untagged(string $address): string
|
||||||
|
{
|
||||||
|
$at = strrpos($address, '@');
|
||||||
|
$pos = ($at === false) ? false : strpos(substr($address, 0, $at), '+');
|
||||||
|
return ($pos === false || $pos === 0) ? $address : substr($address, 0, $pos) . substr($address, $at);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function domainOf(string $address): string
|
||||||
|
{
|
||||||
|
$at = strrpos($address, '@');
|
||||||
|
return ($at === false) ? '' : substr($address, $at + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\MailPolicy\Service;
|
||||||
|
|
||||||
|
use Mailcow\Core\AccessDenied;
|
||||||
|
use Mailcow\Core\Context;
|
||||||
|
use Mailcow\Core\Input;
|
||||||
|
use Mailcow\Core\UserError;
|
||||||
|
use Mailcow\MailPolicy\Model\Decision;
|
||||||
|
use Mailcow\MailPolicy\Model\Policy;
|
||||||
|
use Mailcow\MailPolicy\Repository\PolicyRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use cases of mail policies.
|
||||||
|
*
|
||||||
|
* Read: admins all policies (also read-only API keys); domain admins with the `mail_policies` ACL the policies
|
||||||
|
* of their domains.
|
||||||
|
* Edit: admins; domain admins with the ACL for policies of their domains. A policy is assigned to mailboxes and
|
||||||
|
* groups of its own domain; `group:<id>` entries name groups of that domain. Groups need the `mailbox_groups`
|
||||||
|
* ACL as well: without it a domain admin sees no groups and keeps the group entries and assignments.
|
||||||
|
*/
|
||||||
|
class PolicyService
|
||||||
|
{
|
||||||
|
private const MAX_ENTRIES = 500;
|
||||||
|
/** The reject text goes into the SMTP reply: printable ASCII */
|
||||||
|
private const MAX_REJECT_TEXT = 200;
|
||||||
|
private const LISTS = ['send_allow' => 'sendAllow', 'send_block' => 'sendBlock', 'receive_allow' => 'receiveAllow', 'receive_block' => 'receiveBlock'];
|
||||||
|
|
||||||
|
public function __construct(private PolicyRepository $repo, private PolicyEngine $engine, private Context $context)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canRead(): bool
|
||||||
|
{
|
||||||
|
return $this->context->hasAdminRights()
|
||||||
|
|| ($this->context->isDomainAdmin() && $this->context->hasAcl('mail_policies'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws AccessDenied unless the caller may edit policies at all */
|
||||||
|
public function requireEditor(): void
|
||||||
|
{
|
||||||
|
if (!$this->canRead() || $this->context->isReadOnlyApi()) {
|
||||||
|
throw new AccessDenied();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(int $id): ?Policy
|
||||||
|
{
|
||||||
|
$policy = $this->canRead() ? $this->repo->find($id) : null;
|
||||||
|
return ($policy !== null && $this->visible($policy)) ? $policy : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return int[] */
|
||||||
|
public function visibleIds(array $domains = []): array
|
||||||
|
{
|
||||||
|
return $this->canRead() ? $this->repo->visibleIds($this->context, $domains) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canEdit(Policy $policy): bool
|
||||||
|
{
|
||||||
|
return $this->canRead() && !$this->context->isReadOnlyApi() && $this->visible($policy);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toArray(Policy $policy): array
|
||||||
|
{
|
||||||
|
return $policy->toArray() + ['can_edit' => (int)$this->canEdit($policy)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws UserError */
|
||||||
|
public function create(array $input): Policy
|
||||||
|
{
|
||||||
|
$this->requireEditor();
|
||||||
|
$policy = new Policy();
|
||||||
|
$policy->domain = $this->domainFor($input['domain'] ?? null);
|
||||||
|
$this->apply($policy, $input);
|
||||||
|
$this->repo->save($policy);
|
||||||
|
return $policy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attributes missing in $input keep their value @throws UserError */
|
||||||
|
public function update(int $id, array $input): Policy
|
||||||
|
{
|
||||||
|
$policy = $this->editable($id);
|
||||||
|
$this->apply($policy, $input);
|
||||||
|
$this->repo->save($policy);
|
||||||
|
return $policy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws UserError */
|
||||||
|
public function delete(int $id): Policy
|
||||||
|
{
|
||||||
|
$policy = $this->editable($id);
|
||||||
|
$this->repo->delete($id);
|
||||||
|
return $policy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Address entries of the lists follow a renamed mailbox or alias; called after the rename, without permission check */
|
||||||
|
public function addressRenamed(string $old, string $new): void
|
||||||
|
{
|
||||||
|
$this->repo->renameAddress(strtolower($old), strtolower($new));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array[] id, name, domain, address of the groups in these domains (choices of the forms) */
|
||||||
|
public function groupChoices(array $domains): array
|
||||||
|
{
|
||||||
|
return ($this->canRead() && $this->canUseGroups()) ? $this->repo->groupsIn($domains) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canUseGroups(): bool
|
||||||
|
{
|
||||||
|
return $this->context->hasAdminRights() || $this->context->hasAcl('mailbox_groups');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* "Test a rule": would the mail be accepted? A sender that is a mailbox counts as logged in when $loggedIn.
|
||||||
|
*
|
||||||
|
* @return array{allowed: int, side: string, policy: ?string, internal_alias: int, text: string}
|
||||||
|
* @throws UserError
|
||||||
|
*/
|
||||||
|
public function test(mixed $sender, mixed $recipient, bool $loggedIn): array
|
||||||
|
{
|
||||||
|
if (!$this->canRead()) {
|
||||||
|
throw new AccessDenied();
|
||||||
|
}
|
||||||
|
$sender = strtolower((string)Input::text($sender));
|
||||||
|
$recipient = strtolower((string)Input::text($recipient));
|
||||||
|
if (($sender !== '' && !filter_var($sender, FILTER_VALIDATE_EMAIL)) || !filter_var($recipient, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
throw new UserError('mail_policy_test_invalid');
|
||||||
|
}
|
||||||
|
// domain admins test mail of their domains; a sender of another domain counts as not logged in
|
||||||
|
$ownSender = $this->context->hasAdminRights() || $this->context->canAccessDomain(self::domainOf($sender));
|
||||||
|
if (!$ownSender && !$this->context->canAccessDomain(self::domainOf($recipient))) {
|
||||||
|
throw new AccessDenied();
|
||||||
|
}
|
||||||
|
$login = ($loggedIn && $ownSender && $this->repo->mailboxDomain($sender) !== null) ? $sender : '';
|
||||||
|
$decision = $this->engine->check($sender, $recipient, $login);
|
||||||
|
$policy = $decision->policy;
|
||||||
|
$hidden = $policy !== null && $policy->id !== null && !$this->visible($policy);
|
||||||
|
return [
|
||||||
|
'allowed' => (int)$decision->allowed,
|
||||||
|
'side' => $decision->side,
|
||||||
|
'policy' => ($policy !== null && $policy->id !== null && !$hidden) ? $policy->name : null,
|
||||||
|
'internal_alias' => (int)($policy !== null && $policy->id === null),
|
||||||
|
'logged_in' => (int)($login !== ''),
|
||||||
|
// the text of a policy of another domain stays hidden
|
||||||
|
'text' => $decision->allowed ? '' : ($hidden ? Decision::defaultText($decision->side) : $decision->text()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function visible(Policy $policy): bool
|
||||||
|
{
|
||||||
|
return $this->context->hasAdminRights() || $this->context->canAccessDomain($policy->domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws UserError */
|
||||||
|
private function editable(int $id): Policy
|
||||||
|
{
|
||||||
|
$this->requireEditor();
|
||||||
|
$policy = $this->get($id);
|
||||||
|
if ($policy === null || !$this->canEdit($policy)) {
|
||||||
|
throw new AccessDenied();
|
||||||
|
}
|
||||||
|
return $policy;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws UserError */
|
||||||
|
private function domainFor(mixed $value): string
|
||||||
|
{
|
||||||
|
$domain = strtolower((string)Input::text($value));
|
||||||
|
if ($domain === '' || !$this->repo->isDomain($domain)) {
|
||||||
|
throw new UserError('domain_not_found', [htmlspecialchars((string)Input::text($value))]);
|
||||||
|
}
|
||||||
|
if (!$this->context->hasAdminRights() && !$this->context->canAccessDomain($domain)) {
|
||||||
|
throw new AccessDenied();
|
||||||
|
}
|
||||||
|
return $domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws UserError */
|
||||||
|
private function apply(Policy $policy, array $input): void
|
||||||
|
{
|
||||||
|
if (array_key_exists('name', $input) || $policy->id === null) {
|
||||||
|
$name = Input::text($input['name'] ?? null);
|
||||||
|
if ($name === null || $name === '' || mb_strlen($name) > 100 || preg_match('/[\x00-\x1f\x7f]/', $name)) {
|
||||||
|
throw new UserError('mail_policy_name_invalid');
|
||||||
|
}
|
||||||
|
if ($this->repo->nameExists($policy->domain, $name, $policy->id)) {
|
||||||
|
throw new UserError('object_exists', [htmlspecialchars($name)]);
|
||||||
|
}
|
||||||
|
$policy->name = $name;
|
||||||
|
}
|
||||||
|
if (array_key_exists('description', $input)) {
|
||||||
|
$description = Input::text($input['description']);
|
||||||
|
if ($description === null || mb_strlen($description) > 255 || preg_match('/[\x00-\x1f\x7f]/', $description)) {
|
||||||
|
throw new UserError('mail_policy_description_invalid');
|
||||||
|
}
|
||||||
|
$policy->description = $description;
|
||||||
|
}
|
||||||
|
foreach (['send_mode' => 'sendMode', 'receive_mode' => 'receiveMode'] as $key => $property) {
|
||||||
|
if (array_key_exists($key, $input)) {
|
||||||
|
$mode = Input::text($input[$key]);
|
||||||
|
$policy->$property = in_array($mode, Policy::MODES, true) ? $mode : throw new UserError('mail_policy_mode_invalid');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$groupAccess = $this->canUseGroups();
|
||||||
|
foreach (self::LISTS as $key => $property) {
|
||||||
|
// the UI sends the groups of a list separately (<list>_groups); the API may name them as group:<id>
|
||||||
|
if (!array_key_exists($key, $input) && !array_key_exists($key . '_groups', $input)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$entries = array_key_exists($key, $input) ? self::values($input[$key]) : self::withoutGroups($policy->$property);
|
||||||
|
$kept = [];
|
||||||
|
if (!$groupAccess) {
|
||||||
|
// without access to groups the group entries stay (also when sent back unchanged); the form shows no group choices
|
||||||
|
$kept = array_values(array_diff($policy->$property, self::withoutGroups($policy->$property)));
|
||||||
|
$entries = array_values(array_diff(array_map('strtolower', $entries), $kept));
|
||||||
|
} elseif (array_key_exists($key . '_groups', $input)) {
|
||||||
|
$entries = array_merge(self::withoutGroups($entries), array_map(fn(string $id): string => 'group:' . $id, self::values($input[$key . '_groups'])));
|
||||||
|
}
|
||||||
|
$entries = array_values(array_unique(array_merge($this->entries($entries, $policy->domain, $groupAccess), $kept)));
|
||||||
|
if (count($entries) > self::MAX_ENTRIES) {
|
||||||
|
throw new UserError('mail_policy_entries_exceeded', [self::MAX_ENTRIES]);
|
||||||
|
}
|
||||||
|
$policy->$property = $entries;
|
||||||
|
}
|
||||||
|
if (array_key_exists('reject_text', $input)) {
|
||||||
|
$text = Input::text($input['reject_text']);
|
||||||
|
if ($text === null || strlen($text) > self::MAX_REJECT_TEXT || preg_match('/[^\x20-\x7e]/', $text)) {
|
||||||
|
throw new UserError('mail_policy_reject_text_invalid');
|
||||||
|
}
|
||||||
|
$policy->rejectText = $text;
|
||||||
|
}
|
||||||
|
if (array_key_exists('active', $input)) {
|
||||||
|
$policy->active = (int)self::flag($input['active']);
|
||||||
|
}
|
||||||
|
if (array_key_exists('mailboxes', $input)) {
|
||||||
|
$policy->mailboxes = [];
|
||||||
|
foreach (self::limited($input['mailboxes']) as $username) {
|
||||||
|
$username = strtolower($username);
|
||||||
|
if ($this->repo->mailboxDomain($username) !== $policy->domain) {
|
||||||
|
throw new UserError('mail_policy_member_invalid', [htmlspecialchars($username)]);
|
||||||
|
}
|
||||||
|
$policy->mailboxes[$username] = $username;
|
||||||
|
}
|
||||||
|
$policy->mailboxes = array_values($policy->mailboxes);
|
||||||
|
}
|
||||||
|
// without access to groups the assignments stay; only sending them back unchanged is accepted
|
||||||
|
if (array_key_exists('groups', $input) && !$groupAccess && self::values($input['groups']) !== []) {
|
||||||
|
$sent = array_map('intval', self::values($input['groups']));
|
||||||
|
$stored = $policy->groups;
|
||||||
|
sort($sent);
|
||||||
|
sort($stored);
|
||||||
|
if (array_values(array_unique($sent)) !== $stored) {
|
||||||
|
throw new AccessDenied();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (array_key_exists('groups', $input) && $groupAccess) {
|
||||||
|
$policy->groups = [];
|
||||||
|
foreach (self::limited($input['groups']) as $value) {
|
||||||
|
$id = Input::intInRange($value, 1, PHP_INT_MAX);
|
||||||
|
if ($id === null || $this->repo->groupDomain($id) !== $policy->domain) {
|
||||||
|
throw new UserError('mail_policy_member_invalid', [htmlspecialchars($value)]);
|
||||||
|
}
|
||||||
|
$policy->groups[$id] = $id;
|
||||||
|
}
|
||||||
|
$policy->groups = array_values($policy->groups);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entries of a list: addresses, domains (a leading @ is dropped) and group:<id> of a group of the policy's
|
||||||
|
* domain, lower case, without repeats.
|
||||||
|
*
|
||||||
|
* @param string[] $values
|
||||||
|
* @return string[]
|
||||||
|
* @throws UserError
|
||||||
|
*/
|
||||||
|
private function entries(array $values, string $domain, bool $groupAccess): array
|
||||||
|
{
|
||||||
|
$entries = [];
|
||||||
|
foreach (self::limited(array_map('strtolower', $values)) as $entry) {
|
||||||
|
if (str_starts_with($entry, 'group:')) {
|
||||||
|
$id = Input::intInRange(substr($entry, 6), 1, PHP_INT_MAX);
|
||||||
|
if (!$groupAccess || $id === null || $this->repo->groupDomain($id) !== $domain) {
|
||||||
|
throw new UserError('mail_policy_entry_invalid', [htmlspecialchars($entry)]);
|
||||||
|
}
|
||||||
|
$entry = 'group:' . $id;
|
||||||
|
} elseif (str_contains(ltrim($entry, '@'), '@')) {
|
||||||
|
if (!filter_var($entry, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
throw new UserError('mail_policy_entry_invalid', [htmlspecialchars($entry)]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$entry = ltrim($entry, '@');
|
||||||
|
if (!str_contains($entry, '.') || !filter_var($entry, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME)) {
|
||||||
|
throw new UserError('mail_policy_entry_invalid', [htmlspecialchars($entry)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$entries[$entry] = $entry;
|
||||||
|
}
|
||||||
|
return array_values($entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Values of a list field without repeats, at most MAX_ENTRIES (checked before anything is looked up)
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
* @throws UserError
|
||||||
|
*/
|
||||||
|
private static function limited(mixed $value): array
|
||||||
|
{
|
||||||
|
$values = array_values(array_unique(self::values($value)));
|
||||||
|
if (count($values) > self::MAX_ENTRIES) {
|
||||||
|
throw new UserError('mail_policy_entries_exceeded', [self::MAX_ENTRIES]);
|
||||||
|
}
|
||||||
|
return $values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return string[] entries without group:<id> */
|
||||||
|
private static function withoutGroups(array $entries): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter($entries, fn(string $entry): bool => !str_starts_with(strtolower($entry), 'group:')));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Values of a list field: an array (multi-select, JSON) or a string separated by line breaks, commas or spaces.
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
private static function values(mixed $value): array
|
||||||
|
{
|
||||||
|
$values = is_array($value) ? $value : preg_split('/[\s,;]+/', (string)Input::text($value));
|
||||||
|
return array_values(array_filter(array_map(fn(mixed $v): string => is_scalar($v) ? trim((string)$v) : '', $values), 'strlen'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checkbox or API flag; a hidden "0" + checkbox pair arrives as ["0", "1"] when checked */
|
||||||
|
private static function flag(mixed $value): bool
|
||||||
|
{
|
||||||
|
if (is_array($value)) {
|
||||||
|
$value = end($value);
|
||||||
|
}
|
||||||
|
return filter_var($value, FILTER_VALIDATE_BOOLEAN);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function domainOf(string $address): string
|
||||||
|
{
|
||||||
|
$at = strrpos($address, '@');
|
||||||
|
return ($at === false) ? '' : substr($address, $at + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,6 +42,8 @@ class Group
|
|||||||
public array $sendAs = [];
|
public array $sendAs = [];
|
||||||
/** @var array[] {owner, rights, subscribe, state, last_error}: members may open these mailboxes (ACL group=mcg_<id>) */
|
/** @var array[] {owner, rights, subscribe, state, last_error}: members may open these mailboxes (ACL group=mcg_<id>) */
|
||||||
public array $imapGrants = [];
|
public array $imapGrants = [];
|
||||||
|
/** @var string[] owners of removed IMAP grants whose ACL Dovecot still has, not part of the grants to write */
|
||||||
|
public array $removingImapOwners = [];
|
||||||
/** @var array[] {owner, type, folder, access, subscribe, state, last_error}: SOGo folders shared with the members */
|
/** @var array[] {owner, type, folder, access, subscribe, state, last_error}: SOGo folders shared with the members */
|
||||||
public array $sogoGrants = [];
|
public array $sogoGrants = [];
|
||||||
/** @var array[] warnings of the last write (msg values), not stored */
|
/** @var array[] warnings of the last write (msg values), not stored */
|
||||||
|
|||||||
@@ -501,6 +501,8 @@ class GroupRepository extends Repository
|
|||||||
$group->imapGrants = array_map(fn(array $grant): array => array_merge($grant, ['subscribe' => (int)$grant['subscribe']]),
|
$group->imapGrants = array_map(fn(array $grant): array => array_merge($grant, ['subscribe' => (int)$grant['subscribe']]),
|
||||||
$this->query("SELECT `owner`, `rights`, `subscribe`, `state`, `last_error` FROM `mailbox_group_imap_grant`
|
$this->query("SELECT `owner`, `rights`, `subscribe`, `state`, `last_error` FROM `mailbox_group_imap_grant`
|
||||||
WHERE `group_id` = :id AND `state` != 'removing' ORDER BY `owner`", $id)->fetchAll(PDO::FETCH_ASSOC));
|
WHERE `group_id` = :id AND `state` != 'removing' ORDER BY `owner`", $id)->fetchAll(PDO::FETCH_ASSOC));
|
||||||
|
$group->removingImapOwners = $this->query("SELECT `owner` FROM `mailbox_group_imap_grant` WHERE `group_id` = :id AND `state` = 'removing'",
|
||||||
|
$id)->fetchAll(PDO::FETCH_COLUMN);
|
||||||
$group->sogoGrants = array_map(fn(array $grant): array => array_merge($grant, ['subscribe' => (int)$grant['subscribe']]),
|
$group->sogoGrants = array_map(fn(array $grant): array => array_merge($grant, ['subscribe' => (int)$grant['subscribe']]),
|
||||||
$this->query("SELECT `owner`, `folder_type` AS `type`, `folder`, `access`, `subscribe`, `state`, `last_error`
|
$this->query("SELECT `owner`, `folder_type` AS `type`, `folder`, `access`, `subscribe`, `state`, `last_error`
|
||||||
FROM `mailbox_group_sogo_grant` WHERE `group_id` = :id ORDER BY `owner`, `folder_type`, `folder`", $id)->fetchAll(PDO::FETCH_ASSOC));
|
FROM `mailbox_group_sogo_grant` WHERE `group_id` = :id ORDER BY `owner`, `folder_type`, `folder`", $id)->fetchAll(PDO::FETCH_ASSOC));
|
||||||
|
|||||||
@@ -79,6 +79,12 @@ class GroupService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A removed grant still opens the mailbox until Dovecot dropped its ACL
|
||||||
|
foreach ($group->removingImapOwners as $owner) {
|
||||||
|
if (!$this->context->canAccessMailbox((string)$owner)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,7 +119,7 @@ class GroupService
|
|||||||
$current = $this->editable($id);
|
$current = $this->editable($id);
|
||||||
$group = clone $current;
|
$group = clone $current;
|
||||||
$this->apply($group, $input, $current);
|
$this->apply($group, $input, $current);
|
||||||
$snapshot = $this->effects->snapshot(array_merge($current->usernames(), $group->usernames()));
|
$snapshot = $this->effects->snapshot($this->inScope(array_merge($current->usernames(), $group->usernames())));
|
||||||
$sogo = $this->repo->update($group);
|
$sogo = $this->repo->update($group);
|
||||||
$group->warnings = array_merge($this->sizeWarnings($group), $this->effects->apply($sogo, $snapshot),
|
$group->warnings = array_merge($this->sizeWarnings($group), $this->effects->apply($sogo, $snapshot),
|
||||||
$this->sharing->applyImap((int)$group->id), $this->sharing->applySogo((int)$group->id));
|
$this->sharing->applyImap((int)$group->id), $this->sharing->applySogo((int)$group->id));
|
||||||
@@ -132,7 +138,7 @@ class GroupService
|
|||||||
{
|
{
|
||||||
$this->requireEditor();
|
$this->requireEditor();
|
||||||
$group = $this->editable($id);
|
$group = $this->editable($id);
|
||||||
$snapshot = $this->effects->snapshot($group->usernames());
|
$snapshot = $this->effects->snapshot($this->inScope($group->usernames()));
|
||||||
$this->sharing->removeImap($group);
|
$this->sharing->removeImap($group);
|
||||||
$sogo = $this->repo->delete($id);
|
$sogo = $this->repo->delete($id);
|
||||||
$group->warnings = $this->effects->apply($sogo, $snapshot);
|
$group->warnings = $this->effects->apply($sogo, $snapshot);
|
||||||
@@ -150,13 +156,30 @@ class GroupService
|
|||||||
if (!$own && !($this->canRead() && $this->context->canAccessMailbox($username))) {
|
if (!$own && !($this->canRead() && $this->context->canAccessMailbox($username))) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
$groups = $this->lookup->membershipsOf($username);
|
||||||
|
// Domain admins see the mailbox's groups in their domains, not those of other domains
|
||||||
|
if (!$own && !$this->context->hasAdminRights()) {
|
||||||
|
$groups = array_values(array_filter($groups, fn(array $group): bool => $this->context->canAccessDomain((string)$group['domain'])));
|
||||||
|
}
|
||||||
return [
|
return [
|
||||||
'groups' => $this->lookup->membershipsOf($username),
|
'groups' => $groups,
|
||||||
'template' => $this->templates->effective([$username])[$username] ?? null,
|
'template' => $this->templates->effective([$username])[$username] ?? null,
|
||||||
'send_as' => $this->lookup->sendAsOf($username),
|
'send_as' => $this->lookup->sendAsOf($username),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Members whose templates a change of the caller may apply: a domain admin never changes mailboxes outside
|
||||||
|
* his domains, their new effective template applies with the next admin action or IdP login.
|
||||||
|
*/
|
||||||
|
private function inScope(array $usernames): array
|
||||||
|
{
|
||||||
|
if ($this->context->hasAdminRights()) {
|
||||||
|
return $usernames;
|
||||||
|
}
|
||||||
|
return array_values(array_filter($usernames, fn(string $username): bool => $this->context->canAccessMailbox($username)));
|
||||||
|
}
|
||||||
|
|
||||||
/** @throws AccessDenied when the caller cannot see or edit the group */
|
/** @throws AccessDenied when the caller cannot see or edit the group */
|
||||||
private function editable(int $id): Group
|
private function editable(int $id): Group
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\Retention\Controller;
|
||||||
|
|
||||||
|
use Mailcow\Core\AccessDenied;
|
||||||
|
use Mailcow\Core\ApiResponse;
|
||||||
|
use Mailcow\Core\UserError;
|
||||||
|
use Mailcow\Retention\Service\Planner;
|
||||||
|
use Mailcow\Retention\Service\RuleService;
|
||||||
|
use PDOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP API adapter for json_api.php, edit.php and user.php (routes `retention-rule`, `logs/retention`), and the
|
||||||
|
* plan and results of the nightly run (data/conf/rspamd/dynmaps/retention.php). Messages are logged as
|
||||||
|
* ['retention', action, 'rule', data].
|
||||||
|
*/
|
||||||
|
class RetentionApiController
|
||||||
|
{
|
||||||
|
public function __construct(private RuleService $rules, private ApiResponse $response)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST add/retention-rule */
|
||||||
|
public function addRule(array $attr): bool
|
||||||
|
{
|
||||||
|
return $this->handle('add', $attr, function (array $log) use ($attr): bool {
|
||||||
|
$rule = $this->rules->create($attr);
|
||||||
|
return $this->response->success(['retention_rule_added', htmlspecialchars($rule->name)], $log);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST edit/retention-rule: one message per id */
|
||||||
|
public function editRules(?array $items, array $attr): bool
|
||||||
|
{
|
||||||
|
$data = array_merge(['id' => $items], $attr);
|
||||||
|
return $this->handle('edit', $data, function (array $log) use ($data, $attr): bool {
|
||||||
|
$this->rules->requireEditor();
|
||||||
|
return $this->batch((array)($data['id'] ?? []), $log,
|
||||||
|
fn(int $id): array => ['retention_rule_modified', htmlspecialchars($this->rules->update($id, $attr)->name)]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST delete/retention-rule: one message per id */
|
||||||
|
public function deleteRules(array $items): bool
|
||||||
|
{
|
||||||
|
return $this->handle('delete', ['id' => $items], function (array $log) use ($items): bool {
|
||||||
|
$this->rules->requireEditor();
|
||||||
|
return $this->batch($items, $log, fn(int $id): array => ['retention_rule_removed', htmlspecialchars($this->rules->delete($id)->name)]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET retention-rule/all[/<domain,domain>]: with the summary of the last nightly run */
|
||||||
|
public function getRules(?string $domains = null): array
|
||||||
|
{
|
||||||
|
$ids = $this->rules->visibleIds(self::domains($domains));
|
||||||
|
$runs = $this->rules->lastRuns($ids);
|
||||||
|
$rules = [];
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
$rule = $this->rules->get($id);
|
||||||
|
if ($rule !== null) {
|
||||||
|
$rules[] = $this->rules->toArray($rule, $runs[$id] ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET retention-rule/<id>, also edit.php */
|
||||||
|
public function getRule(mixed $id): array
|
||||||
|
{
|
||||||
|
$id = $this->toId($id);
|
||||||
|
$rule = ($id === null) ? null : $this->rules->get($id);
|
||||||
|
return ($rule === null) ? [] : $this->rules->toArray($rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET retention-rule/effective/<username>: what the rules delete in the mailbox; [] when not allowed to ask.
|
||||||
|
* $describe: for people (user.php), see Planner::describe()
|
||||||
|
*/
|
||||||
|
public function getEffective(mixed $username, bool $describe = false): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$actions = $this->rules->effective($username);
|
||||||
|
return $describe ? Planner::describe($actions) : $actions;
|
||||||
|
} catch (UserError) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET logs/retention[/<count>]: mailboxes with found mail or errors in the nightly runs, newest first */
|
||||||
|
public function getLog(int $limit = 0): array
|
||||||
|
{
|
||||||
|
return $this->rules->log($limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A page of the plan of the nightly run (retention.php): mailboxes with their actions, next: after for the next page */
|
||||||
|
public function plan(string $after = '', int $limit = 0): array
|
||||||
|
{
|
||||||
|
$page = $this->rules->plan($after, $limit);
|
||||||
|
$plan = [];
|
||||||
|
foreach ($page['mailboxes'] as $username => $actions) {
|
||||||
|
$plan[] = ['username' => (string)$username, 'actions' => $actions];
|
||||||
|
}
|
||||||
|
return ['mailboxes' => $plan, 'next' => $page['next']];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Results of the nightly run (retention.php): mailboxes with found mail or errors, a summary per rule, the run */
|
||||||
|
public function report(array $results, array $summary, ?array $run = null): int
|
||||||
|
{
|
||||||
|
return $this->rules->report($results, $summary, $run);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The domain was deleted (functions.mailbox.inc.php) */
|
||||||
|
public function domainDeleted(string $domain): void
|
||||||
|
{
|
||||||
|
$this->rules->domainDeleted($domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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, mixed $data, callable $work): bool
|
||||||
|
{
|
||||||
|
$log = ['retention', $action, 'rule', $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), 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)), $log);
|
||||||
|
} catch (UserError $e) {
|
||||||
|
$this->response->error($e, $log);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return string[] domains of a comma separated filter */
|
||||||
|
private static function domains(?string $domains): array
|
||||||
|
{
|
||||||
|
return array_values(array_filter(array_map('trim', explode(',', (string)$domains)), 'strlen'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Integer id from request input ("12", 12); null for anything else */
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\Retention\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A retention rule (table `retention_rule`): mail is deleted from its folders once it was saved there `days` days
|
||||||
|
* ago. Folders are special folders with all their names (trash: Trash, Papierkorb, Deleted Items, ...), the INBOX,
|
||||||
|
* all folders, or own folder names (`folder:<name>`, wildcard *). A rule applies to all mailboxes of its domain or
|
||||||
|
* to mailboxes and groups of it. In mode `simulate` the nightly run only counts what it would delete.
|
||||||
|
*/
|
||||||
|
class Rule
|
||||||
|
{
|
||||||
|
/** Special folders, the INBOX and all folders; own folders are folder:<name> */
|
||||||
|
public const FOLDERS = ['trash', 'junk', 'sent', 'drafts', 'archive', 'inbox', 'all'];
|
||||||
|
public const MODES = ['simulate', 'delete'];
|
||||||
|
public const OWN_FOLDER = 'folder:';
|
||||||
|
|
||||||
|
public ?int $id = null;
|
||||||
|
public string $domain = '';
|
||||||
|
public string $name = '';
|
||||||
|
public string $description = '';
|
||||||
|
/** @var string[] keys of FOLDERS and folder:<name> */
|
||||||
|
public array $folders = [];
|
||||||
|
public int $days = 30;
|
||||||
|
/** Flagged mail stays */
|
||||||
|
public int $keepFlagged = 0;
|
||||||
|
public string $mode = 'simulate';
|
||||||
|
/** Applies to all mailboxes of the domain */
|
||||||
|
public int $allMailboxes = 0;
|
||||||
|
public int $active = 1;
|
||||||
|
/** @var string[] usernames the rule is assigned to */
|
||||||
|
public array $mailboxes = [];
|
||||||
|
/** @var int[] groups the rule is assigned to (their members of the rule's domain) */
|
||||||
|
public array $groups = [];
|
||||||
|
public ?string $created = null;
|
||||||
|
public ?string $modified = null;
|
||||||
|
|
||||||
|
/** Row of `retention_rule`; assignments are set by the repository */
|
||||||
|
public static function fromRow(array $row): self
|
||||||
|
{
|
||||||
|
$rule = new self();
|
||||||
|
$rule->id = (int)$row['id'];
|
||||||
|
$rule->domain = (string)$row['domain'];
|
||||||
|
$rule->name = (string)$row['name'];
|
||||||
|
$rule->description = (string)($row['description'] ?? '');
|
||||||
|
$folders = is_string($row['folders'] ?? null) ? json_decode($row['folders'], true) : null;
|
||||||
|
$rule->folders = is_array($folders) ? array_values(array_filter($folders, 'is_string')) : [];
|
||||||
|
$rule->days = (int)$row['days'];
|
||||||
|
$rule->keepFlagged = (int)$row['keep_flagged'];
|
||||||
|
$rule->mode = (string)$row['mode'];
|
||||||
|
$rule->allMailboxes = (int)$row['all_mailboxes'];
|
||||||
|
$rule->active = (int)$row['active'];
|
||||||
|
$rule->created = $row['created'] ?? null;
|
||||||
|
$rule->modified = $row['modified'] ?? null;
|
||||||
|
return $rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Column values for `retention_rule` */
|
||||||
|
public function toRow(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'domain' => $this->domain,
|
||||||
|
'name' => $this->name,
|
||||||
|
'description' => $this->description,
|
||||||
|
'folders' => json_encode($this->folders),
|
||||||
|
'days' => $this->days,
|
||||||
|
'keep_flagged' => $this->keepFlagged,
|
||||||
|
'mode' => $this->mode,
|
||||||
|
'all_mailboxes' => $this->allMailboxes,
|
||||||
|
'active' => $this->active,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entry of the API and the edit page */
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'domain' => $this->domain,
|
||||||
|
'name' => $this->name,
|
||||||
|
'description' => $this->description,
|
||||||
|
'folders' => $this->folders,
|
||||||
|
'days' => $this->days,
|
||||||
|
'keep_flagged' => $this->keepFlagged,
|
||||||
|
'mode' => $this->mode,
|
||||||
|
'all_mailboxes' => $this->allMailboxes,
|
||||||
|
'active' => $this->active,
|
||||||
|
'mailboxes' => $this->mailboxes,
|
||||||
|
'groups' => $this->groups,
|
||||||
|
'created' => $this->created,
|
||||||
|
'modified' => $this->modified,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\Retention\Repository;
|
||||||
|
|
||||||
|
use Mailcow\Core\Context;
|
||||||
|
use Mailcow\Core\Repository;
|
||||||
|
use Mailcow\Retention\Model\Rule;
|
||||||
|
use Mailcow\Retention\Service\Planner;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tables `retention_rule`, `retention_rule_member` (the mailboxes and groups a rule is assigned to) and
|
||||||
|
* `retention_log` (results of the nightly run), plus the lookups the editing needs. Assignments go with their
|
||||||
|
* mailbox, group or domain (foreign keys, ON DELETE CASCADE).
|
||||||
|
*/
|
||||||
|
class RuleRepository extends Repository
|
||||||
|
{
|
||||||
|
/** Results of the nightly run are kept this long */
|
||||||
|
private const LOG_DAYS = 90;
|
||||||
|
|
||||||
|
public function find(int $id): ?Rule
|
||||||
|
{
|
||||||
|
$row = $this->query("SELECT * FROM `retention_rule` WHERE `id` = :id", ['id' => $id])->fetch();
|
||||||
|
return is_array($row) ? $this->withMembers(Rule::fromRow($row)) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $domains limit to these domains, [] = all visible
|
||||||
|
* @return int[] ids of the rules visible to the caller, ordered by domain and name
|
||||||
|
*/
|
||||||
|
public function visibleIds(Context $context, array $domains = []): array
|
||||||
|
{
|
||||||
|
[$where, $params] = $this->visibleDomains($context, $domains);
|
||||||
|
if ($where === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return array_map('intval', $this->query("SELECT `id` FROM `retention_rule` WHERE $where ORDER BY `domain`, `name`", $params)
|
||||||
|
->fetchAll(PDO::FETCH_COLUMN));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function nameExists(string $domain, string $name, ?int $exceptId = null): bool
|
||||||
|
{
|
||||||
|
return (bool)$this->query("SELECT 1 FROM `retention_rule` WHERE `domain` = :domain AND `name` = :name AND `id` != :id",
|
||||||
|
['domain' => $domain, 'name' => $name, 'id' => $exceptId ?? 0])->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Inserts or updates the rule with its assignments; returns its id */
|
||||||
|
public function save(Rule $rule): int
|
||||||
|
{
|
||||||
|
return $this->transaction(function () use ($rule): int {
|
||||||
|
if ($rule->id === null) {
|
||||||
|
$rule->id = $this->insertRow('retention_rule', $rule->toRow());
|
||||||
|
} else {
|
||||||
|
$this->updateRow('retention_rule', $rule->id, $rule->toRow());
|
||||||
|
$this->query("DELETE FROM `retention_rule_member` WHERE `rule_id` = :id", ['id' => $rule->id]);
|
||||||
|
}
|
||||||
|
foreach ($rule->mailboxes as $username) {
|
||||||
|
$this->insertRow('retention_rule_member', ['rule_id' => $rule->id, 'username' => $username]);
|
||||||
|
}
|
||||||
|
foreach ($rule->groups as $groupId) {
|
||||||
|
$this->insertRow('retention_rule_member', ['rule_id' => $rule->id, 'group_id' => $groupId]);
|
||||||
|
}
|
||||||
|
return $rule->id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(int $id): void
|
||||||
|
{
|
||||||
|
$this->query("DELETE FROM `retention_rule` WHERE `id` = :id", ['id' => $id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A primary domain (rules never belong to alias domains) */
|
||||||
|
public function isDomain(string $domain): bool
|
||||||
|
{
|
||||||
|
return (bool)$this->query("SELECT 1 FROM `domain` WHERE `domain` = :domain", ['domain' => $domain])->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Domain of a regular mailbox, null if there is none */
|
||||||
|
public function mailboxDomain(string $username): ?string
|
||||||
|
{
|
||||||
|
$domain = $this->query("SELECT `domain` FROM `mailbox` WHERE `username` = :username AND `kind` = ''", ['username' => $username])->fetchColumn();
|
||||||
|
return is_string($domain) ? $domain : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function groupDomain(int $id): ?string
|
||||||
|
{
|
||||||
|
$domain = $this->query("SELECT `domain` FROM `mailbox_group` WHERE `id` = :id", ['id' => $id])->fetchColumn();
|
||||||
|
return is_string($domain) ? $domain : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array[] id, name, domain, address of the groups in these domains (choices of the forms) */
|
||||||
|
public function groupsIn(array $domains): array
|
||||||
|
{
|
||||||
|
if (empty($domains)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
[$in, $params] = self::in('dom', $domains);
|
||||||
|
return $this->query("SELECT `id`, `name`, `domain`, `address` FROM `mailbox_group` WHERE `domain` IN ($in) ORDER BY `domain`, `name`", $params)
|
||||||
|
->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A page of the mailboxes the nightly run looks at: Dovecot knows them (regular, not disabled), their domain is
|
||||||
|
* active and has active rules.
|
||||||
|
*
|
||||||
|
* @return string[] usernames after $after, in order
|
||||||
|
*/
|
||||||
|
public function mailboxesWithRules(string $after, int $limit): array
|
||||||
|
{
|
||||||
|
return $this->query("SELECT `m`.`username` FROM `mailbox` `m` JOIN `domain` `d` ON `d`.`domain` = `m`.`domain` AND `d`.`active` = '1'
|
||||||
|
WHERE `m`.`kind` = '' AND `m`.`active` IN ('1', '2') AND `m`.`username` > :after
|
||||||
|
AND EXISTS (SELECT 1 FROM `retention_rule` `r` WHERE `r`.`domain` = `m`.`domain` AND `r`.`active` = '1')
|
||||||
|
ORDER BY `m`.`username` LIMIT " . max(1, $limit), ['after' => $after])->fetchAll(PDO::FETCH_COLUMN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active rules of these mailboxes with the level of their assignment: the whole domain, a group of the mailbox
|
||||||
|
* (members of the rule's domain only) or the mailbox itself. Only mailboxes Dovecot knows (regular, not
|
||||||
|
* disabled) in active domains.
|
||||||
|
*
|
||||||
|
* @param string[] $usernames
|
||||||
|
* @return array<string, array<array{rule: Rule, level: int}>> username => candidates
|
||||||
|
*/
|
||||||
|
public function candidates(array $usernames): array
|
||||||
|
{
|
||||||
|
if (empty($usernames)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$params = [];
|
||||||
|
$mailbox = [];
|
||||||
|
foreach ([1, 2, 3] as $part) {
|
||||||
|
[$in, $inParams] = self::in('u' . $part . '_', $usernames);
|
||||||
|
$params += $inParams;
|
||||||
|
$mailbox[$part] = "`m`.`kind` = '' AND `m`.`active` IN ('1', '2') AND `m`.`username` IN ($in)
|
||||||
|
AND `m`.`domain` IN (SELECT `domain` FROM `domain` WHERE `active` = '1')";
|
||||||
|
}
|
||||||
|
$rows = $this->query("SELECT `r`.*, `m`.`username` AS `member`, " . Planner::LEVEL_DOMAIN . " AS `level` FROM `retention_rule` `r`
|
||||||
|
JOIN `mailbox` `m` ON `m`.`domain` = `r`.`domain` AND {$mailbox[1]}
|
||||||
|
WHERE `r`.`active` = '1' AND `r`.`all_mailboxes` = '1'
|
||||||
|
UNION ALL SELECT `r`.*, `m`.`username`, " . Planner::LEVEL_GROUP . " FROM `retention_rule` `r`
|
||||||
|
JOIN `retention_rule_member` `rm` ON `rm`.`rule_id` = `r`.`id`
|
||||||
|
JOIN `mailbox_group` `g` ON `g`.`id` = `rm`.`group_id` AND `g`.`active` = '1'
|
||||||
|
JOIN `mailbox_group_member` `gm` ON `gm`.`group_id` = `g`.`id`
|
||||||
|
JOIN `mailbox` `m` ON `m`.`username` = `gm`.`username` AND `m`.`domain` = `r`.`domain` AND {$mailbox[2]}
|
||||||
|
WHERE `r`.`active` = '1'
|
||||||
|
UNION ALL SELECT `r`.*, `m`.`username`, " . Planner::LEVEL_MAILBOX . " FROM `retention_rule` `r`
|
||||||
|
JOIN `retention_rule_member` `rm` ON `rm`.`rule_id` = `r`.`id`
|
||||||
|
JOIN `mailbox` `m` ON `m`.`username` = `rm`.`username` AND `m`.`domain` = `r`.`domain` AND {$mailbox[3]}
|
||||||
|
WHERE `r`.`active` = '1'
|
||||||
|
ORDER BY `member`, `id`", $params)->fetchAll();
|
||||||
|
$candidates = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$candidates[strtolower((string)$row['member'])][] = ['rule' => Rule::fromRow($row), 'level' => (int)$row['level']];
|
||||||
|
}
|
||||||
|
return $candidates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores results of the nightly run. Domain and rule name come from the database, not from the runner; results
|
||||||
|
* of a rule from another domain than the mailbox are dropped. With the summary of a rule, the results its
|
||||||
|
* earlier runs found while simulating are dropped: only the last simulation is kept. After a run without error,
|
||||||
|
* the active rules that decided about no folder get an empty summary.
|
||||||
|
*
|
||||||
|
* @param array[] $results username, rule_id, folder (names), days, matched, deleted, simulated, error: mailboxes
|
||||||
|
* with found mail or an error
|
||||||
|
* @param array[] $summary rule_id, mailboxes, matched, deleted, errors, simulated: one per rule and run
|
||||||
|
* @param array|null $run started (unix time), mailboxes, matched, deleted, errors, error: the whole run, sent at its end
|
||||||
|
*/
|
||||||
|
public function addLog(array $results, array $summary, ?array $run = null): int
|
||||||
|
{
|
||||||
|
$stored = $this->transaction(function () use ($results, $summary, $run): int {
|
||||||
|
$stored = 0;
|
||||||
|
foreach ($results as $result) {
|
||||||
|
$domain = $this->mailboxDomain((string)$result['username']);
|
||||||
|
$rule = $this->ruleName((int)$result['rule_id']);
|
||||||
|
if ($domain === null || $rule === null || $rule['domain'] !== $domain) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$this->insertRow('retention_log', [
|
||||||
|
'domain' => $domain,
|
||||||
|
'username' => (string)$result['username'],
|
||||||
|
'rule_id' => (int)$result['rule_id'],
|
||||||
|
'rule_name' => $rule['name'],
|
||||||
|
'folder' => mb_substr((string)$result['folder'], 0, 10000),
|
||||||
|
'days' => (int)($result['days'] ?? 0),
|
||||||
|
'matched' => (int)($result['matched'] ?? 0),
|
||||||
|
'deleted' => (int)($result['deleted'] ?? 0),
|
||||||
|
'errors' => (int)(($result['error'] ?? '') !== ''),
|
||||||
|
'simulated' => (int)(bool)($result['simulated'] ?? false),
|
||||||
|
'error' => ($result['error'] ?? '') === '' ? null : mb_substr((string)$result['error'], 0, 1000),
|
||||||
|
]);
|
||||||
|
$stored++;
|
||||||
|
}
|
||||||
|
$summarized = [];
|
||||||
|
foreach ($summary as $runOfRule) {
|
||||||
|
$rule = $this->ruleName((int)$runOfRule['rule_id']);
|
||||||
|
if ($rule === null || isset($summarized[(int)$runOfRule['rule_id']])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$summarized[(int)$runOfRule['rule_id']] = true;
|
||||||
|
$this->addSummary((int)$runOfRule['rule_id'], $rule, $runOfRule);
|
||||||
|
$stored++;
|
||||||
|
}
|
||||||
|
if ($run !== null && ($run['error'] ?? '') === '') {
|
||||||
|
// rules created during the run may have missed its first pages
|
||||||
|
foreach ($this->query("SELECT `r`.`id`, `r`.`domain`, `r`.`name`, `r`.`mode` FROM `retention_rule` `r`
|
||||||
|
JOIN `domain` `d` ON `d`.`domain` = `r`.`domain` AND `d`.`active` = '1'
|
||||||
|
WHERE `r`.`active` = '1' AND `r`.`created` < FROM_UNIXTIME(:started)", ['started' => (int)($run['started'] ?? 0)])->fetchAll() as $row) {
|
||||||
|
if (!isset($summarized[(int)$row['id']])) {
|
||||||
|
$this->addSummary((int)$row['id'], ['domain' => (string)$row['domain'], 'name' => (string)$row['name']],
|
||||||
|
['simulated' => $row['mode'] !== 'delete']);
|
||||||
|
$stored++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($run !== null) {
|
||||||
|
$this->insertRow('retention_log', [
|
||||||
|
'domain' => '',
|
||||||
|
'mailboxes' => (int)($run['mailboxes'] ?? 0),
|
||||||
|
'matched' => (int)($run['matched'] ?? 0),
|
||||||
|
'deleted' => (int)($run['deleted'] ?? 0),
|
||||||
|
'errors' => (int)($run['errors'] ?? 0),
|
||||||
|
'error' => ($run['error'] ?? '') === '' ? null : mb_substr((string)$run['error'], 0, 1000),
|
||||||
|
]);
|
||||||
|
$stored++;
|
||||||
|
}
|
||||||
|
return $stored;
|
||||||
|
});
|
||||||
|
if ($run !== null) {
|
||||||
|
$this->prune();
|
||||||
|
}
|
||||||
|
return $stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Summary of a rule's run; drops what its earlier runs found while simulating */
|
||||||
|
private function addSummary(int $ruleId, array $rule, array $values): void
|
||||||
|
{
|
||||||
|
$previous = $this->query("SELECT MAX(`id`) FROM `retention_log` WHERE `rule_id` = :id AND `username` = ''", ['id' => $ruleId])->fetchColumn();
|
||||||
|
if ($previous !== null && $previous !== false) {
|
||||||
|
$this->query("DELETE FROM `retention_log` WHERE `rule_id` = :id AND `username` != '' AND `simulated` = '1' AND `id` < :previous",
|
||||||
|
['id' => $ruleId, 'previous' => (int)$previous]);
|
||||||
|
}
|
||||||
|
$this->insertRow('retention_log', [
|
||||||
|
'domain' => $rule['domain'],
|
||||||
|
'rule_id' => $ruleId,
|
||||||
|
'rule_name' => $rule['name'],
|
||||||
|
'mailboxes' => (int)($values['mailboxes'] ?? 0),
|
||||||
|
'matched' => (int)($values['matched'] ?? 0),
|
||||||
|
'deleted' => (int)($values['deleted'] ?? 0),
|
||||||
|
'errors' => (int)($values['errors'] ?? 0),
|
||||||
|
'simulated' => (int)(bool)($values['simulated'] ?? false),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops the results of the runs in a domain (the domain was deleted) */
|
||||||
|
public function deleteLog(string $domain): void
|
||||||
|
{
|
||||||
|
$this->query("DELETE FROM `retention_log` WHERE `domain` = :domain", ['domain' => $domain]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, array> rule id => the summary of its last run (created, mailboxes, matched, deleted, errors, simulated) */
|
||||||
|
public function lastRuns(array $ruleIds): array
|
||||||
|
{
|
||||||
|
if (empty($ruleIds)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
[$in, $params] = self::in('rule', array_map('intval', $ruleIds));
|
||||||
|
$runs = [];
|
||||||
|
foreach ($this->query("SELECT `l`.`rule_id`, `l`.`created`, `l`.`mailboxes`, `l`.`matched`, `l`.`deleted`, `l`.`errors`, `l`.`simulated`
|
||||||
|
FROM `retention_log` `l` WHERE `l`.`username` = '' AND `l`.`rule_id` IN ($in) AND `l`.`id` = (SELECT MAX(`id`) FROM `retention_log`
|
||||||
|
WHERE `rule_id` = `l`.`rule_id` AND `username` = '')", $params)->fetchAll() as $row) {
|
||||||
|
$runs[(int)$row['rule_id']] = $row;
|
||||||
|
}
|
||||||
|
return $runs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array[] mailboxes with found mail or errors in the nightly runs visible to the caller, and for admins the
|
||||||
|
* whole runs (username '', rule_id null), newest first
|
||||||
|
*/
|
||||||
|
public function log(Context $context, array $domains, int $limit): array
|
||||||
|
{
|
||||||
|
[$where, $params] = $this->visibleDomains($context, $domains);
|
||||||
|
if ($where === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return $this->query("SELECT UNIX_TIMESTAMP(`created`) AS `time`, `domain`, `username`, `rule_id`, `rule_name`, `folder`, `days`, `mailboxes`, `matched`, `deleted`, `errors`, `simulated`, `error`
|
||||||
|
FROM `retention_log` WHERE (`username` != '' OR `rule_id` IS NULL) AND $where ORDER BY `created` DESC, `id` DESC LIMIT " . max(1, $limit), $params)->fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops results older than LOG_DAYS, in parts so a large log does not hold the table */
|
||||||
|
private function prune(): void
|
||||||
|
{
|
||||||
|
do {
|
||||||
|
$deleted = $this->query("DELETE FROM `retention_log` WHERE `created` < NOW() - INTERVAL " . self::LOG_DAYS . " DAY LIMIT 10000")->rowCount();
|
||||||
|
} while ($deleted >= 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{domain: string, name: string}|null */
|
||||||
|
private function ruleName(int $id): ?array
|
||||||
|
{
|
||||||
|
$row = $this->query("SELECT `domain`, `name` FROM `retention_rule` WHERE `id` = :id", ['id' => $id])->fetch();
|
||||||
|
return is_array($row) ? ['domain' => (string)$row['domain'], 'name' => (string)$row['name']] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{0: ?string, 1: array} condition on `domain` for the caller and the domain filter, null = nothing visible */
|
||||||
|
private function visibleDomains(Context $context, array $domains): array
|
||||||
|
{
|
||||||
|
if ($context->hasAdminRights()) {
|
||||||
|
[$where, $params] = ['1=1', []];
|
||||||
|
} elseif ($context->isDomainAdmin()) {
|
||||||
|
[$where, $params] = ["`domain` IN (SELECT `domain` FROM `domain_admins` WHERE `active` = '1' AND `username` = :vis_da)",
|
||||||
|
['vis_da' => $context->username]];
|
||||||
|
} else {
|
||||||
|
return [null, []];
|
||||||
|
}
|
||||||
|
if (!empty($domains)) {
|
||||||
|
[$in, $inParams] = self::in('dom', $domains);
|
||||||
|
$where .= " AND `domain` IN ($in)";
|
||||||
|
$params += $inParams;
|
||||||
|
}
|
||||||
|
return [$where, $params];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{0: string, 1: array} placeholders and parameters of an IN list */
|
||||||
|
private static function in(string $prefix, array $values): array
|
||||||
|
{
|
||||||
|
$names = [];
|
||||||
|
$params = [];
|
||||||
|
foreach (array_values($values) as $i => $value) {
|
||||||
|
$names[] = ':' . $prefix . $i;
|
||||||
|
$params[$prefix . $i] = $value;
|
||||||
|
}
|
||||||
|
return [implode(', ', $names), $params];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function withMembers(Rule $rule): Rule
|
||||||
|
{
|
||||||
|
foreach ($this->query("SELECT `username`, `group_id` FROM `retention_rule_member` WHERE `rule_id` = :id ORDER BY `username`, `group_id`",
|
||||||
|
['id' => $rule->id])->fetchAll() as $row) {
|
||||||
|
if ($row['username'] !== null) {
|
||||||
|
$rule->mailboxes[] = strtolower((string)$row['username']);
|
||||||
|
} elseif ($row['group_id'] !== null) {
|
||||||
|
$rule->groups[] = (int)$row['group_id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $rule;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\Retention;
|
||||||
|
|
||||||
|
use Mailcow\Core\ApiResponse;
|
||||||
|
use Mailcow\Core\Context;
|
||||||
|
use Mailcow\Retention\Controller\RetentionApiController;
|
||||||
|
use Mailcow\Retention\Repository\RuleRepository;
|
||||||
|
use Mailcow\Retention\Service\RuleService;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composition root of the retention rules: which mail is deleted from which folders after how many days. The
|
||||||
|
* nightly run in the dovecot container (retention_runner.py) gets its plan from data/conf/rspamd/dynmaps/retention.php.
|
||||||
|
*
|
||||||
|
* process_get_return(RetentionModule::fromGlobals()->api->getRules());
|
||||||
|
* echo json_encode(RetentionModule::create($pdo, Context::system())->api->plan()); // nightly run
|
||||||
|
*/
|
||||||
|
class RetentionModule
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public readonly RuleService $rules,
|
||||||
|
public readonly RetentionApiController $api
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function create(PDO $pdo, Context $context): self
|
||||||
|
{
|
||||||
|
$rules = new RuleService(new RuleRepository($pdo), $context);
|
||||||
|
return new self($rules, new RetentionApiController($rules, new ApiResponse()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy entry point for procedural callers: reads $pdo and $_SESSION */
|
||||||
|
public static function fromGlobals(?Context $context = null): self
|
||||||
|
{
|
||||||
|
return self::create($GLOBALS['pdo'], $context ?? Context::fromSession($_SESSION ?? []));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\Retention\Service;
|
||||||
|
|
||||||
|
use Mailcow\Retention\Model\Rule;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the rules of one mailbox delete: the most specific rule wins, per folder of the mailbox. The folders of the
|
||||||
|
* rules become entries in order: a rule assigned to the mailbox before one of its groups before one of the whole
|
||||||
|
* domain; then by what an entry matches (MATCHES, the folder itself first); then the longer period, then the lower
|
||||||
|
* id. The runner gives every folder to the first entry that matches it.
|
||||||
|
*/
|
||||||
|
class Planner
|
||||||
|
{
|
||||||
|
public const LEVEL_DOMAIN = 1;
|
||||||
|
public const LEVEL_GROUP = 2;
|
||||||
|
public const LEVEL_MAILBOX = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What an entry matches, most specific first: the folder itself (a special folder, the inbox, an own folder
|
||||||
|
* without *), an own folder name with *, the subfolders of a special folder or the inbox, all folders
|
||||||
|
*/
|
||||||
|
public const MATCHES = ['folder' => 3, 'pattern' => 2, 'subfolders' => 1, 'all' => 0];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<array{rule: Rule, level: int}> $candidates active rules of one mailbox with their assignment level
|
||||||
|
* @return array[] in order: folder (key), match, days, keep_flagged, mode, rule_id, rule_name. Entries that never
|
||||||
|
* win are left out: one matching the same folders as an entry before and everything after all folders.
|
||||||
|
*/
|
||||||
|
public static function actions(array $candidates): array
|
||||||
|
{
|
||||||
|
$entries = [];
|
||||||
|
foreach ($candidates as $candidate) {
|
||||||
|
foreach ($candidate['rule']->folders as $folder) {
|
||||||
|
foreach (self::matches($folder) as $match) {
|
||||||
|
// the lower id decides between equal rules, so the result does not depend on the order
|
||||||
|
$entries[] = [
|
||||||
|
'score' => [$candidate['level'], self::MATCHES[$match], $candidate['rule']->days, -(int)$candidate['rule']->id],
|
||||||
|
'folder' => $folder,
|
||||||
|
'match' => $match,
|
||||||
|
'rule' => $candidate['rule'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
usort($entries, fn(array $a, array $b): int => $b['score'] <=> $a['score']);
|
||||||
|
$actions = [];
|
||||||
|
foreach ($entries as $entry) {
|
||||||
|
if (isset($actions[$entry['folder'] . "\n" . $entry['match']])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$actions[$entry['folder'] . "\n" . $entry['match']] = self::action($entry['folder'], $entry['match'], $entry['rule']);
|
||||||
|
if ($entry['match'] === 'all') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return array_values($actions);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The plan for people: the subfolders of a special folder or the inbox go with the folder (subfolders 1) when the
|
||||||
|
* same rule decides both and no entry with * comes between them.
|
||||||
|
*
|
||||||
|
* @return array[] the entries of actions() with subfolders 0 or 1
|
||||||
|
*/
|
||||||
|
public static function describe(array $actions): array
|
||||||
|
{
|
||||||
|
$described = [];
|
||||||
|
$folders = [];
|
||||||
|
foreach ($actions as $action) {
|
||||||
|
$key = $action['folder'] . "\n" . $action['rule_id'];
|
||||||
|
if ($action['match'] === 'subfolders' && isset($folders[$key])) {
|
||||||
|
$described[$folders[$key]]['subfolders'] = 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if ($action['match'] === 'pattern') {
|
||||||
|
$folders = [];
|
||||||
|
} elseif ($action['match'] === 'folder') {
|
||||||
|
$folders[$key] = count($described);
|
||||||
|
}
|
||||||
|
$described[] = $action + ['subfolders' => (int)($action['match'] === 'subfolders')];
|
||||||
|
}
|
||||||
|
return $described;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return string[] what the folder key of a rule matches */
|
||||||
|
private static function matches(string $folder): array
|
||||||
|
{
|
||||||
|
if ($folder === 'all') {
|
||||||
|
return ['all'];
|
||||||
|
}
|
||||||
|
if (str_starts_with($folder, Rule::OWN_FOLDER)) {
|
||||||
|
return [str_contains($folder, '*') ? 'pattern' : 'folder'];
|
||||||
|
}
|
||||||
|
return ['folder', 'subfolders'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function action(string $folder, string $match, Rule $rule): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'folder' => $folder,
|
||||||
|
'match' => $match,
|
||||||
|
'days' => $rule->days,
|
||||||
|
'keep_flagged' => $rule->keepFlagged,
|
||||||
|
'mode' => $rule->mode,
|
||||||
|
'rule_id' => $rule->id,
|
||||||
|
'rule_name' => $rule->name,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,359 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\Retention\Service;
|
||||||
|
|
||||||
|
use Mailcow\Core\AccessDenied;
|
||||||
|
use Mailcow\Core\Context;
|
||||||
|
use Mailcow\Core\Input;
|
||||||
|
use Mailcow\Core\UserError;
|
||||||
|
use Mailcow\Retention\Model\Rule;
|
||||||
|
use Mailcow\Retention\Repository\RuleRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use cases of retention rules.
|
||||||
|
*
|
||||||
|
* Read: admins all rules (also read-only API keys); domain admins with the `retention` ACL the rules of their
|
||||||
|
* domains. Mailbox users see the rules that apply to their mailbox (effective()).
|
||||||
|
* Edit: admins; domain admins with the ACL for rules of their domains. A rule is assigned to all mailboxes of its
|
||||||
|
* domain or to mailboxes and groups of it. Groups need the `mailbox_groups` ACL as well: without it a domain
|
||||||
|
* admin sees no groups and keeps the group assignments.
|
||||||
|
*/
|
||||||
|
class RuleService
|
||||||
|
{
|
||||||
|
private const MAX_FOLDERS = 50;
|
||||||
|
private const MAX_MEMBERS = 500;
|
||||||
|
private const MAX_DAYS = 36500;
|
||||||
|
private const LOG_LIMIT = 1000;
|
||||||
|
/** Mailboxes per page of the plan, and results per report of the runner */
|
||||||
|
private const PLAN_PAGE = 500;
|
||||||
|
private const MAX_RESULTS = 1000;
|
||||||
|
|
||||||
|
public function __construct(private RuleRepository $repo, private Context $context)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canRead(): bool
|
||||||
|
{
|
||||||
|
return $this->context->hasAdminRights()
|
||||||
|
|| ($this->context->isDomainAdmin() && $this->context->hasAcl('retention'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws AccessDenied unless the caller may edit rules at all */
|
||||||
|
public function requireEditor(): void
|
||||||
|
{
|
||||||
|
if (!$this->canRead() || $this->context->isReadOnlyApi()) {
|
||||||
|
throw new AccessDenied();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(int $id): ?Rule
|
||||||
|
{
|
||||||
|
$rule = $this->canRead() ? $this->repo->find($id) : null;
|
||||||
|
return ($rule !== null && $this->visible($rule)) ? $rule : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return int[] */
|
||||||
|
public function visibleIds(array $domains = []): array
|
||||||
|
{
|
||||||
|
return $this->canRead() ? $this->repo->visibleIds($this->context, $domains) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canEdit(Rule $rule): bool
|
||||||
|
{
|
||||||
|
return $this->canRead() && !$this->context->isReadOnlyApi() && $this->visible($rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** $lastRun: summary of the last nightly run, null = not run yet */
|
||||||
|
public function toArray(Rule $rule, ?array $lastRun = null): array
|
||||||
|
{
|
||||||
|
return $rule->toArray() + ['can_edit' => (int)$this->canEdit($rule), 'last_run' => $lastRun];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, array> rule id => summary of its last nightly run */
|
||||||
|
public function lastRuns(array $ruleIds): array
|
||||||
|
{
|
||||||
|
return $this->canRead() ? $this->repo->lastRuns($ruleIds) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws UserError */
|
||||||
|
public function create(array $input): Rule
|
||||||
|
{
|
||||||
|
$this->requireEditor();
|
||||||
|
$rule = new Rule();
|
||||||
|
$rule->domain = $this->domainFor($input['domain'] ?? null);
|
||||||
|
$this->apply($rule, $input);
|
||||||
|
$this->repo->save($rule);
|
||||||
|
return $rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attributes missing in $input keep their value @throws UserError */
|
||||||
|
public function update(int $id, array $input): Rule
|
||||||
|
{
|
||||||
|
$rule = $this->editable($id);
|
||||||
|
$this->apply($rule, $input);
|
||||||
|
$this->repo->save($rule);
|
||||||
|
return $rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws UserError */
|
||||||
|
public function delete(int $id): Rule
|
||||||
|
{
|
||||||
|
$rule = $this->editable($id);
|
||||||
|
$this->repo->delete($id);
|
||||||
|
return $rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array[] id, name, domain, address of the groups in these domains (choices of the forms) */
|
||||||
|
public function groupChoices(array $domains): array
|
||||||
|
{
|
||||||
|
return ($this->canRead() && $this->canUseGroups()) ? $this->repo->groupsIn($domains) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the rules delete in a mailbox, per folder (see Planner): for the mailbox user, or for those who may read
|
||||||
|
* the rules and access the mailbox.
|
||||||
|
*
|
||||||
|
* @return array[] actions of Planner::actions()
|
||||||
|
* @throws AccessDenied
|
||||||
|
*/
|
||||||
|
public function effective(mixed $username): array
|
||||||
|
{
|
||||||
|
$username = strtolower((string)Input::text($username));
|
||||||
|
$allowed = $this->context->isUser() ? $username === strtolower((string)$this->context->username)
|
||||||
|
: ($this->canRead() && ($this->context->hasAdminRights() || $this->context->canAccessMailbox($username)));
|
||||||
|
if ($username === '' || !$allowed) {
|
||||||
|
throw new AccessDenied();
|
||||||
|
}
|
||||||
|
return Planner::actions($this->repo->candidates([$username])[$username] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array[] results of the nightly runs in the caller's domains, newest first; $limit 0 = LOG_LIMIT */
|
||||||
|
public function log(int $limit = 0): array
|
||||||
|
{
|
||||||
|
return $this->canRead() ? $this->repo->log($this->context, [], ($limit > 0) ? min($limit, self::LOG_LIMIT * 10) : self::LOG_LIMIT) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A page of the plan of the nightly run (system context), mailboxes after $after in order
|
||||||
|
*
|
||||||
|
* @return array{mailboxes: array<string, array[]>, next: ?string} username => actions of Planner::actions() for
|
||||||
|
* the mailboxes with rules; next: the $after of the next page, null after the last
|
||||||
|
*/
|
||||||
|
public function plan(string $after = '', int $limit = 0): array
|
||||||
|
{
|
||||||
|
$limit = ($limit > 0) ? min($limit, self::PLAN_PAGE) : self::PLAN_PAGE;
|
||||||
|
$usernames = $this->repo->mailboxesWithRules($after, $limit);
|
||||||
|
$plan = array_filter(array_map(fn(array $candidates): array => Planner::actions($candidates), $this->repo->candidates($usernames)));
|
||||||
|
return ['mailboxes' => $plan, 'next' => (count($usernames) < $limit) ? null : (string)end($usernames)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Results of the nightly run (system context): mailboxes with found mail or errors, a summary per rule and at
|
||||||
|
* the end the whole run
|
||||||
|
*
|
||||||
|
* @throws UserError for more than MAX_RESULTS results
|
||||||
|
*/
|
||||||
|
public function report(array $results, array $summary, ?array $run = null): int
|
||||||
|
{
|
||||||
|
if (count($results) > self::MAX_RESULTS || count($summary) > self::MAX_RESULTS * 10) {
|
||||||
|
throw new UserError('retention_entries_exceeded', [self::MAX_RESULTS]);
|
||||||
|
}
|
||||||
|
$results = array_filter($results, fn(mixed $result): bool => is_array($result) && is_string($result['username'] ?? null)
|
||||||
|
&& is_numeric($result['rule_id'] ?? null) && is_string($result['folder'] ?? null));
|
||||||
|
$summary = array_filter($summary, fn(mixed $runOfRule): bool => is_array($runOfRule) && is_numeric($runOfRule['rule_id'] ?? null));
|
||||||
|
return $this->repo->addLog(array_values($results), array_values($summary), $run);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The domain was deleted: its rules went with it (foreign key), the results of its runs go now */
|
||||||
|
public function domainDeleted(string $domain): void
|
||||||
|
{
|
||||||
|
$this->repo->deleteLog($domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function visible(Rule $rule): bool
|
||||||
|
{
|
||||||
|
return $this->context->hasAdminRights() || $this->context->canAccessDomain($rule->domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function canUseGroups(): bool
|
||||||
|
{
|
||||||
|
return $this->context->hasAdminRights() || $this->context->hasAcl('mailbox_groups');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws UserError */
|
||||||
|
private function editable(int $id): Rule
|
||||||
|
{
|
||||||
|
$this->requireEditor();
|
||||||
|
$rule = $this->get($id);
|
||||||
|
if ($rule === null || !$this->canEdit($rule)) {
|
||||||
|
throw new AccessDenied();
|
||||||
|
}
|
||||||
|
return $rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws UserError */
|
||||||
|
private function domainFor(mixed $value): string
|
||||||
|
{
|
||||||
|
$domain = strtolower((string)Input::text($value));
|
||||||
|
if ($domain === '' || !$this->repo->isDomain($domain)) {
|
||||||
|
throw new UserError('domain_not_found', [htmlspecialchars((string)Input::text($value))]);
|
||||||
|
}
|
||||||
|
if (!$this->context->hasAdminRights() && !$this->context->canAccessDomain($domain)) {
|
||||||
|
throw new AccessDenied();
|
||||||
|
}
|
||||||
|
return $domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws UserError */
|
||||||
|
private function apply(Rule $rule, array $input): void
|
||||||
|
{
|
||||||
|
if (array_key_exists('name', $input) || $rule->id === null) {
|
||||||
|
$name = Input::text($input['name'] ?? null);
|
||||||
|
if ($name === null || $name === '' || mb_strlen($name) > 100 || preg_match('/[\x00-\x1f\x7f]/', $name)) {
|
||||||
|
throw new UserError('retention_name_invalid');
|
||||||
|
}
|
||||||
|
if ($this->repo->nameExists($rule->domain, $name, $rule->id)) {
|
||||||
|
throw new UserError('object_exists', [htmlspecialchars($name)]);
|
||||||
|
}
|
||||||
|
$rule->name = $name;
|
||||||
|
}
|
||||||
|
if (array_key_exists('description', $input)) {
|
||||||
|
$description = Input::text($input['description']);
|
||||||
|
if ($description === null || mb_strlen($description) > 255 || preg_match('/[\x00-\x1f\x7f]/', $description)) {
|
||||||
|
throw new UserError('retention_description_invalid');
|
||||||
|
}
|
||||||
|
$rule->description = $description;
|
||||||
|
}
|
||||||
|
// the UI sends the special folders and the own folder names apart (own_folders, one per line or a list)
|
||||||
|
if (array_key_exists('folders', $input) || array_key_exists('own_folders', $input)) {
|
||||||
|
$folders = array_key_exists('folders', $input) ? self::values($input['folders'])
|
||||||
|
: array_filter($rule->folders, fn(string $folder): bool => in_array($folder, Rule::FOLDERS, true));
|
||||||
|
if (array_key_exists('own_folders', $input)) {
|
||||||
|
$folders = array_filter($folders, fn(string $folder): bool => !str_starts_with($folder, Rule::OWN_FOLDER));
|
||||||
|
$names = is_array($input['own_folders']) ? $input['own_folders'] : preg_split('/\r?\n/', (string)Input::text($input['own_folders']));
|
||||||
|
foreach ($names as $name) {
|
||||||
|
if (!is_string($name)) {
|
||||||
|
throw new UserError('retention_folder_invalid', ['']);
|
||||||
|
}
|
||||||
|
if (trim($name) !== '') {
|
||||||
|
$folders[] = Rule::OWN_FOLDER . trim($name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$rule->folders = self::folders($folders);
|
||||||
|
}
|
||||||
|
if ($rule->folders === []) {
|
||||||
|
throw new UserError('retention_folders_invalid');
|
||||||
|
}
|
||||||
|
if (array_key_exists('days', $input) || $rule->id === null) {
|
||||||
|
$days = Input::intInRange($input['days'] ?? null, 1, self::MAX_DAYS);
|
||||||
|
$rule->days = $days ?? throw new UserError('retention_days_invalid', [self::MAX_DAYS]);
|
||||||
|
}
|
||||||
|
if (array_key_exists('mode', $input)) {
|
||||||
|
$mode = Input::text($input['mode']);
|
||||||
|
$rule->mode = in_array($mode, Rule::MODES, true) ? $mode : throw new UserError('retention_mode_invalid');
|
||||||
|
}
|
||||||
|
foreach (['keep_flagged' => 'keepFlagged', 'all_mailboxes' => 'allMailboxes', 'active' => 'active'] as $key => $property) {
|
||||||
|
if (array_key_exists($key, $input)) {
|
||||||
|
$rule->$property = (int)self::flag($input[$key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (array_key_exists('mailboxes', $input)) {
|
||||||
|
$rule->mailboxes = [];
|
||||||
|
foreach (self::limited($input['mailboxes'], self::MAX_MEMBERS) as $username) {
|
||||||
|
$username = strtolower($username);
|
||||||
|
if ($this->repo->mailboxDomain($username) !== $rule->domain) {
|
||||||
|
throw new UserError('retention_member_invalid', [htmlspecialchars($username)]);
|
||||||
|
}
|
||||||
|
$rule->mailboxes[$username] = $username;
|
||||||
|
}
|
||||||
|
$rule->mailboxes = array_values($rule->mailboxes);
|
||||||
|
}
|
||||||
|
// without access to groups the assignments stay; only sending them back unchanged is accepted
|
||||||
|
$groupAccess = $this->canUseGroups();
|
||||||
|
if (array_key_exists('groups', $input) && !$groupAccess && self::values($input['groups']) !== []) {
|
||||||
|
$sent = array_values(array_unique(array_map('intval', self::values($input['groups']))));
|
||||||
|
$stored = $rule->groups;
|
||||||
|
sort($sent);
|
||||||
|
sort($stored);
|
||||||
|
if ($sent !== $stored) {
|
||||||
|
throw new AccessDenied();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (array_key_exists('groups', $input) && $groupAccess) {
|
||||||
|
$rule->groups = [];
|
||||||
|
foreach (self::limited($input['groups'], self::MAX_MEMBERS) as $value) {
|
||||||
|
$id = Input::intInRange($value, 1, PHP_INT_MAX);
|
||||||
|
if ($id === null || $this->repo->groupDomain($id) !== $rule->domain) {
|
||||||
|
throw new UserError('retention_member_invalid', [htmlspecialchars($value)]);
|
||||||
|
}
|
||||||
|
$rule->groups[$id] = $id;
|
||||||
|
}
|
||||||
|
$rule->groups = array_values($rule->groups);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Folders of a rule: keys of Rule::FOLDERS and folder:<name>, without repeats. Names are Dovecot folder names
|
||||||
|
* (separator /, wildcard *), printable and at most 255 characters; INBOX is written in capitals like Dovecot does.
|
||||||
|
*
|
||||||
|
* @param string[] $values
|
||||||
|
* @return string[]
|
||||||
|
* @throws UserError
|
||||||
|
*/
|
||||||
|
private static function folders(array $values): array
|
||||||
|
{
|
||||||
|
$folders = [];
|
||||||
|
foreach (self::limited($values, self::MAX_FOLDERS) as $folder) {
|
||||||
|
if (str_starts_with($folder, Rule::OWN_FOLDER)) {
|
||||||
|
$name = trim(substr($folder, strlen(Rule::OWN_FOLDER)), " \t/");
|
||||||
|
if ($name === '' || mb_strlen($name) > 255 || preg_match('/[\x00-\x1f\x7f]/u', $name) || !mb_check_encoding($name, 'UTF-8')) {
|
||||||
|
throw new UserError('retention_folder_invalid', [htmlspecialchars($folder)]);
|
||||||
|
}
|
||||||
|
$name = preg_replace('~^inbox(?=/|$)~i', 'INBOX', $name);
|
||||||
|
$folder = Rule::OWN_FOLDER . $name;
|
||||||
|
} elseif (!in_array($folder, Rule::FOLDERS, true)) {
|
||||||
|
throw new UserError('retention_folder_invalid', [htmlspecialchars($folder)]);
|
||||||
|
}
|
||||||
|
$folders[$folder] = $folder;
|
||||||
|
}
|
||||||
|
return array_values($folders);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Values of a list field without repeats, at most $max (checked before anything is looked up)
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
* @throws UserError
|
||||||
|
*/
|
||||||
|
private static function limited(mixed $value, int $max): array
|
||||||
|
{
|
||||||
|
$values = array_values(array_unique(self::values($value)));
|
||||||
|
if (count($values) > $max) {
|
||||||
|
throw new UserError('retention_entries_exceeded', [$max]);
|
||||||
|
}
|
||||||
|
return $values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Values of a list field: an array (multi-select, JSON) or a string separated by line breaks or commas.
|
||||||
|
*
|
||||||
|
* @return string[]
|
||||||
|
*/
|
||||||
|
private static function values(mixed $value): array
|
||||||
|
{
|
||||||
|
$values = is_array($value) ? $value : preg_split('/[\r\n,;]+/', (string)Input::text($value));
|
||||||
|
return array_values(array_filter(array_map(fn(mixed $v): string => is_scalar($v) ? trim((string)$v) : '', $values), 'strlen'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checkbox or API flag; a hidden "0" + checkbox pair arrives as ["0", "1"] when checked */
|
||||||
|
private static function flag(mixed $value): bool
|
||||||
|
{
|
||||||
|
if (is_array($value)) {
|
||||||
|
$value = end($value);
|
||||||
|
}
|
||||||
|
return filter_var($value, FILTER_VALIDATE_BOOLEAN);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,8 +19,10 @@ if ($iam_provider){
|
|||||||
header('Location: ' . $redirect_uri);
|
header('Location: ' . $redirect_uri);
|
||||||
die();
|
die();
|
||||||
}
|
}
|
||||||
} elseif ($_GET['code'] && $_GET['state'] === $_SESSION['oauth2state']) {
|
} elseif (!empty($_GET['code']) && !empty($_SESSION['oauth2state']) && is_string($_GET['state'] ?? null)
|
||||||
// Check given state against previously stored one to mitigate CSRF attack
|
&& hash_equals((string)$_SESSION['oauth2state'], $_GET['state'])) {
|
||||||
|
// Check given state against previously stored one to mitigate CSRF attack, a state is used once
|
||||||
|
unset($_SESSION['oauth2state']);
|
||||||
// Received access token in $_GET['code']
|
// Received access token in $_GET['code']
|
||||||
// extract info and verify user
|
// extract info and verify user
|
||||||
identity_provider('verify-sso');
|
identity_provider('verify-sso');
|
||||||
|
|||||||
@@ -169,6 +169,12 @@ $SESSION_NAME = "MCSESSID";
|
|||||||
// Label for OTP devices
|
// Label for OTP devices
|
||||||
$OTP_LABEL = "mailcow UI";
|
$OTP_LABEL = "mailcow UI";
|
||||||
|
|
||||||
|
// Failed second factor logins (TOTP, Yubico OTP, WebAuthn) of an account before its second factor is locked
|
||||||
|
$TFA_MAX_FAILS = 10;
|
||||||
|
|
||||||
|
// Time (in s) from the first failed second factor login until the counter and the lock are reset
|
||||||
|
$TFA_FAIL_WINDOW = 900;
|
||||||
|
|
||||||
// How long to wait (in s) for cURL Docker requests
|
// How long to wait (in s) for cURL Docker requests
|
||||||
$DOCKER_TIMEOUT = 60;
|
$DOCKER_TIMEOUT = 60;
|
||||||
|
|
||||||
@@ -291,13 +297,13 @@ $RSPAMD_MAPS = array(
|
|||||||
// true = option requires a non-empty value
|
// true = option requires a non-empty value
|
||||||
// false = pure flag, must NOT have a value
|
// false = pure flag, must NOT have a value
|
||||||
// string = PCRE (no delimiters) the whole non-empty value must match
|
// 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).
|
// Anything not listed here is rejected when a job is saved; the runner passes stored options as they are, except the blocked ones below.
|
||||||
// Override in vars.local.inc.php per option, e.g. $IMAPSYNC_OPTIONS['someoption'] = true; or unset($IMAPSYNC_OPTIONS['debug']);
|
// 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).
|
// 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', 'oauthrefreshcmd1', 'oauthrefreshcmd2'
|
// NEVER add (RCE): 'delete2foldersonly', 'delete2foldersbutnot', 'regexflag', 'regexmess', 'pipemess', 'regextrans2', 'maxlinelengthcmd', 'oauthrefreshcmd1', 'oauthrefreshcmd2'
|
||||||
// NEVER add options taking local file paths (e.g. 'logfile', 'logdir', 'pidfile', 'passfile1', 'sslargs1' with SSL_ca_file)
|
// NEVER add options taking local file paths (e.g. 'logfile', 'logdir', 'pidfile', 'passfile1', 'sslargs1' with SSL_ca_file)
|
||||||
|
// These, the tests* options (print the environment), 'showpasswords' and 'abort' (stops other runs) are also blocked in code
|
||||||
$IMAPSYNC_OPTIONS_DEFAULT = array(
|
$IMAPSYNC_OPTIONS_DEFAULT = array(
|
||||||
'abort' => false,
|
|
||||||
'authmd51' => false,
|
'authmd51' => false,
|
||||||
'authuser1' => true,
|
'authuser1' => true,
|
||||||
'debug' => false,
|
'debug' => false,
|
||||||
@@ -373,9 +379,6 @@ $IMAPSYNC_OPTIONS_DEFAULT = array(
|
|||||||
'syncduplicates' => false,
|
'syncduplicates' => false,
|
||||||
'syncinternaldates' => false,
|
'syncinternaldates' => false,
|
||||||
'synclabels' => false,
|
'synclabels' => false,
|
||||||
'tests' => false,
|
|
||||||
'testslive' => false,
|
|
||||||
'testslive6' => false,
|
|
||||||
'truncmess' => true,
|
'truncmess' => true,
|
||||||
'usecache' => false,
|
'usecache' => false,
|
||||||
'useheader' => true,
|
'useheader' => true,
|
||||||
|
|||||||
@@ -875,6 +875,93 @@ jQuery(function($){
|
|||||||
hideTableExpandCollapseBtn('#tab-cron-logs', '#cron_log');
|
hideTableExpandCollapseBtn('#tab-cron-logs', '#cron_log');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// results of the nightly retention runs: mailboxes with found mail or errors, and the runs (username '', no rule)
|
||||||
|
function draw_retention_logs() {
|
||||||
|
// just recalc width if instance already exists
|
||||||
|
if ($.fn.DataTable.isDataTable('#retention_log') ) {
|
||||||
|
$('#retention_log').DataTable().columns.adjust().responsive.recalc();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var L = function(key) { return (lang.retention && lang.retention[key]) || ''; };
|
||||||
|
$('#retention_log').DataTable({
|
||||||
|
responsive: true,
|
||||||
|
processing: true,
|
||||||
|
serverSide: false,
|
||||||
|
stateSave: true,
|
||||||
|
pageLength: log_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: [[0, 'desc']],
|
||||||
|
ajax: {
|
||||||
|
type: "GET",
|
||||||
|
url: "/api/v1/get/logs/retention",
|
||||||
|
dataSrc: function(data){
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
title: lang.time,
|
||||||
|
data: 'time',
|
||||||
|
defaultContent: '',
|
||||||
|
createdCell: function(td, cellData) {
|
||||||
|
createSortableDate(td, cellData)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: L('mailbox'),
|
||||||
|
data: 'username',
|
||||||
|
defaultContent: '',
|
||||||
|
render: function(data, type, row) {
|
||||||
|
return row.rule_id === null ? '<i>' + escapeHtml(L('run')) + '</i>' : escapeHtml(data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: L('rule'),
|
||||||
|
data: 'rule_name',
|
||||||
|
defaultContent: '',
|
||||||
|
render: function(data) {
|
||||||
|
return escapeHtml(data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: L('folder'),
|
||||||
|
data: 'folder',
|
||||||
|
defaultContent: '',
|
||||||
|
className: 'text-break',
|
||||||
|
render: function(data, type, row) {
|
||||||
|
if (row.rule_id === null) return escapeHtml(L('run_mailboxes').replace('%s', row.mailboxes));
|
||||||
|
return (data || '').split('\n').map(escapeHtml).join('<br>') + ' <span class="text-muted small">(' + escapeHtml(L('days_short').replace('%s', row.days)) + ')</span>';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: L('found'),
|
||||||
|
data: 'matched',
|
||||||
|
defaultContent: ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: L('deleted'),
|
||||||
|
data: 'deleted',
|
||||||
|
defaultContent: '',
|
||||||
|
render: function(data, type, row) {
|
||||||
|
return row.simulated == 1 ? '<span class="badge bg-secondary">' + escapeHtml(L('simulated')) + '</span>' : escapeHtml(data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: L('error'),
|
||||||
|
data: 'error',
|
||||||
|
defaultContent: '',
|
||||||
|
className: 'dtr-col-md text-break',
|
||||||
|
render: function(data, type, row) {
|
||||||
|
if (!data && row.rule_id === null && row.errors > 0) data = L('errors').replace('%s', row.errors);
|
||||||
|
return data ? '<span class="text-danger">' + escapeHtml(data) + '</span>' : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
function rspamd_pie_graph() {
|
function rspamd_pie_graph() {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: '/api/v1/get/rspamd/actions',
|
url: '/api/v1/get/rspamd/actions',
|
||||||
@@ -1295,6 +1382,7 @@ jQuery(function($){
|
|||||||
onVisible("[id^=sasl_logs]", () => draw_sasl_logs());
|
onVisible("[id^=sasl_logs]", () => draw_sasl_logs());
|
||||||
onVisible("[id^=netfilter_log]", () => draw_netfilter_logs());
|
onVisible("[id^=netfilter_log]", () => draw_netfilter_logs());
|
||||||
onVisible("[id^=cron_log]", () => draw_cron_logs());
|
onVisible("[id^=cron_log]", () => draw_cron_logs());
|
||||||
|
onVisible("[id^=retention_log]", () => draw_retention_logs());
|
||||||
onVisible("[id^=rspamd_history]", () => draw_rspamd_history());
|
onVisible("[id^=rspamd_history]", () => draw_rspamd_history());
|
||||||
onVisible("[id^=rspamd_donut]", () => rspamd_pie_graph());
|
onVisible("[id^=rspamd_donut]", () => rspamd_pie_graph());
|
||||||
|
|
||||||
|
|||||||
@@ -184,7 +184,11 @@ var mailcowImapsync = (function($) {
|
|||||||
dataSrc: function(json) {
|
dataSrc: function(json) {
|
||||||
if (!Array.isArray(json)) return [];
|
if (!Array.isArray(json)) return [];
|
||||||
$.each(json, function(i, item) {
|
$.each(json, function(i, item) {
|
||||||
item.endpoint = wrapCell(esc((item.host || item.host1) + ':' + (item.port || item.port1) + ' (' + (item.enc || item.enc1) + ')'));
|
item.endpoint = esc((item.host || item.host1) + ':' + (item.port || item.port1) + ' (' + (item.enc || item.enc1) + ')');
|
||||||
|
if (item.verify_cert == 0 && (item.enc || item.enc1) !== 'PLAIN') {
|
||||||
|
item.endpoint += ' <i class="bi bi-shield-exclamation text-warning" title="' + esc(L('source_verify_cert_off')) + '"></i>';
|
||||||
|
}
|
||||||
|
item.endpoint = wrapCell(item.endpoint);
|
||||||
item.name_display = esc(item.name);
|
item.name_display = esc(item.name);
|
||||||
if (item.description) item.name_display += '<div class="small text-muted">' + esc(item.description) + '</div>';
|
if (item.description) item.name_display += '<div class="small text-muted">' + esc(item.description) + '</div>';
|
||||||
item.name_display = wrapCell(item.name_display);
|
item.name_display = wrapCell(item.name_display);
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
// Mail policies (admin/domainadmin mailbox page): list, domain choices of the add modal, test of a rule. Loaded via $js_minifier.
|
||||||
|
var mailcowMailPolicy = (function($) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// lang.mail_policy of the mailbox page, '' if missing
|
||||||
|
function L(key) {
|
||||||
|
var src = (typeof lang !== 'undefined' && lang && lang.mail_policy) ? lang.mail_policy : {};
|
||||||
|
return (src[key] !== undefined && src[key] !== null) ? src[key] : '';
|
||||||
|
}
|
||||||
|
function esc(s) {
|
||||||
|
return escapeHtml(s === null || s === undefined ? '' : s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// mode of one direction with the number of allowed and blocked entries
|
||||||
|
function sideCell(mode, allow, block) {
|
||||||
|
var html = esc(L('summary_' + mode));
|
||||||
|
var counts = [];
|
||||||
|
if (mode !== 'all' && allow.length) counts.push('+' + allow.length + ' ' + esc(L('summary_allowed')));
|
||||||
|
if (block.length) counts.push(block.length + ' ' + esc(L('summary_blocked')));
|
||||||
|
return html + (counts.length ? '<div class="small text-muted">' + counts.join(', ') + '</div>' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignedCell(item) {
|
||||||
|
var list = (item.mailboxes || []).slice(0, 3).map(esc);
|
||||||
|
if ((item.mailboxes || []).length > 3) list.push('<span class="text-muted">+' + (item.mailboxes.length - 3) + '</span>');
|
||||||
|
if ((item.groups || []).length) list.push('<i class="bi bi-people"></i> ' + esc(L('groups')) + ': ' + item.groups.length);
|
||||||
|
return list.length ? list.join('<br>') : '<span class="text-muted">' + esc(L('assigned_none')) + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawTable() {
|
||||||
|
if ($.fn.DataTable.isDataTable('#mail_policy_table')) {
|
||||||
|
$('#mail_policy_table').DataTable().columns.adjust().responsive.recalc();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$('#mail_policy_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: [[3, 'asc']],
|
||||||
|
ajax: {
|
||||||
|
type: "GET",
|
||||||
|
url: "/api/v1/get/mail-policy/all",
|
||||||
|
dataSrc: function(json) {
|
||||||
|
if (!Array.isArray(json)) return [];
|
||||||
|
$.each(json, function(i, item) {
|
||||||
|
item.chkbox = item.can_edit ?
|
||||||
|
'<input type="checkbox" class="form-check-input" data-id="mail_policy" name="multi_select" value="' + encodeURIComponent(item.id) + '" />' :
|
||||||
|
'<input type="checkbox" class="form-check-input" disabled />';
|
||||||
|
item.name_display = esc(item.name) + (item.description ? '<div class="small text-muted">' + esc(item.description) + '</div>' : '');
|
||||||
|
item.send_display = sideCell(item.send_mode, item.send_allow || [], item.send_block || []);
|
||||||
|
item.receive_display = sideCell(item.receive_mode, item.receive_allow || [], item.receive_block || []);
|
||||||
|
item.assigned_display = assignedCell(item);
|
||||||
|
item.action = '<div class="btn-group">' +
|
||||||
|
'<a href="/edit/mail-policy/' + encodeURIComponent(item.id) + '" class="btn btn-sm btn-xs-lg btn-xs-half btn-secondary"><i class="bi bi-pencil-fill"></i> ' + esc(item.can_edit ? lang.edit : L('view')) + '</a>' +
|
||||||
|
(item.can_edit ? '<a href="#" data-action="delete_selected" data-id="single-mail_policy" data-api-url="delete/mail-policy" 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>';
|
||||||
|
});
|
||||||
|
return json;
|
||||||
|
},
|
||||||
|
error: function() {
|
||||||
|
mailcow_alert_box(L('list_error'), 'danger');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
// placeholder, so checkbox will not block child row toggle
|
||||||
|
title: '',
|
||||||
|
data: null,
|
||||||
|
searchable: false,
|
||||||
|
orderable: false,
|
||||||
|
defaultContent: '',
|
||||||
|
responsivePriority: 1
|
||||||
|
},
|
||||||
|
{ title: '', data: 'chkbox', searchable: false, orderable: false, defaultContent: '', responsivePriority: 2 },
|
||||||
|
{ title: 'ID', data: 'id', defaultContent: '', responsivePriority: 6 },
|
||||||
|
{ title: L('name'), data: 'name_display', defaultContent: '', responsivePriority: 2 },
|
||||||
|
{ title: L('domain'), data: 'domain', defaultContent: '', responsivePriority: 5 },
|
||||||
|
{ title: L('send'), data: 'send_display', defaultContent: '', responsivePriority: 3 },
|
||||||
|
{ title: L('receive'), data: 'receive_display', defaultContent: '', responsivePriority: 3 },
|
||||||
|
{ title: L('assign'), data: 'assigned_display', defaultContent: '', responsivePriority: 4 },
|
||||||
|
{
|
||||||
|
title: lang.active,
|
||||||
|
data: 'active',
|
||||||
|
defaultContent: '',
|
||||||
|
responsivePriority: 6,
|
||||||
|
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: ''
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// add modal: mailboxes and groups of the chosen domain only. The options are rebuilt and bootstrap-select is
|
||||||
|
// built anew: its refresh repeats the list and still shows hidden options
|
||||||
|
function filterDomain($form) {
|
||||||
|
var domain = $form.find('select.mail-policy-domain-select').val();
|
||||||
|
$form.find('select.mail-policy-mailboxes, select.mail-policy-groups').each(function() {
|
||||||
|
var $select = $(this);
|
||||||
|
if (!$select.data('all-options')) $select.data('all-options', $select.children('option').clone());
|
||||||
|
$select.selectpicker('destroy');
|
||||||
|
$select.empty().append($select.data('all-options').filter(function() {
|
||||||
|
return $(this).data('domain') === domain;
|
||||||
|
}).clone().prop('selected', false));
|
||||||
|
$select.selectpicker({'styleBase': 'btn btn-xs-lg', 'noneSelectedText': lang_footer.nothing_selected});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function test(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var sender = $.trim($('#mail_policy_test_sender').val());
|
||||||
|
var recipient = $.trim($('#mail_policy_test_recipient').val());
|
||||||
|
if (!recipient) return;
|
||||||
|
// an empty sender (bounces) is sent as "-"
|
||||||
|
var url = '/api/v1/get/mail-policy/test/' + encodeURIComponent(sender || '-') + '/' + encodeURIComponent(recipient) +
|
||||||
|
($('#mail_policy_test_login').is(':checked') ? '/login' : '');
|
||||||
|
$.get(url, function(r) {
|
||||||
|
var html;
|
||||||
|
if (!r || r.allowed === undefined) {
|
||||||
|
html = '<div class="alert alert-danger py-2">' + esc(L('test_invalid')) + '</div>';
|
||||||
|
} else if (r.allowed == 1) {
|
||||||
|
html = '<div class="alert alert-success py-2">' + esc(L('test_allowed')) + '</div>';
|
||||||
|
} else {
|
||||||
|
var reason = r.internal_alias == 1 ? L('test_denied_internal') : r.side === 'address' ? L('test_denied_address') :
|
||||||
|
(r.policy ? L('test_denied_' + r.side).replace('%s', r.policy) : L('test_denied_other'));
|
||||||
|
html = '<div class="alert alert-danger py-2">' + esc(reason) + '<div class="small mt-1"><code>550 5.7.1 ' + esc(r.text) + '</code></div></div>';
|
||||||
|
}
|
||||||
|
if (r && r.allowed !== undefined && sender && r.logged_in == 0 && $('#mail_policy_test_login').is(':checked')) {
|
||||||
|
html += '<div class="small text-muted">' + esc(L('test_not_a_mailbox')) + '</div>';
|
||||||
|
}
|
||||||
|
$('#mail_policy_test_result').html(html);
|
||||||
|
}, 'json').fail(function() {
|
||||||
|
$('#mail_policy_test_result').html('<div class="alert alert-danger py-2">' + esc(lang_danger.generic_server_error) + '</div>');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$(document).ready(function() {
|
||||||
|
$('select.mail-policy-domain-select').on('change', function() {
|
||||||
|
filterDomain($(this).closest('form'));
|
||||||
|
});
|
||||||
|
$('#addMailPolicyModal').on('show.bs.modal', function() {
|
||||||
|
filterDomain($(this).find('form'));
|
||||||
|
});
|
||||||
|
$('#mail_policy_test').on('submit', test);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
drawTable: drawTable
|
||||||
|
};
|
||||||
|
})(jQuery);
|
||||||
@@ -2493,6 +2493,8 @@ jQuery(function($){
|
|||||||
onVisible("[id^=sync_job_table]", () => draw_sync_job_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^=imapsync_source_table]", () => mailcowImapsync.drawSourceTable(role === 'admin' || acl_data.syncjobs === 1));
|
||||||
onVisible("[id^=mailbox_group_table]", () => mailcowGroups.drawTable());
|
onVisible("[id^=mailbox_group_table]", () => mailcowGroups.drawTable());
|
||||||
|
onVisible("[id^=mail_policy_table]", () => mailcowMailPolicy.drawTable());
|
||||||
|
onVisible("[id^=retention_table]", () => mailcowRetention.drawTable());
|
||||||
onVisible("[id^=filter_table]", () => draw_filter_table());
|
onVisible("[id^=filter_table]", () => draw_filter_table());
|
||||||
onVisible("[id^=bcc_table]", () => draw_bcc_table());
|
onVisible("[id^=bcc_table]", () => draw_bcc_table());
|
||||||
onVisible("[id^=recipient_map_table]", () => draw_recipient_map_table());
|
onVisible("[id^=recipient_map_table]", () => draw_recipient_map_table());
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
// Retention rules (admin/domainadmin mailbox page): list with the last nightly run, domain choices of the add modal.
|
||||||
|
// The log of the runs is on the dashboard (draw_retention_logs). Loaded via $js_minifier.
|
||||||
|
var mailcowRetention = (function($) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// lang.retention of the mailbox page, '' if missing
|
||||||
|
function L(key) {
|
||||||
|
var src = (typeof lang !== 'undefined' && lang && lang.retention) ? lang.retention : {};
|
||||||
|
return (src[key] !== undefined && src[key] !== null) ? src[key] : '';
|
||||||
|
}
|
||||||
|
function esc(s) {
|
||||||
|
return escapeHtml(s === null || s === undefined ? '' : s);
|
||||||
|
}
|
||||||
|
// replaces the %s of a text one after the other
|
||||||
|
function fill(text) {
|
||||||
|
var args = Array.prototype.slice.call(arguments, 1);
|
||||||
|
return text.replace(/%s/g, function() { return args.length ? args.shift() : ''; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function folderLabel(folder) {
|
||||||
|
return folder.indexOf('folder:') === 0 ? folder.substr(7) : (L('folder_' + folder) || folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
function foldersCell(item) {
|
||||||
|
return (item.folders || []).map(function(folder) { return esc(folderLabel(folder)); }).join('<br>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function periodCell(item) {
|
||||||
|
return esc(fill(L('days_short'), item.days)) + (item.keep_flagged == 1 ? '<div class="small text-muted">' + esc(L('keep_flagged')) + '</div>' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeCell(mode) {
|
||||||
|
return mode === 'delete' ? '<span class="badge bg-danger">' + esc(L('mode_delete')) + '</span>'
|
||||||
|
: '<span class="badge bg-secondary">' + esc(L('simulated')) + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function assignedCell(item) {
|
||||||
|
var list = [];
|
||||||
|
if (item.all_mailboxes == 1) list.push(esc(L('all_mailboxes')));
|
||||||
|
(item.mailboxes || []).slice(0, 3).forEach(function(mailbox) { list.push(esc(mailbox)); });
|
||||||
|
if ((item.mailboxes || []).length > 3) list.push('<span class="text-muted">+' + (item.mailboxes.length - 3) + '</span>');
|
||||||
|
if ((item.groups || []).length) list.push('<i class="bi bi-people"></i> ' + esc(L('groups')) + ': ' + item.groups.length);
|
||||||
|
return list.length ? list.join('<br>') : '<span class="text-muted">' + esc(L('assigned_none')) + '</span>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastRunCell(run) {
|
||||||
|
if (!run) return '<span class="text-muted">' + esc(L('never_run')) + '</span>';
|
||||||
|
var text = run.simulated == 1 ? fill(L('run_simulated'), run.mailboxes, run.matched) : fill(L('run_deleted'), run.mailboxes, run.deleted);
|
||||||
|
return esc(run.created) + '<div class="small text-muted">' + esc(text) + '</div>'
|
||||||
|
+ (run.errors > 0 ? '<div class="small text-danger">' + esc(fill(L('errors'), run.errors)) + '</div>' : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function drawTable() {
|
||||||
|
if ($.fn.DataTable.isDataTable('#retention_table')) {
|
||||||
|
$('#retention_table').DataTable().columns.adjust().responsive.recalc();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$('#retention_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: [[3, 'asc']],
|
||||||
|
ajax: {
|
||||||
|
type: "GET",
|
||||||
|
url: "/api/v1/get/retention-rule/all",
|
||||||
|
dataSrc: function(json) {
|
||||||
|
if (!Array.isArray(json)) return [];
|
||||||
|
$.each(json, function(i, item) {
|
||||||
|
item.chkbox = item.can_edit ?
|
||||||
|
'<input type="checkbox" class="form-check-input" data-id="retention_rule" name="multi_select" value="' + encodeURIComponent(item.id) + '" />' :
|
||||||
|
'<input type="checkbox" class="form-check-input" disabled />';
|
||||||
|
item.name_display = esc(item.name) + (item.description ? '<div class="small text-muted">' + esc(item.description) + '</div>' : '');
|
||||||
|
item.folders_display = foldersCell(item);
|
||||||
|
item.period_display = periodCell(item);
|
||||||
|
item.mode_display = modeCell(item.mode);
|
||||||
|
item.assigned_display = assignedCell(item);
|
||||||
|
item.last_run_display = lastRunCell(item.last_run);
|
||||||
|
item.action = '<div class="btn-group">' +
|
||||||
|
'<a href="/edit/retention-rule/' + encodeURIComponent(item.id) + '" class="btn btn-sm btn-xs-lg btn-xs-half btn-secondary"><i class="bi bi-pencil-fill"></i> ' + esc(item.can_edit ? lang.edit : L('view')) + '</a>' +
|
||||||
|
(item.can_edit ? '<a href="#" data-action="delete_selected" data-id="single-retention_rule" data-api-url="delete/retention-rule" 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>';
|
||||||
|
});
|
||||||
|
return json;
|
||||||
|
},
|
||||||
|
error: function() {
|
||||||
|
mailcow_alert_box(L('list_error'), 'danger');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
// placeholder, so checkbox will not block child row toggle
|
||||||
|
title: '',
|
||||||
|
data: null,
|
||||||
|
searchable: false,
|
||||||
|
orderable: false,
|
||||||
|
defaultContent: '',
|
||||||
|
responsivePriority: 1
|
||||||
|
},
|
||||||
|
{ title: '', data: 'chkbox', searchable: false, orderable: false, defaultContent: '', responsivePriority: 2 },
|
||||||
|
{ title: 'ID', data: 'id', defaultContent: '', responsivePriority: 7 },
|
||||||
|
{ title: L('name'), data: 'name_display', defaultContent: '', responsivePriority: 2 },
|
||||||
|
{ title: L('domain'), data: 'domain', defaultContent: '', responsivePriority: 5 },
|
||||||
|
{ title: L('folders'), data: 'folders_display', defaultContent: '', responsivePriority: 3 },
|
||||||
|
{ title: L('days'), data: 'period_display', defaultContent: '', responsivePriority: 3 },
|
||||||
|
{ title: L('mode'), data: 'mode_display', defaultContent: '', responsivePriority: 4 },
|
||||||
|
{ title: L('assign'), data: 'assigned_display', defaultContent: '', responsivePriority: 5 },
|
||||||
|
{ title: L('last_run'), data: 'last_run_display', defaultContent: '', responsivePriority: 4 },
|
||||||
|
{
|
||||||
|
title: lang.active,
|
||||||
|
data: 'active',
|
||||||
|
defaultContent: '',
|
||||||
|
responsivePriority: 6,
|
||||||
|
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: ''
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// add modal: mailboxes and groups of the chosen domain only. The options are rebuilt and bootstrap-select is
|
||||||
|
// built anew: its refresh repeats the list and still shows hidden options
|
||||||
|
function filterDomain($form) {
|
||||||
|
var domain = $form.find('select.retention-domain-select').val();
|
||||||
|
$form.find('select.retention-mailboxes, select.retention-groups').each(function() {
|
||||||
|
var $select = $(this);
|
||||||
|
if (!$select.data('all-options')) $select.data('all-options', $select.children('option').clone());
|
||||||
|
$select.selectpicker('destroy');
|
||||||
|
$select.empty().append($select.data('all-options').filter(function() {
|
||||||
|
return $(this).data('domain') === domain;
|
||||||
|
}).clone().prop('selected', false));
|
||||||
|
$select.selectpicker({'styleBase': 'btn btn-xs-lg', 'noneSelectedText': lang_footer.nothing_selected});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$(document).ready(function() {
|
||||||
|
$('select.retention-domain-select').on('change', function() {
|
||||||
|
filterDomain($(this).closest('form'));
|
||||||
|
});
|
||||||
|
$('#addRetentionModal').on('show.bs.modal', function() {
|
||||||
|
filterDomain($(this).find('form'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
drawTable: drawTable
|
||||||
|
};
|
||||||
|
})(jQuery);
|
||||||
+85
-2
@@ -105,9 +105,28 @@ if (isset($_GET['query'])) {
|
|||||||
'msg' => 'Cannot find attributes in post data'
|
'msg' => 'Cannot find attributes in post data'
|
||||||
));
|
));
|
||||||
|
|
||||||
|
// A login with pending 2FA setup or password update may only use the calls of these steps
|
||||||
|
if (!empty($_SESSION['pending_tfa_setup']) || !empty($_SESSION['pending_pw_update'])) {
|
||||||
|
if (!empty($_SESSION['pending_tfa_setup'])) {
|
||||||
|
$pending_allowed = array('get/passwordpolicy', 'get/webauthn-tfa-registration', 'add/webauthn-tfa-registration');
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
// the WebAuthn login of a TFA account comes before the password update
|
||||||
|
$pending_allowed = array('get/passwordpolicy', 'get/webauthn-tfa-get-args', 'edit/self', 'edit/admin');
|
||||||
|
}
|
||||||
|
if (!in_array($action . '/' . $category, $pending_allowed)) {
|
||||||
|
http_response_code(403);
|
||||||
|
echo json_encode(array(
|
||||||
|
'type' => 'error',
|
||||||
|
'msg' => 'API access denied until the pending account setup is finished'
|
||||||
|
));
|
||||||
|
exit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch ($action) {
|
switch ($action) {
|
||||||
case "add":
|
case "add":
|
||||||
if ($_SESSION['mailcow_cc_api_access'] == 'ro' || isset($_SESSION['pending_mailcow_cc_username'])) {
|
if ($_SESSION['mailcow_cc_api_access'] == 'ro' || isset($_SESSION['pending_mailcow_cc_username']) || !isset($_SESSION["mailcow_cc_username"])) {
|
||||||
http_response_code(403);
|
http_response_code(403);
|
||||||
echo json_encode(array(
|
echo json_encode(array(
|
||||||
'type' => 'error',
|
'type' => 'error',
|
||||||
@@ -261,6 +280,12 @@ if (isset($_GET['query'])) {
|
|||||||
case "mailbox-group":
|
case "mailbox-group":
|
||||||
process_add_return(\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->addGroup($attr));
|
process_add_return(\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->addGroup($attr));
|
||||||
break;
|
break;
|
||||||
|
case "mail-policy":
|
||||||
|
process_add_return(\Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->api->addPolicy($attr));
|
||||||
|
break;
|
||||||
|
case "retention-rule":
|
||||||
|
process_add_return(\Mailcow\Retention\RetentionModule::fromGlobals()->api->addRule($attr));
|
||||||
|
break;
|
||||||
case "domain":
|
case "domain":
|
||||||
switch ($object) {
|
switch ($object) {
|
||||||
case "template":
|
case "template":
|
||||||
@@ -839,6 +864,40 @@ if (isset($_GET['query'])) {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "mail-policy":
|
||||||
|
switch ($object) {
|
||||||
|
case "all":
|
||||||
|
process_get_return(\Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->api->getPolicies($extra));
|
||||||
|
break;
|
||||||
|
|
||||||
|
// test/<sender>/<recipient>[/login]: would the mail be accepted?
|
||||||
|
case "test":
|
||||||
|
process_get_return(\Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->api->test((string)$extra, (string)($query[4] ?? ''), ($query[5] ?? '') === 'login'));
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
process_get_return(\Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->api->getPolicy($object));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "retention-rule":
|
||||||
|
switch ($object) {
|
||||||
|
case "all":
|
||||||
|
process_get_return(\Mailcow\Retention\RetentionModule::fromGlobals()->api->getRules($extra));
|
||||||
|
break;
|
||||||
|
|
||||||
|
// effective/<username>: what the rules delete in the mailbox
|
||||||
|
case "effective":
|
||||||
|
process_get_return(\Mailcow\Retention\RetentionModule::fromGlobals()->api->getEffective($extra));
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
process_get_return(\Mailcow\Retention\RetentionModule::fromGlobals()->api->getRule($object));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case "logs":
|
case "logs":
|
||||||
switch ($object) {
|
switch ($object) {
|
||||||
case "dovecot":
|
case "dovecot":
|
||||||
@@ -874,6 +933,11 @@ if (isset($_GET['query'])) {
|
|||||||
}
|
}
|
||||||
echo (isset($logs) && !empty($logs)) ? json_encode($logs, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) : '{}';
|
echo (isset($logs) && !empty($logs)) ? json_encode($logs, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) : '{}';
|
||||||
break;
|
break;
|
||||||
|
case "retention":
|
||||||
|
// results of the nightly retention runs: mailboxes with found mail or errors, newest first
|
||||||
|
$logs = \Mailcow\Retention\RetentionModule::fromGlobals()->api->getLog(isset($extra) ? intval(preg_replace('/[^\d]/', '', $extra)) : 0);
|
||||||
|
echo (!empty($logs)) ? json_encode($logs, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) : '{}';
|
||||||
|
break;
|
||||||
case "cron":
|
case "cron":
|
||||||
// 0 is first record, so empty is fine
|
// 0 is first record, so empty is fine
|
||||||
if (isset($extra)) {
|
if (isset($extra)) {
|
||||||
@@ -1549,7 +1613,14 @@ if (isset($_GET['query'])) {
|
|||||||
if ($object == "sync-status") {
|
if ($object == "sync-status") {
|
||||||
process_get_return(\Mailcow\Identity\IdentityModule::fromGlobals()->api->getStatus());
|
process_get_return(\Mailcow\Identity\IdentityModule::fromGlobals()->api->getStatus());
|
||||||
} elseif($_SESSION['mailcow_cc_role'] === 'admin') {
|
} elseif($_SESSION['mailcow_cc_role'] === 'admin') {
|
||||||
process_get_return($iam_settings);
|
$iam_settings_out = $iam_settings;
|
||||||
|
// Read-only API keys never get the secrets of the identity provider
|
||||||
|
if (($_SESSION['mailcow_cc_api_access'] ?? '') == 'ro') {
|
||||||
|
foreach (array('client_secret', 'bindpass', 'access_token') as $iam_secret) {
|
||||||
|
if (isset($iam_settings_out[$iam_secret])) $iam_settings_out[$iam_secret] = '*';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
process_get_return($iam_settings_out);
|
||||||
} else {
|
} else {
|
||||||
process_get_return(null);
|
process_get_return(null);
|
||||||
}
|
}
|
||||||
@@ -1753,6 +1824,12 @@ if (isset($_GET['query'])) {
|
|||||||
case "mailbox-group":
|
case "mailbox-group":
|
||||||
process_delete_return(\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->deleteGroups($items));
|
process_delete_return(\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->deleteGroups($items));
|
||||||
break;
|
break;
|
||||||
|
case "mail-policy":
|
||||||
|
process_delete_return(\Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->api->deletePolicies($items));
|
||||||
|
break;
|
||||||
|
case "retention-rule":
|
||||||
|
process_delete_return(\Mailcow\Retention\RetentionModule::fromGlobals()->api->deleteRules($items));
|
||||||
|
break;
|
||||||
case "app-passwd":
|
case "app-passwd":
|
||||||
process_delete_return(app_passwd('delete', array('id' => $items)));
|
process_delete_return(app_passwd('delete', array('id' => $items)));
|
||||||
break;
|
break;
|
||||||
@@ -1927,6 +2004,12 @@ if (isset($_GET['query'])) {
|
|||||||
case "mailbox-group":
|
case "mailbox-group":
|
||||||
process_edit_return(\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->editGroups($items, $attr));
|
process_edit_return(\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->editGroups($items, $attr));
|
||||||
break;
|
break;
|
||||||
|
case "mail-policy":
|
||||||
|
process_edit_return(\Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->api->editPolicies($items, $attr));
|
||||||
|
break;
|
||||||
|
case "retention-rule":
|
||||||
|
process_edit_return(\Mailcow\Retention\RetentionModule::fromGlobals()->api->editRules($items, $attr));
|
||||||
|
break;
|
||||||
case "recipient_map":
|
case "recipient_map":
|
||||||
process_edit_return(recipient_map('edit', array_merge(array('id' => $items), $attr)));
|
process_edit_return(recipient_map('edit', array_merge(array('id' => $items), $attr)));
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"extend_sender_acl": "Eingabe externer Absenderadressen erlauben",
|
"extend_sender_acl": "Eingabe externer Absenderadressen erlauben",
|
||||||
"filters": "Filter",
|
"filters": "Filter",
|
||||||
"login_as": "Einloggen als Mailbox-Benutzer",
|
"login_as": "Einloggen als Mailbox-Benutzer",
|
||||||
|
"mail_policies": "Mail-Richtlinien",
|
||||||
"mailbox_groups": "Gruppen",
|
"mailbox_groups": "Gruppen",
|
||||||
"mailbox_relayhost": "Relayhost für eine Mailbox setzen",
|
"mailbox_relayhost": "Relayhost für eine Mailbox setzen",
|
||||||
"prohibited": "Untersagt durch Richtlinie",
|
"prohibited": "Untersagt durch Richtlinie",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"quarantine_notification": "Ändern der Quarantäne-Benachrichtigung",
|
"quarantine_notification": "Ändern der Quarantäne-Benachrichtigung",
|
||||||
"ratelimit": "Rate limit",
|
"ratelimit": "Rate limit",
|
||||||
"recipient_maps": "Empfängerumschreibungen",
|
"recipient_maps": "Empfängerumschreibungen",
|
||||||
|
"retention": "Aufbewahrungsregeln",
|
||||||
"smtp_ip_access": "Verwalten der erlaubten Hosts für SMTP",
|
"smtp_ip_access": "Verwalten der erlaubten Hosts für SMTP",
|
||||||
"sogo_access": "Verwalten des SOGo-Zugriffsrechts erlauben",
|
"sogo_access": "Verwalten des SOGo-Zugriffsrechts erlauben",
|
||||||
"sogo_profile_reset": "SOGo-Profil zurücksetzen",
|
"sogo_profile_reset": "SOGo-Profil zurücksetzen",
|
||||||
@@ -66,7 +68,7 @@
|
|||||||
"hostname": "Host",
|
"hostname": "Host",
|
||||||
"inactive": "Inaktiv",
|
"inactive": "Inaktiv",
|
||||||
"internal": "Intern",
|
"internal": "Intern",
|
||||||
"internal_info": "Interne Aliasse sind nur von der eigenen Domäne oder Alias-Domänen erreichbar.",
|
"internal_info": "Interne Aliasse nehmen nur E-Mails von angemeldeten Absendern ihrer Domain an (auch über Alias-Domains).",
|
||||||
"sender_allowed": "Als dieser Alias senden erlauben",
|
"sender_allowed": "Als dieser Alias senden erlauben",
|
||||||
"kind": "Art",
|
"kind": "Art",
|
||||||
"mailbox_quota_def": "Standard-Quota einer Mailbox",
|
"mailbox_quota_def": "Standard-Quota einer Mailbox",
|
||||||
@@ -475,6 +477,7 @@
|
|||||||
"ui_theme_default": "Standard",
|
"ui_theme_default": "Standard",
|
||||||
"ui_theme_info": "Themes sind Ordner in <code>data/web/css/themes/<name>/</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_info": "Themes sind Ordner in <code>data/web/css/themes/<name>/</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.",
|
"ui_theme_none": "Keine Themes gefunden.",
|
||||||
|
"unban_all": "Alle entsperren",
|
||||||
"unban_pending": "ausstehend",
|
"unban_pending": "ausstehend",
|
||||||
"unchanged_if_empty": "Unverändert, wenn leer",
|
"unchanged_if_empty": "Unverändert, wenn leer",
|
||||||
"upload": "Hochladen",
|
"upload": "Hochladen",
|
||||||
@@ -581,6 +584,14 @@
|
|||||||
"is_spam_alias": "%s lautet bereits eine temporäre Alias-Adresse (Spam-Alias-Adresse)",
|
"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.",
|
"last_key": "Letzter Key kann nicht gelöscht werden, bitte stattdessen die 2FA deaktivieren.",
|
||||||
"login_failed": "Anmeldung fehlgeschlagen",
|
"login_failed": "Anmeldung fehlgeschlagen",
|
||||||
|
"mail_policy_description_invalid": "Ungültige Beschreibung",
|
||||||
|
"mail_policy_entries_exceeded": "Höchstens %s Einträge pro Liste oder Auswahl möglich",
|
||||||
|
"mail_policy_entry_invalid": "Ungültige Adresse, Domain oder Gruppe in der Liste: %s",
|
||||||
|
"mail_policy_member_invalid": "Mailbox oder Gruppe %s gehört nicht zur Domain der Richtlinie",
|
||||||
|
"mail_policy_mode_invalid": "Ungültiger Modus",
|
||||||
|
"mail_policy_name_invalid": "Ungültiger Name (1 bis 100 Zeichen)",
|
||||||
|
"mail_policy_reject_text_invalid": "Ungültiger Ablehnungstext: druckbare ASCII-Zeichen, höchstens 200",
|
||||||
|
"mail_policy_test_invalid": "Ungültiger Absender oder Empfänger",
|
||||||
"mailbox_defquota_exceeds_mailbox_maxquota": "Standard-Quota überschreitet das Limit der maximal erlaubten Größe einer Mailbox",
|
"mailbox_defquota_exceeds_mailbox_maxquota": "Standard-Quota überschreitet das Limit der maximal erlaubten Größe einer Mailbox",
|
||||||
"mailbox_invalid": "Mailboxname ist ungültig",
|
"mailbox_invalid": "Mailboxname ist ungültig",
|
||||||
"mailbox_quota_exceeded": "Speicherplatz überschreitet das Limit (max. %d MiB)",
|
"mailbox_quota_exceeded": "Speicherplatz überschreitet das Limit (max. %d MiB)",
|
||||||
@@ -626,6 +637,14 @@
|
|||||||
"reset_f2b_regex": "Regex-Filter konnten nicht in vorgegebener Zeit zurückgesetzt werden, bitte erneut versuchen oder die Webseite neu laden.",
|
"reset_f2b_regex": "Regex-Filter konnten nicht in vorgegebener Zeit zurückgesetzt werden, bitte erneut versuchen oder die Webseite neu laden.",
|
||||||
"reset_token_limit_exceeded": "Das Limit für Rücksetz-Tokens wurde überschritten. Bitte versuchen Sie es später erneut.",
|
"reset_token_limit_exceeded": "Das Limit für Rücksetz-Tokens wurde überschritten. Bitte versuchen Sie es später erneut.",
|
||||||
"resource_invalid": "Ressourcenname %s ist ungültig",
|
"resource_invalid": "Ressourcenname %s ist ungültig",
|
||||||
|
"retention_days_invalid": "Ungültige Frist: 1 bis %s Tage",
|
||||||
|
"retention_description_invalid": "Ungültige Beschreibung",
|
||||||
|
"retention_entries_exceeded": "Höchstens %s Einträge pro Auswahl möglich",
|
||||||
|
"retention_folder_invalid": "Ungültiger Ordner: %s",
|
||||||
|
"retention_folders_invalid": "Wählen Sie mindestens einen Ordner",
|
||||||
|
"retention_member_invalid": "Mailbox oder Gruppe %s gehört nicht zur Domain der Regel",
|
||||||
|
"retention_mode_invalid": "Ungültiger Modus",
|
||||||
|
"retention_name_invalid": "Ungültiger Name (1 bis 100 Zeichen)",
|
||||||
"rl_timeframe": "Ratelimit-Zeitraum ist inkorrekt",
|
"rl_timeframe": "Ratelimit-Zeitraum ist inkorrekt",
|
||||||
"rspamd_ui_pw_length": "Rspamd UI-Passwort muss mindestens 6 Zeichen lang sein",
|
"rspamd_ui_pw_length": "Rspamd UI-Passwort muss mindestens 6 Zeichen lang sein",
|
||||||
"script_empty": "Script darf nicht leer sein",
|
"script_empty": "Script darf nicht leer sein",
|
||||||
@@ -641,6 +660,7 @@
|
|||||||
"temp_error": "Temporärer Fehler",
|
"temp_error": "Temporärer Fehler",
|
||||||
"text_empty": "Text darf nicht leer sein",
|
"text_empty": "Text darf nicht leer sein",
|
||||||
"tfa_token_invalid": "TFA-Token ungültig",
|
"tfa_token_invalid": "TFA-Token ungültig",
|
||||||
|
"tfa_login_locked": "Zu viele fehlgeschlagene Zwei-Faktor-Anmeldungen, bitte später erneut versuchen",
|
||||||
"tfa_removal_blocked": "Zwei-Faktor-Authentifizierung kann nicht entfernt werden, da sie für Ihr Konto erforderlich ist.",
|
"tfa_removal_blocked": "Zwei-Faktor-Authentifizierung kann nicht entfernt werden, da sie für Ihr Konto erforderlich ist.",
|
||||||
"tls_policy_map_dest_invalid": "Ziel ist ungültig",
|
"tls_policy_map_dest_invalid": "Ziel ist ungültig",
|
||||||
"tls_policy_map_entry_exists": "Eine TLS-Richtlinie \"%s\" existiert bereits",
|
"tls_policy_map_entry_exists": "Eine TLS-Richtlinie \"%s\" existiert bereits",
|
||||||
@@ -666,6 +686,7 @@
|
|||||||
"template_name_invalid": "Name der Vorlage ungültig",
|
"template_name_invalid": "Name der Vorlage ungültig",
|
||||||
"required_data_missing": "Die benötigten Daten: %s fehlen",
|
"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_in_use": "Sync-Quelle %s wird noch von einem Sync-Job verwendet und kann nicht gelöscht werden",
|
||||||
|
"imapsync_source_used_by_others": "Der Server der Sync-Quelle %s kann nicht geändert werden, Sync-Jobs anderer Postfächer verwenden sie",
|
||||||
"imapsync_source_token_refresh_failed": "OAuth-Token-Erneuerung für %s fehlgeschlagen",
|
"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_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_unavailable": "Die gewählte Sync-Quelle ist nicht verfügbar",
|
||||||
@@ -826,7 +847,7 @@
|
|||||||
"iam_unlink": "Verknüpfung lösen",
|
"iam_unlink": "Verknüpfung lösen",
|
||||||
"inactive": "Inaktiv",
|
"inactive": "Inaktiv",
|
||||||
"internal": "Intern",
|
"internal": "Intern",
|
||||||
"internal_info": "Interne Aliasse sind nur von der eigenen Domäne oder Alias-Domänen erreichbar.",
|
"internal_info": "Interne Aliasse nehmen nur E-Mails von angemeldeten Absendern ihrer Domain an (auch über Alias-Domains).",
|
||||||
"sender_allowed": "Als dieser Alias senden erlauben",
|
"sender_allowed": "Als dieser Alias senden erlauben",
|
||||||
"sender_allowed_info": "Wenn deaktiviert, kann dieser Alias nur E-Mails empfangen. Verwenden Sie Sender-ACL, um bestimmten Postfächern die Berechtigung zum Senden zu erteilen.",
|
"sender_allowed_info": "Wenn deaktiviert, kann dieser Alias nur E-Mails empfangen. Verwenden Sie Sender-ACL, um bestimmten Postfächern die Berechtigung zum Senden zu erteilen.",
|
||||||
"kind": "Art",
|
"kind": "Art",
|
||||||
@@ -965,8 +986,8 @@
|
|||||||
"idp_ref": "IdP-Gruppe",
|
"idp_ref": "IdP-Gruppe",
|
||||||
"imap_grants": "Postfachzugriff",
|
"imap_grants": "Postfachzugriff",
|
||||||
"imap_grants_hint": "Mitglieder öffnen diese Mailboxen in ihrem Mailprogramm (Ordner Shared/<mailbox>). Lesen: lesen und als gelesen markieren. Schreiben: zusätzlich ablegen, verschieben und löschen. Vollzugriff: zusätzlich Ordner löschen. Team-Postfächer ohne eigenen Login als geteiltes Postfach markieren.",
|
"imap_grants_hint": "Mitglieder öffnen diese Mailboxen in ihrem Mailprogramm (Ordner Shared/<mailbox>). Lesen: lesen und als gelesen markieren. Schreiben: zusätzlich ablegen, verschieben und löschen. Vollzugriff: zusätzlich Ordner löschen. Team-Postfächer ohne eigenen Login als geteiltes Postfach markieren.",
|
||||||
"info": "Gruppen bündeln Postfächer. Eine Gruppe kann eine eigene Adresse haben: ein Alias, der automatisch an alle Mitglieder zustellt (in Exchange: Verteilerliste). Mitglieder können als andere Adressen senden, auf freigegebene Postfächer, Kalender und Adressbücher zugreifen und ein Postfach-Template erhalten. Vom Identity Provider synchronisierte Mitglieder sind mit IdP markiert.",
|
"info": "Gruppen bündeln Postfächer. Eine Gruppe kann eine eigene Adresse haben: ein Alias, der automatisch an alle Mitglieder zustellt. Mitglieder können als andere Adressen senden, auf freigegebene Postfächer, Kalender und Adressbücher zugreifen und ein Postfach-Template erhalten. Vom Identity Provider synchronisierte Mitglieder sind mit IdP markiert.",
|
||||||
"internal": "Nur Mails von Absendern der Domain annehmen",
|
"internal": "Nur E-Mails von angemeldeten Absendern der Domain annehmen",
|
||||||
"list_error": "Gruppen konnten nicht geladen werden",
|
"list_error": "Gruppen konnten nicht geladen werden",
|
||||||
"managed_alias": "Wird von Gruppe %s verwaltet",
|
"managed_alias": "Wird von Gruppe %s verwaltet",
|
||||||
"members": "Mitglieder",
|
"members": "Mitglieder",
|
||||||
@@ -1033,6 +1054,61 @@
|
|||||||
"username": "Benutzername",
|
"username": "Benutzername",
|
||||||
"email": "E-Mail-Adresse"
|
"email": "E-Mail-Adresse"
|
||||||
},
|
},
|
||||||
|
"mail_policy": {
|
||||||
|
"add": "Mail-Richtlinie hinzufügen",
|
||||||
|
"allow_receive": "Erlaubte Absender",
|
||||||
|
"allow_send": "Erlaubte Empfänger",
|
||||||
|
"assign": "Zugewiesen an",
|
||||||
|
"assign_info": "Die Richtlinie gilt für diese Mailboxen und für die Mitglieder dieser Gruppen aus der Domain der Richtlinie, bei einer Gruppe auch für E-Mails an die Gruppenadresse. E-Mails an einen Alias oder eine andere Gruppe erreichen die Mitglieder ohne deren Richtlinien.",
|
||||||
|
"assigned_none": "Nicht zugewiesen",
|
||||||
|
"block_receive": "Gesperrte Absender",
|
||||||
|
"block_send": "Gesperrte Empfänger",
|
||||||
|
"description": "Beschreibung",
|
||||||
|
"domain": "Domain",
|
||||||
|
"entries_info_receive": "Adressen oder Domains, eine pro Zeile, und Gruppen der Domain. Erlaubte Adressen und Domains dieser mailcow gelten nur für angemeldete Absender, gesperrte auch für die Absenderadresse im Umschlag. Externe Absender werden an der Absenderadresse im Umschlag erkannt.",
|
||||||
|
"entries_info_send": "Adressen oder Domains, eine pro Zeile, und Gruppen der Domain.",
|
||||||
|
"groups": "Gruppen",
|
||||||
|
"info": "Mail-Richtlinien legen fest, an wen Mailboxen und Gruppen senden und von wem sie empfangen dürfen, z. B. nur intern oder nur mit bestimmten Domains. Postfix prüft sie für jeden Empfänger und lehnt nicht erlaubte Empfänger mit einem 550-Fehler ab. Eingehende E-Mails erreichen die übrigen Empfänger trotzdem, SOGo sendet eine E-Mail mit einem abgelehnten Empfänger dagegen gar nicht.",
|
||||||
|
"internal_info": "Intern: Empfänger in der Domain der Richtlinie und ihren Alias-Domains; Absender, die mit einer Mailbox dieser Domain angemeldet sind.",
|
||||||
|
"list_error": "Die Mail-Richtlinien konnten nicht geladen werden",
|
||||||
|
"mail_policies": "Mail-Richtlinien",
|
||||||
|
"mailboxes": "Mailboxen",
|
||||||
|
"mode": "Erlaubt",
|
||||||
|
"mode_receive_all": "Alle Absender",
|
||||||
|
"mode_receive_internal": "Interne Absender und die erlaubten Absender",
|
||||||
|
"mode_receive_list": "Nur die erlaubten Absender",
|
||||||
|
"mode_send_all": "Alle Empfänger",
|
||||||
|
"mode_send_internal": "Interne Empfänger und die erlaubten Empfänger",
|
||||||
|
"mode_send_list": "Nur die erlaubten Empfänger",
|
||||||
|
"name": "Name",
|
||||||
|
"policy": "Mail-Richtlinie",
|
||||||
|
"receive": "Empfangen",
|
||||||
|
"receive_info": "Für E-Mails an eine zugewiesene Mailbox oder an die Adresse einer zugewiesenen Gruppe.",
|
||||||
|
"reject_text": "Ablehnungstext",
|
||||||
|
"reject_text_info": "Text der SMTP-Ablehnung (550 5.7.1 …), nur ASCII. Leer: ein Standardtext.",
|
||||||
|
"send": "Senden",
|
||||||
|
"send_info": "Für E-Mails, die eine zugewiesene Mailbox angemeldet sendet oder die unter ihrer Adresse gesendet werden, auch Weiterleitungen per Filter und Abwesenheitsnotizen.",
|
||||||
|
"summary_all": "Alle",
|
||||||
|
"summary_allowed": "erlaubt",
|
||||||
|
"summary_blocked": "gesperrt",
|
||||||
|
"summary_internal": "Intern",
|
||||||
|
"summary_list": "Nur erlaubte",
|
||||||
|
"test": "Regel testen",
|
||||||
|
"test_allowed": "Angenommen: Keine Richtlinie lehnt diese E-Mail ab.",
|
||||||
|
"test_denied_address": "Abgelehnt: Diese Schreibweise leitet über eine andere Adresse um und ist nicht erlaubt.",
|
||||||
|
"test_denied_internal": "Abgelehnt: Die Adresse nimmt nur E-Mails von angemeldeten Absendern ihrer Domain an (interner Alias oder interne Gruppe).",
|
||||||
|
"test_denied_other": "Abgelehnt durch eine Richtlinie einer anderen Domain.",
|
||||||
|
"test_denied_receive": "Abgelehnt durch den Empfangen-Teil der Richtlinie „%s“.",
|
||||||
|
"test_denied_send": "Abgelehnt durch den Senden-Teil der Richtlinie „%s“.",
|
||||||
|
"test_info": "Prüft, ob Postfix die E-Mail für diesen Empfänger annehmen würde. Für Bounces den Absender leer lassen.",
|
||||||
|
"test_invalid": "Geben Sie einen gültigen Empfänger (und Absender) Ihrer Domains ein.",
|
||||||
|
"test_logged_in": "Absender ist angemeldet",
|
||||||
|
"test_not_a_mailbox": "Der Absender ist keine Mailbox Ihrer Domains und wurde als nicht angemeldet geprüft.",
|
||||||
|
"test_recipient": "Empfänger",
|
||||||
|
"test_run": "Testen",
|
||||||
|
"test_sender": "Absender",
|
||||||
|
"view": "Ansehen"
|
||||||
|
},
|
||||||
"mailbox": {
|
"mailbox": {
|
||||||
"action": "Aktion",
|
"action": "Aktion",
|
||||||
"activate": "Aktivieren",
|
"activate": "Aktivieren",
|
||||||
@@ -1131,6 +1207,10 @@
|
|||||||
"no_record": "Kein Eintrag für Objekt %s",
|
"no_record": "Kein Eintrag für Objekt %s",
|
||||||
"no_record_single": "Kein Eintrag",
|
"no_record_single": "Kein Eintrag",
|
||||||
"owner": "Besitzer",
|
"owner": "Besitzer",
|
||||||
|
"policies": "Richtlinien",
|
||||||
|
"policies_mail": "Mail",
|
||||||
|
"policies_retention": "Aufbewahrung",
|
||||||
|
"policies_tls": "TLS",
|
||||||
"private_comment": "Privater Kommentar",
|
"private_comment": "Privater Kommentar",
|
||||||
"public_comment": "Öffentlicher Kommentar",
|
"public_comment": "Öffentlicher Kommentar",
|
||||||
"q_add_header": "bei Mail in Junk-Ordner",
|
"q_add_header": "bei Mail in Junk-Ordner",
|
||||||
@@ -1281,6 +1361,63 @@
|
|||||||
"unhold_mail": "Freigeben",
|
"unhold_mail": "Freigeben",
|
||||||
"unhold_mail_legend": "Gibt ausgewählte Mails zur Auslieferung frei. (Erfordert vorheriges Zurückhalten)"
|
"unhold_mail_legend": "Gibt ausgewählte Mails zur Auslieferung frei. (Erfordert vorheriges Zurückhalten)"
|
||||||
},
|
},
|
||||||
|
"retention": {
|
||||||
|
"add": "Aufbewahrungsregel hinzufügen",
|
||||||
|
"all_mailboxes": "Alle Mailboxen der Domain",
|
||||||
|
"assign": "Gilt für",
|
||||||
|
"assign_info": "Gelten mehrere Regeln für denselben Ordner, gewinnt die genaueste: eine Regel für die Mailbox vor einer für ihre Gruppe vor einer für die ganze Domain, danach eine Regel, die den Ordner selbst nennt, vor einem Namen mit *, vor den Unterordnern eines Spezialordners oder des Posteingangs, vor allen Ordnern, danach die längere Frist. Gruppen gelten für ihre Mitglieder aus der Domain der Regel.",
|
||||||
|
"assigned_none": "Nicht zugewiesen",
|
||||||
|
"days": "Löschen nach",
|
||||||
|
"days_info": "Tage, gezählt ab dem Speichern der E-Mail im Ordner: im Papierkorb ab dem Löschen, im Posteingang ab dem Empfang.",
|
||||||
|
"days_short": "%s Tage",
|
||||||
|
"deleted": "Gelöscht",
|
||||||
|
"description": "Beschreibung",
|
||||||
|
"domain": "Domain",
|
||||||
|
"error": "Fehler",
|
||||||
|
"errors": "%s Fehler",
|
||||||
|
"folder": "Ordner",
|
||||||
|
"folder_all": "Alle Ordner",
|
||||||
|
"folder_archive": "Archiv",
|
||||||
|
"folder_drafts": "Entwürfe",
|
||||||
|
"folder_inbox": "Posteingang",
|
||||||
|
"folder_junk": "Junk",
|
||||||
|
"folder_sent": "Gesendet",
|
||||||
|
"folder_trash": "Papierkorb",
|
||||||
|
"folders": "Ordner",
|
||||||
|
"folders_info": "Spezialordner und der Posteingang gelten mit ihren Unterordnern, Spezialordner unter allen Namen, die mailcow für sie kennt (z. B. Papierkorb, Trash, Deleted Items). „Alle Ordner“ umfasst die Ordner, für die keine andere Regel gilt.",
|
||||||
|
"found": "Gefunden",
|
||||||
|
"groups": "Gruppen",
|
||||||
|
"info": "Aufbewahrungsregeln löschen E-Mails automatisch, sobald sie länger als die eingestellte Zahl an Tagen in einem Ordner liegen. Sie laufen jede Nacht. Neue Regeln zählen zunächst nur (Simulation); gelöscht wird erst, wenn der Modus auf „Löschen“ steht.",
|
||||||
|
"keep_flagged": "Markierte E-Mails behalten",
|
||||||
|
"last_run": "Letzter Lauf",
|
||||||
|
"list_error": "Die Aufbewahrungsregeln konnten nicht geladen werden",
|
||||||
|
"log_info": "Mailboxen, in denen der nächtliche Lauf E-Mails gefunden hat oder ein Fehler auftrat, und die Läufe selbst, der letzten 90 Tage. Von Regeln in der Simulation steht nur der letzte Lauf hier.",
|
||||||
|
"mailbox": "Mailbox",
|
||||||
|
"mailboxes": "Mailboxen",
|
||||||
|
"mode": "Modus",
|
||||||
|
"mode_delete": "Löschen",
|
||||||
|
"mode_info": "In der Simulation zählt der nächtliche Lauf nur, was er löschen würde. Das Ergebnis steht unter „Letzter Lauf“ und im Protokoll.",
|
||||||
|
"mode_simulate": "Simulation (nur zählen)",
|
||||||
|
"name": "Name",
|
||||||
|
"never_run": "Noch nicht gelaufen",
|
||||||
|
"own_folders": "Eigene Ordner",
|
||||||
|
"own_folders_info": "Ordnernamen, einer pro Zeile, Unterordner mit / (z. B. Projekte/Alt). Ein Name gilt nur für genau diesen Ordner, * steht für beliebige Zeichen, z. B. Projekte/* für alle Unterordner von Projekte.",
|
||||||
|
"rule": "Regel",
|
||||||
|
"rules": "Aufbewahrungsregeln",
|
||||||
|
"run": "Nächtlicher Lauf",
|
||||||
|
"run_deleted": "%s Mailboxen, %s E-Mails gelöscht",
|
||||||
|
"run_mailboxes": "%s Mailboxen",
|
||||||
|
"run_simulated": "%s Mailboxen, %s E-Mails würden gelöscht",
|
||||||
|
"simulated": "Simulation",
|
||||||
|
"user_all_other": "Alle übrigen Ordner",
|
||||||
|
"user_info": "Diese Regeln löschen E-Mails in Ihrer Mailbox automatisch, sobald sie die angegebene Zeit im Ordner liegen:",
|
||||||
|
"user_keep_flagged": "markierte E-Mails bleiben",
|
||||||
|
"user_none": "Für Ihre Mailbox gelten keine Aufbewahrungsregeln.",
|
||||||
|
"user_simulated": "nur Simulation, es wird nichts gelöscht",
|
||||||
|
"user_subfolders_of": "Unterordner von %s",
|
||||||
|
"user_with_subfolders": "mit Unterordnern",
|
||||||
|
"view": "Ansehen"
|
||||||
|
},
|
||||||
"start": {
|
"start": {
|
||||||
"help": "Hilfe ein-/ausblenden",
|
"help": "Hilfe ein-/ausblenden",
|
||||||
"imap_smtp_server_auth_info": "Bitte verwenden Sie Ihre vollständige E-Mail-Adresse sowie das PLAIN-Authentifizierungsverfahren.<br>\r\nIhre Anmeldedaten werden durch die obligatorische Verschlüsselung entgegen des Begriffes \"PLAIN\" nicht unverschlüsselt übertragen."
|
"imap_smtp_server_auth_info": "Bitte verwenden Sie Ihre vollständige E-Mail-Adresse sowie das PLAIN-Authentifizierungsverfahren.<br>\r\nIhre Anmeldedaten werden durch die obligatorische Verschlüsselung entgegen des Begriffes \"PLAIN\" nicht unverschlüsselt übertragen."
|
||||||
@@ -1345,6 +1482,9 @@
|
|||||||
"learned_ham": "ID %s wurde erfolgreich als Ham gelernt",
|
"learned_ham": "ID %s wurde erfolgreich als Ham gelernt",
|
||||||
"license_modified": "Änderungen an Lizenz wurden gespeichert",
|
"license_modified": "Änderungen an Lizenz wurden gespeichert",
|
||||||
"logged_in_as": "Eingeloggt als %s",
|
"logged_in_as": "Eingeloggt als %s",
|
||||||
|
"mail_policy_added": "Mail-Richtlinie %s hinzugefügt",
|
||||||
|
"mail_policy_modified": "Mail-Richtlinie %s gespeichert",
|
||||||
|
"mail_policy_removed": "Mail-Richtlinie %s entfernt",
|
||||||
"mailbox_added": "Mailbox %s wurde angelegt",
|
"mailbox_added": "Mailbox %s wurde angelegt",
|
||||||
"mailbox_modified": "Änderungen an Mailbox %s wurden gespeichert",
|
"mailbox_modified": "Änderungen an Mailbox %s wurden gespeichert",
|
||||||
"mailbox_removed": "Mailbox %s wurde entfernt",
|
"mailbox_removed": "Mailbox %s wurde entfernt",
|
||||||
@@ -1365,6 +1505,9 @@
|
|||||||
"resource_added": "Ressource %s wurde angelegt",
|
"resource_added": "Ressource %s wurde angelegt",
|
||||||
"resource_modified": "Änderungen an Ressource %s wurden gespeichert",
|
"resource_modified": "Änderungen an Ressource %s wurden gespeichert",
|
||||||
"resource_removed": "Ressource %s wurde entfernt",
|
"resource_removed": "Ressource %s wurde entfernt",
|
||||||
|
"retention_rule_added": "Aufbewahrungsregel %s hinzugefügt",
|
||||||
|
"retention_rule_modified": "Aufbewahrungsregel %s gespeichert",
|
||||||
|
"retention_rule_removed": "Aufbewahrungsregel %s entfernt",
|
||||||
"rl_saved": "Ratelimit für Objekt %s wurde gesetzt",
|
"rl_saved": "Ratelimit für Objekt %s wurde gesetzt",
|
||||||
"rspamd_ui_pw_set": "Rspamd-UI-Passwort wurde gesetzt",
|
"rspamd_ui_pw_set": "Rspamd-UI-Passwort wurde gesetzt",
|
||||||
"saved_settings": "Regel wurde gespeichert",
|
"saved_settings": "Regel wurde gespeichert",
|
||||||
@@ -1537,6 +1680,9 @@
|
|||||||
"source_tenant_hint": "Verzeichnis-ID (Mandant) oder eine Domain des Tenants, z. B. contoso.onmicrosoft.com. Wird in die Endpunkte eingesetzt.",
|
"source_tenant_hint": "Verzeichnis-ID (Mandant) oder eine Domain des Tenants, z. B. contoso.onmicrosoft.com. Wird in die Endpunkte eingesetzt.",
|
||||||
"source_scope_cc_hint": "Quellen mit Client-Credentials-Flow sind immer nur für ihren Ersteller sichtbar.",
|
"source_scope_cc_hint": "Quellen mit Client-Credentials-Flow sind immer nur für ihren Ersteller sichtbar.",
|
||||||
"source_readonly_hint": "Diese Sync-Quelle ist freigegeben und kann nur angesehen werden.",
|
"source_readonly_hint": "Diese Sync-Quelle ist freigegeben und kann nur angesehen werden.",
|
||||||
|
"source_verify_cert": "Zertifikat des Servers prüfen",
|
||||||
|
"source_verify_cert_hint": "Gilt für SSL und STARTTLS. Für Server mit selbstsigniertem, abgelaufenem oder nicht zum Host passendem Zertifikat abschalten.",
|
||||||
|
"source_verify_cert_off": "Das Zertifikat des Servers wird nicht geprüft",
|
||||||
"copy": "Kopieren",
|
"copy": "Kopieren",
|
||||||
"copied": "In die Zwischenablage kopiert",
|
"copied": "In die Zwischenablage kopiert",
|
||||||
"source_view": "Anzeigen",
|
"source_view": "Anzeigen",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"extend_sender_acl": "Allow to extend sender ACL by external addresses",
|
"extend_sender_acl": "Allow to extend sender ACL by external addresses",
|
||||||
"filters": "Filters",
|
"filters": "Filters",
|
||||||
"login_as": "Login as mailbox user",
|
"login_as": "Login as mailbox user",
|
||||||
|
"mail_policies": "Mail policies",
|
||||||
"mailbox_groups": "Groups",
|
"mailbox_groups": "Groups",
|
||||||
"mailbox_relayhost": "Change relayhost for a mailbox",
|
"mailbox_relayhost": "Change relayhost for a mailbox",
|
||||||
"prohibited": "Prohibited by ACL",
|
"prohibited": "Prohibited by ACL",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"quarantine_notification": "Change quarantine notifications",
|
"quarantine_notification": "Change quarantine notifications",
|
||||||
"ratelimit": "Rate limit",
|
"ratelimit": "Rate limit",
|
||||||
"recipient_maps": "Recipient maps",
|
"recipient_maps": "Recipient maps",
|
||||||
|
"retention": "Retention rules",
|
||||||
"smtp_ip_access": "Change allowed hosts for SMTP",
|
"smtp_ip_access": "Change allowed hosts for SMTP",
|
||||||
"sogo_access": "Allow management of SOGo access",
|
"sogo_access": "Allow management of SOGo access",
|
||||||
"sogo_profile_reset": "Reset SOGo profile",
|
"sogo_profile_reset": "Reset SOGo profile",
|
||||||
@@ -66,7 +68,7 @@
|
|||||||
"hostname": "Host",
|
"hostname": "Host",
|
||||||
"inactive": "Inactive",
|
"inactive": "Inactive",
|
||||||
"internal": "Internal",
|
"internal": "Internal",
|
||||||
"internal_info": "Internal aliases are only accessible from the own domain or alias domains.",
|
"internal_info": "Internal aliases only accept mail from logged-in senders of their domain (also through alias domains).",
|
||||||
"sender_allowed": "Allow to send as this alias",
|
"sender_allowed": "Allow to send as this alias",
|
||||||
"kind": "Kind",
|
"kind": "Kind",
|
||||||
"mailbox_quota_def": "Default mailbox quota",
|
"mailbox_quota_def": "Default mailbox quota",
|
||||||
@@ -485,6 +487,7 @@
|
|||||||
"ui_theme_default": "Default",
|
"ui_theme_default": "Default",
|
||||||
"ui_theme_info": "Themes are folders in <code>data/web/css/themes/<name>/</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_info": "Themes are folders in <code>data/web/css/themes/<name>/</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.",
|
"ui_theme_none": "No themes found.",
|
||||||
|
"unban_all": "Unban all",
|
||||||
"unban_pending": "unban pending",
|
"unban_pending": "unban pending",
|
||||||
"unchanged_if_empty": "If unchanged leave blank",
|
"unchanged_if_empty": "If unchanged leave blank",
|
||||||
"upload": "Upload",
|
"upload": "Upload",
|
||||||
@@ -517,6 +520,23 @@
|
|||||||
"group_send_as_invalid": "Send-as address %s is invalid or unknown",
|
"group_send_as_invalid": "Send-as address %s is invalid or unknown",
|
||||||
"iam_sync_mode_invalid": "Invalid sync mode",
|
"iam_sync_mode_invalid": "Invalid sync mode",
|
||||||
"iam_sync_unavailable": "The identity provider cannot be synced (Keycloak and LDAP only)",
|
"iam_sync_unavailable": "The identity provider cannot be synced (Keycloak and LDAP only)",
|
||||||
|
"mail_policy_description_invalid": "Invalid description",
|
||||||
|
"mail_policy_entries_exceeded": "At most %s entries per list or selection are possible",
|
||||||
|
"mail_policy_entry_invalid": "Invalid address, domain or group in the list: %s",
|
||||||
|
"mail_policy_member_invalid": "Mailbox or group %s does not belong to the domain of the policy",
|
||||||
|
"mail_policy_mode_invalid": "Invalid mode",
|
||||||
|
"mail_policy_name_invalid": "Invalid name (1 to 100 characters)",
|
||||||
|
"mail_policy_reject_text_invalid": "Invalid rejection text: printable ASCII, at most 200 characters",
|
||||||
|
"mail_policy_test_invalid": "Invalid sender or recipient",
|
||||||
|
"retention_days_invalid": "Invalid period: 1 to %s days",
|
||||||
|
"retention_description_invalid": "Invalid description",
|
||||||
|
"retention_entries_exceeded": "At most %s entries per selection are possible",
|
||||||
|
"retention_folder_invalid": "Invalid folder: %s",
|
||||||
|
"retention_folders_invalid": "Choose at least one folder",
|
||||||
|
"retention_member_invalid": "Mailbox or group %s does not belong to the domain of the rule",
|
||||||
|
"retention_mode_invalid": "Invalid mode",
|
||||||
|
"retention_name_invalid": "Invalid name (1 to 100 characters)",
|
||||||
|
"tfa_login_locked": "Too many failed two-factor logins, please try again later",
|
||||||
"tfa_removal_blocked": "Two-factor authentication cannot be removed because it is required for your account.",
|
"tfa_removal_blocked": "Two-factor authentication cannot be removed because it is required for your account.",
|
||||||
"alias_domain_invalid": "Alias domain %s is invalid",
|
"alias_domain_invalid": "Alias domain %s is invalid",
|
||||||
"alias_empty": "Alias address must not be empty",
|
"alias_empty": "Alias address must not be empty",
|
||||||
@@ -666,6 +686,7 @@
|
|||||||
"version_invalid": "Version %s is invalid",
|
"version_invalid": "Version %s is invalid",
|
||||||
"yotp_verification_failed": "Yubico OTP verification failed: %s",
|
"yotp_verification_failed": "Yubico OTP verification failed: %s",
|
||||||
"imapsync_source_in_use": "Sync source %s is still used by a sync job and cannot be deleted",
|
"imapsync_source_in_use": "Sync source %s is still used by a sync job and cannot be deleted",
|
||||||
|
"imapsync_source_used_by_others": "The server of sync source %s cannot be changed, sync jobs of other mailboxes use it",
|
||||||
"imapsync_source_token_refresh_failed": "OAuth token refresh for %s failed",
|
"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_scope_empty": "Select at least one domain for the chosen visibility",
|
||||||
"imapsync_source_unavailable": "The selected sync source is unavailable",
|
"imapsync_source_unavailable": "The selected sync source is unavailable",
|
||||||
@@ -826,7 +847,7 @@
|
|||||||
"iam_unlink": "Unlink",
|
"iam_unlink": "Unlink",
|
||||||
"inactive": "Inactive",
|
"inactive": "Inactive",
|
||||||
"internal": "Internal",
|
"internal": "Internal",
|
||||||
"internal_info": "Internal aliases are only accessible from the own domain or alias domains.",
|
"internal_info": "Internal aliases only accept mail from logged-in senders of their domain (also through alias domains).",
|
||||||
"sender_allowed": "Allow to send as this alias",
|
"sender_allowed": "Allow to send as this alias",
|
||||||
"sender_allowed_info": "If disabled, this alias can only receive mail. Use sender ACL to override and grant specific mailboxes permission to send.",
|
"sender_allowed_info": "If disabled, this alias can only receive mail. Use sender ACL to override and grant specific mailboxes permission to send.",
|
||||||
"kind": "Kind",
|
"kind": "Kind",
|
||||||
@@ -965,8 +986,8 @@
|
|||||||
"idp_ref": "IdP group",
|
"idp_ref": "IdP group",
|
||||||
"imap_grants": "Mailbox access",
|
"imap_grants": "Mailbox access",
|
||||||
"imap_grants_hint": "Members open these mailboxes in their mail client (folder Shared/<mailbox>). Read: read and mark as read. Write: also file, move and delete mails. Full access: also delete folders. For team mailboxes without a login of their own, mark the mailbox as shared mailbox.",
|
"imap_grants_hint": "Members open these mailboxes in their mail client (folder Shared/<mailbox>). Read: read and mark as read. Write: also file, move and delete mails. Full access: also delete folders. For team mailboxes without a login of their own, mark the mailbox as shared mailbox.",
|
||||||
"info": "Groups bundle mailboxes. A group can have its own address: an alias that delivers to all members automatically (in Exchange: distribution list). Members can send as other addresses, access shared mailboxes, calendars and address books and get a mailbox template. Members synced from the identity provider are marked IdP.",
|
"info": "Groups bundle mailboxes. A group can have its own address: an alias that delivers to all members automatically. Members can send as other addresses, access shared mailboxes, calendars and address books and get a mailbox template. Members synced from the identity provider are marked IdP.",
|
||||||
"internal": "Only accept mail from senders of the domain",
|
"internal": "Only accept mail from logged-in senders of the domain",
|
||||||
"list_error": "Groups could not be loaded",
|
"list_error": "Groups could not be loaded",
|
||||||
"managed_alias": "Managed by group %s",
|
"managed_alias": "Managed by group %s",
|
||||||
"members": "Members",
|
"members": "Members",
|
||||||
@@ -1033,6 +1054,61 @@
|
|||||||
"username": "Username",
|
"username": "Username",
|
||||||
"email": "Email address"
|
"email": "Email address"
|
||||||
},
|
},
|
||||||
|
"mail_policy": {
|
||||||
|
"add": "Add mail policy",
|
||||||
|
"allow_receive": "Allowed senders",
|
||||||
|
"allow_send": "Allowed recipients",
|
||||||
|
"assign": "Assigned to",
|
||||||
|
"assign_info": "The policy applies to these mailboxes and to the members of these groups in the policy's domain; for a group also to mail sent to the group address. Mail sent to an alias or another group reaches the members without their policies.",
|
||||||
|
"assigned_none": "Not assigned",
|
||||||
|
"block_receive": "Blocked senders",
|
||||||
|
"block_send": "Blocked recipients",
|
||||||
|
"description": "Description",
|
||||||
|
"domain": "Domain",
|
||||||
|
"entries_info_receive": "Addresses or domains, one per line, and groups of the domain. Allowed addresses and domains of this mailcow count for logged-in senders only, blocked ones also for the envelope sender address. External senders are recognised by the envelope sender address.",
|
||||||
|
"entries_info_send": "Addresses or domains, one per line, and groups of the domain.",
|
||||||
|
"groups": "Groups",
|
||||||
|
"info": "Mail policies define whom mailboxes and groups may send to and receive from, e.g. only internally or only with certain domains. Postfix checks them for every recipient and refuses recipients that are not allowed with a 550 error. Incoming mail still reaches the other recipients, whereas SOGo does not send a mail with a refused recipient at all.",
|
||||||
|
"internal_info": "Internal: recipients in the domain of the policy and its alias domains; senders logged in with a mailbox of this domain.",
|
||||||
|
"list_error": "The mail policies could not be loaded",
|
||||||
|
"mail_policies": "Mail policies",
|
||||||
|
"mailboxes": "Mailboxes",
|
||||||
|
"mode": "Allowed",
|
||||||
|
"mode_receive_all": "All senders",
|
||||||
|
"mode_receive_internal": "Internal senders and the allowed senders",
|
||||||
|
"mode_receive_list": "Only the allowed senders",
|
||||||
|
"mode_send_all": "All recipients",
|
||||||
|
"mode_send_internal": "Internal recipients and the allowed recipients",
|
||||||
|
"mode_send_list": "Only the allowed recipients",
|
||||||
|
"name": "Name",
|
||||||
|
"policy": "Mail policy",
|
||||||
|
"receive": "Receiving",
|
||||||
|
"receive_info": "For mail to an assigned mailbox or to the address of an assigned group.",
|
||||||
|
"reject_text": "Rejection text",
|
||||||
|
"reject_text_info": "Text of the SMTP rejection (550 5.7.1 ...), ASCII only. Empty: a default text.",
|
||||||
|
"send": "Sending",
|
||||||
|
"send_info": "For mail an assigned mailbox sends while logged in, or that is sent with its address, including redirects by filters and vacation replies.",
|
||||||
|
"summary_all": "All",
|
||||||
|
"summary_allowed": "allowed",
|
||||||
|
"summary_blocked": "blocked",
|
||||||
|
"summary_internal": "Internal",
|
||||||
|
"summary_list": "Allowed only",
|
||||||
|
"test": "Test a rule",
|
||||||
|
"test_allowed": "Accepted: no policy refuses this mail.",
|
||||||
|
"test_denied_address": "Refused: this form of the address routes through another address and is not allowed.",
|
||||||
|
"test_denied_internal": "Refused: the address only accepts mail from logged-in senders of its domain (internal alias or group).",
|
||||||
|
"test_denied_other": "Refused by a policy of another domain.",
|
||||||
|
"test_denied_receive": "Refused by the receiving part of the policy \"%s\".",
|
||||||
|
"test_denied_send": "Refused by the sending part of the policy \"%s\".",
|
||||||
|
"test_info": "Checks whether Postfix would accept the mail for this recipient. Leave the sender empty for bounces.",
|
||||||
|
"test_invalid": "Enter a valid recipient (and sender) of your domains.",
|
||||||
|
"test_logged_in": "Sender is logged in",
|
||||||
|
"test_not_a_mailbox": "The sender is no mailbox of your domains and was tested as not logged in.",
|
||||||
|
"test_recipient": "Recipient",
|
||||||
|
"test_run": "Test",
|
||||||
|
"test_sender": "Sender",
|
||||||
|
"view": "View"
|
||||||
|
},
|
||||||
"mailbox": {
|
"mailbox": {
|
||||||
"action": "Action",
|
"action": "Action",
|
||||||
"activate": "Activate",
|
"activate": "Activate",
|
||||||
@@ -1138,6 +1214,10 @@
|
|||||||
"no_record_single": "No record",
|
"no_record_single": "No record",
|
||||||
"open_logs": "Open logs",
|
"open_logs": "Open logs",
|
||||||
"owner": "Owner",
|
"owner": "Owner",
|
||||||
|
"policies": "Policies",
|
||||||
|
"policies_mail": "Mail",
|
||||||
|
"policies_retention": "Retention",
|
||||||
|
"policies_tls": "TLS",
|
||||||
"private_comment": "Private comment",
|
"private_comment": "Private comment",
|
||||||
"public_comment": "Public comment",
|
"public_comment": "Public comment",
|
||||||
"q_add_header": "when moved to Junk folder",
|
"q_add_header": "when moved to Junk folder",
|
||||||
@@ -1288,6 +1368,63 @@
|
|||||||
"hour": "msgs / hour",
|
"hour": "msgs / hour",
|
||||||
"day": "msgs / day"
|
"day": "msgs / day"
|
||||||
},
|
},
|
||||||
|
"retention": {
|
||||||
|
"add": "Add retention rule",
|
||||||
|
"all_mailboxes": "All mailboxes of the domain",
|
||||||
|
"assign": "Applies to",
|
||||||
|
"assign_info": "If several rules apply to the same folder, the most specific one wins: a rule for the mailbox before one for its group before one for the whole domain, then a rule naming the folder itself before a name with *, before the subfolders of a special folder or the inbox, before all folders, then the longer period. Groups apply to their members of the rule's domain.",
|
||||||
|
"assigned_none": "Not assigned",
|
||||||
|
"days": "Delete after",
|
||||||
|
"days_info": "Days, counted from the time the mail was saved in the folder: in the trash from its deletion, in the inbox from its receipt.",
|
||||||
|
"days_short": "%s days",
|
||||||
|
"deleted": "Deleted",
|
||||||
|
"description": "Description",
|
||||||
|
"domain": "Domain",
|
||||||
|
"error": "Error",
|
||||||
|
"errors": "%s errors",
|
||||||
|
"folder": "Folder",
|
||||||
|
"folder_all": "All folders",
|
||||||
|
"folder_archive": "Archive",
|
||||||
|
"folder_drafts": "Drafts",
|
||||||
|
"folder_inbox": "Inbox",
|
||||||
|
"folder_junk": "Junk",
|
||||||
|
"folder_sent": "Sent",
|
||||||
|
"folder_trash": "Trash",
|
||||||
|
"folders": "Folders",
|
||||||
|
"folders_info": "Special folders and the inbox apply with their subfolders, special folders under all names mailcow knows for them (e.g. Trash, Papierkorb, Deleted Items). \"All folders\" covers the folders no other rule applies to.",
|
||||||
|
"found": "Found",
|
||||||
|
"groups": "Groups",
|
||||||
|
"info": "Retention rules delete mail automatically once it has been in a folder for longer than the set number of days. They run every night. New rules only count at first (simulation); mail is deleted once the mode is set to \"Delete\".",
|
||||||
|
"keep_flagged": "Keep flagged mail",
|
||||||
|
"last_run": "Last run",
|
||||||
|
"list_error": "The retention rules could not be loaded",
|
||||||
|
"log_info": "Mailboxes in which the nightly run found mail or an error occurred, and the runs themselves, of the last 90 days. Of rules in simulation only the last run is shown here.",
|
||||||
|
"mailbox": "Mailbox",
|
||||||
|
"mailboxes": "Mailboxes",
|
||||||
|
"mode": "Mode",
|
||||||
|
"mode_delete": "Delete",
|
||||||
|
"mode_info": "In simulation the nightly run only counts what it would delete. The result is shown under \"Last run\" and in the log.",
|
||||||
|
"mode_simulate": "Simulation (count only)",
|
||||||
|
"name": "Name",
|
||||||
|
"never_run": "Not run yet",
|
||||||
|
"own_folders": "Own folders",
|
||||||
|
"own_folders_info": "Folder names, one per line, subfolders with / (e.g. Projects/Old). A name applies to exactly this folder, * stands for any characters, e.g. Projects/* for all subfolders of Projects.",
|
||||||
|
"rule": "Rule",
|
||||||
|
"rules": "Retention rules",
|
||||||
|
"run": "Nightly run",
|
||||||
|
"run_deleted": "%s mailboxes, %s mails deleted",
|
||||||
|
"run_mailboxes": "%s mailboxes",
|
||||||
|
"run_simulated": "%s mailboxes, %s mails would be deleted",
|
||||||
|
"simulated": "Simulation",
|
||||||
|
"user_all_other": "All other folders",
|
||||||
|
"user_info": "These rules delete mail in your mailbox automatically once it has been in the folder for the given time:",
|
||||||
|
"user_keep_flagged": "flagged mail stays",
|
||||||
|
"user_none": "No retention rules apply to your mailbox.",
|
||||||
|
"user_simulated": "simulation only, nothing is deleted",
|
||||||
|
"user_subfolders_of": "Subfolders of %s",
|
||||||
|
"user_with_subfolders": "with subfolders",
|
||||||
|
"view": "View"
|
||||||
|
},
|
||||||
"start": {
|
"start": {
|
||||||
"help": "Show/Hide help panel",
|
"help": "Show/Hide help panel",
|
||||||
"imap_smtp_server_auth_info": "Please use your full email address and the PLAIN authentication mechanism.<br>\r\nYour login data will be encrypted by the server-side mandatory encryption."
|
"imap_smtp_server_auth_info": "Please use your full email address and the PLAIN authentication mechanism.<br>\r\nYour login data will be encrypted by the server-side mandatory encryption."
|
||||||
@@ -1352,6 +1489,9 @@
|
|||||||
"learned_ham": "Successfully learned ID %s as ham",
|
"learned_ham": "Successfully learned ID %s as ham",
|
||||||
"license_modified": "Changes to license have been saved",
|
"license_modified": "Changes to license have been saved",
|
||||||
"logged_in_as": "Logged in as %s",
|
"logged_in_as": "Logged in as %s",
|
||||||
|
"mail_policy_added": "Mail policy %s added",
|
||||||
|
"mail_policy_modified": "Mail policy %s saved",
|
||||||
|
"mail_policy_removed": "Mail policy %s removed",
|
||||||
"mailbox_added": "Mailbox %s has been added",
|
"mailbox_added": "Mailbox %s has been added",
|
||||||
"mailbox_modified": "Changes to mailbox %s have been saved",
|
"mailbox_modified": "Changes to mailbox %s have been saved",
|
||||||
"mailbox_removed": "Mailbox %s has been removed",
|
"mailbox_removed": "Mailbox %s has been removed",
|
||||||
@@ -1372,6 +1512,9 @@
|
|||||||
"resource_added": "Resource %s has been added",
|
"resource_added": "Resource %s has been added",
|
||||||
"resource_modified": "Changes to mailbox %s have been saved",
|
"resource_modified": "Changes to mailbox %s have been saved",
|
||||||
"resource_removed": "Resource %s has been removed",
|
"resource_removed": "Resource %s has been removed",
|
||||||
|
"retention_rule_added": "Retention rule %s added",
|
||||||
|
"retention_rule_modified": "Retention rule %s saved",
|
||||||
|
"retention_rule_removed": "Retention rule %s removed",
|
||||||
"rl_saved": "Rate limit for object %s saved",
|
"rl_saved": "Rate limit for object %s saved",
|
||||||
"rspamd_ui_pw_set": "Rspamd UI password successfully set",
|
"rspamd_ui_pw_set": "Rspamd UI password successfully set",
|
||||||
"saved_settings": "Saved settings",
|
"saved_settings": "Saved settings",
|
||||||
@@ -1544,6 +1687,9 @@
|
|||||||
"source_tenant_hint": "Directory (tenant) ID or a domain of the tenant, e.g. contoso.onmicrosoft.com. It is inserted into the endpoints.",
|
"source_tenant_hint": "Directory (tenant) ID or a domain of the tenant, e.g. contoso.onmicrosoft.com. It is inserted into the endpoints.",
|
||||||
"source_scope_cc_hint": "Sources using the client credentials flow are always private to their creator.",
|
"source_scope_cc_hint": "Sources using the client credentials flow are always private to their creator.",
|
||||||
"source_readonly_hint": "This sync source is shared with you and can only be viewed.",
|
"source_readonly_hint": "This sync source is shared with you and can only be viewed.",
|
||||||
|
"source_verify_cert": "Verify the server certificate",
|
||||||
|
"source_verify_cert_hint": "For SSL and STARTTLS. Turn off for servers with a self-signed or expired certificate or a certificate that does not match the host.",
|
||||||
|
"source_verify_cert_off": "The server certificate is not verified",
|
||||||
"copy": "Copy",
|
"copy": "Copy",
|
||||||
"copied": "Copied to clipboard",
|
"copied": "Copied to clipboard",
|
||||||
"source_view": "Show",
|
"source_view": "Show",
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ if (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != 'use
|
|||||||
header('Location: index.php?mobileconfig' . $append_get);
|
header('Location: index.php?mobileconfig' . $append_get);
|
||||||
die();
|
die();
|
||||||
}
|
}
|
||||||
|
if (!empty($_SESSION['pending_tfa_setup']) || !empty($_SESSION['pending_pw_update'])) {
|
||||||
|
header('Location: /');
|
||||||
|
die();
|
||||||
|
}
|
||||||
|
|
||||||
error_reporting(0);
|
error_reporting(0);
|
||||||
|
|
||||||
@@ -39,7 +43,7 @@ if (isset($_GET['only_email'])) {
|
|||||||
$onlyEmailAccount = false;
|
$onlyEmailAccount = false;
|
||||||
$description = 'IMAP, CalDAV, CardDAV';
|
$description = 'IMAP, CalDAV, CardDAV';
|
||||||
}
|
}
|
||||||
if (isset($_GET['app_password'])) {
|
if (isset($_GET['app_password']) && ($_SESSION['acl']['app_passwds'] ?? '0') == '1') {
|
||||||
$app_password = true;
|
$app_password = true;
|
||||||
$description .= ' with application password';
|
$description .= ' with application password';
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,9 @@ if (empty($_SESSION['mailcow_cc_username']) || empty($_SESSION['mailcow_cc_role'
|
|||||||
}
|
}
|
||||||
$context = \Mailcow\Core\Context::fromSession($_SESSION);
|
$context = \Mailcow\Core\Context::fromSession($_SESSION);
|
||||||
$imapsync = \Mailcow\Imapsync\ImapsyncModule::fromGlobals($context);
|
$imapsync = \Mailcow\Imapsync\ImapsyncModule::fromGlobals($context);
|
||||||
if (!$context->hasAcl('syncjobs')) {
|
// Storing a token is a write: no read-only API keys, no session with pending 2FA setup or password update
|
||||||
|
if (!$context->hasAcl('syncjobs') || $context->isReadOnlyApi()
|
||||||
|
|| !empty($_SESSION['pending_tfa_setup']) || !empty($_SESSION['pending_pw_update'])) {
|
||||||
syncjob_oauth_finish(false, array('error' => 'access_denied'));
|
syncjob_oauth_finish(false, array('error' => 'access_denied'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,9 @@
|
|||||||
<button class="btn btn-secondary" type="button" data-action="edit_selected" data-item="{{ f2b_data.banlist_id }}" data-id="f2b_banlist" data-api-url='edit/fail2ban/banlist' data-api-attr='{}'><i class="bi bi-arrow-clockwise"></i></button>
|
<button class="btn btn-secondary" type="button" data-action="edit_selected" data-item="{{ f2b_data.banlist_id }}" data-id="f2b_banlist" data-api-url='edit/fail2ban/banlist' data-api-attr='{}'><i class="bi bi-arrow-clockwise"></i></button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
{% if f2b_data.active_bans %}
|
||||||
|
<p><button class="btn btn-sm btn-secondary" data-action="edit_selected" data-item="self" data-id="f2b-quick" data-api-url='edit/fail2ban' data-api-attr='{"action":"unban-all"}' href="#"><i class="bi bi-unlock"></i> {{ lang.admin.unban_all }}</button></p>
|
||||||
|
{% endif %}
|
||||||
{% for active_ban in f2b_data.active_bans %}
|
{% for active_ban in f2b_data.active_bans %}
|
||||||
<p>
|
<p>
|
||||||
<span class="badge fs-7 bg-info d-block d-sm-inline-block">
|
<span class="badge fs-7 bg-info d-block d-sm-inline-block">
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
<li role="presentation"><button class="dropdown-item" data-bs-target="#tab-api-logs" aria-selected="false" aria-controls="tab-api-logs" role="tab" data-bs-toggle="tab">API</button></li>
|
<li role="presentation"><button class="dropdown-item" data-bs-target="#tab-api-logs" aria-selected="false" aria-controls="tab-api-logs" role="tab" data-bs-toggle="tab">API</button></li>
|
||||||
<li role="presentation"><button class="dropdown-item" data-bs-target="#tab-api-rl" aria-selected="false" aria-controls="tab-api-rl" role="tab" data-bs-toggle="tab">Ratelimits</button></li>
|
<li role="presentation"><button class="dropdown-item" data-bs-target="#tab-api-rl" aria-selected="false" aria-controls="tab-api-rl" role="tab" data-bs-toggle="tab">Ratelimits</button></li>
|
||||||
<li role="presentation"><button class="dropdown-item" data-bs-target="#tab-cron-logs" aria-selected="false" aria-controls="tab-api-rl" role="tab" data-bs-toggle="tab">Crontasks</button></li>
|
<li role="presentation"><button class="dropdown-item" data-bs-target="#tab-cron-logs" aria-selected="false" aria-controls="tab-api-rl" role="tab" data-bs-toggle="tab">Crontasks</button></li>
|
||||||
|
<li role="presentation"><button class="dropdown-item" data-bs-target="#tab-retention-logs" aria-selected="false" aria-controls="tab-retention-logs" role="tab" data-bs-toggle="tab">{{ lang.mailbox.policies_retention }}</button></li>
|
||||||
<li role="presentation"><span class="dropdown-header fs-6">{{ lang.debug.external_logs }}</span></li>
|
<li role="presentation"><span class="dropdown-header fs-6">{{ lang.debug.external_logs }}</span></li>
|
||||||
<li role="presentation"><button class="dropdown-item" data-bs-target="#tab-rspamd-history" aria-selected="false" aria-controls="tab-rspamd-history" role="tab" data-bs-toggle="tab">Rspamd</button></li>
|
<li role="presentation"><button class="dropdown-item" data-bs-target="#tab-rspamd-history" aria-selected="false" aria-controls="tab-rspamd-history" role="tab" data-bs-toggle="tab">Rspamd</button></li>
|
||||||
<li role="presentation"><span class="dropdown-header fs-6">{{ lang.debug.static_logs }}</span></li>
|
<li role="presentation"><span class="dropdown-header fs-6">{{ lang.debug.static_logs }}</span></li>
|
||||||
@@ -547,6 +548,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div role="tabpanel" class="tab-pane" id="tab-retention-logs">
|
||||||
|
<div class="debug-log-info">{{ lang.retention.log_info }}</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex align-items-center fs-5">
|
||||||
|
<span class="mt-2 ms-2">{{ lang.retention.rules }}</span>
|
||||||
|
<div class="btn-group ms-auto">
|
||||||
|
<button class="btn btn-sm btn-secondary refresh_table" data-table="retention_log">{{ lang.admin.refresh }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table id="retention_log" class="table table-striped dt-responsive w-100"></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div> <!-- /tab-content -->
|
</div> <!-- /tab-content -->
|
||||||
</div> <!-- /col-md-12 -->
|
</div> <!-- /col-md-12 -->
|
||||||
</div> <!-- /row -->
|
</div> <!-- /row -->
|
||||||
@@ -554,6 +570,7 @@
|
|||||||
<script type='text/javascript'>
|
<script type='text/javascript'>
|
||||||
var lang = {{ lang_admin|raw }};
|
var lang = {{ lang_admin|raw }};
|
||||||
var lang_debug = {{ lang_debug|raw }};
|
var lang_debug = {{ lang_debug|raw }};
|
||||||
|
lang.retention = {{ lang_retention|raw }};
|
||||||
var lang_datatables = {{ lang_datatables|raw }};
|
var lang_datatables = {{ lang_datatables|raw }};
|
||||||
var csrf_token = '{{ csrf_token }}';
|
var csrf_token = '{{ csrf_token }}';
|
||||||
var log_pagination_size = Math.trunc('{{ log_pagination_size }}');
|
var log_pagination_size = Math.trunc('{{ log_pagination_size }}');
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
<form class="form-horizontal" data-id="edit_imapsync_source" role="form" method="post">
|
<form class="form-horizontal" data-id="edit_imapsync_source" role="form" method="post">
|
||||||
<input type="hidden" value="0" name="active">
|
<input type="hidden" value="0" name="active">
|
||||||
|
<input type="hidden" value="0" name="verify_cert">
|
||||||
<div class="row mb-2">
|
<div class="row mb-2">
|
||||||
<label class="control-label col-sm-2" for="name">{{ lang.syncjobs.source_name }}</label>
|
<label class="control-label col-sm-2" for="name">{{ lang.syncjobs.source_name }}</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
@@ -99,6 +100,14 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="offset-sm-2 col-sm-10">
|
||||||
|
<div class="form-check">
|
||||||
|
<label><input type="checkbox" class="form-check-input" value="1" name="verify_cert"{% if result.verify_cert == '1' %} checked{% endif %}{% if ro %} disabled{% endif %}> {{ lang.syncjobs.source_verify_cert }}</label>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">{{ lang.syncjobs.source_verify_cert_hint }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<label class="control-label col-sm-2" for="auth_type">{{ lang.syncjobs.source_auth_type }}</label>
|
<label class="control-label col-sm-2" for="auth_type">{{ lang.syncjobs.source_auth_type }}</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
{# Fields of a mail policy (add modal, edit page): policy, id (prefix of the element ids), groups (choices: id, name,
|
||||||
|
domain, address), mailboxes (choices), ro, label_class. Groups of a list are sent apart as <list>_groups; without
|
||||||
|
group choices (none in the domain, no access to groups) the group fields are left out and stay unchanged. #}
|
||||||
|
{% set entries = {} %}
|
||||||
|
{% set listed = {} %}
|
||||||
|
{% for key in ['send_allow', 'send_block', 'receive_allow', 'receive_block'] %}
|
||||||
|
{% set plain = [] %}
|
||||||
|
{% set group_ids = [] %}
|
||||||
|
{% for entry in attribute(policy, key)|default([]) %}
|
||||||
|
{% if entry starts with 'group:' %}
|
||||||
|
{% set group_ids = group_ids|merge([entry|slice(6)]) %}
|
||||||
|
{% else %}
|
||||||
|
{% set plain = plain|merge([entry]) %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% set entries = entries|merge({(key): plain|join("\n")}) %}
|
||||||
|
{% set listed = listed|merge({(key): group_ids}) %}
|
||||||
|
{% endfor %}
|
||||||
|
{% for side in ['send', 'receive'] %}
|
||||||
|
<legend class="fs-5 mt-4"><i class="bi {{ side == 'send' ? 'bi-send' : 'bi-inbox' }}"></i> {{ attribute(lang.mail_policy, side) }}</legend><hr>
|
||||||
|
<p class="text-muted small">{{ attribute(lang.mail_policy, side ~ '_info') }}</p>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<label class="control-label col-sm-2{{ label_class }}" for="{{ id }}_{{ side }}_mode">{{ lang.mail_policy.mode }}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<select class="form-control" name="{{ side }}_mode" id="{{ id }}_{{ side }}_mode"{% if ro %} disabled{% endif %}>
|
||||||
|
{% for mode in ['all', 'internal', 'list'] %}
|
||||||
|
<option value="{{ mode }}"{% if attribute(policy, side ~ '_mode')|default('all') == mode %} selected{% endif %}>{{ attribute(lang.mail_policy, 'mode_' ~ side ~ '_' ~ mode) }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small class="text-muted d-block">{{ lang.mail_policy.internal_info }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% for list in ['allow', 'block'] %}
|
||||||
|
{% set key = side ~ '_' ~ list %}
|
||||||
|
<div class="row mb-2">
|
||||||
|
<label class="control-label col-sm-2{{ label_class }}" for="{{ id }}_{{ key }}">{{ attribute(lang.mail_policy, list ~ '_' ~ side) }}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<textarea class="form-control" rows="3" name="{{ key }}" id="{{ id }}_{{ key }}" placeholder="partner.example, user@example.org"{% if ro %} disabled{% endif %}>{{ attribute(entries, key) }}</textarea>
|
||||||
|
<small class="text-muted d-block mb-1">{{ attribute(lang.mail_policy, 'entries_info_' ~ side) }}</small>
|
||||||
|
{% if groups %}
|
||||||
|
{% if not ro %}<input type="hidden" name="{{ key }}_groups" value="">{% endif %}
|
||||||
|
<select data-live-search="true" data-width="100%" name="{{ key }}_groups" class="form-control mail-policy-groups" multiple title="{{ lang.mail_policy.groups }}"{% if ro %} disabled{% endif %}>
|
||||||
|
{% for group in groups %}
|
||||||
|
<option value="{{ group.id }}" data-domain="{{ group.domain }}"{% if group.id ~ '' in attribute(listed, key) %} selected{% endif %}>{{ group.name }}{% if group.address %} ({{ group.address }}){% endif %}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endfor %}
|
||||||
|
<div class="row mb-2">
|
||||||
|
<label class="control-label col-sm-2{{ label_class }}" for="{{ id }}_reject_text">{{ lang.mail_policy.reject_text }}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input type="text" class="form-control" name="reject_text" id="{{ id }}_reject_text" maxlength="200" value="{{ policy.reject_text|default('') }}"{% if ro %} disabled{% endif %}>
|
||||||
|
<small class="text-muted d-block">{{ lang.mail_policy.reject_text_info }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<legend class="fs-5 mt-4"><i class="bi bi-people"></i> {{ lang.mail_policy.assign }}</legend><hr>
|
||||||
|
<p class="text-muted small">{{ lang.mail_policy.assign_info }}</p>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<label class="control-label col-sm-2{{ label_class }}" for="{{ id }}_mailboxes">{{ lang.mail_policy.mailboxes }}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
{% if not ro %}<input type="hidden" name="mailboxes" value="">{% endif %}
|
||||||
|
<select data-live-search="true" data-width="100%" name="mailboxes" id="{{ id }}_mailboxes" class="form-control mail-policy-mailboxes" multiple title="{{ lang.mail_policy.mailboxes }}"{% if ro %} disabled{% endif %}>
|
||||||
|
{% for mailbox in mailboxes %}
|
||||||
|
<option value="{{ mailbox }}" data-domain="{{ mailbox|split('@')|last }}"{% if mailbox in policy.mailboxes|default([]) %} selected{% endif %}>{{ mailbox }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if groups %}
|
||||||
|
<div class="row mb-2">
|
||||||
|
<label class="control-label col-sm-2{{ label_class }}" for="{{ id }}_groups">{{ lang.mail_policy.groups }}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
{% if not ro %}<input type="hidden" name="groups" value="">{% endif %}
|
||||||
|
<select data-live-search="true" data-width="100%" name="groups" id="{{ id }}_groups" class="form-control mail-policy-groups" multiple title="{{ lang.mail_policy.groups }}"{% if ro %} disabled{% endif %}>
|
||||||
|
{% for group in groups %}
|
||||||
|
<option value="{{ group.id }}" data-domain="{{ group.domain }}"{% if group.id in policy.groups|default([]) %} selected{% endif %}>{{ group.name }}{% if group.address %} ({{ group.address }}){% endif %}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{% extends 'edit.twig' %}
|
||||||
|
|
||||||
|
{% block inner_content %}
|
||||||
|
{% if result %}
|
||||||
|
{% set ro = not result.can_edit %}
|
||||||
|
<h4>{{ lang.mail_policy.policy }}: {{ result.name }}</h4>
|
||||||
|
<form data-id="edit_mail_policy" class="form-horizontal" role="form" method="post" autocapitalize="none" autocorrect="off">
|
||||||
|
<input type="hidden" value="0" name="active">
|
||||||
|
<div class="row mb-2">
|
||||||
|
<label class="control-label col-sm-2" for="mail_policy_domain">{{ lang.mail_policy.domain }}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input type="text" class="form-control" id="mail_policy_domain" value="{{ result.domain }}" disabled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<label class="control-label col-sm-2" for="mail_policy_name">{{ lang.mail_policy.name }}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input type="text" class="form-control" name="name" id="mail_policy_name" value="{{ result.name }}" maxlength="100" required{% if ro %} disabled{% endif %}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<label class="control-label col-sm-2" for="mail_policy_description">{{ lang.mail_policy.description }}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input type="text" class="form-control" name="description" id="mail_policy_description" value="{{ result.description }}" maxlength="255"{% if ro %} disabled{% endif %}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% include 'edit/mail-policy-fields.twig' with {'policy': result, 'id': 'mail_policy', 'groups': mail_policy_groups, 'mailboxes': mailboxes, 'ro': ro, 'label_class': ''} %}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="offset-sm-2 col-sm-10">
|
||||||
|
<div class="form-check">
|
||||||
|
<label><input type="checkbox" class="form-check-input" value="1" name="active"{% if result.active == 1 %} checked{% endif %}{% if ro %} disabled{% endif %}> {{ lang.mailbox.active }}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if not ro %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="offset-sm-2 col-sm-10">
|
||||||
|
<button class="btn btn-xs-lg d-block d-sm-inline btn-success" data-action="edit_selected" data-id="edit_mail_policy" data-item="{{ result.id }}" data-api-url='edit/mail-policy' data-api-attr='{}' href="#"><i class="bi bi-check-lg"></i> {{ lang.admin.save }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
{{ parent() }}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
{% if not ro %}
|
{% if not ro %}
|
||||||
<button class="btn btn-sm btn-secondary group-grant-del" title="{{ lang.mailbox.remove }}"><i class="bi bi-x-lg"></i></button>
|
<button type="button" class="btn btn-sm btn-secondary group-grant-del" title="{{ lang.mailbox.remove }}"><i class="bi bi-x-lg"></i></button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<template id="{{ id }}_template">
|
<template id="{{ id }}_template">
|
||||||
{% include 'edit/mailbox-group-grant.twig' with {'kind': kind, 'grant': {}} %}
|
{% include 'edit/mailbox-group-grant.twig' with {'kind': kind, 'grant': {}} %}
|
||||||
</template>
|
</template>
|
||||||
<button class="btn btn-sm btn-secondary group-grant-add" data-table="{{ id }}"><i class="bi bi-plus-lg"></i> {{ lang.groups.grant_add }}</button>
|
<button type="button" class="btn btn-sm btn-secondary group-grant-add" data-table="{{ id }}"><i class="bi bi-plus-lg"></i> {{ lang.groups.grant_add }}</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<small class="text-muted d-block">{{ attribute(lang.groups, field ~ '_hint') }}</small>
|
<small class="text-muted d-block">{{ attribute(lang.groups, field ~ '_hint') }}</small>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
{# Fields of a retention rule (add modal, edit page): rule, id (prefix of the element ids), groups (choices: id, name,
|
||||||
|
domain, address), mailboxes (choices), ro, label_class. Own folders are sent apart as own_folders, one per line;
|
||||||
|
without group choices (none in the domain, no access to groups) the group field is left out and stays unchanged. #}
|
||||||
|
{% set special = [] %}
|
||||||
|
{% set own = [] %}
|
||||||
|
{% for folder in rule.folders|default([]) %}
|
||||||
|
{% if folder starts with 'folder:' %}
|
||||||
|
{% set own = own|merge([folder|slice(7)]) %}
|
||||||
|
{% else %}
|
||||||
|
{% set special = special|merge([folder]) %}
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<div class="row mb-2">
|
||||||
|
<label class="control-label col-sm-2{{ label_class }}" for="{{ id }}_folders">{{ lang.retention.folders }}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
{% if not ro %}<input type="hidden" name="folders" value="">{% endif %}
|
||||||
|
<select data-width="100%" name="folders" id="{{ id }}_folders" class="form-control" multiple title="{{ lang.retention.folders }}"{% if ro %} disabled{% endif %}>
|
||||||
|
{% for folder in ['trash', 'junk', 'sent', 'drafts', 'archive', 'inbox', 'all'] %}
|
||||||
|
<option value="{{ folder }}"{% if folder in special %} selected{% endif %}>{{ attribute(lang.retention, 'folder_' ~ folder) }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small class="text-muted d-block">{{ lang.retention.folders_info }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<label class="control-label col-sm-2{{ label_class }}" for="{{ id }}_own_folders">{{ lang.retention.own_folders }}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<textarea class="form-control" rows="2" name="own_folders" id="{{ id }}_own_folders" placeholder="Newsletter Projekte/*"{% if ro %} disabled{% endif %}>{{ own|join("\n") }}</textarea>
|
||||||
|
<small class="text-muted d-block">{{ lang.retention.own_folders_info }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<label class="control-label col-sm-2{{ label_class }}" for="{{ id }}_days">{{ lang.retention.days }}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<div class="input-group" style="max-width: 16rem">
|
||||||
|
<input type="number" class="form-control" name="days" id="{{ id }}_days" min="1" max="36500" value="{{ rule.days|default(30) }}" required{% if ro %} disabled{% endif %}>
|
||||||
|
<span class="input-group-text">{{ lang.retention.days_short|replace({'%s ': ''}) }}</span>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted d-block">{{ lang.retention.days_info }}</small>
|
||||||
|
<div class="form-check mt-1">
|
||||||
|
{% if not ro %}<input type="hidden" value="0" name="keep_flagged">{% endif %}
|
||||||
|
<label><input type="checkbox" class="form-check-input" value="1" name="keep_flagged"{% if rule.keep_flagged|default(0) == 1 %} checked{% endif %}{% if ro %} disabled{% endif %}> {{ lang.retention.keep_flagged }}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<label class="control-label col-sm-2{{ label_class }}" for="{{ id }}_mode">{{ lang.retention.mode }}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<select class="form-control" name="mode" id="{{ id }}_mode"{% if ro %} disabled{% endif %}>
|
||||||
|
{% for mode in ['simulate', 'delete'] %}
|
||||||
|
<option value="{{ mode }}"{% if rule.mode|default('simulate') == mode %} selected{% endif %}>{{ attribute(lang.retention, 'mode_' ~ mode) }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<small class="text-muted d-block">{{ lang.retention.mode_info }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<legend class="fs-5 mt-4"><i class="bi bi-people"></i> {{ lang.retention.assign }}</legend><hr>
|
||||||
|
<p class="text-muted small">{{ lang.retention.assign_info }}</p>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="offset-sm-2 col-sm-10">
|
||||||
|
<div class="form-check">
|
||||||
|
{% if not ro %}<input type="hidden" value="0" name="all_mailboxes">{% endif %}
|
||||||
|
<label><input type="checkbox" class="form-check-input" value="1" name="all_mailboxes"{% if rule.all_mailboxes|default(0) == 1 %} checked{% endif %}{% if ro %} disabled{% endif %}> {{ lang.retention.all_mailboxes }}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<label class="control-label col-sm-2{{ label_class }}" for="{{ id }}_mailboxes">{{ lang.retention.mailboxes }}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
{% if not ro %}<input type="hidden" name="mailboxes" value="">{% endif %}
|
||||||
|
<select data-live-search="true" data-width="100%" name="mailboxes" id="{{ id }}_mailboxes" class="form-control retention-mailboxes" multiple title="{{ lang.retention.mailboxes }}"{% if ro %} disabled{% endif %}>
|
||||||
|
{% for mailbox in mailboxes %}
|
||||||
|
<option value="{{ mailbox }}" data-domain="{{ mailbox|split('@')|last }}"{% if mailbox in rule.mailboxes|default([]) %} selected{% endif %}>{{ mailbox }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if groups %}
|
||||||
|
<div class="row mb-2">
|
||||||
|
<label class="control-label col-sm-2{{ label_class }}" for="{{ id }}_groups">{{ lang.retention.groups }}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
{% if not ro %}<input type="hidden" name="groups" value="">{% endif %}
|
||||||
|
<select data-live-search="true" data-width="100%" name="groups" id="{{ id }}_groups" class="form-control retention-groups" multiple title="{{ lang.retention.groups }}"{% if ro %} disabled{% endif %}>
|
||||||
|
{% for group in groups %}
|
||||||
|
<option value="{{ group.id }}" data-domain="{{ group.domain }}"{% if group.id in rule.groups|default([]) %} selected{% endif %}>{{ group.name }}{% if group.address %} ({{ group.address }}){% endif %}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
{% extends 'edit.twig' %}
|
||||||
|
|
||||||
|
{% block inner_content %}
|
||||||
|
{% if result %}
|
||||||
|
{% set ro = not result.can_edit %}
|
||||||
|
<h4>{{ lang.retention.rule }}: {{ result.name }}</h4>
|
||||||
|
<form data-id="edit_retention_rule" class="form-horizontal" role="form" method="post" autocapitalize="none" autocorrect="off">
|
||||||
|
<input type="hidden" value="0" name="active">
|
||||||
|
<div class="row mb-2">
|
||||||
|
<label class="control-label col-sm-2" for="retention_rule_domain">{{ lang.retention.domain }}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input type="text" class="form-control" id="retention_rule_domain" value="{{ result.domain }}" disabled>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<label class="control-label col-sm-2" for="retention_rule_name">{{ lang.retention.name }}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input type="text" class="form-control" name="name" id="retention_rule_name" value="{{ result.name }}" maxlength="100" required{% if ro %} disabled{% endif %}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<label class="control-label col-sm-2" for="retention_rule_description">{{ lang.retention.description }}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input type="text" class="form-control" name="description" id="retention_rule_description" value="{{ result.description }}" maxlength="255"{% if ro %} disabled{% endif %}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% include 'edit/retention-rule-fields.twig' with {'rule': result, 'id': 'retention_rule', 'groups': retention_groups, 'mailboxes': mailboxes, 'ro': ro, 'label_class': ''} %}
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="offset-sm-2 col-sm-10">
|
||||||
|
<div class="form-check">
|
||||||
|
<label><input type="checkbox" class="form-check-input" value="1" name="active"{% if result.active == 1 %} checked{% endif %}{% if ro %} disabled{% endif %}> {{ lang.mailbox.active }}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if not ro %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="offset-sm-2 col-sm-10">
|
||||||
|
<button class="btn btn-xs-lg d-block d-sm-inline btn-success" data-action="edit_selected" data-id="edit_retention_rule" data-item="{{ result.id }}" data-api-url='edit/retention-rule' data-api-attr='{}' href="#"><i class="bi bi-check-lg"></i> {{ lang.admin.save }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
{{ parent() }}
|
||||||
|
{% endif %}
|
||||||
|
{% endblock %}
|
||||||
@@ -36,10 +36,26 @@
|
|||||||
{% if acl.mailbox_groups == 1 %}
|
{% if acl.mailbox_groups == 1 %}
|
||||||
<li class="nav-item" role="presentation"><button class="nav-link" aria-selected="false" aria-controls="tab-groups" role="tab" data-bs-toggle="tab" data-bs-target="#tab-groups">{{ lang.groups.groups }}</button></li>
|
<li class="nav-item" role="presentation"><button class="nav-link" aria-selected="false" aria-controls="tab-groups" role="tab" data-bs-toggle="tab" data-bs-target="#tab-groups">{{ lang.groups.groups }}</button></li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{# policies: TLS (admins), mail policies and retention rules (ACLs); a single one is a plain tab #}
|
||||||
|
{% set policy_tabs = [] %}
|
||||||
|
{% if mailcow_cc_role == 'admin' %}{% set policy_tabs = policy_tabs|merge([{'id': 'tab-tls-policy', 'short': lang.mailbox.policies_tls, 'long': lang.mailbox.tls_policy_maps}]) %}{% endif %}
|
||||||
|
{% if acl.mail_policies == 1 %}{% set policy_tabs = policy_tabs|merge([{'id': 'tab-mail-policies', 'short': lang.mailbox.policies_mail, 'long': lang.mail_policy.mail_policies}]) %}{% endif %}
|
||||||
|
{% if acl.retention == 1 %}{% set policy_tabs = policy_tabs|merge([{'id': 'tab-retention', 'short': lang.mailbox.policies_retention, 'long': lang.retention.rules}]) %}{% endif %}
|
||||||
|
{% if policy_tabs|length > 1 %}
|
||||||
|
<li class="nav-item dropdown">
|
||||||
|
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#">{{ lang.mailbox.policies }}</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
{% for tab in policy_tabs %}
|
||||||
|
<li role="presentation"><button class="dropdown-item" aria-selected="false" aria-controls="{{ tab.id }}" role="tab" data-bs-toggle="tab" data-bs-target="#{{ tab.id }}">{{ tab.short }}</button></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
{% elseif policy_tabs|length == 1 %}
|
||||||
|
<li class="nav-item" role="presentation"><button class="nav-link" aria-selected="false" aria-controls="{{ policy_tabs[0].id }}" role="tab" data-bs-toggle="tab" data-bs-target="#{{ policy_tabs[0].id }}">{{ policy_tabs[0].long }}</button></li>
|
||||||
|
{% endif %}
|
||||||
<li class="nav-item" role="presentation"><button class="nav-link" aria-selected="false" aria-controls="tab-syncjobs" role="tab" data-bs-toggle="tab" data-bs-target="#tab-syncjobs">{{ lang.syncjobs.sync_jobs }}</button></li>
|
<li class="nav-item" role="presentation"><button class="nav-link" aria-selected="false" aria-controls="tab-syncjobs" role="tab" data-bs-toggle="tab" data-bs-target="#tab-syncjobs">{{ lang.syncjobs.sync_jobs }}</button></li>
|
||||||
<li class="nav-item" role="presentation"><button class="nav-link" aria-selected="false" aria-controls="tab-filters" role="tab" data-bs-toggle="tab" data-bs-target="#tab-filters">{{ lang.mailbox.filters }}</button></li>
|
<li class="nav-item" role="presentation"><button class="nav-link" aria-selected="false" aria-controls="tab-filters" role="tab" data-bs-toggle="tab" data-bs-target="#tab-filters">{{ lang.mailbox.filters }}</button></li>
|
||||||
<li class="nav-item" role="presentation"><button class="nav-link" aria-selected="false" aria-controls="tab-bcc" role="tab" data-bs-toggle="tab" data-bs-target="#tab-bcc">{{ lang.mailbox.address_rewriting }}</button></li>
|
<li class="nav-item" role="presentation"><button class="nav-link" aria-selected="false" aria-controls="tab-bcc" role="tab" data-bs-toggle="tab" data-bs-target="#tab-bcc">{{ lang.mailbox.address_rewriting }}</button></li>
|
||||||
<li class="nav-item" role="presentation"{% if mailcow_cc_role != 'admin' %} class="d-none"{% endif %}><button class="nav-link" aria-selected="false" aria-controls="tab-tls-policy" role="tab" data-bs-toggle="tab" data-bs-target="#tab-tls-policy">{{ lang.mailbox.tls_policy_maps }}</button></li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
@@ -59,6 +75,12 @@
|
|||||||
{% if acl.mailbox_groups == 1 %}
|
{% if acl.mailbox_groups == 1 %}
|
||||||
{% include 'mailbox/tab-groups.twig' %}
|
{% include 'mailbox/tab-groups.twig' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if acl.mail_policies == 1 %}
|
||||||
|
{% include 'mailbox/tab-mail-policies.twig' %}
|
||||||
|
{% endif %}
|
||||||
|
{% if acl.retention == 1 %}
|
||||||
|
{% include 'mailbox/tab-retention.twig' %}
|
||||||
|
{% endif %}
|
||||||
{% include 'mailbox/tab-syncjobs.twig' %}
|
{% include 'mailbox/tab-syncjobs.twig' %}
|
||||||
{% include 'mailbox/tab-filters.twig' %}
|
{% include 'mailbox/tab-filters.twig' %}
|
||||||
{% include 'mailbox/tab-bcc.twig' %}
|
{% include 'mailbox/tab-bcc.twig' %}
|
||||||
@@ -75,6 +97,8 @@
|
|||||||
var lang = {{ lang_mailbox|raw }};
|
var lang = {{ lang_mailbox|raw }};
|
||||||
lang.syncjobs = {{ lang_syncjobs|raw }};
|
lang.syncjobs = {{ lang_syncjobs|raw }};
|
||||||
lang.groups = {{ lang_groups|raw }};
|
lang.groups = {{ lang_groups|raw }};
|
||||||
|
lang.mail_policy = {{ lang_mail_policy|raw }};
|
||||||
|
lang.retention = {{ lang_retention|raw }};
|
||||||
var lang_rl = {{ lang_rl|raw }};
|
var lang_rl = {{ lang_rl|raw }};
|
||||||
var lang_edit = {{ lang_edit|raw }};
|
var lang_edit = {{ lang_edit|raw }};
|
||||||
var lang_datatables = {{ lang_datatables|raw }};
|
var lang_datatables = {{ lang_datatables|raw }};
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<div class="tab-pane fade" id="tab-mail-policies" role="tabpanel" aria-labelledby="tab-mail-policies">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header d-flex fs-5">
|
||||||
|
<button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-mail-policies" data-bs-toggle="collapse" aria-controls="collapse-tab-mail-policies">
|
||||||
|
{{ lang.mail_policy.mail_policies }} <span class="badge bg-info table-lines"></span>
|
||||||
|
</button>
|
||||||
|
<span class="d-none d-md-block">{{ lang.mail_policy.mail_policies }} <span class="badge bg-info table-lines"></span></span>
|
||||||
|
<div class="btn-group ms-auto d-flex">
|
||||||
|
<button class="btn btn-xs btn-secondary refresh_table" data-table="mail_policy_table">{{ lang.admin.refresh }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="collapse-tab-mail-policies" class="card-body collapse" data-bs-parent="#mail-content">
|
||||||
|
<p class="text-muted">{{ lang.mail_policy.info }}</p>
|
||||||
|
<table id="mail_policy_table" class="table table-striped dt-responsive w-100"></table>
|
||||||
|
<div class="mass-actions-mailbox mt-4">
|
||||||
|
<div class="btn-group">
|
||||||
|
<a class="btn btn-sm btn-xs-half d-block d-sm-inline btn-secondary" id="toggle_multi_select_all" data-id="mail_policy" href="#"><i class="bi bi-check-all"></i> {{ lang.mailbox.toggle_all }}</a>
|
||||||
|
<a class="btn btn-sm btn-xs-half d-block d-sm-inline btn-secondary dropdown-toggle" data-bs-toggle="dropdown" href="#">{{ lang.mailbox.quick_actions }}</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" data-action="edit_selected" data-id="mail_policy" data-api-url='edit/mail-policy' data-api-attr='{"active":"1"}' href="#">{{ lang.mailbox.activate }}</a></li>
|
||||||
|
<li><a class="dropdown-item" data-action="edit_selected" data-id="mail_policy" data-api-url='edit/mail-policy' data-api-attr='{"active":"0"}' href="#">{{ lang.mailbox.deactivate }}</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item" data-action="delete_selected" data-id="mail_policy" data-api-url='delete/mail-policy' href="#">{{ lang.mailbox.remove }}</a></li>
|
||||||
|
</ul>
|
||||||
|
<a class="btn btn-sm d-block d-sm-inline btn-success" href="#" data-bs-toggle="modal" data-bs-target="#addMailPolicyModal"><i class="bi bi-plus-lg"></i> {{ lang.mail_policy.add }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Test a rule: would the mail be accepted? Filled by js/site/mail-policy.js #}
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-header">{{ lang.mail_policy.test }}</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted small">{{ lang.mail_policy.test_info }}</p>
|
||||||
|
<form class="row g-2 align-items-center" id="mail_policy_test" autocomplete="off">
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<input type="email" class="form-control" id="mail_policy_test_sender" placeholder="{{ lang.mail_policy.test_sender }}" aria-label="{{ lang.mail_policy.test_sender }}">
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4">
|
||||||
|
<input type="email" class="form-control" id="mail_policy_test_recipient" placeholder="{{ lang.mail_policy.test_recipient }}" aria-label="{{ lang.mail_policy.test_recipient }}" required>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4 d-flex align-items-center">
|
||||||
|
<div class="form-check me-3">
|
||||||
|
<input class="form-check-input" type="checkbox" id="mail_policy_test_login" checked>
|
||||||
|
<label class="form-check-label" for="mail_policy_test_login">{{ lang.mail_policy.test_logged_in }}</label>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-sm btn-secondary ms-auto"><i class="bi bi-play"></i> {{ lang.mail_policy.test_run }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<div id="mail_policy_test_result" class="mt-3"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<div class="tab-pane fade" id="tab-retention" role="tabpanel" aria-labelledby="tab-retention">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header d-flex fs-5">
|
||||||
|
<button class="btn d-md-none flex-grow-1 text-start" data-bs-target="#collapse-tab-retention" data-bs-toggle="collapse" aria-controls="collapse-tab-retention">
|
||||||
|
{{ lang.retention.rules }} <span class="badge bg-info table-lines"></span>
|
||||||
|
</button>
|
||||||
|
<span class="d-none d-md-block">{{ lang.retention.rules }} <span class="badge bg-info table-lines"></span></span>
|
||||||
|
<div class="btn-group ms-auto d-flex">
|
||||||
|
<button class="btn btn-xs btn-secondary refresh_table" data-table="retention_table">{{ lang.admin.refresh }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="collapse-tab-retention" class="card-body collapse" data-bs-parent="#mail-content">
|
||||||
|
<p class="text-muted">{{ lang.retention.info }}</p>
|
||||||
|
<table id="retention_table" class="table table-striped dt-responsive w-100"></table>
|
||||||
|
<div class="mass-actions-mailbox mt-4">
|
||||||
|
<div class="btn-group">
|
||||||
|
<a class="btn btn-sm btn-xs-half d-block d-sm-inline btn-secondary" id="toggle_multi_select_all" data-id="retention_rule" href="#"><i class="bi bi-check-all"></i> {{ lang.mailbox.toggle_all }}</a>
|
||||||
|
<a class="btn btn-sm btn-xs-half d-block d-sm-inline btn-secondary dropdown-toggle" data-bs-toggle="dropdown" href="#">{{ lang.mailbox.quick_actions }}</a>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" data-action="edit_selected" data-id="retention_rule" data-api-url='edit/retention-rule' data-api-attr='{"active":"1"}' href="#">{{ lang.mailbox.activate }}</a></li>
|
||||||
|
<li><a class="dropdown-item" data-action="edit_selected" data-id="retention_rule" data-api-url='edit/retention-rule' data-api-attr='{"active":"0"}' href="#">{{ lang.mailbox.deactivate }}</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item" data-action="edit_selected" data-id="retention_rule" data-api-url='edit/retention-rule' data-api-attr='{"mode":"simulate"}' href="#">{{ lang.retention.mode }}: {{ lang.retention.mode_simulate }}</a></li>
|
||||||
|
<li><hr class="dropdown-divider"></li>
|
||||||
|
<li><a class="dropdown-item" data-action="delete_selected" data-id="retention_rule" data-api-url='delete/retention-rule' href="#">{{ lang.mailbox.remove }}</a></li>
|
||||||
|
</ul>
|
||||||
|
<a class="btn btn-sm d-block d-sm-inline btn-success" href="#" data-bs-toggle="modal" data-bs-target="#addRetentionModal"><i class="bi bi-plus-lg"></i> {{ lang.retention.add }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<form class="form-horizontal" data-cached-form="true" role="form" data-id="add_imapsync_source">
|
<form class="form-horizontal" data-cached-form="true" role="form" data-id="add_imapsync_source">
|
||||||
<input type="hidden" value="0" name="active">
|
<input type="hidden" value="0" name="active">
|
||||||
|
<input type="hidden" value="0" name="verify_cert">
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<label class="control-label col-sm-2 text-sm-end">{{ lang.syncjobs.source_presets }}</label>
|
<label class="control-label col-sm-2 text-sm-end">{{ lang.syncjobs.source_presets }}</label>
|
||||||
<div class="col-sm-10 small">
|
<div class="col-sm-10 small">
|
||||||
@@ -99,6 +100,14 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="offset-sm-2 col-sm-10">
|
||||||
|
<div class="form-check">
|
||||||
|
<label><input type="checkbox" class="form-check-input" value="1" name="verify_cert" checked> {{ lang.syncjobs.source_verify_cert }}</label>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">{{ lang.syncjobs.source_verify_cert_hint }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<label class="control-label col-sm-2 text-sm-end" for="addsrc_auth_type">{{ lang.syncjobs.source_auth_type }}</label>
|
<label class="control-label col-sm-2 text-sm-end" for="addsrc_auth_type">{{ lang.syncjobs.source_auth_type }}</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<div class="modal fade" id="addMailPolicyModal" tabindex="-1" role="dialog" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-xl">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">{{ lang.mail_policy.add }}</h3>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form class="form-horizontal" role="form" data-id="add_mail_policy" autocapitalize="none" autocorrect="off">
|
||||||
|
<input type="hidden" value="0" name="active">
|
||||||
|
<div class="row mb-2">
|
||||||
|
<label class="control-label col-sm-2 text-sm-end" for="add_mail_policy_domain">{{ lang.mail_policy.domain }}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<select data-live-search="true" name="domain" id="add_mail_policy_domain" class="form-control mail-policy-domain-select" required>
|
||||||
|
{% for domain in domains %}
|
||||||
|
<option>{{ domain }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<label class="control-label col-sm-2 text-sm-end" for="add_mail_policy_name">{{ lang.mail_policy.name }}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input type="text" class="form-control" name="name" id="add_mail_policy_name" maxlength="100" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<label class="control-label col-sm-2 text-sm-end" for="add_mail_policy_description">{{ lang.mail_policy.description }}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input type="text" class="form-control" name="description" id="add_mail_policy_description" maxlength="255">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% include 'edit/mail-policy-fields.twig' with {'policy': {}, 'id': 'add_mail_policy', 'groups': mail_policy_groups, 'mailboxes': mailboxes, 'ro': false, 'label_class': ' text-sm-end'} %}
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="offset-sm-2 col-sm-10">
|
||||||
|
<div class="form-check">
|
||||||
|
<label><input type="checkbox" class="form-check-input" value="1" name="active" checked> {{ lang.mailbox.active }}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="offset-sm-2 col-sm-10">
|
||||||
|
<button class="btn btn-xs-lg d-block d-sm-inline btn-success" data-action="add_item" data-id="add_mail_policy" data-api-url='add/mail-policy' data-api-attr='{}' href="#">{{ lang.admin.add }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1362,3 +1362,9 @@
|
|||||||
{% if acl.mailbox_groups == 1 %}
|
{% if acl.mailbox_groups == 1 %}
|
||||||
{% include "modals/mailbox_group.twig" %}
|
{% include "modals/mailbox_group.twig" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if acl.mail_policies == 1 %}
|
||||||
|
{% include "modals/mail_policy.twig" %}
|
||||||
|
{% endif %}
|
||||||
|
{% if acl.retention == 1 %}
|
||||||
|
{% include "modals/retention.twig" %}
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<div class="modal fade" id="addRetentionModal" tabindex="-1" role="dialog" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-xl">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3 class="modal-title">{{ lang.retention.add }}</h3>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form class="form-horizontal" role="form" data-id="add_retention_rule" autocapitalize="none" autocorrect="off">
|
||||||
|
<input type="hidden" value="0" name="active">
|
||||||
|
<div class="row mb-2">
|
||||||
|
<label class="control-label col-sm-2 text-sm-end" for="add_retention_rule_domain">{{ lang.retention.domain }}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<select data-live-search="true" name="domain" id="add_retention_rule_domain" class="form-control retention-domain-select" required>
|
||||||
|
{% for domain in domains %}
|
||||||
|
<option>{{ domain }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<label class="control-label col-sm-2 text-sm-end" for="add_retention_rule_name">{{ lang.retention.name }}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input type="text" class="form-control" name="name" id="add_retention_rule_name" maxlength="100" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<label class="control-label col-sm-2 text-sm-end" for="add_retention_rule_description">{{ lang.retention.description }}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<input type="text" class="form-control" name="description" id="add_retention_rule_description" maxlength="255">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% include 'edit/retention-rule-fields.twig' with {'rule': {}, 'id': 'add_retention_rule', 'groups': retention_groups, 'mailboxes': mailboxes, 'ro': false, 'label_class': ' text-sm-end'} %}
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="offset-sm-2 col-sm-10">
|
||||||
|
<div class="form-check">
|
||||||
|
<label><input type="checkbox" class="form-check-input" value="1" name="active" checked> {{ lang.mailbox.active }}</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="offset-sm-2 col-sm-10">
|
||||||
|
<button class="btn btn-xs-lg d-block d-sm-inline btn-success" data-action="add_item" data-id="add_retention_rule" data-api-url='add/retention-rule' data-api-attr='{}' href="#">{{ lang.admin.add }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -77,6 +77,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if acl.app_passwds == 1 %}
|
||||||
<div class="row mt-2">
|
<div class="row mt-2">
|
||||||
<div class="col-12 col-md-3 d-flex">
|
<div class="col-12 col-md-3 d-flex">
|
||||||
<span class="mt-2 w-100 text-md-end">{{ lang.user.apple_connection_profile }}<br>{{ lang.user.with_app_password }}:</span>
|
<span class="mt-2 w-100 text-md-end">{{ lang.user.apple_connection_profile }}<br>{{ lang.user.with_app_password }}:</span>
|
||||||
@@ -94,6 +95,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12 d-flex flex-column text-center align-items-center mt-4">
|
<div class="col-12 d-flex flex-column text-center align-items-center mt-4">
|
||||||
<a target="_blank" href="https://mailcow.github.io/mailcow-dockerized-docs/client/client/#{{ clientconfigstr }}">{{ lang.user.client_configuration }}</a>
|
<a target="_blank" href="https://mailcow.github.io/mailcow-dockerized-docs/client/client/#{{ clientconfigstr }}">{{ lang.user.client_configuration }}</a>
|
||||||
|
|||||||
@@ -73,6 +73,21 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-sm-4 col-md-3 col-12 text-sm-end text-start mb-4"><i class="bi bi-hourglass-split"></i> {{ lang.mailbox.policies_retention }}:
|
||||||
|
<p class="small">{{ lang.retention.user_info }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="col-sm-8 col-md-9 col-12">
|
||||||
|
<p>
|
||||||
|
{% for action in retention_rules %}
|
||||||
|
{% if action.folder starts with 'folder:' %}{{ action.folder|slice(7) }}{% elseif action.folder == 'all' and not loop.first %}{{ lang.retention.user_all_other }}{% elseif action.match == 'subfolders' %}{{ lang.retention.user_subfolders_of|format(attribute(lang.retention, 'folder_' ~ action.folder)) }}{% else %}{{ attribute(lang.retention, 'folder_' ~ action.folder) }}{% if action.subfolders %} {{ lang.retention.user_with_subfolders }}{% endif %}{% endif %}:
|
||||||
|
{{ lang.retention.days_short|format(action.days) }}{% if action.keep_flagged %}, {{ lang.retention.user_keep_flagged }}{% endif %}{% if action.mode != 'delete' %} <span class="text-muted">({{ lang.retention.user_simulated }})</span>{% endif %}{% if not loop.last %}<br>{% endif %}
|
||||||
|
{% else %}
|
||||||
|
{{ lang.retention.user_none }}
|
||||||
|
{% endfor %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-sm-4 col-md-3 col-12 text-sm-end text-start mb-4">{{ lang.user.is_catch_all }}:</div>
|
<div class="col-sm-4 col-md-3 col-12 text-sm-end text-start mb-4">{{ lang.user.is_catch_all }}:</div>
|
||||||
<div class="col-sm-8 col-md-9 col-12">
|
<div class="col-sm-8 col-md-9 col-12">
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ $template_data = [
|
|||||||
'clientconfigstr' => $clientconfigstr,
|
'clientconfigstr' => $clientconfigstr,
|
||||||
'user_get_alias_details' => $user_get_alias_details,
|
'user_get_alias_details' => $user_get_alias_details,
|
||||||
'mailbox_groups' => \Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->getMemberships($username),
|
'mailbox_groups' => \Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->getMemberships($username),
|
||||||
|
'retention_rules' => \Mailcow\Retention\RetentionModule::fromGlobals()->api->getEffective($username, true),
|
||||||
'get_tagging_options' => mailbox('get', 'delimiter_action', $username),
|
'get_tagging_options' => mailbox('get', 'delimiter_action', $username),
|
||||||
'get_tls_policy' => mailbox('get', 'tls_policy', $username),
|
'get_tls_policy' => mailbox('get', 'tls_policy', $username),
|
||||||
'quarantine_notification' => mailbox('get', 'quarantine_notification', $username),
|
'quarantine_notification' => mailbox('get', 'quarantine_notification', $username),
|
||||||
|
|||||||
+18
-11
@@ -1,5 +1,5 @@
|
|||||||
# Version of the database schema
|
# Version of the database schema
|
||||||
x-db-version: &db_version DB_VERSION=28102026_1300
|
x-db-version: &db_version DB_VERSION=07112026_2330
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
|
||||||
@@ -194,20 +194,20 @@ services:
|
|||||||
ofelia.enabled: "true"
|
ofelia.enabled: "true"
|
||||||
ofelia.job-exec.phpfpm_identity_sync.schedule: "0 * * * * *"
|
ofelia.job-exec.phpfpm_identity_sync.schedule: "0 * * * * *"
|
||||||
ofelia.job-exec.phpfpm_identity_sync.no-overlap: "true"
|
ofelia.job-exec.phpfpm_identity_sync.no-overlap: "true"
|
||||||
ofelia.job-exec.phpfpm_identity_sync.command: "/bin/bash -c \"php /crons/identity-sync.php || exit 0\""
|
ofelia.job-exec.phpfpm_identity_sync.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && php /crons/identity-sync.php || exit 0\""
|
||||||
ofelia.job-exec.phpfpm_group_grants.schedule: "0 * * * * *"
|
ofelia.job-exec.phpfpm_group_grants.schedule: "0 * * * * *"
|
||||||
ofelia.job-exec.phpfpm_group_grants.no-overlap: "true"
|
ofelia.job-exec.phpfpm_group_grants.no-overlap: "true"
|
||||||
ofelia.job-exec.phpfpm_group_grants.command: "/bin/bash -c \"php /crons/group-grants.php || exit 0\""
|
ofelia.job-exec.phpfpm_group_grants.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && php /crons/group-grants.php || exit 0\""
|
||||||
ofelia.job-exec.phpfpm_imapsync_oauth_refresh.schedule: "0 * * * * *"
|
ofelia.job-exec.phpfpm_imapsync_oauth_refresh.schedule: "0 * * * * *"
|
||||||
ofelia.job-exec.phpfpm_imapsync_oauth_refresh.no-overlap: "true"
|
ofelia.job-exec.phpfpm_imapsync_oauth_refresh.no-overlap: "true"
|
||||||
ofelia.job-exec.phpfpm_imapsync_oauth_refresh.command: "/bin/bash -c \"php /crons/imapsync-oauth-refresh.php || exit 0\""
|
ofelia.job-exec.phpfpm_imapsync_oauth_refresh.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && php /crons/imapsync-oauth-refresh.php || exit 0\""
|
||||||
networks:
|
networks:
|
||||||
mailcow-network:
|
mailcow-network:
|
||||||
aliases:
|
aliases:
|
||||||
- phpfpm
|
- phpfpm
|
||||||
|
|
||||||
sogo-mailcow:
|
sogo-mailcow:
|
||||||
image: ghcr.io/mailcow/sogo:5.12.12-2
|
image: ghcr.io/mailcow/sogo:5.12.13-1
|
||||||
environment:
|
environment:
|
||||||
- *db_version
|
- *db_version
|
||||||
- DBNAME=${DBNAME}
|
- DBNAME=${DBNAME}
|
||||||
@@ -259,7 +259,7 @@ services:
|
|||||||
- sogo
|
- sogo
|
||||||
|
|
||||||
dovecot-mailcow:
|
dovecot-mailcow:
|
||||||
image: ghcr.io/mailcow/dovecot:2.3.21.1-3
|
image: ghcr.io/mailcow/dovecot:2.3.21.1-5
|
||||||
depends_on:
|
depends_on:
|
||||||
- mysql-mailcow
|
- mysql-mailcow
|
||||||
- netfilter-mailcow
|
- netfilter-mailcow
|
||||||
@@ -295,6 +295,7 @@ services:
|
|||||||
- MAILCOW_HOSTNAME=${MAILCOW_HOSTNAME}
|
- MAILCOW_HOSTNAME=${MAILCOW_HOSTNAME}
|
||||||
- MAILCOW_PASS_SCHEME=${MAILCOW_PASS_SCHEME:-BLF-CRYPT}
|
- MAILCOW_PASS_SCHEME=${MAILCOW_PASS_SCHEME:-BLF-CRYPT}
|
||||||
- IPV4_NETWORK=${IPV4_NETWORK:-172.22.1}
|
- IPV4_NETWORK=${IPV4_NETWORK:-172.22.1}
|
||||||
|
- IPV6_NETWORK=${IPV6_NETWORK:-fd4d:6169:6c63:6f77::/64}
|
||||||
- ALLOW_ADMIN_EMAIL_LOGIN=${ALLOW_ADMIN_EMAIL_LOGIN:-n}
|
- ALLOW_ADMIN_EMAIL_LOGIN=${ALLOW_ADMIN_EMAIL_LOGIN:-n}
|
||||||
- MAILDIR_GC_TIME=${MAILDIR_GC_TIME:-7200}
|
- MAILDIR_GC_TIME=${MAILDIR_GC_TIME:-7200}
|
||||||
- ACL_ANYONE=${ACL_ANYONE:-disallow}
|
- ACL_ANYONE=${ACL_ANYONE:-disallow}
|
||||||
@@ -321,6 +322,9 @@ services:
|
|||||||
ofelia.job-exec.dovecot_imapsync_runner.schedule: "0 * * * * *"
|
ofelia.job-exec.dovecot_imapsync_runner.schedule: "0 * * * * *"
|
||||||
ofelia.job-exec.dovecot_imapsync_runner.no-overlap: "true"
|
ofelia.job-exec.dovecot_imapsync_runner.no-overlap: "true"
|
||||||
ofelia.job-exec.dovecot_imapsync_runner.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && /usr/local/bin/gosu nobody /usr/local/bin/imapsync_runner.py || exit 0\""
|
ofelia.job-exec.dovecot_imapsync_runner.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && /usr/local/bin/gosu nobody /usr/local/bin/imapsync_runner.py || exit 0\""
|
||||||
|
ofelia.job-exec.dovecot_retention.schedule: "0 30 3 * * *"
|
||||||
|
ofelia.job-exec.dovecot_retention.no-overlap: "true"
|
||||||
|
ofelia.job-exec.dovecot_retention.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && /usr/local/bin/gosu vmail /usr/local/bin/retention_runner.py || exit 0\""
|
||||||
ofelia.job-exec.dovecot_trim_logs.schedule: "0 * * * * *"
|
ofelia.job-exec.dovecot_trim_logs.schedule: "0 * * * * *"
|
||||||
ofelia.job-exec.dovecot_trim_logs.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && /usr/local/bin/gosu vmail /usr/local/bin/trim_logs.sh || exit 0\""
|
ofelia.job-exec.dovecot_trim_logs.command: "/bin/bash -c \"[[ $${MASTER} == y ]] && /usr/local/bin/gosu vmail /usr/local/bin/trim_logs.sh || exit 0\""
|
||||||
ofelia.job-exec.dovecot_quarantine.schedule: "0 */20 * * * *"
|
ofelia.job-exec.dovecot_quarantine.schedule: "0 */20 * * * *"
|
||||||
@@ -347,7 +351,7 @@ services:
|
|||||||
- dovecot
|
- dovecot
|
||||||
|
|
||||||
postfix-mailcow:
|
postfix-mailcow:
|
||||||
image: ghcr.io/mailcow/postfix:3.10.12-1
|
image: ghcr.io/mailcow/postfix:3.10.12-2
|
||||||
depends_on:
|
depends_on:
|
||||||
mysql-mailcow:
|
mysql-mailcow:
|
||||||
condition: service_started
|
condition: service_started
|
||||||
@@ -374,6 +378,7 @@ services:
|
|||||||
- REDISPASS=${REDISPASS}
|
- REDISPASS=${REDISPASS}
|
||||||
- MAILCOW_HOSTNAME=${MAILCOW_HOSTNAME}
|
- MAILCOW_HOSTNAME=${MAILCOW_HOSTNAME}
|
||||||
- SPAMHAUS_DQS_KEY=${SPAMHAUS_DQS_KEY:-}
|
- SPAMHAUS_DQS_KEY=${SPAMHAUS_DQS_KEY:-}
|
||||||
|
- *db_version
|
||||||
cap_add:
|
cap_add:
|
||||||
- NET_BIND_SERVICE
|
- NET_BIND_SERVICE
|
||||||
ports:
|
ports:
|
||||||
@@ -427,7 +432,7 @@ services:
|
|||||||
- php-fpm-mailcow
|
- php-fpm-mailcow
|
||||||
- sogo-mailcow
|
- sogo-mailcow
|
||||||
- rspamd-mailcow
|
- rspamd-mailcow
|
||||||
image: ghcr.io/mailcow/nginx:1.30.6-1
|
image: ghcr.io/mailcow/nginx:1.30.6-2
|
||||||
dns:
|
dns:
|
||||||
- ${IPV4_NETWORK:-172.22.1}.254
|
- ${IPV4_NETWORK:-172.22.1}.254
|
||||||
environment:
|
environment:
|
||||||
@@ -447,6 +452,7 @@ services:
|
|||||||
- IPV4_NETWORK=${IPV4_NETWORK:-172.22.1}
|
- IPV4_NETWORK=${IPV4_NETWORK:-172.22.1}
|
||||||
- NGINX_USE_PROXY_PROTOCOL=${NGINX_USE_PROXY_PROTOCOL:-n}
|
- NGINX_USE_PROXY_PROTOCOL=${NGINX_USE_PROXY_PROTOCOL:-n}
|
||||||
- TRUSTED_PROXIES=${TRUSTED_PROXIES:-}
|
- TRUSTED_PROXIES=${TRUSTED_PROXIES:-}
|
||||||
|
- NGINX_MAX_BODY_SIZE=${NGINX_MAX_BODY_SIZE:-128m}
|
||||||
volumes:
|
volumes:
|
||||||
- ./data/web:/web:ro,z
|
- ./data/web:/web:ro,z
|
||||||
- ./data/conf/rspamd/dynmaps:/dynmaps:ro,z
|
- ./data/conf/rspamd/dynmaps:/dynmaps:ro,z
|
||||||
@@ -514,7 +520,7 @@ services:
|
|||||||
- acme
|
- acme
|
||||||
|
|
||||||
netfilter-mailcow:
|
netfilter-mailcow:
|
||||||
image: ghcr.io/mailcow/netfilter:1.66
|
image: ghcr.io/mailcow/netfilter:1.67
|
||||||
stop_grace_period: 30s
|
stop_grace_period: 30s
|
||||||
restart: always
|
restart: always
|
||||||
privileged: true
|
privileged: true
|
||||||
@@ -534,7 +540,7 @@ services:
|
|||||||
- /lib/modules:/lib/modules:ro
|
- /lib/modules:/lib/modules:ro
|
||||||
|
|
||||||
watchdog-mailcow:
|
watchdog-mailcow:
|
||||||
image: ghcr.io/mailcow/watchdog:2.12
|
image: ghcr.io/mailcow/watchdog:2.13
|
||||||
dns:
|
dns:
|
||||||
- ${IPV4_NETWORK:-172.22.1}.254
|
- ${IPV4_NETWORK:-172.22.1}.254
|
||||||
tmpfs:
|
tmpfs:
|
||||||
@@ -552,6 +558,7 @@ services:
|
|||||||
- acme-mailcow
|
- acme-mailcow
|
||||||
- redis-mailcow
|
- redis-mailcow
|
||||||
environment:
|
environment:
|
||||||
|
- *db_version
|
||||||
- IPV6_NETWORK=${IPV6_NETWORK:-fd4d:6169:6c63:6f77::/64}
|
- IPV6_NETWORK=${IPV6_NETWORK:-fd4d:6169:6c63:6f77::/64}
|
||||||
- LOG_LINES=${LOG_LINES:-9999}
|
- LOG_LINES=${LOG_LINES:-9999}
|
||||||
- TZ=${TZ}
|
- TZ=${TZ}
|
||||||
@@ -609,7 +616,7 @@ services:
|
|||||||
- watchdog
|
- watchdog
|
||||||
|
|
||||||
dockerapi-mailcow:
|
dockerapi-mailcow:
|
||||||
image: ghcr.io/mailcow/dockerapi:2.14
|
image: ghcr.io/mailcow/dockerapi:2.15
|
||||||
security_opt:
|
security_opt:
|
||||||
- label=disable
|
- label=disable
|
||||||
restart: always
|
restart: always
|
||||||
|
|||||||
@@ -215,6 +215,10 @@ HTTPS_BIND=
|
|||||||
# Redirect HTTP connections to HTTPS - y/n
|
# Redirect HTTP connections to HTTPS - y/n
|
||||||
HTTP_REDIRECT=y
|
HTTP_REDIRECT=y
|
||||||
|
|
||||||
|
# Maximum request size for SOGo webmail, CalDAV/CardDAV and ActiveSync, e.g. 128m or 1g - 0 means unlimited
|
||||||
|
# Keep it above the message size limit of Postfix (100 MB by default). The mailcow UI and API stay limited to 64m.
|
||||||
|
NGINX_MAX_BODY_SIZE=128m
|
||||||
|
|
||||||
# ------------------------------
|
# ------------------------------
|
||||||
# Other bindings
|
# Other bindings
|
||||||
# ------------------------------
|
# ------------------------------
|
||||||
|
|||||||
@@ -188,25 +188,6 @@ class BanManagerTest(unittest.TestCase):
|
|||||||
self.bans.purge()
|
self.bans.purge()
|
||||||
self.assertEqual(self.bans.counters, {})
|
self.assertEqual(self.bans.counters, {})
|
||||||
|
|
||||||
def test_excused_address_is_not_counted(self):
|
|
||||||
ip = ipaddress.ip_address('203.0.113.1')
|
|
||||||
self.bans.excuse(ip)
|
|
||||||
self.bans.excuse(ip)
|
|
||||||
self.attempts('203.0.113.1', 5)
|
|
||||||
self.assertEqual(self.active(), {})
|
|
||||||
self.assertEqual(len([message for message in self.logger.messages('warn') if 'Auth backend unavailable' in message]), 1)
|
|
||||||
# other addresses are counted
|
|
||||||
self.attempts('203.0.113.2', 3)
|
|
||||||
self.assertIn('203.0.113.2/32', self.active())
|
|
||||||
|
|
||||||
def test_excuse_ends(self):
|
|
||||||
self.bans.excuse(ipaddress.ip_address('203.0.113.1'))
|
|
||||||
self.clock.now += BanManager.EXCUSE_TIME + 1
|
|
||||||
self.bans.purge()
|
|
||||||
self.assertEqual(self.bans.excused, {})
|
|
||||||
self.attempts('203.0.113.1', 3)
|
|
||||||
self.assertIn('203.0.113.1/32', self.active())
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
@@ -25,8 +25,8 @@ class NetfilterTest(unittest.TestCase):
|
|||||||
netfilter.regexes.refresh(self.r)
|
netfilter.regexes.refresh(self.r)
|
||||||
return netfilter
|
return netfilter
|
||||||
|
|
||||||
def sasl(self, address):
|
def failed(self, address):
|
||||||
return 'warning: unknown[%s]: SASL LOGIN authentication failed: UGFzc3dvcmQ6' % address
|
return 'MAILCOWAUTH: Login failed for user a@example.org with service imap from IP %s' % address
|
||||||
|
|
||||||
def test_setup_restores_bans_and_resets_the_firewall(self):
|
def test_setup_restores_bans_and_resets_the_firewall(self):
|
||||||
self.r.hset('F2B_ACTIVE_BANS', '1.2.3.4/32', '9999999999')
|
self.r.hset('F2B_ACTIVE_BANS', '1.2.3.4/32', '9999999999')
|
||||||
@@ -49,8 +49,8 @@ class NetfilterTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_shutdown_keeps_the_active_bans(self):
|
def test_shutdown_keeps_the_active_bans(self):
|
||||||
netfilter = self.create()
|
netfilter = self.create()
|
||||||
netfilter.handle_line(self.sasl('1.2.3.4'))
|
netfilter.handle_line(self.failed('1.2.3.4'))
|
||||||
netfilter.handle_line(self.sasl('1.2.3.4'))
|
netfilter.handle_line(self.failed('1.2.3.4'))
|
||||||
self.r.hset('F2B_PERM_BANS', '198.51.100.1', '1')
|
self.r.hset('F2B_PERM_BANS', '198.51.100.1', '1')
|
||||||
netfilter.shutdown()
|
netfilter.shutdown()
|
||||||
self.assertEqual(self.tables.rules, set())
|
self.assertEqual(self.tables.rules, set())
|
||||||
@@ -59,52 +59,51 @@ class NetfilterTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_handle_line_bans(self):
|
def test_handle_line_bans(self):
|
||||||
netfilter = self.create()
|
netfilter = self.create()
|
||||||
netfilter.handle_line(self.sasl('1.2.3.4'))
|
netfilter.handle_line(self.failed('1.2.3.4'))
|
||||||
netfilter.handle_line(self.sasl('1.2.3.4'))
|
netfilter.handle_line(self.failed('1.2.3.4'))
|
||||||
self.assertEqual(self.tables.rules, {'1.2.3.4/32'})
|
self.assertEqual(self.tables.rules, {'1.2.3.4/32'})
|
||||||
self.assertIn('1.2.3.4 matched rule id 3 (%s)' % self.sasl('1.2.3.4'), self.logger.messages('warn'))
|
self.assertIn('1.2.3.4 matched rule id 3 (%s)' % self.failed('1.2.3.4'), self.logger.messages('warn'))
|
||||||
|
|
||||||
def test_handle_line_with_trailing_colon(self):
|
def test_handle_line_with_trailing_colon(self):
|
||||||
netfilter = self.create()
|
netfilter = self.create()
|
||||||
netfilter.handle_line(self.sasl('1.2.3.4:'))
|
netfilter.handle_line(self.failed('1.2.3.4:'))
|
||||||
netfilter.handle_line(self.sasl('1.2.3.4:'))
|
netfilter.handle_line(self.failed('1.2.3.4:'))
|
||||||
self.assertEqual(self.tables.rules, {'1.2.3.4/32'})
|
self.assertEqual(self.tables.rules, {'1.2.3.4/32'})
|
||||||
|
|
||||||
def test_handle_line_ignores_private_and_invalid_addresses(self):
|
def test_handle_line_ignores_private_and_invalid_addresses(self):
|
||||||
netfilter = self.create()
|
netfilter = self.create()
|
||||||
for _ in range(3):
|
for _ in range(3):
|
||||||
netfilter.handle_line(self.sasl('192.168.1.1'))
|
netfilter.handle_line(self.failed('192.168.1.1'))
|
||||||
netfilter.handle_line(self.sasl('::1'))
|
netfilter.handle_line(self.failed('::1'))
|
||||||
netfilter.handle_line(self.sasl('1.2.3'))
|
netfilter.handle_line(self.failed('1.2.3'))
|
||||||
self.assertEqual(self.tables.rules, set())
|
self.assertEqual(self.tables.rules, set())
|
||||||
self.assertTrue(any('invalid address 1.2.3' in message for message in self.logger.messages('warn')))
|
self.assertTrue(any('invalid address 1.2.3' in message for message in self.logger.messages('warn')))
|
||||||
|
|
||||||
def test_handle_line_with_replaced_characters(self):
|
def test_handle_line_with_replaced_characters(self):
|
||||||
netfilter = self.create()
|
netfilter = self.create()
|
||||||
line = b'warning: unknown[1.2.3.4]: SASL LOGIN authentication failed: \xff\xfe'.decode('utf-8', 'replace')
|
line = b'MAILCOWAUTH: Login failed for user \xff\xfe with service imap from IP 1.2.3.4'.decode('utf-8', 'replace')
|
||||||
netfilter.handle_line(line)
|
netfilter.handle_line(line)
|
||||||
netfilter.handle_line(line)
|
netfilter.handle_line(line)
|
||||||
self.assertEqual(self.tables.rules, {'1.2.3.4/32'})
|
self.assertEqual(self.tables.rules, {'1.2.3.4/32'})
|
||||||
|
|
||||||
def test_unavailable_auth_backend_is_no_failed_login(self):
|
def test_postfix_and_dovecot_lines_are_no_failed_logins(self):
|
||||||
# Dovecot logs the temporary failure, Postfix then the failed SASL login like for a wrong password
|
# mailcowauth.php reports failed logins, not when the auth backend was unavailable
|
||||||
netfilter = self.create()
|
netfilter = self.create()
|
||||||
netfilter.handle_line('auth-worker(1): Error: conn unix:auth-worker (pid=2,uid=401): auth-worker<4>: '
|
|
||||||
'lua(a@example.org,1.2.3.4): passdb-lua: Upstream unavailable')
|
|
||||||
for _ in range(3):
|
for _ in range(3):
|
||||||
netfilter.handle_line('warning: unknown[1.2.3.4]: SASL LOGIN authentication failed: (reason unavailable), sasl_username=a@example.org')
|
netfilter.handle_line('warning: unknown[1.2.3.4]: SASL LOGIN authentication failed: (reason unavailable), sasl_username=a@example.org')
|
||||||
|
netfilter.handle_line('imap-login: Disconnected: Connection closed (auth failed, 1 attempts in 2 secs): user=<a@example.org>, '
|
||||||
|
'method=PLAIN, rip=1.2.3.4, lip=172.22.1.250')
|
||||||
self.assertEqual(self.tables.rules, set())
|
self.assertEqual(self.tables.rules, set())
|
||||||
self.assertNotIn('1.2.3.4/32', self.r.hgetall('F2B_ACTIVE_BANS'))
|
|
||||||
|
|
||||||
def test_handle_line_survives_errors(self):
|
def test_handle_line_survives_errors(self):
|
||||||
netfilter = self.create()
|
netfilter = self.create()
|
||||||
with mock.patch.object(netfilter.bans, 'attempt', side_effect=RuntimeError('boom')):
|
with mock.patch.object(netfilter.bans, 'attempt', side_effect=RuntimeError('boom')):
|
||||||
netfilter.handle_line(self.sasl('1.2.3.4'))
|
netfilter.handle_line(self.failed('1.2.3.4'))
|
||||||
self.assertTrue(any('boom' in message for message in self.logger.messages('warn')))
|
self.assertTrue(any('boom' in message for message in self.logger.messages('warn')))
|
||||||
|
|
||||||
def test_watch_reconnects_after_errors(self):
|
def test_watch_reconnects_after_errors(self):
|
||||||
netfilter = self.create()
|
netfilter = self.create()
|
||||||
self.r.messages.extend([self.sasl('1.2.3.4'), ConnectionError('gone'), self.sasl('1.2.3.4')])
|
self.r.messages.extend([self.failed('1.2.3.4'), ConnectionError('gone'), self.failed('1.2.3.4')])
|
||||||
netfilter.loop('watch', netfilter.watch, 0)
|
netfilter.loop('watch', netfilter.watch, 0)
|
||||||
self.assertEqual(self.tables.rules, {'1.2.3.4/32'})
|
self.assertEqual(self.tables.rules, {'1.2.3.4/32'})
|
||||||
self.assertEqual(len(self.r.pubsubs), 2)
|
self.assertEqual(len(self.r.pubsubs), 2)
|
||||||
@@ -126,8 +125,8 @@ class NetfilterTest(unittest.TestCase):
|
|||||||
|
|
||||||
def test_recreated_chain_gets_isolation_and_bans_again(self):
|
def test_recreated_chain_gets_isolation_and_bans_again(self):
|
||||||
netfilter = self.create()
|
netfilter = self.create()
|
||||||
netfilter.handle_line(self.sasl('1.2.3.4'))
|
netfilter.handle_line(self.failed('1.2.3.4'))
|
||||||
netfilter.handle_line(self.sasl('1.2.3.4'))
|
netfilter.handle_line(self.failed('1.2.3.4'))
|
||||||
self.tables.rules = set()
|
self.tables.rules = set()
|
||||||
self.tables.calls = []
|
self.tables.calls = []
|
||||||
self.tables.recreate = True
|
self.tables.recreate = True
|
||||||
@@ -151,8 +150,8 @@ class NetfilterTest(unittest.TestCase):
|
|||||||
self.r.hset('F2B_BLACKLIST', '198.51.100.1', 1)
|
self.r.hset('F2B_BLACKLIST', '198.51.100.1', 1)
|
||||||
netfilter.update_lists()
|
netfilter.update_lists()
|
||||||
self.assertEqual(self.tables.rules, {'198.51.100.1/32'})
|
self.assertEqual(self.tables.rules, {'198.51.100.1/32'})
|
||||||
netfilter.handle_line(self.sasl('1.2.3.4'))
|
netfilter.handle_line(self.failed('1.2.3.4'))
|
||||||
netfilter.handle_line(self.sasl('1.2.3.4'))
|
netfilter.handle_line(self.failed('1.2.3.4'))
|
||||||
self.assertNotIn('1.2.3.4/32', self.tables.rules)
|
self.assertNotIn('1.2.3.4/32', self.tables.rules)
|
||||||
|
|
||||||
def test_resolve_keeps_addresses_on_dns_errors(self):
|
def test_resolve_keeps_addresses_on_dns_errors(self):
|
||||||
|
|||||||
Loaded 100 of 112 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user