mirror of
https://github.com/mailcow/mailcow-dockerized.git
synced 2026-11-07 09:19:21 +00:00
Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0175fce9b5 | ||
|
|
1c24de19c3 | ||
|
|
fb85a9eb5c | ||
|
|
fc56d53029 | ||
|
|
2ac2edf0ad | ||
|
|
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
|
||||||
|
|||||||
@@ -306,13 +306,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):
|
||||||
|
|||||||
@@ -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/'
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -163,6 +178,45 @@ query = SELECT CONCAT('smtp_via_transport_maps:', nexthop) AS transport FROM tra
|
|||||||
AND destination = '%s';
|
AND destination = '%s';
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
|
# Archive copies (journal) to the archive set in the admin UI: on port 591 the sender archive-bounce@localhost
|
||||||
|
# sends the whole mail through archive_smtp (master.cf), whatever the recipient domain
|
||||||
|
cat <<EOF > /opt/postfix/conf/sql/mysql_archive_sender_filter_maps.cf
|
||||||
|
# Autogenerated by mailcow
|
||||||
|
user = ${DBUSER}
|
||||||
|
password = ${DBPASS}
|
||||||
|
hosts = unix:/var/run/mysqld/mysqld.sock
|
||||||
|
dbname = ${DBNAME}
|
||||||
|
query = SELECT CONCAT('FILTER archive_smtp:', nexthop) AS action FROM archive_transport
|
||||||
|
WHERE active = '1'
|
||||||
|
AND '%s' = 'archive-bounce@localhost'
|
||||||
|
LIMIT 1;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat <<EOF > /opt/postfix/conf/sql/mysql_archive_sasl_passwd_maps.cf
|
||||||
|
# Autogenerated by mailcow
|
||||||
|
user = ${DBUSER}
|
||||||
|
password = ${DBPASS}
|
||||||
|
hosts = unix:/var/run/mysqld/mysqld.sock
|
||||||
|
dbname = ${DBNAME}
|
||||||
|
query = SELECT CONCAT_WS(':', username, password) AS auth_data FROM archive_transport
|
||||||
|
WHERE nexthop = '%s'
|
||||||
|
AND active = '1'
|
||||||
|
AND username != ''
|
||||||
|
LIMIT 1;
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat <<EOF > /opt/postfix/conf/sql/mysql_archive_tls_policy_maps.cf
|
||||||
|
# Autogenerated by mailcow
|
||||||
|
user = ${DBUSER}
|
||||||
|
password = ${DBPASS}
|
||||||
|
hosts = unix:/var/run/mysqld/mysqld.sock
|
||||||
|
dbname = ${DBNAME}
|
||||||
|
query = SELECT tls_policy FROM archive_transport
|
||||||
|
WHERE nexthop = '%s'
|
||||||
|
AND active = '1'
|
||||||
|
LIMIT 1;
|
||||||
|
EOF
|
||||||
|
|
||||||
cat <<EOF > /opt/postfix/conf/sql/mysql_virtual_resource_maps.cf
|
cat <<EOF > /opt/postfix/conf/sql/mysql_virtual_resource_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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ $_SESSION['acl']['sogo_access'] = "1";
|
|||||||
$_SESSION['acl']['protocol_access'] = "1";
|
$_SESSION['acl']['protocol_access'] = "1";
|
||||||
$_SESSION['acl']['mailbox_relayhost'] = "1";
|
$_SESSION['acl']['mailbox_relayhost'] = "1";
|
||||||
$_SESSION['acl']['unlimited_quota'] = "1";
|
$_SESSION['acl']['unlimited_quota'] = "1";
|
||||||
|
$_SESSION['acl']['archive'] = "1";
|
||||||
|
|
||||||
// One run at a time, released when the process ends
|
// One run at a time, released when the process ends
|
||||||
$lock = fopen('/tmp/identity-sync.lock', 'c');
|
$lock = fopen('/tmp/identity-sync.lock', 'c');
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
/watchdog@localhost$/ watchdog_discard:
|
/watchdog@localhost$/ watchdog_discard:
|
||||||
|
/^archive-bounce@localhost$/ archive_discard:
|
||||||
/localhost$/ local:
|
/localhost$/ local:
|
||||||
@@ -50,6 +50,9 @@ 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_archive_sender_filter_maps.cf,
|
||||||
|
proxy:mysql:/opt/postfix/conf/sql/mysql_archive_sasl_passwd_maps.cf,
|
||||||
|
proxy:mysql:/opt/postfix/conf/sql/mysql_archive_tls_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,
|
||||||
|
|||||||
@@ -61,13 +61,14 @@ submission inet n - n - - smtpd
|
|||||||
-o non_smtpd_milters=
|
-o non_smtpd_milters=
|
||||||
-o syslog_name=postfix/quarantine
|
-o syslog_name=postfix/quarantine
|
||||||
|
|
||||||
# used to send bcc mails
|
# used to send bcc mails and archive copies (sender archive-bounce@localhost goes to the archive)
|
||||||
591 inet n - n - - smtpd
|
591 inet n - n - - smtpd
|
||||||
-o smtpd_helo_restrictions=
|
-o smtpd_helo_restrictions=
|
||||||
-o smtpd_client_restrictions=permit_mynetworks,reject
|
-o smtpd_client_restrictions=permit_mynetworks,reject
|
||||||
-o smtpd_tls_auth_only=no
|
-o smtpd_tls_auth_only=no
|
||||||
-o smtpd_milters=
|
-o smtpd_milters=
|
||||||
-o non_smtpd_milters=
|
-o non_smtpd_milters=
|
||||||
|
-o smtpd_sender_restrictions=check_sender_access,proxy:mysql:/opt/postfix/conf/sql/mysql_archive_sender_filter_maps.cf,permit_mynetworks,reject
|
||||||
-o syslog_name=postfix/bcc
|
-o syslog_name=postfix/bcc
|
||||||
|
|
||||||
# enforced smtp connector
|
# enforced smtp connector
|
||||||
@@ -81,6 +82,27 @@ smtp_enforced_tls unix - - n - - smtp
|
|||||||
smtp_via_transport_maps unix - - n - - smtp
|
smtp_via_transport_maps unix - - n - - smtp
|
||||||
-o smtp_sasl_password_maps=proxy:mysql:/opt/postfix/conf/sql/mysql_sasl_passwd_maps_transport_maps.cf
|
-o smtp_sasl_password_maps=proxy:mysql:/opt/postfix/conf/sql/mysql_sasl_passwd_maps_transport_maps.cf
|
||||||
|
|
||||||
|
# archive copies (journal) to the archive set in the admin UI: only mails from archive-bounce@localhost on 591 get here
|
||||||
|
# (FILTER of mysql_archive_sender_filter_maps), sent unchanged, TLS by default, refusals are retried (soft_bounce)
|
||||||
|
# until maximal_queue_lifetime
|
||||||
|
archive_smtp unix - - n - - smtp
|
||||||
|
-o soft_bounce=yes
|
||||||
|
-o smtp_header_checks=
|
||||||
|
-o smtp_mime_header_checks=
|
||||||
|
-o smtp_nested_header_checks=
|
||||||
|
-o smtp_body_checks=
|
||||||
|
-o smtp_line_length_limit=0
|
||||||
|
-o disable_mime_output_conversion=yes
|
||||||
|
-o smtp_tls_security_level=encrypt
|
||||||
|
-o smtp_tls_policy_maps=proxy:mysql:/opt/postfix/conf/sql/mysql_archive_tls_policy_maps.cf
|
||||||
|
-o smtp_sasl_auth_enable=yes
|
||||||
|
-o smtp_sasl_password_maps=proxy:mysql:/opt/postfix/conf/sql/mysql_archive_sasl_passwd_maps.cf
|
||||||
|
-o smtp_sasl_tls_security_options=noanonymous
|
||||||
|
-o syslog_name=postfix/archive
|
||||||
|
# notices about archive copies (delays, bounces) go to archive-bounce@localhost and are dropped here
|
||||||
|
archive_discard unix - - n - - discard
|
||||||
|
-o syslog_name=postfix/archive
|
||||||
|
|
||||||
tlsproxy unix - - n - 0 tlsproxy
|
tlsproxy unix - - n - 0 tlsproxy
|
||||||
dnsblog unix - - n - 0 dnsblog
|
dnsblog unix - - n - 0 dnsblog
|
||||||
pickup fifo n - n 60 1 pickup
|
pickup fifo n - n 60 1 pickup
|
||||||
|
|||||||
@@ -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,47 @@
|
|||||||
|
<?php
|
||||||
|
// Archive decision for an accepted mail, asked by the MAILCOW_ARCHIVE symbol of rspamd
|
||||||
|
// Request: JSON {from, rcpt[], user, bcc, qid, received, sha256}; answer: JSON {archive, rcpt, sender, headers}
|
||||||
|
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);
|
||||||
|
// 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("ARCHIVE: " . $e . PHP_EOL);
|
||||||
|
http_response_code(501);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
// Init Redis, only to mirror the switch rspamd reads
|
||||||
|
$redis = new Redis();
|
||||||
|
try {
|
||||||
|
$redis->connect('redis-mailcow', 6379);
|
||||||
|
$redis->auth(getenv("REDISPASS"));
|
||||||
|
}
|
||||||
|
catch (Exception $e) {
|
||||||
|
$redis = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$request = json_decode(file_get_contents('php://input'), true);
|
||||||
|
if (!is_array($request)) {
|
||||||
|
http_response_code(400);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$archive = \Mailcow\Archive\ArchiveModule::create($pdo, \Mailcow\Core\Context::system(), $redis);
|
||||||
|
echo json_encode($archive->journal->prepare($request));
|
||||||
|
}
|
||||||
|
catch (Throwable $e) {
|
||||||
|
error_log("ARCHIVE: " . $e->getMessage() . PHP_EOL);
|
||||||
|
http_response_code(502);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -947,3 +956,138 @@ rspamd_config:register_symbol({
|
|||||||
end,
|
end,
|
||||||
priority = 1
|
priority = 1
|
||||||
})
|
})
|
||||||
|
|
||||||
|
-- Archive: a copy of every accepted mail of an archived mailbox goes by SMTP to the archive, through Postfix (port 591)
|
||||||
|
rspamd_config:register_symbol({
|
||||||
|
name = 'MAILCOW_ARCHIVE',
|
||||||
|
type = 'idempotent',
|
||||||
|
flags = 'empty,explicit_disable,ignore_passthrough',
|
||||||
|
callback = function(task)
|
||||||
|
local rspamd_http = require "rspamd_http"
|
||||||
|
local rspamd_logger = require "rspamd_logger"
|
||||||
|
local rspamd_text = require "rspamd_text"
|
||||||
|
local rspamd_util = require "rspamd_util"
|
||||||
|
local hash = require "rspamd_cryptobox_hash"
|
||||||
|
local lua_smtp = require "lua_smtp"
|
||||||
|
local cjson = require "cjson"
|
||||||
|
|
||||||
|
-- accepted mails only: no reject, soft reject (greylisting included), discard or quarantine
|
||||||
|
local action = task:get_metric_action('default')
|
||||||
|
local accepted = (action == 'no action' or action == 'add header' or action == 'rewrite subject' or action == 'greylist')
|
||||||
|
local pre_accept = false
|
||||||
|
if task.has_pre_result then
|
||||||
|
local has_pre, pre_action = task:has_pre_result()
|
||||||
|
pre_accept = (has_pre and pre_action == 'accept')
|
||||||
|
end
|
||||||
|
if not accepted and not pre_accept then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local rcpts = {}
|
||||||
|
for _, r in ipairs(task:get_recipients('smtp') or {}) do
|
||||||
|
if r.addr and r.addr ~= '' then
|
||||||
|
table.insert(rcpts, r.addr)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #rcpts == 0 then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local from = task:get_from('smtp')
|
||||||
|
local from_addr = (from and from[1] and from[1].addr) or ''
|
||||||
|
local qid = task:get_queue_id() or ''
|
||||||
|
-- the BCC symbol sends copies under the same condition, they bypass rspamd
|
||||||
|
local bcc = (pre_accept or (action ~= 'greylist' and accepted)) and not task:has_symbol('ENCRYPTED_CHAT')
|
||||||
|
local redis_params = rspamd_parse_redis_server('archive')
|
||||||
|
|
||||||
|
-- not archived: counted and listed for the archive status in the UI
|
||||||
|
-- the Message-ID finds the mail in the archive log of the UI
|
||||||
|
local message_id = task:get_message_id()
|
||||||
|
if not message_id or message_id == 'undef' then
|
||||||
|
message_id = ''
|
||||||
|
end
|
||||||
|
local function dropped(reason)
|
||||||
|
rspamd_logger.errx(task, "archive: mail %s is not archived: %s", qid, reason)
|
||||||
|
rspamd_redis_make_request(task, redis_params, 'ARCHIVE_DROPPED', true, function() end, 'INCR', {'ARCHIVE_DROPPED'})
|
||||||
|
rspamd_redis_make_request(task, redis_params, 'ARCHIVE_DROPPED_LOG', true, function() end, 'LPUSH',
|
||||||
|
{'ARCHIVE_DROPPED_LOG', cjson.encode({time = os.time(), qid = qid, reason = tostring(reason), message_id = message_id})})
|
||||||
|
rspamd_redis_make_request(task, redis_params, 'ARCHIVE_DROPPED_LOG', true, function() end, 'LTRIM', {'ARCHIVE_DROPPED_LOG', '0', '99'})
|
||||||
|
end
|
||||||
|
|
||||||
|
local function archive()
|
||||||
|
-- the archive gets the mail as accepted, with CRLF line ends and a final CRLF (also what the hash covers)
|
||||||
|
local content = rspamd_text.fromstring(tostring(task:get_content()))
|
||||||
|
content:normalize_newlines('smtp')
|
||||||
|
if #content < 2 or content:at(#content) ~= 10 or content:at(#content - 1) ~= 13 then
|
||||||
|
content = rspamd_text.fromtable({content, '\r\n'})
|
||||||
|
end
|
||||||
|
local body = {
|
||||||
|
from = from_addr,
|
||||||
|
rcpt = rcpts,
|
||||||
|
user = task:get_user() or '',
|
||||||
|
bcc = bcc,
|
||||||
|
qid = qid,
|
||||||
|
received = rspamd_util.get_time(),
|
||||||
|
sha256 = hash.create_specific('sha256', content):hex()
|
||||||
|
}
|
||||||
|
|
||||||
|
local function sent_cb(ok, err)
|
||||||
|
if not ok then
|
||||||
|
dropped(err)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function journal_cb(err, code, res_body)
|
||||||
|
if err or code ~= 200 then
|
||||||
|
dropped(string.format('owner lookup failed (%s, %s)', err, code))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local ok, res = pcall(cjson.decode, tostring(res_body))
|
||||||
|
if not ok or type(res) ~= 'table' then
|
||||||
|
dropped('invalid answer of the owner lookup')
|
||||||
|
return
|
||||||
|
end
|
||||||
|
if res.archive ~= true then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
-- lua_smtp ends the data with CRLF.CRLF, so the last CRLF of the content is left to it
|
||||||
|
lua_smtp.sendmail({
|
||||||
|
task = task,
|
||||||
|
host = os.getenv("IPV4_NETWORK") .. '.253',
|
||||||
|
port = 591,
|
||||||
|
from = res.sender,
|
||||||
|
recipients = res.rcpt,
|
||||||
|
helo = 'archive',
|
||||||
|
timeout = 20,
|
||||||
|
}, {res.headers or '', content:span(1, #content - 2)}, sent_cb)
|
||||||
|
end
|
||||||
|
|
||||||
|
rspamd_http.request({
|
||||||
|
task = task,
|
||||||
|
url = 'http://nginx:8081/archive.php',
|
||||||
|
body = cjson.encode(body),
|
||||||
|
timeout = (#rcpts > 100) and 10.0 or 2.0,
|
||||||
|
callback = journal_cb,
|
||||||
|
headers = {['Content-Type'] = 'application/json'},
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
local function enabled_cb(err, data)
|
||||||
|
if not err and type(data) == 'string' and data == '0' then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
archive()
|
||||||
|
end
|
||||||
|
|
||||||
|
local ret = rspamd_redis_make_request(task,
|
||||||
|
redis_params, -- connect params
|
||||||
|
'ARCHIVE_ENABLED', -- hash key
|
||||||
|
false, -- is write
|
||||||
|
enabled_cb, --callback
|
||||||
|
'GET', -- command
|
||||||
|
{'ARCHIVE_ENABLED'} -- arguments
|
||||||
|
)
|
||||||
|
if not ret then
|
||||||
|
archive()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
})
|
||||||
@@ -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 (
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ $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(),
|
||||||
|
'archive_enabled' => \Mailcow\Archive\ArchiveModule::fromGlobals()->settings->enabled(),
|
||||||
];
|
];
|
||||||
|
|
||||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
|
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
|
||||||
@@ -13,6 +13,7 @@ $js_minifier->add('/web/js/site/admin.js');
|
|||||||
$js_minifier->add('/web/js/presets/rspamd.js');
|
$js_minifier->add('/web/js/presets/rspamd.js');
|
||||||
$js_minifier->add('/web/js/site/pwgen.js');
|
$js_minifier->add('/web/js/site/pwgen.js');
|
||||||
$js_minifier->add('/web/js/site/identity.js');
|
$js_minifier->add('/web/js/site/identity.js');
|
||||||
|
$js_minifier->add('/web/js/site/archive.js');
|
||||||
|
|
||||||
// all domains
|
// all domains
|
||||||
$domains = mailbox('get', 'domains');
|
$domains = mailbox('get', 'domains');
|
||||||
@@ -116,6 +117,7 @@ $template_data = [
|
|||||||
'ui_theme' => customize('get', 'ui_theme'),
|
'ui_theme' => customize('get', 'ui_theme'),
|
||||||
'password_complexity' => password_complexity('get'),
|
'password_complexity' => password_complexity('get'),
|
||||||
'imapsync_settings' => \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getSettings(),
|
'imapsync_settings' => \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->api->getSettings(),
|
||||||
|
'archive_settings' => \Mailcow\Archive\ArchiveModule::fromGlobals()->api->getSettings(),
|
||||||
'show_rspamd_global_filters' => @$_SESSION['show_rspamd_global_filters'],
|
'show_rspamd_global_filters' => @$_SESSION['show_rspamd_global_filters'],
|
||||||
'cors_settings' => $cors_settings,
|
'cors_settings' => $cors_settings,
|
||||||
'is_https' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on',
|
'is_https' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on',
|
||||||
|
|||||||
@@ -685,6 +685,11 @@ paths:
|
|||||||
is domain global address list active or not, it enables
|
is domain global address list active or not, it enables
|
||||||
shared contacts accross domain in SOGo webmail
|
shared contacts accross domain in SOGo webmail
|
||||||
type: boolean
|
type: boolean
|
||||||
|
archive:
|
||||||
|
description: >-
|
||||||
|
archive mails of the mailboxes of this domain by default (mailboxes set to `inherit`),
|
||||||
|
see GET /api/v1/get/archive/mailboxes
|
||||||
|
type: boolean
|
||||||
mailboxes:
|
mailboxes:
|
||||||
description: limit count of mailboxes associated with this domain
|
description: limit count of mailboxes associated with this domain
|
||||||
type: number
|
type: number
|
||||||
@@ -1254,6 +1259,12 @@ paths:
|
|||||||
force_tfa:
|
force_tfa:
|
||||||
description: force 2FA enrollment at login
|
description: force 2FA enrollment at login
|
||||||
type: boolean
|
type: boolean
|
||||||
|
archive:
|
||||||
|
description: >-
|
||||||
|
archiving of the mailbox: `inherit` follows the domain, `1` always, `0` never (admins, domain
|
||||||
|
admins with the archive permission; otherwise the template decides)
|
||||||
|
type: string
|
||||||
|
enum: [inherit, "1", "0"]
|
||||||
tls_enforce_in:
|
tls_enforce_in:
|
||||||
description: force inbound email tls encryption
|
description: force inbound email tls encryption
|
||||||
type: boolean
|
type: boolean
|
||||||
@@ -1936,6 +1947,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
|
||||||
@@ -3910,6 +3929,11 @@ paths:
|
|||||||
is domain global address list active or not, it enables
|
is domain global address list active or not, it enables
|
||||||
shared contacts accross domain in SOGo webmail
|
shared contacts accross domain in SOGo webmail
|
||||||
type: boolean
|
type: boolean
|
||||||
|
archive:
|
||||||
|
description: >-
|
||||||
|
archive mails of the mailboxes of this domain by default (admins, domain admins with the
|
||||||
|
archive permission)
|
||||||
|
type: boolean
|
||||||
mailboxes:
|
mailboxes:
|
||||||
description: limit count of mailboxes associated with this domain
|
description: limit count of mailboxes associated with this domain
|
||||||
type: number
|
type: number
|
||||||
@@ -4214,6 +4238,12 @@ paths:
|
|||||||
force_tfa:
|
force_tfa:
|
||||||
description: force 2FA enrollment at login
|
description: force 2FA enrollment at login
|
||||||
type: boolean
|
type: boolean
|
||||||
|
archive:
|
||||||
|
description: >-
|
||||||
|
archiving of the mailbox: `inherit` follows the domain, `1` always, `0` never (admins, domain
|
||||||
|
admins with the archive permission)
|
||||||
|
type: string
|
||||||
|
enum: [inherit, "1", "0"]
|
||||||
name:
|
name:
|
||||||
description: Full name of the mailbox user
|
description: Full name of the mailbox user
|
||||||
type: string
|
type: string
|
||||||
@@ -4538,6 +4568,104 @@ paths:
|
|||||||
default: run
|
default: run
|
||||||
type: object
|
type: object
|
||||||
summary: Request identity sync run
|
summary: Request identity sync run
|
||||||
|
/api/v1/edit/archive:
|
||||||
|
post:
|
||||||
|
responses:
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
examples:
|
||||||
|
response:
|
||||||
|
value:
|
||||||
|
- log:
|
||||||
|
- archive
|
||||||
|
- edit
|
||||||
|
- settings
|
||||||
|
- enabled: "1"
|
||||||
|
address: journal@archive.example.org
|
||||||
|
signing_secret: "*"
|
||||||
|
msg: archive_settings_saved
|
||||||
|
type: success
|
||||||
|
description: OK
|
||||||
|
headers: {}
|
||||||
|
tags:
|
||||||
|
- Archive
|
||||||
|
description: >-
|
||||||
|
Archive target, admins with a read-write key only. mailcow sends a copy of every accepted mail of archived
|
||||||
|
mailboxes by SMTP to `address`, through the Postfix queue (envelope sender `archive-bounce@localhost`, notices
|
||||||
|
to it are dropped: no bounce or delay warning reaches a sender). Attributes missing in the request keep their
|
||||||
|
value, an empty `password` or `signing_secret` keeps the stored one. Switching on needs the address, and the
|
||||||
|
signing key for the profile archivecow. With `action: test` the stored target is tested (connection, TLS,
|
||||||
|
AUTH, whether the address is accepted; no mail is sent), with `action: testmail` a test copy is sent through
|
||||||
|
the mail queue.
|
||||||
|
operationId: Edit archive settings
|
||||||
|
requestBody:
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
example:
|
||||||
|
items:
|
||||||
|
- archive
|
||||||
|
attr:
|
||||||
|
enabled: "1"
|
||||||
|
address: journal@archive.example.org
|
||||||
|
host: archive.example.org
|
||||||
|
port: 25
|
||||||
|
tls: encrypt
|
||||||
|
profile: archivecow
|
||||||
|
signing_secret: secret-key
|
||||||
|
properties:
|
||||||
|
items:
|
||||||
|
type: array
|
||||||
|
default: ["archive"]
|
||||||
|
attr:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
enabled:
|
||||||
|
description: copies of accepted mails go to the archive; copies already queued are delivered either way
|
||||||
|
type: boolean
|
||||||
|
address:
|
||||||
|
description: address the archive receives copies at
|
||||||
|
type: string
|
||||||
|
host:
|
||||||
|
description: SMTP server of the archive, empty for the MX of the address domain
|
||||||
|
type: string
|
||||||
|
port:
|
||||||
|
type: integer
|
||||||
|
default: 25
|
||||||
|
tls:
|
||||||
|
description: "`encrypt` (TLS required), `verify` (TLS required, certificate verified), `may`"
|
||||||
|
type: string
|
||||||
|
enum: [encrypt, verify, may]
|
||||||
|
username:
|
||||||
|
description: SMTP AUTH user at the archive, empty for none
|
||||||
|
type: string
|
||||||
|
password:
|
||||||
|
description: SMTP AUTH password, never returned
|
||||||
|
type: string
|
||||||
|
profile:
|
||||||
|
description: >-
|
||||||
|
headers in front of each copy: `archivecow` (X-Archive-Owner, -Rcpt, -From, -Queue-ID,
|
||||||
|
-Received, -Direction, -Source, -SHA256 and X-Archive-Signature), `envelope` (sender and
|
||||||
|
recipients under `header_from` / `header_rcpt`), `none`
|
||||||
|
type: string
|
||||||
|
enum: [archivecow, envelope, none]
|
||||||
|
signing_secret:
|
||||||
|
description: key of X-Archive-Signature (HMAC-SHA256), never returned
|
||||||
|
type: string
|
||||||
|
header_from:
|
||||||
|
type: string
|
||||||
|
default: X-Envelope-From
|
||||||
|
header_rcpt:
|
||||||
|
type: string
|
||||||
|
default: X-Envelope-To
|
||||||
|
action:
|
||||||
|
type: string
|
||||||
|
enum: [test, testmail]
|
||||||
|
type: object
|
||||||
|
summary: Edit archive settings
|
||||||
/api/v1/edit/mailq:
|
/api/v1/edit/mailq:
|
||||||
post:
|
post:
|
||||||
responses:
|
responses:
|
||||||
@@ -5080,6 +5208,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
|
||||||
@@ -5741,6 +5878,7 @@ paths:
|
|||||||
description: Some description
|
description: Some description
|
||||||
domain_name: domain.tld
|
domain_name: domain.tld
|
||||||
gal: "0"
|
gal: "0"
|
||||||
|
archive: 1
|
||||||
max_new_mailbox_quota: 10737418240
|
max_new_mailbox_quota: 10737418240
|
||||||
max_num_aliases_for_domain: 400
|
max_num_aliases_for_domain: 400
|
||||||
max_num_mboxes_for_domain: 10
|
max_num_mboxes_for_domain: 10
|
||||||
@@ -6295,7 +6433,9 @@ paths:
|
|||||||
response:
|
response:
|
||||||
value:
|
value:
|
||||||
- active: "1"
|
- active: "1"
|
||||||
|
archive: 1
|
||||||
attributes:
|
attributes:
|
||||||
|
archive: inherit
|
||||||
force_pw_update: "0"
|
force_pw_update: "0"
|
||||||
force_tfa: "0"
|
force_tfa: "0"
|
||||||
mailbox_format: "maildir:"
|
mailbox_format: "maildir:"
|
||||||
@@ -6490,6 +6630,154 @@ paths:
|
|||||||
nothing was changed).
|
nothing was changed).
|
||||||
operationId: Get identity sync status
|
operationId: Get identity sync status
|
||||||
summary: Get identity sync status
|
summary: Get identity sync status
|
||||||
|
/api/v1/get/archive/mailboxes:
|
||||||
|
get:
|
||||||
|
responses:
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
examples:
|
||||||
|
response:
|
||||||
|
value:
|
||||||
|
enabled: 1
|
||||||
|
mailboxes:
|
||||||
|
- username: anna@example.org
|
||||||
|
domain: example.org
|
||||||
|
name: Anna Example
|
||||||
|
active: 1
|
||||||
|
source: domain
|
||||||
|
- username: board@example.net
|
||||||
|
domain: example.net
|
||||||
|
name: Board
|
||||||
|
active: 2
|
||||||
|
source: mailbox
|
||||||
|
description: OK
|
||||||
|
headers: {}
|
||||||
|
tags:
|
||||||
|
- Archive
|
||||||
|
description: >-
|
||||||
|
Mailboxes whose mails go to the archive now (admins, read-only keys included). A mailbox is archived when
|
||||||
|
archiving is switched on (`enabled`) and its setting is `1`, or `inherit` while its domain archives by default
|
||||||
|
(`source` says which). Only mailboxes that receive mail are listed: `active` 1, or 2 (no login, mail is
|
||||||
|
delivered). `username` is the value of the X-Archive-Owner header. Mailboxes that drop out of the list
|
||||||
|
(archiving switched off, deleted, renamed) keep their mails in the archive.
|
||||||
|
operationId: Get archived mailboxes
|
||||||
|
summary: Get archived mailboxes
|
||||||
|
/api/v1/get/archive/status:
|
||||||
|
get:
|
||||||
|
responses:
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
examples:
|
||||||
|
response:
|
||||||
|
value:
|
||||||
|
enabled: 1
|
||||||
|
configured: 1
|
||||||
|
queued: 2
|
||||||
|
queued_bytes: 182044
|
||||||
|
oldest_queued: 1762425600
|
||||||
|
delay_reasons:
|
||||||
|
- "connect to archive.example.org[192.0.2.10]:25: Connection refused"
|
||||||
|
last_delivery: 1762425580
|
||||||
|
dropped: 3
|
||||||
|
dropped_24h: 1
|
||||||
|
last_dropped:
|
||||||
|
time: 1762425500
|
||||||
|
reason: "owner lookup failed (nil, 502)"
|
||||||
|
description: OK
|
||||||
|
headers: {}
|
||||||
|
tags:
|
||||||
|
- Archive
|
||||||
|
description: >-
|
||||||
|
Summary of the archive state (admins, read-only keys included): copies waiting in the Postfix queue with the
|
||||||
|
reasons Postfix gives, the time of the last delivery to the archive, and mails that were not archived because
|
||||||
|
their copy could not be handed to Postfix (`dropped` since ever, `dropped_24h` and `last_dropped` for the last
|
||||||
|
24 hours).
|
||||||
|
operationId: Get archive status
|
||||||
|
summary: Get archive status
|
||||||
|
"/api/v1/get/logs/archive/{count}":
|
||||||
|
get:
|
||||||
|
parameters:
|
||||||
|
- description: Number of logs to return
|
||||||
|
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: 1762425580
|
||||||
|
status: sent
|
||||||
|
qid: 4XyZ1abc
|
||||||
|
message_id: "c3d1e0f2@example.org"
|
||||||
|
text: "250 2.0.0 Ok: queued as 9F2C4"
|
||||||
|
- time: 1762425500
|
||||||
|
status: dropped
|
||||||
|
qid: 3AbC2def
|
||||||
|
message_id: "a1b2c3@example.org"
|
||||||
|
text: "owner lookup failed (nil, 502)"
|
||||||
|
description: OK
|
||||||
|
headers: {}
|
||||||
|
tags:
|
||||||
|
- Logs
|
||||||
|
description: >-
|
||||||
|
Archive log, newest first (admins, read-only keys included): deliveries of copies from the Postfix log
|
||||||
|
(`status` sent, deferred, softbounce when the archive refused the copy and Postfix keeps it, or bounced),
|
||||||
|
copies discarded after the queue lifetime (expired) and mails that were not archived (dropped, `text` is the
|
||||||
|
reason). `message_id` is the Message-ID of the mail, `qid` the queue ID of the copy (of the mail for dropped).
|
||||||
|
|
||||||
|
Tip: You can limit how many logs you want to get by using `/<count>` at
|
||||||
|
the end of the api url.
|
||||||
|
operationId: Get archive log
|
||||||
|
summary: Get archive log
|
||||||
|
/api/v1/get/archive/settings:
|
||||||
|
get:
|
||||||
|
responses:
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
"200":
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
examples:
|
||||||
|
response:
|
||||||
|
value:
|
||||||
|
enabled: 1
|
||||||
|
address: journal@archive.example.org
|
||||||
|
host: archive.example.org
|
||||||
|
port: 25
|
||||||
|
tls: encrypt
|
||||||
|
username: ""
|
||||||
|
has_password: 0
|
||||||
|
profile: archivecow
|
||||||
|
has_signing_secret: 1
|
||||||
|
header_from: X-Envelope-From
|
||||||
|
header_rcpt: X-Envelope-To
|
||||||
|
description: OK
|
||||||
|
headers: {}
|
||||||
|
tags:
|
||||||
|
- Archive
|
||||||
|
description: Archive target (admins, read-only keys included); password and signing key are never returned.
|
||||||
|
operationId: Get archive settings
|
||||||
|
summary: Get archive settings
|
||||||
"/api/v1/get/identity-link/{username}":
|
"/api/v1/get/identity-link/{username}":
|
||||||
get:
|
get:
|
||||||
parameters:
|
parameters:
|
||||||
@@ -7313,6 +7601,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
|
||||||
@@ -7579,7 +7868,9 @@ paths:
|
|||||||
response:
|
response:
|
||||||
value:
|
value:
|
||||||
- active: "1"
|
- active: "1"
|
||||||
|
archive: 1
|
||||||
attributes:
|
attributes:
|
||||||
|
archive: inherit
|
||||||
force_pw_update: "0"
|
force_pw_update: "0"
|
||||||
force_tfa: "0"
|
force_tfa: "0"
|
||||||
mailbox_format: "maildir:"
|
mailbox_format: "maildir:"
|
||||||
@@ -8007,3 +8298,7 @@ tags:
|
|||||||
description: Manage Cross-Origin Resource Sharing (CORS) settings
|
description: Manage Cross-Origin Resource Sharing (CORS) settings
|
||||||
- name: Identity Provider
|
- name: Identity Provider
|
||||||
description: Manage external Identity Provider settings
|
description: Manage external Identity Provider settings
|
||||||
|
- name: Archive
|
||||||
|
description: >-
|
||||||
|
Archiving (SMTP journaling): a copy of every accepted incoming, outgoing and internal mail of archived
|
||||||
|
mailboxes goes by SMTP to the archive, through the Postfix queue.
|
||||||
+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>
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ $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(),
|
||||||
|
'archive_enabled' => \Mailcow\Archive\ArchiveModule::fromGlobals()->settings->enabled(),
|
||||||
];
|
];
|
||||||
|
|
||||||
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
|
require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php';
|
||||||
+7
-2
@@ -65,7 +65,8 @@ if (isset($_SESSION['mailcow_cc_role'])) {
|
|||||||
'mta_sts' => $mta_sts,
|
'mta_sts' => $mta_sts,
|
||||||
'mailboxes' => mailbox('get', 'mailboxes', $_GET["domain"]),
|
'mailboxes' => mailbox('get', 'mailboxes', $_GET["domain"]),
|
||||||
'aliases' => mailbox('get', 'aliases', $_GET["domain"], 'address'),
|
'aliases' => mailbox('get', 'aliases', $_GET["domain"], 'address'),
|
||||||
'alias_domains' => mailbox('get', 'alias_domains', $_GET["domain"])
|
'alias_domains' => mailbox('get', 'alias_domains', $_GET["domain"]),
|
||||||
|
'archive_enabled' => \Mailcow\Archive\ArchiveModule::fromGlobals()->settings->enabled()
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,6 +76,7 @@ if (isset($_SESSION['mailcow_cc_role'])) {
|
|||||||
$template_data = [
|
$template_data = [
|
||||||
'template' => $domain_template,
|
'template' => $domain_template,
|
||||||
'rl' => ['frame' => $domain_template['attributes']['rl_frame']],
|
'rl' => ['frame' => $domain_template['attributes']['rl_frame']],
|
||||||
|
'archive_enabled' => \Mailcow\Archive\ArchiveModule::fromGlobals()->settings->enabled(),
|
||||||
];
|
];
|
||||||
$template = 'edit/domain-templates.twig';
|
$template = 'edit/domain-templates.twig';
|
||||||
$result = true;
|
$result = true;
|
||||||
@@ -85,6 +87,7 @@ if (isset($_SESSION['mailcow_cc_role'])) {
|
|||||||
$template_data = [
|
$template_data = [
|
||||||
'template' => $mailbox_template,
|
'template' => $mailbox_template,
|
||||||
'rl' => ['frame' => $mailbox_template['attributes']['rl_frame']],
|
'rl' => ['frame' => $mailbox_template['attributes']['rl_frame']],
|
||||||
|
'archive_enabled' => \Mailcow\Archive\ArchiveModule::fromGlobals()->settings->enabled(),
|
||||||
];
|
];
|
||||||
$template = 'edit/mailbox-templates.twig';
|
$template = 'edit/mailbox-templates.twig';
|
||||||
$result = true;
|
$result = true;
|
||||||
@@ -162,6 +165,7 @@ if (isset($_SESSION['mailcow_cc_role'])) {
|
|||||||
'user_acls' => acl('get', 'user', $mailbox),
|
'user_acls' => acl('get', 'user', $mailbox),
|
||||||
'mailbox_details' => $result,
|
'mailbox_details' => $result,
|
||||||
'iam_settings' => $iam_settings,
|
'iam_settings' => $iam_settings,
|
||||||
|
'archive_enabled' => \Mailcow\Archive\ArchiveModule::fromGlobals()->settings->enabled(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,7 +231,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,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'])) {
|
||||||
|
|||||||
@@ -539,6 +539,7 @@ function sys_mail($_data) {
|
|||||||
$mail->Body = $mass_text;
|
$mail->Body = $mass_text;
|
||||||
}
|
}
|
||||||
$mail->XMailer = 'MooMassMail';
|
$mail->XMailer = 'MooMassMail';
|
||||||
|
$archive = \Mailcow\Archive\ArchiveModule::fromGlobals();
|
||||||
foreach ($rcpts as $rcpt) {
|
foreach ($rcpts as $rcpt) {
|
||||||
$mail->AddAddress($rcpt);
|
$mail->AddAddress($rcpt);
|
||||||
if (!$mail->send()) {
|
if (!$mail->send()) {
|
||||||
@@ -548,6 +549,10 @@ function sys_mail($_data) {
|
|||||||
'msg' => 'Mailer error (RCPT "' . htmlspecialchars($rcpt) . '"): ' . str_replace('https://github.com/PHPMailer/PHPMailer/wiki/Troubleshooting', '', $mail->ErrorInfo)
|
'msg' => 'Mailer error (RCPT "' . htmlspecialchars($rcpt) . '"): ' . str_replace('https://github.com/PHPMailer/PHPMailer/wiki/Troubleshooting', '', $mail->ErrorInfo)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
|
// Delivered by LMTP without rspamd, so the archive gets it here
|
||||||
|
$archive->journal->submitDelivered($mail->getSentMIMEMessage(), $mass_from, array($rcpt), 'massmail');
|
||||||
|
}
|
||||||
$mail->ClearAllRecipients();
|
$mail->ClearAllRecipients();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1857,6 +1862,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 +3052,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`
|
||||||
|
|||||||
@@ -366,6 +366,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
$relay_unknown_only = (isset($_data['relay_unknown_only'])) ? intval($_data['relay_unknown_only']) : $DOMAIN_DEFAULT_ATTRIBUTES['relay_unknown_only'];
|
$relay_unknown_only = (isset($_data['relay_unknown_only'])) ? intval($_data['relay_unknown_only']) : $DOMAIN_DEFAULT_ATTRIBUTES['relay_unknown_only'];
|
||||||
$backupmx = (isset($_data['backupmx'])) ? intval($_data['backupmx']) : $DOMAIN_DEFAULT_ATTRIBUTES['backupmx'];
|
$backupmx = (isset($_data['backupmx'])) ? intval($_data['backupmx']) : $DOMAIN_DEFAULT_ATTRIBUTES['backupmx'];
|
||||||
$gal = (isset($_data['gal'])) ? intval($_data['gal']) : $DOMAIN_DEFAULT_ATTRIBUTES['gal'];
|
$gal = (isset($_data['gal'])) ? intval($_data['gal']) : $DOMAIN_DEFAULT_ATTRIBUTES['gal'];
|
||||||
|
$archive = (intval((isset($_data['archive'])) ? $_data['archive'] : ($DOMAIN_DEFAULT_ATTRIBUTES['archive'] ?? 0)) == 1) ? 1 : 0;
|
||||||
if ($relay_all_recipients == 1) {
|
if ($relay_all_recipients == 1) {
|
||||||
$backupmx = '1';
|
$backupmx = '1';
|
||||||
}
|
}
|
||||||
@@ -421,8 +422,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
':domain' => '%@' . $domain
|
':domain' => '%@' . $domain
|
||||||
));
|
));
|
||||||
// save domain
|
// save domain
|
||||||
$stmt = $pdo->prepare("INSERT INTO `domain` (`domain`, `description`, `aliases`, `mailboxes`, `defquota`, `maxquota`, `quota`, `backupmx`, `gal`, `active`, `relay_unknown_only`, `relay_all_recipients`)
|
$stmt = $pdo->prepare("INSERT INTO `domain` (`domain`, `description`, `aliases`, `mailboxes`, `defquota`, `maxquota`, `quota`, `backupmx`, `gal`, `active`, `relay_unknown_only`, `relay_all_recipients`, `archive`)
|
||||||
VALUES (:domain, :description, :aliases, :mailboxes, :defquota, :maxquota, :quota, :backupmx, :gal, :active, :relay_unknown_only, :relay_all_recipients)");
|
VALUES (:domain, :description, :aliases, :mailboxes, :defquota, :maxquota, :quota, :backupmx, :gal, :active, :relay_unknown_only, :relay_all_recipients, :archive)");
|
||||||
$stmt->execute(array(
|
$stmt->execute(array(
|
||||||
':domain' => $domain,
|
':domain' => $domain,
|
||||||
':description' => $description,
|
':description' => $description,
|
||||||
@@ -435,7 +436,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
':gal' => $gal,
|
':gal' => $gal,
|
||||||
':active' => $active,
|
':active' => $active,
|
||||||
':relay_unknown_only' => $relay_unknown_only,
|
':relay_unknown_only' => $relay_unknown_only,
|
||||||
':relay_all_recipients' => $relay_all_recipients
|
':relay_all_recipients' => $relay_all_recipients,
|
||||||
|
':archive' => $archive
|
||||||
));
|
));
|
||||||
// save tags
|
// save tags
|
||||||
foreach($tags as $index => $tag){
|
foreach($tags as $index => $tag){
|
||||||
@@ -940,6 +942,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
$relayhost = (isset($_data['relayhost'])) ? intval($_data['relayhost']) : 0;
|
$relayhost = (isset($_data['relayhost'])) ? intval($_data['relayhost']) : 0;
|
||||||
// Shared mailbox: no login of its own, members open it through group shares
|
// Shared mailbox: no login of its own, members open it through group shares
|
||||||
$shared = (isset($_data['shared'])) ? intval($_data['shared']) : 0;
|
$shared = (isset($_data['shared'])) ? intval($_data['shared']) : 0;
|
||||||
|
// Archiving comes from the template unless the caller may set it
|
||||||
|
$archive = \Mailcow\Archive\Model\Mailbox::setting((isset($_data['archive']) && hasACLAccess("archive")) ? $_data['archive'] : ($MAILBOX_DEFAULT_ATTRIBUTES['archive'] ?? 'inherit'));
|
||||||
$quarantine_notification = (isset($_data['quarantine_notification'])) ? strval($_data['quarantine_notification']) : strval($MAILBOX_DEFAULT_ATTRIBUTES['quarantine_notification']);
|
$quarantine_notification = (isset($_data['quarantine_notification'])) ? strval($_data['quarantine_notification']) : strval($MAILBOX_DEFAULT_ATTRIBUTES['quarantine_notification']);
|
||||||
$quarantine_category = (isset($_data['quarantine_category'])) ? strval($_data['quarantine_category']) : strval($MAILBOX_DEFAULT_ATTRIBUTES['quarantine_category']);
|
$quarantine_category = (isset($_data['quarantine_category'])) ? strval($_data['quarantine_category']) : strval($MAILBOX_DEFAULT_ATTRIBUTES['quarantine_category']);
|
||||||
// Validate quarantine_category
|
// Validate quarantine_category
|
||||||
@@ -974,6 +978,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
'dav_access' => strval($dav_access),
|
'dav_access' => strval($dav_access),
|
||||||
'relayhost' => strval($relayhost),
|
'relayhost' => strval($relayhost),
|
||||||
'shared' => strval($shared),
|
'shared' => strval($shared),
|
||||||
|
'archive' => $archive,
|
||||||
'passwd_update' => time(),
|
'passwd_update' => time(),
|
||||||
'mailbox_format' => strval($MAILBOX_DEFAULT_ATTRIBUTES['mailbox_format']),
|
'mailbox_format' => strval($MAILBOX_DEFAULT_ATTRIBUTES['mailbox_format']),
|
||||||
'quarantine_notification' => strval($quarantine_notification),
|
'quarantine_notification' => strval($quarantine_notification),
|
||||||
@@ -1525,6 +1530,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
$attr['backupmx'] = (isset($_data['backupmx'])) ? intval($_data['backupmx']) : 0;
|
$attr['backupmx'] = (isset($_data['backupmx'])) ? intval($_data['backupmx']) : 0;
|
||||||
$attr['relay_all_recipients'] = (isset($_data['relay_all_recipients'])) ? intval($_data['relay_all_recipients']) : 0;
|
$attr['relay_all_recipients'] = (isset($_data['relay_all_recipients'])) ? intval($_data['relay_all_recipients']) : 0;
|
||||||
$attr['relay_unknown_only'] = (isset($_data['relay_unknown_only'])) ? intval($_data['relay_unknown_only']) : 0;
|
$attr['relay_unknown_only'] = (isset($_data['relay_unknown_only'])) ? intval($_data['relay_unknown_only']) : 0;
|
||||||
|
$attr['archive'] = (isset($_data['archive']) && intval($_data['archive']) == 1) ? 1 : 0;
|
||||||
$attr['dkim_selector'] = (isset($_data['dkim_selector'])) ? $_data['dkim_selector'] : "dkim";
|
$attr['dkim_selector'] = (isset($_data['dkim_selector'])) ? $_data['dkim_selector'] : "dkim";
|
||||||
$attr['key_size'] = isset($_data['key_size']) ? intval($_data['key_size']) : 2048;
|
$attr['key_size'] = isset($_data['key_size']) ? intval($_data['key_size']) : 2048;
|
||||||
|
|
||||||
@@ -1600,6 +1606,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
$attr["rl_value"] = (!empty($_data['rl_value'])) ? $_data['rl_value'] : "";
|
$attr["rl_value"] = (!empty($_data['rl_value'])) ? $_data['rl_value'] : "";
|
||||||
$attr["force_pw_update"] = isset($_data['force_pw_update']) ? intval($_data['force_pw_update']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['force_pw_update']);
|
$attr["force_pw_update"] = isset($_data['force_pw_update']) ? intval($_data['force_pw_update']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['force_pw_update']);
|
||||||
$attr["force_tfa"] = isset($_data['force_tfa']) ? intval($_data['force_tfa']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['force_tfa']);
|
$attr["force_tfa"] = isset($_data['force_tfa']) ? intval($_data['force_tfa']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['force_tfa']);
|
||||||
|
$attr["archive"] = \Mailcow\Archive\Model\Mailbox::setting($_data['archive'] ?? $MAILBOX_DEFAULT_ATTRIBUTES['archive']);
|
||||||
$attr["sogo_access"] = isset($_data['sogo_access']) ? intval($_data['sogo_access']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['sogo_access']);
|
$attr["sogo_access"] = isset($_data['sogo_access']) ? intval($_data['sogo_access']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['sogo_access']);
|
||||||
$attr["active"] = isset($_data['active']) ? intval($_data['active']) : 1;
|
$attr["active"] = isset($_data['active']) ? intval($_data['active']) : 1;
|
||||||
$attr["tls_enforce_in"] = isset($_data['tls_enforce_in']) ? intval($_data['tls_enforce_in']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['tls_enforce_in']);
|
$attr["tls_enforce_in"] = isset($_data['tls_enforce_in']) ? intval($_data['tls_enforce_in']) : intval($MAILBOX_DEFAULT_ATTRIBUTES['tls_enforce_in']);
|
||||||
@@ -2488,6 +2495,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
$gal = (isset($_data['gal'])) ? intval($_data['gal']) : $is_now['gal'];
|
$gal = (isset($_data['gal'])) ? intval($_data['gal']) : $is_now['gal'];
|
||||||
$description = (!empty($_data['description']) && isset($_SESSION['acl']['domain_desc']) && $_SESSION['acl']['domain_desc'] == "1") ? $_data['description'] : $is_now['description'];
|
$description = (!empty($_data['description']) && isset($_SESSION['acl']['domain_desc']) && $_SESSION['acl']['domain_desc'] == "1") ? $_data['description'] : $is_now['description'];
|
||||||
(int)$relayhost = (isset($_data['relayhost']) && isset($_SESSION['acl']['domain_relayhost']) && $_SESSION['acl']['domain_relayhost'] == "1") ? intval($_data['relayhost']) : intval($is_now['relayhost']);
|
(int)$relayhost = (isset($_data['relayhost']) && isset($_SESSION['acl']['domain_relayhost']) && $_SESSION['acl']['domain_relayhost'] == "1") ? intval($_data['relayhost']) : intval($is_now['relayhost']);
|
||||||
|
$archive = (isset($_data['archive']) && hasACLAccess("archive")) ? ((intval($_data['archive']) == 1) ? 1 : 0) : intval($is_now['archive']);
|
||||||
$tags = (is_array($_data['tags']) ? $_data['tags'] : array());
|
$tags = (is_array($_data['tags']) ? $_data['tags'] : array());
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
@@ -2501,11 +2509,13 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
|
|
||||||
$stmt = $pdo->prepare("UPDATE `domain` SET
|
$stmt = $pdo->prepare("UPDATE `domain` SET
|
||||||
`description` = :description,
|
`description` = :description,
|
||||||
`gal` = :gal
|
`gal` = :gal,
|
||||||
|
`archive` = :archive
|
||||||
WHERE `domain` = :domain");
|
WHERE `domain` = :domain");
|
||||||
$stmt->execute(array(
|
$stmt->execute(array(
|
||||||
':description' => $description,
|
':description' => $description,
|
||||||
':gal' => $gal,
|
':gal' => $gal,
|
||||||
|
':archive' => $archive,
|
||||||
':domain' => $domain
|
':domain' => $domain
|
||||||
));
|
));
|
||||||
// save tags
|
// save tags
|
||||||
@@ -2538,6 +2548,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
$active = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active'];
|
$active = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active'];
|
||||||
$backupmx = (isset($_data['backupmx'])) ? intval($_data['backupmx']) : $is_now['backupmx'];
|
$backupmx = (isset($_data['backupmx'])) ? intval($_data['backupmx']) : $is_now['backupmx'];
|
||||||
$gal = (isset($_data['gal'])) ? intval($_data['gal']) : $is_now['gal'];
|
$gal = (isset($_data['gal'])) ? intval($_data['gal']) : $is_now['gal'];
|
||||||
|
$archive = (isset($_data['archive'])) ? ((intval($_data['archive']) == 1) ? 1 : 0) : intval($is_now['archive']);
|
||||||
$relay_all_recipients = (isset($_data['relay_all_recipients'])) ? intval($_data['relay_all_recipients']) : $is_now['relay_all_recipients'];
|
$relay_all_recipients = (isset($_data['relay_all_recipients'])) ? intval($_data['relay_all_recipients']) : $is_now['relay_all_recipients'];
|
||||||
$relay_unknown_only = (isset($_data['relay_unknown_only'])) ? intval($_data['relay_unknown_only']) : $is_now['relay_unknown_only'];
|
$relay_unknown_only = (isset($_data['relay_unknown_only'])) ? intval($_data['relay_unknown_only']) : $is_now['relay_unknown_only'];
|
||||||
$relayhost = (isset($_data['relayhost'])) ? intval($_data['relayhost']) : $is_now['relayhost'];
|
$relayhost = (isset($_data['relayhost'])) ? intval($_data['relayhost']) : $is_now['relayhost'];
|
||||||
@@ -2652,6 +2663,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
`relay_unknown_only` = :relay_unknown_only,
|
`relay_unknown_only` = :relay_unknown_only,
|
||||||
`backupmx` = :backupmx,
|
`backupmx` = :backupmx,
|
||||||
`gal` = :gal,
|
`gal` = :gal,
|
||||||
|
`archive` = :archive,
|
||||||
`active` = :active,
|
`active` = :active,
|
||||||
`quota` = :quota,
|
`quota` = :quota,
|
||||||
`defquota` = :defquota,
|
`defquota` = :defquota,
|
||||||
@@ -2666,6 +2678,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
':relay_unknown_only' => $relay_unknown_only,
|
':relay_unknown_only' => $relay_unknown_only,
|
||||||
':backupmx' => $backupmx,
|
':backupmx' => $backupmx,
|
||||||
':gal' => $gal,
|
':gal' => $gal,
|
||||||
|
':archive' => $archive,
|
||||||
':active' => $active,
|
':active' => $active,
|
||||||
':quota' => $quota,
|
':quota' => $quota,
|
||||||
':defquota' => $defquota,
|
':defquota' => $defquota,
|
||||||
@@ -2753,6 +2766,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
$attr['backupmx'] = (isset($_data['backupmx'])) ? intval($_data['backupmx']) : 0;
|
$attr['backupmx'] = (isset($_data['backupmx'])) ? intval($_data['backupmx']) : 0;
|
||||||
$attr['relay_all_recipients'] = (isset($_data['relay_all_recipients'])) ? intval($_data['relay_all_recipients']) : 0;
|
$attr['relay_all_recipients'] = (isset($_data['relay_all_recipients'])) ? intval($_data['relay_all_recipients']) : 0;
|
||||||
$attr['relay_unknown_only'] = (isset($_data['relay_unknown_only'])) ? intval($_data['relay_unknown_only']) : 0;
|
$attr['relay_unknown_only'] = (isset($_data['relay_unknown_only'])) ? intval($_data['relay_unknown_only']) : 0;
|
||||||
|
$attr['archive'] = (isset($_data['archive'])) ? ((intval($_data['archive']) == 1) ? 1 : 0) : intval($is_now['attributes']['archive'] ?? 0);
|
||||||
$attr['dkim_selector'] = (isset($_data['dkim_selector'])) ? $_data['dkim_selector'] : "dkim";
|
$attr['dkim_selector'] = (isset($_data['dkim_selector'])) ? $_data['dkim_selector'] : "dkim";
|
||||||
$attr['key_size'] = isset($_data['key_size']) ? intval($_data['key_size']) : 2048;
|
$attr['key_size'] = isset($_data['key_size']) ? intval($_data['key_size']) : 2048;
|
||||||
|
|
||||||
@@ -2814,6 +2828,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
(int)$eas_access = (isset($_data['eas_access']) && hasACLAccess("protocol_access")) ? intval($_data['eas_access']) : intval($is_now['attributes']['eas_access']);
|
(int)$eas_access = (isset($_data['eas_access']) && hasACLAccess("protocol_access")) ? intval($_data['eas_access']) : intval($is_now['attributes']['eas_access']);
|
||||||
(int)$dav_access = (isset($_data['dav_access']) && hasACLAccess("protocol_access")) ? intval($_data['dav_access']) : intval($is_now['attributes']['dav_access']);
|
(int)$dav_access = (isset($_data['dav_access']) && hasACLAccess("protocol_access")) ? intval($_data['dav_access']) : intval($is_now['attributes']['dav_access']);
|
||||||
(int)$relayhost = (isset($_data['relayhost']) && hasACLAccess("mailbox_relayhost")) ? intval($_data['relayhost']) : intval($is_now['attributes']['relayhost']);
|
(int)$relayhost = (isset($_data['relayhost']) && hasACLAccess("mailbox_relayhost")) ? intval($_data['relayhost']) : intval($is_now['attributes']['relayhost']);
|
||||||
|
$archive = \Mailcow\Archive\Model\Mailbox::setting((isset($_data['archive']) && hasACLAccess("archive")) ? $_data['archive'] : ($is_now['attributes']['archive'] ?? null));
|
||||||
(int)$shared = (isset($_data['shared'])) ? intval($_data['shared']) : intval($is_now['attributes']['shared'] ?? 0);
|
(int)$shared = (isset($_data['shared'])) ? intval($_data['shared']) : intval($is_now['attributes']['shared'] ?? 0);
|
||||||
(int)$quota_m = (isset_has_content($_data['quota'])) ? intval($_data['quota']) : ($is_now['quota'] / 1048576);
|
(int)$quota_m = (isset_has_content($_data['quota'])) ? intval($_data['quota']) : ($is_now['quota'] / 1048576);
|
||||||
$name = (!empty($_data['name'])) ? ltrim(rtrim($_data['name'], '>'), '<') : $is_now['name'];
|
$name = (!empty($_data['name'])) ? ltrim(rtrim($_data['name'], '>'), '<') : $is_now['name'];
|
||||||
@@ -3114,6 +3129,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
`attributes` = JSON_SET(`attributes`, '$.smtp_access', :smtp_access),
|
`attributes` = JSON_SET(`attributes`, '$.smtp_access', :smtp_access),
|
||||||
`attributes` = JSON_SET(`attributes`, '$.eas_access', :eas_access),
|
`attributes` = JSON_SET(`attributes`, '$.eas_access', :eas_access),
|
||||||
`attributes` = JSON_SET(`attributes`, '$.dav_access', :dav_access),
|
`attributes` = JSON_SET(`attributes`, '$.dav_access', :dav_access),
|
||||||
|
`attributes` = JSON_SET(`attributes`, '$.archive', :archive),
|
||||||
`attributes` = JSON_SET(`attributes`, '$.recovery_email', :recovery_email),
|
`attributes` = JSON_SET(`attributes`, '$.recovery_email', :recovery_email),
|
||||||
`attributes` = JSON_SET(`attributes`, '$.attribute_hash', :attribute_hash)
|
`attributes` = JSON_SET(`attributes`, '$.attribute_hash', :attribute_hash)
|
||||||
WHERE `username` = :username");
|
WHERE `username` = :username");
|
||||||
@@ -3131,6 +3147,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
':smtp_access' => $smtp_access,
|
':smtp_access' => $smtp_access,
|
||||||
':eas_access' => $eas_access,
|
':eas_access' => $eas_access,
|
||||||
':dav_access' => $dav_access,
|
':dav_access' => $dav_access,
|
||||||
|
':archive' => $archive,
|
||||||
':recovery_email' => $pw_recovery_email,
|
':recovery_email' => $pw_recovery_email,
|
||||||
':relayhost' => $relayhost,
|
':relayhost' => $relayhost,
|
||||||
':shared' => strval($shared),
|
':shared' => strval($shared),
|
||||||
@@ -3534,6 +3551,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
$attr["rl_value"] = (!empty($_data['rl_value'])) ? $_data['rl_value'] : $is_now['rl_value'];
|
$attr["rl_value"] = (!empty($_data['rl_value'])) ? $_data['rl_value'] : $is_now['rl_value'];
|
||||||
$attr["force_pw_update"] = isset($_data['force_pw_update']) ? intval($_data['force_pw_update']) : $is_now['force_pw_update'];
|
$attr["force_pw_update"] = isset($_data['force_pw_update']) ? intval($_data['force_pw_update']) : $is_now['force_pw_update'];
|
||||||
$attr["force_tfa"] = isset($_data['force_tfa']) ? intval($_data['force_tfa']) : $is_now['force_tfa'];
|
$attr["force_tfa"] = isset($_data['force_tfa']) ? intval($_data['force_tfa']) : $is_now['force_tfa'];
|
||||||
|
$attr["archive"] = \Mailcow\Archive\Model\Mailbox::setting($_data['archive'] ?? ($is_now['attributes']['archive'] ?? null));
|
||||||
$attr["sogo_access"] = isset($_data['sogo_access']) ? intval($_data['sogo_access']) : $is_now['sogo_access'];
|
$attr["sogo_access"] = isset($_data['sogo_access']) ? intval($_data['sogo_access']) : $is_now['sogo_access'];
|
||||||
$attr["active"] = isset($_data['active']) ? intval($_data['active']) : $is_now['active'];
|
$attr["active"] = isset($_data['active']) ? intval($_data['active']) : $is_now['active'];
|
||||||
$attr["tls_enforce_in"] = isset($_data['tls_enforce_in']) ? intval($_data['tls_enforce_in']) : $is_now['tls_enforce_in'];
|
$attr["tls_enforce_in"] = isset($_data['tls_enforce_in']) ? intval($_data['tls_enforce_in']) : $is_now['tls_enforce_in'];
|
||||||
@@ -4682,6 +4700,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
`relay_unknown_only`,
|
`relay_unknown_only`,
|
||||||
`backupmx`,
|
`backupmx`,
|
||||||
`gal`,
|
`gal`,
|
||||||
|
`archive`,
|
||||||
`active`
|
`active`
|
||||||
FROM `domain` WHERE `domain`= :domain");
|
FROM `domain` WHERE `domain`= :domain");
|
||||||
$stmt->execute(array(
|
$stmt->execute(array(
|
||||||
@@ -4742,6 +4761,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
$domaindata['backupmx_int'] = $row['backupmx'];
|
$domaindata['backupmx_int'] = $row['backupmx'];
|
||||||
$domaindata['gal'] = $row['gal'];
|
$domaindata['gal'] = $row['gal'];
|
||||||
$domaindata['gal_int'] = $row['gal'];
|
$domaindata['gal_int'] = $row['gal'];
|
||||||
|
$domaindata['archive'] = $row['archive'];
|
||||||
$domaindata['rl'] = $rl;
|
$domaindata['rl'] = $rl;
|
||||||
$domaindata['active'] = $row['active'];
|
$domaindata['active'] = $row['active'];
|
||||||
$domaindata['active_int'] = $row['active'];
|
$domaindata['active_int'] = $row['active'];
|
||||||
@@ -4829,6 +4849,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
if (preg_match('/y|yes/i', getenv('MASTER'))) {
|
if (preg_match('/y|yes/i', getenv('MASTER'))) {
|
||||||
$stmt = $pdo->prepare("SELECT
|
$stmt = $pdo->prepare("SELECT
|
||||||
`domain`.`backupmx`,
|
`domain`.`backupmx`,
|
||||||
|
`domain`.`active` AS `domain_active`,
|
||||||
|
`domain`.`archive` AS `domain_archive`,
|
||||||
`mailbox`.`username`,
|
`mailbox`.`username`,
|
||||||
`mailbox`.`name`,
|
`mailbox`.`name`,
|
||||||
`mailbox`.`active`,
|
`mailbox`.`active`,
|
||||||
@@ -4851,6 +4873,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
else {
|
else {
|
||||||
$stmt = $pdo->prepare("SELECT
|
$stmt = $pdo->prepare("SELECT
|
||||||
`domain`.`backupmx`,
|
`domain`.`backupmx`,
|
||||||
|
`domain`.`active` AS `domain_active`,
|
||||||
|
`domain`.`archive` AS `domain_archive`,
|
||||||
`mailbox`.`username`,
|
`mailbox`.`username`,
|
||||||
`mailbox`.`name`,
|
`mailbox`.`name`,
|
||||||
`mailbox`.`active`,
|
`mailbox`.`active`,
|
||||||
@@ -4890,6 +4914,18 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
|
|||||||
$mailboxdata['created'] = $row['created'];
|
$mailboxdata['created'] = $row['created'];
|
||||||
$mailboxdata['modified'] = $row['modified'];
|
$mailboxdata['modified'] = $row['modified'];
|
||||||
$mailboxdata['authsource'] = ($row['authsource']) ? $row['authsource'] : 'mailcow';
|
$mailboxdata['authsource'] = ($row['authsource']) ? $row['authsource'] : 'mailcow';
|
||||||
|
// Mails of the mailbox go to the archive: global switch, mailbox setting ("inherit" follows the domain), receives mail
|
||||||
|
static $archive_enabled = null;
|
||||||
|
if ($archive_enabled === null) {
|
||||||
|
try {
|
||||||
|
$archive_enabled = \Mailcow\Archive\ArchiveModule::fromGlobals()->settings->enabled();
|
||||||
|
}
|
||||||
|
catch (PDOException $e) {
|
||||||
|
$archive_enabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$mailboxdata['archive'] = ($archive_enabled && $row['active'] != 0 && $row['domain_active'] == 1 && $row['backupmx'] == 0
|
||||||
|
&& \Mailcow\Archive\Model\Mailbox::effective($mailboxdata['attributes']['archive'] ?? null, $row['domain_archive'])) ? 1 : 0;
|
||||||
|
|
||||||
if ($mailboxdata['percent_in_use'] === '- ') {
|
if ($mailboxdata['percent_in_use'] === '- ') {
|
||||||
$mailboxdata['percent_class'] = "info";
|
$mailboxdata['percent_class'] = "info";
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ function quarantine($_action, $_data = null) {
|
|||||||
$mail->addAttachment($msg_tmpf, $detail_row['qid'] . '.eml');
|
$mail->addAttachment($msg_tmpf, $detail_row['qid'] . '.eml');
|
||||||
$mail->Body = sprintf($lang['quarantine']['release_body']);
|
$mail->Body = sprintf($lang['quarantine']['release_body']);
|
||||||
$mail->send();
|
$mail->send();
|
||||||
|
$released_msg = $mail->getSentMIMEMessage();
|
||||||
unlink($msg_tmpf);
|
unlink($msg_tmpf);
|
||||||
}
|
}
|
||||||
catch (phpmailerException $e) {
|
catch (phpmailerException $e) {
|
||||||
@@ -170,6 +171,7 @@ function quarantine($_action, $_data = null) {
|
|||||||
}
|
}
|
||||||
elseif ($release_format == 'raw') {
|
elseif ($release_format == 'raw') {
|
||||||
$detail_row['msg'] = preg_replace('/^X-Spam-Flag: (.*)/m', 'X-Pre-Release-Spam-Flag: $1', $detail_row['msg']);
|
$detail_row['msg'] = preg_replace('/^X-Spam-Flag: (.*)/m', 'X-Pre-Release-Spam-Flag: $1', $detail_row['msg']);
|
||||||
|
$released_msg = $detail_row['msg'];
|
||||||
// dot-stuffing per RFC5321 4.5.2: escape leading dots
|
// dot-stuffing per RFC5321 4.5.2: escape leading dots
|
||||||
$detail_row['msg'] = preg_replace('~^\.~m', '..', $detail_row['msg']);
|
$detail_row['msg'] = preg_replace('~^\.~m', '..', $detail_row['msg']);
|
||||||
$postfix_talk = array(
|
$postfix_talk = array(
|
||||||
@@ -213,6 +215,10 @@ function quarantine($_action, $_data = null) {
|
|||||||
}
|
}
|
||||||
fclose($smtp_connection);
|
fclose($smtp_connection);
|
||||||
}
|
}
|
||||||
|
// Port 590 bypasses rspamd, so the archive gets released mails here
|
||||||
|
if (isset($released_msg)) {
|
||||||
|
\Mailcow\Archive\ArchiveModule::fromGlobals()->journal->submitDelivered($released_msg, $sender, array($detail_row['rcpt']), 'quarantine');
|
||||||
|
}
|
||||||
$stmt = $pdo->prepare("DELETE FROM `quarantine` WHERE id = :id");
|
$stmt = $pdo->prepare("DELETE FROM `quarantine` WHERE id = :id");
|
||||||
$stmt->execute(array(
|
$stmt->execute(array(
|
||||||
':id' => $row['id']
|
':id' => $row['id']
|
||||||
@@ -453,6 +459,7 @@ function quarantine($_action, $_data = null) {
|
|||||||
$mail->addAttachment($msg_tmpf, $row['qid'] . '.eml');
|
$mail->addAttachment($msg_tmpf, $row['qid'] . '.eml');
|
||||||
$mail->Body = sprintf($lang['quarantine']['release_body']);
|
$mail->Body = sprintf($lang['quarantine']['release_body']);
|
||||||
$mail->send();
|
$mail->send();
|
||||||
|
$released_msg = $mail->getSentMIMEMessage();
|
||||||
unlink($msg_tmpf);
|
unlink($msg_tmpf);
|
||||||
}
|
}
|
||||||
catch (phpmailerException $e) {
|
catch (phpmailerException $e) {
|
||||||
@@ -467,6 +474,7 @@ function quarantine($_action, $_data = null) {
|
|||||||
}
|
}
|
||||||
elseif ($release_format == 'raw') {
|
elseif ($release_format == 'raw') {
|
||||||
$row['msg'] = preg_replace('/^X-Spam-Flag: (.*)/m', 'X-Pre-Release-Spam-Flag: $1', $row['msg']);
|
$row['msg'] = preg_replace('/^X-Spam-Flag: (.*)/m', 'X-Pre-Release-Spam-Flag: $1', $row['msg']);
|
||||||
|
$released_msg = $row['msg'];
|
||||||
// dot-stuffing per RFC5321 4.5.2: escape leading dots
|
// dot-stuffing per RFC5321 4.5.2: escape leading dots
|
||||||
$row['msg'] = preg_replace('~^\.~m', '..', $row['msg']);
|
$row['msg'] = preg_replace('~^\.~m', '..', $row['msg']);
|
||||||
$postfix_talk = array(
|
$postfix_talk = array(
|
||||||
@@ -506,6 +514,11 @@ function quarantine($_action, $_data = null) {
|
|||||||
}
|
}
|
||||||
fclose($smtp_connection);
|
fclose($smtp_connection);
|
||||||
}
|
}
|
||||||
|
// Port 590 bypasses rspamd, so the archive gets released mails here
|
||||||
|
if (isset($released_msg)) {
|
||||||
|
\Mailcow\Archive\ArchiveModule::fromGlobals()->journal->submitDelivered($released_msg, $sender, array($row['rcpt']), 'quarantine');
|
||||||
|
unset($released_msg);
|
||||||
|
}
|
||||||
$stmt = $pdo->prepare("DELETE FROM `quarantine` WHERE `id` = :id");
|
$stmt = $pdo->prepare("DELETE FROM `quarantine` WHERE `id` = :id");
|
||||||
$stmt->execute(array(
|
$stmt->execute(array(
|
||||||
':id' => $id
|
':id' => $id
|
||||||
@@ -767,8 +780,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;
|
||||||
|
|||||||
@@ -242,6 +242,7 @@ function init_db_schema()
|
|||||||
"gal" => "TINYINT(1) NOT NULL DEFAULT '1'",
|
"gal" => "TINYINT(1) NOT NULL DEFAULT '1'",
|
||||||
"relay_all_recipients" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
"relay_all_recipients" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||||
"relay_unknown_only" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
"relay_unknown_only" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||||
|
"archive" => "TINYINT(1) NOT NULL DEFAULT '0'",
|
||||||
"created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)",
|
"created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)",
|
||||||
"modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP",
|
"modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP",
|
||||||
"active" => "TINYINT(1) NOT NULL DEFAULT '1'"
|
"active" => "TINYINT(1) NOT NULL DEFAULT '1'"
|
||||||
@@ -1053,7 +1054,8 @@ 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'",
|
||||||
|
"archive" => "TINYINT(1) NOT NULL DEFAULT '0'"
|
||||||
),
|
),
|
||||||
"keys" => array(
|
"keys" => array(
|
||||||
"primary" => array(
|
"primary" => array(
|
||||||
@@ -1098,6 +1100,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",
|
||||||
@@ -1276,6 +1279,42 @@ function init_db_schema()
|
|||||||
),
|
),
|
||||||
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||||
),
|
),
|
||||||
|
"archive_settings" => array(
|
||||||
|
"cols" => array(
|
||||||
|
"name" => "VARCHAR(64) NOT NULL",
|
||||||
|
"value" => "TEXT NOT NULL",
|
||||||
|
"created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)",
|
||||||
|
"modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP"
|
||||||
|
),
|
||||||
|
"keys" => array(
|
||||||
|
"primary" => array(
|
||||||
|
"" => array("name")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||||
|
),
|
||||||
|
// Archive target the Postfix maps read (FILTER of the copies on port 591, SASL, TLS policy), one row kept in line with archive_settings
|
||||||
|
"archive_transport" => array(
|
||||||
|
"cols" => array(
|
||||||
|
"id" => "INT NOT NULL AUTO_INCREMENT",
|
||||||
|
"address" => "VARCHAR(255) NOT NULL",
|
||||||
|
"nexthop" => "VARCHAR(255) NOT NULL",
|
||||||
|
"username" => "VARCHAR(255) NOT NULL DEFAULT ''",
|
||||||
|
"password" => "VARCHAR(255) NOT NULL DEFAULT ''",
|
||||||
|
"tls_policy" => "VARCHAR(64) NOT NULL DEFAULT 'encrypt'",
|
||||||
|
"active" => "TINYINT(1) NOT NULL DEFAULT '1'"
|
||||||
|
),
|
||||||
|
"keys" => array(
|
||||||
|
"primary" => array(
|
||||||
|
"" => array("id")
|
||||||
|
),
|
||||||
|
"key" => array(
|
||||||
|
"address" => array("address"),
|
||||||
|
"nexthop" => array("nexthop")
|
||||||
|
)
|
||||||
|
),
|
||||||
|
"attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC"
|
||||||
|
),
|
||||||
"bcc_maps" => array(
|
"bcc_maps" => array(
|
||||||
"cols" => array(
|
"cols" => array(
|
||||||
"id" => "INT NOT NULL AUTO_INCREMENT",
|
"id" => "INT NOT NULL AUTO_INCREMENT",
|
||||||
@@ -1679,9 +1718,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 +1786,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,
|
||||||
@@ -1998,6 +2047,7 @@ function init_db_schema()
|
|||||||
$pdo->query("UPDATE `mailbox` SET `attributes` = JSON_SET(`attributes`, '$.relayhost', \"0\") WHERE JSON_VALUE(`attributes`, '$.relayhost') IS NULL;");
|
$pdo->query("UPDATE `mailbox` SET `attributes` = JSON_SET(`attributes`, '$.relayhost', \"0\") WHERE JSON_VALUE(`attributes`, '$.relayhost') IS NULL;");
|
||||||
$pdo->query("UPDATE `mailbox` SET `attributes` = JSON_SET(`attributes`, '$.force_pw_update', \"0\") WHERE JSON_VALUE(`attributes`, '$.force_pw_update') IS NULL;");
|
$pdo->query("UPDATE `mailbox` SET `attributes` = JSON_SET(`attributes`, '$.force_pw_update', \"0\") WHERE JSON_VALUE(`attributes`, '$.force_pw_update') IS NULL;");
|
||||||
$pdo->query("UPDATE `mailbox` SET `attributes` = JSON_SET(`attributes`, '$.force_tfa', \"0\") WHERE JSON_VALUE(`attributes`, '$.force_tfa') IS NULL;");
|
$pdo->query("UPDATE `mailbox` SET `attributes` = JSON_SET(`attributes`, '$.force_tfa', \"0\") WHERE JSON_VALUE(`attributes`, '$.force_tfa') IS NULL;");
|
||||||
|
$pdo->query("UPDATE `mailbox` SET `attributes` = JSON_SET(`attributes`, '$.archive', \"inherit\") WHERE JSON_VALUE(`attributes`, '$.archive') IS NULL;");
|
||||||
// admin attributes
|
// admin attributes
|
||||||
$pdo->query("UPDATE `admin` SET `attributes` = '{}' WHERE `attributes` = '' OR `attributes` IS NULL;");
|
$pdo->query("UPDATE `admin` SET `attributes` = '{}' WHERE `attributes` = '' OR `attributes` IS NULL;");
|
||||||
$pdo->query("UPDATE `admin` SET `attributes` = JSON_SET(`attributes`, '$.force_tfa', \"0\") WHERE JSON_VALUE(`attributes`, '$.force_tfa') IS NULL;");
|
$pdo->query("UPDATE `admin` SET `attributes` = JSON_SET(`attributes`, '$.force_tfa', \"0\") WHERE JSON_VALUE(`attributes`, '$.force_tfa') IS NULL;");
|
||||||
@@ -2045,6 +2095,7 @@ function init_db_schema()
|
|||||||
"backupmx" => 0,
|
"backupmx" => 0,
|
||||||
"relay_all_recipients" => 0,
|
"relay_all_recipients" => 0,
|
||||||
"relay_unknown_only" => 0,
|
"relay_unknown_only" => 0,
|
||||||
|
"archive" => 0,
|
||||||
"dkim_selector" => "dkim",
|
"dkim_selector" => "dkim",
|
||||||
"key_size" => 2048,
|
"key_size" => 2048,
|
||||||
"max_quota_for_domain" => 10240 * 1048576,
|
"max_quota_for_domain" => 10240 * 1048576,
|
||||||
@@ -2062,6 +2113,7 @@ function init_db_schema()
|
|||||||
"rl_value" => "",
|
"rl_value" => "",
|
||||||
"force_pw_update" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['force_pw_update']),
|
"force_pw_update" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['force_pw_update']),
|
||||||
"force_tfa" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['force_tfa']),
|
"force_tfa" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['force_tfa']),
|
||||||
|
"archive" => strval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['archive']),
|
||||||
"sogo_access" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['sogo_access']),
|
"sogo_access" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['sogo_access']),
|
||||||
"active" => 1,
|
"active" => 1,
|
||||||
"tls_enforce_in" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['tls_enforce_in']),
|
"tls_enforce_in" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['tls_enforce_in']),
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\Archive;
|
||||||
|
|
||||||
|
use Mailcow\Archive\Controller\ArchiveApiController;
|
||||||
|
use Mailcow\Archive\Legacy\LegacyQueue;
|
||||||
|
use Mailcow\Archive\Redis\ArchiveState;
|
||||||
|
use Mailcow\Archive\Repository\LookupRepository;
|
||||||
|
use Mailcow\Archive\Repository\SettingsRepository;
|
||||||
|
use Mailcow\Archive\Service\HeaderBuilder;
|
||||||
|
use Mailcow\Archive\Service\JournalService;
|
||||||
|
use Mailcow\Archive\Service\MailboxListService;
|
||||||
|
use Mailcow\Archive\Service\OwnerResolver;
|
||||||
|
use Mailcow\Archive\Service\SettingsService;
|
||||||
|
use Mailcow\Archive\Service\StatusService;
|
||||||
|
use Mailcow\Archive\Smtp\SmtpClient;
|
||||||
|
use Mailcow\Core\ApiResponse;
|
||||||
|
use Mailcow\Core\Context;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composition root of the archive module (archive copies by SMTP through Postfix).
|
||||||
|
*
|
||||||
|
* process_get_return(ArchiveModule::fromGlobals()->api->getMailboxes());
|
||||||
|
* ArchiveModule::create($pdo, Context::system(), $redis)->journal->prepare($request); // rspamd dynmap
|
||||||
|
* ArchiveModule::fromGlobals()->journal->submitDelivered($message, $from, $rcpts, 'quarantine');
|
||||||
|
*/
|
||||||
|
class ArchiveModule
|
||||||
|
{
|
||||||
|
/** Request keys whose values never reach a log */
|
||||||
|
private const SECRET_KEYS = ['password', 'signing_secret'];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly SettingsService $settings,
|
||||||
|
public readonly JournalService $journal,
|
||||||
|
public readonly StatusService $status,
|
||||||
|
public readonly MailboxListService $mailboxes,
|
||||||
|
public readonly ArchiveApiController $api
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param ?object $redis Redis connection; without it nothing is read from or written to Redis */
|
||||||
|
public static function create(PDO $pdo, Context $context, ?object $redis = null): self
|
||||||
|
{
|
||||||
|
$settingsRepo = new SettingsRepository($pdo);
|
||||||
|
$lookup = new LookupRepository($pdo);
|
||||||
|
$state = new ArchiveState($redis);
|
||||||
|
$smtp = new SmtpClient();
|
||||||
|
$settings = new SettingsService($settingsRepo, $lookup, $state, $smtp, $context);
|
||||||
|
$journal = new JournalService($settingsRepo, new OwnerResolver($lookup), new HeaderBuilder(), $smtp, $state, $context);
|
||||||
|
$status = new StatusService($settingsRepo, $state, new LegacyQueue(), $context);
|
||||||
|
$mailboxes = new MailboxListService($lookup, $settingsRepo, $context);
|
||||||
|
$api = new ArchiveApiController($settings, $status, $journal, $mailboxes, new ApiResponse(self::SECRET_KEYS));
|
||||||
|
return new self($settings, $journal, $status, $mailboxes, $api);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Legacy entry point for procedural callers: reads $pdo, $redis and $_SESSION; $context overrides the session caller */
|
||||||
|
public static function fromGlobals(?Context $context = null): self
|
||||||
|
{
|
||||||
|
$redis = $GLOBALS['redis'] ?? null;
|
||||||
|
return self::create($GLOBALS['pdo'], $context ?? Context::fromSession($_SESSION ?? []), is_object($redis) ? $redis : null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\Archive\Controller;
|
||||||
|
|
||||||
|
use Mailcow\Archive\Service\JournalService;
|
||||||
|
use Mailcow\Archive\Service\MailboxListService;
|
||||||
|
use Mailcow\Archive\Service\SettingsService;
|
||||||
|
use Mailcow\Archive\Service\StatusService;
|
||||||
|
use Mailcow\Core\ApiResponse;
|
||||||
|
use Mailcow\Core\UserError;
|
||||||
|
use PDOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP API adapter for json_api.php (routes `archive/*`, `archive`).
|
||||||
|
*
|
||||||
|
* Write methods return what json_api passes to process_*_return() and write their messages via ApiResponse.
|
||||||
|
* Messages are logged as ['archive', action, object, data].
|
||||||
|
*/
|
||||||
|
class ArchiveApiController
|
||||||
|
{
|
||||||
|
public function __construct(private SettingsService $settings, private StatusService $status, private JournalService $journal,
|
||||||
|
private MailboxListService $mailboxes, private ApiResponse $response)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET archive/settings */
|
||||||
|
public function getSettings(): array
|
||||||
|
{
|
||||||
|
return $this->read(fn(): array => $this->settings->get()->toArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET archive/status */
|
||||||
|
public function getStatus(): array
|
||||||
|
{
|
||||||
|
return $this->read(fn(): array => $this->status->status());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET logs/archive[/count] */
|
||||||
|
public function getLog(int $count = 0): array
|
||||||
|
{
|
||||||
|
return $this->read(fn(): array => $this->status->log($count));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET archive/mailboxes */
|
||||||
|
public function getMailboxes(): array
|
||||||
|
{
|
||||||
|
return $this->read(fn(): array => $this->mailboxes->archived());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST edit/archive: the settings; `action: test` tests the connection, `action: testmail` sends a test copy */
|
||||||
|
public function edit(array $attr): bool
|
||||||
|
{
|
||||||
|
return match ($attr['action'] ?? null) {
|
||||||
|
'test' => $this->handle('edit', 'test', [], fn(array $log): bool => $this->testResult($this->settings->test(), $log)),
|
||||||
|
'testmail' => $this->handle('edit', 'testmail', [],
|
||||||
|
fn(array $log): bool => $this->response->success(['archive_testmail_queued', htmlspecialchars($this->journal->testMail())], $log)),
|
||||||
|
default => $this->handle('edit', 'settings', $attr, function (array $log) use ($attr): bool {
|
||||||
|
$this->settings->save($attr);
|
||||||
|
return $this->response->success('archive_settings_saved', $log);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A missing extension is a warning: the archive works, but may refuse some mails */
|
||||||
|
private function testResult(string|array $msg, array $log): bool
|
||||||
|
{
|
||||||
|
if (is_array($msg)) {
|
||||||
|
$this->response->warning($msg, $log);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return $this->response->success($msg, $log);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A GET answer, empty when the caller may not read it */
|
||||||
|
private function read(callable $work): array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $work();
|
||||||
|
} catch (UserError) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs one write call: $work gets the log entry and returns the result for process_*_return().
|
||||||
|
* A UserError becomes a danger message, a database error stops the call with one `mysql_error` message.
|
||||||
|
*/
|
||||||
|
private function handle(string $action, string $object, mixed $data, callable $work): bool
|
||||||
|
{
|
||||||
|
$log = ['archive', $action, $object, $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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\Archive\Legacy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bridge to the legacy mailq() (Postfix queue through dockerapi) until it is converted.
|
||||||
|
*/
|
||||||
|
class LegacyQueue
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array[] queue entries of $sender: queue_id, queue_name, arrival_time, message_size,
|
||||||
|
* recipients ("address (delay reason)")
|
||||||
|
*/
|
||||||
|
public function entries(string $sender): array
|
||||||
|
{
|
||||||
|
if (!function_exists('mailq')) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$list = json_decode((string)mailq('get'), true);
|
||||||
|
if (!is_array($list)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return array_values(array_filter($list, fn(mixed $entry): bool => is_array($entry) && ($entry['sender'] ?? '') === $sender));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\Archive\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the archive learns about one mail besides its content: owners, SMTP envelope, origin and content hash.
|
||||||
|
*/
|
||||||
|
class Journal
|
||||||
|
{
|
||||||
|
public const DIRECTIONS = ['in', 'out', 'internal'];
|
||||||
|
public const SOURCES = ['smtp', 'quarantine', 'massmail', 'test'];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $owners mailbox usernames the mail belongs to
|
||||||
|
* @param string[] $rcpts SMTP envelope recipients as received (incl. BCC)
|
||||||
|
* @param float $received time mailcow accepted the mail (unix time with milliseconds)
|
||||||
|
* @param string $sha256 hash of the content as sent to the archive (CRLF line ends)
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly array $owners,
|
||||||
|
public readonly array $rcpts,
|
||||||
|
public readonly string $from,
|
||||||
|
public readonly string $qid,
|
||||||
|
public readonly string $direction,
|
||||||
|
public readonly string $source,
|
||||||
|
public readonly float $received,
|
||||||
|
public readonly string $sha256
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\Archive\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A mailbox that receives mail (regular kind, active 1 or 2, in an active domain that is no backup MX)
|
||||||
|
* with its archive settings: the mailbox attribute `archive` ('1', '0' or 'inherit') and the domain default.
|
||||||
|
*/
|
||||||
|
class Mailbox
|
||||||
|
{
|
||||||
|
public const INHERIT = 'inherit';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $username,
|
||||||
|
public readonly string $domain,
|
||||||
|
public readonly string $archive,
|
||||||
|
public readonly bool $domainArchive,
|
||||||
|
public readonly string $name = '',
|
||||||
|
public readonly int $active = 1
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromRow(array $row): self
|
||||||
|
{
|
||||||
|
return new self(
|
||||||
|
strtolower((string)$row['username']),
|
||||||
|
strtolower((string)$row['domain']),
|
||||||
|
self::setting($row['archive'] ?? null),
|
||||||
|
(int)($row['domain_archive'] ?? 0) === 1,
|
||||||
|
(string)($row['name'] ?? ''),
|
||||||
|
(int)($row['active'] ?? 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mailbox setting as stored: '1', '0', anything else (also a missing attribute) is 'inherit' */
|
||||||
|
public static function setting(mixed $value): string
|
||||||
|
{
|
||||||
|
$value = is_scalar($value) ? (string)$value : '';
|
||||||
|
return in_array($value, ['0', '1'], true) ? $value : self::INHERIT;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Archiving applies by the mailbox setting, 'inherit' follows the domain (without the global switch) */
|
||||||
|
public static function effective(mixed $archive, mixed $domainArchive): bool
|
||||||
|
{
|
||||||
|
$archive = self::setting($archive);
|
||||||
|
return ($archive === self::INHERIT) ? (int)$domainArchive === 1 : $archive === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function archived(): bool
|
||||||
|
{
|
||||||
|
return self::effective($this->archive, $this->domainArchive);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why the mailbox is archived: its own setting or the domain default */
|
||||||
|
public function source(): string
|
||||||
|
{
|
||||||
|
return ($this->archive === '1') ? 'mailbox' : 'domain';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entry of GET archive/mailboxes */
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'username' => $this->username,
|
||||||
|
'domain' => $this->domain,
|
||||||
|
'name' => $this->name,
|
||||||
|
'active' => $this->active,
|
||||||
|
'source' => $this->source(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\Archive\Model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The archive target (table `archive_settings`): archive copies of accepted mails go by SMTP to `address`,
|
||||||
|
* through the Postfix queue (table `archive_transport`, service archive_smtp in master.cf).
|
||||||
|
*/
|
||||||
|
class Settings
|
||||||
|
{
|
||||||
|
public const TLS_MODES = ['encrypt', 'verify', 'may'];
|
||||||
|
/** Metadata headers of a copy: archivecow (X-Archive-*, signed), envelope (sender/recipients), none */
|
||||||
|
public const PROFILES = ['archivecow', 'envelope', 'none'];
|
||||||
|
/** Envelope sender of copies; Postfix drops every notice to it (archive_discard), nothing reaches a real sender */
|
||||||
|
public const SENDER = 'archive-bounce@localhost';
|
||||||
|
|
||||||
|
/** rspamd hands accepted mails to the archive */
|
||||||
|
public bool $enabled = false;
|
||||||
|
/** Address the archive receives copies at */
|
||||||
|
public string $address = '';
|
||||||
|
/** SMTP server of the archive; empty: MX of the address domain */
|
||||||
|
public string $host = '';
|
||||||
|
public int $port = 25;
|
||||||
|
public string $tls = 'encrypt';
|
||||||
|
/** SMTP AUTH at the archive, optional */
|
||||||
|
public string $username = '';
|
||||||
|
/** Never part of toArray() */
|
||||||
|
public string $password = '';
|
||||||
|
public string $profile = 'archivecow';
|
||||||
|
/** Key of X-Archive-Signature (profile archivecow), never part of toArray() */
|
||||||
|
public string $signingKey = '';
|
||||||
|
/** Header names of the envelope profile */
|
||||||
|
public string $headerFrom = 'X-Envelope-From';
|
||||||
|
public string $headerRcpt = 'X-Envelope-To';
|
||||||
|
|
||||||
|
/** Copies can be sent: an address, and a signing key for archivecow */
|
||||||
|
public function canDeliver(): bool
|
||||||
|
{
|
||||||
|
return $this->address !== '' && ($this->profile !== 'archivecow' || $this->signingKey !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Next hop for Postfix: [host]:port, else the domain of the address (MX lookup), with the port when not 25 */
|
||||||
|
public function nexthop(): string
|
||||||
|
{
|
||||||
|
if ($this->host !== '') {
|
||||||
|
$host = filter_var($this->host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? 'ipv6:' . $this->host : $this->host;
|
||||||
|
return '[' . $host . ']:' . $this->port;
|
||||||
|
}
|
||||||
|
$domain = substr($this->address, (int)strrpos($this->address, '@') + 1);
|
||||||
|
return ($this->port === 25) ? $domain : $domain . ':' . $this->port;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** API representation (GET archive/settings), without secrets */
|
||||||
|
public function toArray(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'enabled' => (int)$this->enabled,
|
||||||
|
'address' => $this->address,
|
||||||
|
'host' => $this->host,
|
||||||
|
'port' => $this->port,
|
||||||
|
'tls' => $this->tls,
|
||||||
|
'username' => $this->username,
|
||||||
|
'has_password' => (int)($this->password !== ''),
|
||||||
|
'profile' => $this->profile,
|
||||||
|
'has_signing_secret' => (int)($this->signingKey !== ''),
|
||||||
|
'header_from' => $this->headerFrom,
|
||||||
|
'header_rcpt' => $this->headerRcpt,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\Archive\Redis;
|
||||||
|
|
||||||
|
use Mailcow\Archive\Model\Settings;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redis side of the archive:
|
||||||
|
* - ARCHIVE_ENABLED: the switch rspamd reads before it asks for owners, mirrored from the settings.
|
||||||
|
* - ARCHIVE_DROPPED / ARCHIVE_DROPPED_LOG: copies that could not be handed to Postfix (rspamd.local.lua, release
|
||||||
|
* hooks), counted and the last 100 listed.
|
||||||
|
* - POSTFIX_MAILLOG: the Postfix log, read for the archive log: deliveries of the archive_smtp service and expired
|
||||||
|
* copies, with the Message-ID of the mail (a copy keeps the Message-ID of the original).
|
||||||
|
*
|
||||||
|
* The connection is a plain object, so the class loads without the redis extension; without a connection
|
||||||
|
* nothing is read or written.
|
||||||
|
*/
|
||||||
|
class ArchiveState
|
||||||
|
{
|
||||||
|
public const ENABLED = 'ARCHIVE_ENABLED';
|
||||||
|
public const DROPPED = 'ARCHIVE_DROPPED';
|
||||||
|
public const DROPPED_LOG = 'ARCHIVE_DROPPED_LOG';
|
||||||
|
private const MAILLOG = 'POSTFIX_MAILLOG';
|
||||||
|
|
||||||
|
public function __construct(private ?object $redis)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setEnabled(bool $enabled): void
|
||||||
|
{
|
||||||
|
$this->redis?->set(self::ENABLED, $enabled ? '1' : '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A mail was not archived: its copy could not be handed to Postfix */
|
||||||
|
public function dropped(string $qid, string $reason, string $messageId = ''): void
|
||||||
|
{
|
||||||
|
if ($this->redis === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->redis->incr(self::DROPPED);
|
||||||
|
$this->redis->lPush(self::DROPPED_LOG, json_encode(['time' => time(), 'qid' => $qid, 'reason' => $reason, 'message_id' => $messageId]));
|
||||||
|
$this->redis->lTrim(self::DROPPED_LOG, 0, 99);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{count: int, recent: array[]} count since ever; recent: time, qid, reason, message_id; newest first */
|
||||||
|
public function droppedStats(int $limit = 100): array
|
||||||
|
{
|
||||||
|
if ($this->redis === null) {
|
||||||
|
return ['count' => 0, 'recent' => []];
|
||||||
|
}
|
||||||
|
$recent = [];
|
||||||
|
foreach ((array)$this->redis->lRange(self::DROPPED_LOG, 0, $limit - 1) as $line) {
|
||||||
|
$entry = is_string($line) ? json_decode($line, true) : null;
|
||||||
|
if (is_array($entry)) {
|
||||||
|
$recent[] = ['time' => (int)($entry['time'] ?? 0), 'qid' => (string)($entry['qid'] ?? ''),
|
||||||
|
'reason' => (string)($entry['reason'] ?? ''), 'message_id' => (string)($entry['message_id'] ?? '')];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ['count' => (int)$this->redis->get(self::DROPPED), 'recent' => $recent];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archive log, newest first: deliveries of copies (status sent|deferred|softbounce|bounced, text = answer of the
|
||||||
|
* archive), expired copies (expired) and mails not archived (dropped, text = reason).
|
||||||
|
*
|
||||||
|
* @return array[] time, status, qid (of the copy, of the mail for dropped), message_id, text
|
||||||
|
*/
|
||||||
|
public function events(int $limit = 1000): array
|
||||||
|
{
|
||||||
|
if ($this->redis === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$events = [];
|
||||||
|
$messageIds = [];
|
||||||
|
foreach ((array)$this->redis->lRange(self::MAILLOG, 0, -1) as $line) {
|
||||||
|
$entry = is_string($line) ? json_decode($line, true) : null;
|
||||||
|
if (!is_array($entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$program = (string)($entry['program'] ?? '');
|
||||||
|
$message = (string)($entry['message'] ?? '');
|
||||||
|
$time = (int)($entry['time'] ?? 0);
|
||||||
|
if ($program === 'postfix/cleanup' && preg_match('/^([0-9A-Za-z]+): message-id=(\S*)/', $message, $m)) {
|
||||||
|
$messageIds[$m[1]] = trim($m[2], '<>');
|
||||||
|
}
|
||||||
|
// the archive_discard lines are the dropped notices, not deliveries
|
||||||
|
elseif ($program === 'postfix/archive/smtp' && preg_match('/^([0-9A-Za-z]+): to=<[^>]*>.*\bstatus=(\w+) \((.*)\)$/', $message, $m)) {
|
||||||
|
$events[] = ['time' => $time, 'status' => strtolower($m[2]), 'qid' => $m[1], 'message_id' => '', 'text' => $m[3]];
|
||||||
|
}
|
||||||
|
elseif ($program === 'postfix/qmgr' && preg_match('/^([0-9A-Za-z]+): from=<' . preg_quote(Settings::SENDER, '/') . '>, status=expired/', $message, $m)) {
|
||||||
|
$events[] = ['time' => $time, 'status' => 'expired', 'qid' => $m[1], 'message_id' => '', 'text' => ''];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
foreach ($events as &$event) {
|
||||||
|
$event['message_id'] = $messageIds[$event['qid']] ?? '';
|
||||||
|
}
|
||||||
|
unset($event);
|
||||||
|
foreach ($this->droppedStats()['recent'] as $dropped) {
|
||||||
|
$events[] = ['time' => $dropped['time'], 'status' => 'dropped', 'qid' => $dropped['qid'],
|
||||||
|
'message_id' => $dropped['message_id'], 'text' => $dropped['reason']];
|
||||||
|
}
|
||||||
|
usort($events, fn(array $a, array $b): int => $b['time'] <=> $a['time']);
|
||||||
|
return array_slice($events, 0, $limit);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\Archive\Repository;
|
||||||
|
|
||||||
|
use Mailcow\Archive\Model\Mailbox;
|
||||||
|
use Mailcow\Core\Repository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only lookups of the address tables, with the same conditions as the Postfix maps
|
||||||
|
* (data/Dockerfiles/postfix/postfix.sh), and the mailboxes with their archive settings.
|
||||||
|
*/
|
||||||
|
class LookupRepository extends Repository
|
||||||
|
{
|
||||||
|
/** Columns of a mailbox that receives mail; the domain must be active and no backup MX */
|
||||||
|
private const MAILBOX = "SELECT m.`username`, m.`domain`, m.`name`, m.`active`,
|
||||||
|
JSON_VALUE(m.`attributes`, '$.archive') AS `archive`, d.`archive` AS `domain_archive`
|
||||||
|
FROM `mailbox` m
|
||||||
|
JOIN `domain` d ON d.`domain` = m.`domain`
|
||||||
|
WHERE m.`kind` = '' AND (m.`active` = '1' OR m.`active` = '2') AND d.`active` = '1' AND d.`backupmx` = '0'";
|
||||||
|
|
||||||
|
/** recipient_canonical_maps: new destination of an address or '@domain' */
|
||||||
|
public function recipientMap(string $key): ?string
|
||||||
|
{
|
||||||
|
return $this->value("SELECT `new_dest` FROM `recipient_maps` WHERE `old_dest` = :key AND `active` = '1' LIMIT 1", $key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Targets of an alias row (aliases, catch-alls '@domain', groups, mailbox self rows) */
|
||||||
|
public function aliasGoto(string $key): ?string
|
||||||
|
{
|
||||||
|
return $this->value("SELECT `goto` FROM `alias` WHERE `address` = :key AND (`active` = '1' OR `active` = '2') LIMIT 1", $key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resources (location, thing, group) are discarded by Postfix */
|
||||||
|
public function isResource(string $username): bool
|
||||||
|
{
|
||||||
|
return $this->value("SELECT `username` FROM `mailbox` WHERE `username` = :key AND `kind` REGEXP 'location|thing|group' LIMIT 1", $username) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function spamAliasGoto(string $key): ?string
|
||||||
|
{
|
||||||
|
return $this->value("SELECT `goto` FROM `spamalias` WHERE `address` = :key
|
||||||
|
AND (`validity` >= UNIX_TIMESTAMP() OR `permanent` != 0) LIMIT 1", $key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mailbox local@target behind local@domain when domain is an alias domain */
|
||||||
|
public function aliasDomainMailbox(string $local, string $domain): ?string
|
||||||
|
{
|
||||||
|
$username = $this->query("SELECT `mailbox`.`username` FROM `mailbox`, `alias_domain`
|
||||||
|
WHERE `alias_domain`.`alias_domain` = :domain AND `mailbox`.`username` = CONCAT(:local, '@', `alias_domain`.`target_domain`)
|
||||||
|
AND (`mailbox`.`active` = '1' OR `mailbox`.`active` = '2') AND `alias_domain`.`active` = '1' LIMIT 1",
|
||||||
|
['domain' => $domain, 'local' => $local])->fetchColumn();
|
||||||
|
return is_string($username) ? $username : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** BCC destination of an address or '@domain' as rspamd's bcc.php finds it; $type 'rcpt' or 'sender' */
|
||||||
|
public function bccDestination(string $key, string $type): ?string
|
||||||
|
{
|
||||||
|
$dest = $this->query("SELECT `bcc_dest` FROM `bcc_maps` WHERE `type` = :type AND `local_dest` = :key AND `active` = '1' LIMIT 1",
|
||||||
|
['type' => $type, 'key' => $key])->fetchColumn();
|
||||||
|
return is_string($dest) ? $dest : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A domain or alias domain of this mailcow */
|
||||||
|
public function isMailcowDomain(string $domain): bool
|
||||||
|
{
|
||||||
|
return $this->query("SELECT `domain` FROM `domain` WHERE `domain` = :domain
|
||||||
|
UNION SELECT `alias_domain` FROM `alias_domain` WHERE `alias_domain` = :alias LIMIT 1",
|
||||||
|
['domain' => $domain, 'alias' => $domain])->fetchColumn() !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The mailbox when it receives mail */
|
||||||
|
public function mailbox(string $username): ?Mailbox
|
||||||
|
{
|
||||||
|
$row = $this->query(self::MAILBOX . " AND m.`username` = :username", ['username' => $username])->fetch();
|
||||||
|
return is_array($row) ? Mailbox::fromRow($row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Mailbox[] mailboxes archived by their setting or their domain (without the global switch) */
|
||||||
|
public function archivedMailboxes(): array
|
||||||
|
{
|
||||||
|
$rows = $this->query(self::MAILBOX . " AND (JSON_VALUE(m.`attributes`, '$.archive') = '1'
|
||||||
|
OR (COALESCE(JSON_VALUE(m.`attributes`, '$.archive'), '') NOT IN ('0', '1') AND d.`archive` = '1'))
|
||||||
|
ORDER BY m.`username`")->fetchAll();
|
||||||
|
return array_map(fn(array $row): Mailbox => Mailbox::fromRow($row), $rows);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function value(string $sql, string $key): ?string
|
||||||
|
{
|
||||||
|
$value = $this->query($sql, ['key' => $key])->fetchColumn();
|
||||||
|
return is_string($value) ? $value : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\Archive\Repository;
|
||||||
|
|
||||||
|
use Mailcow\Archive\Model\Settings;
|
||||||
|
use Mailcow\Core\Repository;
|
||||||
|
use PDO;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SQL for the archive target: the settings (table `archive_settings`, name => value) and the row the Postfix maps
|
||||||
|
* read (table `archive_transport`: transport, SASL, TLS policy of the archive_smtp service).
|
||||||
|
*/
|
||||||
|
class SettingsRepository extends Repository
|
||||||
|
{
|
||||||
|
/** Stored settings; keys that are not stored yet keep the defaults of the model */
|
||||||
|
public function load(): Settings
|
||||||
|
{
|
||||||
|
$rows = $this->query("SELECT `name`, `value` FROM `archive_settings`")->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||||
|
$settings = new Settings();
|
||||||
|
$settings->enabled = ($rows['enabled'] ?? '0') === '1';
|
||||||
|
$settings->address = (string)($rows['address'] ?? '');
|
||||||
|
$settings->host = (string)($rows['host'] ?? '');
|
||||||
|
$settings->port = (int)($rows['port'] ?? $settings->port);
|
||||||
|
$settings->tls = (string)($rows['tls'] ?? $settings->tls);
|
||||||
|
$settings->username = (string)($rows['username'] ?? '');
|
||||||
|
$settings->password = (string)($rows['password'] ?? '');
|
||||||
|
$settings->profile = (string)($rows['profile'] ?? $settings->profile);
|
||||||
|
$settings->signingKey = (string)($rows['signing_secret'] ?? '');
|
||||||
|
$settings->headerFrom = (string)($rows['header_from'] ?? $settings->headerFrom);
|
||||||
|
$settings->headerRcpt = (string)($rows['header_rcpt'] ?? $settings->headerRcpt);
|
||||||
|
return $settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the settings and the transport row. The row stays while an address is set, also with archiving off,
|
||||||
|
* so copies still queued in Postfix are delivered.
|
||||||
|
*/
|
||||||
|
public function save(Settings $settings): void
|
||||||
|
{
|
||||||
|
$values = [
|
||||||
|
'enabled' => $settings->enabled ? '1' : '0',
|
||||||
|
'address' => $settings->address,
|
||||||
|
'host' => $settings->host,
|
||||||
|
'port' => (string)$settings->port,
|
||||||
|
'tls' => $settings->tls,
|
||||||
|
'username' => $settings->username,
|
||||||
|
'password' => $settings->password,
|
||||||
|
'profile' => $settings->profile,
|
||||||
|
'signing_secret' => $settings->signingKey,
|
||||||
|
'header_from' => $settings->headerFrom,
|
||||||
|
'header_rcpt' => $settings->headerRcpt,
|
||||||
|
];
|
||||||
|
$sql = "INSERT INTO `archive_settings` (`name`, `value`) VALUES (:name, :value) ON DUPLICATE KEY UPDATE `value` = VALUES(`value`)";
|
||||||
|
$this->transaction(function () use ($sql, $values, $settings): void {
|
||||||
|
foreach ($values as $name => $value) {
|
||||||
|
$this->query($sql, ['name' => $name, 'value' => $value]);
|
||||||
|
}
|
||||||
|
$this->query("DELETE FROM `archive_transport`");
|
||||||
|
if ($settings->address !== '') {
|
||||||
|
$this->insertRow('archive_transport', [
|
||||||
|
'address' => $settings->address,
|
||||||
|
'nexthop' => $settings->nexthop(),
|
||||||
|
'username' => $settings->username,
|
||||||
|
'password' => $settings->password,
|
||||||
|
'tls_policy' => $settings->tls,
|
||||||
|
'active' => 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\Archive\Service;
|
||||||
|
|
||||||
|
use DateTime;
|
||||||
|
use Mailcow\Archive\Model\Journal;
|
||||||
|
use Mailcow\Archive\Model\Settings;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata header block in front of an archive copy, by profile:
|
||||||
|
*
|
||||||
|
* - archivecow: X-Archive-Owner, -Rcpt, -From, -Queue-ID, -Received, -Direction, -Source, -SHA256 and as the last
|
||||||
|
* line X-Archive-Signature: v=1; a=hmac-sha256; s=<hex>. The signature is HMAC-SHA256 with the signing key over
|
||||||
|
* the lines above it, each as "<lower case name>:<value without any whitespace>", joined by "\n".
|
||||||
|
* The block ends with the signature line, everything after it is the mail as accepted (CRLF line ends).
|
||||||
|
* - envelope: SMTP sender and recipients under configurable names (default X-Envelope-From / X-Envelope-To).
|
||||||
|
* - none: no headers.
|
||||||
|
*
|
||||||
|
* Empty values are left out. Lists are folded after commas (CRLF + space).
|
||||||
|
*/
|
||||||
|
class HeaderBuilder
|
||||||
|
{
|
||||||
|
/** Fold a list before a line would get longer */
|
||||||
|
private const FOLD_AT = 78;
|
||||||
|
|
||||||
|
public function build(Settings $settings, Journal $journal): string
|
||||||
|
{
|
||||||
|
return match ($settings->profile) {
|
||||||
|
'archivecow' => $this->archivecow($settings, $journal),
|
||||||
|
'envelope' => $this->envelope($settings, $journal),
|
||||||
|
default => '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One line of the signed text */
|
||||||
|
public static function canonical(string $name, string $value): string
|
||||||
|
{
|
||||||
|
return strtolower($name) . ':' . preg_replace('/\s+/', '', $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function archivecow(Settings $settings, Journal $journal): string
|
||||||
|
{
|
||||||
|
$values = [
|
||||||
|
'X-Archive-Owner' => $journal->owners,
|
||||||
|
'X-Archive-Rcpt' => $journal->rcpts,
|
||||||
|
'X-Archive-From' => $journal->from,
|
||||||
|
'X-Archive-Queue-ID' => $journal->qid,
|
||||||
|
'X-Archive-Received' => self::time($journal->received),
|
||||||
|
'X-Archive-Direction' => $journal->direction,
|
||||||
|
'X-Archive-Source' => $journal->source,
|
||||||
|
'X-Archive-SHA256' => $journal->sha256,
|
||||||
|
];
|
||||||
|
$lines = [];
|
||||||
|
$signed = [];
|
||||||
|
foreach ($values as $name => $value) {
|
||||||
|
$value = is_array($value) ? self::cleanList($value) : self::clean($value);
|
||||||
|
if ($value === '' || $value === []) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$lines[] = self::line($name, $value);
|
||||||
|
$signed[] = self::canonical($name, is_array($value) ? implode(',', $value) : $value);
|
||||||
|
}
|
||||||
|
$lines[] = 'X-Archive-Signature: v=1; a=hmac-sha256; s=' . hash_hmac('sha256', implode("\n", $signed), $settings->signingKey);
|
||||||
|
return implode("\r\n", $lines) . "\r\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
private function envelope(Settings $settings, Journal $journal): string
|
||||||
|
{
|
||||||
|
$from = self::clean($journal->from);
|
||||||
|
$lines = [self::line($settings->headerFrom, ($from === '') ? '<>' : $from)];
|
||||||
|
$rcpts = self::cleanList($journal->rcpts);
|
||||||
|
if ($rcpts !== []) {
|
||||||
|
$lines[] = self::line($settings->headerRcpt, $rcpts);
|
||||||
|
}
|
||||||
|
return implode("\r\n", $lines) . "\r\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Header line; a list is folded after commas */
|
||||||
|
private static function line(string $name, string|array $value): string
|
||||||
|
{
|
||||||
|
if (is_string($value)) {
|
||||||
|
return $name . ': ' . $value;
|
||||||
|
}
|
||||||
|
$out = $name . ': ' . array_shift($value);
|
||||||
|
$length = strlen($out);
|
||||||
|
foreach ($value as $item) {
|
||||||
|
if ($length + strlen($item) + 1 > self::FOLD_AT) {
|
||||||
|
$out .= ",\r\n " . $item;
|
||||||
|
$length = strlen($item) + 1;
|
||||||
|
} else {
|
||||||
|
$out .= ',' . $item;
|
||||||
|
$length += strlen($item) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Header value without control characters (no header injection) */
|
||||||
|
private static function clean(string $value): string
|
||||||
|
{
|
||||||
|
return trim((string)preg_replace('/[\x00-\x1f\x7f]+/', ' ', $value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return string[] */
|
||||||
|
private static function cleanList(array $values): array
|
||||||
|
{
|
||||||
|
$list = [];
|
||||||
|
foreach ($values as $value) {
|
||||||
|
$value = is_string($value) ? str_replace(',', '', self::clean($value)) : '';
|
||||||
|
if ($value !== '') {
|
||||||
|
$list[] = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RFC 3339 in UTC with milliseconds */
|
||||||
|
private static function time(float $time): string
|
||||||
|
{
|
||||||
|
$date = DateTime::createFromFormat('U.u', sprintf('%.3F', $time));
|
||||||
|
return $date ? $date->format('Y-m-d\TH:i:s.v\Z') : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\Archive\Service;
|
||||||
|
|
||||||
|
use Mailcow\Archive\Model\Journal;
|
||||||
|
use Mailcow\Archive\Model\Settings;
|
||||||
|
use Mailcow\Archive\Redis\ArchiveState;
|
||||||
|
use Mailcow\Archive\Repository\SettingsRepository;
|
||||||
|
use Mailcow\Archive\Smtp\SmtpClient;
|
||||||
|
use Mailcow\Archive\Smtp\SmtpException;
|
||||||
|
use Mailcow\Core\Context;
|
||||||
|
use Mailcow\Core\UserError;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archive copies: a copy of every accepted mail of an archived mailbox goes by SMTP to the archive, through the
|
||||||
|
* Postfix queue. rspamd sends the copies of mails it scanned (rspamd.local.lua) and asks prepare() whether to send
|
||||||
|
* one and for its header block; mails that bypass rspamd (quarantine release, mass mail) are sent here.
|
||||||
|
*
|
||||||
|
* Copies have the envelope sender Settings::SENDER, Postfix drops every notice to it: an archive copy never causes
|
||||||
|
* a bounce or delay warning to a real sender.
|
||||||
|
*/
|
||||||
|
class JournalService
|
||||||
|
{
|
||||||
|
/** Postfix service that takes copies (no milter, mynetworks only) */
|
||||||
|
private const SUBMIT_PORT = 591;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private SettingsRepository $settings,
|
||||||
|
private OwnerResolver $resolver,
|
||||||
|
private HeaderBuilder $headers,
|
||||||
|
private SmtpClient $smtp,
|
||||||
|
private ArchiveState $state,
|
||||||
|
private Context $context,
|
||||||
|
private string $postfixHost = 'postfix'
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Archive decision for a mail rspamd accepted (dynmap archive.php), no copy while archiving is off or no
|
||||||
|
* archived mailbox is involved.
|
||||||
|
*
|
||||||
|
* @param array $request from, rcpt[], user, bcc, qid, received, sha256 (of the content rspamd sends)
|
||||||
|
* @return array{archive: bool, rcpt?: string, sender?: string, headers?: string}
|
||||||
|
*/
|
||||||
|
public function prepare(array $request): array
|
||||||
|
{
|
||||||
|
$settings = $this->settings->load();
|
||||||
|
$this->state->setEnabled($settings->enabled);
|
||||||
|
if (!$settings->enabled || !$settings->canDeliver()) {
|
||||||
|
return ['archive' => false];
|
||||||
|
}
|
||||||
|
$from = self::text($request['from'] ?? '');
|
||||||
|
$rcpts = is_array($request['rcpt'] ?? null) ? array_values(array_filter($request['rcpt'], 'is_string')) : [];
|
||||||
|
$resolved = $this->resolver->resolve($from, $rcpts, self::text($request['user'] ?? ''), ($request['bcc'] ?? false) === true);
|
||||||
|
if ($resolved['owners'] === []) {
|
||||||
|
return ['archive' => false];
|
||||||
|
}
|
||||||
|
$sha256 = self::text($request['sha256'] ?? '');
|
||||||
|
$journal = new Journal($resolved['owners'], $rcpts, $from, mb_strcut(self::text($request['qid'] ?? ''), 0, 64),
|
||||||
|
$resolved['direction'], 'smtp', is_numeric($request['received'] ?? null) ? (float)$request['received'] : microtime(true),
|
||||||
|
preg_match('/^[0-9a-f]{64}$/', $sha256) ? $sha256 : '');
|
||||||
|
return ['archive' => true, 'rcpt' => $settings->address, 'sender' => Settings::SENDER, 'headers' => $this->headers->build($settings, $journal)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends the copy of a mail that reached its mailbox without rspamd (quarantine release, mass mail) for its
|
||||||
|
* archived recipients. Errors are logged and counted, never thrown: the mail is delivered already.
|
||||||
|
*
|
||||||
|
* @param string[] $rcpts
|
||||||
|
*/
|
||||||
|
public function submitDelivered(string $message, string $from, array $rcpts, string $source): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$settings = $this->settings->load();
|
||||||
|
if (!$settings->enabled || !$settings->canDeliver()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$resolved = $this->resolver->resolve($from, $rcpts, '', false);
|
||||||
|
if ($resolved['owners'] === []) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$this->send($settings, $message, $resolved['owners'], array_values($rcpts), $from, 'in', $source);
|
||||||
|
return true;
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
error_log('ARCHIVE: cannot send the archive copy (' . $source . '): ' . $e->getMessage());
|
||||||
|
$this->state->dropped('', $source . ': ' . $e->getMessage(), self::messageId($message));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a test copy through Postfix to the archive and returns the Postfix queue id.
|
||||||
|
*
|
||||||
|
* @throws UserError
|
||||||
|
*/
|
||||||
|
public function testMail(): string
|
||||||
|
{
|
||||||
|
$this->context->requireAdmin();
|
||||||
|
$settings = $this->settings->load();
|
||||||
|
if (!$settings->canDeliver()) {
|
||||||
|
throw new UserError('archive_incomplete');
|
||||||
|
}
|
||||||
|
$message = implode("\r\n", [
|
||||||
|
'From: mailcow <' . Settings::SENDER . '>',
|
||||||
|
'To: <' . $settings->address . '>',
|
||||||
|
'Subject: mailcow archive test',
|
||||||
|
'Date: ' . date(DATE_RFC2822),
|
||||||
|
'Message-ID: <' . bin2hex(random_bytes(12)) . '@archive-test.mailcow>',
|
||||||
|
'MIME-Version: 1.0',
|
||||||
|
'Content-Type: text/plain; charset=utf-8',
|
||||||
|
'',
|
||||||
|
'Test copy from mailcow (System - Configuration - Archive).',
|
||||||
|
'',
|
||||||
|
]);
|
||||||
|
try {
|
||||||
|
return $this->send($settings, $message, [], [$settings->address], Settings::SENDER, 'in', 'test');
|
||||||
|
} catch (SmtpException $e) {
|
||||||
|
throw new UserError('archive_testmail_failed', [htmlspecialchars($e->describe())]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws SmtpException */
|
||||||
|
private function send(Settings $settings, string $message, array $owners, array $rcpts, string $from, string $direction, string $source): string
|
||||||
|
{
|
||||||
|
$message = SmtpClient::crlf($message);
|
||||||
|
$journal = new Journal($owners, $rcpts, $from, '', $direction, $source, microtime(true), hash('sha256', $message));
|
||||||
|
return $this->smtp->submit($this->postfixHost, self::SUBMIT_PORT, Settings::SENDER, $settings->address,
|
||||||
|
$this->headers->build($settings, $journal) . $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Message-ID of a mail, for the archive log */
|
||||||
|
private static function messageId(string $message): string
|
||||||
|
{
|
||||||
|
$headers = explode("\n\n", str_replace("\r\n", "\n", $message), 2)[0];
|
||||||
|
return preg_match('/^Message-ID:\s*<?([^>\s]+)>?/mi', $headers, $m) ? $m[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function text(mixed $value): string
|
||||||
|
{
|
||||||
|
return is_scalar($value) ? trim((string)preg_replace('/[\x00-\x1f\x7f]+/', ' ', (string)$value)) : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\Archive\Service;
|
||||||
|
|
||||||
|
use Mailcow\Archive\Model\Mailbox;
|
||||||
|
use Mailcow\Archive\Repository\LookupRepository;
|
||||||
|
use Mailcow\Archive\Repository\SettingsRepository;
|
||||||
|
use Mailcow\Core\AccessDenied;
|
||||||
|
use Mailcow\Core\Context;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mailboxes archived now (GET archive/mailboxes), the list archivecow keeps its mailboxes in line with.
|
||||||
|
* Same rule as the owners of a mail: the global switch, then the mailbox setting, 'inherit' follows the domain.
|
||||||
|
*/
|
||||||
|
class MailboxListService
|
||||||
|
{
|
||||||
|
public function __construct(private LookupRepository $lookup, private SettingsRepository $settings, private Context $context)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admins, read-only API keys included.
|
||||||
|
*
|
||||||
|
* @return array{enabled: int, mailboxes: array[]}
|
||||||
|
* @throws AccessDenied
|
||||||
|
*/
|
||||||
|
public function archived(): array
|
||||||
|
{
|
||||||
|
$this->context->requireAdmin(false);
|
||||||
|
$enabled = $this->settings->load()->enabled;
|
||||||
|
return [
|
||||||
|
'enabled' => (int)$enabled,
|
||||||
|
'mailboxes' => $enabled ? array_map(fn(Mailbox $mailbox): array => $mailbox->toArray(), $this->lookup->archivedMailboxes()) : [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\Archive\Service;
|
||||||
|
|
||||||
|
use Mailcow\Archive\Model\Mailbox;
|
||||||
|
use Mailcow\Archive\Repository\LookupRepository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the mailboxes a mail belongs to in the archive, following the way Postfix delivers it.
|
||||||
|
*
|
||||||
|
* Incoming: each envelope recipient goes through recipient_maps (one rewrite) and the virtual alias expansion:
|
||||||
|
* alias rows (incl. catch-alls and groups), resources (discarded), spam aliases and mailboxes of alias domains,
|
||||||
|
* each tried as u+ext@d, u@d and @d, recursively. The local mailboxes at the end receive the mail. BCC map
|
||||||
|
* destinations count as recipients when rspamd sends the BCC copy (it bypasses rspamd).
|
||||||
|
*
|
||||||
|
* Outgoing: the SASL user and the mailbox the user sent as (its address, with +tag or in an alias domain);
|
||||||
|
* Postfix already checked that the login may use that sender. Without a login the sender never counts, so a
|
||||||
|
* forged local sender cannot make a mailbox an owner.
|
||||||
|
*
|
||||||
|
* Only mailboxes with archiving become owners.
|
||||||
|
*/
|
||||||
|
class OwnerResolver
|
||||||
|
{
|
||||||
|
/** virtual_alias_recursion_limit and virtual_alias_expansion_limit of Postfix */
|
||||||
|
private const MAX_DEPTH = 1000;
|
||||||
|
private const MAX_ADDRESSES = 1000;
|
||||||
|
|
||||||
|
public function __construct(private LookupRepository $lookup)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string[] $rcpts SMTP envelope recipients
|
||||||
|
* @param bool $bcc rspamd sent the BCC copies of this mail
|
||||||
|
* @return array{owners: string[], direction: string} sorted usernames; direction in, out or internal
|
||||||
|
*/
|
||||||
|
public function resolve(string $from, array $rcpts, string $user, bool $bcc): array
|
||||||
|
{
|
||||||
|
$from = self::address($from);
|
||||||
|
$incoming = [];
|
||||||
|
foreach ($rcpts as $rcpt) {
|
||||||
|
$rcpt = self::address($rcpt);
|
||||||
|
if ($rcpt !== '') {
|
||||||
|
$this->deliver($rcpt, $incoming);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($bcc) {
|
||||||
|
foreach ($this->bccDestinations($from, $rcpts) as $destination) {
|
||||||
|
$this->deliver($destination, $incoming);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$outgoing = $this->senders($from, self::address($user));
|
||||||
|
$owners = [];
|
||||||
|
foreach (array_merge(array_values($incoming), array_values($outgoing)) as $mailbox) {
|
||||||
|
if ($mailbox->archived()) {
|
||||||
|
$owners[$mailbox->username] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$owners = array_keys($owners);
|
||||||
|
sort($owners);
|
||||||
|
$direction = empty($outgoing) ? 'in' : (empty($incoming) ? 'out' : 'internal');
|
||||||
|
return ['owners' => $owners, 'direction' => $direction];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds the local mailboxes a recipient ends in to $mailboxes (username => Mailbox) */
|
||||||
|
private function deliver(string $address, array &$mailboxes): void
|
||||||
|
{
|
||||||
|
$seen = [];
|
||||||
|
$this->expand($this->canonical($address), 0, $mailboxes, $seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** recipient_maps: first matching key form, one rewrite */
|
||||||
|
private function canonical(string $address): string
|
||||||
|
{
|
||||||
|
foreach (self::keys($address) as $key) {
|
||||||
|
$target = self::target($this->lookup->recipientMap($key));
|
||||||
|
if ($target !== '') {
|
||||||
|
return self::rewrite($address, $key, $target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $address;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function expand(string $address, int $depth, array &$mailboxes, array &$seen): void
|
||||||
|
{
|
||||||
|
if ($depth > self::MAX_DEPTH || count($seen) >= self::MAX_ADDRESSES || isset($seen[$address])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$seen[$address] = true;
|
||||||
|
$targets = $this->virtual($address);
|
||||||
|
if ($targets === null) {
|
||||||
|
$this->final($address, $mailboxes);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach ($targets as $target) {
|
||||||
|
// an address that expands to itself is delivered as it is
|
||||||
|
if ($target === $address) {
|
||||||
|
$this->final($address, $mailboxes);
|
||||||
|
} else {
|
||||||
|
$this->expand($target, $depth + 1, $mailboxes, $seen);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Targets from the first key form a virtual alias map knows, null if none does */
|
||||||
|
private function virtual(string $address): ?array
|
||||||
|
{
|
||||||
|
if (self::split($address)[1] === 'localhost') {
|
||||||
|
// null@, spam@, ham@localhost: local delivery of Postfix, never a mailbox
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
foreach (self::keys($address) as $key) {
|
||||||
|
$targets = $this->virtualLookup($key);
|
||||||
|
if ($targets !== null) {
|
||||||
|
return array_map(fn(string $target): string => self::rewrite($address, $key, $target), $targets);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** virtual_alias_maps in the order of main.cf: alias, resource, spam alias, alias domain mailbox */
|
||||||
|
private function virtualLookup(string $key): ?array
|
||||||
|
{
|
||||||
|
$domainKey = str_starts_with($key, '@');
|
||||||
|
$targets = self::targets($this->lookup->aliasGoto($key));
|
||||||
|
if ($targets === null && !$domainKey && $this->lookup->isResource($key)) {
|
||||||
|
$targets = ['null@localhost'];
|
||||||
|
}
|
||||||
|
$targets ??= self::targets($this->lookup->spamAliasGoto($key));
|
||||||
|
if ($targets === null && !$domainKey) {
|
||||||
|
[$local, $domain] = self::split($key);
|
||||||
|
$targets = self::targets($this->lookup->aliasDomainMailbox($local, $domain));
|
||||||
|
}
|
||||||
|
return $targets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A final address: a mailbox (as u+ext@d or u@d) or anything else, which is not ours */
|
||||||
|
private function final(string $address, array &$mailboxes): void
|
||||||
|
{
|
||||||
|
foreach (array_unique([$address, self::untagged($address)]) as $candidate) {
|
||||||
|
$mailbox = $this->lookup->mailbox($candidate);
|
||||||
|
if ($mailbox !== null) {
|
||||||
|
$mailboxes[$mailbox->username] = $mailbox;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** BCC destinations of rspamd: rcpt maps for every recipient and its domain, sender maps for MAIL FROM and its domain */
|
||||||
|
private function bccDestinations(string $from, array $rcpts): array
|
||||||
|
{
|
||||||
|
$destinations = [];
|
||||||
|
foreach ($rcpts as $rcpt) {
|
||||||
|
$rcpt = self::address($rcpt);
|
||||||
|
if ($rcpt !== '') {
|
||||||
|
$destinations[] = $this->lookup->bccDestination(self::untagged($rcpt), 'rcpt');
|
||||||
|
$destinations[] = $this->lookup->bccDestination('@' . self::split($rcpt)[1], 'rcpt');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($from !== '') {
|
||||||
|
$destinations[] = $this->lookup->bccDestination(self::untagged($from), 'sender');
|
||||||
|
$destinations[] = $this->lookup->bccDestination('@' . self::split($from)[1], 'sender');
|
||||||
|
}
|
||||||
|
return array_values(array_unique(array_filter(array_map(fn(?string $address): string => self::address($address), $destinations))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mailboxes on the sending side: the SASL user and the mailbox the user sent as */
|
||||||
|
private function senders(string $from, string $user): array
|
||||||
|
{
|
||||||
|
$mailbox = ($user !== '') ? $this->lookup->mailbox($user) : null;
|
||||||
|
if ($mailbox === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
$senders = [$mailbox->username => $mailbox];
|
||||||
|
$sentAs = ($from !== '') ? $this->sentAs(self::untagged($from)) : null;
|
||||||
|
if ($sentAs !== null) {
|
||||||
|
$senders[$sentAs->username] = $sentAs;
|
||||||
|
}
|
||||||
|
return $senders;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mailbox a sender address belongs to: the mailbox itself or the mailbox behind an alias domain address, not aliases */
|
||||||
|
private function sentAs(string $address): ?Mailbox
|
||||||
|
{
|
||||||
|
$mailbox = $this->lookup->mailbox($address);
|
||||||
|
if ($mailbox === null) {
|
||||||
|
[$local, $domain] = self::split($address);
|
||||||
|
$username = $this->lookup->aliasDomainMailbox($local, $domain);
|
||||||
|
$mailbox = ($username !== null) ? $this->lookup->mailbox(strtolower($username)) : null;
|
||||||
|
}
|
||||||
|
return $mailbox;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lookup keys in the order of Postfix: u+ext@d, u@d, @d */
|
||||||
|
private static function keys(string $address): array
|
||||||
|
{
|
||||||
|
$keys = [$address, self::untagged($address), '@' . self::split($address)[1]];
|
||||||
|
return array_values(array_unique($keys));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a lookup for $address under $key: '@domain' keeps the local part, a match without the extension
|
||||||
|
* passes it on (propagate_unmatched_extensions).
|
||||||
|
*/
|
||||||
|
private static function rewrite(string $address, string $key, string $target): string
|
||||||
|
{
|
||||||
|
[$local] = self::split($address);
|
||||||
|
if (str_starts_with($target, '@')) {
|
||||||
|
return $local . $target;
|
||||||
|
}
|
||||||
|
$extension = self::extension($local);
|
||||||
|
if ($key === $address || $extension === '') {
|
||||||
|
return $target;
|
||||||
|
}
|
||||||
|
[$targetLocal, $targetDomain] = self::split($target);
|
||||||
|
return ($targetDomain === 'localhost' || self::extension($targetLocal) !== '')
|
||||||
|
? $target
|
||||||
|
: $targetLocal . '+' . $extension . '@' . $targetDomain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Comma separated lookup result; null when empty (not found) */
|
||||||
|
private static function targets(?string $value): ?array
|
||||||
|
{
|
||||||
|
$targets = array_values(array_filter(array_map(fn(string $target): string => self::target($target), explode(',', (string)$value))));
|
||||||
|
return empty($targets) ? null : $targets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Address or '@domain' from a lookup result, lower case; '' if invalid */
|
||||||
|
private static function target(?string $value): string
|
||||||
|
{
|
||||||
|
$value = strtolower(trim((string)$value));
|
||||||
|
return preg_match('/^[^@\s,<>]*@[^@\s,<>]+$/', $value) ? $value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lower case address without angle brackets; '' unless local@domain */
|
||||||
|
private static function address(mixed $value): string
|
||||||
|
{
|
||||||
|
$value = is_string($value) ? strtolower(trim($value, " \t\r\n<>")) : '';
|
||||||
|
return preg_match('/^[^@\s,<>]+@[^@\s,<>]+$/', $value) ? $value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function untagged(string $address): string
|
||||||
|
{
|
||||||
|
[$local, $domain] = self::split($address);
|
||||||
|
$pos = strpos($local, '+');
|
||||||
|
return ($pos === false || $pos === 0) ? $address : substr($local, 0, $pos) . '@' . $domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Address extension after the recipient delimiter "+" */
|
||||||
|
private static function extension(string $local): string
|
||||||
|
{
|
||||||
|
$pos = strpos($local, '+');
|
||||||
|
return ($pos === false || $pos === 0) ? '' : substr($local, $pos + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** [local part, domain] of an address or '@domain' */
|
||||||
|
private static function split(string $address): array
|
||||||
|
{
|
||||||
|
$at = strrpos($address, '@');
|
||||||
|
return ($at === false) ? [$address, ''] : [substr($address, 0, $at), substr($address, $at + 1)];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\Archive\Service;
|
||||||
|
|
||||||
|
use Mailcow\Archive\Model\Settings;
|
||||||
|
use Mailcow\Archive\Redis\ArchiveState;
|
||||||
|
use Mailcow\Archive\Repository\LookupRepository;
|
||||||
|
use Mailcow\Archive\Repository\SettingsRepository;
|
||||||
|
use Mailcow\Archive\Smtp\SmtpClient;
|
||||||
|
use Mailcow\Archive\Smtp\SmtpException;
|
||||||
|
use Mailcow\Core\AccessDenied;
|
||||||
|
use Mailcow\Core\Context;
|
||||||
|
use Mailcow\Core\Input;
|
||||||
|
use Mailcow\Core\UserError;
|
||||||
|
use PDOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin use cases of the archive target. Read-only API keys may read the settings (never password or key).
|
||||||
|
*/
|
||||||
|
class SettingsService
|
||||||
|
{
|
||||||
|
private const MAX_SECRET_LENGTH = 255;
|
||||||
|
/** Extensions the archive should announce: Postfix passes 8-bit mails unchanged (archive_smtp in master.cf) */
|
||||||
|
private const WANTED_EXTENSIONS = ['8BITMIME'];
|
||||||
|
|
||||||
|
public function __construct(private SettingsRepository $repo, private LookupRepository $lookup, private ArchiveState $state,
|
||||||
|
private SmtpClient $smtp, private Context $context)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws AccessDenied */
|
||||||
|
public function get(): Settings
|
||||||
|
{
|
||||||
|
$this->context->requireAdmin(false);
|
||||||
|
return $this->repo->load();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Global switch for internal callers (page templates, mailbox details), no permission check; off while the table is missing */
|
||||||
|
public function enabled(): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
return $this->repo->load()->enabled;
|
||||||
|
} catch (PDOException) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the settings: attributes missing in $input keep their value, an empty password or signing key keeps
|
||||||
|
* the stored one, `password_remove` deletes the password. Switching on needs an address (and a signing key for archivecow).
|
||||||
|
*
|
||||||
|
* @throws UserError
|
||||||
|
*/
|
||||||
|
public function save(array $input): Settings
|
||||||
|
{
|
||||||
|
$this->context->requireAdmin();
|
||||||
|
$settings = $this->repo->load();
|
||||||
|
if (array_key_exists('address', $input)) {
|
||||||
|
$address = strtolower((string)Input::text($input['address']));
|
||||||
|
if (!is_scalar($input['address']) || ($address !== '' && !filter_var($address, FILTER_VALIDATE_EMAIL))) {
|
||||||
|
throw new UserError('archive_address_invalid');
|
||||||
|
}
|
||||||
|
// Postfix would treat an own address as local (aliases, unknown users) before the copy reaches the archive
|
||||||
|
if ($address !== '' && $this->lookup->isMailcowDomain(substr($address, strrpos($address, '@') + 1))) {
|
||||||
|
throw new UserError('archive_address_local');
|
||||||
|
}
|
||||||
|
$settings->address = $address;
|
||||||
|
}
|
||||||
|
if (array_key_exists('host', $input)) {
|
||||||
|
$host = strtolower(trim((string)Input::text($input['host']), " []"));
|
||||||
|
if (!is_scalar($input['host']) || ($host !== '' && !filter_var($host, FILTER_VALIDATE_IP)
|
||||||
|
&& !filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME))) {
|
||||||
|
throw new UserError('archive_host_invalid');
|
||||||
|
}
|
||||||
|
$settings->host = $host;
|
||||||
|
}
|
||||||
|
if (array_key_exists('port', $input)) {
|
||||||
|
$settings->port = Input::intInRange($input['port'], 1, 65535) ?? throw new UserError('archive_port_invalid');
|
||||||
|
}
|
||||||
|
if (array_key_exists('tls', $input)) {
|
||||||
|
$settings->tls = in_array($input['tls'], Settings::TLS_MODES, true) ? $input['tls'] : throw new UserError('archive_tls_invalid');
|
||||||
|
}
|
||||||
|
if (array_key_exists('username', $input)) {
|
||||||
|
$username = Input::text($input['username']);
|
||||||
|
if ($username === null || strlen($username) > self::MAX_SECRET_LENGTH || preg_match('/[\s:]/', $username)) {
|
||||||
|
throw new UserError('archive_username_invalid');
|
||||||
|
}
|
||||||
|
$settings->username = $username;
|
||||||
|
}
|
||||||
|
if (array_key_exists('password', $input)) {
|
||||||
|
$settings->password = self::secret($input['password'], $settings->password, 'archive_password_invalid');
|
||||||
|
}
|
||||||
|
if (self::flag($input['password_remove'] ?? '0')) {
|
||||||
|
$settings->password = '';
|
||||||
|
}
|
||||||
|
if (array_key_exists('profile', $input)) {
|
||||||
|
$settings->profile = in_array($input['profile'], Settings::PROFILES, true) ? $input['profile'] : throw new UserError('archive_profile_invalid');
|
||||||
|
}
|
||||||
|
if (array_key_exists('signing_secret', $input)) {
|
||||||
|
$settings->signingKey = self::secret($input['signing_secret'], $settings->signingKey, 'archive_signing_secret_invalid');
|
||||||
|
}
|
||||||
|
foreach (['header_from' => 'headerFrom', 'header_rcpt' => 'headerRcpt'] as $key => $property) {
|
||||||
|
if (array_key_exists($key, $input)) {
|
||||||
|
$name = Input::text($input[$key]);
|
||||||
|
if ($name === null || !preg_match('/^[A-Za-z][A-Za-z0-9-]{0,63}$/', $name)) {
|
||||||
|
throw new UserError('archive_header_invalid');
|
||||||
|
}
|
||||||
|
$settings->$property = $name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (array_key_exists('enabled', $input)) {
|
||||||
|
$settings->enabled = self::flag($input['enabled']);
|
||||||
|
}
|
||||||
|
if ($settings->enabled && !$settings->canDeliver()) {
|
||||||
|
throw new UserError('archive_incomplete');
|
||||||
|
}
|
||||||
|
$this->repo->save($settings);
|
||||||
|
$this->state->setEnabled($settings->enabled);
|
||||||
|
return $settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests the stored target: connection, TLS, AUTH and whether the archive accepts the address (no mail is sent).
|
||||||
|
* Returns the success message, with the missing extension when the archive lacks 8BITMIME.
|
||||||
|
*
|
||||||
|
* @throws UserError
|
||||||
|
*/
|
||||||
|
public function test(): string|array
|
||||||
|
{
|
||||||
|
$this->context->requireAdmin();
|
||||||
|
$settings = $this->repo->load();
|
||||||
|
if ($settings->address === '') {
|
||||||
|
throw new UserError('archive_incomplete');
|
||||||
|
}
|
||||||
|
$host = ($settings->host !== '') ? $settings->host : $this->smtp->mailHost(substr($settings->address, strrpos($settings->address, '@') + 1));
|
||||||
|
try {
|
||||||
|
$extensions = $this->smtp->probe($host, $settings->port, $settings->tls, $settings->username, $settings->password,
|
||||||
|
Settings::SENDER, $settings->address);
|
||||||
|
} catch (SmtpException $e) {
|
||||||
|
throw new UserError('archive_test_failed', [htmlspecialchars($host . ':' . $settings->port . ' ' . $e->describe())]);
|
||||||
|
}
|
||||||
|
$missing = array_values(array_diff(self::WANTED_EXTENSIONS, $extensions));
|
||||||
|
return empty($missing) ? 'archive_test_ok' : ['archive_test_ok_missing', implode(', ', $missing)];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A new secret, or the stored one when $value is empty */
|
||||||
|
private static function secret(mixed $value, string $current, string $error): string
|
||||||
|
{
|
||||||
|
$secret = Input::text($value);
|
||||||
|
if ($secret === null || strlen($secret) > self::MAX_SECRET_LENGTH || preg_match('/[^\x21-\x7e]/', $secret)) {
|
||||||
|
throw new UserError($error);
|
||||||
|
}
|
||||||
|
return ($secret === '') ? $current : $secret;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\Archive\Service;
|
||||||
|
|
||||||
|
use Mailcow\Archive\Legacy\LegacyQueue;
|
||||||
|
use Mailcow\Archive\Model\Settings;
|
||||||
|
use Mailcow\Archive\Redis\ArchiveState;
|
||||||
|
use Mailcow\Archive\Repository\SettingsRepository;
|
||||||
|
use Mailcow\Core\AccessDenied;
|
||||||
|
use Mailcow\Core\Context;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State of the archive for the admin: the summary of the archive tab (copies waiting in the Postfix queue with the
|
||||||
|
* reason Postfix gives, last delivery, mails not archived) and the archive log of the dashboard.
|
||||||
|
*/
|
||||||
|
class StatusService
|
||||||
|
{
|
||||||
|
/** Mails not archived count as a problem for a day */
|
||||||
|
private const DROPPED_WINDOW = 86400;
|
||||||
|
|
||||||
|
public function __construct(private SettingsRepository $settings, private ArchiveState $state, private LegacyQueue $queue,
|
||||||
|
private Context $context)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET archive/status (admins, read-only keys included).
|
||||||
|
*
|
||||||
|
* @throws AccessDenied
|
||||||
|
*/
|
||||||
|
public function status(): array
|
||||||
|
{
|
||||||
|
$this->context->requireAdmin(false);
|
||||||
|
$settings = $this->settings->load();
|
||||||
|
$copies = $this->queue->entries(Settings::SENDER);
|
||||||
|
$reasons = [];
|
||||||
|
$oldest = null;
|
||||||
|
$bytes = 0;
|
||||||
|
foreach ($copies as $copy) {
|
||||||
|
$arrival = (int)($copy['arrival_time'] ?? 0);
|
||||||
|
$oldest = ($oldest === null) ? $arrival : min($oldest, $arrival);
|
||||||
|
$bytes += (int)($copy['message_size'] ?? 0);
|
||||||
|
foreach ((array)($copy['recipients'] ?? []) as $rcpt) {
|
||||||
|
if (is_string($rcpt) && preg_match('/^\S+ \((.+)\)$/', $rcpt, $m)) {
|
||||||
|
$reasons[$m[1]] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$dropped = $this->state->droppedStats();
|
||||||
|
$recent = array_values(array_filter($dropped['recent'], fn(array $d): bool => $d['time'] >= time() - self::DROPPED_WINDOW));
|
||||||
|
$lastDelivery = null;
|
||||||
|
foreach ($this->state->events() as $event) {
|
||||||
|
if ($event['status'] === 'sent') {
|
||||||
|
$lastDelivery = $event['time'];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
'enabled' => (int)$settings->enabled,
|
||||||
|
'configured' => (int)$settings->canDeliver(),
|
||||||
|
'queued' => count($copies),
|
||||||
|
'queued_bytes' => $bytes,
|
||||||
|
'oldest_queued' => $oldest,
|
||||||
|
'delay_reasons' => array_slice(array_keys($reasons), 0, 5),
|
||||||
|
'last_delivery' => $lastDelivery,
|
||||||
|
'dropped' => $dropped['count'],
|
||||||
|
'dropped_24h' => count($recent),
|
||||||
|
'last_dropped' => $recent ? ['time' => $recent[0]['time'], 'reason' => $recent[0]['reason']] : null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET logs/archive: deliveries, expired copies and mails not archived, newest first (admins, read-only keys included);
|
||||||
|
* $count limits the entries, 0 keeps the default.
|
||||||
|
*
|
||||||
|
* @throws AccessDenied
|
||||||
|
*/
|
||||||
|
public function log(int $count = 0): array
|
||||||
|
{
|
||||||
|
$this->context->requireAdmin(false);
|
||||||
|
return ($count > 0) ? $this->state->events($count) : $this->state->events();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\Archive\Smtp;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small SMTP client: hands archive copies to Postfix (port 591: no milter, mynetworks only) and tests the
|
||||||
|
* connection to the archive server (EHLO, STARTTLS, AUTH, MAIL/RCPT, no mail is sent).
|
||||||
|
*
|
||||||
|
* The socket I/O sits in protected methods, so tests can replace it.
|
||||||
|
*/
|
||||||
|
class SmtpClient
|
||||||
|
{
|
||||||
|
private const TIMEOUT = 20;
|
||||||
|
|
||||||
|
/** @var resource|null */
|
||||||
|
private $socket = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends one mail and returns the queue id Postfix reports. $data must end with CRLF; it is received as it is.
|
||||||
|
*
|
||||||
|
* @throws SmtpException
|
||||||
|
*/
|
||||||
|
public function submit(string $host, int $port, string $from, string $rcpt, string $data): string
|
||||||
|
{
|
||||||
|
$this->open($host, $port);
|
||||||
|
try {
|
||||||
|
$this->expect('connect', 220);
|
||||||
|
$this->ehlo();
|
||||||
|
$this->command('sender', 'MAIL FROM:<' . $from . '>', 250);
|
||||||
|
$this->command('recipient', 'RCPT TO:<' . $rcpt . '>', 250);
|
||||||
|
$this->command('data', 'DATA', 354);
|
||||||
|
$this->write(self::dotStuff(self::crlf($data)) . ".\r\n");
|
||||||
|
$reply = $this->expect('data', 250);
|
||||||
|
$this->write("QUIT\r\n");
|
||||||
|
return preg_match('/queued as ([0-9A-Za-z]+)/', $reply, $m) ? $m[1] : '';
|
||||||
|
} finally {
|
||||||
|
$this->close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connection test against the archive server: STARTTLS (unless $tls is 'may' and it is not offered), AUTH when
|
||||||
|
* a username is set, then MAIL and RCPT for the archive address, then RSET.
|
||||||
|
*
|
||||||
|
* @return string[] extensions the server announced (after STARTTLS)
|
||||||
|
* @throws SmtpException
|
||||||
|
*/
|
||||||
|
public function probe(string $host, int $port, string $tls, string $username, string $password, string $from, string $rcpt): array
|
||||||
|
{
|
||||||
|
$this->open($host, $port);
|
||||||
|
try {
|
||||||
|
$this->expect('connect', 220);
|
||||||
|
$extensions = $this->ehlo();
|
||||||
|
if ($tls !== 'may' || in_array('STARTTLS', $extensions, true)) {
|
||||||
|
if (!in_array('STARTTLS', $extensions, true)) {
|
||||||
|
throw new SmtpException('tls', 'STARTTLS not offered');
|
||||||
|
}
|
||||||
|
$this->command('tls', 'STARTTLS', 220);
|
||||||
|
$this->startTls($host, $tls === 'verify');
|
||||||
|
$extensions = $this->ehlo();
|
||||||
|
}
|
||||||
|
if ($username !== '') {
|
||||||
|
$this->command('auth', 'AUTH PLAIN ' . base64_encode("\0" . $username . "\0" . $password), 235);
|
||||||
|
}
|
||||||
|
$this->command('sender', 'MAIL FROM:<' . $from . '>', 250);
|
||||||
|
$this->command('recipient', 'RCPT TO:<' . $rcpt . '>', 250);
|
||||||
|
$this->command('recipient', 'RSET', 250);
|
||||||
|
$this->write("QUIT\r\n");
|
||||||
|
return $extensions;
|
||||||
|
} finally {
|
||||||
|
$this->close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mail server of a domain: the MX with the lowest preference, else the domain itself */
|
||||||
|
public function mailHost(string $domain): string
|
||||||
|
{
|
||||||
|
if (@getmxrr($domain, $hosts, $weights) && !empty($hosts)) {
|
||||||
|
array_multisort($weights, SORT_NUMERIC, $hosts);
|
||||||
|
return rtrim($hosts[0], '.');
|
||||||
|
}
|
||||||
|
return $domain;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CRLF line ends, ending with CRLF */
|
||||||
|
public static function crlf(string $data): string
|
||||||
|
{
|
||||||
|
$data = (string)preg_replace('/\r\n|\r|\n/', "\r\n", $data);
|
||||||
|
return str_ends_with($data, "\r\n") ? $data : $data . "\r\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RFC 5321 4.5.2: a line starting with "." gets another "." */
|
||||||
|
public static function dotStuff(string $data): string
|
||||||
|
{
|
||||||
|
$data = str_replace("\r\n.", "\r\n..", $data);
|
||||||
|
return str_starts_with($data, '.') ? '.' . $data : $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws SmtpException */
|
||||||
|
protected function open(string $host, int $port): void
|
||||||
|
{
|
||||||
|
$target = filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? '[' . $host . ']' : $host;
|
||||||
|
$socket = @stream_socket_client('tcp://' . $target . ':' . $port, $errno, $error, self::TIMEOUT);
|
||||||
|
if ($socket === false) {
|
||||||
|
throw new SmtpException('connect', $error !== '' ? $error : 'connection failed');
|
||||||
|
}
|
||||||
|
stream_set_timeout($socket, self::TIMEOUT);
|
||||||
|
$this->socket = $socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function close(): void
|
||||||
|
{
|
||||||
|
if (is_resource($this->socket)) {
|
||||||
|
fclose($this->socket);
|
||||||
|
}
|
||||||
|
$this->socket = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws SmtpException */
|
||||||
|
protected function write(string $data): void
|
||||||
|
{
|
||||||
|
for ($written = 0; $written < strlen($data); $written += $count) {
|
||||||
|
$count = @fwrite($this->socket, substr($data, $written, 1048576));
|
||||||
|
if ($count === false || $count === 0) {
|
||||||
|
throw new SmtpException('data', 'connection lost while sending');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One reply line without CRLF, null when the connection ended */
|
||||||
|
protected function readLine(): ?string
|
||||||
|
{
|
||||||
|
$line = fgets($this->socket, 4096);
|
||||||
|
return ($line === false) ? null : rtrim($line, "\r\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws SmtpException */
|
||||||
|
protected function startTls(string $host, bool $verify): void
|
||||||
|
{
|
||||||
|
stream_context_set_option($this->socket, 'ssl', 'verify_peer', $verify);
|
||||||
|
stream_context_set_option($this->socket, 'ssl', 'verify_peer_name', $verify);
|
||||||
|
stream_context_set_option($this->socket, 'ssl', 'allow_self_signed', !$verify);
|
||||||
|
stream_context_set_option($this->socket, 'ssl', 'peer_name', $host);
|
||||||
|
if (!@stream_socket_enable_crypto($this->socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) {
|
||||||
|
throw new SmtpException('tls', $verify ? 'TLS handshake failed or certificate not valid' : 'TLS handshake failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return string[] extensions in upper case */
|
||||||
|
private function ehlo(): array
|
||||||
|
{
|
||||||
|
$reply = $this->command('connect', 'EHLO ' . (gethostname() ?: 'mailcow'), 250);
|
||||||
|
$extensions = [];
|
||||||
|
foreach (array_slice(explode("\n", $reply), 1) as $line) {
|
||||||
|
$extensions[] = strtoupper((string)strtok(substr($line, 4), ' '));
|
||||||
|
}
|
||||||
|
return $extensions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws SmtpException */
|
||||||
|
private function command(string $step, string $command, int $code): string
|
||||||
|
{
|
||||||
|
$this->write($command . "\r\n");
|
||||||
|
return $this->expect($step, $code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a (multi-line) reply and returns it, lines joined by "\n".
|
||||||
|
*
|
||||||
|
* @throws SmtpException when the code differs
|
||||||
|
*/
|
||||||
|
private function expect(string $step, int $code): string
|
||||||
|
{
|
||||||
|
$lines = [];
|
||||||
|
do {
|
||||||
|
$line = $this->readLine();
|
||||||
|
if ($line === null) {
|
||||||
|
throw new SmtpException($step, 'connection closed');
|
||||||
|
}
|
||||||
|
$lines[] = $line;
|
||||||
|
} while (strlen($line) > 3 && $line[3] === '-');
|
||||||
|
$reply = implode("\n", $lines);
|
||||||
|
if ((int)substr($reply, 0, 3) !== $code && !($code === 250 && (int)substr($reply, 0, 3) === 251)) {
|
||||||
|
throw new SmtpException($step, $reply);
|
||||||
|
}
|
||||||
|
return $reply;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\Archive\Smtp;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An SMTP step failed: connect, tls, auth, sender, recipient or data, with the reply of the server.
|
||||||
|
*/
|
||||||
|
class SmtpException extends RuntimeException
|
||||||
|
{
|
||||||
|
private const MAX_TEXT_LENGTH = 200;
|
||||||
|
|
||||||
|
public function __construct(public readonly string $step, public readonly string $reply)
|
||||||
|
{
|
||||||
|
parent::__construct($step . ': ' . $reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short text for messages: "<step>: <reply>", without angle brackets (the UI escapes message arguments twice) */
|
||||||
|
public function describe(): string
|
||||||
|
{
|
||||||
|
$text = trim((string)preg_replace('/\s+/', ' ', $this->step . ': ' . str_replace(['<', '>'], '', $this->reply)));
|
||||||
|
return mb_substr($text, 0, self::MAX_TEXT_LENGTH);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
@@ -199,6 +205,9 @@ $MAILBOX_DEFAULT_ATTRIBUTES['force_pw_update'] = false;
|
|||||||
// Force 2FA enrollment at next login
|
// Force 2FA enrollment at next login
|
||||||
$MAILBOX_DEFAULT_ATTRIBUTES['force_tfa'] = false;
|
$MAILBOX_DEFAULT_ATTRIBUTES['force_tfa'] = false;
|
||||||
|
|
||||||
|
// Archive mails of the mailbox (archivecow): "inherit" follows the domain, "1" always, "0" never
|
||||||
|
$MAILBOX_DEFAULT_ATTRIBUTES['archive'] = 'inherit';
|
||||||
|
|
||||||
// Enable SOGo access - Users will be redirected to SOGo after login (set to false to disable redirect by default)
|
// Enable SOGo access - Users will be redirected to SOGo after login (set to false to disable redirect by default)
|
||||||
$MAILBOX_DEFAULT_ATTRIBUTES['sogo_access'] = true;
|
$MAILBOX_DEFAULT_ATTRIBUTES['sogo_access'] = true;
|
||||||
|
|
||||||
@@ -291,13 +300,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 +382,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,
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
// Archive tab of the admin system page: header profile fields and, while archiving is enabled, the state of the archive
|
||||||
|
// under the switch (one line per problem; the details are in the archive log of the dashboard and in the queue manager).
|
||||||
|
var mailcowArchive = (function($) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
function L(key) {
|
||||||
|
return (lang[key] !== undefined) ? lang[key] : key;
|
||||||
|
}
|
||||||
|
function esc(s) {
|
||||||
|
return escapeHtml(s === null || s === undefined ? '' : String(s));
|
||||||
|
}
|
||||||
|
function time(ts) {
|
||||||
|
return ts ? new Date(ts * 1000).toLocaleString(LOCALE, DATETIME_FORMAT) : '-';
|
||||||
|
}
|
||||||
|
// lang string with %s placeholders, values escaped
|
||||||
|
function text(key, values) {
|
||||||
|
var i = 0;
|
||||||
|
return esc(L(key)).replace(/%s/g, function() { return esc(values[i++]); });
|
||||||
|
}
|
||||||
|
function notice(cls, html) {
|
||||||
|
return '<div class="alert alert-' + cls + ' py-2 mb-2">' + html + '</div>';
|
||||||
|
}
|
||||||
|
var loaded = 0;
|
||||||
|
|
||||||
|
function renderStatus(s) {
|
||||||
|
var html = '';
|
||||||
|
if (s.enabled && !s.configured) {
|
||||||
|
html += notice('warning', esc(L('archive_state_unconfigured')));
|
||||||
|
}
|
||||||
|
if (s.dropped_24h) {
|
||||||
|
html += notice('danger', text('archive_state_dropped', [s.dropped_24h, time(s.last_dropped.time), s.last_dropped.reason]));
|
||||||
|
}
|
||||||
|
if (s.queued) {
|
||||||
|
var reasons = $.map(s.delay_reasons || [], function(r) { return '<li class="text-break">' + esc(r) + '</li>'; });
|
||||||
|
html += notice('warning', text('archive_state_queued', [s.queued, time(s.oldest_queued)]) +
|
||||||
|
(reasons.length ? '<ul class="mb-0 mt-1 small">' + reasons.join('') + '</ul>' : ''));
|
||||||
|
}
|
||||||
|
if (!s.enabled) {
|
||||||
|
html += notice('secondary', esc(L('archive_state_off')));
|
||||||
|
} else if (!html) {
|
||||||
|
html += notice('success', s.last_delivery ? text('archive_state_ok', [time(s.last_delivery)]) : esc(L('archive_state_ok_none')));
|
||||||
|
}
|
||||||
|
$('#archive_state').html(html);
|
||||||
|
}
|
||||||
|
|
||||||
|
function load() {
|
||||||
|
loaded = Date.now();
|
||||||
|
$.get('/api/v1/get/archive/status', function(s) { renderStatus(s || {}); }, 'json').fail(function() {
|
||||||
|
$('#archive_state').html(notice('danger', esc(lang_danger.generic_server_error)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// fields of the selected header profile only; hidden fields are not posted, so their stored values are kept
|
||||||
|
function showProfile() {
|
||||||
|
var profile = $('#archive_profile').val();
|
||||||
|
$('.archive-profile').each(function() {
|
||||||
|
var on = $(this).hasClass('archive-profile-' + profile);
|
||||||
|
$(this).toggleClass('d-none', !on).find(':input').prop('disabled', !on);
|
||||||
|
});
|
||||||
|
$('.archive-profile-info').each(function() {
|
||||||
|
$(this).toggleClass('d-none', !$(this).hasClass('archive-profile-info-' + profile));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$(document).ready(function() {
|
||||||
|
$('#archive_profile').on('change', showProfile);
|
||||||
|
// the tests use the saved settings: not with unsaved changes in the form
|
||||||
|
$('form[data-id="archive"]').on('input change', ':input', function() {
|
||||||
|
$('.archive-test').prop('disabled', true);
|
||||||
|
$('#archive_test_info').text(L('archive_save_first')).removeClass('text-muted').addClass('text-warning');
|
||||||
|
});
|
||||||
|
// the state line is only shown while archiving is enabled
|
||||||
|
var tab = document.getElementById('collapse-tab-archive');
|
||||||
|
if (!tab || !document.getElementById('archive_state')) return;
|
||||||
|
// the tab body is at the top: visible as soon as the tab (or on mobile its card) is opened; load again when shown later
|
||||||
|
new IntersectionObserver(function(entries) {
|
||||||
|
if (entries.some(function(e) { return e.intersectionRatio > 0; }) && Date.now() - loaded > 10000) load();
|
||||||
|
}).observe(tab);
|
||||||
|
$('#archive_refresh').on('click', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
load();
|
||||||
|
});
|
||||||
|
// open the dashboard with the archive log (the dashboard restores the last tab)
|
||||||
|
$('#archive_log_link').on('click', function() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem('lastTag', 'tab-archive-logs');
|
||||||
|
} catch (e) {}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return {load: load};
|
||||||
|
})(jQuery);
|
||||||
@@ -875,6 +875,81 @@ jQuery(function($){
|
|||||||
hideTableExpandCollapseBtn('#tab-cron-logs', '#cron_log');
|
hideTableExpandCollapseBtn('#tab-cron-logs', '#cron_log');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// archive copies from the Postfix log and mails not archived; the Message-ID finds a mail
|
||||||
|
function draw_archive_logs() {
|
||||||
|
// just recalc width if instance already exists
|
||||||
|
if ($.fn.DataTable.isDataTable('#archive_log') ) {
|
||||||
|
$('#archive_log').DataTable().columns.adjust().responsive.recalc();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// softbounce: the archive refused the copy, Postfix keeps it and tries again
|
||||||
|
var status_class = {sent: 'bg-success', deferred: 'bg-warning text-dark', softbounce: 'bg-danger', bounced: 'bg-danger', expired: 'bg-danger', dropped: 'bg-danger'};
|
||||||
|
|
||||||
|
$('#archive_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/archive",
|
||||||
|
dataSrc: function(data){
|
||||||
|
return Array.isArray(data) ? data : [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
columns: [
|
||||||
|
{
|
||||||
|
title: lang.time,
|
||||||
|
data: 'time',
|
||||||
|
defaultContent: '',
|
||||||
|
createdCell: function(td, cellData) {
|
||||||
|
createSortableDate(td, cellData)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: lang.archive_delivery,
|
||||||
|
data: 'status',
|
||||||
|
defaultContent: '',
|
||||||
|
render: function(data) {
|
||||||
|
var label = lang['archive_delivery_' + data] || data;
|
||||||
|
return '<span class="badge ' + (status_class[data] || 'bg-secondary') + '">' + escapeHtml(label) + '</span>';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: lang.archive_message_id,
|
||||||
|
data: 'message_id',
|
||||||
|
defaultContent: '',
|
||||||
|
className: 'text-break',
|
||||||
|
render: function(data) {
|
||||||
|
return escapeHtml(data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: lang.archive_queue_id,
|
||||||
|
data: 'qid',
|
||||||
|
defaultContent: '',
|
||||||
|
render: function(data) {
|
||||||
|
return escapeHtml(data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: lang.archive_details,
|
||||||
|
data: 'text',
|
||||||
|
defaultContent: '',
|
||||||
|
className: 'dtr-col-md text-break',
|
||||||
|
render: function(data) {
|
||||||
|
return escapeHtml(data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
}
|
||||||
function rspamd_pie_graph() {
|
function rspamd_pie_graph() {
|
||||||
$.ajax({
|
$.ajax({
|
||||||
url: '/api/v1/get/rspamd/actions',
|
url: '/api/v1/get/rspamd/actions',
|
||||||
@@ -1295,6 +1370,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^=archive_log]", () => draw_archive_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);
|
||||||
|
|||||||
@@ -232,6 +232,7 @@ $(document).ready(function() {
|
|||||||
} else {
|
} else {
|
||||||
$('#addDomain_gal').prop('checked', false);
|
$('#addDomain_gal').prop('checked', false);
|
||||||
}
|
}
|
||||||
|
$('#addDomain_archive').prop('checked', template.archive == 1);
|
||||||
|
|
||||||
if (template.active == 1){
|
if (template.active == 1){
|
||||||
$('#addDomain_active').prop('checked', true);
|
$('#addDomain_active').prop('checked', true);
|
||||||
@@ -433,6 +434,7 @@ $(document).ready(function() {
|
|||||||
} else {
|
} else {
|
||||||
$('#force_tfa').prop('checked', false);
|
$('#force_tfa').prop('checked', false);
|
||||||
}
|
}
|
||||||
|
$('#addMailbox_archive').selectpicker('val', ['0', '1'].includes(String(template.archive)) ? String(template.archive) : 'inherit');
|
||||||
if (template.sogo_access == 1){
|
if (template.sogo_access == 1){
|
||||||
$('#sogo_access').prop('checked', true);
|
$('#sogo_access').prop('checked', true);
|
||||||
} else {
|
} else {
|
||||||
@@ -1187,6 +1189,18 @@ jQuery(function($){
|
|||||||
return 1==data?'<i class="bi bi-check-lg"><span class="sorting-value">1</span></i>':(0==data?'<i class="bi bi-x-lg"><span class="sorting-value">0</span></i>':2==data&&'—');
|
return 1==data?'<i class="bi bi-check-lg"><span class="sorting-value">1</span></i>':(0==data?'<i class="bi bi-x-lg"><span class="sorting-value">0</span></i>':2==data&&'—');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: lang.archive,
|
||||||
|
data: 'archive',
|
||||||
|
searchable: false,
|
||||||
|
defaultContent: '',
|
||||||
|
responsivePriority: 5,
|
||||||
|
// not shown while archiving is switched off
|
||||||
|
className: archive_enabled ? '' : 'never',
|
||||||
|
render: function (data, type) {
|
||||||
|
return 1==data?'<i class="bi bi-check-lg"><span class="sorting-value">1</span></i>':'<i class="bi bi-x-lg"><span class="sorting-value">0</span></i>';
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: lang.action,
|
title: lang.action,
|
||||||
data: 'action',
|
data: 'action',
|
||||||
|
|||||||
+58
-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',
|
||||||
@@ -839,6 +858,26 @@ if (isset($_GET['query'])) {
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case "archive":
|
||||||
|
switch ($object) {
|
||||||
|
case "settings":
|
||||||
|
process_get_return(\Mailcow\Archive\ArchiveModule::fromGlobals()->api->getSettings());
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "status":
|
||||||
|
process_get_return(\Mailcow\Archive\ArchiveModule::fromGlobals()->api->getStatus());
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "mailboxes":
|
||||||
|
process_get_return(\Mailcow\Archive\ArchiveModule::fromGlobals()->api->getMailboxes());
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
process_get_return(null);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
case "logs":
|
case "logs":
|
||||||
switch ($object) {
|
switch ($object) {
|
||||||
case "dovecot":
|
case "dovecot":
|
||||||
@@ -885,6 +924,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 "archive":
|
||||||
|
// deliveries of archive copies and mails not archived, newest first
|
||||||
|
$logs = \Mailcow\Archive\ArchiveModule::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 "postfix":
|
case "postfix":
|
||||||
// 0 is first record, so empty is fine
|
// 0 is first record, so empty is fine
|
||||||
if (isset($extra)) {
|
if (isset($extra)) {
|
||||||
@@ -1549,7 +1593,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);
|
||||||
}
|
}
|
||||||
@@ -1656,6 +1707,8 @@ if (isset($_GET['query'])) {
|
|||||||
['db' => 'messages', 'dt' => 20, 'dummy' => true, 'order_subquery' => "SELECT SUM(messages) FROM `quota2` WHERE `quota2`.`username` = `m`.`username`"],
|
['db' => 'messages', 'dt' => 20, 'dummy' => true, 'order_subquery' => "SELECT SUM(messages) FROM `quota2` WHERE `quota2`.`username` = `m`.`username`"],
|
||||||
['db' => 'tags', 'dt' => 23, 'dummy' => true, 'search' => ['join' => 'LEFT JOIN `tags_mailbox` AS `tm` ON `tm`.`username` = `m`.`username`', 'where_column' => '`tm`.`tag_name`']],
|
['db' => 'tags', 'dt' => 23, 'dummy' => true, 'search' => ['join' => 'LEFT JOIN `tags_mailbox` AS `tm` ON `tm`.`username` = `m`.`username`', 'where_column' => '`tm`.`tag_name`']],
|
||||||
['db' => 'active', 'dt' => 24],
|
['db' => 'active', 'dt' => 24],
|
||||||
|
// archived by the mailbox setting or ('inherit') the domain default
|
||||||
|
['db' => 'archive', 'dt' => 25, 'dummy' => true, 'order_subquery' => "SELECT IF(JSON_VALUE(`m`.`attributes`, '$.archive') = '1' OR (COALESCE(JSON_VALUE(`m`.`attributes`, '$.archive'), '') NOT IN ('0', '1') AND `domain`.`archive` = '1'), 1, 0) FROM `domain` WHERE `domain`.`domain` = `m`.`domain`"],
|
||||||
];
|
];
|
||||||
|
|
||||||
if($_SESSION['mailcow_cc_role'] === 'admin') {
|
if($_SESSION['mailcow_cc_role'] === 'admin') {
|
||||||
@@ -1927,6 +1980,9 @@ 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 "archive":
|
||||||
|
process_edit_return(\Mailcow\Archive\ArchiveModule::fromGlobals()->api->edit($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;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"alias_domains": "Alias-Domains hinzufügen",
|
"alias_domains": "Alias-Domains hinzufügen",
|
||||||
"alias_external_goto": "Aliase mit externen Ziel-Domains erlauben",
|
"alias_external_goto": "Aliase mit externen Ziel-Domains erlauben",
|
||||||
"app_passwds": "App-Passwörter verwalten",
|
"app_passwds": "App-Passwörter verwalten",
|
||||||
|
"archive": "Archivierung von Domains und Mailboxen ändern",
|
||||||
"bcc_maps": "BCC-Maps",
|
"bcc_maps": "BCC-Maps",
|
||||||
"delimiter_action": "Delimiter-Aktionen (tags)",
|
"delimiter_action": "Delimiter-Aktionen (tags)",
|
||||||
"domain_desc": "Domainbeschreibung ändern",
|
"domain_desc": "Domainbeschreibung ändern",
|
||||||
@@ -138,6 +139,57 @@
|
|||||||
"app_links": "App-Links",
|
"app_links": "App-Links",
|
||||||
"app_name": "App-Name",
|
"app_name": "App-Name",
|
||||||
"apps_name": "\"mailcow Apps\" Name",
|
"apps_name": "\"mailcow Apps\" Name",
|
||||||
|
"archive": "Archivierung",
|
||||||
|
"archive_address": "Archivadresse",
|
||||||
|
"archive_address_info": "mailcow sendet eine Kopie jeder archivierten E-Mail an diese Adresse.",
|
||||||
|
"archive_delivery": "Ergebnis",
|
||||||
|
"archive_delivery_bounced": "Abgewiesen",
|
||||||
|
"archive_delivery_deferred": "Verzögert",
|
||||||
|
"archive_delivery_dropped": "Nicht archiviert",
|
||||||
|
"archive_delivery_expired": "Abgelaufen, verworfen",
|
||||||
|
"archive_delivery_sent": "Zugestellt",
|
||||||
|
"archive_delivery_softbounce": "Abgelehnt, wird wiederholt",
|
||||||
|
"archive_details": "Antwort des Archivs / Grund",
|
||||||
|
"archive_enabled": "Archivierung aktivieren",
|
||||||
|
"archive_enabled_info": "Archiviert die Domains und Mailboxen, bei denen die Archivierung aktiviert ist. Nach dem Deaktivieren entstehen keine neuen Kopien; Kopien in der Mailqueue werden noch zugestellt.",
|
||||||
|
"archive_header_from": "Header-Name für den Envelope-Absender",
|
||||||
|
"archive_header_rcpt": "Header-Name für die Envelope-Empfänger",
|
||||||
|
"archive_host": "Archivserver",
|
||||||
|
"archive_host_info": "Leer lassen, um den MX der Domain der Archivadresse zu verwenden.",
|
||||||
|
"archive_info": "mailcow sendet eine Kopie jeder angenommenen eingehenden, ausgehenden und internen E-Mail archivierter Mailboxen per SMTP an ein externes Archiv (Journaling). Die Kopien laufen über die Mailqueue und werden erneut zugestellt, solange das Archiv nicht erreichbar ist; Absender erhalten dafür nie einen Bounce oder eine Verzögerungswarnung.",
|
||||||
|
"archive_log": "Archiv-Protokoll",
|
||||||
|
"archive_log_info": "Kopien an das Archiv aus dem Postfix-Protokoll im Speicher und E-Mails, die nicht archiviert wurden. Suchen Sie nach der Message-ID einer E-Mail.",
|
||||||
|
"archive_message_id": "Message-ID",
|
||||||
|
"archive_password": "SMTP-Passwort",
|
||||||
|
"archive_password_remove": "Gespeichertes Passwort entfernen",
|
||||||
|
"archive_port": "Port",
|
||||||
|
"archive_profile": "Profil",
|
||||||
|
"archive_profile_archivecow": "archivecow (Besitzer, Envelope, Signatur)",
|
||||||
|
"archive_profile_archivecow_info": "Für archivecow: Signierte Header über der E-Mail nennen die archivierten Mailboxen, denen sie gehört, und den SMTP-Envelope (auch BCC-Empfänger). archivecow prüft die Signatur mit dem Signaturschlüssel.",
|
||||||
|
"archive_profile_envelope": "Envelope-Absender und -Empfänger",
|
||||||
|
"archive_profile_envelope_info": "Für andere Archive (z. B. MailPiler): Zwei Header über der E-Mail nennen Envelope-Absender und -Empfänger, damit das Archiv auch BCC-Empfänger zuordnen kann. Die Header sind nicht signiert.",
|
||||||
|
"archive_profile_none": "Keine (unveränderte Kopie)",
|
||||||
|
"archive_profile_none_info": "Die E-Mail geht unverändert ins Archiv. BCC-Empfänger sind nicht erkennbar, das Archiv ordnet die E-Mail nur nach ihren eigenen Headern (From, To, Cc) zu.",
|
||||||
|
"archive_queue_id": "Queue-ID",
|
||||||
|
"archive_save_first": "Erst speichern: Die Tests verwenden die gespeicherten Einstellungen.",
|
||||||
|
"archive_section_connection": "Verbindung",
|
||||||
|
"archive_section_headers": "Archiv-Header",
|
||||||
|
"archive_signing_secret": "Signaturschlüssel",
|
||||||
|
"archive_signing_secret_info": "Signiert die Archiv-Header (X-Archive-Signature). Muss mit dem Schlüssel in archivecow übereinstimmen.",
|
||||||
|
"archive_state_dropped": "%s E-Mails wurden in den letzten 24 Stunden nicht archiviert, zuletzt um %s: %s",
|
||||||
|
"archive_state_off": "Die Archivierung ist deaktiviert.",
|
||||||
|
"archive_state_ok": "Die Archivierung läuft, letzte Zustellung an das Archiv: %s",
|
||||||
|
"archive_state_ok_none": "Die Archivierung ist aktiv, das Protokoll enthält noch keine Zustellung an das Archiv",
|
||||||
|
"archive_state_queued": "%s Kopien warten auf das Archiv, die älteste seit %s",
|
||||||
|
"archive_state_unconfigured": "Archivadresse oder Signaturschlüssel fehlt",
|
||||||
|
"archive_test": "Verbindung testen",
|
||||||
|
"archive_test_info": "Die Tests verwenden die gespeicherten Einstellungen. Die Test-E-Mail geht über die Mailqueue an das Archiv.",
|
||||||
|
"archive_testmail": "Test-E-Mail senden",
|
||||||
|
"archive_tls": "TLS",
|
||||||
|
"archive_tls_encrypt": "Erforderlich, ohne Zertifikatsprüfung",
|
||||||
|
"archive_tls_may": "Wenn verfügbar",
|
||||||
|
"archive_tls_verify": "Erforderlich, mit Zertifikatsprüfung",
|
||||||
|
"archive_username": "SMTP-Benutzername",
|
||||||
"arrival_time": "Ankunftszeit (Serverzeit)",
|
"arrival_time": "Ankunftszeit (Serverzeit)",
|
||||||
"authed_user": "Auth. Benutzer",
|
"authed_user": "Auth. Benutzer",
|
||||||
"ays": "Soll der Vorgang wirklich ausgeführt werden?",
|
"ays": "Soll der Vorgang wirklich ausgeführt werden?",
|
||||||
@@ -475,6 +527,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",
|
||||||
@@ -515,6 +568,19 @@
|
|||||||
"api_key_service_locked": "API-Key %s ist in der mailcow.conf definiert und kann nur dort geändert werden",
|
"api_key_service_locked": "API-Key %s ist in der mailcow.conf definiert und kann nur dort geändert werden",
|
||||||
"app_name_empty": "App-Name darf nicht leer sein",
|
"app_name_empty": "App-Name darf nicht leer sein",
|
||||||
"app_passwd_id_invalid": "App-Passwort ID %s ist ungültig",
|
"app_passwd_id_invalid": "App-Passwort ID %s ist ungültig",
|
||||||
|
"archive_address_invalid": "Ungültige Archivadresse",
|
||||||
|
"archive_address_local": "Die Archivadresse darf in keiner Domain oder Alias-Domain dieser mailcow liegen",
|
||||||
|
"archive_header_invalid": "Ungültiger Header-Name: Buchstaben, Ziffern und Bindestriche, beginnend mit einem Buchstaben",
|
||||||
|
"archive_host_invalid": "Ungültiger Archivserver",
|
||||||
|
"archive_incomplete": "Für die Archivierung sind eine Archivadresse und beim Profil archivecow ein Signaturschlüssel erforderlich",
|
||||||
|
"archive_password_invalid": "Ungültiges SMTP-Passwort: druckbare ASCII-Zeichen ohne Leerzeichen, höchstens 255 Zeichen",
|
||||||
|
"archive_port_invalid": "Ungültiger Port",
|
||||||
|
"archive_profile_invalid": "Ungültiges Profil für die Archiv-Header",
|
||||||
|
"archive_signing_secret_invalid": "Ungültiger Signaturschlüssel: druckbare ASCII-Zeichen ohne Leerzeichen, höchstens 255 Zeichen",
|
||||||
|
"archive_test_failed": "Verbindungstest zum Archiv fehlgeschlagen: %s",
|
||||||
|
"archive_testmail_failed": "Test-E-Mail konnte nicht in die Queue gestellt werden: %s",
|
||||||
|
"archive_tls_invalid": "Ungültiger TLS-Modus",
|
||||||
|
"archive_username_invalid": "Ungültiger SMTP-Benutzername",
|
||||||
"authsource_in_use": "Der Identity Provider kann nicht geändert oder gelöscht werden, da er derzeit von einem oder mehreren Benutzern verwendet wird.",
|
"authsource_in_use": "Der Identity Provider kann nicht geändert oder gelöscht werden, da er derzeit von einem oder mehreren Benutzern verwendet wird.",
|
||||||
"bcc_empty": "BCC-Ziel darf nicht leer sein",
|
"bcc_empty": "BCC-Ziel darf nicht leer sein",
|
||||||
"bcc_exists": "Ein BCC-Map-Eintrag %s existiert bereits als Typ %s",
|
"bcc_exists": "Ein BCC-Map-Eintrag %s existiert bereits als Typ %s",
|
||||||
@@ -641,6 +707,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 +733,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",
|
||||||
@@ -777,6 +845,15 @@
|
|||||||
"app_name": "App-Name",
|
"app_name": "App-Name",
|
||||||
"app_passwd": "App-Passwörter",
|
"app_passwd": "App-Passwörter",
|
||||||
"app_passwd_protocols": "Zugelassene Protokolle für App-Passwort",
|
"app_passwd_protocols": "Zugelassene Protokolle für App-Passwort",
|
||||||
|
"archive": "Archivierung",
|
||||||
|
"archive_active": "Archiviert",
|
||||||
|
"archive_domain": "Alle Mailboxen standardmäßig archivieren",
|
||||||
|
"archive_domain_info": "Gilt für Mailboxen mit der Einstellung „Domain-Standard“.",
|
||||||
|
"archive_inactive": "Nicht archiviert",
|
||||||
|
"archive_info": "„Domain-Standard“ folgt der Einstellung der Domain. Von archivierten Mailboxen geht eine Kopie jeder gesendeten und empfangenen E-Mail an das Archiv.",
|
||||||
|
"archive_inherit": "Domain-Standard",
|
||||||
|
"archive_off": "Deaktiviert",
|
||||||
|
"archive_on": "Aktiviert",
|
||||||
"backup_mx_options": "Relay-Optionen",
|
"backup_mx_options": "Relay-Optionen",
|
||||||
"bcc_dest_format": "BCC-Ziel muss eine gültige E-Mail-Adresse sein.",
|
"bcc_dest_format": "BCC-Ziel muss eine gültige E-Mail-Adresse sein.",
|
||||||
"client_id": "Client-ID",
|
"client_id": "Client-ID",
|
||||||
@@ -965,7 +1042,7 @@
|
|||||||
"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 Mails von 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",
|
||||||
@@ -1058,6 +1135,7 @@
|
|||||||
"allow_from_smtp": "Nur folgende IPs für <b>SMTP</b> erlauben",
|
"allow_from_smtp": "Nur folgende IPs für <b>SMTP</b> erlauben",
|
||||||
"allow_from_smtp_info": "Leer lassen, um keine Prüfung durchzuführen.<br>IPv4- sowie IPv6-Adressen und Subnetzwerke.",
|
"allow_from_smtp_info": "Leer lassen, um keine Prüfung durchzuführen.<br>IPv4- sowie IPv6-Adressen und Subnetzwerke.",
|
||||||
"allowed_protocols": "Erlaubte Protokolle",
|
"allowed_protocols": "Erlaubte Protokolle",
|
||||||
|
"archive": "Archiviert",
|
||||||
"backup_mx": "Relay-Domain",
|
"backup_mx": "Relay-Domain",
|
||||||
"bcc": "BCC",
|
"bcc": "BCC",
|
||||||
"bcc_destination": "BCC-Ziel",
|
"bcc_destination": "BCC-Ziel",
|
||||||
@@ -1302,6 +1380,9 @@
|
|||||||
"app_links": "Änderungen an App-Links wurden gespeichert",
|
"app_links": "Änderungen an App-Links wurden gespeichert",
|
||||||
"app_passwd_added": "App-Passwort wurde gespeichert",
|
"app_passwd_added": "App-Passwort wurde gespeichert",
|
||||||
"app_passwd_removed": "App-Passwort-ID %s wurde entfernt",
|
"app_passwd_removed": "App-Passwort-ID %s wurde entfernt",
|
||||||
|
"archive_settings_saved": "Archivierungseinstellungen gespeichert",
|
||||||
|
"archive_test_ok": "Verbindungstest erfolgreich: Der Archivserver akzeptiert die Archivadresse",
|
||||||
|
"archive_testmail_queued": "Test-E-Mail an das Archiv in die Queue gestellt (Queue-ID %s)",
|
||||||
"bcc_deleted": "BCC-Map-Einträge gelöscht: %s",
|
"bcc_deleted": "BCC-Map-Einträge gelöscht: %s",
|
||||||
"bcc_edited": "BCC-Map-Eintrag %s wurde geändert",
|
"bcc_edited": "BCC-Map-Eintrag %s wurde geändert",
|
||||||
"bcc_saved": "BCC- Map-Eintrag wurde gespeichert",
|
"bcc_saved": "BCC- Map-Eintrag wurde gespeichert",
|
||||||
@@ -1537,6 +1618,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",
|
||||||
@@ -1712,6 +1796,7 @@
|
|||||||
"pushover_sound": "Ton"
|
"pushover_sound": "Ton"
|
||||||
},
|
},
|
||||||
"warning": {
|
"warning": {
|
||||||
|
"archive_test_ok_missing": "Verbindungstest erfolgreich, aber der Archivserver kündigt %s nicht an: 8-Bit-E-Mails werden unverändert gesendet und eventuell abgelehnt",
|
||||||
"cannot_delete_self": "Kann derzeit eingeloggten Benutzer nicht entfernen",
|
"cannot_delete_self": "Kann derzeit eingeloggten Benutzer nicht entfernen",
|
||||||
"domain_added_sogo_failed": "Domain wurde hinzugefügt, aber SOGo konnte nicht neugestartet werden",
|
"domain_added_sogo_failed": "Domain wurde hinzugefügt, aber SOGo konnte nicht neugestartet werden",
|
||||||
"dovecot_restart_failed": "Dovecot wurde nicht erfolgreich neu gestartet, bitte prüfen Sie die Logs.",
|
"dovecot_restart_failed": "Dovecot wurde nicht erfolgreich neu gestartet, bitte prüfen Sie die Logs.",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"alias_domains": "Add alias domains",
|
"alias_domains": "Add alias domains",
|
||||||
"alias_external_goto": "Allow aliases with external goto domains",
|
"alias_external_goto": "Allow aliases with external goto domains",
|
||||||
"app_passwds": "Manage app passwords",
|
"app_passwds": "Manage app passwords",
|
||||||
|
"archive": "Change archiving of domains and mailboxes",
|
||||||
"bcc_maps": "BCC maps",
|
"bcc_maps": "BCC maps",
|
||||||
"delimiter_action": "Delimiter action",
|
"delimiter_action": "Delimiter action",
|
||||||
"domain_desc": "Change domain description",
|
"domain_desc": "Change domain description",
|
||||||
@@ -142,6 +143,57 @@
|
|||||||
"app_links": "App links",
|
"app_links": "App links",
|
||||||
"app_name": "App name",
|
"app_name": "App name",
|
||||||
"apps_name": "\"mailcow Apps\" name",
|
"apps_name": "\"mailcow Apps\" name",
|
||||||
|
"archive": "Archiving",
|
||||||
|
"archive_address": "Archive address",
|
||||||
|
"archive_address_info": "mailcow sends a copy of every archived message to this address.",
|
||||||
|
"archive_delivery": "Result",
|
||||||
|
"archive_delivery_bounced": "Bounced",
|
||||||
|
"archive_delivery_deferred": "Deferred",
|
||||||
|
"archive_delivery_dropped": "Not archived",
|
||||||
|
"archive_delivery_expired": "Expired, discarded",
|
||||||
|
"archive_delivery_sent": "Delivered",
|
||||||
|
"archive_delivery_softbounce": "Refused, retrying",
|
||||||
|
"archive_details": "Answer of the archive / reason",
|
||||||
|
"archive_enabled": "Enable archiving",
|
||||||
|
"archive_enabled_info": "Archives the domains and mailboxes that have archiving enabled. Disabling stops new copies; copies already in the mail queue are still delivered.",
|
||||||
|
"archive_header_from": "Header name for the envelope sender",
|
||||||
|
"archive_header_rcpt": "Header name for the envelope recipients",
|
||||||
|
"archive_host": "Archive server",
|
||||||
|
"archive_host_info": "Leave empty to use the MX of the archive address domain.",
|
||||||
|
"archive_info": "mailcow sends a copy of every accepted incoming, outgoing and internal message of archived mailboxes to an external archive by SMTP (journaling). Copies are queued and retried while the archive is unreachable; senders never get a bounce or delay notice for them.",
|
||||||
|
"archive_log": "Archive log",
|
||||||
|
"archive_log_info": "Copies sent to the archive, from the Postfix log in memory, and mails that were not archived. Search for the Message-ID of a mail.",
|
||||||
|
"archive_message_id": "Message-ID",
|
||||||
|
"archive_password": "SMTP password",
|
||||||
|
"archive_password_remove": "Remove stored password",
|
||||||
|
"archive_port": "Port",
|
||||||
|
"archive_profile": "Profile",
|
||||||
|
"archive_profile_archivecow": "archivecow (owners, envelope, signature)",
|
||||||
|
"archive_profile_archivecow_info": "For archivecow: signed headers above the mail name the archived mailboxes it belongs to and the SMTP envelope (BCC recipients included). archivecow verifies the signature with the signing key.",
|
||||||
|
"archive_profile_envelope": "Envelope sender and recipients",
|
||||||
|
"archive_profile_envelope_info": "For other archives (e.g. MailPiler): two headers above the mail name the envelope sender and recipients, so the archive can assign BCC recipients too. The headers are not signed.",
|
||||||
|
"archive_profile_none": "None (unchanged copy)",
|
||||||
|
"archive_profile_none_info": "The mail goes to the archive unchanged. BCC recipients cannot be seen, the archive assigns the mail by its own headers (From, To, Cc) only.",
|
||||||
|
"archive_queue_id": "Queue ID",
|
||||||
|
"archive_save_first": "Save your changes first: the tests use the saved settings.",
|
||||||
|
"archive_section_connection": "Connection",
|
||||||
|
"archive_section_headers": "Archive headers",
|
||||||
|
"archive_signing_secret": "Signing key",
|
||||||
|
"archive_signing_secret_info": "Signs the archive headers (X-Archive-Signature). Must match the key in archivecow.",
|
||||||
|
"archive_state_dropped": "%s mails were not archived in the last 24 hours, the last at %s: %s",
|
||||||
|
"archive_state_off": "Archiving is disabled.",
|
||||||
|
"archive_state_ok": "Archiving is working, last delivery to the archive: %s",
|
||||||
|
"archive_state_ok_none": "Archiving is enabled, the log has no delivery to the archive yet",
|
||||||
|
"archive_state_queued": "%s copies are waiting for the archive, the oldest since %s",
|
||||||
|
"archive_state_unconfigured": "Archive address or signing key missing",
|
||||||
|
"archive_test": "Test connection",
|
||||||
|
"archive_test_info": "The tests use the saved settings. The test mail goes through the mail queue to the archive.",
|
||||||
|
"archive_testmail": "Send test mail",
|
||||||
|
"archive_tls": "TLS",
|
||||||
|
"archive_tls_encrypt": "Required, no certificate check",
|
||||||
|
"archive_tls_may": "If available",
|
||||||
|
"archive_tls_verify": "Required, with certificate check",
|
||||||
|
"archive_username": "SMTP username",
|
||||||
"arrival_time": "Arrival time (server time)",
|
"arrival_time": "Arrival time (server time)",
|
||||||
"authed_user": "Auth. user",
|
"authed_user": "Auth. user",
|
||||||
"ays": "Are you sure you want to proceed?",
|
"ays": "Are you sure you want to proceed?",
|
||||||
@@ -485,6 +537,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",
|
||||||
@@ -507,6 +560,19 @@
|
|||||||
"api_key_description_invalid": "API key description must not exceed 255 characters",
|
"api_key_description_invalid": "API key description must not exceed 255 characters",
|
||||||
"api_key_network_invalid": "Invalid IP address or network: %s",
|
"api_key_network_invalid": "Invalid IP address or network: %s",
|
||||||
"api_key_service_locked": "API key %s is defined in mailcow.conf and can only be changed there",
|
"api_key_service_locked": "API key %s is defined in mailcow.conf and can only be changed there",
|
||||||
|
"archive_address_invalid": "Invalid archive address",
|
||||||
|
"archive_address_local": "The archive address must not be in a domain or alias domain of this mailcow",
|
||||||
|
"archive_header_invalid": "Invalid header name: letters, digits and hyphens, starting with a letter",
|
||||||
|
"archive_host_invalid": "Invalid archive server",
|
||||||
|
"archive_incomplete": "Archiving requires an archive address and, for the archivecow profile, a signing key",
|
||||||
|
"archive_password_invalid": "Invalid SMTP password: printable ASCII without spaces, at most 255 characters",
|
||||||
|
"archive_port_invalid": "Invalid port",
|
||||||
|
"archive_profile_invalid": "Invalid archive header profile",
|
||||||
|
"archive_signing_secret_invalid": "Invalid signing key: printable ASCII without spaces, at most 255 characters",
|
||||||
|
"archive_test_failed": "Connection test to the archive failed: %s",
|
||||||
|
"archive_testmail_failed": "Test mail could not be queued: %s",
|
||||||
|
"archive_tls_invalid": "Invalid TLS mode",
|
||||||
|
"archive_username_invalid": "Invalid SMTP username",
|
||||||
"group_address_invalid": "Group address %s is invalid or not in the domain of the group",
|
"group_address_invalid": "Group address %s is invalid or not in the domain of the group",
|
||||||
"group_description_invalid": "The group description must not exceed 255 characters",
|
"group_description_invalid": "The group description must not exceed 255 characters",
|
||||||
"group_grant_invalid": "Invalid share for mailbox %s",
|
"group_grant_invalid": "Invalid share for mailbox %s",
|
||||||
@@ -517,6 +583,7 @@
|
|||||||
"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)",
|
||||||
|
"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 +733,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",
|
||||||
@@ -777,6 +845,15 @@
|
|||||||
"app_name": "App name",
|
"app_name": "App name",
|
||||||
"app_passwd": "App password",
|
"app_passwd": "App password",
|
||||||
"app_passwd_protocols": "Allowed protocols for app password",
|
"app_passwd_protocols": "Allowed protocols for app password",
|
||||||
|
"archive": "Archiving",
|
||||||
|
"archive_active": "Archived",
|
||||||
|
"archive_domain": "Archive all mailboxes by default",
|
||||||
|
"archive_domain_info": "Applies to mailboxes set to \"Domain default\".",
|
||||||
|
"archive_inactive": "Not archived",
|
||||||
|
"archive_info": "\"Domain default\" follows the domain setting. Archived mailboxes send a copy of every message they send or receive to the archive.",
|
||||||
|
"archive_inherit": "Domain default",
|
||||||
|
"archive_off": "Disabled",
|
||||||
|
"archive_on": "Enabled",
|
||||||
"backup_mx_options": "Relay options",
|
"backup_mx_options": "Relay options",
|
||||||
"bcc_dest_format": "BCC destination must be a single valid email address.<br>If you need to send a copy to multiple addresses, create an alias and use it here.",
|
"bcc_dest_format": "BCC destination must be a single valid email address.<br>If you need to send a copy to multiple addresses, create an alias and use it here.",
|
||||||
"client_id": "Client ID",
|
"client_id": "Client ID",
|
||||||
@@ -965,7 +1042,7 @@
|
|||||||
"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 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",
|
||||||
@@ -1059,6 +1136,7 @@
|
|||||||
"allow_from_smtp": "Only allow these IPs to use <b>SMTP</b>",
|
"allow_from_smtp": "Only allow these IPs to use <b>SMTP</b>",
|
||||||
"allow_from_smtp_info": "Leave empty to allow all senders.<br>IPv4/IPv6 addresses and networks.",
|
"allow_from_smtp_info": "Leave empty to allow all senders.<br>IPv4/IPv6 addresses and networks.",
|
||||||
"allowed_protocols": "Allowed protocols",
|
"allowed_protocols": "Allowed protocols",
|
||||||
|
"archive": "Archived",
|
||||||
"backup_mx": "Relay domain",
|
"backup_mx": "Relay domain",
|
||||||
"bcc": "BCC",
|
"bcc": "BCC",
|
||||||
"bcc_destination": "BCC destination",
|
"bcc_destination": "BCC destination",
|
||||||
@@ -1309,6 +1387,9 @@
|
|||||||
"app_links": "Saved changes to app links",
|
"app_links": "Saved changes to app links",
|
||||||
"app_passwd_added": "Added new app password",
|
"app_passwd_added": "Added new app password",
|
||||||
"app_passwd_removed": "Removed app password ID %s",
|
"app_passwd_removed": "Removed app password ID %s",
|
||||||
|
"archive_settings_saved": "Archive settings saved",
|
||||||
|
"archive_test_ok": "Connection test passed: the archive server accepts the archive address",
|
||||||
|
"archive_testmail_queued": "Test mail to the archive queued (queue ID %s)",
|
||||||
"bcc_deleted": "BCC map entries deleted: %s",
|
"bcc_deleted": "BCC map entries deleted: %s",
|
||||||
"bcc_edited": "BCC map entry %s edited",
|
"bcc_edited": "BCC map entry %s edited",
|
||||||
"bcc_saved": "BCC map entry saved",
|
"bcc_saved": "BCC map entry saved",
|
||||||
@@ -1544,6 +1625,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",
|
||||||
@@ -1719,6 +1803,7 @@
|
|||||||
"years": "years"
|
"years": "years"
|
||||||
},
|
},
|
||||||
"warning": {
|
"warning": {
|
||||||
|
"archive_test_ok_missing": "Connection test passed, but the archive server does not announce %s: 8-bit messages are sent unchanged and may be rejected",
|
||||||
"cannot_delete_self": "Cannot delete logged in user",
|
"cannot_delete_self": "Cannot delete logged in user",
|
||||||
"domain_added_sogo_failed": "Added domain but failed to restart SOGo, please check your server logs.",
|
"domain_added_sogo_failed": "Added domain but failed to restart SOGo, please check your server logs.",
|
||||||
"dovecot_restart_failed": "Dovecot failed to restart, please check the logs",
|
"dovecot_restart_failed": "Dovecot failed to restart, please check the logs",
|
||||||
|
|||||||
@@ -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'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li role="presentation" class="nav-item"><button class="nav-link" data-bs-target="#tab-routing" aria-selected="false" aria-controls="tab-routing" role="tab" data-bs-toggle="tab">{{ lang.admin.routing }}</button></li>
|
<li role="presentation" class="nav-item"><button class="nav-link" data-bs-target="#tab-routing" aria-selected="false" aria-controls="tab-routing" role="tab" data-bs-toggle="tab">{{ lang.admin.routing }}</button></li>
|
||||||
|
<li role="presentation" class="nav-item"><button class="nav-link" data-bs-target="#tab-archive" aria-selected="false" aria-controls="tab-archive" role="tab" data-bs-toggle="tab">{{ lang.admin.archive }}</button></li>
|
||||||
<li role="presentation" class="nav-item"><button class="nav-link" data-bs-target="#tab-sys-mails" aria-selected="false" aria-controls="tab-sys-mails" role="tab" data-bs-toggle="tab">{{ lang.admin.sys_mails }}</button></li>
|
<li role="presentation" class="nav-item"><button class="nav-link" data-bs-target="#tab-sys-mails" aria-selected="false" aria-controls="tab-sys-mails" role="tab" data-bs-toggle="tab">{{ lang.admin.sys_mails }}</button></li>
|
||||||
<li class="nav-item dropdown">
|
<li class="nav-item dropdown">
|
||||||
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#" role="button" aria-expanded="false">{{ lang.admin.rspamd_global_filters }}</a>
|
<a class="nav-link dropdown-toggle" data-bs-toggle="dropdown" href="#" role="button" aria-expanded="false">{{ lang.admin.rspamd_global_filters }}</a>
|
||||||
@@ -47,6 +48,7 @@
|
|||||||
{% include 'admin/tab-config-oauth2.twig' %}
|
{% include 'admin/tab-config-oauth2.twig' %}
|
||||||
{% include 'admin/tab-config-rspamd.twig' %}
|
{% include 'admin/tab-config-rspamd.twig' %}
|
||||||
{% include 'admin/tab-routing.twig' %}
|
{% include 'admin/tab-routing.twig' %}
|
||||||
|
{% include 'admin/tab-archive.twig' %}
|
||||||
{% include 'admin/tab-config-dkim.twig' %}
|
{% include 'admin/tab-config-dkim.twig' %}
|
||||||
{% include 'admin/tab-config-fwdhosts.twig' %}
|
{% include 'admin/tab-config-fwdhosts.twig' %}
|
||||||
{% include 'admin/tab-config-f2b.twig' %}
|
{% include 'admin/tab-config-f2b.twig' %}
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
<div class="tab-pane fade" id="tab-archive" role="tabpanel" aria-labelledby="tab-archive">
|
||||||
|
<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-archive" data-bs-toggle="collapse" aria-controls="collapse-tab-archive">
|
||||||
|
{{ lang.admin.archive }}
|
||||||
|
</button>
|
||||||
|
<span class="d-none d-md-block">{{ lang.admin.archive }}</span>
|
||||||
|
</div>
|
||||||
|
<div id="collapse-tab-archive" class="card-body collapse" data-bs-parent="#admin-content">
|
||||||
|
<div class="row">
|
||||||
|
<p class="offset-md-3 col-12 col-md-9 mb-4">{{ lang.admin.archive_info }}</p>
|
||||||
|
</div>
|
||||||
|
<form class="form-horizontal" role="form" data-id="archive" method="post" autocomplete="off">
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
|
||||||
|
<label class="control-label" for="archive_enabled">{{ lang.admin.archive_enabled }}:</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-9 d-flex align-items-center">
|
||||||
|
<input type="hidden" name="enabled" value="0">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch" id="archive_enabled" name="enabled" value="1"{% if archive_settings.enabled == 1 %} checked{% endif %}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="offset-md-3 col-12 col-md-9">
|
||||||
|
<small class="text-muted">{{ lang.admin.archive_enabled_info }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% if archive_settings.enabled == 1 %}
|
||||||
|
{# Filled by js/site/archive.js: one line per problem; details in the archive log and the queue manager #}
|
||||||
|
<div class="row mb-2" id="archive_status">
|
||||||
|
<div class="offset-md-3 col-12 col-md-9 col-lg-7">
|
||||||
|
<div id="archive_state"><p class="text-muted mb-2">{{ lang.datatables.loadingRecords }}</p></div>
|
||||||
|
<small class="d-block">
|
||||||
|
<a href="/admin/dashboard" id="archive_log_link"><i class="bi bi-journal-text"></i> {{ lang.admin.archive_log }}</a>
|
||||||
|
<a href="/admin/queue" class="ms-3"><i class="bi bi-inboxes"></i> {{ lang.queue.queue_manager }}</a>
|
||||||
|
<a href="#" class="ms-3" id="archive_refresh"><i class="bi bi-arrow-clockwise"></i> {{ lang.admin.refresh }}</a>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<legend class="fs-5 mt-4"><i class="bi bi-plug"></i> {{ lang.admin.archive_section_connection }}</legend><hr>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
|
||||||
|
<label class="control-label" for="archive_address">{{ lang.admin.archive_address }}:</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-9 col-lg-5">
|
||||||
|
<input type="email" class="form-control" id="archive_address" name="address" placeholder="journal@archive.example.com" value="{{ archive_settings.address }}">
|
||||||
|
</div>
|
||||||
|
<div class="offset-md-3 col-12 col-md-9">
|
||||||
|
<small class="text-muted">{{ lang.admin.archive_address_info }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
|
||||||
|
<label class="control-label" for="archive_host">{{ lang.admin.archive_host }}:</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-9 col-lg-5">
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" class="form-control" id="archive_host" name="host" placeholder="archive.example.com" value="{{ archive_settings.host }}">
|
||||||
|
<span class="input-group-text">{{ lang.admin.archive_port }}</span>
|
||||||
|
<input type="number" class="form-control" id="archive_port" name="port" min="1" max="65535" style="max-width: 7rem" aria-label="{{ lang.admin.archive_port }}" value="{{ archive_settings.port }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="offset-md-3 col-12 col-md-9">
|
||||||
|
<small class="text-muted">{{ lang.admin.archive_host_info }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
|
||||||
|
<label class="control-label" for="archive_tls">{{ lang.admin.archive_tls }}:</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-9 col-lg-5">
|
||||||
|
<select class="full-width-select form-control" id="archive_tls" name="tls">
|
||||||
|
{% for mode in ['encrypt', 'verify', 'may'] %}
|
||||||
|
<option value="{{ mode }}"{% if archive_settings.tls == mode %} selected{% endif %}>{{ attribute(lang.admin, 'archive_tls_' ~ mode) }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
|
||||||
|
<label class="control-label" for="archive_username">{{ lang.admin.archive_username }}:</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-9 col-lg-5">
|
||||||
|
<input type="text" class="form-control" id="archive_username" name="username" autocomplete="off" value="{{ archive_settings.username }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
|
||||||
|
<label class="control-label" for="archive_password">{{ lang.admin.archive_password }}:</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-9 col-lg-5">
|
||||||
|
<input type="password" class="form-control" id="archive_password" name="password" autocomplete="new-password"
|
||||||
|
placeholder="{% if archive_settings.has_password == 1 %}{{ lang.edit.unchanged_if_empty }}{% endif %}">
|
||||||
|
{% if archive_settings.has_password == 1 %}
|
||||||
|
<div class="form-check mt-1">
|
||||||
|
<input class="form-check-input" type="checkbox" id="archive_password_remove" name="password_remove" value="1">
|
||||||
|
<label class="form-check-label" for="archive_password_remove">{{ lang.admin.archive_password_remove }}</label>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<legend class="fs-5 mt-4"><i class="bi bi-file-earmark-text"></i> {{ lang.admin.archive_section_headers }}</legend><hr>
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
|
||||||
|
<label class="control-label" for="archive_profile">{{ lang.admin.archive_profile }}:</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-9 col-lg-5">
|
||||||
|
<select class="full-width-select form-control" id="archive_profile" name="profile">
|
||||||
|
{% for profile in ['archivecow', 'envelope', 'none'] %}
|
||||||
|
<option value="{{ profile }}"{% if archive_settings.profile == profile %} selected{% endif %}>{{ attribute(lang.admin, 'archive_profile_' ~ profile) }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="offset-md-3 col-12 col-md-9">
|
||||||
|
{% for profile in ['archivecow', 'envelope', 'none'] %}
|
||||||
|
<small class="text-muted archive-profile-info archive-profile-info-{{ profile }}{% if archive_settings.profile != profile %} d-none{% endif %}">{{ attribute(lang.admin, 'archive_profile_' ~ profile ~ '_info') }}</small>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{# Fields of the other profiles are hidden and disabled, so their stored values are kept #}
|
||||||
|
{% set archivecow = archive_settings.profile == 'archivecow' %}
|
||||||
|
{% set envelope = archive_settings.profile == 'envelope' %}
|
||||||
|
<div class="row mb-2 archive-profile archive-profile-archivecow{% if not archivecow %} d-none{% endif %}">
|
||||||
|
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
|
||||||
|
<label class="control-label" for="archive_signing_secret">{{ lang.admin.archive_signing_secret }}:</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-9 col-lg-5">
|
||||||
|
<input type="password" class="form-control" id="archive_signing_secret" name="signing_secret" autocomplete="new-password"
|
||||||
|
placeholder="{% if archive_settings.has_signing_secret == 1 %}{{ lang.edit.unchanged_if_empty }}{% endif %}"{% if not archivecow %} disabled{% endif %}>
|
||||||
|
</div>
|
||||||
|
<div class="offset-md-3 col-12 col-md-9">
|
||||||
|
<small class="text-muted">{{ lang.admin.archive_signing_secret_info }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2 archive-profile archive-profile-envelope{% if not envelope %} d-none{% endif %}">
|
||||||
|
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
|
||||||
|
<label class="control-label" for="archive_header_from">{{ lang.admin.archive_header_from }}:</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-9 col-lg-5">
|
||||||
|
<input type="text" class="form-control" id="archive_header_from" name="header_from" value="{{ archive_settings.header_from }}"{% if not envelope %} disabled{% endif %}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row mb-2 archive-profile archive-profile-envelope{% if not envelope %} d-none{% endif %}">
|
||||||
|
<div class="col-md-3 d-flex align-items-center justify-content-md-end">
|
||||||
|
<label class="control-label" for="archive_header_rcpt">{{ lang.admin.archive_header_rcpt }}:</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-9 col-lg-5">
|
||||||
|
<input type="text" class="form-control" id="archive_header_rcpt" name="header_rcpt" value="{{ archive_settings.header_rcpt }}"{% if not envelope %} disabled{% endif %}>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr class="mt-4">
|
||||||
|
<div class="row mb-2">
|
||||||
|
<div class="offset-md-3 col-12 col-md-9">
|
||||||
|
{% set archive_ready = archive_settings.address and (archive_settings.profile != 'archivecow' or archive_settings.has_signing_secret == 1) %}
|
||||||
|
<button type="button" class="btn btn-sm d-block d-sm-inline btn-success mb-1" data-action="edit_selected" data-item="archive" data-id="archive" data-api-url='edit/archive' data-api-attr='{}'><i class="bi bi-check-lg"></i> {{ lang.admin.save }}</button>
|
||||||
|
<button type="button" class="btn btn-sm d-block d-sm-inline btn-secondary mb-1 archive-test" data-action="edit_selected" data-item="archive" data-id="archive_test" data-api-url='edit/archive' data-api-attr='{"action":"test"}'{% if not archive_ready %} disabled{% endif %}><i class="bi bi-plug"></i> {{ lang.admin.archive_test }}</button>
|
||||||
|
<button type="button" class="btn btn-sm d-block d-sm-inline btn-secondary mb-1 archive-test" data-action="edit_selected" data-item="archive" data-id="archive_test" data-api-url='edit/archive' data-api-attr='{"action":"testmail"}'{% if not archive_ready %} disabled{% endif %}><i class="bi bi-envelope"></i> {{ lang.admin.archive_testmail }}</button>
|
||||||
|
<small class="text-muted d-block" id="archive_test_info">{{ lang.admin.archive_test_info }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -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-archive-logs" aria-selected="false" aria-controls="tab-archive-logs" role="tab" data-bs-toggle="tab">{{ lang.admin.archive }}</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-archive-logs">
|
||||||
|
<div class="debug-log-info">{{ lang.admin.archive_log_info }}</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex align-items-center fs-5">
|
||||||
|
<span class="mt-2 ms-2">{{ lang.admin.archive }}</span>
|
||||||
|
<div class="btn-group ms-auto">
|
||||||
|
<button class="btn btn-sm btn-secondary refresh_table" data-table="archive_log">{{ lang.admin.refresh }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table id="archive_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 -->
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
<input type="hidden" value="0" name="active">
|
<input type="hidden" value="0" name="active">
|
||||||
<input type="hidden" value="0" name="backupmx">
|
<input type="hidden" value="0" name="backupmx">
|
||||||
<input type="hidden" value="0" name="gal">
|
<input type="hidden" value="0" name="gal">
|
||||||
|
<input type="hidden" value="0" name="archive">
|
||||||
<input type="hidden" value="0" name="relay_all_recipients">
|
<input type="hidden" value="0" name="relay_all_recipients">
|
||||||
<input type="hidden" value="0" name="relay_unknown_only">
|
<input type="hidden" value="0" name="relay_unknown_only">
|
||||||
|
|
||||||
@@ -70,6 +71,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row{% if not archive_enabled %} d-none{% endif %}">
|
||||||
|
<div class="offset-sm-2 col-sm-10">
|
||||||
|
<div class="form-check">
|
||||||
|
<label><input type="checkbox" class="form-check-input" value="1" name="archive"{% if template.attributes.archive == '1' %} checked{% endif %}> {{ lang.edit.archive_domain }}</label>
|
||||||
|
<small class="text-muted">{{ lang.edit.archive_domain_info }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="row mb-2">
|
<div class="row mb-2">
|
||||||
<div class="offset-sm-2 col-sm-10">
|
<div class="offset-sm-2 col-sm-10">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
<input type="hidden" value="0" name="active">
|
<input type="hidden" value="0" name="active">
|
||||||
<input type="hidden" value="0" name="backupmx">
|
<input type="hidden" value="0" name="backupmx">
|
||||||
<input type="hidden" value="0" name="gal">
|
<input type="hidden" value="0" name="gal">
|
||||||
|
<input type="hidden" value="0" name="archive">
|
||||||
<input type="hidden" value="0" name="relay_all_recipients">
|
<input type="hidden" value="0" name="relay_all_recipients">
|
||||||
<input type="hidden" value="0" name="relay_unknown_only">
|
<input type="hidden" value="0" name="relay_unknown_only">
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
@@ -133,6 +134,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row{% if not archive_enabled %} d-none{% endif %}" data-acl="{{ acl.archive }}">
|
||||||
|
<div class="offset-sm-2 col-sm-10">
|
||||||
|
<div class="form-check">
|
||||||
|
<label><input type="checkbox" class="form-check-input" value="1" name="archive"{% if result.archive == '1' %} checked{% endif %}{% if acl.archive != 1 %} disabled{% endif %}> {{ lang.edit.archive_domain }}</label>
|
||||||
|
<small class="text-muted">{{ lang.edit.archive_domain_info }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<hr>
|
<hr>
|
||||||
<div class="row mb-2">
|
<div class="row mb-2">
|
||||||
<div class="offset-sm-2 col-sm-10">
|
<div class="offset-sm-2 col-sm-10">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -114,6 +114,17 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row mb-2{% if not archive_enabled %} d-none{% endif %}">
|
||||||
|
<label class="control-label col-sm-2" for="archive">{{ lang.edit.archive }}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<select class="form-control" name="archive" id="archive">
|
||||||
|
<option value="inherit"{% if template.attributes.archive not in ['0', '1'] %} selected{% endif %}>{{ lang.edit.archive_inherit }}</option>
|
||||||
|
<option value="1"{% if template.attributes.archive == '1' %} selected{% endif %}>{{ lang.edit.archive_on }}</option>
|
||||||
|
<option value="0"{% if template.attributes.archive == '0' %} selected{% endif %}>{{ lang.edit.archive_off }}</option>
|
||||||
|
</select>
|
||||||
|
<small class="text-muted d-block">{{ lang.edit.archive_info }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<label class="control-label col-sm-2">ACL</label>
|
<label class="control-label col-sm-2">ACL</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
|
|||||||
@@ -331,6 +331,19 @@
|
|||||||
<small class="text-muted">{{ lang.edit.allow_from_smtp_info }}</small>
|
<small class="text-muted">{{ lang.edit.allow_from_smtp_info }}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row mt-2{% if not archive_enabled %} d-none{% endif %}" data-acl="{{ acl.archive }}">
|
||||||
|
<label class="control-label col-sm-2" for="archive">{{ lang.edit.archive }}
|
||||||
|
<br><span class="badge {% if result.archive == 1 %}bg-success{% else %}bg-secondary{% endif %}">{% if result.archive == 1 %}{{ lang.edit.archive_active }}{% else %}{{ lang.edit.archive_inactive }}{% endif %}</span>
|
||||||
|
</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<select class="form-control" name="archive" id="archive"{% if acl.archive != 1 %} disabled{% endif %}>
|
||||||
|
<option value="inherit"{% if result.attributes.archive not in ['0', '1'] %} selected{% endif %}>{{ lang.edit.archive_inherit }}</option>
|
||||||
|
<option value="1"{% if result.attributes.archive == '1' %} selected{% endif %}>{{ lang.edit.archive_on }}</option>
|
||||||
|
<option value="0"{% if result.attributes.archive == '0' %} selected{% endif %}>{{ lang.edit.archive_off }}</option>
|
||||||
|
</select>
|
||||||
|
<small class="text-muted d-block">{{ lang.edit.archive_info }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<hr>
|
<hr>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="offset-sm-2 col-sm-10">
|
<div class="offset-sm-2 col-sm-10">
|
||||||
|
|||||||
@@ -81,6 +81,7 @@
|
|||||||
var csrf_token = '{{ csrf_token }}';
|
var csrf_token = '{{ csrf_token }}';
|
||||||
var pagination_size = Math.trunc('{{ pagination_size }}');
|
var pagination_size = Math.trunc('{{ pagination_size }}');
|
||||||
var role = '{{ role }}';
|
var role = '{{ role }}';
|
||||||
|
var archive_enabled = {{ archive_enabled ? 1 : 0 }};
|
||||||
var is_dual = {{ is_dual }};
|
var is_dual = {{ is_dual }};
|
||||||
var ALLOW_ADMIN_EMAIL_LOGIN = {{ allow_admin_email_login }};
|
var ALLOW_ADMIN_EMAIL_LOGIN = {{ allow_admin_email_login }};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<label class="control-label col-sm-2 text-sm-end text-sm-end" for="description">{{ lang.mailbox.template }}</label>
|
<label class="control-label col-sm-2 text-sm-end text-sm-end" for="description">{{ lang.mailbox.template }}</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
<select data-live-search="true" id="mailbox_templates" class="form-control" title="{{ lang.mailbox.template }}">
|
<select data-live-search="true" id="mailbox_templates" name="template" class="form-control" title="{{ lang.mailbox.template }}">
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,6 +154,17 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row mb-2{% if not archive_enabled %} d-none{% endif %}" data-acl="{{ acl.archive }}">
|
||||||
|
<label class="control-label col-sm-2 text-sm-end" for="addMailbox_archive">{{ lang.edit.archive }}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<select class="form-control" name="archive" id="addMailbox_archive"{% if acl.archive != 1 %} disabled{% endif %}>
|
||||||
|
<option value="inherit" selected>{{ lang.edit.archive_inherit }}</option>
|
||||||
|
<option value="1">{{ lang.edit.archive_on }}</option>
|
||||||
|
<option value="0">{{ lang.edit.archive_off }}</option>
|
||||||
|
</select>
|
||||||
|
<small class="text-muted d-block">{{ lang.edit.archive_info }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<label class="control-label col-sm-2 text-sm-end text-sm-end">ACL</label>
|
<label class="control-label col-sm-2 text-sm-end text-sm-end">ACL</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
@@ -360,6 +371,17 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row mb-2{% if not archive_enabled %} d-none{% endif %}">
|
||||||
|
<label class="control-label col-sm-2 text-sm-end" for="addMailboxTemplate_archive">{{ lang.edit.archive }}</label>
|
||||||
|
<div class="col-sm-10">
|
||||||
|
<select class="form-control" name="archive" id="addMailboxTemplate_archive">
|
||||||
|
<option value="inherit" selected>{{ lang.edit.archive_inherit }}</option>
|
||||||
|
<option value="1">{{ lang.edit.archive_on }}</option>
|
||||||
|
<option value="0">{{ lang.edit.archive_off }}</option>
|
||||||
|
</select>
|
||||||
|
<small class="text-muted d-block">{{ lang.edit.archive_info }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<label class="control-label col-sm-2 text-sm-end text-sm-end">ACL</label>
|
<label class="control-label col-sm-2 text-sm-end text-sm-end">ACL</label>
|
||||||
<div class="col-sm-10">
|
<div class="col-sm-10">
|
||||||
@@ -451,6 +473,7 @@
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<form class="form-horizontal" data-cached-form="true" data-id="add_domain" role="form">
|
<form class="form-horizontal" data-cached-form="true" data-id="add_domain" role="form">
|
||||||
<input type="hidden" value="0" name="gal">
|
<input type="hidden" value="0" name="gal">
|
||||||
|
<input type="hidden" value="0" name="archive">
|
||||||
<input type="hidden" value="0" name="active">
|
<input type="hidden" value="0" name="active">
|
||||||
<input type="hidden" value="0" name="backupmx">
|
<input type="hidden" value="0" name="backupmx">
|
||||||
<input type="hidden" value="0" name="relay_all_recipients">
|
<input type="hidden" value="0" name="relay_all_recipients">
|
||||||
@@ -525,6 +548,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div class="row mb-2{% if not archive_enabled %} d-none{% endif %}" data-acl="{{ acl.archive }}">
|
||||||
|
<div class="offset-sm-2 col-sm-10">
|
||||||
|
<div class="form-check">
|
||||||
|
<label><input type="checkbox" class="form-check-input" value="1" name="archive" id="addDomain_archive"> {{ lang.edit.archive_domain }}</label>
|
||||||
|
<small class="text-muted">{{ lang.edit.archive_domain_info }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="offset-sm-2 col-sm-10">
|
<div class="offset-sm-2 col-sm-10">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
@@ -664,6 +695,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row{% if not archive_enabled %} d-none{% endif %}">
|
||||||
|
<div class="offset-sm-2 col-sm-10">
|
||||||
|
<div class="form-check">
|
||||||
|
<label><input type="checkbox" class="form-check-input" value="1" name="archive"> {{ lang.edit.archive_domain }}</label>
|
||||||
|
<small class="text-muted">{{ lang.edit.archive_domain_info }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="row mb-2">
|
<div class="row mb-2">
|
||||||
<div class="offset-sm-2 col-sm-10">
|
<div class="offset-sm-2 col-sm-10">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
+15
-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=06112026_1800
|
||||||
|
|
||||||
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-4
|
||||||
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}
|
||||||
@@ -347,7 +348,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 +375,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 +429,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 +449,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 +517,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 +537,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 +555,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 +613,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):
|
||||||
|
|||||||
+103
-17
@@ -10,14 +10,35 @@ class RegexesTest(unittest.TestCase):
|
|||||||
LINES = {
|
LINES = {
|
||||||
'1': ('mailcow UI: Invalid password for admin by 203.0.113.5', '203.0.113.5'),
|
'1': ('mailcow UI: Invalid password for admin by 203.0.113.5', '203.0.113.5'),
|
||||||
'2': ('Rspamd UI: Invalid password by 2001:db8::5', '2001:db8::5'),
|
'2': ('Rspamd UI: Invalid password by 2001:db8::5', '2001:db8::5'),
|
||||||
'3': ('warning: unknown[203.0.113.6]: SASL LOGIN authentication failed: UGFzc3dvcmQ6', '203.0.113.6'),
|
'3': ('MAILCOWAUTH: Login failed for user user@example.org with service smtp from IP 203.0.113.6', '203.0.113.6'),
|
||||||
'4': ('warning: non-SMTP command from unknown[203.0.113.7]: GET / HTTP/1.1', '203.0.113.7'),
|
'4': ('warning: non-SMTP command from unknown[203.0.113.7]: GET _ HTTP_1.1', '203.0.113.7'),
|
||||||
'5': ('NOQUEUE: reject: RCPT from [203.0.113.8]: 503 5.5.0 <a@example.org>: Protocol error; proto=ESMTP', '203.0.113.8'),
|
'5': ('NOQUEUE: reject: RCPT from [203.0.113.8]:52516: 550 5.5.1 Protocol error; from=<a@example.org> to=<b@example.org>',
|
||||||
'6': ('auth: lua(user@example.org,203.0.113.9,<abcdef>): Password mismatch (SHA1 of given password: 0123ab)', '203.0.113.9'),
|
'203.0.113.8'),
|
||||||
'7': ('auth: lua(nobody@example.org,203.0.113.10,<abcdef>): unknown user (SHA1 of given password: 0123ab)', '203.0.113.10'),
|
'6': ("[6714]: SOGoRootPage Login from '203.0.113.11' for user 'nobody@example.org' might not have worked - "
|
||||||
'8': ("SOGo[123]: Login from '203.0.113.11' for user nobody might not have worked", '203.0.113.11'),
|
"password policy: 65535 grace: -1 expire: -1 bound: 0", '203.0.113.11'),
|
||||||
'9': ('203.0.113.12 "GET /SOGo/so/ HTTP/1.1" 403 123 "-"', '203.0.113.12'),
|
'7': ('203.0.113.12 "GET /SOGo/so/ HTTP/1.1" 403 123 "-"', '203.0.113.12'),
|
||||||
|
'8': ('auth: passwd-file(master@mailcow.local,203.0.113.13,master,<qIIX9EZfYISsFgEB>): '
|
||||||
|
'Password mismatch (SHA1 of given password: 05d839)', '203.0.113.13'),
|
||||||
}
|
}
|
||||||
|
# Defaults of 2025-02 to 2026-10 and of 2022-11 to 2025-02
|
||||||
|
FORMER = {
|
||||||
|
'1': r'mailcow UI: Invalid password for .+ 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).+',
|
||||||
|
'4': r'warning: non-SMTP command from .*\[([0-9a-f\.:]+)]:.+',
|
||||||
|
'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]+\)',
|
||||||
|
'7': r'\w+\([^,]+,([0-9a-f\.:]+),<[^>]+>\): unknown user \(SHA1 of given password: [a-f0-9]+\)',
|
||||||
|
'8': r'SOGo.+ Login from \'([0-9a-f\.:]+)\' for user .+ might not have worked',
|
||||||
|
'9': r'([0-9a-f\.:]+) \"GET \/SOGo\/.* HTTP.+\" 403 .+',
|
||||||
|
}
|
||||||
|
OLDER = dict(FORMER, **{
|
||||||
|
'6': r'-login: Disconnected.+ \(auth failed, .+\): user=.*, method=.+, rip=([0-9a-f\.:]+),',
|
||||||
|
'7': r'-login: Aborted login.+ \(auth failed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+',
|
||||||
|
'8': r'-login: Aborted login.+ \(tried to use disallowed .+\): user=.+, rip=([0-9a-f\.:]+), lip.+',
|
||||||
|
'9': r'SOGo.+ Login from \'([0-9a-f\.:]+)\' for user .+ might not have worked',
|
||||||
|
'10': r'([0-9a-f\.:]+) \"GET \/SOGo\/.* HTTP.+\" 403 .+',
|
||||||
|
})
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
self.r = FakeRedis()
|
self.r = FakeRedis()
|
||||||
@@ -30,10 +51,82 @@ class RegexesTest(unittest.TestCase):
|
|||||||
for rule_id, (line, address) in self.LINES.items():
|
for rule_id, (line, address) in self.LINES.items():
|
||||||
self.assertEqual(self.regexes.match(line), (rule_id, address), line)
|
self.assertEqual(self.regexes.match(line), (rule_id, address), line)
|
||||||
|
|
||||||
def test_lost_connection_to_auth_server_is_no_attempt(self):
|
def test_postfix_and_dovecot_lines_are_no_attempts(self):
|
||||||
|
# mailcowauth.php reports the failed login once, also when Postfix asked Dovecot
|
||||||
self.regexes.refresh(self.r)
|
self.regexes.refresh(self.r)
|
||||||
line = 'warning: unknown[203.0.113.6]: SASL LOGIN authentication failed: Connection lost to authentication server'
|
for line in ('warning: unknown[203.0.113.6]: SASL LOGIN authentication failed: (reason unavailable), sasl_username=a@example.org',
|
||||||
self.assertIsNone(self.regexes.match(line))
|
'auth-worker(37358): conn unix:auth-worker (pid=577,uid=401): auth-worker<25>: '
|
||||||
|
'lua(a@example.org,203.0.113.6,<j4rbQeReAqwqAPggBBcAAAAFAAEAdgES>): Password mismatch (SHA1 of given password: 6426a0)',
|
||||||
|
'imap-login: Disconnected: Connection closed (auth failed, 1 attempts in 2 secs): user=<a@example.org>, '
|
||||||
|
'method=PLAIN, rip=203.0.113.6, lip=172.22.1.250'):
|
||||||
|
self.assertIsNone(self.regexes.match(line), line)
|
||||||
|
|
||||||
|
def test_failed_master_logins(self):
|
||||||
|
# Dovecot checks the master passdb itself, mailcowauth.php is not asked
|
||||||
|
self.regexes.refresh(self.r)
|
||||||
|
self.assertEqual(self.regexes.match('auth: passwd-file(x@mailcow.local,2001:db8::13,master,<dfbigUdfZJysFgEB>): '
|
||||||
|
'unknown user (SHA1 of given password: 6d0653)'), ('8', '2001:db8::13'))
|
||||||
|
# no session of Postfix
|
||||||
|
self.assertEqual(self.regexes.match('auth: passwd-file(x@mailcow.local,203.0.113.13,master): '
|
||||||
|
'Password mismatch (SHA1 of given password: 6d0653)'), ('8', '203.0.113.13'))
|
||||||
|
# without the client address (doveadm) or for the user of a master login
|
||||||
|
self.assertIsNone(self.regexes.match('auth: passwd-file(x@mailcow.local,master,<dfbigUdfZJysFgEB>): Password mismatch'))
|
||||||
|
self.assertIsNone(self.regexes.match('auth: lua(a@example.org,203.0.113.13,master,<dfbigUdfZJysFgEB>): unknown user'))
|
||||||
|
|
||||||
|
def test_text_of_clients_does_not_match(self):
|
||||||
|
# Login names, SMTP commands and addresses of other lines must not ban a foreign address
|
||||||
|
self.regexes.refresh(self.r)
|
||||||
|
self.assertEqual(self.regexes.match('warning: non-SMTP command from unknown[198.51.100.7]: '
|
||||||
|
'mailcow UI: Invalid password for x by 203.0.113.1'), ('4', '198.51.100.7'))
|
||||||
|
self.assertEqual(self.regexes.match('warning: non-SMTP command from unknown[198.51.100.7]: x [203.0.113.1]: y'),
|
||||||
|
('4', '198.51.100.7'))
|
||||||
|
self.assertIsNone(self.regexes.match('NOQUEUE: reject: RCPT from unknown[198.51.100.7]: 554 5.7.1 Relay access denied; '
|
||||||
|
'from=<NOQUEUE: reject: RCPT from [203.0.113.1] Protocol error x>'))
|
||||||
|
self.assertIsNone(self.regexes.match('improper command pipelining after EHLO from unknown[198.51.100.7]: '
|
||||||
|
'MAILCOWAUTH: Login failed for user x with service imap from IP 203.0.113.1'))
|
||||||
|
self.assertEqual(self.regexes.match('MAILCOWAUTH: Login failed for user x with service imap from IP 203.0.113.1 '
|
||||||
|
'with service smtp from IP 198.51.100.7'),
|
||||||
|
('3', '198.51.100.7'))
|
||||||
|
self.assertEqual(self.regexes.match("[1]: SOGoRootPage Login from '198.51.100.7' for user 'x' might not have worked "
|
||||||
|
"SOGoRootPage Login from '203.0.113.1' for user y might not have worked"), ('6', '198.51.100.7'))
|
||||||
|
# nginx appends the client to the X-Forwarded-For header a client sent
|
||||||
|
self.assertEqual(self.regexes.match("[1]: SOGoRootPage Login from '203.0.113.1, 198.51.100.7' for user 'x' might not have worked"),
|
||||||
|
('6', '198.51.100.7'))
|
||||||
|
# the address Dovecot adds follows the login name
|
||||||
|
self.assertEqual(self.regexes.match('auth: passwd-file(x,203.0.113.1,master,<a>): Password mismatch,198.51.100.7,master,<b>): '
|
||||||
|
'Password mismatch (SHA1 of given password: 6d0653)'), ('8', '198.51.100.7'))
|
||||||
|
# lines of the web UI are not sanitized like syslog lines
|
||||||
|
self.assertEqual(self.regexes.match('mailcow UI: Invalid password for admin\n by 203.0.113.5'), ('1', '203.0.113.5'))
|
||||||
|
|
||||||
|
def test_former_defaults_are_replaced(self):
|
||||||
|
self.r.set('F2B_REGEX', json.dumps(self.FORMER))
|
||||||
|
self.regexes.refresh(self.r)
|
||||||
|
defaults = {str(rule_id): regex for rule_id, regex in Regexes.DEFAULTS.items()}
|
||||||
|
# the rule for failed Dovecot logins counts failed master logins now
|
||||||
|
expected = dict(defaults, **{'6': defaults['8'], '8': defaults['6'], '9': defaults['7']})
|
||||||
|
del expected['7']
|
||||||
|
self.assertEqual(json.loads(self.r.get('F2B_REGEX')), expected)
|
||||||
|
self.assertEqual(self.regexes.match(self.LINES['3'][0]), ('3', '203.0.113.6'))
|
||||||
|
self.assertEqual(self.regexes.match(self.LINES['6'][0]), ('8', '203.0.113.11'))
|
||||||
|
self.assertEqual(self.regexes.match(self.LINES['8'][0]), ('6', '203.0.113.13'))
|
||||||
|
|
||||||
|
def test_older_defaults_are_replaced(self):
|
||||||
|
self.r.set('F2B_REGEX', json.dumps(self.OLDER))
|
||||||
|
self.regexes.refresh(self.r)
|
||||||
|
stored = json.loads(self.r.get('F2B_REGEX'))
|
||||||
|
self.assertEqual(sorted(stored, key=int), ['1', '2', '3', '4', '5', '9', '10'])
|
||||||
|
self.assertEqual(stored['9'], Regexes.DEFAULTS[6])
|
||||||
|
self.assertEqual(stored['10'], self.OLDER['10'])
|
||||||
|
|
||||||
|
def test_changed_rules_are_kept(self):
|
||||||
|
custom = {'1': r'mailcow UI: Invalid password for (?:x|y) by ([0-9a-f\.:]+)', '2': self.FORMER['3'], '3': r'custom ([0-9\.]+)'}
|
||||||
|
self.r.set('F2B_REGEX', json.dumps(custom))
|
||||||
|
self.regexes.refresh(self.r)
|
||||||
|
self.assertEqual(json.loads(self.r.get('F2B_REGEX')), dict(custom, **{'2': Regexes.DEFAULTS[3]}))
|
||||||
|
# current rules are not written again
|
||||||
|
raw = self.r.get('F2B_REGEX')
|
||||||
|
self.regexes.refresh(self.r)
|
||||||
|
self.assertEqual(self.r.get('F2B_REGEX'), raw)
|
||||||
|
|
||||||
def test_only_the_first_matching_rule_counts(self):
|
def test_only_the_first_matching_rule_counts(self):
|
||||||
self.r.set('F2B_REGEX', json.dumps({'2': r'by ([0-9a-f\.:]+)', '10': r'from ([0-9a-f\.:]+)', '1': r'login from ([0-9a-f\.:]+)'}))
|
self.r.set('F2B_REGEX', json.dumps({'2': r'by ([0-9a-f\.:]+)', '10': r'from ([0-9a-f\.:]+)', '1': r'login from ([0-9a-f\.:]+)'}))
|
||||||
@@ -58,13 +151,6 @@ class RegexesTest(unittest.TestCase):
|
|||||||
self.regexes.refresh(self.r)
|
self.regexes.refresh(self.r)
|
||||||
self.assertIsNone(self.regexes.match('failed by nobody'))
|
self.assertIsNone(self.regexes.match('failed by nobody'))
|
||||||
|
|
||||||
def test_temporary_failure(self):
|
|
||||||
worker = 'auth-worker(3933): Error: conn unix:auth-worker (pid=3932,uid=401): auth-worker<4>: '
|
|
||||||
self.assertEqual(Regexes.temporary_failure(worker + 'lua(a@example.org,203.0.113.1,<GGexLdBezoasFgEB>): passdb-lua: Upstream unavailable'), '203.0.113.1')
|
|
||||||
self.assertEqual(Regexes.temporary_failure(worker + 'lua(a@example.org,2001:db8::1): passdb-lua: Upstream unreachable'), '2001:db8::1')
|
|
||||||
self.assertIsNone(Regexes.temporary_failure('auth: lua(a@example.org,203.0.113.1,<GGexLdBezoasFgEB>): unknown user (SHA1 of given password: 0123ab)'))
|
|
||||||
self.assertIsNone(Regexes.temporary_failure('warning: unknown[203.0.113.1]: SASL LOGIN authentication failed: (reason unavailable)'))
|
|
||||||
|
|
||||||
def test_parse_ip(self):
|
def test_parse_ip(self):
|
||||||
self.assertEqual(Regexes.parse_ip('203.0.113.1'), ipaddress.ip_address('203.0.113.1'))
|
self.assertEqual(Regexes.parse_ip('203.0.113.1'), ipaddress.ip_address('203.0.113.1'))
|
||||||
self.assertEqual(Regexes.parse_ip('203.0.113.1:'), ipaddress.ip_address('203.0.113.1'))
|
self.assertEqual(Regexes.parse_ip('203.0.113.1:'), ipaddress.ip_address('203.0.113.1'))
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\Tests\Archive\Controller;
|
||||||
|
|
||||||
|
use Mailcow\Archive\Controller\ArchiveApiController;
|
||||||
|
use Mailcow\Archive\Model\Settings;
|
||||||
|
use Mailcow\Archive\Service\JournalService;
|
||||||
|
use Mailcow\Archive\Service\MailboxListService;
|
||||||
|
use Mailcow\Archive\Service\SettingsService;
|
||||||
|
use Mailcow\Archive\Service\StatusService;
|
||||||
|
use Mailcow\Core\AccessDenied;
|
||||||
|
use Mailcow\Core\ApiResponse;
|
||||||
|
use Mailcow\Core\UserError;
|
||||||
|
use PDOException;
|
||||||
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class ArchiveApiControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
private SettingsService&MockObject $settings;
|
||||||
|
private StatusService&MockObject $status;
|
||||||
|
private JournalService&MockObject $journal;
|
||||||
|
private MailboxListService&MockObject $mailboxes;
|
||||||
|
private ArchiveApiController $api;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$_SESSION = [];
|
||||||
|
$this->settings = $this->createMock(SettingsService::class);
|
||||||
|
$this->status = $this->createMock(StatusService::class);
|
||||||
|
$this->journal = $this->createMock(JournalService::class);
|
||||||
|
$this->mailboxes = $this->createMock(MailboxListService::class);
|
||||||
|
$this->api = new ArchiveApiController($this->settings, $this->status, $this->journal, $this->mailboxes,
|
||||||
|
new ApiResponse(['password', 'signing_secret']));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Messages written to the session as [type, msg] */
|
||||||
|
private function messages(): array
|
||||||
|
{
|
||||||
|
return array_map(fn(array $m): array => [$m['type'], $m['msg']], $_SESSION['return'] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSaveLogsWithoutSecrets(): void
|
||||||
|
{
|
||||||
|
$this->settings->expects($this->once())->method('save')->willReturn(new Settings());
|
||||||
|
|
||||||
|
$this->assertTrue($this->api->edit(['enabled' => '1', 'username' => 'mailcow', 'password' => 'pw', 'signing_secret' => 'key']));
|
||||||
|
|
||||||
|
$this->assertSame([[
|
||||||
|
'type' => 'success',
|
||||||
|
'log' => ['archive', 'edit', 'settings', ['enabled' => '1', 'username' => 'mailcow', 'password' => '*', 'signing_secret' => '*']],
|
||||||
|
'msg' => 'archive_settings_saved',
|
||||||
|
]], $_SESSION['return']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testActionsAndErrors(): void
|
||||||
|
{
|
||||||
|
$this->settings->method('test')->willReturnOnConsecutiveCalls(
|
||||||
|
['archive_test_ok_missing', '8BITMIME'],
|
||||||
|
$this->throwException(new UserError('archive_test_failed', ['archive.example:25 connect: Connection refused'])),
|
||||||
|
);
|
||||||
|
$this->journal->method('testMail')->willReturn('4XyZ<1>');
|
||||||
|
$this->settings->method('save')->willThrowException(new PDOException('gone away'));
|
||||||
|
|
||||||
|
$this->assertTrue($this->api->edit(['action' => 'test']));
|
||||||
|
$this->assertFalse($this->api->edit(['action' => 'test']));
|
||||||
|
$this->assertTrue($this->api->edit(['action' => 'testmail']));
|
||||||
|
$this->assertFalse($this->api->edit(['address' => 'journal@archive.example']));
|
||||||
|
|
||||||
|
$this->assertSame([
|
||||||
|
['warning', ['archive_test_ok_missing', '8BITMIME']],
|
||||||
|
['danger', ['archive_test_failed', 'archive.example:25 connect: Connection refused']],
|
||||||
|
['success', ['archive_testmail_queued', '4XyZ<1>']],
|
||||||
|
['danger', ['mysql_error', 'gone away']],
|
||||||
|
], $this->messages());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testActionsNeedWriteAccess(): void
|
||||||
|
{
|
||||||
|
$this->journal->method('testMail')->willThrowException(new AccessDenied());
|
||||||
|
$this->settings->expects($this->never())->method('save');
|
||||||
|
|
||||||
|
$this->assertFalse($this->api->edit(['action' => 'testmail']));
|
||||||
|
$this->assertSame([['danger', 'access_denied']], $this->messages());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReadsAreEmptyWhenDenied(): void
|
||||||
|
{
|
||||||
|
$this->settings->method('get')->willThrowException(new AccessDenied());
|
||||||
|
$this->status->method('status')->willThrowException(new AccessDenied());
|
||||||
|
$this->status->method('log')->willThrowException(new AccessDenied());
|
||||||
|
$this->mailboxes->method('archived')->willReturn(['enabled' => 1, 'mailboxes' => []]);
|
||||||
|
|
||||||
|
$this->assertSame([], $this->api->getSettings());
|
||||||
|
$this->assertSame([], $this->api->getStatus());
|
||||||
|
$this->assertSame([], $this->api->getLog());
|
||||||
|
$this->assertSame(['enabled' => 1, 'mailboxes' => []], $this->api->getMailboxes());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\Tests\Archive\Model;
|
||||||
|
|
||||||
|
use Mailcow\Archive\Model\Mailbox;
|
||||||
|
use Mailcow\Archive\Service\MailboxListService;
|
||||||
|
use Mailcow\Archive\Repository\SettingsRepository;
|
||||||
|
use Mailcow\Archive\Model\Settings;
|
||||||
|
use Mailcow\Core\Context;
|
||||||
|
use Mailcow\Core\UserError;
|
||||||
|
use Mailcow\Tests\Support\FakeArchiveLookup;
|
||||||
|
use Mailcow\Tests\Support\FakeContext;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class MailboxTest extends TestCase
|
||||||
|
{
|
||||||
|
public static function settings(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'inherit, domain on' => ['inherit', 1, true],
|
||||||
|
'inherit, domain off' => ['inherit', 0, false],
|
||||||
|
'missing attribute' => [null, 1, true],
|
||||||
|
'unknown value' => ['yes', 0, false],
|
||||||
|
'on' => ['1', 0, true],
|
||||||
|
'on as number' => [1, 0, true],
|
||||||
|
'off' => ['0', 1, false],
|
||||||
|
'off as number' => [0, 1, false],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('settings')]
|
||||||
|
public function testEffective(mixed $archive, int $domainArchive, bool $archived): void
|
||||||
|
{
|
||||||
|
$this->assertSame($archived, Mailbox::effective($archive, $domainArchive));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testListOfArchivedMailboxes(): void
|
||||||
|
{
|
||||||
|
$lookup = new FakeArchiveLookup();
|
||||||
|
$lookup->mailboxes = ['b@firma.de' => ['inherit', 1], 'a@other.de' => ['1', 0], 'c@firma.de' => ['0', 1]];
|
||||||
|
$settings = new Settings();
|
||||||
|
$repo = $this->createMock(SettingsRepository::class);
|
||||||
|
$repo->method('load')->willReturnCallback(fn(): Settings => $settings);
|
||||||
|
$readOnly = new Context('admin', 'API', [], false, 'ro');
|
||||||
|
|
||||||
|
$this->assertSame(['enabled' => 0, 'mailboxes' => []], (new MailboxListService($lookup, $repo, $readOnly))->archived());
|
||||||
|
|
||||||
|
$settings->enabled = true;
|
||||||
|
$this->assertSame(['enabled' => 1, 'mailboxes' => [
|
||||||
|
['username' => 'b@firma.de', 'domain' => 'firma.de', 'name' => '', 'active' => 1, 'source' => 'domain'],
|
||||||
|
['username' => 'a@other.de', 'domain' => 'other.de', 'name' => '', 'active' => 1, 'source' => 'mailbox'],
|
||||||
|
]], (new MailboxListService($lookup, $repo, $readOnly))->archived());
|
||||||
|
|
||||||
|
$this->expectException(UserError::class);
|
||||||
|
(new MailboxListService($lookup, $repo, FakeContext::domainAdmin()))->archived();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\Tests\Archive\Model;
|
||||||
|
|
||||||
|
use Mailcow\Archive\Model\Settings;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class SettingsTest extends TestCase
|
||||||
|
{
|
||||||
|
public static function nexthops(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'mx of the address domain' => ['', 25, 'archive.example'],
|
||||||
|
'mx, other port' => ['', 2525, 'archive.example:2525'],
|
||||||
|
'host' => ['smtp.archive.example', 25, '[smtp.archive.example]:25'],
|
||||||
|
'ipv4' => ['192.0.2.5', 587, '[192.0.2.5]:587'],
|
||||||
|
'ipv6' => ['2001:db8::25', 25, '[ipv6:2001:db8::25]:25'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('nexthops')]
|
||||||
|
public function testNexthop(string $host, int $port, string $nexthop): void
|
||||||
|
{
|
||||||
|
$settings = new Settings();
|
||||||
|
$settings->address = 'journal@archive.example';
|
||||||
|
$settings->host = $host;
|
||||||
|
$settings->port = $port;
|
||||||
|
|
||||||
|
$this->assertSame($nexthop, $settings->nexthop());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCanDeliverAndNoSecretsInTheApi(): void
|
||||||
|
{
|
||||||
|
$settings = new Settings();
|
||||||
|
$this->assertFalse($settings->canDeliver());
|
||||||
|
$settings->address = 'journal@archive.example';
|
||||||
|
$this->assertFalse($settings->canDeliver());
|
||||||
|
$settings->profile = 'envelope';
|
||||||
|
$this->assertTrue($settings->canDeliver());
|
||||||
|
|
||||||
|
$settings->password = 'password-value';
|
||||||
|
$settings->signingKey = 'signing-key-value';
|
||||||
|
$api = json_encode($settings->toArray());
|
||||||
|
$this->assertStringNotContainsString('password-value', $api);
|
||||||
|
$this->assertStringNotContainsString('signing-key-value', $api);
|
||||||
|
$this->assertSame([1, 1], [$settings->toArray()['has_password'], $settings->toArray()['has_signing_secret']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\Tests\Archive\Redis;
|
||||||
|
|
||||||
|
use Mailcow\Archive\Redis\ArchiveState;
|
||||||
|
use Mailcow\Tests\Support\FakeRedis;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class ArchiveStateTest extends TestCase
|
||||||
|
{
|
||||||
|
private FakeRedis $redis;
|
||||||
|
private ArchiveState $state;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->redis = new FakeRedis();
|
||||||
|
$this->state = new ArchiveState($this->redis);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A Postfix log entry as syslog-ng pushes it (newest first) */
|
||||||
|
private function log(string $program, string $message, int $time): void
|
||||||
|
{
|
||||||
|
$this->redis->lPush('POSTFIX_MAILLOG', json_encode(['time' => $time, 'program' => $program, 'message' => $message]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSwitchIsMirroredForRspamd(): void
|
||||||
|
{
|
||||||
|
$this->state->setEnabled(true);
|
||||||
|
$this->assertSame('1', $this->redis->strings['ARCHIVE_ENABLED']);
|
||||||
|
$this->state->setEnabled(false);
|
||||||
|
$this->assertSame('0', $this->redis->strings['ARCHIVE_ENABLED']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDroppedCopiesAreCountedAndTheLast100Listed(): void
|
||||||
|
{
|
||||||
|
for ($i = 1; $i <= 105; $i++) {
|
||||||
|
$this->state->dropped('Q' . $i, 'owner lookup failed', 'm' . $i . '@firma.de');
|
||||||
|
}
|
||||||
|
|
||||||
|
$stats = $this->state->droppedStats(2);
|
||||||
|
$this->assertSame(105, $stats['count']);
|
||||||
|
$this->assertSame(['Q105', 'Q104'], array_column($stats['recent'], 'qid'));
|
||||||
|
$this->assertSame(['owner lookup failed', 'm105@firma.de'], [$stats['recent'][0]['reason'], $stats['recent'][0]['message_id']]);
|
||||||
|
$this->assertCount(100, $this->redis->lists['ARCHIVE_DROPPED_LOG']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEventsOfTheArchiveServiceOnly(): void
|
||||||
|
{
|
||||||
|
$this->log('postfix/cleanup', '1A2B: message-id=<orig@extern.example>', 1700000000);
|
||||||
|
$this->log('postfix/archive/smtp', '1A2B: to=<journal@archive.example>, relay=archive.example[192.0.2.5]:25, delay=0.2, dsn=2.0.0, '
|
||||||
|
. 'status=sent (250 2.0.0 Ok: queued as X1)', 1700000001);
|
||||||
|
$this->log('postfix/smtp', '3C4D: to=<someone@extern.example>, relay=mx.extern.example[192.0.2.9]:25, dsn=2.0.0, status=sent (250 ok)', 1700000002);
|
||||||
|
// notices about copies are dropped by archive_discard, they are no deliveries
|
||||||
|
$this->log('postfix/archive/discard', '5E6F: to=<archive-bounce@localhost>, relay=none, dsn=2.0.0, status=sent (discards mail)', 1700000003);
|
||||||
|
$this->log('postfix/archive/smtp', 'connect to archive.example[192.0.2.5]:25: Connection refused', 1700000004);
|
||||||
|
$this->log('postfix/archive/smtp', '7A8B: to=<journal@archive.example>, relay=archive.example[192.0.2.5]:25, dsn=5.1.1, '
|
||||||
|
. 'status=SOFTBOUNCE (host archive.example[192.0.2.5] said: 550 5.1.1 unknown (in reply to RCPT TO command))', 1700000005);
|
||||||
|
$this->redis->lPush('POSTFIX_MAILLOG', 'no json');
|
||||||
|
|
||||||
|
$this->assertSame([
|
||||||
|
['time' => 1700000005, 'status' => 'softbounce', 'qid' => '7A8B', 'message_id' => '',
|
||||||
|
'text' => 'host archive.example[192.0.2.5] said: 550 5.1.1 unknown (in reply to RCPT TO command)'],
|
||||||
|
['time' => 1700000001, 'status' => 'sent', 'qid' => '1A2B', 'message_id' => 'orig@extern.example', 'text' => '250 2.0.0 Ok: queued as X1'],
|
||||||
|
], $this->state->events());
|
||||||
|
$this->assertCount(1, $this->state->events(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWithoutRedisNothingHappens(): void
|
||||||
|
{
|
||||||
|
$state = new ArchiveState(null);
|
||||||
|
$state->setEnabled(true);
|
||||||
|
$state->dropped('Q1', 'reason');
|
||||||
|
|
||||||
|
$this->assertSame(['count' => 0, 'recent' => []], $state->droppedStats());
|
||||||
|
$this->assertSame([], $state->events());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Mailcow\Tests\Archive\Service;
|
||||||
|
|
||||||
|
use Mailcow\Archive\Model\Journal;
|
||||||
|
use Mailcow\Archive\Model\Settings;
|
||||||
|
use Mailcow\Archive\Service\HeaderBuilder;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class HeaderBuilderTest extends TestCase
|
||||||
|
{
|
||||||
|
private static function settings(string $profile): Settings
|
||||||
|
{
|
||||||
|
$settings = new Settings();
|
||||||
|
$settings->profile = $profile;
|
||||||
|
$settings->signingKey = 'key';
|
||||||
|
return $settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function journal(array $rcpts = ['anna@firma.de', 'bcc@extern.example'], string $from = 'x@extern.example'): Journal
|
||||||
|
{
|
||||||
|
return new Journal(['anna@firma.de'], $rcpts, $from, '4XyZ1', 'internal', 'smtp', 1700000000.25, str_repeat('a', 64));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testArchivecowHeadersAndSignature(): void
|
||||||
|
{
|
||||||
|
$block = (new HeaderBuilder())->build(self::settings('archivecow'), self::journal());
|
||||||
|
|
||||||
|
$this->assertSame("X-Archive-Owner: anna@firma.de\r\nX-Archive-Rcpt: anna@firma.de,bcc@extern.example\r\n"
|
||||||
|
. "X-Archive-From: x@extern.example\r\nX-Archive-Queue-ID: 4XyZ1\r\nX-Archive-Received: 2023-11-14T22:13:20.250Z\r\n"
|
||||||
|
. "X-Archive-Direction: internal\r\nX-Archive-Source: smtp\r\nX-Archive-SHA256: " . str_repeat('a', 64) . "\r\n",
|
||||||
|
substr($block, 0, strpos($block, 'X-Archive-Signature:')));
|
||||||
|
$signed = implode("\n", ['x-archive-owner:anna@firma.de', 'x-archive-rcpt:anna@firma.de,bcc@extern.example',
|
||||||
|
'x-archive-from:x@extern.example', 'x-archive-queue-id:4XyZ1', 'x-archive-received:2023-11-14T22:13:20.250Z',
|
||||||
|
'x-archive-direction:internal', 'x-archive-source:smtp', 'x-archive-sha256:' . str_repeat('a', 64)]);
|
||||||
|
$this->assertStringEndsWith("X-Archive-Signature: v=1; a=hmac-sha256; s=" . hash_hmac('sha256', $signed, 'key') . "\r\n", $block);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fixed vector shared with archivecow: if this changes, every archive that verifies the signature refuses the copies */
|
||||||
|
public function testWireFormatIsStable(): void
|
||||||
|
{
|
||||||
|
$message = "From: sender@example.com\r\nTo: anna.schmidt@example.org\r\nSubject: Test\r\nMessage-ID: <1@example.com>\r\n\r\nHallo.\r\n";
|
||||||
|
$settings = self::settings('archivecow');
|
||||||
|
$settings->signingKey = 'test-signing-key';
|
||||||
|
$journal = new Journal(['anna.schmidt@example.org', 'buchhaltung@example.org'],
|
||||||
|
['anna.schmidt@example.org', 'buchhaltung@example.org', 'extern.partner@example.net'],
|
||||||
|
'sender@example.com', '4ZxYw1abcDz9', 'in', 'smtp', 1793983297.25, hash('sha256', $message));
|
||||||
|
|
||||||
|
$this->assertSame('0042f6cd8a846e5ce8f4a57c97121324c1213380fc204736d15ffe5693a9ef5c', hash('sha256', $message));
|
||||||
|
$this->assertSame("X-Archive-Owner: anna.schmidt@example.org,buchhaltung@example.org\r\n"
|
||||||
|
. "X-Archive-Rcpt: anna.schmidt@example.org,buchhaltung@example.org,\r\n extern.partner@example.net\r\n"
|
||||||
|
. "X-Archive-From: sender@example.com\r\n"
|
||||||
|
. "X-Archive-Queue-ID: 4ZxYw1abcDz9\r\n"
|
||||||
|
. "X-Archive-Received: 2026-11-06T16:41:37.250Z\r\n"
|
||||||
|
. "X-Archive-Direction: in\r\n"
|
||||||
|
. "X-Archive-Source: smtp\r\n"
|
||||||
|
. "X-Archive-SHA256: 0042f6cd8a846e5ce8f4a57c97121324c1213380fc204736d15ffe5693a9ef5c\r\n"
|
||||||
|
. "X-Archive-Signature: v=1; a=hmac-sha256; s=443c927e165fd1f418fdfde2be54645d3201ef3b17d9e74ed3569d937fcf84d1\r\n",
|
||||||
|
(new HeaderBuilder())->build($settings, $journal));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLongListsAreFoldedAndSignedUnfolded(): void
|
||||||
|
{
|
||||||
|
$rcpts = array_map(fn(int $i): string => sprintf('recipient%04d@extern.example', $i), range(1, 200));
|
||||||
|
$block = (new HeaderBuilder())->build(self::settings('archivecow'), self::journal($rcpts));
|
||||||
|
|
||||||
|
foreach (explode("\r\n", $block) as $line) {
|
||||||
|
$this->assertLessThanOrEqual(78 + 30, strlen($line));
|
||||||
|
}
|
||||||
|
preg_match('/^X-Archive-Rcpt: (.*?)\r\n(?=\S)/ms', $block, $m);
|
||||||
|
$this->assertSame($rcpts, explode(',', preg_replace('/\r\n /', '', $m[1])));
|
||||||
|
// the signature covers the values without whitespace, so folding does not matter
|
||||||
|
$this->assertStringContainsString(hash_hmac('sha256', implode("\n", [
|
||||||
|
'x-archive-owner:anna@firma.de', 'x-archive-rcpt:' . implode(',', $rcpts), 'x-archive-from:x@extern.example',
|
||||||
|
'x-archive-queue-id:4XyZ1', 'x-archive-received:2023-11-14T22:13:20.250Z', 'x-archive-direction:internal',
|
||||||
|
'x-archive-source:smtp', 'x-archive-sha256:' . str_repeat('a', 64)]), 'key'), $block);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEmptyValuesAreLeftOutAndControlCharactersRemoved(): void
|
||||||
|
{
|
||||||
|
$journal = new Journal(["anna@firma.de\r\nX-Evil: 1"], ['a@b.example', ''], '', '', 'in', 'quarantine', 1700000000.0, '');
|
||||||
|
$block = (new HeaderBuilder())->build(self::settings('archivecow'), $journal);
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString("\r\nX-Evil", $block);
|
||||||
|
$this->assertStringNotContainsString('X-Archive-From', $block);
|
||||||
|
$this->assertStringNotContainsString('X-Archive-Queue-ID', $block);
|
||||||
|
$this->assertStringNotContainsString('X-Archive-SHA256', $block);
|
||||||
|
$this->assertStringContainsString("X-Archive-Owner: anna@firma.de X-Evil: 1\r\n", $block);
|
||||||
|
$this->assertStringContainsString("X-Archive-Rcpt: a@b.example\r\n", $block);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEnvelopeAndNoneProfiles(): void
|
||||||
|
{
|
||||||
|
$settings = self::settings('envelope');
|
||||||
|
$settings->headerFrom = 'X-Real-MailFrom';
|
||||||
|
$settings->headerRcpt = 'X-Real-RcptTo';
|
||||||
|
$this->assertSame("X-Real-MailFrom: x@extern.example\r\nX-Real-RcptTo: anna@firma.de,bcc@extern.example\r\n",
|
||||||
|
(new HeaderBuilder())->build($settings, self::journal()));
|
||||||
|
$this->assertSame("X-Real-MailFrom: <>\r\nX-Real-RcptTo: anna@firma.de,bcc@extern.example\r\n",
|
||||||
|
(new HeaderBuilder())->build($settings, self::journal(from: '')));
|
||||||
|
$this->assertSame('', (new HeaderBuilder())->build(self::settings('none'), self::journal()));
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user