Compare commits

..
59 Commits
Author SHA1 Message Date
FreddleSpl0it dc880d861c [Update] Keep ENABLE_IPV6 as set, do not disable IPv6 on hosts with a public IPv6 address 2026-11-10 17:04:50 +01:00
FreddleSpl0it a03fd8aaad Merge pull request #7500 from philippderdiedas/fix/update-sh-masquerade-bridge
[Update] Fix update.sh deleting mailcow's own MASQUERADE rule
2026-11-10 15:39:48 +01:00
FreddleSpl0it e3a90718c2 Merge pull request #7516 from wbilalz400/fix/webauthn-forced-enrollment-empty-credential
[Web] Fix forced WebAuthn TFA enrollment storing an empty credential
2026-11-10 15:21:30 +01:00
FreddleSpl0it 54a85ba439 Merge pull request #7540 from pReya/fix/table-ui-polish
[Web] Table UI polish (icon centering, tag heights, sort headers)
2026-11-10 14:07:39 +01:00
FreddleSpl0it 4ced82d554 Merge pull request #7548 from junkerderprovinz/fix/idp-ignore-ssl-error-key
[Web] Fix ignore_ssl_error key in identity provider settings
2026-11-10 13:55:15 +01:00
FreddleSpl0it f83e02b740 Merge pull request #7640 from mailcow/fix/7562
[Rspamd] Remove the URLhaus and MalwareBazaar maps retired by abuse.ch
2026-11-10 13:51:45 +01:00
FreddleSpl0it 04c9413ed9 [Rspamd] Remove the URLhaus and MalwareBazaar maps retired by abuse.ch 2026-11-10 13:50:48 +01:00
FreddleSpl0it 5e98d8e279 Merge pull request #7581 from peterschwps/fix/trim-logs-redis-password
[Dovecot] Mask Redis password in trim_logs output
2026-11-10 13:43:36 +01:00
FreddleSpl0it 4caecf77e0 Merge pull request #7610 from woodfairy/fix/brotli-cve-2026-111497
[Nginx][Clamd][Postfix] Rebuild images for brotli CVE-2026-111497 (CISA KEV)
2026-11-10 13:40:04 +01:00
FreddleSpl0it 37d304d075 Merge pull request #7544 from jorgeavlobo/fix/keycloak-admin-token-introspect
[Web] Keycloak: skip introspection of an expired admin token and reuse the new one
2026-11-10 01:45:16 +01:00
FreddleSpl0it 25086a9b3f Merge pull request #7488 from apio-sys/fix/rspamd-dqs-dbl-full-rule
[Rspamd] write full DBL rule when SPAMHAUS_DQS_KEY is set
2026-11-10 01:42:29 +01:00
FreddleSpl0it 7a3f1cbd41 Merge pull request #7530 from ThxGiving/fix/eas-auth-challenge
[Nginx] Send Basic auth challenge for unauthenticated ActiveSync requests
2026-11-10 01:39:28 +01:00
FreddleSpl0it 616717bc0d Merge pull request #7639 from mailcow/fix/7580
[Rspamd] Remove MX check overrides that rspamd ignores
2026-11-10 01:28:42 +01:00
FreddleSpl0it 6fd271ee64 [Rspamd] Remove MX check overrides that rspamd ignores 2026-11-10 01:27:52 +01:00
FreddleSpl0it 7459cc046a Merge pull request #7638 from mailcow/fix/7579
[Web] Template lists: sort size columns numerically
2026-11-10 01:08:36 +01:00
FreddleSpl0it 58dad73eee [Web] Template lists: sort size columns by bytes 2026-11-10 01:07:32 +01:00
FreddleSpl0it b21acd4787 Merge pull request #7637 from mailcow/fix/7557
[Web] Domain admins saw sender aliases of other domains
2026-11-10 01:02:39 +01:00
FreddleSpl0it 4ab954dcd1 [Web] Domain admins: show sender ACL and alias entries of their own domains only 2026-11-10 01:01:22 +01:00
FreddleSpl0it eefc17274e Merge pull request #7636 from mailcow/fix/7545
[Rspamd] WHITELISTED_FWD_HOST was skipped at the reject threshold
2026-11-10 00:57:45 +01:00
FreddleSpl0it 4878840bdd [Rspamd] Forwarding hosts: check them as prefilter and scan the mail completely 2026-11-10 00:56:53 +01:00
FreddleSpl0it 869bcb3c42 Merge pull request #7635 from mailcow/fix/7518
[Rspamd] Domain footer broke DKIM for mail without Content-Transfer-Encoding
2026-11-09 23:36:42 +01:00
FreddleSpl0it 0bcde13500 [Rspamd] Domain footer: add the Content-Transfer-Encoding header to the delivered mail 2026-11-09 23:34:57 +01:00
FreddleSpl0it 867b9dddab Merge pull request #7634 from mailcow/fix/7503
[Web] Keycloak: accept Base64-encoded mailcow_password
2026-11-09 23:30:20 +01:00
FreddleSpl0it a4326e6642 [Web] Keycloak Mailpassword Flow: accept Base64-encoded password hashes 2026-11-09 23:28:53 +01:00
FreddleSpl0it c5e89a9e03 Merge pull request #7633 from mailcow/fix/7501
[Web] Forwarding hosts: store IPv6 addresses in canonical form
2026-11-09 23:25:40 +01:00
FreddleSpl0it 45d31b3279 [Web] Forwarding hosts: store IPv6 addresses in canonical form 2026-11-09 23:24:39 +01:00
FreddleSpl0it 24780ea5fb Merge pull request #7632 from mailcow/fix/7489
[Rspamd] Quarantine stored nothing without Q_EXCLUDE_DOMAINS
2026-11-09 23:22:11 +01:00
FreddleSpl0it 38b031e10c [Rspamd] Quarantine: store mail when Q_EXCLUDE_DOMAINS is not set 2026-11-09 23:21:14 +01:00
FreddleSpl0it e2fb833ceb Merge pull request #7631 from mailcow/fix/7482
[Web] Autodiscover: no ActiveSync when autodiscoverType is imap
2026-11-09 23:18:50 +01:00
FreddleSpl0it e27cb83326 [Web] Autodiscover: no ActiveSync when autodiscoverType is imap 2026-11-09 23:17:55 +01:00
FreddleSpl0it f810cecd3a Merge pull request #7630 from mailcow/fix/7476
[Web] force_tfa did not prevent removing 2FA
2026-11-09 23:14:38 +01:00
FreddleSpl0it cfad223824 [Web] force_tfa: read the flag with JSON_VALUE 2026-11-09 23:13:26 +01:00
FreddleSpl0it 6d20463acb Merge pull request #7629 from mailcow/fix/7454
[Nginx] Edit and quarantine links: allow a trailing slash
2026-11-09 23:09:05 +01:00
FreddleSpl0it 1a42c19050 [Nginx] Accept a trailing slash in /edit and /qhandler links 2026-11-09 23:08:14 +01:00
FreddleSpl0it b9f8eb4c31 Merge pull request #7628 from mailcow/fix/7449
[Postfix-TLSPol] Update to 1.14.5
2026-11-09 23:05:35 +01:00
FreddleSpl0it ce4150f921 [Postfix-TLSPol] Update to 1.14.5 2026-11-09 23:04:33 +01:00
FreddleSpl0it 7cc8cac705 Merge pull request #7627 from mailcow/fix/7318
[Rspamd] Mails between 50 and 100 MiB were deferred forever
2026-11-09 23:01:43 +01:00
FreddleSpl0it 3fd1d017cf [Rspamd] Raise max_message above the Postfix message size limit 2026-11-09 23:00:38 +01:00
FreddleSpl0it 8597954f91 Merge pull request #7626 from mailcow/fix/7179
[Rspamd] Sub-addressing: read the tag from the original SMTP recipient
2026-11-09 22:55:05 +01:00
FreddleSpl0it 2b4ffe8808 [Rspamd] Sub-addressing: read the tag from the original SMTP recipient 2026-11-09 22:53:07 +01:00
FreddleSpl0it 1911847c3b Merge pull request #7625 from mailcow/fix/6931
[Web] Update "Last modified" when tags of a domain or mailbox change
2026-11-09 22:48:21 +01:00
FreddleSpl0it 497872080b [Web] Update "Last modified" when tags of a domain or mailbox change 2026-11-09 22:46:15 +01:00
FreddleSpl0it a0dc3447f8 Merge pull request #7624 from mailcow/fix/6853
[Web][Dovecot] Mailbox list and API: show the last SOGo login
2026-11-09 22:42:27 +01:00
FreddleSpl0it e0640949a2 [Web][Dovecot] Show the last SOGo login in the mailbox list and API 2026-11-09 22:39:42 +01:00
milkmaker bf744413bd Translations update from Weblate (#7623)
* [Web] Language file updated by 'Cleanup translation files' addon

Co-authored-by: milkmaker <milkmaker@mailcow.de>

* [Web] Language file updated by 'Cleanup translation files' addon

Co-authored-by: milkmaker <milkmaker@mailcow.de>
2026-11-09 21:41:36 +01:00
FreddleSpl0it 262de81da1 Merge pull request #7621 from mailcow/fix/7606
[Dockerapi] Group IMAP grants: set ACLs only on existing folders
2026-11-09 19:43:38 +01:00
FreddleSpl0it 5f13bb2046 [Dockerapi] Group IMAP grants: set ACLs only on existing folders 2026-11-09 19:41:17 +01:00
woodfairy 0a0686ef19 [Nginx][Clamd][Postfix] Rebuild images for brotli CVE-2026-111497
The current nginx, clamd and postfix images ship a brotli decoder in the
affected range 1.0.3 - 1.2.0 (CVE-2026-111497, CVSS 9.8, listed in CISA KEV):

- nginx:1.30.6-2   brotli-libs 1.2.0-r1 (fixed in Alpine 3.24: 1.2.1-r0)
- clamd:1.4.6-1    brotli-libs 1.2.0-r1 (fixed in Alpine 3.24: 1.2.1-r0)
- postfix:3.10.12-1 libbrotli1 1.1.0-2+b7 (fixed in trixie-security:
  1.1.0-2+deb13u1, DSA-6582-1)

All other mailcow images already contain a fixed version.

clamd and postfix only need a rebuild, which pulls the fixed packages.
The postfix rebuild also picks up Debian's postfix 3.10.13, hence the
new tag postfix:3.10.13-1. The
nginx image never upgraded the packages of its base image, so it only got
fixes when nginx:<version>-alpine was republished. It now runs
apk upgrade like clamd and acme do.
2026-11-08 00:49:55 +01:00
Peter Schwips 24b91f6089 [Dovecot] Mask Redis password in trim_logs output 2026-10-30 23:54:21 +01:00
Junker der Provinzanddiko 5705a48724 [Web] Fix ignore_ssl_error key in identity provider settings
The edit action stores the setting as ignore_ssl_error, but the get
action cast and defaulted ignore_ssl_errors. The stored value was
returned as a string and LDAP setups got an extra ignore_ssl_errors
field that was always false.

Co-authored-by: diko <108140792+admins-little-helper@users.noreply.github.com>
2026-10-24 20:05:10 +02:00
Jorge Lobo 2f76230daf [Web] Keycloak: skip introspection of expired admin token 2026-10-23 00:52:03 +01:00
Moritz Stückler 498434ee3d [Web] Even out spacing of the expanded table row
DataTables removes the top padding of the first entry in the expanded
row, but mailcow's 0.5em padding left 7px under the last entry, so the
list had 7px of space above and 15px below and the last entry (often
Tags) looked taller than the others. Drop the last entry's bottom
padding as well.

Signed-off-by: Moritz Stückler <moritz@bitbetter.de>
2026-10-22 13:10:49 +02:00
Moritz Stückler f662af4c50 [Web] Even out badge and tag sizes in tables
- Use the normal cursor on non-orderable headers. The expand column
  header kept a pointer cursor from sorting_asc although clicking it
  does nothing.
- Let icons in badges inherit the badge font size, like .btn .bi, and
  keep their glyph from adding line height. The global 12pt icon size
  made e.g. the "SSO @" badge in Last mail login taller than the IMAP
  and SMTP badges next to it. Tag badges keep their 12px icon.
- Drop the vertical margin of tag badges inside tables. It is meant for
  the tag editor in edit forms and made the Tags entry of the expanded
  row taller than the others.
- Center tag badge captions: the 12px tag icon stretched the 10.5px
  badge line and pushed the caption below center.

Signed-off-by: Moritz Stückler <moritz@bitbetter.de>
2026-10-22 13:06:00 +02:00
Moritz Stückler 8d4895a54d [Web] Align the table expand control and row checkbox
- Center the +/- glyph in the expand control: its line-height (1.25em)
  was larger than the 1em box, pushing the glyph down. Derive the box's
  margin-top from its size so it is centered in the row (desktop and
  the larger mobile variant).
- Hide sort arrows on non-orderable headers. The default initial order
  targets column 0, the expand column, which got a sorting_asc arrow
  and arrow padding although it cannot be sorted.
- Center row checkboxes level with the expand control by using
  Bootstrap's 0.25em margin-top instead of a fixed 7px.

Signed-off-by: Moritz Stückler <moritz@bitbetter.de>
2026-10-22 12:43:18 +02:00
Moritz Stückler 81d6b1f547 [Web] Clearer captions for domain table columns
Caption the "RL" column "Rate limit" using the existing translated
edit.ratelimit string, and shorten "Max. size of a mailbox" to
"Max. mailbox size" (en-gb only; the key is only used for this column).

Signed-off-by: Moritz Stückler <moritz@bitbetter.de>
2026-10-22 12:43:18 +02:00
Sebastian Müller 8db3cae826 [Nginx] Send Basic auth challenge for unauthenticated ActiveSync requests
Outlook for iOS/Android connects through Microsoft's sync service, which
sends each EAS command without credentials first and only retries with
Basic auth after a 401 + WWW-Authenticate challenge. SOGo answers these
unauthenticated POSTs (Provision, FolderSync, Sync, ...) with 403, so the
Outlook apps never get past Provision and show an empty mailbox (#5846).

Answer requests without an Authorization header with a Basic challenge
in nginx before they reach SOGo. Clients that send credentials up front
are not affected.
2026-10-20 20:48:30 +02:00
Bilal Zahid d93f32072e fix(web): post WebAuthn TFA enrollment through the JSON API
The forced TFA enrollment form submitted the raw credential JSON in the
'token' field, which set_tfa()'s webauthn case never reads. It expects the
parsed \['registration'] object that only the JSON API builds, so the
form POST inserted a row with an empty keyHandle and a NULL publicKey while
still reporting success. Every WebAuthn 2FA login then failed, because
webauthn-tfa-get-args sent an empty credential id in allowCredentials.

- base.twig: POST the attestation to /api/v1/add/webauthn-tfa-registration
  like the regular WebAuthn modal does
- functions.inc.php: refuse incomplete WebAuthn registrations
- lang.en-gb.json: message for the new guard
2026-10-13 19:25:17 +05:00
Philipp Harms 7b658fa65c [Update] Fix update.sh deleting mailcow's own MASQUERADE rule
The "Checking for conflicting bridges" step extracts the bridge name with
`cut -d':' -f2`, which yields " br-mailcow" (leading space). Since the
variable got quoted in `grep -v "${MAILCOW_BRIDGE}"`, the leading space is
significant and never matches "!br-mailcow" in the `iptables -L` output, so
the rule `-s <IPV4_NETWORK>.0/24 ! -o br-mailcow -j MASQUERADE` itself gets
deleted. The mailcow network loses outbound NAT, unbound turns unhealthy and
dependent containers stay in "Created" until Docker is restarted.

Strip whitespace/quotes from the bridge name, match the interface as a whole
token, and skip the cleanup if the bridge name could not be determined.
2026-10-02 18:53:12 +02:00
Joris Le BlanschandClaude Opus 5.5 9f934af3f2 [Rspamd] write full DBL rule when SPAMHAUS_DQS_KEY is set
The generated "DBL" block only set rbl and disable_monitoring, assuming
it would merge into the stock rule. It is included with override=true,
so it replaced the stock rule instead: DBL lost all its checks (urls,
emails, dkim, helo, rdns, replyto), no_ip, selectors and returncodes,
and rspamd fell back to querying the client IP against DBL.

Write the full stock rule pointed at the DQS zone, like the ZRD block.

Fixes #7485

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:20:23 +02:00
89 changed files with 343 additions and 4544 deletions

No files matched your search

+46 -21
View File
@@ -3,6 +3,11 @@
# THIS SCRIPT IS DESIGNED TO BE RUNNING BY MAILCOW SCRIPTS ONLY!
# DO NOT, AGAIN, NOT TRY TO RUN THIS SCRIPT STANDALONE!!!!!!
# Global IPv6 address that is no unique local address (fc00::/7, e.g. Docker bridges)
_has_public_ipv6() {
ip -6 -o addr show scope global 2>/dev/null | awk '{print $4}' | grep -qviE '^f[cd]'
}
# 1) Check if the host supports IPv6
get_ipv6_support() {
# ---- helper: probe external IPv6 connectivity without DNS ----
@@ -37,6 +42,9 @@ get_ipv6_support() {
if _probe_ipv6_connectivity; then
DETECTED_IPV6=true
echo -e "IPv6 detected on host – ${LIGHT_GREEN}leaving IPv6 support enabled${YELLOW}.${NC}"
elif _has_public_ipv6; then
DETECTED_IPV6=true
echo -e "${YELLOW}External IPv6 connectivity test failed, but the host has a public IPv6 address – ${LIGHT_GREEN}leaving IPv6 support enabled${YELLOW}.${NC}"
else
DETECTED_IPV6=false
echo -e "${YELLOW}Default IPv6 route present but external IPv6 connectivity failed – ${LIGHT_RED}disabling IPv6 support${YELLOW}.${NC}"
@@ -192,9 +200,31 @@ EOF
fi
}
_set_enable_ipv6() {
if [[ -n "$MAILCOW_CONF" && -f "$MAILCOW_CONF" ]]; then
if grep -q '^ENABLE_IPV6=' "$MAILCOW_CONF"; then
sed -i "s/^ENABLE_IPV6=.*/ENABLE_IPV6=$1/" "$MAILCOW_CONF"
else
echo "ENABLE_IPV6=$1" >> "$MAILCOW_CONF"
fi
else
export IPV6_BOOL=$1
fi
}
_warn_ipv6_disabled() {
_has_public_ipv6 || return 0
ip -6 route show default 2>/dev/null | grep -qE '^default' || return 0
grep -Eq '"userland-proxy"[[:space:]]*:[[:space:]]*false' /etc/docker/daemon.json 2>/dev/null && return 0
echo -e "${LIGHT_RED}WARNING: ENABLE_IPV6=false, but this host has a public IPv6 address.${NC}"
echo -e "${YELLOW}Docker forwards IPv6 connections to mailcow with an internal source address, which Postfix trusts. This can make your server an open relay over IPv6.${NC}"
echo -e "${YELLOW}Set ENABLE_IPV6=true in mailcow.conf, or set \"userland-proxy\": false in /etc/docker/daemon.json and restart Docker.${NC}"
echo -e "${YELLOW}See https://docs.mailcow.email/post_installation/firststeps-disable_ipv6/${NC}"
sleep 5
}
# 3) Main wrapper for generate_config.sh and update.sh
configure_ipv6() {
# detect manual override if mailcow.conf is present
if [[ -n "$MAILCOW_CONF" && -f "$MAILCOW_CONF" ]] && grep -q '^ENABLE_IPV6=' "$MAILCOW_CONF"; then
MANUAL_SETTING=$(grep '^ENABLE_IPV6=' "$MAILCOW_CONF" | cut -d= -f2)
elif [[ -z "$MAILCOW_CONF" ]] && [[ -n "${ENABLE_IPV6:-}" ]]; then
@@ -203,19 +233,23 @@ configure_ipv6() {
MANUAL_SETTING=""
fi
if [[ "${MANUAL_SETTING,,}" == "false" ]]; then
_set_enable_ipv6 false
echo "ENABLE_IPV6=false is set, skipping Docker IPv6 configuration."
_warn_ipv6_disabled
echo "IPv6 configuration complete: ENABLE_IPV6=false"
return
fi
get_ipv6_support
# if user manually set it, check for mismatch
if [[ "$DETECTED_IPV6" != "true" && "${MANUAL_SETTING,,}" == "true" ]] && _has_public_ipv6; then
echo -e "${YELLOW}Keeping ENABLE_IPV6=true, the host has a public IPv6 address.${NC}"
DETECTED_IPV6=true
fi
if [[ "$DETECTED_IPV6" != "true" ]]; then
if [[ -n "$MAILCOW_CONF" && -f "$MAILCOW_CONF" ]]; then
if grep -q '^ENABLE_IPV6=' "$MAILCOW_CONF"; then
sed -i 's/^ENABLE_IPV6=.*/ENABLE_IPV6=false/' "$MAILCOW_CONF"
else
echo "ENABLE_IPV6=false" >> "$MAILCOW_CONF"
fi
else
export IPV6_BOOL=false
fi
_set_enable_ipv6 false
echo "Skipping Docker IPv6 configuration because host does not support IPv6."
echo "Make sure to check if your docker daemon.json does not include \"enable_ipv6\": true if you do not want IPv6."
echo "IPv6 configuration complete: ENABLE_IPV6=false"
@@ -224,16 +258,7 @@ configure_ipv6() {
fi
docker_daemon_edit
if [[ -n "$MAILCOW_CONF" && -f "$MAILCOW_CONF" ]]; then
if grep -q '^ENABLE_IPV6=' "$MAILCOW_CONF"; then
sed -i 's/^ENABLE_IPV6=.*/ENABLE_IPV6=true/' "$MAILCOW_CONF"
else
echo "ENABLE_IPV6=true" >> "$MAILCOW_CONF"
fi
else
export IPV6_BOOL=true
fi
_set_enable_ipv6 true
echo "IPv6 configuration complete: ENABLE_IPV6=true"
}
@@ -586,8 +586,8 @@ class DockerApi:
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"'
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"')
script = ('set -o pipefail; doveadm -f tab mailbox status -u "$ACL_USER" guid "*" | tail -n +2 | cut -f1 | while IFS= read -r folder; do '
'case "$folder" in Shared|Shared/*) continue;; esac; ' + action + ' || exit 1; done && doveadm acl recalc -u "$ACL_USER"')
acl_return = container.exec_run(["/bin/bash", "-c", script],
environment={ 'ACL_USER': user, 'ACL_ID': id, 'ACL_RIGHTS': ' '.join(rights) })
return self.exec_run_handler('generic', acl_return)
+1 -1
View File
@@ -4,7 +4,7 @@ catch_non_zero() {
${CMD} > /dev/null
EC=$?
if [ ${EC} -ne 0 ]; then
echo "Command ${CMD} failed to execute, exit code was ${EC}"
echo "Command ${CMD//${REDISPASS}/***} failed to execute, exit code was ${EC}"
fi
}
source /source_env.sh
+2 -1
View File
@@ -3,7 +3,8 @@ LABEL maintainer "The Infrastructure Company GmbH <info@servercow.de>"
ENV PIP_BREAK_SYSTEM_PACKAGES=1
RUN apk add --no-cache nginx \
RUN apk upgrade --no-cache && \
apk add --no-cache nginx \
python3 \
py3-pip && \
pip install --upgrade pip && \
+1 -1
View File
@@ -4,7 +4,7 @@ WORKDIR /src
ENV CGO_ENABLED=0 \
GO111MODULE=on \
NOOPT=1 \
VERSION=1.14.0
VERSION=1.14.5
RUN git clone --branch v${VERSION} https://github.com/Zuplu/postfix-tlspol && \
cd /src/postfix-tlspol && \
-54
View File
@@ -15,21 +15,6 @@ 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
@@ -178,45 +163,6 @@ 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}
+26 -1
View File
@@ -277,9 +277,34 @@ if [[ ! -z ${SPAMHAUS_DQS_KEY} ]]; then
}
}
"DBL" {
# override the defaults for DBL defined in modules.d/rbl.conf
# full copy of the DBL rule from modules.d/rbl.conf, pointed at DQS:
# the override include replaces the stock rule instead of merging into it
rbl = "${SPAMHAUS_DQS_KEY}.dbl.dq.spamhaus.net";
disable_monitoring = true;
checks = ["emails", "dkim", "helo", "rdns", "replyto", "urls", "content_urls"];
no_ip = true;
ignore_defaults = true;
exclude_users = false;
emails_domainonly = true;
selector = {
from_smtp = "from('smtp'):domain";
from_mime = "from('mime'):domain";
mid = "header(Message-Id).regexp('@([^\.]+\.[^>]+)').last";
}
returncodes = {
DBL_SPAM = "127.0.1.2";
DBL_PHISH = "127.0.1.4";
DBL_MALWARE = "127.0.1.5";
DBL_BOTNET = "127.0.1.6";
DBL_ABUSE = "127.0.1.102";
DBL_ABUSE_REDIR = "127.0.1.103";
DBL_ABUSE_PHISH = "127.0.1.104";
DBL_ABUSE_MALWARE = "127.0.1.105";
DBL_ABUSE_BOTNET = "127.0.1.106";
DBL_PROHIBIT = "127.0.1.255";
DBL_BLOCKED_OPENRESOLVER = "127.255.255.254";
DBL_BLOCKED = "127.255.255.255";
}
}
"ZRD" {
ignore_defaults = true;
+2 -2
View File
@@ -116,8 +116,8 @@ if ($result === false){
exit;
}
if ($result) {
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']);
error_log('MAILCOWAUTH: User auth for user ' . $post['username'] . " with service " . $service . " from IP " . $client_ip);
set_sasl_log($post['username'], $client_ip, $service);
}
}
@@ -94,11 +94,11 @@ location / {
}
location /qhandler {
rewrite ^/qhandler/(.*)/(.*) /qhandler.php?action=$1&hash=$2;
rewrite ^/qhandler/([^/]+)/(.+?)/?$ /qhandler.php?action=$1&hash=$2;
}
location /edit {
rewrite ^/edit/(.*)/(.*) /edit.php?$1=$2;
rewrite ^/edit/([^/]+)/(.+?)/?$ /edit.php?$1=$2;
}
location ~ ^/api/v1/(.*)$ {
@@ -187,6 +187,12 @@ location /sogo-auth-verify {
}
location ^~ /Microsoft-Server-ActiveSync {
# Outlook for iOS/Android (Microsoft's sync service) sends EAS commands without credentials
# and only authenticates after a 401 Basic challenge. SOGo answers those with 403, so challenge here.
if ($http_authorization = "") {
add_header WWW-Authenticate 'Basic realm="SOGo"' always;
return 401;
}
auth_request /sogo-auth-verify;
auth_request_set $user $upstream_http_x_user;
auth_request_set $auth $upstream_http_x_auth;
-1
View File
@@ -60,7 +60,6 @@ $_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,3 +1,2 @@
/watchdog@localhost$/ watchdog_discard:
/^archive-bounce@localhost$/ archive_discard:
/localhost$/ local:
-3
View File
@@ -50,9 +50,6 @@ 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,
+1 -24
View File
@@ -61,15 +61,13 @@ submission inet n - n - - smtpd
-o non_smtpd_milters=
-o syslog_name=postfix/quarantine
# used to send bcc mails and archive copies (sender archive-bounce@localhost goes to the archive)
# the archive FILTER comes after the one of MX-based transports, Postfix uses the last FILTER
# used to send bcc mails
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_recipient_restrictions=check_recipient_mx_access,proxy:mysql:/opt/postfix/conf/sql/mysql_mbr_access_maps.cf,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
@@ -83,27 +81,6 @@ 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
-47
View File
@@ -1,47 +0,0 @@
<?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, it keeps the global switch; without it nothing is archived
$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);
}
-4
View File
@@ -1,7 +1,3 @@
MX_IMPLICIT {
expression = "MX_GOOD & MX_MISSING";
score = -0.01;
}
VIRUS_FOUND {
expression = "CLAM_VIRUS & !MAILCOW_WHITE";
score = 2000.0;
-18
View File
@@ -23,24 +23,6 @@ symbols {
}
}
group "MX" {
"MX_INVALID" {
score = 0.5;
description = "No connectable MX";
one_shot = true;
}
"MX_MISSING" {
score = 2.0;
description = "No MX record";
one_shot = true;
}
"MX_GOOD" {
score = -0.01;
description = "MX was ok";
one_shot = true;
}
}
group "reputation" {
symbols = {
"IP_REPUTATION_HAM" {
-20
View File
@@ -5,12 +5,6 @@ RCPT_MAILCOW_DOMAIN {
symbols_set = ["RCPT_MAILCOW_DOMAIN"];
}
WHITELISTED_FWD_HOST {
type = "ip";
map = "redis://WHITELISTED_FWD_HOST";
symbols_set = ["WHITELISTED_FWD_HOST"];
}
BULK_HEADER {
type = "content";
map = "${LOCAL_CONFDIR}/custom/bulk_header.map";
@@ -151,20 +145,6 @@ BAD_LANG {
score = 5.0;
}
BAZAAR_ABUSE_CH {
type = "selector";
selector = "attachments(hex,md5)";
map = "https://bazaar.abuse.ch/export/txt/md5/recent/";
score = 10.0;
}
URLHAUS_ABUSE_CH {
type = "selector";
selector = "urls";
map = "https://urlhaus.abuse.ch/downloads/text_online/";
score = 10.0;
}
SMTP_LIMITED_ACCESS {
type = "user";
map = "redis://SMTP_LIMITED_ACCESS";
-1
View File
@@ -1,6 +1,5 @@
timeout = 8.0;
symbol_bad_mx = "MX_INVALID";
symbol_no_mx = "MX_MISSING";
symbol_good_mx = "MX_GOOD";
expire = 86400;
key_prefix = "rmx";
+2
View File
@@ -9,3 +9,5 @@ disable_monitoring = true;
# instead of silently accepting the message without further processing.
soft_reject_on_timeout = true;
local_addrs = /etc/rspamd/custom/mailcow_networks.map;
# Must not be lower than message_size_limit in postfix main.cf
max_message = 134217728;
+63 -137
View File
@@ -362,6 +362,61 @@ rspamd_config:register_symbol({
priority = 19
})
rspamd_config:register_symbol({
name = 'WHITELISTED_FWD_HOST',
type = 'prefilter',
score = 0.0,
callback = function(task)
local rspamd_logger = require "rspamd_logger"
local redis_params = rspamd_parse_redis_server('fwd_host')
local ip = task:get_from_ip()
if ip == nil or not ip:is_valid() then
return false
end
local from_ip_string = tostring(ip)
local ip_check_table = {'WHITELISTED_FWD_HOST', from_ip_string}
local maxbits = 128
local minbits = 7
if ip:get_version() == 4 then
maxbits = 32
minbits = 8
end
for i=maxbits,minbits,-1 do
table.insert(ip_check_table, ip:apply_mask(i):to_string() .. "/" .. i)
end
local function fwd_host_cb(err, data)
if err or type(data) ~= 'table' then
rspamd_logger.infox(rspamd_config, "fwd_host query request for ip %s returned invalid or empty data (\"%s\") or error (\"%s\")", ip, data, err)
return
end
for _,v in pairs(data) do
if type(v) == 'string' then
task:insert_result('WHITELISTED_FWD_HOST', 1.0, from_ip_string)
task:set_flag('pass_all')
return
end
end
end
local redis_ret = rspamd_redis_make_request(task,
redis_params, -- connect params
'WHITELISTED_FWD_HOST', -- hash key
false, -- is write
fwd_host_cb, --callback
'HMGET', -- command
ip_check_table -- arguments
)
if not redis_ret then
rspamd_logger.infox(rspamd_config, "cannot check fwd_host redis map")
end
end,
priority = 18
})
rspamd_config:register_symbol({
name = 'TLS_HEADER',
type = 'postfilter',
@@ -390,9 +445,8 @@ rspamd_config:register_symbol({
local rspamd_logger = require "rspamd_logger"
local redis_params = rspamd_parse_redis_server('taghandler')
local rspamd_http = require "rspamd_http"
local rcpts = task:get_recipients('smtp')
local rcpts = task:get_recipients({'smtp', 'orig'})
local lua_util = require "lua_util"
local tagged_rcpt = task:get_symbol("TAGGED_RCPT")
local function remove_moo_tag()
local moo_tag_header = task:get_header('X-Moo-Tag', false)
@@ -417,8 +471,9 @@ rspamd_config:register_symbol({
-- Check if recipient has a tag (contains '+')
local tag = nil
if tagged_rcpt ~= nil then
tag = tagged_rcpt
local base_user, tag_part = rcpt_user:match('^([^%+]+)%+(.+)$')
if base_user and tag_part then
tag = tag_part
rspamd_logger.infox("TAG_MOO: found tag in recipient: %s (base: %s, tag: %s)", rcpt_addr, base_user, tag)
end
@@ -498,8 +553,7 @@ rspamd_config:register_symbol({
else
rspamd_logger.infox("TAG_MOO: user wants subject modified for tagged mail")
local sbj = task:get_header('Subject') or ''
local tag_value = tag[1] and tag[1].options and tag[1].options[1] or ''
new_sbj = '=?UTF-8?B?' .. tostring(util.encode_base64('[' .. tag_value .. '] ' .. sbj)) .. '?='
new_sbj = '=?UTF-8?B?' .. tostring(util.encode_base64('[' .. tag .. '] ' .. sbj)) .. '?='
task:set_milter_reply({
remove_headers = {
['Subject'] = 1,
@@ -907,6 +961,9 @@ rspamd_config:register_symbol({
if not seen_cte and rewrite.need_rewrite_ct then
out[#out + 1] = string.format('%s: %s', 'Content-Transfer-Encoding', new_cte)
task:set_milter_reply({
add_headers = {['Content-Transfer-Encoding'] = new_cte}
})
end
-- End of headers
@@ -956,134 +1013,3 @@ 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 == '1' then
archive()
end
end
rspamd_redis_make_request(task,
redis_params, -- connect params
'ARCHIVE_ENABLED', -- hash key
false, -- is write
enabled_cb, --callback
'GET', -- command
{'ARCHIVE_ENABLED'} -- arguments
)
end
})
+1 -1
View File
@@ -109,7 +109,7 @@ foreach ($rcpts as $rcpt) {
}
// Skip if domain is excluded
if (in_array($parsed_rcpt['domain'], $exclude_domains)) {
if (is_array($exclude_domains) && in_array($parsed_rcpt['domain'], $exclude_domains)) {
error_log(sprintf("QUARANTINE: Skipped domain %s", $parsed_rcpt['domain']) . PHP_EOL);
continue;
}
-1
View File
@@ -46,7 +46,6 @@ $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,7 +13,6 @@ $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');
@@ -117,7 +116,6 @@ $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',
-277
View File
@@ -685,11 +685,6 @@ 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
@@ -1259,12 +1254,6 @@ 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
@@ -3929,11 +3918,6 @@ 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
@@ -4238,12 +4222,6 @@ 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
@@ -4568,104 +4546,6 @@ 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:
@@ -5878,7 +5758,6 @@ 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
@@ -6433,9 +6312,7 @@ paths:
response:
value:
- active: "1"
archive: 1
attributes:
archive: inherit
force_pw_update: "0"
force_tfa: "0"
mailbox_format: "maildir:"
@@ -6630,154 +6507,6 @@ 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:
@@ -7868,9 +7597,7 @@ paths:
response:
value:
- active: "1"
archive: 1
attributes:
archive: inherit
force_pw_update: "0"
force_tfa: "0"
mailbox_format: "maildir:"
@@ -8298,7 +8025,3 @@ 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.
+1 -1
View File
@@ -8,7 +8,7 @@ if(file_exists('inc/vars.local.inc.php')) {
$autodiscover_config = array_merge($default_autodiscover_config, $autodiscover_config);
header('Content-type: application/json');
if (strtolower($_GET['Protocol']) == 'activesync' && getenv('SKIP_SOGO') != "y") {
if (strtolower($_GET['Protocol']) == 'activesync' && $autodiscover_config['autodiscoverType'] == 'activesync' && getenv('SKIP_SOGO') != "y") {
echo '{"Protocol":"ActiveSync","Url":"' . $autodiscover_config['activesync']['url'] . '"}';
}
elseif (strtolower($_GET['Protocol']) == 'autodiscoverv1') {
+1 -1
View File
@@ -31,7 +31,7 @@ error_reporting(0);
$data = trim(file_get_contents("php://input"));
if (strpos($data, 'autodiscover/outlook/responseschema') !== false) {
if (strpos($data, 'autodiscover/outlook/responseschema') !== false && $autodiscover_config['autodiscoverType'] == 'activesync') {
$autodiscover_config['autodiscoverType'] = 'imap';
if ($autodiscover_config['useEASforOutlook'] == 'yes' &&
// Office for macOS does not support EAS
+25 -2
View File
@@ -15,6 +15,10 @@ table.dataTable>tbody>tr.child ul.dtr-details>li {
border-bottom: 1px solid rgba(0, 0, 0, 0.129);
padding: 0.5em 0;
}
/* like the first entry has no top padding, the last one needs no bottom padding */
table.dataTable>tbody>tr.child ul.dtr-details>li:last-child {
padding-bottom: 0;
}
table.dataTable.dtr-inline.collapsed>tbody>tr>td.dtr-control:before:hover,
table.dataTable.dtr-inline.collapsed>tbody>tr>th.dtr-control:before:hover {
background-color: var(--mc-dtr-hover-bg);
@@ -28,7 +32,8 @@ table.dataTable td.dt-control:before {
color: var(--mc-dtr-color);
height: 1em;
width: 1em;
line-height: 1.25em;
line-height: 1em;
margin-top: calc(-0.5em - 1.5px);
border-radius: 0px;
box-shadow: none;
font-size: 14px;
@@ -39,11 +44,29 @@ table.dataTable.dtr-inline.collapsed>tbody>tr.parent>th.dtr-control:before,
table.dataTable td.dt-control:before {
background-color: var(--mc-dtr-active-bg) !important;
}
/* The default initial order targets column 0, which is usually the non-orderable expand column */
table.dataTable thead > tr > th.sorting_disabled.sorting_asc,
table.dataTable thead > tr > th.sorting_disabled.sorting_desc {
padding-right: 0.5rem;
cursor: auto;
}
table.dataTable thead > tr > th.sorting_disabled.sorting_asc:before,
table.dataTable thead > tr > th.sorting_disabled.sorting_asc:after,
table.dataTable thead > tr > th.sorting_disabled.sorting_desc:before,
table.dataTable thead > tr > th.sorting_disabled.sorting_desc:after {
display: none;
}
table.dataTable.table-striped>tbody>tr>td {
vertical-align: middle;
}
table.dataTable.table-striped>tbody>tr>td>input[type="checkbox"] {
margin-top: 7px;
/* centers the 1em checkbox in the 1.5em line, level with the expand control */
margin-top: 0.25em;
}
/* the vertical margin is meant for the tag editor in edit forms, in tables it only adds height */
table.dataTable .tag-badge {
margin-top: 0;
margin-bottom: 0;
}
td.dtr-col-lg {
min-width: 350px;
+8
View File
@@ -139,6 +139,13 @@ body {
display: inline-block;
font-size: inherit;
}
.badge .bi {
font-size: inherit;
}
/* keeps icons from making a badge taller than one with text only */
.badge .bi::before {
line-height: 0;
}
.btn-group-xs > .btn, .btn-xs {
padding: .25rem .4rem;
font-size: .875rem;
@@ -343,6 +350,7 @@ code {
}
.tag-badge .bi {
font-size: 12px;
line-height: 0;
}
.tag-badge.btn-badge:hover {
filter: brightness(0.9);
+1 -1
View File
@@ -191,7 +191,7 @@
height: 2rem;
width: 2rem;
line-height: 2rem;
margin-top: -15px;
margin-top: calc(-1rem - 1.5px);
}
li .dtr-data {
-1
View File
@@ -45,7 +45,6 @@ $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';
+1 -5
View File
@@ -65,8 +65,7 @@ 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"]),
'archive_enabled' => \Mailcow\Archive\ArchiveModule::fromGlobals()->settings->enabled()
'alias_domains' => mailbox('get', 'alias_domains', $_GET["domain"])
];
}
}
@@ -76,7 +75,6 @@ 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;
@@ -87,7 +85,6 @@ 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;
@@ -165,7 +162,6 @@ 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(),
];
}
}
+7
View File
@@ -601,6 +601,13 @@ function keycloak_mbox_login_rest($user, $pass, $extra = null){
// validate mailcow_password
$mailcow_password = $user_res['attributes']['mailcow_password'][0];
// Keycloak returns binary LDAP attributes like userPassword Base64 encoded
if (substr($mailcow_password, 0, 1) != '{') {
$decoded_password = base64_decode($mailcow_password, true);
if ($decoded_password !== false && substr($decoded_password, 0, 1) == '{') {
$mailcow_password = $decoded_password;
}
}
if (!verify_hash($mailcow_password, $pass)) {
return false;
}
+7
View File
@@ -35,6 +35,13 @@ function fwdhost($_action, $_data = null) {
);
return false;
}
$hosts = array_unique(array_map(function ($host) {
$net = explode('/', $host, 2);
if (filter_var($net[0], FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
$net[0] = inet_ntop(inet_pton($net[0]));
}
return implode('/', $net);
}, $hosts));
foreach ($hosts as $host) {
try {
$redis->hSet('WHITELISTED_FWD_HOST', $host, $source);
+31 -12
View File
@@ -539,7 +539,6 @@ 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()) {
@@ -549,10 +548,6 @@ 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();
}
}
@@ -1399,6 +1394,18 @@ function set_tfa($_data) {
case "webauthn":
$key_id = (!isset($_data["key_id"])) ? 'unidentified' : $_data["key_id"];
// Refuse incomplete WebAuthn registrations. Without this guard a request
// that does not contain the parsed credential data (e.g. a plain form
// POST) would silently insert an empty keyHandle while reporting success.
if (empty($_data['registration']) || empty($_data['registration']->credentialId)) {
$_SESSION['return'][] = array(
'type' => 'danger',
'log' => array(__FUNCTION__, $_data_log),
'msg' => 'webauthn_registration_failed'
);
return false;
}
$stmt = $pdo->prepare("INSERT INTO `tfa` (`username`, `key_id`, `authmech`, `keyHandle`, `publicKey`, `certificate`, `counter`, `active`)
VALUES (?, ?, 'webauthn', ?, ?, ?, ?, '1')");
$stmt->execute(array(
@@ -1421,11 +1428,11 @@ function set_tfa($_data) {
// Block TFA removal if force_tfa policy is active
$is_forced_tfa = false;
if ($_SESSION['mailcow_cc_role'] === 'user') {
$stmt_check = $pdo->prepare("SELECT JSON_EXTRACT(`attributes`, '$.force_tfa') FROM `mailbox` WHERE `username` = ?");
$stmt_check = $pdo->prepare("SELECT JSON_VALUE(`attributes`, '$.force_tfa') FROM `mailbox` WHERE `username` = ?");
$stmt_check->execute(array($username));
$is_forced_tfa = ($stmt_check->fetchColumn() == '1');
} else {
$stmt_check = $pdo->prepare("SELECT JSON_EXTRACT(`attributes`, '$.force_tfa') FROM `admin` WHERE `username` = ?");
$stmt_check = $pdo->prepare("SELECT JSON_VALUE(`attributes`, '$.force_tfa') FROM `admin` WHERE `username` = ?");
$stmt_check->execute(array($username));
$is_forced_tfa = ($stmt_check->fetchColumn() == '1');
}
@@ -1692,11 +1699,11 @@ function unset_tfa_key($_data) {
// Block key removal if force_tfa policy is active
$is_forced_tfa = false;
if ($_SESSION['mailcow_cc_role'] === 'user') {
$stmt_check = $pdo->prepare("SELECT JSON_EXTRACT(`attributes`, '$.force_tfa') FROM `mailbox` WHERE `username` = ?");
$stmt_check = $pdo->prepare("SELECT JSON_VALUE(`attributes`, '$.force_tfa') FROM `mailbox` WHERE `username` = ?");
$stmt_check->execute(array($username));
$is_forced_tfa = ($stmt_check->fetchColumn() == '1');
} else {
$stmt_check = $pdo->prepare("SELECT JSON_EXTRACT(`attributes`, '$.force_tfa') FROM `admin` WHERE `username` = ?");
$stmt_check = $pdo->prepare("SELECT JSON_VALUE(`attributes`, '$.force_tfa') FROM `admin` WHERE `username` = ?");
$stmt_check->execute(array($username));
$is_forced_tfa = ($stmt_check->fetchColumn() == '1');
}
@@ -2425,7 +2432,7 @@ function identity_provider($_action = null, $_data = null, $_extra = null) {
case "use_ssl":
case "use_tls":
case "login_provisioning":
case "ignore_ssl_errors":
case "ignore_ssl_error":
$settings[$row["key"]] = boolval($row["value"]);
break;
default:
@@ -2449,7 +2456,7 @@ function identity_provider($_action = null, $_data = null, $_extra = null) {
if ($settings["authsource"] == "ldap"){
$settings['use_ssl'] = !isset($settings['use_ssl']) ? false : $settings['use_ssl'];
$settings['use_tls'] = !isset($settings['use_tls']) ? false : $settings['use_tls'];
$settings['ignore_ssl_errors'] = !isset($settings['ignore_ssl_errors']) ? false : $settings['ignore_ssl_errors'];
$settings['ignore_ssl_error'] = !isset($settings['ignore_ssl_error']) ? false : $settings['ignore_ssl_error'];
}
return $settings;
break;
@@ -3111,7 +3118,17 @@ function identity_provider($_action = null, $_data = null, $_extra = null) {
case "get-keycloak-admin-token":
// get access_token for service account of mailcow client
if ($iam_settings['authsource'] !== 'keycloak') return false;
if (isset($iam_settings['access_token'])) {
// only introspect a cached access_token that is not (about to be) expired,
// introspecting an expired token makes Keycloak log INTROSPECT_TOKEN_ERROR
$token_not_expired = false;
if (!empty($iam_settings['access_token'])) {
$token_parts = explode('.', $iam_settings['access_token']);
if (count($token_parts) === 3) {
$token_payload = json_decode(base64_decode(strtr($token_parts[1], '-_', '+/')), true);
$token_not_expired = is_array($token_payload) && isset($token_payload['exp']) && intval($token_payload['exp']) > time() + 30;
}
}
if ($token_not_expired) {
// check if access_token is valid
$url = "{$iam_settings['server_url']}/realms/{$iam_settings['realm']}/protocol/openid-connect/token/introspect";
$req = http_build_query(array(
@@ -3162,6 +3179,8 @@ function identity_provider($_action = null, $_data = null, $_extra = null) {
':key' => 'access_token',
':value' => $res['access_token']
));
// keep the cached token in sync, so subsequent calls in the same run reuse it
$iam_settings['access_token'] = $res['access_token'];
return $res['access_token'];
break;
}
+48 -41
View File
@@ -366,7 +366,6 @@ 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';
}
@@ -422,8 +421,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`, `archive`)
VALUES (:domain, :description, :aliases, :mailboxes, :defquota, :maxquota, :quota, :backupmx, :gal, :active, :relay_unknown_only, :relay_all_recipients, :archive)");
$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->execute(array(
':domain' => $domain,
':description' => $description,
@@ -436,8 +435,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
':gal' => $gal,
':active' => $active,
':relay_unknown_only' => $relay_unknown_only,
':relay_all_recipients' => $relay_all_recipients,
':archive' => $archive
':relay_all_recipients' => $relay_all_recipients
));
// save tags
foreach($tags as $index => $tag){
@@ -942,8 +940,6 @@ 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
@@ -978,7 +974,6 @@ 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),
@@ -1530,7 +1525,6 @@ 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;
@@ -1606,7 +1600,6 @@ 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']);
@@ -2495,7 +2488,6 @@ 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 {
@@ -2509,13 +2501,11 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$stmt = $pdo->prepare("UPDATE `domain` SET
`description` = :description,
`gal` = :gal,
`archive` = :archive
`gal` = :gal
WHERE `domain` = :domain");
$stmt->execute(array(
':description' => $description,
':gal' => $gal,
':archive' => $archive,
':domain' => $domain
));
// save tags
@@ -2534,6 +2524,10 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
':domain' => $domain,
':tag_name' => $tag,
));
$stmt = $pdo->prepare("UPDATE `domain` SET `modified` = NOW() WHERE `domain` = :domain");
$stmt->execute(array(
':domain' => $domain,
));
}
$_SESSION['return'][] = array(
@@ -2548,7 +2542,6 @@ 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'];
@@ -2663,7 +2656,6 @@ 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,
@@ -2678,7 +2670,6 @@ 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,
@@ -2705,6 +2696,10 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
':domain' => $domain,
':tag_name' => $tag,
));
$stmt = $pdo->prepare("UPDATE `domain` SET `modified` = NOW() WHERE `domain` = :domain");
$stmt->execute(array(
':domain' => $domain,
));
}
$_SESSION['return'][] = array(
@@ -2766,7 +2761,6 @@ 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;
@@ -2828,7 +2822,6 @@ 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'];
@@ -3129,7 +3122,6 @@ 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");
@@ -3147,7 +3139,6 @@ 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),
@@ -3187,6 +3178,10 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
':username' => $username,
':tag_name' => $tag,
));
$stmt = $pdo->prepare("UPDATE `mailbox` SET `modified` = NOW() WHERE `username` = :username");
$stmt->execute(array(
':username' => $username,
));
} catch (Exception $e) {
}
}
@@ -3551,7 +3546,6 @@ 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'];
@@ -4006,6 +4000,9 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$stmt->execute(array(':goto' => '(^|,)'.preg_quote($_data, '/').'($|,)'));
$rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
while ($row = array_shift($rows)) {
if (!hasAliasObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $row['address'])) {
continue;
}
// Keep old array for backward compatibility
$data['fixed_sender_aliases'][] = $row['address'];
// Split into allowed/blocked for proper display
@@ -4700,7 +4697,6 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
`relay_unknown_only`,
`backupmx`,
`gal`,
`archive`,
`active`
FROM `domain` WHERE `domain`= :domain");
$stmt->execute(array(
@@ -4761,7 +4757,6 @@ 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'];
@@ -4849,8 +4844,6 @@ 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`,
@@ -4873,8 +4866,6 @@ 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`,
@@ -4914,18 +4905,6 @@ 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";
@@ -4959,6 +4938,9 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
else if ($SaslLogs['service'] == 'SSO') {
$last_sso_login = strtotime($SaslLogs['datetime']);
}
else if ($SaslLogs['service'] == 'SOGO') {
$last_sogo_login = strtotime($SaslLogs['datetime']);
}
}
if (!isset($last_imap_login) || $GLOBALS['SHOW_LAST_LOGIN'] === false) {
$last_imap_login = 0;
@@ -4972,10 +4954,14 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
if (!isset($last_sso_login) || $GLOBALS['SHOW_LAST_LOGIN'] === false) {
$last_sso_login = 0;
}
if (!isset($last_sogo_login) || $GLOBALS['SHOW_LAST_LOGIN'] === false) {
$last_sogo_login = 0;
}
$mailboxdata['last_imap_login'] = $last_imap_login;
$mailboxdata['last_smtp_login'] = $last_smtp_login;
$mailboxdata['last_pop3_login'] = $last_pop3_login;
$mailboxdata['last_sso_login'] = $last_sso_login;
$mailboxdata['last_sogo_login'] = $last_sogo_login;
if (!isset($_extra) || $_extra != 'reduced') {
$rl = ratelimit('get', 'mailbox', $_data);
@@ -5013,7 +4999,16 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
$stmt->execute(array(':username' => $_data));
$sender_acl_rows = $stmt->fetchAll(PDO::FETCH_ASSOC);
foreach ($sender_acl_rows as $sender_acl_row) {
$mailboxdata['sender_acl'][] = $sender_acl_row['send_as'];
$send_as = $sender_acl_row['send_as'];
if ($_SESSION['mailcow_cc_role'] == 'domainadmin' && $send_as != '*') {
$in_scope = (substr($send_as, 0, 1) == '@')
? hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], substr($send_as, 1))
: hasAliasObjectAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], $send_as);
if (!$in_scope) {
continue;
}
}
$mailboxdata['sender_acl'][] = $send_as;
}
}
$stmt = $pdo->prepare("SELECT `tag_name`
@@ -5952,6 +5947,12 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
':domain' => $domain,
':tag_name' => $tag,
));
if ($stmt->rowCount() > 0) {
$stmt = $pdo->prepare("UPDATE `domain` SET `modified` = NOW() WHERE `domain` = :domain");
$stmt->execute(array(
':domain' => $domain,
));
}
}
}
@@ -6004,6 +6005,12 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) {
':username' => $username,
':tag_name' => $tag,
));
if ($stmt->rowCount() > 0) {
$stmt = $pdo->prepare("UPDATE `mailbox` SET `modified` = NOW() WHERE `username` = :username");
$stmt->execute(array(
':username' => $username,
));
}
}
}
-13
View File
@@ -154,7 +154,6 @@ 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) {
@@ -171,7 +170,6 @@ 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(
@@ -215,10 +213,6 @@ 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']
@@ -459,7 +453,6 @@ 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) {
@@ -474,7 +467,6 @@ 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(
@@ -514,11 +506,6 @@ 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
+1 -42
View File
@@ -242,7 +242,6 @@ 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'"
@@ -1054,8 +1053,7 @@ 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'",
"archive" => "TINYINT(1) NOT NULL DEFAULT '0'"
"mailbox_groups" => "TINYINT(1) NOT NULL DEFAULT '1'"
),
"keys" => array(
"primary" => array(
@@ -1279,42 +1277,6 @@ 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",
@@ -2047,7 +2009,6 @@ 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;");
@@ -2095,7 +2056,6 @@ 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,
@@ -2113,7 +2073,6 @@ 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']),
@@ -1,65 +0,0 @@
<?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, $state, $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);
}
}
@@ -1,103 +0,0 @@
<?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;
}
}
@@ -1,27 +0,0 @@
<?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));
}
}
@@ -1,32 +0,0 @@
<?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
) {
}
}
@@ -1,74 +0,0 @@
<?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: '1', '0' (also as number or boolean), anything else (also a missing attribute) is 'inherit' */
public static function setting(mixed $value): string
{
$value = is_bool($value) ? (int)$value : $value;
$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(),
];
}
}
@@ -1,72 +0,0 @@
<?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,
];
}
}
@@ -1,118 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Archive\Redis;
use Mailcow\Archive\Model\Settings;
use RuntimeException;
/**
* Redis side of the archive:
* - ARCHIVE_ENABLED: the global switch, kept here only; rspamd reads it before it asks for owners.
* - 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');
}
/** The global switch: on only while ARCHIVE_ENABLED is '1', as rspamd reads it (missing or unreadable is off) */
public function enabled(): bool
{
try {
return $this->redis?->get(self::ENABLED) === '1';
} catch (RuntimeException) {
return false;
}
}
/** 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);
}
}
@@ -1,94 +0,0 @@
<?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;
}
}
@@ -1,71 +0,0 @@
<?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->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 = [
'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,
]);
}
});
}
}
@@ -1,125 +0,0 @@
<?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') : '';
}
}
@@ -1,144 +0,0 @@
<?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();
if (!$this->state->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 (!$this->state->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)) : '';
}
}
@@ -1,38 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Archive\Service;
use Mailcow\Archive\Model\Mailbox;
use Mailcow\Archive\Redis\ArchiveState;
use Mailcow\Archive\Repository\LookupRepository;
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 ArchiveState $state, 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->state->enabled();
return [
'enabled' => (int)$enabled,
'mailboxes' => $enabled ? array_map(fn(Mailbox $mailbox): array => $mailbox->toArray(), $this->lookup->archivedMailboxes()) : [],
];
}
}
@@ -1,264 +0,0 @@
<?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)];
}
}
@@ -1,164 +0,0 @@
<?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;
/**
* 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);
$settings = $this->repo->load();
$settings->enabled = $this->state->enabled();
return $settings;
}
/** Global switch for internal callers (page templates, mailbox details), no permission check */
public function enabled(): bool
{
return $this->state->enabled();
}
/**
* 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();
$settings->enabled = $this->state->enabled();
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);
}
}
@@ -1,85 +0,0 @@
<?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)$this->state->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();
}
}
@@ -1,190 +0,0 @@
<?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;
}
}
@@ -1,27 +0,0 @@
<?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);
}
}
-3
View File
@@ -205,9 +205,6 @@ $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;
-92
View File
@@ -1,92 +0,0 @@
// 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,81 +875,6 @@ 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',
@@ -1370,7 +1295,6 @@ 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());
+14 -21
View File
@@ -232,7 +232,6 @@ $(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);
@@ -434,7 +433,6 @@ $(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 {
@@ -616,7 +614,7 @@ jQuery(function($){
defaultContent: ''
},
{
title: 'RL',
title: lang_edit.ratelimit,
data: 'rl',
searchable: false,
orderable: false,
@@ -717,10 +715,6 @@ jQuery(function($){
$.each(json, function (i, item) {
item.chkbox = '<input type="checkbox" class="form-check-input" data-id="domain_template" name="multi_select" value="' + encodeURIComponent(item.id) + '" />';
item.attributes.def_quota_for_mbox = humanFileSize(item.attributes.def_quota_for_mbox);
item.attributes.max_quota_for_mbox = humanFileSize(item.attributes.max_quota_for_mbox);
item.attributes.max_quota_for_domain = humanFileSize(item.attributes.max_quota_for_domain);
item.template = escapeHtml(item.template);
if (item.attributes.rl_frame === "s"){
item.attributes.rl_frame = lang_rl.second;
@@ -803,16 +797,25 @@ jQuery(function($){
title: lang.mailbox_defquota,
data: 'attributes.def_quota_for_mbox',
defaultContent: '',
render: function (data, type) {
return type === 'sort' || type === 'type' ? data : humanFileSize(data);
}
},
{
title: lang.max_quota,
data: 'attributes.max_quota_for_mbox',
defaultContent: '',
render: function (data, type) {
return type === 'sort' || type === 'type' ? data : humanFileSize(data);
}
},
{
title: lang.domain_quota_total,
data: 'attributes.max_quota_for_domain',
defaultContent: '',
render: function (data, type) {
return type === 'sort' || type === 'type' ? data : humanFileSize(data);
}
},
{
title: lang.gal,
@@ -934,6 +937,7 @@ jQuery(function($){
item.last_mail_login = (item.attributes.imap_access == 1 ? '<div class="text-start badge bg-info mb-2" style="min-width: 70px;">IMAP @ ' + unix_time_format(Number(item.last_imap_login)) + '</div><br>' : '') +
(item.attributes.pop3_access == 1 ? '<div class="text-start badge bg-info mb-2" style="min-width: 70px;">POP3 @ ' + unix_time_format(Number(item.last_pop3_login)) + '</div><br>' : '') +
(item.attributes.smtp_access == 1 ? '<div class="text-start badge bg-info mb-2" style="min-width: 70px;">SMTP @ ' + unix_time_format(Number(item.last_smtp_login)) + '</div><br>' : '') +
'<div class="text-start badge bg-info mb-2" style="min-width: 70px;">SOGO @ ' + unix_time_format(Number(item.last_sogo_login)) + '</div><br>' +
'<div class="text-start badge bg-info" style="min-width: 70px;">SSO @ ' + unix_time_format(Number(item.last_sso_login)) + '</div>';
/*
if (!item.rl) {
@@ -1189,18 +1193,6 @@ 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',
@@ -1258,8 +1250,6 @@ jQuery(function($){
item.attributes.rl_value = (!item.attributes.rl_value) ? "∞" : escapeHtml(item.attributes.rl_value);
item.attributes.ratelimit = item.attributes.rl_value + " " + item.attributes.rl_frame;
item.attributes.quota = humanFileSize(item.attributes.quota);
item.attributes.tls_enforce_in = '<i class="text-' + (item.attributes.tls_enforce_in == 1 ? 'success bi bi-lock-fill' : 'danger bi bi-unlock-fill') + '"><span class="sorting-value">' + (item.attributes.tls_enforce_in == 1 ? '1' : '0') + '</span></i>';
item.attributes.tls_enforce_out = '<i class="text-' + (item.attributes.tls_enforce_out == 1 ? 'success bi bi-lock-fill' : 'danger bi bi-unlock-fill') + '"><span class="sorting-value">' + (item.attributes.tls_enforce_out == 1 ? '1' : '0') + '</span></i>';
item.attributes.pop3_access = '<i class="text-' + (item.attributes.pop3_access == 1 ? 'success' : 'danger') + ' bi bi-' + (item.attributes.pop3_access == 1 ? 'check-lg' : 'x-lg') + '"><span class="sorting-value">' + (item.attributes.pop3_access == 1 ? '1' : '0') + '</span></i>';
@@ -1346,6 +1336,9 @@ jQuery(function($){
title: lang.domain_quota,
data: 'attributes.quota',
defaultContent: '',
render: function (data, type) {
return type === 'sort' || type === 'type' ? data : humanFileSize(data);
}
},
{
title: lang.tls_enforce_in,
+6 -32
View File
@@ -858,26 +858,6 @@ 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":
@@ -924,11 +904,6 @@ 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)) {
@@ -1112,10 +1087,14 @@ if (isset($_GET['query'])) {
$data['mailboxes'][$mailbox][] = $mailbox;
$user_alias_details = user_get_alias_details($mailbox);
foreach ($user_alias_details['direct_aliases'] as $k => $v) {
$data['mailboxes'][$mailbox][] = $k;
if (hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], substr(strrchr($k, '@'), 1))) {
$data['mailboxes'][$mailbox][] = $k;
}
}
foreach ($user_alias_details['shared_aliases'] as $k => $v) {
$data['mailboxes'][$mailbox][] = $k;
if (hasDomainAccess($_SESSION['mailcow_cc_username'], $_SESSION['mailcow_cc_role'], substr(strrchr($k, '@'), 1))) {
$data['mailboxes'][$mailbox][] = $k;
}
}
}
}
@@ -1707,8 +1686,6 @@ 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') {
@@ -1980,9 +1957,6 @@ 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;
-79
View File
@@ -3,7 +3,6 @@
"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",
@@ -139,57 +138,6 @@
"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?",
@@ -568,19 +516,6 @@
"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",
@@ -845,15 +780,6 @@
"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",
@@ -1135,7 +1061,6 @@
"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",
@@ -1380,9 +1305,6 @@
"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",
@@ -1796,7 +1718,6 @@
"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.",
+2 -80
View File
@@ -3,7 +3,6 @@
"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",
@@ -143,57 +142,6 @@
"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?",
@@ -560,19 +508,6 @@
"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",
@@ -845,15 +780,6 @@
"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",
@@ -1136,7 +1062,6 @@
"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",
@@ -1202,7 +1127,7 @@
"mailbox_defaults_info": "Define default settings for new mailboxes.",
"mailbox_defquota": "Default mailbox size",
"mailbox_templates": "Mailbox Templates",
"mailbox_quota": "Max. size of a mailbox",
"mailbox_quota": "Max. mailbox size",
"mailboxes": "Mailboxes",
"max_aliases": "Max. aliases",
"max_mailboxes": "Max. possible mailboxes",
@@ -1387,9 +1312,6 @@
"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",
@@ -1507,6 +1429,7 @@
"u2f_deprecated": "It seems that your Key was registered using the deprecated U2F method. We will deactivate Two-Factor-Authenticaiton for you and delete your Key.",
"u2f_deprecated_important": "Please register your Key in the admin panel with the new WebAuthn method.",
"webauthn": "WebAuthn authentication",
"webauthn_registration_failed": "WebAuthn registration failed. The credential could not be verified, please try again.",
"waiting_usb_auth": "<i>Waiting for USB device...</i><br><br>Please tap the button on your USB device now.",
"waiting_usb_register": "<i>Waiting for USB device...</i><br><br>Please enter your password above and confirm your registration by tapping the button on your USB device.",
"yubi_otp": "Yubico OTP authentication"
@@ -1803,7 +1726,6 @@
"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
View File
@@ -1074,7 +1074,6 @@
"verified_webauthn_login": "Inicio de sesión WebAuthn verificado",
"verified_yotp_login": "Inicio de sesión Yubico OTP verificado",
"admin_added": "Se agregó el administrador %s",
"admin_api_modified": "Se guardaron los cambios en la API",
"admin_removed": "Se eliminó el administrador %s",
"app_links": "Se guardaron los cambios en los enlaces de aplicaciones",
"app_passwd_added": "Se agregó una nueva contraseña de aplicación",
@@ -1333,10 +1332,6 @@
"sogo_profile_reset_help": "Esto destruirá el perfil de SOGo del usuario y <b>eliminará de forma irrecuperable todos sus contactos y datos de calendario</b>.",
"spam_score_reset": "Restablecer al valor predeterminado del servidor",
"spamfilter_table_domain_policy": "n/d (política del dominio)",
"syncjob_check_log": "Revisar registro",
"syncjob_EX_OK": "Correcto",
"syncjob_EXIT_OVERQUOTA": "El buzón de destino superó su cuota",
"syncjob_EXIT_AUTHENTICATION_FAILURE_USER1": "Usuario o contraseña incorrectos",
"tfa_info": "La autenticación de dos factores ayuda a proteger su cuenta. Si la activa, necesitará contraseñas de aplicación para iniciar sesión en aplicaciones o servicios que no admiten la autenticación de dos factores (p. ej., clientes de correo)."
},
"warning": {
-1
View File
@@ -95,7 +95,6 @@
"bcc_dest_format": "Il destinatario in copia nascosta deve essere un singolo indirizzo email.<br>Se si vuole spedire una copia del messaggio a più destinatari, bisogna creare un alias ed utilizzarlo per questa opzione.",
"app_passwd_protocols": "Protocolli consentiti per la password dell'app",
"tags": "Tag",
"dry": "Simula sincronizzazione",
"internal": "Interno",
"internal_info": "Gli alias interni sono accessibili solo dal proprio dominio o dai domini alias.",
"sender_allowed": "Consenti l'invio con questo alias"
-2
View File
@@ -29,7 +29,6 @@
</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>
@@ -48,7 +47,6 @@
{% 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
@@ -1,168 +0,0 @@
<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>
+17 -11
View File
@@ -416,18 +416,24 @@ function recursiveBase64StrToArrayBuffer(obj) {
return navigator.credentials.create(json);
}).then(function(cred) {
return {
id: cred.id,
rawId: arrayBufferToBase64(cred.rawId),
response: {
attestationObject: arrayBufferToBase64(cred.response.attestationObject),
clientDataJSON: arrayBufferToBase64(cred.response.clientDataJSON)
},
type: cred.type
clientDataJSON: arrayBufferToBase64(cred.response.clientDataJSON),
attestationObject: arrayBufferToBase64(cred.response.attestationObject),
key_id: keyId,
tfa_method: 'webauthn',
confirm_password: ''
};
}).then(function(credData) {
$('#setup_webauthn_register_data').val(JSON.stringify(credData));
$('#setup_webauthn_reg_form input[name=set_tfa]').val('1');
$('#setup_webauthn_reg_form').submit();
}).then(JSON.stringify).then(function(credentialData) {
// send the attestation to the JSON API, like the regular WebAuthn modal does,
// so that the credential is parsed and stored correctly
return window.fetch('/api/v1/add/webauthn-tfa-registration', {method: 'POST', body: credentialData, cache: 'no-cache'});
}).then(function(response) {
return response.json();
}).then(function(json) {
if (json.success) {
window.location = window.location.href.split('#')[0];
} else {
throw new Error(json.msg);
}
}).catch(function(err) {
$('#setup_webauthn_return_code').show().text(err.message || 'Registration failed');
});
-16
View File
@@ -17,7 +17,6 @@
<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>
@@ -548,21 +547,6 @@
</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,7 +8,6 @@
<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">
@@ -71,14 +70,6 @@
</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,7 +25,6 @@
<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">
@@ -134,14 +133,6 @@
</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">
@@ -114,17 +114,6 @@
</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">
+5 -16
View File
@@ -97,9 +97,11 @@
<div class="col-sm-10">
<select data-live-search="true" data-width="100%" style="width:100%" id="editSelectSenderACL" name="sender_acl" size="10" multiple>
{% for domain in sender_acl_handles.sender_acl_domains.ro %}
<option data-subtext="Admin" value="{{ domain }}" disabled selected>
{{ lang.edit.dont_check_sender_acl|format(domain) }}
</option>
{% if domain == '*' or mailcow_cc_role == 'admin' %}
<option data-subtext="Admin" value="{{ domain }}" disabled selected>
{{ lang.edit.dont_check_sender_acl|format(domain) }}
</option>
{% endif %}
{% endfor %}
{% for domain in sender_acl_handles.sender_acl_domains.rw %}
<option value="{{ domain }}" selected>
@@ -331,19 +333,6 @@
<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,7 +81,6 @@
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>
+1 -40
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" name="template" class="form-control" title="{{ lang.mailbox.template }}">
<select data-live-search="true" id="mailbox_templates" class="form-control" title="{{ lang.mailbox.template }}">
</select>
</div>
</div>
@@ -154,17 +154,6 @@
</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">
@@ -371,17 +360,6 @@
</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">
@@ -473,7 +451,6 @@
<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">
@@ -548,14 +525,6 @@
</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">
@@ -695,14 +664,6 @@
</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">
+6 -7
View File
@@ -1,5 +1,5 @@
# Version of the database schema
x-db-version: &db_version DB_VERSION=06112026_1800
x-db-version: &db_version DB_VERSION=02112026_1600
services:
@@ -68,7 +68,7 @@ services:
- redis
clamd-mailcow:
image: ghcr.io/mailcow/clamd:1.4.6-1
image: ghcr.io/mailcow/clamd:1.4.6-2
restart: always
depends_on:
unbound-mailcow:
@@ -348,7 +348,7 @@ services:
- dovecot
postfix-mailcow:
image: ghcr.io/mailcow/postfix:3.10.12-2
image: ghcr.io/mailcow/postfix:3.10.13-1
depends_on:
mysql-mailcow:
condition: service_started
@@ -375,7 +375,6 @@ services:
- REDISPASS=${REDISPASS}
- MAILCOW_HOSTNAME=${MAILCOW_HOSTNAME}
- SPAMHAUS_DQS_KEY=${SPAMHAUS_DQS_KEY:-}
- *db_version
cap_add:
- NET_BIND_SERVICE
ports:
@@ -392,7 +391,7 @@ services:
- postfix
postfix-tlspol-mailcow:
image: ghcr.io/mailcow/postfix-tlspol:1.14.0
image: ghcr.io/mailcow/postfix-tlspol:1.14.5
depends_on:
unbound-mailcow:
condition: service_healthy
@@ -429,7 +428,7 @@ services:
- php-fpm-mailcow
- sogo-mailcow
- rspamd-mailcow
image: ghcr.io/mailcow/nginx:1.30.6-2
image: ghcr.io/mailcow/nginx:1.30.6-3
dns:
- ${IPV4_NETWORK:-172.22.1}.254
environment:
@@ -613,7 +612,7 @@ services:
- watchdog
dockerapi-mailcow:
image: ghcr.io/mailcow/dockerapi:2.15
image: ghcr.io/mailcow/dockerapi:2.16
security_opt:
- label=disable
restart: always
@@ -1,101 +0,0 @@
<?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
@@ -1,60 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\Archive\Model;
use Mailcow\Archive\Model\Mailbox;
use Mailcow\Archive\Redis\ArchiveState;
use Mailcow\Archive\Service\MailboxListService;
use Mailcow\Core\Context;
use Mailcow\Core\UserError;
use Mailcow\Tests\Support\FakeArchiveLookup;
use Mailcow\Tests\Support\FakeContext;
use Mailcow\Tests\Support\FakeRedis;
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],
'on as boolean' => [true, 0, true],
'off as boolean' => [false, 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]];
$redis = new FakeRedis();
$readOnly = new Context('admin', 'API', [], false, 'ro');
$this->assertSame(['enabled' => 0, 'mailboxes' => []], (new MailboxListService($lookup, new ArchiveState($redis), $readOnly))->archived());
$redis->strings['ARCHIVE_ENABLED'] = '1';
$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, new ArchiveState($redis), $readOnly))->archived());
$this->expectException(UserError::class);
(new MailboxListService($lookup, new ArchiveState($redis), FakeContext::domainAdmin()))->archived();
}
}
-51
View File
@@ -1,51 +0,0 @@
<?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']]);
}
}
@@ -1,92 +0,0 @@
<?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 testGlobalSwitchIsKeptHere(): void
{
// no key yet: off
$this->assertFalse($this->state->enabled());
$this->state->setEnabled(true);
$this->assertSame('1', $this->redis->strings['ARCHIVE_ENABLED']);
$this->assertTrue($this->state->enabled());
$this->state->setEnabled(false);
$this->assertSame('0', $this->redis->strings['ARCHIVE_ENABLED']);
$this->assertFalse($this->state->enabled());
// no connection or a failing one: off, rspamd cannot read the switch either
$this->assertFalse((new ArchiveState(null))->enabled());
$failing = new class {
public function get(string $key): string
{
throw new \RuntimeException('connection lost');
}
};
$this->assertFalse((new ArchiveState($failing))->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());
}
}
@@ -1,105 +0,0 @@
<?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()));
}
}
@@ -1,165 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\Archive\Service;
use Mailcow\Archive\Model\Settings;
use Mailcow\Archive\Redis\ArchiveState;
use Mailcow\Archive\Repository\SettingsRepository;
use Mailcow\Archive\Service\HeaderBuilder;
use Mailcow\Archive\Service\JournalService;
use Mailcow\Archive\Service\OwnerResolver;
use Mailcow\Archive\Smtp\SmtpException;
use Mailcow\Core\Context;
use Mailcow\Core\UserError;
use Mailcow\Tests\Support\FakeArchiveLookup;
use Mailcow\Tests\Support\FakeContext;
use Mailcow\Tests\Support\FakeRedis;
use Mailcow\Tests\Support\FakeSmtpClient;
use PHPUnit\Framework\TestCase;
class JournalServiceTest extends TestCase
{
private Settings $stored;
private FakeArchiveLookup $lookup;
private FakeRedis $redis;
private FakeSmtpClient $smtp;
protected function setUp(): void
{
$this->stored = new Settings();
$this->stored->address = 'journal@archive.example';
$this->stored->signingKey = 'key';
$this->lookup = new FakeArchiveLookup();
$this->lookup->mailboxes = ['anna@firma.de' => ['inherit', 1], 'olga@other.de' => ['inherit', 0]];
foreach (array_keys($this->lookup->mailboxes) as $username) {
$this->lookup->aliases[$username] = $username;
}
$this->redis = new FakeRedis();
$this->redis->strings['ARCHIVE_ENABLED'] = '1';
$this->smtp = new FakeSmtpClient();
}
private function service(?Context $context = null): JournalService
{
$repo = $this->createStub(SettingsRepository::class);
$repo->method('load')->willReturnCallback(fn(): Settings => clone $this->stored);
return new JournalService($repo, new OwnerResolver($this->lookup), new HeaderBuilder(), $this->smtp,
new ArchiveState($this->redis), $context ?? Context::system());
}
private function expectUserError(string|array $msg, callable $call): void
{
try {
$call();
$this->fail('UserError ' . json_encode($msg) . ' expected');
} catch (UserError $e) {
$this->assertSame($msg, $e->msg());
}
}
/** Header block and mail of a submitted copy, split after X-Archive-Signature */
private static function split(string $data): array
{
$end = strpos($data, "\r\n", (int)strpos($data, 'X-Archive-Signature: ')) + 2;
return [substr($data, 0, $end), substr($data, $end)];
}
public function testPrepareForAnArchivedRecipient(): void
{
$sha256 = hash('sha256', 'mail');
$result = $this->service()->prepare(['from' => 'x@extern.example', 'rcpt' => ['anna@firma.de', 'olga@other.de'], 'user' => '',
'bcc' => true, 'qid' => '4XyZ1', 'received' => 1700000000.25, 'sha256' => $sha256]);
$this->assertSame(['archive' => true, 'rcpt' => 'journal@archive.example', 'sender' => 'archive-bounce@localhost'],
array_slice($result, 0, 3));
$this->assertStringStartsWith("X-Archive-Owner: anna@firma.de\r\nX-Archive-Rcpt: anna@firma.de,olga@other.de\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: in\r\nX-Archive-Source: smtp\r\nX-Archive-SHA256: $sha256\r\nX-Archive-Signature: ", $result['headers']);
$this->assertSame('1', $this->redis->strings['ARCHIVE_ENABLED']);
}
public function testPrepareWithoutCopy(): void
{
$this->assertSame(['archive' => false], $this->service()->prepare(['from' => 'x@extern.example', 'rcpt' => ['olga@other.de']]));
$this->stored->signingKey = '';
$this->assertSame(['archive' => false], $this->service()->prepare(['from' => 'x@extern.example', 'rcpt' => ['anna@firma.de']]));
$this->redis->strings['ARCHIVE_ENABLED'] = '0';
$this->assertSame(['archive' => false], $this->service()->prepare(['from' => 'x@extern.example', 'rcpt' => ['anna@firma.de']]));
$this->assertSame([], $this->smtp->submitted);
}
public function testPrepareCleansTheRequest(): void
{
$result = $this->service()->prepare(['from' => "x@extern.example\r\nBcc: y@extern.example", 'rcpt' => ['anna@firma.de', ['x'], 5],
'qid' => str_repeat('Q', 100), 'received' => 'yesterday', 'sha256' => 'ABC']);
$this->assertTrue($result['archive']);
$this->assertStringContainsString("\r\nX-Archive-From: x@extern.example Bcc: y@extern.example\r\n", $result['headers']);
$this->assertStringContainsString("\r\nX-Archive-Rcpt: anna@firma.de\r\n", $result['headers']);
$this->assertStringContainsString("\r\nX-Archive-Queue-ID: " . str_repeat('Q', 64) . "\r\n", $result['headers']);
$this->assertMatchesRegularExpression('/\r\nX-Archive-Received: \d{4}-\d\d-\d\dT[\d:.]+Z\r\n/', $result['headers']);
$this->assertStringNotContainsString('X-Archive-SHA256', $result['headers']);
}
public function testCopyOfADeliveredMail(): void
{
$this->assertTrue($this->service()->submitDelivered("Subject: hi\n\n.body\n", 'x@extern.example', ['anna@firma.de'], 'quarantine'));
$sent = $this->smtp->submitted[0];
$this->assertSame(['postfix', 591, 'archive-bounce@localhost', 'journal@archive.example'],
[$sent['host'], $sent['port'], $sent['from'], $sent['rcpt']]);
[$headers, $message] = self::split($sent['data']);
$this->assertSame("Subject: hi\r\n\r\n.body\r\n", $message);
$this->assertStringStartsWith("X-Archive-Owner: anna@firma.de\r\nX-Archive-Rcpt: anna@firma.de\r\n", $headers);
$this->assertStringContainsString("\r\nX-Archive-Source: quarantine\r\nX-Archive-SHA256: " . hash('sha256', $message) . "\r\n", $headers);
}
public function testCopyOfADeliveredMailNeverThrows(): void
{
$this->assertFalse($this->service()->submitDelivered("x\r\n", 'x@extern.example', ['olga@other.de'], 'massmail'));
$this->assertSame([], $this->smtp->submitted);
$this->smtp->error = new SmtpException('connect', 'Connection refused');
$errorLog = ini_set('error_log', '/dev/null');
try {
$this->assertFalse($this->service()->submitDelivered("Message-ID: <mm1@firma.de>\r\nSubject: x\r\n\r\nx\r\n",
'x@extern.example', ['anna@firma.de'], 'massmail'));
} finally {
ini_set('error_log', (string)$errorLog);
}
$this->assertSame('1', $this->redis->strings['ARCHIVE_DROPPED']);
// the Message-ID finds the mail in the archive log
$this->assertSame(['qid' => '', 'reason' => 'massmail: connect: Connection refused', 'message_id' => 'mm1@firma.de'],
array_slice(json_decode($this->redis->lists['ARCHIVE_DROPPED_LOG'][0], true), 1));
}
public function testTestMail(): void
{
$this->redis->strings['ARCHIVE_ENABLED'] = '0';
$this->assertSame('Q1', $this->service(FakeContext::admin())->testMail());
[$headers, $message] = self::split($this->smtp->submitted[0]['data']);
$this->assertStringStartsWith("X-Archive-Rcpt: journal@archive.example\r\nX-Archive-From: archive-bounce@localhost\r\n", $headers);
$this->assertStringContainsString("\r\nX-Archive-Source: test\r\n", $headers);
$this->assertStringContainsString("\r\nSubject: mailcow archive test\r\n", $message);
}
public function testTestMailErrors(): void
{
$this->expectUserError('access_denied', fn() => $this->service(new Context('admin', 'API', [], false, 'ro'))->testMail());
$this->expectUserError('access_denied', fn() => $this->service(FakeContext::domainAdmin(acl: ['archive' => '1']))->testMail());
$this->smtp->error = new SmtpException('recipient', '550 5.7.1 <journal@archive.example>: relay denied');
$this->expectUserError(['archive_testmail_failed', 'recipient: 550 5.7.1 journal@archive.example: relay denied'],
fn() => $this->service(FakeContext::admin())->testMail());
$this->stored->address = '';
$this->expectUserError('archive_incomplete', fn() => $this->service(FakeContext::admin())->testMail());
$this->assertSame([], $this->smtp->submitted);
}
}
@@ -1,68 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\Archive\Service;
use Mailcow\Archive\Redis\ArchiveState;
use Mailcow\Archive\Service\MailboxListService;
use Mailcow\Core\AccessDenied;
use Mailcow\Core\Context;
use Mailcow\Tests\Support\FakeArchiveLookup;
use Mailcow\Tests\Support\FakeContext;
use Mailcow\Tests\Support\FakeRedis;
use PHPUnit\Framework\TestCase;
class MailboxListServiceTest extends TestCase
{
private FakeArchiveLookup $lookup;
private FakeRedis $redis;
protected function setUp(): void
{
$this->redis = new FakeRedis();
$this->lookup = new FakeArchiveLookup();
// username => [mailbox setting, domain default]
$this->lookup->mailboxes = [
'own@firma.de' => ['1', 0],
'inherit@firma.de' => ['inherit', 1],
'excluded@firma.de' => ['0', 1],
'plain@other.example' => ['inherit', 0],
];
}
private function service(bool $enabled, ?Context $context = null): MailboxListService
{
$this->redis->strings['ARCHIVE_ENABLED'] = $enabled ? '1' : '0';
return new MailboxListService($this->lookup, new ArchiveState($this->redis), $context ?? FakeContext::admin());
}
public function testArchivedMailboxesWithTheirSource(): void
{
$expected = ['enabled' => 1, 'mailboxes' => [
['username' => 'own@firma.de', 'domain' => 'firma.de', 'name' => '', 'active' => 1, 'source' => 'mailbox'],
['username' => 'inherit@firma.de', 'domain' => 'firma.de', 'name' => '', 'active' => 1, 'source' => 'domain'],
]];
$this->assertSame($expected, $this->service(true)->archived());
// read-only API keys too: archivecow keeps its mailboxes in line with this list
$this->assertSame($expected, $this->service(true, new Context('admin', 'API', [], false, 'ro'))->archived());
}
public function testEmptyWhileArchivingIsOff(): void
{
$this->assertSame(['enabled' => 0, 'mailboxes' => []], $this->service(false)->archived());
}
public function testOnlyAdmins(): void
{
foreach ([FakeContext::domainAdmin(['own@firma.de'], ['firma.de'], ['archive' => '1']), FakeContext::user('own@firma.de')] as $context) {
try {
$this->service(true, $context)->archived();
$this->fail('AccessDenied expected');
} catch (AccessDenied) {
$this->addToAssertionCount(1);
}
}
}
}
@@ -1,158 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\Archive\Service;
use Mailcow\Archive\Service\OwnerResolver;
use Mailcow\Tests\Support\FakeArchiveLookup;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
class OwnerResolverTest extends TestCase
{
private FakeArchiveLookup $lookup;
protected function setUp(): void
{
$this->lookup = new FakeArchiveLookup();
// archived domain firma.de, domain without archiving other.de
$this->lookup->mailboxes = [
'anna@firma.de' => ['inherit', 1],
'bernd@firma.de' => ['inherit', 1],
'buchhaltung@firma.de' => ['inherit', 1],
'br@firma.de' => ['0', 1],
'olga@other.de' => ['inherit', 0],
'otto@other.de' => ['1', 0],
];
// mailboxes have alias self rows
foreach (array_keys($this->lookup->mailboxes) as $username) {
$this->lookup->aliases[$username] = $username;
}
}
private function owners(array $rcpts, string $from = 'sender@extern.example', string $user = '', bool $bcc = true): array
{
return (new OwnerResolver($this->lookup))->resolve($from, $rcpts, $user, $bcc)['owners'];
}
public static function incoming(): array
{
return [
'mailbox' => [['anna@firma.de'], ['anna@firma.de']],
'upper case and brackets' => [['<Anna@FIRMA.de>'], ['anna@firma.de']],
'tag' => [['anna+news@firma.de'], ['anna@firma.de']],
'external' => [['someone@extern.example'], []],
'mailbox setting off' => [['br@firma.de'], []],
'domain without archiving' => [['olga@other.de'], []],
'mailbox setting on in a domain without archiving' => [['otto@other.de'], ['otto@other.de']],
'several recipients, sorted and unique' => [['bernd@firma.de', 'anna@firma.de', 'anna+x@firma.de'], ['anna@firma.de', 'bernd@firma.de']],
];
}
#[DataProvider('incoming')]
public function testIncomingRecipients(array $rcpts, array $owners): void
{
$this->assertSame($owners, $this->owners($rcpts));
}
public function testAliasExpandsAllBranches(): void
{
$this->lookup->aliases['team@firma.de'] = 'sales@firma.de,support@firma.de';
$this->lookup->aliases['sales@firma.de'] = 'anna@firma.de';
$this->lookup->aliases['support@firma.de'] = 'bernd@firma.de,someone@extern.example';
$this->assertSame(['anna@firma.de', 'bernd@firma.de'], $this->owners(['team@firma.de']));
}
public function testAliasKeepingACopyAndLoops(): void
{
$this->lookup->aliases['anna@firma.de'] = 'anna@firma.de,bernd@firma.de';
$this->lookup->aliases['loop1@firma.de'] = 'loop2@firma.de';
$this->lookup->aliases['loop2@firma.de'] = 'loop1@firma.de,olga@other.de,otto@other.de';
$this->assertSame(['anna@firma.de', 'bernd@firma.de'], $this->owners(['anna@firma.de']));
$this->assertSame(['otto@other.de'], $this->owners(['loop1@firma.de']));
}
public function testCatchAllOnlyForAddressesWithoutOwnEntry(): void
{
$this->lookup->aliases['@firma.de'] = 'buchhaltung@firma.de';
$this->lookup->aliases['old@firma.de'] = 'gone@firma.de';
$this->assertSame(['buchhaltung@firma.de'], $this->owners(['unknown@firma.de']));
$this->assertSame(['anna@firma.de'], $this->owners(['anna@firma.de']));
// a target without entry of its own gets the catch-all too, like Postfix
$this->assertSame(['buchhaltung@firma.de'], $this->owners(['old@firma.de']));
}
public function testAliasDomain(): void
{
$this->lookup->aliasDomains['firma.com'] = 'firma.de';
$this->lookup->aliases['@firma.com'] = 'bernd@firma.de';
$this->assertSame(['anna@firma.de'], $this->owners(['anna@firma.com']));
$this->assertSame(['anna@firma.de'], $this->owners(['anna+tag@firma.com']));
$this->assertSame(['bernd@firma.de'], $this->owners(['nobody@firma.com']));
}
public function testRecipientMapsRewriteFirst(): void
{
$this->lookup->recipientMaps['anna@extern.example'] = 'anna@firma.de';
$this->lookup->recipientMaps['@alt.example'] = '@firma.de';
$this->assertSame(['anna@firma.de'], $this->owners(['anna@extern.example']));
$this->assertSame(['bernd@firma.de'], $this->owners(['bernd@alt.example']));
}
public function testSpamAliasResourceAndLocalTargets(): void
{
$this->lookup->spamAliases['x7k2@firma.de'] = 'anna@firma.de';
$this->lookup->resources = ['room@firma.de'];
$this->lookup->aliases['@firma.de'] = 'bernd@firma.de';
$this->lookup->aliases['learn@firma.de'] = 'spam@localhost';
$this->assertSame(['anna@firma.de'], $this->owners(['x7k2@firma.de']));
$this->assertSame([], $this->owners(['room@firma.de']));
$this->assertSame([], $this->owners(['learn@firma.de']));
}
public function testBccDestinationsOnlyWhenRspamdSentTheCopy(): void
{
$this->lookup->bcc['rcpt']['olga@other.de'] = 'buchhaltung@firma.de';
$this->lookup->bcc['sender']['@extern.example'] = 'bernd@firma.de';
$this->assertSame(['bernd@firma.de', 'buchhaltung@firma.de'], $this->owners(['olga+tag@other.de']));
$this->assertSame([], $this->owners(['olga@other.de'], bcc: false));
}
public function testOutgoingSaslUserAndSentAsMailbox(): void
{
$this->lookup->aliases['info@firma.de'] = 'anna@firma.de,bernd@firma.de';
$this->lookup->aliasDomains['firma.com'] = 'firma.de';
$ext = ['someone@extern.example'];
$this->assertSame(['anna@firma.de'], $this->owners($ext, 'anna@firma.de', 'anna@firma.de'));
$this->assertSame(['anna@firma.de', 'buchhaltung@firma.de'], $this->owners($ext, 'buchhaltung@firma.de', 'anna@firma.de'));
$this->assertSame(['anna@firma.de', 'buchhaltung@firma.de'], $this->owners($ext, 'buchhaltung+x@firma.com', 'Anna@firma.de'));
// an alias as sender is no mailbox
$this->assertSame(['anna@firma.de'], $this->owners($ext, 'info@firma.de', 'anna@firma.de'));
// the sent-as mailbox has archiving off
$this->assertSame(['anna@firma.de'], $this->owners($ext, 'br@firma.de', 'anna@firma.de'));
}
public function testSenderWithoutLoginNeverOwns(): void
{
$this->assertSame([], $this->owners(['someone@extern.example'], 'anna@firma.de'));
$this->assertSame([], $this->owners(['someone@extern.example'], 'anna@firma.de', 'x1@mailcow.local'));
}
public function testDirection(): void
{
$resolver = new OwnerResolver($this->lookup);
$this->assertSame('in', $resolver->resolve('x@extern.example', ['anna@firma.de'], '', false)['direction']);
$this->assertSame('out', $resolver->resolve('anna@firma.de', ['x@extern.example'], 'anna@firma.de', false)['direction']);
$this->assertSame('internal', $resolver->resolve('anna@firma.de', ['olga@other.de'], 'anna@firma.de', false)['direction']);
}
}
@@ -1,195 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\Archive\Service;
use Mailcow\Archive\Model\Settings;
use Mailcow\Archive\Redis\ArchiveState;
use Mailcow\Archive\Repository\SettingsRepository;
use Mailcow\Archive\Service\SettingsService;
use Mailcow\Archive\Smtp\SmtpException;
use Mailcow\Core\Context;
use Mailcow\Core\UserError;
use Mailcow\Tests\Support\FakeArchiveLookup;
use Mailcow\Tests\Support\FakeContext;
use Mailcow\Tests\Support\FakeRedis;
use Mailcow\Tests\Support\FakeSmtpClient;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\MockObject\MockObject;
use PHPUnit\Framework\TestCase;
class SettingsServiceTest extends TestCase
{
private SettingsRepository&MockObject $repo;
private Settings $stored;
private FakeRedis $redis;
private FakeSmtpClient $smtp;
private FakeArchiveLookup $lookup;
protected function setUp(): void
{
$this->stored = new Settings();
$this->stored->address = 'journal@archive.example';
$this->stored->password = 'old-password';
$this->stored->signingKey = 'old-key';
$this->repo = $this->createMock(SettingsRepository::class);
$this->repo->method('load')->willReturnCallback(fn(): Settings => clone $this->stored);
$this->redis = new FakeRedis();
$this->smtp = new FakeSmtpClient();
$this->lookup = new FakeArchiveLookup();
$this->lookup->domains = ['firma.de', 'alias-firma.de'];
}
private function service(?Context $context = null): SettingsService
{
return new SettingsService($this->repo, $this->lookup, new ArchiveState($this->redis), $this->smtp, $context ?? FakeContext::admin());
}
private function expectUserError(string|array $msg, callable $call): void
{
try {
$call();
$this->fail('UserError ' . json_encode($msg) . ' expected');
} catch (UserError $e) {
$this->assertSame($msg, $e->msg());
}
}
public function testSaveKeepsMissingValuesAndEmptySecrets(): void
{
$this->repo->expects($this->once())->method('save')->with($this->callback(fn(Settings $s): bool => $s->enabled
&& $s->address === 'journal@archive.example' && $s->password === 'old-password' && $s->signingKey === 'old-key'));
$settings = $this->service()->save(['enabled' => ['0', '1'], 'password' => '', 'signing_secret' => '']);
$this->assertSame(1, $settings->toArray()['enabled']);
$this->assertSame('1', $this->redis->strings['ARCHIVE_ENABLED']);
}
public function testSaveRemovesThePassword(): void
{
$this->assertSame('old-password', $this->service()->save(['password' => '', 'password_remove' => '0'])->password);
$this->assertSame('', $this->service()->save(['password' => '', 'password_remove' => '1'])->password);
}
public function testSwitchIsKeptInRedisOnly(): void
{
$this->assertFalse($this->service()->enabled());
$this->assertSame(0, $this->service()->get()->toArray()['enabled']);
$this->service()->save(['enabled' => '1']);
$this->assertSame('1', $this->redis->strings['ARCHIVE_ENABLED']);
$this->assertSame(1, $this->service()->get()->toArray()['enabled']);
// saving other settings keeps the switch
$this->service()->save(['port' => '2525']);
$this->assertTrue($this->service()->enabled());
// a lost Redis key shows as off, as rspamd sees it
unset($this->redis->strings['ARCHIVE_ENABLED']);
$this->assertFalse($this->service()->enabled());
}
public function testAddressOutsideTheMailcowDomains(): void
{
$this->expectUserError('archive_address_local', fn() => $this->service()->save(['address' => 'journal@firma.de']));
$this->expectUserError('archive_address_local', fn() => $this->service()->save(['address' => 'Journal@Alias-Firma.de']));
$this->assertSame('journal@archiv.firma.de', $this->service()->save(['address' => 'journal@archiv.firma.de'])->address);
}
public function testSaveAllFields(): void
{
$settings = $this->service()->save(['enabled' => '0', 'address' => ' Journal@Archive.Example ', 'host' => '[2001:DB8::25]',
'port' => '2525', 'tls' => 'verify', 'username' => 'mailcow', 'password' => 'new:password', 'profile' => 'envelope',
'signing_secret' => 'new-key', 'header_from' => 'X-Real-MailFrom', 'header_rcpt' => 'X-Real-RcptTo']);
$this->assertSame(['enabled' => 0, 'address' => 'journal@archive.example', 'host' => '2001:db8::25', 'port' => 2525,
'tls' => 'verify', 'username' => 'mailcow', 'has_password' => 1, 'profile' => 'envelope', 'has_signing_secret' => 1,
'header_from' => 'X-Real-MailFrom', 'header_rcpt' => 'X-Real-RcptTo'], $settings->toArray());
$this->assertSame(['new:password', 'new-key'], [$settings->password, $settings->signingKey]);
$this->assertSame('0', $this->redis->strings['ARCHIVE_ENABLED']);
}
public static function invalid(): array
{
return [
'address' => [['address' => 'journal'], 'archive_address_invalid'],
'address array' => [['address' => ['x']], 'archive_address_invalid'],
'host' => [['host' => 'archive example'], 'archive_host_invalid'],
'port' => [['port' => '0'], 'archive_port_invalid'],
'port too high' => [['port' => '65536'], 'archive_port_invalid'],
'tls' => [['tls' => 'none'], 'archive_tls_invalid'],
'username with colon' => [['username' => 'a:b'], 'archive_username_invalid'],
'password with space' => [['password' => 'a b'], 'archive_password_invalid'],
'password too long' => [['password' => str_repeat('a', 256)], 'archive_password_invalid'],
'profile' => [['profile' => 'journal'], 'archive_profile_invalid'],
'signing secret not ascii' => [['signing_secret' => 'schlüssel'], 'archive_signing_secret_invalid'],
'header name with space' => [['header_from' => 'X Envelope'], 'archive_header_invalid'],
'header name with line break' => [['header_rcpt' => "X-To\r\nBcc"], 'archive_header_invalid'],
'switch on without address' => [['enabled' => '1', 'address' => ''], 'archive_incomplete'],
];
}
#[DataProvider('invalid')]
public function testSaveValidates(array $input, string $error): void
{
$this->repo->expects($this->never())->method('save');
$this->expectUserError($error, fn() => $this->service()->save($input));
$this->assertArrayNotHasKey('ARCHIVE_ENABLED', $this->redis->strings);
}
public function testArchivecowNeedsASigningKey(): void
{
$this->stored->signingKey = '';
$this->expectUserError('archive_incomplete', fn() => $this->service()->save(['enabled' => '1']));
$this->assertTrue($this->service()->save(['enabled' => '1', 'profile' => 'none'])->enabled);
}
public function testPermissions(): void
{
$readOnly = new Context('admin', 'API', [], false, 'ro');
$this->repo->expects($this->never())->method('save');
$this->assertSame(1, $this->service($readOnly)->get()->toArray()['has_password']);
$this->expectUserError('access_denied', fn() => $this->service($readOnly)->save(['enabled' => '0']));
$this->expectUserError('access_denied', fn() => $this->service($readOnly)->test());
$this->expectUserError('access_denied', fn() => $this->service(FakeContext::domainAdmin(acl: ['archive' => '1']))->get());
$this->expectUserError('access_denied', fn() => $this->service(FakeContext::user())->save([]));
$this->assertSame([], $this->smtp->probed);
}
public function testConnectionTestWithTheStoredTarget(): void
{
$this->stored->host = 'smtp.archive.example';
$this->stored->port = 587;
$this->stored->tls = 'verify';
$this->stored->username = 'mailcow';
$this->assertSame('archive_test_ok', $this->service()->test());
$this->assertSame([['host' => 'smtp.archive.example', 'port' => 587, 'tls' => 'verify', 'username' => 'mailcow',
'password' => 'old-password', 'from' => 'archive-bounce@localhost', 'rcpt' => 'journal@archive.example']], $this->smtp->probed);
}
public function testConnectionTestReportsMissingExtensions(): void
{
$this->smtp->mx['archive.example'] = 'mx1.archive.example';
$this->smtp->extensions = ['STARTTLS', 'SIZE'];
$this->assertSame(['archive_test_ok_missing', '8BITMIME'], $this->service()->test());
$this->assertSame('mx1.archive.example', $this->smtp->probed[0]['host']);
}
public function testConnectionTestFailure(): void
{
$this->smtp->error = new SmtpException('recipient', "550 5.1.1 <journal@archive.example>:\n unknown");
$this->expectUserError(['archive_test_failed', 'archive.example:25 recipient: 550 5.1.1 journal@archive.example: unknown'],
fn() => $this->service()->test());
}
public function testConnectionTestNeedsAnAddress(): void
{
$this->stored->address = '';
$this->expectUserError('archive_incomplete', fn() => $this->service()->test());
$this->assertSame([], $this->smtp->probed);
}
}
@@ -1,122 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\Archive\Service;
use Mailcow\Archive\Legacy\LegacyQueue;
use Mailcow\Archive\Model\Settings;
use Mailcow\Archive\Redis\ArchiveState;
use Mailcow\Archive\Repository\SettingsRepository;
use Mailcow\Archive\Service\StatusService;
use Mailcow\Core\Context;
use Mailcow\Core\UserError;
use Mailcow\Tests\Support\FakeContext;
use Mailcow\Tests\Support\FakeRedis;
use PHPUnit\Framework\TestCase;
class StatusServiceTest extends TestCase
{
private const REFUSED = 'connect to archive.example[192.0.2.5]:25: Connection refused';
private FakeRedis $redis;
protected function setUp(): void
{
$this->redis = new FakeRedis();
$this->redis->strings['ARCHIVE_ENABLED'] = '1';
}
private function service(?Context $context = null): StatusService
{
$settings = new Settings();
$settings->address = 'journal@archive.example';
$settings->profile = 'none';
$repo = $this->createStub(SettingsRepository::class);
$repo->method('load')->willReturn($settings);
$queue = $this->createMock(LegacyQueue::class);
$queue->method('entries')->with('archive-bounce@localhost')->willReturn([
['queue_id' => 'A1', 'arrival_time' => 1700000100, 'message_size' => 2048, 'recipients' => ['journal@archive.example (' . self::REFUSED . ')']],
['queue_id' => 'A2', 'arrival_time' => 1700000000, 'message_size' => 1024, 'recipients' => ['journal@archive.example (' . self::REFUSED . ')']],
['queue_id' => 'A3', 'arrival_time' => 1700000200, 'message_size' => 10, 'recipients' => ['journal@archive.example']],
]);
return new StatusService($repo, new ArchiveState($this->redis), $queue, $context ?? new Context('admin', 'API', [], false, 'ro'));
}
/** POSTFIX_MAILLOG line as syslog-ng writes it */
private static function log(int $time, string $program, string $message): string
{
return json_encode(['time' => (string)$time, 'program' => $program, 'priority' => 'info', 'message' => $message]);
}
public function testStatus(): void
{
$state = new ArchiveState($this->redis);
// a day old: no longer a problem, still counted
$this->redis->lPush('ARCHIVE_DROPPED_LOG', json_encode(['time' => time() - 90000, 'qid' => 'OLD1', 'reason' => 'old', 'message_id' => '']));
$state->dropped('4XyZ1', 'owner lookup failed', 'm1@firma.de');
$this->redis->lists['POSTFIX_MAILLOG'] = [
self::log(1700000300, 'postfix/archive/smtp', 'A4: to=<journal@archive.example>, relay=archive.example[192.0.2.5]:25, delay=0.2, '
. 'delays=0.1/0/0.05/0.05, dsn=2.0.0, status=sent (250 2.0.0 Ok: queued as 77)'),
self::log(1700000280, 'postfix/archive/smtp', 'A1: to=<journal@archive.example>, relay=none, delay=180, dsn=4.4.1, status=deferred (' . self::REFUSED . ')'),
self::log(1700000200, 'postfix/archive/smtp', 'A0: to=<journal@archive.example>, relay=archive.example[192.0.2.5]:25, dsn=2.0.0, status=sent (250 ok)'),
];
$this->assertSame([
'enabled' => 1, 'configured' => 1, 'queued' => 3, 'queued_bytes' => 3082, 'oldest_queued' => 1700000000,
'delay_reasons' => [self::REFUSED], 'last_delivery' => 1700000300, 'dropped' => 1, 'dropped_24h' => 1,
'last_dropped' => ['time' => $this->redis->lists['ARCHIVE_DROPPED_LOG'] ? json_decode($this->redis->lists['ARCHIVE_DROPPED_LOG'][0], true)['time'] : 0,
'reason' => 'owner lookup failed'],
], $this->service()->status());
}
public function testSwitchFromRedis(): void
{
$this->redis->strings['ARCHIVE_ENABLED'] = '0';
$this->assertSame(0, $this->service()->status()['enabled']);
}
public function testLogWithMessageIds(): void
{
(new ArchiveState($this->redis))->dropped('4XyZ1', 'owner lookup failed', 'm1@firma.de');
$this->redis->lists['POSTFIX_MAILLOG'] = [
self::log(1700000310, 'postfix/qmgr', 'E1: from=<archive-bounce@localhost>, status=expired, returned to sender'),
self::log(1700000300, 'postfix/archive/smtp', 'A4: to=<journal@archive.example>, relay=archive.example[192.0.2.5]:25, dsn=2.0.0, '
. 'status=sent (250 2.0.0 Ok: queued as 77)'),
self::log(1700000295, 'postfix/qmgr', 'Q9: from=<someone@firma.de>, status=expired, returned to sender'),
self::log(1700000290, 'postfix/smtp', 'B1: to=<x@extern.example>, relay=mx.extern.example[198.51.100.7]:25, dsn=2.0.0, status=sent (250 Ok)'),
self::log(1700000285, 'postfix/archive/discard', 'D1: to=<archive-bounce@localhost>, relay=none, dsn=2.0.0, status=sent (localhost)'),
self::log(1700000280, 'postfix/archive/smtp', 'A2: to=<journal@archive.example>, relay=archive.example[192.0.2.5]:25, dsn=4.1.1, '
. 'status=SOFTBOUNCE (host archive.example[192.0.2.5] said: 550 5.1.1 unknown (in reply to RCPT TO command))'),
self::log(1700000270, 'postfix/archive/smtp', 'Untrusted TLS connection established to archive.example[192.0.2.5]:25'),
self::log(1700000262, 'postfix/cleanup', 'A4: message-id=<m4@extern.example>'),
self::log(1700000261, 'postfix/cleanup', 'A2: message-id=<m2@firma.de>'),
'not json',
];
$log = $this->service()->log();
// the mail not archived just now is the newest entry
$this->assertSame(['status' => 'dropped', 'qid' => '4XyZ1', 'message_id' => 'm1@firma.de', 'text' => 'owner lookup failed'],
array_diff_key($log[0], ['time' => 0]));
$this->assertSame([
['time' => 1700000310, 'status' => 'expired', 'qid' => 'E1', 'message_id' => '', 'text' => ''],
['time' => 1700000300, 'status' => 'sent', 'qid' => 'A4', 'message_id' => 'm4@extern.example', 'text' => '250 2.0.0 Ok: queued as 77'],
['time' => 1700000280, 'status' => 'softbounce', 'qid' => 'A2', 'message_id' => 'm2@firma.de',
'text' => 'host archive.example[192.0.2.5] said: 550 5.1.1 unknown (in reply to RCPT TO command)'],
], array_slice($log, 1));
$this->assertCount(2, $this->service()->log(2));
}
public function testOnlyAdmins(): void
{
foreach (['status', 'log'] as $method) {
try {
$this->service(FakeContext::domainAdmin(acl: ['archive' => '1']))->$method();
$this->fail('UserError expected');
} catch (UserError) {
$this->addToAssertionCount(1);
}
}
}
}
-123
View File
@@ -1,123 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\Archive\Smtp;
use Mailcow\Archive\Smtp\SmtpClient;
use Mailcow\Archive\Smtp\SmtpException;
use PHPUnit\Framework\TestCase;
/**
* SMTP dialog against scripted server replies.
*/
class SmtpClientTest extends TestCase
{
private static function client(array $replies): SmtpClient
{
return new class ($replies) extends SmtpClient {
public array $written = [];
public array $tls = [];
public bool $closed = false;
public function __construct(private array $replies)
{
}
protected function open(string $host, int $port): void
{
$this->written[] = "OPEN $host:$port";
}
protected function close(): void
{
$this->closed = true;
}
protected function write(string $data): void
{
$this->written[] = $data;
}
protected function readLine(): ?string
{
return array_shift($this->replies);
}
protected function startTls(string $host, bool $verify): void
{
$this->tls[] = [$host, $verify];
}
};
}
public function testSubmitWithCrlfAndDotStuffing(): void
{
$client = self::client(['220 postfix ESMTP', '250-postfix', '250 8BITMIME', '250 2.1.0 Ok', '250 2.1.5 Ok',
'354 End data with <CR><LF>.<CR><LF>', '250 2.0.0 Ok: queued as 4XyZ1']);
$qid = $client->submit('postfix', 591, 'archive-bounce@localhost', 'journal@archive.example', "X-A: 1\r\n\r\n.dot\nline\r\n");
$this->assertSame('4XyZ1', $qid);
$this->assertSame(['OPEN postfix:591', 'EHLO ' . (gethostname() ?: 'mailcow') . "\r\n", "MAIL FROM:<archive-bounce@localhost>\r\n",
"RCPT TO:<journal@archive.example>\r\n", "DATA\r\n", "X-A: 1\r\n\r\n..dot\r\nline\r\n.\r\n", "QUIT\r\n"], $client->written);
$this->assertTrue($client->closed);
}
public function testProbeWithStartTlsAndAuth(): void
{
$client = self::client(['220 archive ESMTP', '250-archive', '250-STARTTLS', '250 AUTH PLAIN', '220 2.0.0 Ready',
'250-archive', '250-8BITMIME', '250-SMTPUTF8', '250 AUTH PLAIN', '235 2.7.0 Authenticated', '250 Ok', '250 Ok', '250 Ok']);
$extensions = $client->probe('archive.example', 25, 'verify', 'user', 'pw', 'archive-bounce@localhost', 'journal@archive.example');
$this->assertSame(['8BITMIME', 'SMTPUTF8', 'AUTH'], $extensions);
$this->assertSame([['archive.example', true]], $client->tls);
$this->assertContains('AUTH PLAIN ' . base64_encode("\0user\0pw") . "\r\n", $client->written);
$this->assertContains("RSET\r\n", $client->written);
}
public function testProbeNeedsStartTlsUnlessOptional(): void
{
try {
self::client(['220 archive ESMTP', '250 archive'])->probe('archive.example', 25, 'encrypt', '', '', 'a@localhost', 'j@a.example');
$this->fail('SmtpException expected');
} catch (SmtpException $e) {
$this->assertSame('tls: STARTTLS not offered', $e->describe());
}
$client = self::client(['220 archive ESMTP', '250 archive', '250 Ok', '250 Ok', '250 Ok']);
$this->assertSame([], $client->probe('archive.example', 25, 'may', '', '', 'a@localhost', 'j@a.example'));
$this->assertSame([], $client->tls);
}
public function testRefusalsNameTheStep(): void
{
$cases = [
'connect' => ['421 4.3.2 Service not available'],
'auth' => ['220 archive', '250-archive', '250 STARTTLS', '220 Go', '250 archive', '535 5.7.8 failed'],
'recipient' => ['220 archive', '250-archive', '250 STARTTLS', '220 Go', '250 archive', '235 Ok', '250 Ok', '550 5.1.1 <j@a.example>: unknown'],
];
foreach ($cases as $step => $replies) {
try {
self::client($replies)->probe('archive.example', 25, 'encrypt', 'user', 'pw', 'a@localhost', 'j@a.example');
$this->fail("SmtpException at $step expected");
} catch (SmtpException $e) {
$this->assertSame($step, $e->step);
$this->assertSame(end($replies), $e->reply);
}
}
}
public function testConnectionClosedDuringData(): void
{
$this->expectException(SmtpException::class);
self::client(['220 postfix', '250 postfix', '250 Ok', '250 Ok', '354 go'])->submit('postfix', 591, 'a@localhost', 'j@a.example', "x\r\n");
}
public function testCrlfAndDotStuffing(): void
{
$this->assertSame("a\r\nb\r\nc\r\n", SmtpClient::crlf("a\nb\rc"));
$this->assertSame("a\r\n", SmtpClient::crlf("a\r\n"));
$this->assertSame("..a\r\n..\r\n", SmtpClient::dotStuff(".a\r\n.\r\n"));
}
}
-87
View File
@@ -1,87 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\Support;
use Mailcow\Archive\Model\Mailbox;
use Mailcow\Archive\Repository\LookupRepository;
/**
* Address tables in memory, with the conditions of the SQL already applied: only active rows are listed,
* $mailboxes holds the mailboxes that receive mail as username => [archive setting, domain archive].
*/
class FakeArchiveLookup extends LookupRepository
{
public array $recipientMaps = [];
public array $aliases = [];
public array $resources = [];
public array $spamAliases = [];
/** alias domain => target domain */
public array $aliasDomains = [];
public array $mailboxes = [];
/** ['rcpt' => [key => dest], 'sender' => [key => dest]] */
public array $bcc = ['rcpt' => [], 'sender' => []];
/** @var string[] domains and alias domains of the mailcow */
public array $domains = [];
/** @var string[] every lookup as "<method>:<key>" */
public array $queries = [];
public function __construct()
{
}
public function recipientMap(string $key): ?string
{
$this->queries[] = 'recipientMap:' . $key;
return $this->recipientMaps[$key] ?? null;
}
public function aliasGoto(string $key): ?string
{
$this->queries[] = 'aliasGoto:' . $key;
return $this->aliases[$key] ?? null;
}
public function isResource(string $username): bool
{
return in_array($username, $this->resources, true);
}
public function spamAliasGoto(string $key): ?string
{
return $this->spamAliases[$key] ?? null;
}
public function aliasDomainMailbox(string $local, string $domain): ?string
{
$target = $this->aliasDomains[$domain] ?? null;
return ($target !== null && isset($this->mailboxes[$local . '@' . $target])) ? $local . '@' . $target : null;
}
public function bccDestination(string $key, string $type): ?string
{
return $this->bcc[$type][$key] ?? null;
}
public function isMailcowDomain(string $domain): bool
{
return in_array($domain, $this->domains, true);
}
public function mailbox(string $username): ?Mailbox
{
if (!isset($this->mailboxes[$username])) {
return null;
}
[$archive, $domainArchive] = $this->mailboxes[$username];
return Mailbox::fromRow(['username' => $username, 'domain' => substr($username, strrpos($username, '@') + 1),
'archive' => $archive, 'domain_archive' => $domainArchive, 'name' => '', 'active' => 1]);
}
public function archivedMailboxes(): array
{
return array_values(array_filter(array_map(fn(string $username): Mailbox => $this->mailbox($username), array_keys($this->mailboxes)),
fn(Mailbox $mailbox): bool => $mailbox->archived()));
}
}
-54
View File
@@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\Support;
/**
* The few Redis commands of the archive state, in memory (the test image has no redis extension).
*/
class FakeRedis
{
public array $lists = [];
public array $strings = [];
public function set(string $key, string $value): bool
{
$this->strings[$key] = $value;
return true;
}
public function get(string $key): string|false
{
return $this->strings[$key] ?? false;
}
public function incr(string $key): int
{
$this->strings[$key] = (string)((int)($this->strings[$key] ?? 0) + 1);
return (int)$this->strings[$key];
}
public function lPush(string $key, string $value): int
{
$this->lists[$key] ??= [];
array_unshift($this->lists[$key], $value);
return count($this->lists[$key]);
}
public function lTrim(string $key, int $start, int $stop): bool
{
$this->lists[$key] = array_slice($this->lists[$key] ?? [], $start, $stop - $start + 1);
return true;
}
/** Like Redis, a negative stop counts from the end (-1 is the last element) */
public function lRange(string $key, int $start, int $stop): array
{
$list = $this->lists[$key] ?? [];
if ($stop < 0) {
$stop += count($list);
}
return array_slice($list, $start, max(0, $stop - $start + 1));
}
}
-46
View File
@@ -1,46 +0,0 @@
<?php
declare(strict_types=1);
namespace Mailcow\Tests\Support;
use Mailcow\Archive\Smtp\SmtpClient;
use Mailcow\Archive\Smtp\SmtpException;
/**
* SmtpClient without network: records submit() and probe() calls, answers with the configured results.
*/
class FakeSmtpClient extends SmtpClient
{
/** @var array[] submit() calls: host, port, from, rcpt, data */
public array $submitted = [];
/** @var array[] probe() calls */
public array $probed = [];
public ?SmtpException $error = null;
/** @var string[] extensions probe() reports */
public array $extensions = ['PIPELINING', 'SIZE', 'STARTTLS', '8BITMIME', 'SMTPUTF8'];
public array $mx = [];
public function submit(string $host, int $port, string $from, string $rcpt, string $data): string
{
if ($this->error !== null) {
throw $this->error;
}
$this->submitted[] = compact('host', 'port', 'from', 'rcpt', 'data');
return 'Q' . count($this->submitted);
}
public function probe(string $host, int $port, string $tls, string $username, string $password, string $from, string $rcpt): array
{
$this->probed[] = compact('host', 'port', 'tls', 'username', 'password', 'from', 'rcpt');
if ($this->error !== null) {
throw $this->error;
}
return $this->extensions;
}
public function mailHost(string $domain): string
{
return $this->mx[$domain] ?? $domain;
}
}
+6 -4
View File
@@ -374,10 +374,12 @@ if ! $COMPOSE_COMMAND config -q; then
fi
echo -e "\e[32mChecking for conflicting bridges...\e[0m"
MAILCOW_BRIDGE=$($COMPOSE_COMMAND config | grep -i com.docker.network.bridge.name | cut -d':' -f2)
while read NAT_ID; do
iptables -t nat -D POSTROUTING "$NAT_ID"
done < <(iptables -L -vn -t nat --line-numbers | grep "$IPV4_NETWORK" | grep -E 'MASQUERADE.*all' | grep -v "${MAILCOW_BRIDGE}" | cut -d' ' -f1)
MAILCOW_BRIDGE=$($COMPOSE_COMMAND config | grep -i com.docker.network.bridge.name | cut -d':' -f2 | tr -d "[:space:]\"'")
if [[ -n "${MAILCOW_BRIDGE}" ]]; then
while read NAT_ID; do
iptables -t nat -D POSTROUTING "$NAT_ID"
done < <(iptables -L -vn -t nat --line-numbers | grep "$IPV4_NETWORK" | grep -E 'MASQUERADE.*all' | grep -vE "[[:space:]]!?${MAILCOW_BRIDGE}[[:space:]]" | cut -d' ' -f1)
fi
DIFF_DIRECTORY=update_diffs
DIFF_FILE=${DIFF_DIRECTORY}/diff_before_update_$(date +"%Y-%m-%d-%H-%M-%S")