Compare commits

...
Author SHA1 Message Date
FreddleSpl0it 0175fce9b5 [Web] Add archive translations 2026-11-07 00:45:55 +01:00
FreddleSpl0it 1c24de19c3 [Web] Archive settings, domain and mailbox switches, archive log and API 2026-11-07 00:45:11 +01:00
FreddleSpl0it fb85a9eb5c [Postfix][Rspamd] Send archive copies as SMTP journal through Postfix 2026-11-07 00:27:42 +01:00
FreddleSpl0it fc56d53029 [Web][Tests] Add archive module tests 2026-11-07 00:18:58 +01:00
FreddleSpl0it 2ac2edf0ad [Web] Add archive module classes 2026-11-07 00:15:08 +01:00
FreddleSpl0it 329bca1d46 Merge pull request #7596 from mailcow/fix/7595
[Web] Mailbox protocols do not limit app passwords
2026-11-05 13:19:59 +01:00
FreddleSpl0it 6a83b9e0f0 [Web] Mailbox protocols do not limit app passwords 2026-11-05 12:46:22 +01:00
FreddleSpl0it 3ffecc505e Merge pull request #7593 from mailcow/feat/netfilter-unban-all
[Web] Add Netfilter unban all button
2026-11-04 22:29:32 +01:00
FreddleSpl0it e8f12cd061 [Web] Add Netfilter unban all button 2026-11-04 22:23:16 +01:00
FreddleSpl0it 3a4e479424 [Web] Update group info text 2026-11-04 22:16:10 +01:00
FreddleSpl0it f1e51c0a40 [Update] Mark 2026-11 as major update 2026-11-04 17:39:22 +01:00
FreddleSpl0it ddcba178af Merge pull request #7590 from mailcow/feat/sogo-5.12.13
[SOGo] Update to 5.12.13
2026-11-04 16:55:52 +01:00
FreddleSpl0it 1752dd6ddf [SOGo] Update to 5.12.13 2026-11-04 16:54:00 +01:00
FreddleSpl0it adb1c74ce3 Merge pull request #7589 from mailcow/fix/rework-bugs
Fixes for the reworks: imapsync, logins/netfilter, groups, startup, hardening
2026-11-04 16:32:46 +01:00
FreddleSpl0it 716f67844d [Web] mobileconfig: Wait for a pending account setup 2026-11-04 15:44:57 +01:00
FreddleSpl0it a2ab4301e7 [Web][Dovecot][Compose] Keep group shares on Enter, no restarts while waiting for the schema, run sync crons on master only 2026-11-04 15:10:40 +01:00
FreddleSpl0it 52f28025a5 [Web][Dovecot] imapsync: Block skipmess in custom params 2026-11-04 12:02:18 +01:00
FreddleSpl0it 3c03f8e567 Merge branch 'fix/sec' into fix/rework-bugs 2026-11-04 11:46:47 +01:00
FreddleSpl0it 72613e7e2b [Dovecot][Watchdog] Limit the wait for the schema update to 5 minutes 2026-11-04 11:29:00 +01:00
FreddleSpl0it 9293e4ce43 [Web][Dovecot] Do not count denied logins as failed logins 2026-11-04 10:39:32 +01:00
FreddleSpl0it a8d1121f0e [Dovecot][Web] imapsync: verify certificates against the source host, make the check optional per source 2026-11-03 09:31:16 +01:00
FreddleSpl0it a4b60ae26f [Netfilter][Dovecot] Count failed master logins, log the client address of successful logins 2026-11-02 14:55:42 +01:00
FreddleSpl0it e96fb24a67 [Nginx][Web] Pass the client address to sogo-auth for EAS/DAV logins 2026-11-02 14:44:09 +01:00
FreddleSpl0it 5c762d9949 [Dovecot][Netfilter] Report failed logins from mailcowauth.php, anchor default rules 2026-11-02 14:11:56 +01:00
FreddleSpl0it 06cbdc4409 [Dockerapi] Pass "--" before positional arguments of doveadm 2026-11-02 10:03:30 +01:00
FreddleSpl0it 7c73199bde [Web] Groups: limit changes of domain admins to their domains 2026-11-02 10:01:43 +01:00
FreddleSpl0it 4b7ebab8f3 [Web] Mask IdP secrets for read-only API keys, deny sync job OAuth to read-only and pending sessions 2026-11-02 09:56:24 +01:00
FreddleSpl0it 5eb3148bd5 [Web] OIDC/LDAP: check the OAuth state, keep the mailbox on token refresh, require unique IdP users 2026-11-02 09:54:54 +01:00
FreddleSpl0it 41467dc47f [Web] imapsync: change the server of a sync source only with access to all its sync jobs 2026-11-02 09:50:24 +01:00
FreddleSpl0it a1cbd51d70 [Dovecot][Web] imapsync: restrict custom parameters and source hosts, verify certificates 2026-11-02 09:49:03 +01:00
milkmaker 1cb0656924 update postscreen_access.cidr (#7584) 2026-11-01 21:43:41 +01:00
FreddleSpl0it 510674ee67 [Web] Pending account setup adjustments 2026-10-28 17:11:25 +01:00
FreddleSpl0it a21604cd7b [Web][Rspamd] Quarantine link adjustments 2026-10-28 17:06:17 +01:00
FreddleSpl0it 4c7e2ec036 [Nginx] Limit request body size, configurable via NGINX_MAX_BODY_SIZE 2026-10-28 16:54:26 +01:00
FreddleSpl0it 085d10118d [Nginx][Web] Client address and autodiscover adjustments 2026-10-28 16:36:16 +01:00
FreddleSpl0it 1b6fec6a89 [Web] Hide the sync job form without the syncjobs ACL 2026-10-28 16:24:37 +01:00
FreddleSpl0it 9eb3a79f3b [Web] Quarantine list adjustments 2026-10-28 16:16:00 +01:00
FreddleSpl0it d5e3de3ae8 [Web] Add TFA Ratelimit 2026-10-28 16:04:45 +01:00
FreddleSpl0it e986803980 [Rspamd] Render domain footers without the template engine 2026-10-28 15:50:56 +01:00
FreddleSpl0it 44429fa0d0 [Web] App password and API adjustments 2026-10-28 15:29:55 +01:00
115 changed files with 5461 additions and 350 deletions

No files matched your search

+1
View File
@@ -192,6 +192,7 @@ detect_major_update() {
"2025-02"
"2025-03"
"2025-09"
"2026-11"
)
current_version=""
+6
View File
@@ -60,6 +60,7 @@ adapt_new_options() {
"ACME_DNS_CHALLENGE"
"ACME_DNS_PROVIDER"
"ACME_ACCOUNT_EMAIL"
"NGINX_MAX_BODY_SIZE"
)
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 '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
;;
@@ -318,7 +318,7 @@ class DockerApi:
if 'username' in request_json and 'script_name' in request_json:
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)
return self.exec_run_handler('utf8_text_only', sieve_return)
# api call: container_post - post_action: exec - cmd: maildir - task: cleanup
@@ -491,7 +491,7 @@ class DockerApi:
if mailbox in mailbox_seen:
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()
if len(acls) >= 2:
for acl in acls[1:]:
@@ -510,7 +510,7 @@ class DockerApi:
if mailbox in mailbox_seen:
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()
if len(acls) >= 2:
for acl in acls[1:]:
@@ -534,7 +534,7 @@ class DockerApi:
id = request_json['id'].replace("'", "'\\''")
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)
# api call: container_post - post_action: exec - cmd: doveadm - task: set_acl
def container_post__exec__doveadm__set_acl(self, request_json, **kwargs):
@@ -568,7 +568,7 @@ class DockerApi:
rights += right + " "
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)
# 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
@@ -585,7 +585,7 @@ class DockerApi:
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='):
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 '
'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],
@@ -601,7 +601,7 @@ class DockerApi:
for container in self.sync_docker_client.containers.list(filters=filters):
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)
# Rights doveadm acl set accepts from the API
+11 -4
View File
@@ -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
# 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)
while [[ "${DBV_NOW}" != "${DB_VERSION}" ]]; do
# No error while the versions table is missing (fresh install, set -e)
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..."
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
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
while [[ ${VERSIONS_OK} != 'OK' ]]; do
+54 -4
View File
@@ -1,10 +1,12 @@
#!/usr/bin/python3
import fcntl
import ipaddress
import json
import os
import re
import signal
import socket
import subprocess
import sys
import tempfile
@@ -18,6 +20,15 @@ max_output = 16 * 1024 * 1024
lock_file = run_dir + '/imapsync_busy.lock'
# An OAuth run needs a token valid this long at start (half the lifetime for short-lived tokens)
token_min_valid = 900
# 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
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,
i.subfolder2, i.delete1, i.delete2, i.automap, i.skipcrossduplicates, i.maxbytespersecond, i.custom_params,
i.subscribeall, i.timeout1, i.timeout2, i.dry, i.source_id,
s.host1, s.port1, s.enc1, s.auth_type, s.oauth_flow
s.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
WHERE i.active = 1
AND s.active = 1
@@ -106,6 +117,28 @@ def defer_job(job_id, text, exit_status):
(text, exit_status, 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):
if j['oauth_flow'] == 'authorization_code':
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)
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'
oauth = None
if auth_type == 'XOAUTH2':
@@ -174,7 +212,11 @@ def prepare_job(j, global_bw, files):
except ValueError:
pairs = []
for p in pairs if isinstance(pairs, list) else []:
if not isinstance(p, dict) or not isinstance(p.get('o'), str) or not re.fullmatch(r'[a-z0-9]+', p['o']):
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
value = '' if p.get('v') is None else str(p['v'])
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)
enc1 = {'TLS': '--tls1', 'SSL': '--ssl1'}.get(j['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 += ['--host2', 'localhost', '--user2', j['user2'] + '*' + master_user, '--passfile2', tmpfile(master_pass + '\n')]
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)
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:
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
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
(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):
self.r = r
self.tables = tables
@@ -21,16 +18,6 @@ class BanManager:
self.banned = set()
self.denied = {}
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):
"""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:
self.logger.logInfo('Address %s is allowlisted by rule %s' % (ip, allowed))
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:
# log lines of connections from before the ban
return
@@ -208,8 +192,6 @@ class BanManager:
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]:
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):
for net in self.tables.ban(nets):
@@ -109,12 +109,6 @@ class Netfilter:
def handle_line(self, line):
# a broken line must not end the watcher
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)
if result is None:
return
+69 -16
View File
@@ -6,20 +6,43 @@ import re
class Regexes:
"""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 = {
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 .+',
1: r'^mailcow UI: Invalid password for .+ by ([0-9a-f\.:]+)$',
2: r'^Rspamd UI: Invalid password by ([0-9a-f\.:]+)$',
3: r'^MAILCOWAUTH: Login failed for user .+ with service \w* from IP ([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.+',
6: r'^(?:\[\d+\]: )?SOGo\w* Login from \'(?:[0-9a-f\.:]+, )*([0-9a-f\.:]+)\' for user .+ might not have worked',
7: r'([0-9a-f\.:]+) \"GET \/SOGo\/.* HTTP.+\" 403 .+',
8: r'^auth: passwd-file\(.*,([0-9a-f\.:]+),master(?:,<[^<>]*>)?\): (?:Password mismatch|unknown user)',
}
# Dovecot could not ask the auth backend (identity provider down, mailcowauth.php unreachable). Postfix logs
# such a temporary failure like a wrong password, so failed logins of the address are not counted for a while.
TEMPORARY_FAILURE = re.compile(r'lua\([^,]+,([0-9a-f\.:]+)(?:,<[^>]+>)?\): passdb-lua: Upstream (?:unavailable|unreachable)')
# Former default rules (with any rule id) => rule id of their replacement, None: the rule is removed.
# Rules the admin changed are kept.
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):
self.logger = logger
@@ -42,6 +65,10 @@ class Regexes:
if not isinstance(stored, dict):
self.logger.logWarn('F2B_REGEX is no JSON object, keeping the current rules')
return
migrated = self.migrate(stored)
if migrated != stored:
self.store_migrated(r)
stored = migrated
rules = []
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):
"""(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:
result = regex.search(line)
if result and result.lastindex and result.group(1):
@@ -61,10 +89,35 @@ class Regexes:
return None
@classmethod
def temporary_failure(cls, line):
"""Address of a login that failed because the auth backend was unavailable, None for other lines"""
result = cls.TEMPORARY_FAILURE.search(line)
return result.group(1) if result else None
def migrate(cls, stored):
"""The stored rules with former default rules replaced by the current ones (or removed)"""
migrated = {}
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
def parse_ip(text):
+7
View File
@@ -1,4 +1,5 @@
import os
import re
import subprocess
from jinja2 import Environment, FileSystemLoader
@@ -44,6 +45,11 @@ def prepare_template_vars():
ipv4_network = os.getenv("IPV4_NETWORK", "172.22.1")
additional_server_names = os.getenv("ADDITIONAL_SERVER_NAMES", "")
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 = {
'IPV4_NETWORK': ipv4_network,
@@ -60,6 +66,7 @@ def prepare_template_vars():
'PHPFPMHOST': os.getenv("PHPFPMHOST", "php-fpm-mailcow"),
'ENABLE_IPV6': os.getenv("ENABLE_IPV6", "true").lower() != "false",
'HTTP_REDIRECT': os.getenv("HTTP_REDIRECT", "n").lower() in ("y", "yes"),
'NGINX_MAX_BODY_SIZE': max_body_size,
}
ssl_dir = '/etc/ssl/mail/'
+54
View File
@@ -15,6 +15,21 @@ until dig +short mailcow.email > /dev/null; do
sleep 1
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
# Autogenerated by mailcow
null: /dev/null
@@ -163,6 +178,45 @@ query = SELECT CONCAT('smtp_via_transport_maps:', nexthop) AS transport FROM tra
AND destination = '%s';
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
# Autogenerated by mailcow
user = ${DBUSER}
+3 -3
View File
@@ -1,6 +1,6 @@
# SOGo built from source to enable security patch application
# Repository: https://github.com/Alinto/sogo
# Version: SOGo-5.12.12
# Version: SOGo-5.12.13
#
# Applied security patches:
# -
@@ -12,8 +12,8 @@ FROM debian:bookworm
LABEL maintainer="The Infrastructure Company GmbH <info@servercow.de>"
ARG DEBIAN_FRONTEND=noninteractive
ARG SOGO_VERSION=SOGo-5.12.12
ARG SOPE_VERSION=SOPE-5.12.12
ARG SOGO_VERSION=SOGo-5.12.13
ARG SOPE_VERSION=SOPE-5.12.13
# Security patches to apply (space-separated commit hashes)
ARG SOGO_SECURITY_PATCHES=""
# renovate: datasource=github-releases depName=tianon/gosu versioning=semver-coerced extractVersion=^(?<version>.*)$
+13
View File
@@ -43,6 +43,19 @@ while ! mariadb-admin status --skip-ssl --socket=/var/run/mysqld/mysqld.sock -u$
sleep 2
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
if [[ ! -z ${REDIS_SLAVEOF_IP} ]]; then
REDIS_CMDLINE="redis-cli -h ${REDIS_SLAVEOF_IP} -p ${REDIS_SLAVEOF_PORT} -a ${REDISPASS} --no-auth-warning"
+24 -9
View File
@@ -68,13 +68,17 @@ require_once 'functions.acl.inc.php';
$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;
// set when the password is right, but the account does not allow this login
$denied = false;
if ($isSOGoRequest) {
// This is a SOGo Auth request. First check for SSO password.
$sogo_sso_pass = file_get_contents("/etc/sogo-sso/sogo-sso.pass");
if ($sogo_sso_pass === $post['password']){
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;
}
}
@@ -91,10 +95,10 @@ if ($result === false){
'service' => $post['service'],
'is_internal' => true,
'remote_addr' => $post['real_rip']
));
), $denied);
if ($result) {
error_log('MAILCOWAUTH: App auth for user ' . $post['username'] . " with service " . $service . " from IP " . $post['real_rip']);
set_sasl_log($post['username'], $post['real_rip'], $service);
error_log('MAILCOWAUTH: App auth for user ' . $post['username'] . " with service " . $service . " from IP " . $client_ip);
set_sasl_log($post['username'], $client_ip, $service);
}
}
if ($result === false){
@@ -102,26 +106,37 @@ if ($result === false){
$iam_provider = identity_provider('init');
$iam_settings = identity_provider('get');
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) {
// 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
echo json_encode($return);
session_destroy();
exit;
}
if ($result) {
error_log('MAILCOWAUTH: User auth for user ' . $post['username'] . " with service " . $post['service'] . " from IP " . $post['real_rip']);
set_sasl_log($post['username'], $post['real_rip'], $post['service']);
error_log('MAILCOWAUTH: User auth for user ' . $post['username'] . " with service " . $post['service'] . " from IP " . $client_ip);
set_sasl_log($post['username'], $client_ip, $post['service']);
}
}
if ($result) {
http_response_code(200); // OK
$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 {
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
}
+3 -4
View File
@@ -1,8 +1,4 @@
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 ltn12 = require "ltn12"
local https = require "ssl.https"
@@ -11,7 +7,10 @@ function auth_password_verify(request, password)
local req = {
username = request.user,
password = password,
-- address that connected to Dovecot, for proxied connections the proxy
real_rip = request.real_rip,
-- address of the client, also for proxied connections
client_ip = request.rip,
service = request.service
}
local req_json = json.encode(req)
+2 -8
View File
@@ -51,13 +51,6 @@ recipient_delimiter = +
auth_master_user_separator = *
mail_shared_explicit_inbox = yes
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
passdb {
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
# 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
# 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 {
driver = lua
args = file=/etc/dovecot/auth/passwd-verify.lua blocking=yes cache_key=%s:%u:%w
+7
View File
@@ -47,6 +47,13 @@ http {
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 %}
# HTTP to HTTPS redirect
server {
@@ -22,7 +22,8 @@ add_header Referrer-Policy strict-origin;
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_disable "msie6";
@@ -116,7 +117,8 @@ location ~ \.php$ {
include /etc/nginx/fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
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)
fastcgi_param SOGO_AUTH_INTERNAL $sogo_auth_internal;
fastcgi_read_timeout 3600;
@@ -181,6 +183,7 @@ location /sogo-auth-verify {
proxy_set_header Content-Length "";
proxy_pass http://127.0.0.1:65510/sogo-auth;
proxy_pass_request_body off;
client_max_body_size {{ NGINX_MAX_BODY_SIZE }};
}
location ^~ /Microsoft-Server-ActiveSync {
@@ -204,7 +207,7 @@ location ^~ /Microsoft-Server-ActiveSync {
proxy_busy_buffers_size 512k;
proxy_set_header Host $http_host;
client_body_buffer_size 512k;
client_max_body_size 0;
client_max_body_size {{ NGINX_MAX_BODY_SIZE }};
}
location ^~ /SOGo {
@@ -255,7 +258,7 @@ location ^~ /SOGo {
proxy_send_timeout 3600;
proxy_read_timeout 3600;
client_body_buffer_size 128k;
client_max_body_size 0;
client_max_body_size {{ NGINX_MAX_BODY_SIZE }};
break;
}
+1
View File
@@ -60,6 +60,7 @@ $_SESSION['acl']['sogo_access'] = "1";
$_SESSION['acl']['protocol_access'] = "1";
$_SESSION['acl']['mailbox_relayhost'] = "1";
$_SESSION['acl']['unlimited_quota'] = "1";
$_SESSION['acl']['archive'] = "1";
// One run at a time, released when the process ends
$lock = fopen('/tmp/identity-sync.lock', 'c');
+1
View File
@@ -1,2 +1,3 @@
/watchdog@localhost$/ watchdog_discard:
/^archive-bounce@localhost$/ archive_discard:
/localhost$/ local:
+3
View File
@@ -50,6 +50,9 @@ postscreen_greet_wait = 3s
postscreen_non_smtp_command_enable = no
postscreen_pipelining_enable = no
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_tls_enforce_in_policy.cf,
$sender_dependent_default_transport_maps,
+23 -1
View File
@@ -61,13 +61,14 @@ submission inet n - n - - smtpd
-o non_smtpd_milters=
-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
-o smtpd_helo_restrictions=
-o smtpd_client_restrictions=permit_mynetworks,reject
-o smtpd_tls_auth_only=no
-o 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
# enforced smtp connector
@@ -81,6 +82,27 @@ smtp_enforced_tls 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
# 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
dnsblog unix - - n - 0 dnsblog
pickup fifo n - n 60 1 pickup
+13 -22
View File
@@ -1,6 +1,6 @@
# Whitelist generated by Postwhite v3.4 on Thu Oct 1 00:35:20 UTC 2026
# Whitelist generated by Postwhite v3.4 on Sun Nov 1 00:23:09 UTC 2026
# https://github.com/stevejenkins/postwhite/
# 2246 total rules
# 2237 total rules
2a00:1450:4000::/36 permit
2a00:1450:4864::/56 permit
2a01:111:f400::/48 permit
@@ -197,6 +197,9 @@
44.246.68.102 permit
44.246.77.92 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.133.0/24 permit
45.143.134.0/24 permit
@@ -672,7 +675,6 @@
85.215.255.47 permit
85.215.255.48 permit
85.215.255.49 permit
86.61.88.25 permit
87.238.80.0/21 permit
87.248.103.12 permit
87.248.103.21 permit
@@ -1345,11 +1347,11 @@
107.20.210.250 permit
107.22.191.150 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.215 permit
108.174.3.93 permit
108.174.6.0/24 permit
108.174.6.215 permit
108.174.6.93 permit
108.175.18.45 permit
108.175.30.45 permit
108.179.144.0/20 permit
@@ -1532,11 +1534,6 @@
147.154.63.0/24 permit
147.154.126.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.8.0/21 permit
148.116.32.128/25 permit
@@ -1546,7 +1543,7 @@
149.97.173.180 permit
149.118.160.128/25 permit
150.136.21.199 permit
150.171.109.178 permit
150.171.109.66 permit
150.230.98.160 permit
151.145.38.14 permit
152.67.105.195 permit
@@ -1562,7 +1559,6 @@
155.248.237.141 permit
157.56.120.128/26 permit
157.58.30.128/25 permit
157.58.196.96/29 permit
157.58.249.3 permit
157.137.32.128/25 permit
157.137.96.128/25 permit
@@ -1651,7 +1647,6 @@
167.89.75.126 permit
167.89.75.136 permit
167.89.75.164 permit
167.220.67.232/29 permit
168.107.248.128/25 permit
168.110.160.128/25 permit
168.110.248.128/25 permit
@@ -1979,8 +1974,6 @@
207.46.22.35 permit
207.46.50.72 permit
207.46.50.82 permit
207.46.52.71 permit
207.46.52.79 permit
207.67.38.0/24 permit
207.67.98.192/27 permit
207.97.204.96/29 permit
@@ -2138,8 +2131,6 @@
212.227.126.227 permit
213.199.128.139 permit
213.199.128.145 permit
213.199.138.181 permit
213.199.138.191 permit
216.17.150.242 permit
216.17.150.251 permit
216.24.224.0/20 permit
@@ -2228,23 +2219,23 @@
2001:748:400:3301::4 permit
2404:6800:4000::/36 permit
2404:6800:4864::/56 permit
2603:1061:14:1d0::1 permit
2603:1061:14:41::1 permit
2607:13c0:0001:0000:0000:0000:0000:7000/116 permit
2607:13c0:0002:0000:0000:0000:0000:1000/116 permit
2607:13c0:0004:0000:0000:0000:0000:0000/116 permit
2607:f8b0:4000::/36 permit
2607:f8b0:4864::/56 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::215 permit
2620:109:c006:104::5d permit
2620:109:c00d:104::/64 permit
2620:10d:c090:400::8:1 permit
2620:10d:c091:400::8:1 permit
2620:10d:c09b:400::8:1 permit
2620:10d:c09c:400::8:1 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:4864::/56 permit
49.12.4.251 permit # checks.mailcow.email
+47
View File
@@ -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);
}
+178 -34
View File
@@ -150,41 +150,41 @@ rspamd_config:register_symbol({
local function ipv6_to_segments(ip_str)
-- Remove zone identifier if present (e.g., %eth0)
ip_str = ip_str:gsub("%%.*$", "")
local segments = {}
-- Handle :: compression
if ip_str:find('::') then
local before, after = ip_str:match('^(.*)::(.*)$')
before = before or ''
after = after or ''
local before_parts = {}
local after_parts = {}
if before ~= '' then
for seg in before:gmatch('[^:]+') do
table.insert(before_parts, tonumber(seg, 16) or 0)
end
end
if after ~= '' then
for seg in after:gmatch('[^:]+') do
table.insert(after_parts, tonumber(seg, 16) or 0)
end
end
-- Add before segments
for _, seg in ipairs(before_parts) do
table.insert(segments, seg)
end
-- Add compressed zeros
local zeros_needed = 8 - #before_parts - #after_parts
for i = 1, zeros_needed do
table.insert(segments, 0)
end
-- Add after segments
for _, seg in ipairs(after_parts) do
table.insert(segments, seg)
@@ -195,12 +195,12 @@ rspamd_config:register_symbol({
table.insert(segments, tonumber(seg, 16) or 0)
end
end
-- Ensure we have exactly 8 segments
while #segments < 8 do
table.insert(segments, 0)
end
return segments
end
@@ -208,46 +208,46 @@ rspamd_config:register_symbol({
local function get_ipv6_variants(ip_str)
local variants = {}
local seen = {}
local function add_variant(v)
if v and not seen[v] then
table.insert(variants, v)
seen[v] = true
end
end
-- For IPv4, just return the original
if not ip_str:find(':') then
add_variant(ip_str)
return variants
end
local segments = ipv6_to_segments(ip_str)
-- 1. Fully expanded form (all zeros shown as 0000)
local expanded_parts = {}
for _, seg in ipairs(segments) do
table.insert(expanded_parts, string.format('%04x', seg))
end
add_variant(table.concat(expanded_parts, ':'))
-- 2. Standard form (no leading zeros, but all segments present)
local standard_parts = {}
for _, seg in ipairs(segments) do
table.insert(standard_parts, string.format('%x', seg))
end
add_variant(table.concat(standard_parts, ':'))
-- 3. Find all possible :: compressions
-- RFC 5952: compress the longest run of consecutive zeros
-- But we need to check all possibilities since Redis might have any form
-- Find all zero runs
local zero_runs = {}
local in_run = false
local run_start = 0
local run_length = 0
for i = 1, 8 do
if segments[i] == 0 then
if not in_run then
@@ -266,21 +266,21 @@ rspamd_config:register_symbol({
end
end
end
-- Don't forget the last run
if in_run and run_length >= 1 then
table.insert(zero_runs, {start = run_start, length = run_length})
end
-- Generate variant for each zero run compression
for _, run in ipairs(zero_runs) do
local parts = {}
-- Before compression
for i = 1, run.start - 1 do
table.insert(parts, string.format('%x', segments[i]))
end
-- The compression
if run.start == 1 then
table.insert(parts, '')
@@ -291,22 +291,22 @@ rspamd_config:register_symbol({
else
table.insert(parts, '')
end
-- After compression
for i = run.start + run.length, 8 do
table.insert(parts, string.format('%x', segments[i]))
end
local compressed = table.concat(parts, ':'):gsub('::+', '::')
add_variant(compressed)
end
return variants
end
local from_ip_string = tostring(ip)
local ip_check_table = {}
-- Add all variants of the exact IP
for _, variant in ipairs(get_ipv6_variants(from_ip_string)) do
table.insert(ip_check_table, variant)
@@ -318,18 +318,18 @@ rspamd_config:register_symbol({
maxbits = 32
minbits = 8
end
-- Add all CIDR notations with variants
for i=maxbits,minbits,-1 do
local masked_ip = ip:apply_mask(i)
local cidr_base = masked_ip:to_string()
for _, variant in ipairs(get_ipv6_variants(cidr_base)) do
local cidr = variant .. "/" .. i
table.insert(ip_check_table, cidr)
end
end
local function keep_spam_cb(err, data)
if err then
rspamd_logger.infox(rspamd_config, "keep_spam query request for ip %s returned invalid or empty data (\"%s\") or error (\"%s\")", ip, data, err)
@@ -345,7 +345,7 @@ rspamd_config:register_symbol({
end
end
end
table.insert(ip_check_table, 1, 'KEEP_SPAM')
local redis_ret_user = rspamd_redis_make_request(task,
redis_params, -- connect params
@@ -441,7 +441,7 @@ rspamd_config:register_symbol({
-- Check if we have a pre-result (e.g., from KEEP_SPAM or POSTMASTER_HANDLER)
local allow_processing = false
if task.has_pre_result then
local has_pre, pre_action = task:has_pre_result()
if has_pre then
@@ -765,13 +765,22 @@ rspamd_config:register_symbol({
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({
name = 'MOO_FOOTER',
type = 'prefilter',
callback = function(task)
local cjson = require "cjson"
local lua_mime = require "lua_mime"
local lua_util = require "lua_util"
local rspamd_logger = require "rspamd_logger"
local rspamd_http = require "rspamd_http"
local envfrom = task:get_from(1)
@@ -844,10 +853,10 @@ rspamd_config:register_symbol({
end
end
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
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
-- add footer
@@ -947,3 +956,138 @@ rspamd_config:register_symbol({
end,
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
})
+5 -6
View File
@@ -216,8 +216,9 @@ foreach ($rcpts as $rcpt) {
foreach ($rcpt_final_mailboxes as $rcpt_final) {
error_log("QUARANTINE: quarantine pipe: processing quarantine message for rcpt " . $rcpt_final . PHP_EOL);
try {
$stmt = $pdo->prepare("INSERT INTO `quarantine` (`qid`, `subject`, `score`, `sender`, `rcpt`, `symbols`, `user`, `ip`, `msg`, `action`, `fuzzy_hashes`)
VALUES (: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
$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(
':qid' => $qid,
':subject' => $subject,
@@ -229,11 +230,9 @@ foreach ($rcpt_final_mailboxes as $rcpt_final) {
':ip' => $ip,
':msg' => $raw_data,
':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 (
SELECT `id`
FROM (
+1
View File
@@ -46,6 +46,7 @@ $template_data = [
'lang_edit' => json_encode($lang['edit']),
'lang_datatables' => json_encode($lang['datatables']),
'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';
+2
View File
@@ -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/site/pwgen.js');
$js_minifier->add('/web/js/site/identity.js');
$js_minifier->add('/web/js/site/archive.js');
// all domains
$domains = mailbox('get', 'domains');
@@ -116,6 +117,7 @@ $template_data = [
'ui_theme' => customize('get', 'ui_theme'),
'password_complexity' => password_complexity('get'),
'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'],
'cors_settings' => $cors_settings,
'is_https' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on',
+295
View File
@@ -685,6 +685,11 @@ paths:
is domain global address list active or not, it enables
shared contacts accross domain in SOGo webmail
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:
description: limit count of mailboxes associated with this domain
type: number
@@ -1254,6 +1259,12 @@ paths:
force_tfa:
description: force 2FA enrollment at login
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:
description: force inbound email tls encryption
type: boolean
@@ -1936,6 +1947,14 @@ paths:
- TLS
- SSL
- 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:
description: >-
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
shared contacts accross domain in SOGo webmail
type: boolean
archive:
description: >-
archive mails of the mailboxes of this domain by default (admins, domain admins with the
archive permission)
type: boolean
mailboxes:
description: limit count of mailboxes associated with this domain
type: number
@@ -4214,6 +4238,12 @@ paths:
force_tfa:
description: force 2FA enrollment at login
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:
description: Full name of the mailbox user
type: string
@@ -4538,6 +4568,104 @@ paths:
default: run
type: object
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:
post:
responses:
@@ -5080,6 +5208,15 @@ paths:
- TLS
- SSL
- 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:
description: >-
authentication mechanism; PLAIN/LOGIN/CRAM-MD5 use the per-job
@@ -5741,6 +5878,7 @@ paths:
description: Some description
domain_name: domain.tld
gal: "0"
archive: 1
max_new_mailbox_quota: 10737418240
max_num_aliases_for_domain: 400
max_num_mboxes_for_domain: 10
@@ -6295,7 +6433,9 @@ paths:
response:
value:
- active: "1"
archive: 1
attributes:
archive: inherit
force_pw_update: "0"
force_tfa: "0"
mailbox_format: "maildir:"
@@ -6490,6 +6630,154 @@ paths:
nothing was changed).
operationId: 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}":
get:
parameters:
@@ -7313,6 +7601,7 @@ paths:
host1: imap.example.org
port1: 993
enc1: SSL
verify_cert: 1
auth_type: XOAUTH2
oauth_flow: authorization_code
oauth_token_endpoint: https://login.example.org/token
@@ -7579,7 +7868,9 @@ paths:
response:
value:
- active: "1"
archive: 1
attributes:
archive: inherit
force_pw_update: "0"
force_tfa: "0"
mailbox_format: "maildir:"
@@ -8007,3 +8298,7 @@ tags:
description: Manage Cross-Origin Resource Sharing (CORS) settings
- name: Identity Provider
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.
+42 -43
View File
@@ -144,41 +144,15 @@ try {
}
$username = trim((string)$email);
try {
$stmt = $pdo->prepare("SELECT `mailbox`.`name`, `mailbox`.`active` FROM `mailbox`
INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain`
WHERE `mailbox`.`username` = :username
AND `mailbox`.`active` = '1'
AND `domain`.`active` = '1'");
$stmt->execute(array(':username' => $username));
$MailboxData = $stmt->fetch(PDO::FETCH_ASSOC);
}
catch(PDOException $e) {
// Database error - return error response with complete XML
list($usec, $sec) = explode(' ', microtime());
?>
<Response>
<Error Time="<?=date('H:i:s', $sec) . substr($usec, 0, strlen($usec) - 2);?>" Id="<?=rand(1000000000, 9999999999);?>">
<ErrorCode>500</ErrorCode>
<Message>Database Error</Message>
<DebugData />
</Error>
</Response>
</Autodiscover>
<?php
exit(0);
}
// Mailbox not found or not active - return generic error to prevent user enumeration
if (empty($MailboxData)) {
if (!filter_var($username, FILTER_VALIDATE_EMAIL)) {
try {
$json = json_encode(
array(
"time" => time(),
"ua" => $_SERVER['HTTP_USER_AGENT'],
"user" => $email,
"user" => "none",
"ip" => $_SERVER['REMOTE_ADDR'],
"service" => "Error: mailbox not found or inactive"
"service" => "Error: invalid email address in request"
)
);
$redis->lPush('AUTODISCOVER_LOG', $json);
@@ -202,13 +176,33 @@ if (empty($MailboxData)) {
<?php
exit(0);
}
try {
$stmt = $pdo->prepare("SELECT `mailbox`.`name`, `mailbox`.`active` FROM `mailbox`
INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain`
WHERE `mailbox`.`username` = :username
AND `mailbox`.`active` = '1'
AND `domain`.`active` = '1'");
$stmt->execute(array(':username' => $username));
$MailboxData = $stmt->fetch(PDO::FETCH_ASSOC);
}
catch(PDOException $e) {
// Database error - return error response with complete XML
list($usec, $sec) = explode(' ', microtime());
?>
<Response>
<Error Time="<?=date('H:i:s', $sec) . substr($usec, 0, strlen($usec) - 2);?>" Id="<?=rand(1000000000, 9999999999);?>">
<ErrorCode>500</ErrorCode>
<Message>Database Error</Message>
<DebugData />
</Error>
</Response>
</Autodiscover>
<?php
exit(0);
}
if (!empty($MailboxData['name'])) {
$displayname = $MailboxData['name'];
}
else {
$displayname = $email;
}
$email = $username;
$displayname = $email;
try {
$json = json_encode(
array(
@@ -216,11 +210,16 @@ try {
"ua" => $_SERVER['HTTP_USER_AGENT'],
"user" => $email,
"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->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) {
$_SESSION['return'][] = array(
@@ -243,7 +242,7 @@ if ($autodiscover_config['autodiscoverType'] == 'imap') {
<Server><?=$autodiscover_config['imap']['server'];?></Server>
<Port><?=$autodiscover_config['imap']['port'];?></Port>
<DomainRequired>off</DomainRequired>
<LoginName><?=$email;?></LoginName>
<LoginName><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></LoginName>
<SPA>off</SPA>
<SSL>on</SSL>
<AuthRequired>on</AuthRequired>
@@ -253,7 +252,7 @@ if ($autodiscover_config['autodiscoverType'] == 'imap') {
<Server><?=$autodiscover_config['smtp']['server'];?></Server>
<Port><?=$autodiscover_config['smtp']['port'];?></Port>
<DomainRequired>off</DomainRequired>
<LoginName><?=$email;?></LoginName>
<LoginName><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></LoginName>
<SPA>off</SPA>
<SSL>on</SSL>
<AuthRequired>on</AuthRequired>
@@ -265,15 +264,15 @@ if ($autodiscover_config['autodiscoverType'] == 'imap') {
?>
<Protocol>
<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>
<LoginName><?=$email;?></LoginName>
<LoginName><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></LoginName>
</Protocol>
<Protocol>
<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>
<LoginName><?=$email;?></LoginName>
<LoginName><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></LoginName>
</Protocol>
<?php
}
@@ -288,7 +287,7 @@ if ($autodiscover_config['autodiscoverType'] == 'imap') {
<Culture>en:en</Culture>
<User>
<DisplayName><?=htmlspecialchars($displayname, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></DisplayName>
<EMailAddress><?=$email;?></EMailAddress>
<EMailAddress><?=htmlspecialchars($email, ENT_XML1 | ENT_QUOTES, 'UTF-8');?></EMailAddress>
</User>
<Action>
<Settings>
+1
View File
@@ -45,6 +45,7 @@ $template_data = [
'lang_edit' => json_encode($lang['edit']),
'lang_datatables' => json_encode($lang['datatables']),
'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';
+7 -2
View File
@@ -65,7 +65,8 @@ if (isset($_SESSION['mailcow_cc_role'])) {
'mta_sts' => $mta_sts,
'mailboxes' => mailbox('get', 'mailboxes', $_GET["domain"]),
'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' => $domain_template,
'rl' => ['frame' => $domain_template['attributes']['rl_frame']],
'archive_enabled' => \Mailcow\Archive\ArchiveModule::fromGlobals()->settings->enabled(),
];
$template = 'edit/domain-templates.twig';
$result = true;
@@ -85,6 +87,7 @@ if (isset($_SESSION['mailcow_cc_role'])) {
$template_data = [
'template' => $mailbox_template,
'rl' => ['frame' => $mailbox_template['attributes']['rl_frame']],
'archive_enabled' => \Mailcow\Archive\ArchiveModule::fromGlobals()->settings->enabled(),
];
$template = 'edit/mailbox-templates.twig';
$result = true;
@@ -162,6 +165,7 @@ if (isset($_SESSION['mailcow_cc_role'])) {
'user_acls' => acl('get', 'user', $mailbox),
'mailbox_details' => $result,
'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']) &&
is_numeric($_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';
}
elseif (isset($_GET['syncjob_source']) &&
+9
View File
@@ -74,6 +74,15 @@ function admin($_action, $_data = null) {
$usernames = $_data['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);
if (!empty($is_now)) {
$active = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active'];
@@ -5,6 +5,15 @@ function app_passwd($_action, $_data = null) {
$_data_log = $_data;
!isset($_data_log['app_passwd']) ?: $_data_log['app_passwd'] = '*';
!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 (!hasMailboxObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $_data['username'])) {
$_SESSION['return'][] = array(
+58 -40
View File
@@ -6,6 +6,8 @@ function check_login($user, $pass, $extra = null) {
$is_internal = $extra['is_internal'];
$role = $extra['role'];
$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
if (!isset($role) || $role == "admin") {
@@ -26,7 +28,7 @@ function check_login($user, $pass, $extra = null) {
// Try validate app password
if (!isset($role) || $role == "app") {
$result = apppass_login($user, $pass, $extra);
$result = apppass_login($user, $pass, $extra, $denied);
if ($result !== false) {
$real_rip = ($_SERVER['HTTP_X_REAL_IP'] ?? $_SERVER['REMOTE_ADDR']);
set_sasl_log($user, $real_rip, $extra['service'], $pass);
@@ -37,7 +39,7 @@ function check_login($user, $pass, $extra = null) {
// Try validate user
if (!isset($role) || $role == "user") {
try {
$result = user_login($user, $pass, $extra);
$result = user_login($user, $pass, $extra, $denied);
} catch (\Mailcow\Auth\IdpUnavailable $e) {
// no failed login, netfilter must not count it
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;
}
}
// skip log and only return false if it's an internal request
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'])) {
$_SESSION['ldelay'] = "0";
$redis->publish("F2B_CHANNEL", "mailcow UI: Invalid password for " . $user . " by " . $_SERVER['REMOTE_ADDR']);
error_log("mailcow UI: Invalid password for " . $user . " by " . $_SERVER['REMOTE_ADDR']);
if (!$denied) $redis->publish("F2B_CHANNEL", $msg);
error_log($msg);
}
elseif (!isset($_SESSION['mailcow_cc_username'])) {
$_SESSION['ldelay'] = $_SESSION['ldelay']+0.5;
$redis->publish("F2B_CHANNEL", "mailcow UI: Invalid password for " . $user . " by " . $_SERVER['REMOTE_ADDR']);
error_log("mailcow UI: Invalid password for " . $user . " by " . $_SERVER['REMOTE_ADDR']);
if (!$denied) $redis->publish("F2B_CHANNEL", $msg);
error_log($msg);
}
$_SESSION['return'][] = array(
'type' => 'danger',
@@ -207,7 +210,7 @@ function domainadmin_login($user, $pass){
return false;
}
function user_login($user, $pass, $extra = null){
function user_login($user, $pass, $extra = null, &$denied = false){
global $pdo;
global $iam_provider;
global $iam_settings;
@@ -261,11 +264,14 @@ function user_login($user, $pass, $extra = null){
if ($extra['service'] != 'NONE') {
$key = strtolower($extra['service']) . "_access";
if (isset($row['attributes'][$key]) && $row['attributes'][$key] != '1') {
$denied = true;
return false;
}
}
return true;
}
// the IdP accepted the password, the mailbox is inactive
$denied = true;
}
clear_session();
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
$row['attributes'] = json_decode($row['attributes'], true);
// Shared mailboxes have no login of their own, members open them through group shares
if (($row['attributes']['shared'] ?? '0') == '1') {
return false;
}
if ($extra['service'] != 'NONE') {
$key = strtolower($extra['service']) . "_access";
if (isset($row['attributes'][$key]) && $row['attributes'][$key] != '1') {
return false;
$key = strtolower($extra['service']) . "_access";
// Shared mailboxes have no login of their own, members open them through group shares.
// Checked before asking an IdP (no template side effects).
if (($row['attributes']['shared'] ?? '0') == '1'
|| ($extra['service'] != 'NONE' && isset($row['attributes'][$key]) && $row['attributes'][$key] != '1')
|| $row['active'] != '1' || $row['d_active'] != '1') {
// Denied, not failed, unless the password is wrong; IdP passwords are not checked for that
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;
}
switch ($row['authsource']) {
@@ -303,6 +307,7 @@ function user_login($user, $pass, $extra = null){
$stmt->execute(array(':user' => $user));
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (empty($row)) {
$denied = true;
return false;
}
@@ -361,6 +366,7 @@ function user_login($user, $pass, $extra = null){
$stmt->execute(array(':user' => $user));
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (empty($row)) {
$denied = true;
return false;
}
@@ -403,9 +409,6 @@ function user_login($user, $pass, $extra = null){
return $result;
break;
case 'mailcow':
if ($row['active'] != 1 || $row['d_active'] != 1) {
return false;
}
// verify password
if (verify_hash($row['password'], $pass) !== false) {
@@ -446,13 +449,15 @@ function user_login($user, $pass, $extra = null){
}
return "user";
}
// TFA accounts use app passwords in mail clients
$denied = true;
}
break;
}
return false;
}
function apppass_login($user, $pass, $extra = null){
function apppass_login($user, $pass, $extra = null, &$denied = false){
global $pdo;
$is_internal = $extra['is_internal'];
@@ -473,15 +478,12 @@ function apppass_login($user, $pass, $extra = null){
return false;
}
// fetch app password data
$stmt = $pdo->prepare("SELECT `app_passwd`.*, `app_passwd`.`password` as `password`, `app_passwd`.`id` as `app_passwd_id` FROM `app_passwd`
// 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`,
`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 `domain` ON `mailbox`.`domain` = `domain`.`domain`
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"
);
// fetch password data
@@ -491,16 +493,25 @@ function apppass_login($user, $pass, $extra = null){
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($rows as $row) {
if ($extra['service'] != 'NONE' && $row[strtolower($extra['service']) . '_access'] != '1'){
// verify password
if (verify_hash($row['password'], $pass) === false) {
continue;
}
// verify password
if (verify_hash($row['password'], $pass) !== false) {
$_SESSION['app_passwd_id'] = $row['app_passwd_id'];
unset($_SESSION['ldelay']);
return "user";
$key = strtolower($extra['service']) . '_access';
$mailbox_attributes = json_decode($row['mailbox_attributes'], true);
// the protocols of the mailbox only limit direct user access, app passwords have their own
if ($row['active'] != '1' || $row['mailbox_active'] != '1' || $row['domain_active'] != '1'
|| ($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;
@@ -538,7 +549,8 @@ function keycloak_mbox_login_rest($user, $pass, $extra = null){
// get the mailcow_password attribute from keycloak user
$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);
$curl = curl_init();
curl_setopt($curl, CURLOPT_TIMEOUT, 7);
@@ -559,7 +571,10 @@ function keycloak_mbox_login_rest($user, $pass, $extra = null){
if ($code != 200) {
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) {
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'],
\Mailcow\Identity\Directory\LdapDirectory::idAttributes($iam_settings['id_attribute'] ?? '')))));
$user_res = $ldap_query->firstOrFail();
} catch (\LdapRecord\Query\ObjectNotFoundException $e) {
return false;
$entries = $ldap_query->get();
} catch (Exception $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;
if (!$idp_login->verifyLdapPassword($iam_provider, (string)$user_res['dn'], (string)$pass)) {
return false;
+22
View File
@@ -145,6 +145,28 @@ function fail2ban($_action, $_data = null, $_extra = null) {
);
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
if (!empty($_data['network'])) {
+51
View File
@@ -539,6 +539,7 @@ function sys_mail($_data) {
$mail->Body = $mass_text;
}
$mail->XMailer = 'MooMassMail';
$archive = \Mailcow\Archive\ArchiveModule::fromGlobals();
foreach ($rcpts as $rcpt) {
$mail->AddAddress($rcpt);
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)
);
}
else {
// Delivered by LMTP without rspamd, so the archive gets it here
$archive->journal->submitDelivered($mail->getSentMIMEMessage(), $mass_from, array($rcpt), 'massmail');
}
$mail->ClearAllRecipients();
}
}
@@ -1857,6 +1862,40 @@ function get_tfa($username = null, $id = null) {
}
}
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 $yubi;
global $u2f;
@@ -3013,6 +3052,18 @@ function identity_provider($_action = null, $_data = null, $_extra = null) {
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
$stmt = $pdo->prepare("SELECT `mailbox`.`authsource` FROM `mailbox`
INNER JOIN `domain` ON `mailbox`.`domain` = `domain`.`domain`
+40 -4
View File
@@ -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'];
$backupmx = (isset($_data['backupmx'])) ? intval($_data['backupmx']) : $DOMAIN_DEFAULT_ATTRIBUTES['backupmx'];
$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) {
$backupmx = '1';
}
@@ -421,8 +422,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
':domain' => '%@' . $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`)
VALUES (: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, :archive)");
$stmt->execute(array(
':domain' => $domain,
':description' => $description,
@@ -435,7 +436,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
':gal' => $gal,
':active' => $active,
':relay_unknown_only' => $relay_unknown_only,
':relay_all_recipients' => $relay_all_recipients
':relay_all_recipients' => $relay_all_recipients,
':archive' => $archive
));
// save tags
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;
// Shared mailbox: no login of its own, members open it through group shares
$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_category = (isset($_data['quarantine_category'])) ? strval($_data['quarantine_category']) : strval($MAILBOX_DEFAULT_ATTRIBUTES['quarantine_category']);
// Validate quarantine_category
@@ -974,6 +978,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
'dav_access' => strval($dav_access),
'relayhost' => strval($relayhost),
'shared' => strval($shared),
'archive' => $archive,
'passwd_update' => time(),
'mailbox_format' => strval($MAILBOX_DEFAULT_ATTRIBUTES['mailbox_format']),
'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['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['archive'] = (isset($_data['archive']) && intval($_data['archive']) == 1) ? 1 : 0;
$attr['dkim_selector'] = (isset($_data['dkim_selector'])) ? $_data['dkim_selector'] : "dkim";
$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["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["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["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']);
@@ -2488,6 +2495,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$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'];
(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());
}
else {
@@ -2501,11 +2509,13 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$stmt = $pdo->prepare("UPDATE `domain` SET
`description` = :description,
`gal` = :gal
`gal` = :gal,
`archive` = :archive
WHERE `domain` = :domain");
$stmt->execute(array(
':description' => $description,
':gal' => $gal,
':archive' => $archive,
':domain' => $domain
));
// save tags
@@ -2538,6 +2548,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$active = (isset($_data['active'])) ? intval($_data['active']) : $is_now['active'];
$backupmx = (isset($_data['backupmx'])) ? intval($_data['backupmx']) : $is_now['backupmx'];
$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_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'];
@@ -2652,6 +2663,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
`relay_unknown_only` = :relay_unknown_only,
`backupmx` = :backupmx,
`gal` = :gal,
`archive` = :archive,
`active` = :active,
`quota` = :quota,
`defquota` = :defquota,
@@ -2666,6 +2678,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
':relay_unknown_only' => $relay_unknown_only,
':backupmx' => $backupmx,
':gal' => $gal,
':archive' => $archive,
':active' => $active,
':quota' => $quota,
':defquota' => $defquota,
@@ -2753,6 +2766,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$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_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['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)$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']);
$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)$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'];
@@ -3114,6 +3129,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
`attributes` = JSON_SET(`attributes`, '$.smtp_access', :smtp_access),
`attributes` = JSON_SET(`attributes`, '$.eas_access', :eas_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`, '$.attribute_hash', :attribute_hash)
WHERE `username` = :username");
@@ -3131,6 +3147,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
':smtp_access' => $smtp_access,
':eas_access' => $eas_access,
':dav_access' => $dav_access,
':archive' => $archive,
':recovery_email' => $pw_recovery_email,
':relayhost' => $relayhost,
':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["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["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["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'];
@@ -4682,6 +4700,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
`relay_unknown_only`,
`backupmx`,
`gal`,
`archive`,
`active`
FROM `domain` WHERE `domain`= :domain");
$stmt->execute(array(
@@ -4742,6 +4761,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$domaindata['backupmx_int'] = $row['backupmx'];
$domaindata['gal'] = $row['gal'];
$domaindata['gal_int'] = $row['gal'];
$domaindata['archive'] = $row['archive'];
$domaindata['rl'] = $rl;
$domaindata['active'] = $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'))) {
$stmt = $pdo->prepare("SELECT
`domain`.`backupmx`,
`domain`.`active` AS `domain_active`,
`domain`.`archive` AS `domain_archive`,
`mailbox`.`username`,
`mailbox`.`name`,
`mailbox`.`active`,
@@ -4851,6 +4873,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
else {
$stmt = $pdo->prepare("SELECT
`domain`.`backupmx`,
`domain`.`active` AS `domain_active`,
`domain`.`archive` AS `domain_archive`,
`mailbox`.`username`,
`mailbox`.`name`,
`mailbox`.`active`,
@@ -4890,6 +4914,18 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$mailboxdata['created'] = $row['created'];
$mailboxdata['modified'] = $row['modified'];
$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'] === '- ') {
$mailboxdata['percent_class'] = "info";
+16 -2
View File
@@ -154,6 +154,7 @@ function quarantine($_action, $_data = null) {
$mail->addAttachment($msg_tmpf, $detail_row['qid'] . '.eml');
$mail->Body = sprintf($lang['quarantine']['release_body']);
$mail->send();
$released_msg = $mail->getSentMIMEMessage();
unlink($msg_tmpf);
}
catch (phpmailerException $e) {
@@ -170,6 +171,7 @@ function quarantine($_action, $_data = null) {
}
elseif ($release_format == 'raw') {
$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
$detail_row['msg'] = preg_replace('~^\.~m', '..', $detail_row['msg']);
$postfix_talk = array(
@@ -213,6 +215,10 @@ function quarantine($_action, $_data = null) {
}
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->execute(array(
':id' => $row['id']
@@ -453,6 +459,7 @@ function quarantine($_action, $_data = null) {
$mail->addAttachment($msg_tmpf, $row['qid'] . '.eml');
$mail->Body = sprintf($lang['quarantine']['release_body']);
$mail->send();
$released_msg = $mail->getSentMIMEMessage();
unlink($msg_tmpf);
}
catch (phpmailerException $e) {
@@ -467,6 +474,7 @@ function quarantine($_action, $_data = null) {
}
elseif ($release_format == 'raw') {
$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
$row['msg'] = preg_replace('~^\.~m', '..', $row['msg']);
$postfix_talk = array(
@@ -506,6 +514,11 @@ function quarantine($_action, $_data = null) {
}
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->execute(array(
':id' => $id
@@ -767,8 +780,9 @@ function quarantine($_action, $_data = null) {
else {
$domains = array_merge(mailbox('get', 'domains'), mailbox('get', 'alias_domains'));
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');
$stmt->execute(array(':domain' => '@' . $domain . '$'));
// Compare the domain part exactly, a regex would treat dots in the domain as wildcards
$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);
while($row = array_shift($rows)) {
$q_meta[] = $row;
+65 -13
View File
@@ -242,6 +242,7 @@ function init_db_schema()
"gal" => "TINYINT(1) NOT NULL DEFAULT '1'",
"relay_all_recipients" => "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)",
"modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP",
"active" => "TINYINT(1) NOT NULL DEFAULT '1'"
@@ -1053,7 +1054,8 @@ function init_db_schema()
"domain_relayhost" => "TINYINT(1) NOT NULL DEFAULT '1'",
"domain_desc" => "TINYINT(1) NOT NULL DEFAULT '0'",
"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(
"primary" => array(
@@ -1098,6 +1100,7 @@ function init_db_schema()
"host1" => "VARCHAR(255) NOT NULL",
"port1" => "SMALLINT UNSIGNED NOT NULL",
"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'",
"oauth_flow" => "ENUM('client_credentials','authorization_code') NOT NULL DEFAULT 'client_credentials'",
"oauth_token_endpoint" => "VARCHAR(500) DEFAULT NULL",
@@ -1276,6 +1279,42 @@ function init_db_schema()
),
"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(
"cols" => array(
"id" => "INT NOT NULL AUTO_INCREMENT",
@@ -1679,9 +1718,15 @@ function init_db_schema()
// Converted JSON may not fit into the old VARCHAR(512)
$pdo->exec("ALTER TABLE `imapsync` MODIFY COLUMN `custom_params` TEXT");
$imapsync_allow = \Mailcow\Imapsync\Model\CustomParams::allowlist($GLOBALS['IMAPSYNC_OPTIONS'] ?? [], $GLOBALS['IMAPSYNC_OPTIONS_DEFAULT'] ?? []);
$source_sel = $pdo->prepare("SELECT `id` FROM `imapsync_source` WHERE `created_by` = '' AND `name` = :name");
$source_ins = $pdo->prepare("INSERT IGNORE INTO `imapsync_source` (`name`, `description`, `created_by`, `scope`, `host1`, `port1`, `enc1`, `auth_type`, `active`)
VALUES (:name, '', '', 'domain', :host, :port, :enc, :auth, 1)");
// A legacy source is found by its connection and its only target domain (none for jobs without domain)
$source_sel = $pdo->prepare("SELECT `id` FROM `imapsync_source` s WHERE `created_by` = '' AND `host1` = :host AND `port1` = :port
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)");
$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");
@@ -1741,16 +1786,20 @@ function init_db_schema()
$enc = in_array($row['enc1'], array('TLS', 'SSL', 'PLAIN'), true) ? $row['enc1'] : 'TLS';
$at = strrchr((string)$row['user2'], '@');
$domain = ($at === false) ? '' : strtolower(substr($at, 1));
// One source per (host, port, enc, auth, domain), name always unique and <= 100 chars
$name = 'legacy-' . mb_substr($host, 0, 60) . '-' . $port . '-' . substr(sha1($host . '|' . $port . '|' . $enc . '|' . $auth . '|' . $domain), 0, 8);
$source_sel->execute(array(':name' => $name));
// One source per (host, port, enc, auth, domain), named "host (domain)" plus " 2", " 3"... when taken (<= 100 chars)
$conn = array(':host' => $host, ':port' => $port, ':enc' => $enc, ':auth' => $auth);
$source_sel->execute($conn + array(':domain' => $domain));
if (($source_id = $source_sel->fetchColumn()) === false) {
$source_ins->execute(array(':name' => $name, ':host' => $host, ':port' => $port, ':enc' => $enc, ':auth' => $auth));
$source_sel->execute(array(':name' => $name));
if (($source_id = $source_sel->fetchColumn()) === false) continue;
}
if ($domain !== '') {
$source_domain_ins->execute(array(':id' => $source_id, ':domain' => $domain));
$base = mb_substr($host . ($domain !== '' ? ' (' . $domain . ')' : ''), 0, 94);
for ($i = 1, $name = $base; $i < 1000; $name = $base . ' ' . ++$i) {
$source_name_sel->execute(array(':name' => $name));
if ($source_name_sel->fetchColumn() == 0) break;
}
$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(
':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`, '$.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`, '$.archive', \"inherit\") WHERE JSON_VALUE(`attributes`, '$.archive') IS NULL;");
// admin attributes
$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;");
@@ -2045,6 +2095,7 @@ function init_db_schema()
"backupmx" => 0,
"relay_all_recipients" => 0,
"relay_unknown_only" => 0,
"archive" => 0,
"dkim_selector" => "dkim",
"key_size" => 2048,
"max_quota_for_domain" => 10240 * 1048576,
@@ -2062,6 +2113,7 @@ function init_db_schema()
"rl_value" => "",
"force_pw_update" => intval($GLOBALS['MAILBOX_DEFAULT_ATTRIBUTES']['force_pw_update']),
"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']),
"active" => 1,
"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 */
$candidates = [];
$duplicates = [];
/** @var array<string, true> $ambiguous unlinked mailboxes whose email several IdP users have, left as they are */
$ambiguous = [];
foreach ($listing->users as $user) {
if (isset($owners[$user->id])) {
$username = $owners[$user->id];
@@ -55,10 +57,19 @@ class SyncPlanner
$plan->skipped++;
} elseif (isset($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');
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;
$matched[$user->email] = $user;
} elseif (isset($state->mailboxes[$user->email]) || !$settings->importUsers || !$user->enabled
@@ -85,7 +96,7 @@ class SyncPlanner
if ($groups !== null) {
$this->planGroups($settings, $groups, $state, $usernames, $matched, $plan);
}
$this->planStates($settings, $state, $matched, $plan);
$this->planStates($settings, $state, $matched, $ambiguous, $plan);
if ($settings->periodicSync) {
$this->planUpdates($settings, $state, $matched, $plan);
}
@@ -211,10 +222,13 @@ class SyncPlanner
}
/** @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) {
$username = (string)$username;
if (isset($ambiguous[$username])) {
continue;
}
$user = $matched[$username] ?? null;
if ($user === null && $account->missingSince === null) {
$plan->missing[] = $username;
@@ -19,6 +19,14 @@ class CustomParams
{
private const MAX_PAIRS = 100;
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;
@@ -57,7 +65,7 @@ class CustomParams
/**
* 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
{
@@ -73,7 +81,7 @@ class CustomParams
self::$warnedOldFormat = true;
error_log("mailcow: \$IMAPSYNC_OPTIONS['whitelist'] (old format) is ignored, use 'option' => true|false|'regex' (see data/web/inc/vars.inc.php)");
}
return empty($allowlist) ? $defaults : $allowlist;
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 */
@@ -29,6 +29,7 @@ class Source
public string $host = '';
public int $port = 0;
public ?string $enc = 'TLS';
public int $verifyCert = 1;
public ?string $authType = 'PLAIN';
public string $oauthFlow = self::FLOW_CLIENT_CREDENTIALS;
public ?string $tokenEndpoint = null;
@@ -65,6 +66,7 @@ class Source
$source->scope = (string)($row['scope'] ?? 'user');
$source->host = (string)($row['host1'] ?? '');
$source->port = (int)($row['port1'] ?? 0);
$source->verifyCert = (int)($row['verify_cert'] ?? 1);
$source->oauthFlow = (string)($row['oauth_flow'] ?? self::FLOW_CLIENT_CREDENTIALS);
$source->tokenExpires = isset($row['oauth_token_expires']) ? (int)$row['oauth_token_expires'] : null;
$source->refreshAttemptAge = isset($row['refresh_attempt_age']) ? (int)$row['refresh_attempt_age'] : null;
@@ -112,6 +114,7 @@ class Source
'host1' => $this->host,
'port1' => $this->port,
'enc1' => $this->enc,
'verify_cert' => $this->verifyCert,
'auth_type' => $this->authType,
'oauth_flow' => $this->oauthFlow,
'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 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
{
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,
'port1' => $source->port,
'enc1' => $source->enc,
'verify_cert' => $source->verifyCert,
'auth_type' => $source->authType,
'oauth_flow' => $source->oauthFlow,
'oauth_token_endpoint' => $source->tokenEndpoint,
@@ -121,7 +121,11 @@ class SourceService
if ($writeTargets) {
$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);
return $source;
}
@@ -200,6 +204,24 @@ class SourceService
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 */
private function editable(int $id): Source
{
@@ -274,6 +296,7 @@ class SourceService
$source->host = $host;
$source->port = $port;
$source->enc = $enc;
$source->verifyCert = isset($input['verify_cert']) ? (int)!empty($input['verify_cert']) : $source->verifyCert;
$source->authType = $authType;
$source->active = isset($input['active']) ? (int)$input['active'] : $source->active;
}
@@ -42,6 +42,8 @@ class Group
public array $sendAs = [];
/** @var array[] {owner, rights, subscribe, state, last_error}: members may open these mailboxes (ACL group=mcg_<id>) */
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 */
public array $sogoGrants = [];
/** @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']]),
$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));
$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']]),
$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));
@@ -79,6 +79,12 @@ class GroupService
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;
}
@@ -113,7 +119,7 @@ class GroupService
$current = $this->editable($id);
$group = clone $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);
$group->warnings = array_merge($this->sizeWarnings($group), $this->effects->apply($sogo, $snapshot),
$this->sharing->applyImap((int)$group->id), $this->sharing->applySogo((int)$group->id));
@@ -132,7 +138,7 @@ class GroupService
{
$this->requireEditor();
$group = $this->editable($id);
$snapshot = $this->effects->snapshot($group->usernames());
$snapshot = $this->effects->snapshot($this->inScope($group->usernames()));
$this->sharing->removeImap($group);
$sogo = $this->repo->delete($id);
$group->warnings = $this->effects->apply($sogo, $snapshot);
@@ -150,13 +156,30 @@ class GroupService
if (!$own && !($this->canRead() && $this->context->canAccessMailbox($username))) {
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 [
'groups' => $this->lookup->membershipsOf($username),
'groups' => $groups,
'template' => $this->templates->effective([$username])[$username] ?? null,
'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 */
private function editable(int $id): Group
{
+4 -2
View File
@@ -19,8 +19,10 @@ if ($iam_provider){
header('Location: ' . $redirect_uri);
die();
}
} elseif ($_GET['code'] && $_GET['state'] === $_SESSION['oauth2state']) {
// Check given state against previously stored one to mitigate CSRF attack
} elseif (!empty($_GET['code']) && !empty($_SESSION['oauth2state']) && is_string($_GET['state'] ?? null)
&& 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']
// extract info and verify user
identity_provider('verify-sso');
+11 -5
View File
@@ -169,6 +169,12 @@ $SESSION_NAME = "MCSESSID";
// Label for OTP devices
$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
$DOCKER_TIMEOUT = 60;
@@ -199,6 +205,9 @@ $MAILBOX_DEFAULT_ATTRIBUTES['force_pw_update'] = false;
// Force 2FA enrollment at next login
$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)
$MAILBOX_DEFAULT_ATTRIBUTES['sogo_access'] = true;
@@ -291,13 +300,13 @@ $RSPAMD_MAPS = array(
// true = option requires a non-empty value
// false = pure flag, must NOT have a value
// string = PCRE (no delimiters) the whole non-empty value must match
// Anything not listed here is rejected when a job is saved (the runner passes stored options as they are).
// 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']);
// 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 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(
'abort' => false,
'authmd51' => false,
'authuser1' => true,
'debug' => false,
@@ -373,9 +382,6 @@ $IMAPSYNC_OPTIONS_DEFAULT = array(
'syncduplicates' => false,
'syncinternaldates' => false,
'synclabels' => false,
'tests' => false,
'testslive' => false,
'testslive6' => false,
'truncmess' => true,
'usecache' => false,
'useheader' => true,
+92
View File
@@ -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);
+76
View File
@@ -875,6 +875,81 @@ jQuery(function($){
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() {
$.ajax({
url: '/api/v1/get/rspamd/actions',
@@ -1295,6 +1370,7 @@ jQuery(function($){
onVisible("[id^=sasl_logs]", () => draw_sasl_logs());
onVisible("[id^=netfilter_log]", () => draw_netfilter_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_donut]", () => rspamd_pie_graph());
+5 -1
View File
@@ -184,7 +184,11 @@ var mailcowImapsync = (function($) {
dataSrc: function(json) {
if (!Array.isArray(json)) return [];
$.each(json, function(i, item) {
item.endpoint = wrapCell(esc((item.host || item.host1) + ':' + (item.port || item.port1) + ' (' + (item.enc || item.enc1) + ')'));
item.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);
if (item.description) item.name_display += '<div class="small text-muted">' + esc(item.description) + '</div>';
item.name_display = wrapCell(item.name_display);
+14
View File
@@ -232,6 +232,7 @@ $(document).ready(function() {
} else {
$('#addDomain_gal').prop('checked', false);
}
$('#addDomain_archive').prop('checked', template.archive == 1);
if (template.active == 1){
$('#addDomain_active').prop('checked', true);
@@ -433,6 +434,7 @@ $(document).ready(function() {
} else {
$('#force_tfa').prop('checked', false);
}
$('#addMailbox_archive').selectpicker('val', ['0', '1'].includes(String(template.archive)) ? String(template.archive) : 'inherit');
if (template.sogo_access == 1){
$('#sogo_access').prop('checked', true);
} 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&&'&#8212;');
}
},
{
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,
data: 'action',
+58 -2
View File
@@ -105,9 +105,28 @@ if (isset($_GET['query'])) {
'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) {
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);
echo json_encode(array(
'type' => 'error',
@@ -839,6 +858,26 @@ if (isset($_GET['query'])) {
}
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":
switch ($object) {
case "dovecot":
@@ -885,6 +924,11 @@ if (isset($_GET['query'])) {
}
echo (isset($logs) && !empty($logs)) ? json_encode($logs, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) : '{}';
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":
// 0 is first record, so empty is fine
if (isset($extra)) {
@@ -1549,7 +1593,14 @@ if (isset($_GET['query'])) {
if ($object == "sync-status") {
process_get_return(\Mailcow\Identity\IdentityModule::fromGlobals()->api->getStatus());
} 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 {
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' => '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],
// 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') {
@@ -1927,6 +1980,9 @@ if (isset($_GET['query'])) {
case "mailbox-group":
process_edit_return(\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->editGroups($items, $attr));
break;
case "archive":
process_edit_return(\Mailcow\Archive\ArchiveModule::fromGlobals()->api->edit($attr));
break;
case "recipient_map":
process_edit_return(recipient_map('edit', array_merge(array('id' => $items), $attr)));
break;
+86 -1
View File
@@ -3,6 +3,7 @@
"alias_domains": "Alias-Domains hinzufügen",
"alias_external_goto": "Aliase mit externen Ziel-Domains erlauben",
"app_passwds": "App-Passwörter verwalten",
"archive": "Archivierung von Domains und Mailboxen ändern",
"bcc_maps": "BCC-Maps",
"delimiter_action": "Delimiter-Aktionen (tags)",
"domain_desc": "Domainbeschreibung ändern",
@@ -138,6 +139,57 @@
"app_links": "App-Links",
"app_name": "App-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)",
"authed_user": "Auth. Benutzer",
"ays": "Soll der Vorgang wirklich ausgeführt werden?",
@@ -475,6 +527,7 @@
"ui_theme_default": "Standard",
"ui_theme_info": "Themes sind Ordner in <code>data/web/css/themes/&lt;name&gt;/</code> mit folgenden Dateien:<ul class=\"mb-0\"><li><code>theme.css</code> (erforderlich)</li><li><code>dark.css</code> (optional, Darkmode)</li><li><code>bootstrap.css</code> (optional, ersetzt das Bootstrap-Basistheme)</li><li><code>theme.json</code> (optional, <code>name</code> und <code>description</code>)</li></ul>",
"ui_theme_none": "Keine Themes gefunden.",
"unban_all": "Alle entsperren",
"unban_pending": "ausstehend",
"unchanged_if_empty": "Unverändert, wenn leer",
"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",
"app_name_empty": "App-Name darf nicht leer sein",
"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.",
"bcc_empty": "BCC-Ziel darf nicht leer sein",
"bcc_exists": "Ein BCC-Map-Eintrag %s existiert bereits als Typ %s",
@@ -641,6 +707,7 @@
"temp_error": "Temporärer Fehler",
"text_empty": "Text darf nicht leer sein",
"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.",
"tls_policy_map_dest_invalid": "Ziel ist ungültig",
"tls_policy_map_entry_exists": "Eine TLS-Richtlinie \"%s\" existiert bereits",
@@ -666,6 +733,7 @@
"template_name_invalid": "Name der Vorlage ungültig",
"required_data_missing": "Die benötigten Daten: %s fehlen",
"imapsync_source_in_use": "Sync-Quelle %s wird noch von einem Sync-Job verwendet und kann nicht gelöscht werden",
"imapsync_source_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_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",
@@ -777,6 +845,15 @@
"app_name": "App-Name",
"app_passwd": "App-Passwörter",
"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",
"bcc_dest_format": "BCC-Ziel muss eine gültige E-Mail-Adresse sein.",
"client_id": "Client-ID",
@@ -965,7 +1042,7 @@
"idp_ref": "IdP-Gruppe",
"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.",
"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",
"list_error": "Gruppen konnten nicht geladen werden",
"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_info": "Leer lassen, um keine Prüfung durchzuführen.<br>IPv4- sowie IPv6-Adressen und Subnetzwerke.",
"allowed_protocols": "Erlaubte Protokolle",
"archive": "Archiviert",
"backup_mx": "Relay-Domain",
"bcc": "BCC",
"bcc_destination": "BCC-Ziel",
@@ -1302,6 +1380,9 @@
"app_links": "Änderungen an App-Links wurden gespeichert",
"app_passwd_added": "App-Passwort wurde gespeichert",
"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_edited": "BCC-Map-Eintrag %s wurde geändert",
"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_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_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",
"copied": "In die Zwischenablage kopiert",
"source_view": "Anzeigen",
@@ -1712,6 +1796,7 @@
"pushover_sound": "Ton"
},
"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",
"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.",
+86 -1
View File
@@ -3,6 +3,7 @@
"alias_domains": "Add alias domains",
"alias_external_goto": "Allow aliases with external goto domains",
"app_passwds": "Manage app passwords",
"archive": "Change archiving of domains and mailboxes",
"bcc_maps": "BCC maps",
"delimiter_action": "Delimiter action",
"domain_desc": "Change domain description",
@@ -142,6 +143,57 @@
"app_links": "App links",
"app_name": "App 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)",
"authed_user": "Auth. user",
"ays": "Are you sure you want to proceed?",
@@ -485,6 +537,7 @@
"ui_theme_default": "Default",
"ui_theme_info": "Themes are folders in <code>data/web/css/themes/&lt;name&gt;/</code> with the following files:<ul class=\"mb-0\"><li><code>theme.css</code> (required)</li><li><code>dark.css</code> (optional, dark mode)</li><li><code>bootstrap.css</code> (optional, replaces the Bootstrap base theme)</li><li><code>theme.json</code> (optional, <code>name</code> and <code>description</code>)</li></ul>",
"ui_theme_none": "No themes found.",
"unban_all": "Unban all",
"unban_pending": "unban pending",
"unchanged_if_empty": "If unchanged leave blank",
"upload": "Upload",
@@ -507,6 +560,19 @@
"api_key_description_invalid": "API key description must not exceed 255 characters",
"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",
"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_description_invalid": "The group description must not exceed 255 characters",
"group_grant_invalid": "Invalid share for mailbox %s",
@@ -517,6 +583,7 @@
"group_send_as_invalid": "Send-as address %s is invalid or unknown",
"iam_sync_mode_invalid": "Invalid sync mode",
"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.",
"alias_domain_invalid": "Alias domain %s is invalid",
"alias_empty": "Alias address must not be empty",
@@ -666,6 +733,7 @@
"version_invalid": "Version %s is invalid",
"yotp_verification_failed": "Yubico OTP verification failed: %s",
"imapsync_source_in_use": "Sync source %s is still used by a sync job and cannot be deleted",
"imapsync_source_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_scope_empty": "Select at least one domain for the chosen visibility",
"imapsync_source_unavailable": "The selected sync source is unavailable",
@@ -777,6 +845,15 @@
"app_name": "App name",
"app_passwd": "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",
"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",
@@ -965,7 +1042,7 @@
"idp_ref": "IdP group",
"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.",
"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",
"list_error": "Groups could not be loaded",
"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_info": "Leave empty to allow all senders.<br>IPv4/IPv6 addresses and networks.",
"allowed_protocols": "Allowed protocols",
"archive": "Archived",
"backup_mx": "Relay domain",
"bcc": "BCC",
"bcc_destination": "BCC destination",
@@ -1309,6 +1387,9 @@
"app_links": "Saved changes to app links",
"app_passwd_added": "Added new app password",
"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_edited": "BCC map entry %s edited",
"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_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_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",
"copied": "Copied to clipboard",
"source_view": "Show",
@@ -1719,6 +1803,7 @@
"years": "years"
},
"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",
"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",
+5 -1
View File
@@ -12,6 +12,10 @@ if (!isset($_SESSION['mailcow_cc_role']) || $_SESSION['mailcow_cc_role'] != 'use
header('Location: index.php?mobileconfig' . $append_get);
die();
}
if (!empty($_SESSION['pending_tfa_setup']) || !empty($_SESSION['pending_pw_update'])) {
header('Location: /');
die();
}
error_reporting(0);
@@ -39,7 +43,7 @@ if (isset($_GET['only_email'])) {
$onlyEmailAccount = false;
$description = 'IMAP, CalDAV, CardDAV';
}
if (isset($_GET['app_password'])) {
if (isset($_GET['app_password']) && ($_SESSION['acl']['app_passwds'] ?? '0') == '1') {
$app_password = true;
$description .= ' with application password';
+3 -1
View File
@@ -35,7 +35,9 @@ if (empty($_SESSION['mailcow_cc_username']) || empty($_SESSION['mailcow_cc_role'
}
$context = \Mailcow\Core\Context::fromSession($_SESSION);
$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'));
}
+2
View File
@@ -29,6 +29,7 @@
</ul>
</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 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>
@@ -47,6 +48,7 @@
{% include 'admin/tab-config-oauth2.twig' %}
{% include 'admin/tab-config-rspamd.twig' %}
{% include 'admin/tab-routing.twig' %}
{% include 'admin/tab-archive.twig' %}
{% include 'admin/tab-config-dkim.twig' %}
{% include 'admin/tab-config-fwdhosts.twig' %}
{% include 'admin/tab-config-f2b.twig' %}
+168
View File
@@ -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>
</div>
</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 %}
<p>
<span class="badge fs-7 bg-info d-block d-sm-inline-block">
+16
View File
@@ -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-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-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"><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>
@@ -547,6 +548,21 @@
</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> <!-- /col-md-12 -->
</div> <!-- /row -->
@@ -8,6 +8,7 @@
<input type="hidden" value="0" name="active">
<input type="hidden" value="0" name="backupmx">
<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_unknown_only">
@@ -70,6 +71,14 @@
</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="offset-sm-2 col-sm-10">
<div class="form-check">
+9
View File
@@ -25,6 +25,7 @@
<input type="hidden" value="0" name="active">
<input type="hidden" value="0" name="backupmx">
<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_unknown_only">
<div class="row mb-4">
@@ -133,6 +134,14 @@
</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>
<div class="row mb-2">
<div class="offset-sm-2 col-sm-10">
@@ -12,6 +12,7 @@
{% endif %}
<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="verify_cert">
<div class="row mb-2">
<label class="control-label col-sm-2" for="name">{{ lang.syncjobs.source_name }}</label>
<div class="col-sm-10">
@@ -99,6 +100,14 @@
</select>
</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">
<label class="control-label col-sm-2" for="auth_type">{{ lang.syncjobs.source_auth_type }}</label>
<div class="col-sm-10">
@@ -43,7 +43,7 @@
</td>
<td class="text-end">
{% 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 %}
</td>
</tr>
@@ -17,7 +17,7 @@
<template id="{{ id }}_template">
{% include 'edit/mailbox-group-grant.twig' with {'kind': kind, 'grant': {}} %}
</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 %}
<small class="text-muted d-block">{{ attribute(lang.groups, field ~ '_hint') }}</small>
</div>
@@ -114,6 +114,17 @@
</select>
</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">
<label class="control-label col-sm-2">ACL</label>
<div class="col-sm-10">
+13
View File
@@ -331,6 +331,19 @@
<small class="text-muted">{{ lang.edit.allow_from_smtp_info }}</small>
</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>
<div class="row">
<div class="offset-sm-2 col-sm-10">
+1
View File
@@ -81,6 +81,7 @@
var csrf_token = '{{ csrf_token }}';
var pagination_size = Math.trunc('{{ pagination_size }}');
var role = '{{ role }}';
var archive_enabled = {{ archive_enabled ? 1 : 0 }};
var is_dual = {{ is_dual }};
var ALLOW_ADMIN_EMAIL_LOGIN = {{ allow_admin_email_login }};
</script>
@@ -9,6 +9,7 @@
<div class="modal-body">
<form class="form-horizontal" data-cached-form="true" role="form" data-id="add_imapsync_source">
<input type="hidden" value="0" name="active">
<input type="hidden" value="0" name="verify_cert">
<div class="row mb-3">
<label class="control-label col-sm-2 text-sm-end">{{ lang.syncjobs.source_presets }}</label>
<div class="col-sm-10 small">
@@ -99,6 +100,14 @@
</select>
</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">
<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">
+40 -1
View File
@@ -39,7 +39,7 @@
<div class="row mb-4">
<label class="control-label col-sm-2 text-sm-end text-sm-end" for="description">{{ lang.mailbox.template }}</label>
<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>
</div>
</div>
@@ -154,6 +154,17 @@
</select>
</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">
<label class="control-label col-sm-2 text-sm-end text-sm-end">ACL</label>
<div class="col-sm-10">
@@ -360,6 +371,17 @@
</select>
</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">
<label class="control-label col-sm-2 text-sm-end text-sm-end">ACL</label>
<div class="col-sm-10">
@@ -451,6 +473,7 @@
<div class="modal-body">
<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="archive">
<input type="hidden" value="0" name="active">
<input type="hidden" value="0" name="backupmx">
<input type="hidden" value="0" name="relay_all_recipients">
@@ -525,6 +548,14 @@
</div>
</div>
{% 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="offset-sm-2 col-sm-10">
<div class="form-check">
@@ -664,6 +695,14 @@
</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="offset-sm-2 col-sm-10">
<div class="form-check">
@@ -77,6 +77,7 @@
{% endif %}
</div>
</div>
{% if acl.app_passwds == 1 %}
<div class="row mt-2">
<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>
@@ -94,6 +95,7 @@
{% endif %}
</div>
</div>
{% endif %}
<div class="row">
<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>
+15 -11
View File
@@ -1,5 +1,5 @@
# Version of the database schema
x-db-version: &db_version DB_VERSION=28102026_1300
x-db-version: &db_version DB_VERSION=06112026_1800
services:
@@ -194,20 +194,20 @@ services:
ofelia.enabled: "true"
ofelia.job-exec.phpfpm_identity_sync.schedule: "0 * * * * *"
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.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.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:
mailcow-network:
aliases:
- phpfpm
sogo-mailcow:
image: ghcr.io/mailcow/sogo:5.12.12-2
image: ghcr.io/mailcow/sogo:5.12.13-1
environment:
- *db_version
- DBNAME=${DBNAME}
@@ -259,7 +259,7 @@ services:
- sogo
dovecot-mailcow:
image: ghcr.io/mailcow/dovecot:2.3.21.1-3
image: ghcr.io/mailcow/dovecot:2.3.21.1-4
depends_on:
- mysql-mailcow
- netfilter-mailcow
@@ -295,6 +295,7 @@ services:
- MAILCOW_HOSTNAME=${MAILCOW_HOSTNAME}
- MAILCOW_PASS_SCHEME=${MAILCOW_PASS_SCHEME:-BLF-CRYPT}
- 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}
- MAILDIR_GC_TIME=${MAILDIR_GC_TIME:-7200}
- ACL_ANYONE=${ACL_ANYONE:-disallow}
@@ -347,7 +348,7 @@ services:
- dovecot
postfix-mailcow:
image: ghcr.io/mailcow/postfix:3.10.12-1
image: ghcr.io/mailcow/postfix:3.10.12-2
depends_on:
mysql-mailcow:
condition: service_started
@@ -374,6 +375,7 @@ services:
- REDISPASS=${REDISPASS}
- MAILCOW_HOSTNAME=${MAILCOW_HOSTNAME}
- SPAMHAUS_DQS_KEY=${SPAMHAUS_DQS_KEY:-}
- *db_version
cap_add:
- NET_BIND_SERVICE
ports:
@@ -427,7 +429,7 @@ services:
- php-fpm-mailcow
- sogo-mailcow
- rspamd-mailcow
image: ghcr.io/mailcow/nginx:1.30.6-1
image: ghcr.io/mailcow/nginx:1.30.6-2
dns:
- ${IPV4_NETWORK:-172.22.1}.254
environment:
@@ -447,6 +449,7 @@ services:
- IPV4_NETWORK=${IPV4_NETWORK:-172.22.1}
- NGINX_USE_PROXY_PROTOCOL=${NGINX_USE_PROXY_PROTOCOL:-n}
- TRUSTED_PROXIES=${TRUSTED_PROXIES:-}
- NGINX_MAX_BODY_SIZE=${NGINX_MAX_BODY_SIZE:-128m}
volumes:
- ./data/web:/web:ro,z
- ./data/conf/rspamd/dynmaps:/dynmaps:ro,z
@@ -514,7 +517,7 @@ services:
- acme
netfilter-mailcow:
image: ghcr.io/mailcow/netfilter:1.66
image: ghcr.io/mailcow/netfilter:1.67
stop_grace_period: 30s
restart: always
privileged: true
@@ -534,7 +537,7 @@ services:
- /lib/modules:/lib/modules:ro
watchdog-mailcow:
image: ghcr.io/mailcow/watchdog:2.12
image: ghcr.io/mailcow/watchdog:2.13
dns:
- ${IPV4_NETWORK:-172.22.1}.254
tmpfs:
@@ -552,6 +555,7 @@ services:
- acme-mailcow
- redis-mailcow
environment:
- *db_version
- IPV6_NETWORK=${IPV6_NETWORK:-fd4d:6169:6c63:6f77::/64}
- LOG_LINES=${LOG_LINES:-9999}
- TZ=${TZ}
@@ -609,7 +613,7 @@ services:
- watchdog
dockerapi-mailcow:
image: ghcr.io/mailcow/dockerapi:2.14
image: ghcr.io/mailcow/dockerapi:2.15
security_opt:
- label=disable
restart: always
+4
View File
@@ -215,6 +215,10 @@ HTTPS_BIND=
# Redirect HTTP connections to HTTPS - y/n
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
# ------------------------------
-19
View File
@@ -188,25 +188,6 @@ class BanManagerTest(unittest.TestCase):
self.bans.purge()
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__':
unittest.main()
+23 -24
View File
@@ -25,8 +25,8 @@ class NetfilterTest(unittest.TestCase):
netfilter.regexes.refresh(self.r)
return netfilter
def sasl(self, address):
return 'warning: unknown[%s]: SASL LOGIN authentication failed: UGFzc3dvcmQ6' % address
def failed(self, 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):
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):
netfilter = self.create()
netfilter.handle_line(self.sasl('1.2.3.4'))
netfilter.handle_line(self.sasl('1.2.3.4'))
netfilter.handle_line(self.failed('1.2.3.4'))
netfilter.handle_line(self.failed('1.2.3.4'))
self.r.hset('F2B_PERM_BANS', '198.51.100.1', '1')
netfilter.shutdown()
self.assertEqual(self.tables.rules, set())
@@ -59,52 +59,51 @@ class NetfilterTest(unittest.TestCase):
def test_handle_line_bans(self):
netfilter = self.create()
netfilter.handle_line(self.sasl('1.2.3.4'))
netfilter.handle_line(self.sasl('1.2.3.4'))
netfilter.handle_line(self.failed('1.2.3.4'))
netfilter.handle_line(self.failed('1.2.3.4'))
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):
netfilter = self.create()
netfilter.handle_line(self.sasl('1.2.3.4:'))
netfilter.handle_line(self.sasl('1.2.3.4:'))
netfilter.handle_line(self.failed('1.2.3.4:'))
netfilter.handle_line(self.failed('1.2.3.4:'))
self.assertEqual(self.tables.rules, {'1.2.3.4/32'})
def test_handle_line_ignores_private_and_invalid_addresses(self):
netfilter = self.create()
for _ in range(3):
netfilter.handle_line(self.sasl('192.168.1.1'))
netfilter.handle_line(self.sasl('::1'))
netfilter.handle_line(self.sasl('1.2.3'))
netfilter.handle_line(self.failed('192.168.1.1'))
netfilter.handle_line(self.failed('::1'))
netfilter.handle_line(self.failed('1.2.3'))
self.assertEqual(self.tables.rules, set())
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):
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)
self.assertEqual(self.tables.rules, {'1.2.3.4/32'})
def test_unavailable_auth_backend_is_no_failed_login(self):
# Dovecot logs the temporary failure, Postfix then the failed SASL login like for a wrong password
def test_postfix_and_dovecot_lines_are_no_failed_logins(self):
# mailcowauth.php reports failed logins, not when the auth backend was unavailable
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):
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.assertNotIn('1.2.3.4/32', self.r.hgetall('F2B_ACTIVE_BANS'))
def test_handle_line_survives_errors(self):
netfilter = self.create()
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')))
def test_watch_reconnects_after_errors(self):
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)
self.assertEqual(self.tables.rules, {'1.2.3.4/32'})
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):
netfilter = self.create()
netfilter.handle_line(self.sasl('1.2.3.4'))
netfilter.handle_line(self.sasl('1.2.3.4'))
netfilter.handle_line(self.failed('1.2.3.4'))
netfilter.handle_line(self.failed('1.2.3.4'))
self.tables.rules = set()
self.tables.calls = []
self.tables.recreate = True
@@ -151,8 +150,8 @@ class NetfilterTest(unittest.TestCase):
self.r.hset('F2B_BLACKLIST', '198.51.100.1', 1)
netfilter.update_lists()
self.assertEqual(self.tables.rules, {'198.51.100.1/32'})
netfilter.handle_line(self.sasl('1.2.3.4'))
netfilter.handle_line(self.sasl('1.2.3.4'))
netfilter.handle_line(self.failed('1.2.3.4'))
netfilter.handle_line(self.failed('1.2.3.4'))
self.assertNotIn('1.2.3.4/32', self.tables.rules)
def test_resolve_keeps_addresses_on_dns_errors(self):
+103 -17
View File
@@ -10,14 +10,35 @@ class RegexesTest(unittest.TestCase):
LINES = {
'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'),
'3': ('warning: unknown[203.0.113.6]: SASL LOGIN authentication failed: UGFzc3dvcmQ6', '203.0.113.6'),
'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'),
'6': ('auth: lua(user@example.org,203.0.113.9,<abcdef>): Password mismatch (SHA1 of given password: 0123ab)', '203.0.113.9'),
'7': ('auth: lua(nobody@example.org,203.0.113.10,<abcdef>): unknown user (SHA1 of given password: 0123ab)', '203.0.113.10'),
'8': ("SOGo[123]: Login from '203.0.113.11' for user nobody might not have worked", '203.0.113.11'),
'9': ('203.0.113.12 "GET /SOGo/so/ HTTP/1.1" 403 123 "-"', '203.0.113.12'),
'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'),
'5': ('NOQUEUE: reject: RCPT from [203.0.113.8]:52516: 550 5.5.1 Protocol error; from=<a@example.org> to=<b@example.org>',
'203.0.113.8'),
'6': ("[6714]: SOGoRootPage Login from '203.0.113.11' for user 'nobody@example.org' might not have worked - "
"password policy: 65535 grace: -1 expire: -1 bound: 0", '203.0.113.11'),
'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):
self.r = FakeRedis()
@@ -30,10 +51,82 @@ class RegexesTest(unittest.TestCase):
for rule_id, (line, address) in self.LINES.items():
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)
line = 'warning: unknown[203.0.113.6]: SASL LOGIN authentication failed: Connection lost to authentication server'
self.assertIsNone(self.regexes.match(line))
for line in ('warning: unknown[203.0.113.6]: SASL LOGIN authentication failed: (reason unavailable), sasl_username=a@example.org',
'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):
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.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):
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&lt;1&gt;']],
['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());
}
}
+60
View File
@@ -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();
}
}
+51
View File
@@ -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