From 4484cfcd4266ae245c77bfd7158413a8191b47ac Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Sat, 7 Nov 2026 15:55:43 +0100 Subject: [PATCH 1/5] [Web][Postfix] Add mail policies: per-recipient send/receive rules for mailboxes and groups --- data/Dockerfiles/postfix/Dockerfile | 3 + data/Dockerfiles/postfix/mailcow-policy.py | 53 +++ data/Dockerfiles/postfix/postfix.sh | 29 ++ data/conf/postfix/main.cf | 8 + data/conf/postfix/master.cf | 3 + data/conf/rspamd/dynmaps/mailpolicy.php | 39 ++ data/conf/rspamd/dynmaps/settings.php | 30 -- data/web/admin/mailbox.php | 2 + data/web/api/openapi.yaml | 370 +++++++++++++++++- data/web/domainadmin/mailbox.php | 2 + data/web/edit.php | 11 + data/web/inc/footer.inc.php | 1 + data/web/inc/init_db.inc.php | 83 +++- .../Controller/MailPolicyApiController.php | 126 ++++++ .../mailcow/MailPolicy/MailPolicyModule.php | 44 +++ .../lib/mailcow/MailPolicy/Model/Decision.php | 43 ++ .../lib/mailcow/MailPolicy/Model/Policy.php | 111 ++++++ .../Repository/LookupRepository.php | 91 +++++ .../Repository/PolicyRepository.php | 128 ++++++ .../MailPolicy/Service/PolicyEngine.php | 209 ++++++++++ .../MailPolicy/Service/PolicyService.php | 299 ++++++++++++++ data/web/js/site/mail-policy.js | 159 ++++++++ data/web/js/site/mailbox.js | 1 + data/web/json_api.php | 26 ++ data/web/lang/lang.de-de.json | 74 +++- data/web/lang/lang.en-gb.json | 74 +++- .../templates/edit/mail-policy-fields.twig | 79 ++++ data/web/templates/edit/mail-policy.twig | 46 +++ data/web/templates/mailbox.twig | 16 +- .../templates/mailbox/tab-mail-policies.twig | 54 +++ data/web/templates/modals/mail_policy.twig | 50 +++ data/web/templates/modals/mailbox.twig | 3 + docker-compose.yml | 5 +- .../MailPolicy/Service/PolicyEngineTest.php | 188 +++++++++ .../MailPolicy/Service/PolicyServiceTest.php | 169 ++++++++ tests/php/Support/FakeMailPolicyLookup.php | 90 +++++ 36 files changed, 2677 insertions(+), 42 deletions(-) create mode 100755 data/Dockerfiles/postfix/mailcow-policy.py create mode 100644 data/conf/rspamd/dynmaps/mailpolicy.php create mode 100644 data/web/inc/lib/mailcow/MailPolicy/Controller/MailPolicyApiController.php create mode 100644 data/web/inc/lib/mailcow/MailPolicy/MailPolicyModule.php create mode 100644 data/web/inc/lib/mailcow/MailPolicy/Model/Decision.php create mode 100644 data/web/inc/lib/mailcow/MailPolicy/Model/Policy.php create mode 100644 data/web/inc/lib/mailcow/MailPolicy/Repository/LookupRepository.php create mode 100644 data/web/inc/lib/mailcow/MailPolicy/Repository/PolicyRepository.php create mode 100644 data/web/inc/lib/mailcow/MailPolicy/Service/PolicyEngine.php create mode 100644 data/web/inc/lib/mailcow/MailPolicy/Service/PolicyService.php create mode 100644 data/web/js/site/mail-policy.js create mode 100644 data/web/templates/edit/mail-policy-fields.twig create mode 100644 data/web/templates/edit/mail-policy.twig create mode 100644 data/web/templates/mailbox/tab-mail-policies.twig create mode 100644 data/web/templates/modals/mail_policy.twig create mode 100644 tests/php/MailPolicy/Service/PolicyEngineTest.php create mode 100644 tests/php/MailPolicy/Service/PolicyServiceTest.php create mode 100644 tests/php/Support/FakeMailPolicyLookup.php diff --git a/data/Dockerfiles/postfix/Dockerfile b/data/Dockerfiles/postfix/Dockerfile index cf0647692..47f7b513f 100644 --- a/data/Dockerfiles/postfix/Dockerfile +++ b/data/Dockerfiles/postfix/Dockerfile @@ -26,6 +26,7 @@ RUN groupadd -g 102 postfix \ postfix \ postfix-mysql \ postfix-pcre \ + python3 \ redis-tools \ sasl2-bin \ sudo \ @@ -46,6 +47,7 @@ COPY postfix.sh /opt/postfix.sh COPY rspamd-pipe-ham /usr/local/bin/rspamd-pipe-ham COPY rspamd-pipe-spam /usr/local/bin/rspamd-pipe-spam COPY whitelist_forwardinghosts.sh /usr/local/bin/whitelist_forwardinghosts.sh +COPY mailcow-policy.py /usr/local/bin/mailcow-policy.py COPY stop-supervisor.sh /usr/local/sbin/stop-supervisor.sh COPY docker-entrypoint.sh /docker-entrypoint.sh @@ -53,6 +55,7 @@ RUN chmod +x /opt/postfix.sh \ /usr/local/bin/rspamd-pipe-ham \ /usr/local/bin/rspamd-pipe-spam \ /usr/local/bin/whitelist_forwardinghosts.sh \ + /usr/local/bin/mailcow-policy.py \ /usr/local/sbin/stop-supervisor.sh RUN rm -rf /tmp/* /var/tmp/* diff --git a/data/Dockerfiles/postfix/mailcow-policy.py b/data/Dockerfiles/postfix/mailcow-policy.py new file mode 100755 index 000000000..28316ac77 --- /dev/null +++ b/data/Dockerfiles/postfix/mailcow-policy.py @@ -0,0 +1,53 @@ +#!/usr/bin/python3 +# Policy service of the mail policies (mailcow-policy in master.cf, spawned by Postfix): reads the policy requests +# of smtpd and asks the mailcow UI for the action (nginx:8081/mailpolicy.php). Without an answer the recipient is +# deferred; smtpd only asks while policies or internal aliases exist (mysql_mail_policy_maps.cf). + +import http.client +import re +import socket +import sys +from urllib.parse import urlencode + +# one line: DUNNO or a rejection with code and text +valid_action = re.compile(rb'DUNNO|[45]\d\d [45]\.\d{1,3}\.\d{1,3} [\x20-\x7e]{1,200}') +unavailable = b'DEFER_IF_PERMIT Mail policy check temporarily unavailable' +# address of nginx, resolved once per process (a lookup costs more than the check) +nginx = None + +def check(request): + global nginx + body = urlencode(sorted(request.items())).encode() + try: + if nginx is None: + nginx = socket.gethostbyname('nginx') + # http.client sets TCP_NODELAY, so the body is not held back until the headers are acknowledged + conn = http.client.HTTPConnection(nginx, 8081, timeout=10) + try: + conn.request('POST', '/mailpolicy.php', body, {'Host': 'nginx', 'Content-Type': 'application/x-www-form-urlencoded'}) + response = conn.getresponse() + action = response.read() + finally: + conn.close() + except (OSError, http.client.HTTPException): + # nginx may have a new address after a restart + nginx = None + return unavailable + return action if response.status == 200 and valid_action.fullmatch(action) else unavailable + +def main(): + request = {} + # attributes as bytes: addresses may contain any 8-bit characters + for line in sys.stdin.buffer: + line = line.rstrip(b'\r\n') + if line: + name, separator, value = line.partition(b'=') + if separator: + request[name] = value + continue + sys.stdout.buffer.write(b'action=' + check(request) + b'\n\n') + sys.stdout.buffer.flush() + request = {} + +if __name__ == '__main__': + main() diff --git a/data/Dockerfiles/postfix/postfix.sh b/data/Dockerfiles/postfix/postfix.sh index 51927ea11..d5a857e41 100755 --- a/data/Dockerfiles/postfix/postfix.sh +++ b/data/Dockerfiles/postfix/postfix.sh @@ -15,6 +15,21 @@ until dig +short mailcow.email > /dev/null; do sleep 1 done +# Wait for updated schema (DB_VERSION from docker-compose.yml), the maps read tables the init_db of php-fpm creates on updates +DBV_NOW=$(mariadb --ssl=false --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN 2>/dev/null) +DBV_WAIT=0 +while [[ "${DBV_NOW}" != "${DB_VERSION}" ]] && [[ ${DBV_WAIT} -lt 60 ]]; do + echo "Waiting for schema update..." + sleep 5 + DBV_WAIT=$((DBV_WAIT + 1)) + DBV_NOW=$(mariadb --ssl=false --socket=/var/run/mysqld/mysqld.sock -u ${DBUSER} -p${DBPASS} ${DBNAME} -e "SELECT version FROM versions WHERE application = 'db_schema';" -BN 2>/dev/null) +done +if [[ "${DBV_NOW}" != "${DB_VERSION}" ]]; then + echo -e "\e[31mDB schema is ${DBV_NOW}, expected ${DB_VERSION} - starting anyway, check the php-fpm log (init_db)\e[0m" +else + echo "DB schema is ${DBV_NOW}" +fi + cat < /etc/aliases # Autogenerated by mailcow null: /dev/null @@ -224,6 +239,20 @@ query = SELECT CONCAT_WS(':', username, password) AS auth_data FROM transports LIMIT 1; EOF +# Mail policies: while policies or internal aliases exist, smtpd asks the policy service (mailcow_mail_policy in main.cf) +cat < /opt/postfix/conf/sql/mysql_mail_policy_maps.cf +# Autogenerated by mailcow +user = ${DBUSER} +password = ${DBPASS} +hosts = unix:/var/run/mysqld/mysqld.sock +dbname = ${DBNAME} +query = SELECT 'mailcow_mail_policy' FROM mail_policy_member + JOIN mail_policy ON mail_policy.id = mail_policy_member.policy_id + WHERE mail_policy.active = '1' AND '%s' != '' + UNION SELECT 'mailcow_mail_policy' FROM alias WHERE internal = '1' AND active = '1' + LIMIT 1; +EOF + cat < /opt/postfix/conf/sql/mysql_virtual_alias_domain_maps.cf # Autogenerated by mailcow user = ${DBUSER} diff --git a/data/conf/postfix/main.cf b/data/conf/postfix/main.cf index 4329f5487..c51713d09 100644 --- a/data/conf/postfix/main.cf +++ b/data/conf/postfix/main.cf @@ -50,6 +50,7 @@ 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_mail_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, @@ -90,6 +91,7 @@ smtpd_hard_error_limit = ${stress?1}${stress:5} smtpd_helo_required = yes smtpd_proxy_timeout = 600s smtpd_recipient_restrictions = check_recipient_mx_access proxy:mysql:/opt/postfix/conf/sql/mysql_mbr_access_maps.cf, + check_recipient_access proxy:mysql:/opt/postfix/conf/sql/mysql_mail_policy_maps.cf, permit_sasl_authenticated, permit_mynetworks, check_recipient_access proxy:mysql:/opt/postfix/conf/sql/mysql_tls_enforce_in_policy.cf, @@ -170,5 +172,11 @@ parent_domain_matches_subdomains = debug_peer_list,fast_flush_domains,mynetworks # This Option is added to correctly set the X-Original-To Header when mails are send to lmtp (dovecot) lmtp_destination_recipient_limit=1 +# Mail policies: while policies or internal aliases exist, mysql_mail_policy_maps.cf returns this class and smtpd +# asks the policy service for every recipient (mailcow-policy in master.cf); no answer defers the recipient +smtpd_restriction_classes = mailcow_mail_policy +mailcow_mail_policy = check_policy_service { unix:private/mailcow-policy, timeout=10s, default_action=DEFER_IF_PERMIT } +mailcow-policy_time_limit = 3600s + # DO NOT EDIT ANYTHING BELOW # # Overrides # diff --git a/data/conf/postfix/master.cf b/data/conf/postfix/master.cf index fb49f2df2..745c42f2a 100644 --- a/data/conf/postfix/master.cf +++ b/data/conf/postfix/master.cf @@ -117,6 +117,9 @@ smtp_sender_cleanup unix n - y - 0 cleanup 127.0.0.1:10027 inet n n n - 0 spawn user=nobody argv=/usr/local/bin/whitelist_forwardinghosts.sh # end whitelist_fwd +# mail policies: policy service of smtpd, asks the mailcow UI (mailcow_mail_policy in main.cf) +mailcow-policy unix - n n - 0 spawn user=nobody argv=/usr/local/bin/mailcow-policy.py + # start watchdog-specific # logs to local7 (hidden) 589 inet n - n - - smtpd diff --git a/data/conf/rspamd/dynmaps/mailpolicy.php b/data/conf/rspamd/dynmaps/mailpolicy.php new file mode 100644 index 000000000..ec5f35930 --- /dev/null +++ b/data/conf/rspamd/dynmaps/mailpolicy.php @@ -0,0 +1,39 @@ + PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + PDO::ATTR_EMULATE_PREPARES => false, +]; +try { + $pdo = new PDO($dsn, $database_user, $database_pass, $opt); +} +catch (PDOException $e) { + error_log("MAILPOLICY: " . $e . PHP_EOL); + http_response_code(501); + exit; +} + +if (!isset($_POST['request']) || $_POST['request'] !== 'smtpd_access_policy') { + http_response_code(400); + exit; +} +$value = fn(string $key): string => is_string($_POST[$key] ?? null) ? $_POST[$key] : ''; + +try { + $policy = \Mailcow\MailPolicy\MailPolicyModule::create($pdo, \Mailcow\Core\Context::system()); + echo $policy->api->check($value('sender'), $value('recipient'), $value('sasl_username'), (int)$value('server_port')); +} +catch (Throwable $e) { + // the policy service answers with a temporary error + error_log("MAILPOLICY: " . $e->getMessage() . PHP_EOL); + http_response_code(500); +} diff --git a/data/conf/rspamd/dynmaps/settings.php b/data/conf/rspamd/dynmaps/settings.php index 1b0e6b6e4..52087d851 100644 --- a/data/conf/rspamd/dynmaps/settings.php +++ b/data/conf/rspamd/dynmaps/settings.php @@ -469,35 +469,5 @@ while ($row = array_shift($rows)) { } ?> -query("SELECT `id`, `address`, `domain` FROM `alias` WHERE `active` = '1' AND `internal` = '1'"); -$aliases = $stmt->fetchAll(PDO::FETCH_ASSOC); -while ($alias = array_shift($aliases)) { - // build allowed_domains regex and add target domain and alias domains - $stmt = $pdo->prepare("SELECT `alias_domain` FROM `alias_domain` WHERE `active` = '1' AND `target_domain` = :target_domain"); - $stmt->execute(array(':target_domain' => $alias['domain'])); - $allowed_domains = $stmt->fetchAll(PDO::FETCH_ASSOC); - $allowed_domains = array_map(function($item) { - return str_replace('.', '\.', $item['alias_domain']); - }, $allowed_domains); - $allowed_domains[] = str_replace('.', '\.', $alias['domain']); - $allowed_domains = implode('|', $allowed_domains); -?> - internal_alias_ { - priority = 10; - rcpt = ""; - from = "/^((?!.*@()).)*$/"; - apply "default" { - MAILCOW_INTERNAL_ALIAS = 9999.0; - } - symbols [ - "MAILCOW_INTERNAL_ALIAS" - ] - } - } diff --git a/data/web/admin/mailbox.php b/data/web/admin/mailbox.php index 05530e99e..0ef078659 100644 --- a/data/web/admin/mailbox.php +++ b/data/web/admin/mailbox.php @@ -11,6 +11,7 @@ $_SESSION['return_to'] = $_SERVER['REQUEST_URI']; $js_minifier->add('/web/js/site/imapsync.js'); $js_minifier->add('/web/js/site/groups.js'); +$js_minifier->add('/web/js/site/mail-policy.js'); $js_minifier->add('/web/js/site/mailbox.js'); $js_minifier->add('/web/js/presets/sieveMailbox.js'); $js_minifier->add('/web/js/site/pwgen.js'); @@ -46,6 +47,7 @@ $template_data = [ 'lang_edit' => json_encode($lang['edit']), 'lang_datatables' => json_encode($lang['datatables']), 'imapsync_redirect_uri' => \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->oauth->redirectUri(), + 'mail_policy_groups' => \Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->policies->groupChoices($domains), ]; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php'; diff --git a/data/web/api/openapi.yaml b/data/web/api/openapi.yaml index b08747722..245581de4 100644 --- a/data/web/api/openapi.yaml +++ b/data/web/api/openapi.yaml @@ -1365,7 +1365,7 @@ paths: description: show the group address as SOGo identity (needs sender_allowed) type: boolean internal: - description: only accept mail from senders of the domain + description: only accept mail from logged-in senders of the domain type: boolean template_id: description: mailbox template applied to the members (admin only), empty = none @@ -1377,6 +1377,115 @@ paths: type: boolean type: object summary: Create group + /api/v1/add/mail-policy: + post: + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + - log: + - mail_policy + - add + - policy + - domain: example.org + name: Trainees + send_mode: internal + groups: + - "7" + msg: + - mail_policy_added + - Trainees + type: success + schema: + properties: + log: + description: contains request object + items: {} + type: array + msg: + items: {} + type: array + type: + enum: + - success + - danger + - warning + - error + type: string + type: object + description: OK + headers: {} + tags: + - Mail policies + description: >- + Create a mail policy (admins; domain admins with the "mail_policies" + ACL for their domains). Postfix checks the policies for every + recipient: every policy of the sending mailbox and of the addressed + mailbox or group must allow the mail, otherwise the recipient is + refused with 550 5.7.1. + operationId: Create mail policy + requestBody: + content: + application/json: + schema: + example: + domain: example.org + name: Trainees + send_mode: internal + send_allow: + - partner.example + receive_mode: all + receive_block: + - group:8 + groups: + - "7" + active: "1" + properties: + domain: + description: domain of the policy (required) + type: string + name: + description: unique per domain, max. 100 characters + type: string + description: + description: max. 255 characters + type: string + send_mode: + description: all; internal = recipients in the domain of the policy and its alias domains plus send_allow; list = send_allow only + type: string + send_allow: + description: addresses, domains or group: (array or list separated by commas, spaces or newlines), max. 500 + type: array + send_block: + description: recipients that are always refused, entries as send_allow + type: array + receive_mode: + description: all; internal = senders logged in with a mailbox of the domain plus receive_allow; list = receive_allow only + type: string + receive_allow: + description: entries as send_allow; addresses and domains of this mailcow count for logged-in senders only + type: array + receive_block: + description: senders that are always refused, entries as receive_allow + type: array + reject_text: + description: text of the 550 5.7.1 rejection, printable ASCII, max. 200 characters, empty = default text + type: string + mailboxes: + description: mailboxes of the domain the policy applies to (replaces the list) + type: array + groups: + description: group IDs of the domain the policy applies to, also to mail to the group address (replaces the list) + type: array + active: + type: boolean + type: object + summary: Create mail policy /api/v1/add/oauth2-client: post: responses: @@ -2853,6 +2962,62 @@ paths: type: object type: object summary: Delete groups + /api/v1/delete/mail-policy: + post: + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + - log: + - mail_policy + - delete + - policy + - id: + - "3" + msg: + - mail_policy_removed + - Trainees + type: success + schema: + properties: + log: + description: contains request object + items: {} + type: array + msg: + items: {} + type: array + type: + enum: + - success + - danger + - warning + - error + type: string + type: object + description: OK + headers: {} + tags: + - Mail policies + description: Delete mail policies by ID. + operationId: Delete mail policies + requestBody: + content: + application/json: + schema: + example: + - "3" + properties: + items: + description: contains list of policy IDs you want to delete + type: object + type: object + summary: Delete mail policies /api/v1/delete/identity-link: post: responses: @@ -4422,7 +4587,7 @@ paths: description: show the group address as SOGo identity (needs sender_allowed) type: boolean internal: - description: only accept mail from senders of the domain + description: only accept mail from logged-in senders of the domain type: boolean template_id: description: mailbox template applied to the members (admin only), empty = none @@ -4497,6 +4662,106 @@ paths: type: object type: object summary: Update groups + /api/v1/edit/mail-policy: + post: + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + - log: + - mail_policy + - edit + - policy + - id: + - "3" + receive_mode: internal + msg: + - mail_policy_modified + - Trainees + type: success + schema: + properties: + log: + description: contains request object + items: {} + type: array + msg: + items: {} + type: array + type: + enum: + - success + - danger + - warning + - error + type: string + type: object + description: OK + headers: {} + tags: + - Mail policies + description: >- + Update mail policies. Attributes you do not send keep their value. The + domain cannot be changed. + operationId: Update mail policies + requestBody: + content: + application/json: + schema: + example: + items: + - "3" + attr: + receive_mode: internal + properties: + attr: + properties: + name: + description: unique per domain, max. 100 characters + type: string + description: + description: max. 255 characters + type: string + send_mode: + description: all; internal = recipients in the domain of the policy and its alias domains plus send_allow; list = send_allow only + type: string + send_allow: + description: addresses, domains or group: (array or list separated by commas, spaces or newlines), max. 500 + type: array + send_block: + description: recipients that are always refused, entries as send_allow + type: array + receive_mode: + description: all; internal = senders logged in with a mailbox of the domain plus receive_allow; list = receive_allow only + type: string + receive_allow: + description: entries as send_allow; addresses and domains of this mailcow count for logged-in senders only + type: array + receive_block: + description: senders that are always refused, entries as receive_allow + type: array + reject_text: + description: text of the 550 5.7.1 rejection, printable ASCII, max. 200 characters, empty = default text + type: string + mailboxes: + description: mailboxes of the domain the policy applies to (replaces the list) + type: array + groups: + description: group IDs of the domain the policy applies to, also to mail to the group address (replaces the list) + type: array + active: + type: boolean + type: object + items: + description: contains list of policy IDs you want to update + type: object + type: object + summary: Update mail policies /api/v1/edit/identity-provider-sync: post: responses: @@ -6455,6 +6720,105 @@ paths: may send as through groups. operationId: Get group memberships summary: Get group memberships + "/api/v1/get/mail-policy/{id}": + get: + parameters: + - description: id of the policy, or "all" (optionally followed by /domain1,domain2) + example: all + in: path + name: id + required: true + schema: + type: string + - description: e.g. api-key-string + example: api-key-string + in: header + name: X-API-Key + required: false + schema: + type: string + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + - id: 3 + domain: example.org + name: Trainees + description: Trainees only send internally + send_mode: internal + send_allow: + - partner.example + send_block: [] + receive_mode: all + receive_allow: [] + receive_block: + - group:8 + reject_text: "" + active: 1 + mailboxes: [] + groups: + - 7 + created: "2026-11-07 12:00:00" + modified: null + can_edit: 1 + description: OK + headers: {} + tags: + - Mail policies + description: >- + Mail policies visible to the caller (admins all, domain admins with the + "mail_policies" ACL their domains). + operationId: Get mail policies + summary: Get mail policies + "/api/v1/get/mail-policy/test/{sender}/{recipient}": + get: + parameters: + - description: envelope sender, "-" for the null sender (bounces) + example: trainee@example.org + in: path + name: sender + required: true + schema: + type: string + - description: recipient; append /login to test a sender logged in with its mailbox + example: someone@external.example + in: path + name: recipient + required: true + schema: + type: string + responses: + "401": + $ref: "#/components/responses/Unauthorized" + "200": + content: + application/json: + examples: + response: + value: + allowed: 0 + side: send + policy: Trainees + internal_alias: 0 + logged_in: 1 + text: Sending to this recipient is not allowed by the mail policy of the sender + description: OK + headers: {} + tags: + - Mail policies + description: >- + Would Postfix accept the mail for this recipient? side is "send" or + "receive", policy is empty for a policy of another domain, + internal_alias = refused by the internal flag of an alias or group. + Domain admins test addresses of their domains. Empty answer for invalid + input. + operationId: Test mail policies + summary: Test mail policies /api/v1/get/identity-provider/sync-status: get: responses: @@ -8011,6 +8375,8 @@ tags: description: Manage API keys - name: Groups description: Mailbox groups (distribution lists, send-as rights, templates) + - name: Mail policies + description: Whom mailboxes and groups may send to and receive from - name: Routing description: Define your own email routes - name: Resources diff --git a/data/web/domainadmin/mailbox.php b/data/web/domainadmin/mailbox.php index 4b99579a0..cd1e441a2 100644 --- a/data/web/domainadmin/mailbox.php +++ b/data/web/domainadmin/mailbox.php @@ -11,6 +11,7 @@ $_SESSION['return_to'] = $_SERVER['REQUEST_URI']; $js_minifier->add('/web/js/site/imapsync.js'); $js_minifier->add('/web/js/site/groups.js'); +$js_minifier->add('/web/js/site/mail-policy.js'); $js_minifier->add('/web/js/site/mailbox.js'); $js_minifier->add('/web/js/presets/sieveMailbox.js'); $js_minifier->add('/web/js/site/pwgen.js'); @@ -45,6 +46,7 @@ $template_data = [ 'lang_edit' => json_encode($lang['edit']), 'lang_datatables' => json_encode($lang['datatables']), 'imapsync_redirect_uri' => \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->oauth->redirectUri(), + 'mail_policy_groups' => \Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->policies->groupChoices($domains), ]; require_once $_SERVER['DOCUMENT_ROOT'] . '/inc/footer.inc.php'; \ No newline at end of file diff --git a/data/web/edit.php b/data/web/edit.php index 51d88a12d..d53f74d81 100644 --- a/data/web/edit.php +++ b/data/web/edit.php @@ -115,6 +115,17 @@ if (isset($_SESSION['mailcow_cc_role'])) { 'mbox_templates' => ($_SESSION['mailcow_cc_role'] == 'admin') ? mailbox('get', 'mailbox_templates') : [], ]; } + elseif (isset($_GET['mail-policy']) && + is_numeric($_GET['mail-policy'])) { + $mail_policy = \Mailcow\MailPolicy\MailPolicyModule::fromGlobals(); + $result = $mail_policy->api->getPolicy($_GET['mail-policy']); + $template = 'edit/mail-policy.twig'; + // Choices: mailboxes and groups of the domain of the policy + $template_data = [ + 'mailboxes' => !empty($result) ? mailbox('get', 'mailboxes', $result['domain']) : [], + 'mail_policy_groups' => !empty($result) ? $mail_policy->policies->groupChoices([$result['domain']]) : [], + ]; + } elseif (isset($_GET['api-key']) && is_numeric($_GET['api-key'])) { $result = \Mailcow\ApiKey\ApiKeyModule::fromGlobals()->api->getKey($_GET['api-key']); diff --git a/data/web/inc/footer.inc.php b/data/web/inc/footer.inc.php index 41cd022bb..e8748947b 100644 --- a/data/web/inc/footer.inc.php +++ b/data/web/inc/footer.inc.php @@ -74,6 +74,7 @@ $globalVariables = [ 'lang_danger' => json_encode($lang['danger']), 'lang_syncjobs' => json_encode($lang['syncjobs']), 'lang_groups' => json_encode($lang['groups']), + 'lang_mail_policy' => json_encode($lang['mail_policy'] ?? []), 'docker_timeout' => $DOCKER_TIMEOUT, 'session_lifetime' => (int)$SESSION_LIFETIME, 'csrf_token' => $_SESSION['CSRF']['TOKEN'], diff --git a/data/web/inc/init_db.inc.php b/data/web/inc/init_db.inc.php index ff95a0b0a..6b29256e9 100644 --- a/data/web/inc/init_db.inc.php +++ b/data/web/inc/init_db.inc.php @@ -491,6 +491,86 @@ function init_db_schema() ), "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" ), + // Mail policies: whom the assigned mailboxes and groups may send to and receive from (Mailcow\MailPolicy) + "mail_policy" => array( + "cols" => array( + "id" => "INT NOT NULL AUTO_INCREMENT", + "domain" => "VARCHAR(255) NOT NULL", + "name" => "VARCHAR(100) NOT NULL", + "description" => "VARCHAR(255) NOT NULL DEFAULT ''", + // all, internal or list; the entries are JSON lists of addresses, domains and group: + "send_mode" => "VARCHAR(16) NOT NULL DEFAULT 'all'", + "send_allow" => "TEXT NOT NULL", + "send_block" => "TEXT NOT NULL", + "receive_mode" => "VARCHAR(16) NOT NULL DEFAULT 'all'", + "receive_allow" => "TEXT NOT NULL", + "receive_block" => "TEXT NOT NULL", + "reject_text" => "VARCHAR(255) NOT NULL DEFAULT ''", + "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)", + "modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP", + "active" => "TINYINT(1) NOT NULL DEFAULT '1'" + ), + "keys" => array( + "primary" => array( + "" => array("id") + ), + "unique" => array( + "domain_name" => array("domain", "name") + ), + "fkey" => array( + "fk_mail_policy_domain" => array( + "col" => "domain", + "ref" => "domain.domain", + "delete" => "CASCADE", + "update" => "NO ACTION" + ) + ) + ), + "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" + ), + // a mailbox or a group (its members and its address) a policy is assigned to + "mail_policy_member" => array( + "cols" => array( + "id" => "INT NOT NULL AUTO_INCREMENT", + "policy_id" => "INT NOT NULL", + "username" => "VARCHAR(255) NULL DEFAULT NULL", + "group_id" => "INT NULL DEFAULT NULL" + ), + "keys" => array( + "primary" => array( + "" => array("id") + ), + "unique" => array( + "policy_mailbox" => array("policy_id", "username"), + "policy_group" => array("policy_id", "group_id") + ), + "key" => array( + "username" => array("username"), + "group_id" => array("group_id") + ), + "fkey" => array( + "fk_mail_policy_member_policy" => array( + "col" => "policy_id", + "ref" => "mail_policy.id", + "delete" => "CASCADE", + "update" => "NO ACTION" + ), + "fk_mail_policy_member_mailbox" => array( + "col" => "username", + "ref" => "mailbox.username", + "delete" => "CASCADE", + "update" => "NO ACTION" + ), + "fk_mail_policy_member_group" => array( + "col" => "group_id", + "ref" => "mailbox_group.id", + "delete" => "CASCADE", + "update" => "NO ACTION" + ) + ) + ), + "attr" => "ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC" + ), "mailbox_group_imap_grant" => array( "cols" => array( "id" => "INT NOT NULL AUTO_INCREMENT", @@ -1053,7 +1133,8 @@ function init_db_schema() "domain_relayhost" => "TINYINT(1) NOT NULL DEFAULT '1'", "domain_desc" => "TINYINT(1) NOT NULL DEFAULT '0'", "alias_external_goto" => "TINYINT(1) NOT NULL DEFAULT '1'", - "mailbox_groups" => "TINYINT(1) NOT NULL DEFAULT '1'" + "mailbox_groups" => "TINYINT(1) NOT NULL DEFAULT '1'", + "mail_policies" => "TINYINT(1) NOT NULL DEFAULT '0'" ), "keys" => array( "primary" => array( diff --git a/data/web/inc/lib/mailcow/MailPolicy/Controller/MailPolicyApiController.php b/data/web/inc/lib/mailcow/MailPolicy/Controller/MailPolicyApiController.php new file mode 100644 index 000000000..2cdb6c698 --- /dev/null +++ b/data/web/inc/lib/mailcow/MailPolicy/Controller/MailPolicyApiController.php @@ -0,0 +1,126 @@ +handle('add', $attr, function (array $log) use ($attr): bool { + $policy = $this->policies->create($attr); + return $this->response->success(['mail_policy_added', htmlspecialchars($policy->name)], $log); + }); + } + + /** POST edit/mail-policy: one message per id */ + public function editPolicies(?array $items, array $attr): bool + { + $data = array_merge(['id' => $items], $attr); + return $this->handle('edit', $data, function (array $log) use ($data, $attr): bool { + $this->policies->requireEditor(); + return $this->batch((array)($data['id'] ?? []), $log, + fn(int $id): array => ['mail_policy_modified', htmlspecialchars($this->policies->update($id, $attr)->name)]); + }); + } + + /** POST delete/mail-policy: one message per id */ + public function deletePolicies(array $items): bool + { + return $this->handle('delete', ['id' => $items], function (array $log) use ($items): bool { + $this->policies->requireEditor(); + return $this->batch($items, $log, fn(int $id): array => ['mail_policy_removed', htmlspecialchars($this->policies->delete($id)->name)]); + }); + } + + /** GET mail-policy/all[/] */ + public function getPolicies(?string $domains = null): array + { + $list = array_values(array_filter(array_map('trim', explode(',', (string)$domains)), 'strlen')); + return array_values(array_filter(array_map(fn(int $id): array => $this->getPolicy($id), $this->policies->visibleIds($list)))); + } + + /** GET mail-policy/, also edit.php */ + public function getPolicy(mixed $id): array + { + $id = $this->toId($id); + $policy = ($id === null) ? null : $this->policies->get($id); + return ($policy === null) ? [] : $this->policies->toArray($policy); + } + + /** GET mail-policy/test//[/login]: would the mail be accepted? Sender "-" = null sender; [] when not allowed to ask */ + public function test(string $sender, string $recipient, bool $loggedIn): array + { + try { + return $this->policies->test(($sender === '-') ? '' : $sender, $recipient, $loggedIn); + } catch (UserError) { + return []; + } + } + + /** Answer for the Postfix policy service: DUNNO or the rejection */ + public function check(string $sender, string $recipient, string $login, int $port): string + { + return $this->engine->check($sender, $recipient, $login, $port)->action(); + } + + /** + * Runs one write call: $work gets the log entry and returns the result for process_*_return(). + * A UserError becomes a danger message, a database error stops the call with one `mysql_error` message. + */ + private function handle(string $action, mixed $data, callable $work): bool + { + $log = ['mail_policy', $action, 'policy', $data]; + try { + $result = $work($log); + } catch (UserError $e) { + $result = $this->response->error($e, $log); + } catch (PDOException $e) { + $result = $this->response->danger(['mysql_error', $e->getMessage()], $log); + } + $this->response->flush(); + return $result; + } + + /** One message per id: the success message returned by $work(id), or the UserError */ + private function batch(array $ids, array $log, callable $work): bool + { + foreach ($ids as $id) { + try { + $this->response->success($work($this->requireId($id)), $log); + } catch (UserError $e) { + $this->response->error($e, $log); + } + } + return true; + } + + /** Integer id from request input ("12", 12); null for anything else */ + private function toId(mixed $id): ?int + { + return (is_numeric($id) && (float)$id == (int)(float)$id) ? (int)(float)$id : null; + } + + /** @throws AccessDenied for ids that cannot exist */ + private function requireId(mixed $id): int + { + return $this->toId($id) ?? throw new AccessDenied(); + } +} diff --git a/data/web/inc/lib/mailcow/MailPolicy/MailPolicyModule.php b/data/web/inc/lib/mailcow/MailPolicy/MailPolicyModule.php new file mode 100644 index 000000000..724f617cd --- /dev/null +++ b/data/web/inc/lib/mailcow/MailPolicy/MailPolicyModule.php @@ -0,0 +1,44 @@ +api->getPolicies()); + * echo MailPolicyModule::create($pdo, Context::system())->api->check($sender, $recipient, $login, $port); // policy service + */ +class MailPolicyModule +{ + public function __construct( + public readonly PolicyService $policies, + public readonly PolicyEngine $engine, + public readonly MailPolicyApiController $api + ) { + } + + public static function create(PDO $pdo, Context $context): self + { + $engine = new PolicyEngine(new LookupRepository($pdo)); + $policies = new PolicyService(new PolicyRepository($pdo), $engine, $context); + return new self($policies, $engine, new MailPolicyApiController($policies, $engine, new ApiResponse())); + } + + /** Legacy entry point for procedural callers: reads $pdo and $_SESSION */ + public static function fromGlobals(?Context $context = null): self + { + return self::create($GLOBALS['pdo'], $context ?? Context::fromSession($_SESSION ?? [])); + } +} diff --git a/data/web/inc/lib/mailcow/MailPolicy/Model/Decision.php b/data/web/inc/lib/mailcow/MailPolicy/Model/Decision.php new file mode 100644 index 000000000..d030f488c --- /dev/null +++ b/data/web/inc/lib/mailcow/MailPolicy/Model/Decision.php @@ -0,0 +1,43 @@ + 'Sending to this recipient is not allowed by the mail policy of the sender', + 'receive' => 'This recipient does not accept mail from this sender', + ]; + + private function __construct(public readonly bool $allowed, public readonly string $side = '', public readonly ?Policy $policy = null) + { + } + + public static function allow(): self + { + return new self(true); + } + + /** $side send or receive */ + public static function deny(string $side, Policy $policy): self + { + return new self(false, $side, $policy); + } + + /** Text of the rejection: the text of the policy or the default */ + public function text(): string + { + return ($this->policy !== null && $this->policy->rejectText !== '') ? $this->policy->rejectText : (self::TEXT[$this->side] ?? ''); + } + + /** Answer for the Postfix policy protocol */ + public function action(): string + { + return $this->allowed ? 'DUNNO' : '550 5.7.1 ' . $this->text(); + } +} diff --git a/data/web/inc/lib/mailcow/MailPolicy/Model/Policy.php b/data/web/inc/lib/mailcow/MailPolicy/Model/Policy.php new file mode 100644 index 000000000..99f3db4b9 --- /dev/null +++ b/data/web/inc/lib/mailcow/MailPolicy/Model/Policy.php @@ -0,0 +1,111 @@ +`. A sender counts as internal only when it is logged in + * to this mailcow (SASL) with a mailbox of the domain, never because of its address. + */ +class Policy +{ + public const MODES = ['all', 'internal', 'list']; + + public ?int $id = null; + public string $domain = ''; + public string $name = ''; + public string $description = ''; + public string $sendMode = 'all'; + /** @var string[] */ + public array $sendAllow = []; + /** @var string[] */ + public array $sendBlock = []; + public string $receiveMode = 'all'; + /** @var string[] */ + public array $receiveAllow = []; + /** @var string[] */ + public array $receiveBlock = []; + /** Text of the rejection, '' = default text */ + public string $rejectText = ''; + public int $active = 1; + /** @var string[] usernames the policy is assigned to */ + public array $mailboxes = []; + /** @var int[] groups the policy is assigned to: their members and their address */ + public array $groups = []; + public ?string $created = null; + public ?string $modified = null; + + /** Row of `mail_policy`; assignments are set by the repository */ + public static function fromRow(array $row): self + { + $policy = new self(); + $policy->id = (int)$row['id']; + $policy->domain = (string)$row['domain']; + $policy->name = (string)$row['name']; + $policy->description = (string)($row['description'] ?? ''); + $policy->sendMode = (string)$row['send_mode']; + $policy->sendAllow = self::list($row['send_allow'] ?? null); + $policy->sendBlock = self::list($row['send_block'] ?? null); + $policy->receiveMode = (string)$row['receive_mode']; + $policy->receiveAllow = self::list($row['receive_allow'] ?? null); + $policy->receiveBlock = self::list($row['receive_block'] ?? null); + $policy->rejectText = (string)($row['reject_text'] ?? ''); + $policy->active = (int)$row['active']; + $policy->created = $row['created'] ?? null; + $policy->modified = $row['modified'] ?? null; + return $policy; + } + + /** Column values for `mail_policy` */ + public function toRow(): array + { + return [ + 'domain' => $this->domain, + 'name' => $this->name, + 'description' => $this->description, + 'send_mode' => $this->sendMode, + 'send_allow' => json_encode($this->sendAllow), + 'send_block' => json_encode($this->sendBlock), + 'receive_mode' => $this->receiveMode, + 'receive_allow' => json_encode($this->receiveAllow), + 'receive_block' => json_encode($this->receiveBlock), + 'reject_text' => $this->rejectText, + 'active' => $this->active, + ]; + } + + /** Entry of the API and the edit page */ + public function toArray(): array + { + return [ + 'id' => $this->id, + 'domain' => $this->domain, + 'name' => $this->name, + 'description' => $this->description, + 'send_mode' => $this->sendMode, + 'send_allow' => $this->sendAllow, + 'send_block' => $this->sendBlock, + 'receive_mode' => $this->receiveMode, + 'receive_allow' => $this->receiveAllow, + 'receive_block' => $this->receiveBlock, + 'reject_text' => $this->rejectText, + 'active' => $this->active, + 'mailboxes' => $this->mailboxes, + 'groups' => $this->groups, + 'created' => $this->created, + 'modified' => $this->modified, + ]; + } + + /** @return string[] entries of a JSON list column */ + private static function list(mixed $json): array + { + $list = is_string($json) ? json_decode($json, true) : null; + return is_array($list) ? array_values(array_filter($list, 'is_string')) : []; + } +} diff --git a/data/web/inc/lib/mailcow/MailPolicy/Repository/LookupRepository.php b/data/web/inc/lib/mailcow/MailPolicy/Repository/LookupRepository.php new file mode 100644 index 000000000..042b80654 --- /dev/null +++ b/data/web/inc/lib/mailcow/MailPolicy/Repository/LookupRepository.php @@ -0,0 +1,91 @@ +query("SELECT `username`, `domain` FROM `mailbox` WHERE `username` = :username AND `kind` = '' AND `active` != '0'", + ['username' => $username])->fetch(); + return is_array($row) ? ['username' => strtolower((string)$row['username']), 'domain' => strtolower((string)$row['domain'])] : null; + } + + /** Target domain of an active alias domain */ + public function aliasDomainTarget(string $domain): ?string + { + $target = $this->query("SELECT `target_domain` FROM `alias_domain` WHERE `alias_domain` = :domain AND `active` = '1'", + ['domain' => $domain])->fetchColumn(); + return is_string($target) ? strtolower($target) : null; + } + + /** A domain or alias domain of this mailcow */ + public function isLocalDomain(string $domain): bool + { + return (bool)$this->query("SELECT 1 FROM `domain` WHERE `domain` = :d1 UNION SELECT 1 FROM `alias_domain` WHERE `alias_domain` = :d2", + ['d1' => $domain, 'd2' => $domain])->fetchColumn(); + } + + /** Active group with this distribution address */ + public function groupByAddress(string $address): ?int + { + $id = $this->query("SELECT `id` FROM `mailbox_group` WHERE `address` = :address AND `active` = '1'", ['address' => $address])->fetchColumn(); + return ($id === false) ? null : (int)$id; + } + + public function groupAddress(int $id): ?string + { + $address = $this->query("SELECT `address` FROM `mailbox_group` WHERE `id` = :id AND `active` = '1'", ['id' => $id])->fetchColumn(); + return is_string($address) && $address !== '' ? strtolower($address) : null; + } + + public function isGroupMember(int $groupId, string $username): bool + { + return (bool)$this->query("SELECT 1 FROM `mailbox_group_member` WHERE `group_id` = :id AND `username` = :username", + ['id' => $groupId, 'username' => $username])->fetchColumn(); + } + + /** Domain of an active alias that only accepts mail from its own domain (alias and group flag `internal`) */ + public function internalAlias(string $address): ?string + { + $domain = $this->query("SELECT `domain` FROM `alias` WHERE `address` = :address AND `internal` = '1' AND `active` = '1'", + ['address' => $address])->fetchColumn(); + return is_string($domain) ? strtolower($domain) : null; + } + + /** @return Policy[] active policies of the mailbox: assigned to it or to an active group it is a member of */ + public function policiesForMailbox(string $username): array + { + return $this->policies("SELECT DISTINCT `p`.* FROM `mail_policy` `p` + JOIN `mail_policy_member` `pm` ON `pm`.`policy_id` = `p`.`id` + WHERE `p`.`active` = '1' AND (`pm`.`username` = :u1 OR `pm`.`group_id` IN ( + SELECT `gm`.`group_id` FROM `mailbox_group_member` `gm` JOIN `mailbox_group` `g` ON `g`.`id` = `gm`.`group_id` + WHERE `gm`.`username` = :u2 AND `g`.`active` = '1')) + ORDER BY `p`.`id`", ['u1' => $username, 'u2' => $username]); + } + + /** @return Policy[] active policies assigned to the group */ + public function policiesForGroup(int $groupId): array + { + return $this->policies("SELECT DISTINCT `p`.* FROM `mail_policy` `p` + JOIN `mail_policy_member` `pm` ON `pm`.`policy_id` = `p`.`id` + WHERE `p`.`active` = '1' AND `pm`.`group_id` = :id + ORDER BY `p`.`id`", ['id' => $groupId]); + } + + /** @return Policy[] */ + private function policies(string $sql, array $params): array + { + return array_map(fn(array $row): Policy => Policy::fromRow($row), $this->query($sql, $params)->fetchAll()); + } +} diff --git a/data/web/inc/lib/mailcow/MailPolicy/Repository/PolicyRepository.php b/data/web/inc/lib/mailcow/MailPolicy/Repository/PolicyRepository.php new file mode 100644 index 000000000..15d99840c --- /dev/null +++ b/data/web/inc/lib/mailcow/MailPolicy/Repository/PolicyRepository.php @@ -0,0 +1,128 @@ +query("SELECT * FROM `mail_policy` WHERE `id` = :id", ['id' => $id])->fetch(); + return is_array($row) ? $this->withMembers(Policy::fromRow($row)) : null; + } + + /** + * @param string[] $domains limit to these domains, [] = all visible + * @return int[] ids of the policies visible to the caller, ordered by domain and name + */ + public function visibleIds(Context $context, array $domains = []): array + { + if ($context->hasAdminRights()) { + [$visible, $params] = ['1=1', []]; + } elseif ($context->isDomainAdmin()) { + [$visible, $params] = ["`domain` IN (SELECT `domain` FROM `domain_admins` WHERE `active` = '1' AND `username` = :vis_da)", + ['vis_da' => $context->username]]; + } else { + return []; + } + $sql = "SELECT `id` FROM `mail_policy` WHERE $visible"; + if (!empty($domains)) { + $names = []; + foreach (array_values($domains) as $i => $domain) { + $names[] = ':dom' . $i; + $params['dom' . $i] = $domain; + } + $sql .= " AND `domain` IN (" . implode(', ', $names) . ")"; + } + return array_map('intval', $this->query($sql . " ORDER BY `domain`, `name`", $params)->fetchAll(PDO::FETCH_COLUMN)); + } + + public function nameExists(string $domain, string $name, ?int $exceptId = null): bool + { + return (bool)$this->query("SELECT 1 FROM `mail_policy` WHERE `domain` = :domain AND `name` = :name AND `id` != :id", + ['domain' => $domain, 'name' => $name, 'id' => $exceptId ?? 0])->fetchColumn(); + } + + /** Inserts or updates the policy with its assignments; returns its id */ + public function save(Policy $policy): int + { + return $this->transaction(function () use ($policy): int { + if ($policy->id === null) { + $policy->id = $this->insertRow('mail_policy', $policy->toRow()); + } else { + $this->updateRow('mail_policy', $policy->id, $policy->toRow()); + $this->query("DELETE FROM `mail_policy_member` WHERE `policy_id` = :id", ['id' => $policy->id]); + } + foreach ($policy->mailboxes as $username) { + $this->insertRow('mail_policy_member', ['policy_id' => $policy->id, 'username' => $username]); + } + foreach ($policy->groups as $groupId) { + $this->insertRow('mail_policy_member', ['policy_id' => $policy->id, 'group_id' => $groupId]); + } + return $policy->id; + }); + } + + public function delete(int $id): void + { + $this->query("DELETE FROM `mail_policy` WHERE `id` = :id", ['id' => $id]); + } + + /** A primary domain (policies never belong to alias domains) */ + public function isDomain(string $domain): bool + { + return (bool)$this->query("SELECT 1 FROM `domain` WHERE `domain` = :domain", ['domain' => $domain])->fetchColumn(); + } + + /** Domain of a regular mailbox, null if there is none */ + public function mailboxDomain(string $username): ?string + { + $domain = $this->query("SELECT `domain` FROM `mailbox` WHERE `username` = :username AND `kind` = ''", ['username' => $username])->fetchColumn(); + return is_string($domain) ? $domain : null; + } + + public function groupDomain(int $id): ?string + { + $domain = $this->query("SELECT `domain` FROM `mailbox_group` WHERE `id` = :id", ['id' => $id])->fetchColumn(); + return is_string($domain) ? $domain : null; + } + + /** @return array[] id, name, domain, address of the groups in these domains (choices of the forms) */ + public function groupsIn(array $domains): array + { + if (empty($domains)) { + return []; + } + $names = []; + $params = []; + foreach (array_values($domains) as $i => $domain) { + $names[] = ':dom' . $i; + $params['dom' . $i] = $domain; + } + return $this->query("SELECT `id`, `name`, `domain`, `address` FROM `mailbox_group` WHERE `domain` IN (" . implode(', ', $names) . ") + ORDER BY `domain`, `name`", $params)->fetchAll(); + } + + private function withMembers(Policy $policy): Policy + { + foreach ($this->query("SELECT `username`, `group_id` FROM `mail_policy_member` WHERE `policy_id` = :id ORDER BY `username`, `group_id`", + ['id' => $policy->id])->fetchAll() as $row) { + if ($row['username'] !== null) { + $policy->mailboxes[] = (string)$row['username']; + } elseif ($row['group_id'] !== null) { + $policy->groups[] = (int)$row['group_id']; + } + } + return $policy; + } +} diff --git a/data/web/inc/lib/mailcow/MailPolicy/Service/PolicyEngine.php b/data/web/inc/lib/mailcow/MailPolicy/Service/PolicyEngine.php new file mode 100644 index 000000000..565cf491f --- /dev/null +++ b/data/web/inc/lib/mailcow/MailPolicy/Service/PolicyEngine.php @@ -0,0 +1,209 @@ +lookup->mailbox($login) : null; + if ($login === '' && $port === self::SERVICE_PORT) { + // Sieve redirects and vacation replies use the address of the mailbox, other mail of mailcow passes + $account = $this->lookup->mailbox($this->resolve(self::untagged($sender))); + if ($account === null) { + return Decision::allow(); + } + } + foreach ($this->sendingPolicies($account, $sender) as $policy) { + if (!$this->allows($policy->sendMode, $policy->sendAllow, $policy->sendBlock, $this->isInternalRecipient($recipient, $policy->domain), + fn(string $entry): bool => $this->recipientMatches($entry, $recipient))) { + return Decision::deny('send', $policy); + } + } + foreach ($this->receivingPolicies($recipient) as $policy) { + if (!$this->allows($policy->receiveMode, $policy->receiveAllow, $policy->receiveBlock, + $account !== null && $account['domain'] === $policy->domain, + fn(string $entry): bool => $this->senderMatches($entry, $sender, $account))) { + return Decision::deny('receive', $policy); + } + } + return Decision::allow(); + } + + /** Blocked entries first; then the mode decides */ + private function allows(string $mode, array $allow, array $block, bool $internal, callable $matches): bool + { + foreach ($block as $entry) { + if ($matches($entry)) { + return false; + } + } + return match ($mode) { + 'internal' => $internal || $this->any($allow, $matches), + 'list' => $this->any($allow, $matches), + default => true, + }; + } + + private function any(array $entries, callable $matches): bool + { + foreach ($entries as $entry) { + if ($matches($entry)) { + return true; + } + } + return false; + } + + /** @return Policy[] of the logged-in mailbox and of the mailbox it sends as */ + private function sendingPolicies(?array $account, string $sender): array + { + if ($account === null) { + return []; + } + $policies = $this->lookup->policiesForMailbox($account['username']); + $sentAs = $this->lookup->mailbox($this->resolve(self::untagged($sender))); + if ($sentAs !== null && $sentAs['username'] !== $account['username']) { + $policies = array_merge($policies, $this->lookup->policiesForMailbox($sentAs['username'])); + } + return self::unique($policies); + } + + /** @return Policy[] of the addressed mailbox, group and internal alias */ + private function receivingPolicies(string $recipient): array + { + $address = $this->resolve(self::untagged($recipient)); + $policies = []; + if ($this->lookup->mailbox($address) !== null) { + $policies = $this->lookup->policiesForMailbox($address); + } + $group = $this->lookup->groupByAddress($address); + if ($group !== null) { + $policies = array_merge($policies, $this->lookup->policiesForGroup($group)); + } + $aliasDomain = $this->lookup->internalAlias($address); + if ($aliasDomain !== null) { + // the `internal` flag of an alias or group: only senders of its domain + $internal = new Policy(); + $internal->domain = $aliasDomain; + $internal->receiveMode = 'internal'; + $policies[] = $internal; + } + return self::unique($policies); + } + + private function isInternalRecipient(string $recipient, string $domain): bool + { + return $this->resolveDomain(self::domainOf($recipient)) === $domain; + } + + private function recipientMatches(string $entry, string $recipient): bool + { + $address = $this->resolve(self::untagged($recipient)); + if (str_starts_with($entry, 'group:')) { + $id = (int)substr($entry, 6); + return $this->lookup->groupAddress($id) === $address + || ($this->lookup->mailbox($address) !== null && $this->lookup->isGroupMember($id, $address)); + } + if (str_contains($entry, '@')) { + return $this->resolve($entry) === $address; + } + return $this->resolveDomain(self::domainOf($address)) === $this->resolveDomain($entry); + } + + private function senderMatches(string $entry, string $sender, ?array $account): bool + { + $address = $this->resolve(self::untagged($sender)); + if (str_starts_with($entry, 'group:')) { + $id = (int)substr($entry, 6); + return $account !== null && ($this->lookup->isGroupMember($id, $account['username']) || $this->lookup->groupAddress($id) === $address); + } + if (str_contains($entry, '@')) { + // a local address only for a logged-in sender (Postfix checked that the login may use it) + return $this->lookup->isLocalDomain(self::domainOf($entry)) + ? $account !== null && ($account['username'] === $this->resolve($entry) || $address === $this->resolve($entry)) + : self::untagged($sender) === $entry; + } + return $this->lookup->isLocalDomain($entry) + ? $account !== null && ($account['domain'] === $this->resolveDomain($entry) || $this->resolveDomain(self::domainOf($address)) === $this->resolveDomain($entry)) + : self::domainOf($sender) === $entry; + } + + /** Address in an alias domain as the address in its target domain */ + private function resolve(string $address): string + { + $domain = self::domainOf($address); + $target = ($domain !== '') ? $this->lookup->aliasDomainTarget($domain) : null; + return ($target === null) ? $address : substr($address, 0, strrpos($address, '@') + 1) . $target; + } + + private function resolveDomain(string $domain): string + { + return ($domain === '') ? '' : ($this->lookup->aliasDomainTarget($domain) ?? $domain); + } + + /** @param Policy[] $policies @return Policy[] without repeated policies */ + private static function unique(array $policies): array + { + $unique = []; + foreach ($policies as $policy) { + $unique[$policy->id ?? spl_object_id($policy)] = $policy; + } + return array_values($unique); + } + + /** Lower case address without angle brackets; '' unless local@domain */ + private static function address(string $value): string + { + $value = strtolower(trim($value, " \t\r\n<>")); + return preg_match('/^[^@\s,<>]+@[^@\s,<>]+$/', $value) ? $value : ''; + } + + private static function untagged(string $address): string + { + $at = strrpos($address, '@'); + $pos = ($at === false) ? false : strpos(substr($address, 0, $at), '+'); + return ($pos === false || $pos === 0) ? $address : substr($address, 0, $pos) . substr($address, $at); + } + + private static function domainOf(string $address): string + { + $at = strrpos($address, '@'); + return ($at === false) ? '' : substr($address, $at + 1); + } +} diff --git a/data/web/inc/lib/mailcow/MailPolicy/Service/PolicyService.php b/data/web/inc/lib/mailcow/MailPolicy/Service/PolicyService.php new file mode 100644 index 000000000..0516451aa --- /dev/null +++ b/data/web/inc/lib/mailcow/MailPolicy/Service/PolicyService.php @@ -0,0 +1,299 @@ +` entries name groups the editor may access. + */ +class PolicyService +{ + private const MAX_ENTRIES = 500; + /** The reject text goes into the SMTP reply: printable ASCII */ + private const MAX_REJECT_TEXT = 200; + private const LISTS = ['send_allow' => 'sendAllow', 'send_block' => 'sendBlock', 'receive_allow' => 'receiveAllow', 'receive_block' => 'receiveBlock']; + + public function __construct(private PolicyRepository $repo, private PolicyEngine $engine, private Context $context) + { + } + + public function canRead(): bool + { + return $this->context->hasAdminRights() + || ($this->context->isDomainAdmin() && $this->context->hasAcl('mail_policies')); + } + + /** @throws AccessDenied unless the caller may edit policies at all */ + public function requireEditor(): void + { + if (!$this->canRead() || $this->context->isReadOnlyApi()) { + throw new AccessDenied(); + } + } + + public function get(int $id): ?Policy + { + $policy = $this->canRead() ? $this->repo->find($id) : null; + return ($policy !== null && $this->visible($policy)) ? $policy : null; + } + + /** @return int[] */ + public function visibleIds(array $domains = []): array + { + return $this->canRead() ? $this->repo->visibleIds($this->context, $domains) : []; + } + + public function canEdit(Policy $policy): bool + { + return $this->canRead() && !$this->context->isReadOnlyApi() && $this->visible($policy); + } + + public function toArray(Policy $policy): array + { + return $policy->toArray() + ['can_edit' => (int)$this->canEdit($policy)]; + } + + /** @throws UserError */ + public function create(array $input): Policy + { + $this->requireEditor(); + $policy = new Policy(); + $policy->domain = $this->domainFor($input['domain'] ?? null); + $this->apply($policy, $input); + $this->repo->save($policy); + return $policy; + } + + /** Attributes missing in $input keep their value @throws UserError */ + public function update(int $id, array $input): Policy + { + $policy = $this->editable($id); + $this->apply($policy, $input); + $this->repo->save($policy); + return $policy; + } + + /** @throws UserError */ + public function delete(int $id): Policy + { + $policy = $this->editable($id); + $this->repo->delete($id); + return $policy; + } + + /** @return array[] id, name, domain, address of the groups in these domains (choices of the forms) */ + public function groupChoices(array $domains): array + { + return $this->canRead() ? $this->repo->groupsIn($domains) : []; + } + + /** + * "Test a rule": would the mail be accepted? A sender that is a mailbox counts as logged in when $loggedIn. + * + * @return array{allowed: int, side: string, policy: ?string, internal_alias: int, text: string} + * @throws UserError + */ + public function test(mixed $sender, mixed $recipient, bool $loggedIn): array + { + if (!$this->canRead()) { + throw new AccessDenied(); + } + $sender = strtolower((string)Input::text($sender)); + $recipient = strtolower((string)Input::text($recipient)); + if (($sender !== '' && !filter_var($sender, FILTER_VALIDATE_EMAIL)) || !filter_var($recipient, FILTER_VALIDATE_EMAIL)) { + throw new UserError('mail_policy_test_invalid'); + } + // domain admins test mail of their domains + if (!$this->context->hasAdminRights() && !$this->context->canAccessDomain(self::domainOf($sender)) + && !$this->context->canAccessDomain(self::domainOf($recipient))) { + throw new AccessDenied(); + } + $login = ($loggedIn && $this->repo->mailboxDomain($sender) !== null) ? $sender : ''; + $decision = $this->engine->check($sender, $recipient, $login); + $policy = $decision->policy; + return [ + 'allowed' => (int)$decision->allowed, + 'side' => $decision->side, + 'policy' => ($policy !== null && $policy->id !== null && $this->visible($policy)) ? $policy->name : null, + 'internal_alias' => (int)($policy !== null && $policy->id === null), + 'logged_in' => (int)($login !== ''), + 'text' => $decision->allowed ? '' : $decision->text(), + ]; + } + + private function visible(Policy $policy): bool + { + return $this->context->hasAdminRights() || $this->context->canAccessDomain($policy->domain); + } + + /** @throws UserError */ + private function editable(int $id): Policy + { + $this->requireEditor(); + $policy = $this->get($id); + if ($policy === null || !$this->canEdit($policy)) { + throw new AccessDenied(); + } + return $policy; + } + + /** @throws UserError */ + private function domainFor(mixed $value): string + { + $domain = strtolower((string)Input::text($value)); + if ($domain === '' || !$this->repo->isDomain($domain)) { + throw new UserError('domain_not_found', [htmlspecialchars((string)Input::text($value))]); + } + if (!$this->context->hasAdminRights() && !$this->context->canAccessDomain($domain)) { + throw new AccessDenied(); + } + return $domain; + } + + /** @throws UserError */ + private function apply(Policy $policy, array $input): void + { + if (array_key_exists('name', $input) || $policy->id === null) { + $name = Input::text($input['name'] ?? null); + if ($name === null || $name === '' || mb_strlen($name) > 100 || preg_match('/[\x00-\x1f\x7f]/', $name)) { + throw new UserError('mail_policy_name_invalid'); + } + if ($this->repo->nameExists($policy->domain, $name, $policy->id)) { + throw new UserError('object_exists', [htmlspecialchars($name)]); + } + $policy->name = $name; + } + if (array_key_exists('description', $input)) { + $description = Input::text($input['description']); + if ($description === null || mb_strlen($description) > 255 || preg_match('/[\x00-\x1f\x7f]/', $description)) { + throw new UserError('mail_policy_description_invalid'); + } + $policy->description = $description; + } + foreach (['send_mode' => 'sendMode', 'receive_mode' => 'receiveMode'] as $key => $property) { + if (array_key_exists($key, $input)) { + $mode = Input::text($input[$key]); + $policy->$property = in_array($mode, Policy::MODES, true) ? $mode : throw new UserError('mail_policy_mode_invalid'); + } + } + foreach (self::LISTS as $key => $property) { + // the UI sends the groups of a list separately (_groups); the API may name them as group: + if (array_key_exists($key, $input) || array_key_exists($key . '_groups', $input)) { + $entries = array_key_exists($key, $input) ? self::values($input[$key]) + : array_filter($policy->$property, fn(string $entry): bool => !str_starts_with($entry, 'group:')); + if (array_key_exists($key . '_groups', $input)) { + $entries = array_filter($entries, fn(string $entry): bool => !str_starts_with(strtolower($entry), 'group:')); + $entries = array_merge($entries, array_map(fn(string $id): string => 'group:' . $id, self::values($input[$key . '_groups']))); + } + $policy->$property = $this->entries(array_values($entries)); + } + } + if (array_key_exists('reject_text', $input)) { + $text = Input::text($input['reject_text']); + if ($text === null || strlen($text) > self::MAX_REJECT_TEXT || preg_match('/[^\x20-\x7e]/', $text)) { + throw new UserError('mail_policy_reject_text_invalid'); + } + $policy->rejectText = $text; + } + if (array_key_exists('active', $input)) { + $policy->active = (int)self::flag($input['active']); + } + if (array_key_exists('mailboxes', $input)) { + $policy->mailboxes = []; + foreach (self::values($input['mailboxes']) as $username) { + $username = strtolower($username); + if ($this->repo->mailboxDomain($username) !== $policy->domain) { + throw new UserError('mail_policy_member_invalid', [htmlspecialchars($username)]); + } + $policy->mailboxes[$username] = $username; + } + $policy->mailboxes = array_values($policy->mailboxes); + } + if (array_key_exists('groups', $input)) { + $policy->groups = []; + foreach (self::values($input['groups']) as $value) { + $id = Input::intInRange($value, 1, PHP_INT_MAX); + if ($id === null || $this->repo->groupDomain($id) !== $policy->domain) { + throw new UserError('mail_policy_member_invalid', [htmlspecialchars($value)]); + } + $policy->groups[$id] = $id; + } + $policy->groups = array_values($policy->groups); + } + } + + /** + * Entries of a list: addresses, domains (a leading @ is dropped) and group:, lower case, without repeats. + * + * @return string[] + * @throws UserError + */ + private function entries(mixed $value): array + { + $entries = []; + foreach (self::values($value) as $entry) { + $entry = strtolower($entry); + if (str_starts_with($entry, 'group:')) { + $id = Input::intInRange(substr($entry, 6), 1, PHP_INT_MAX); + $domain = ($id === null) ? null : $this->repo->groupDomain($id); + if ($domain === null || (!$this->context->hasAdminRights() && !$this->context->canAccessDomain($domain))) { + throw new UserError('mail_policy_entry_invalid', [htmlspecialchars($entry)]); + } + $entry = 'group:' . $id; + } elseif (str_contains(ltrim($entry, '@'), '@')) { + if (!filter_var($entry, FILTER_VALIDATE_EMAIL)) { + throw new UserError('mail_policy_entry_invalid', [htmlspecialchars($entry)]); + } + } else { + $entry = ltrim($entry, '@'); + if (!str_contains($entry, '.') || !filter_var($entry, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME)) { + throw new UserError('mail_policy_entry_invalid', [htmlspecialchars($entry)]); + } + } + $entries[$entry] = $entry; + } + if (count($entries) > self::MAX_ENTRIES) { + throw new UserError('mail_policy_entries_exceeded', [self::MAX_ENTRIES]); + } + return array_values($entries); + } + + /** + * Values of a list field: an array (multi-select, JSON) or a string separated by line breaks, commas or spaces. + * + * @return string[] + */ + private static function values(mixed $value): array + { + $values = is_array($value) ? $value : preg_split('/[\s,;]+/', (string)Input::text($value)); + return array_values(array_filter(array_map(fn(mixed $v): string => is_scalar($v) ? trim((string)$v) : '', $values), 'strlen')); + } + + /** Checkbox or API flag; a hidden "0" + checkbox pair arrives as ["0", "1"] when checked */ + private static function flag(mixed $value): bool + { + if (is_array($value)) { + $value = end($value); + } + return filter_var($value, FILTER_VALIDATE_BOOLEAN); + } + + private static function domainOf(string $address): string + { + $at = strrpos($address, '@'); + return ($at === false) ? '' : substr($address, $at + 1); + } +} diff --git a/data/web/js/site/mail-policy.js b/data/web/js/site/mail-policy.js new file mode 100644 index 000000000..8b728e2a6 --- /dev/null +++ b/data/web/js/site/mail-policy.js @@ -0,0 +1,159 @@ +// Mail policies (admin/domainadmin mailbox page): list, domain choices of the add modal, test of a rule. Loaded via $js_minifier. +var mailcowMailPolicy = (function($) { + 'use strict'; + + // lang.mail_policy of the mailbox page, '' if missing + function L(key) { + var src = (typeof lang !== 'undefined' && lang && lang.mail_policy) ? lang.mail_policy : {}; + return (src[key] !== undefined && src[key] !== null) ? src[key] : ''; + } + function esc(s) { + return escapeHtml(s === null || s === undefined ? '' : s); + } + + // mode of one direction with the number of allowed and blocked entries + function sideCell(mode, allow, block) { + var html = esc(L('summary_' + mode)); + var counts = []; + if (mode !== 'all' && allow.length) counts.push('+' + allow.length + ' ' + esc(L('summary_allowed'))); + if (block.length) counts.push(block.length + ' ' + esc(L('summary_blocked'))); + return html + (counts.length ? '
' + counts.join(', ') + '
' : ''); + } + + function assignedCell(item) { + var list = (item.mailboxes || []).slice(0, 3).map(esc); + if ((item.mailboxes || []).length > 3) list.push('+' + (item.mailboxes.length - 3) + ''); + if ((item.groups || []).length) list.push(' ' + esc(L('groups')) + ': ' + item.groups.length); + return list.length ? list.join('
') : '' + esc(L('assigned_none')) + ''; + } + + function drawTable() { + if ($.fn.DataTable.isDataTable('#mail_policy_table')) { + $('#mail_policy_table').DataTable().columns.adjust().responsive.recalc(); + return; + } + $('#mail_policy_table').DataTable({ + responsive: true, + processing: true, + serverSide: false, + stateSave: true, + pageLength: pagination_size, + dom: "<'row'<'col-sm-12 col-md-6'f><'col-sm-12 col-md-6'l>>" + "tr" + + "<'row'<'col-sm-12 col-md-5'i><'col-sm-12 col-md-7'p>>", + language: lang_datatables, + order: [[3, 'asc']], + ajax: { + type: "GET", + url: "/api/v1/get/mail-policy/all", + dataSrc: function(json) { + if (!Array.isArray(json)) return []; + $.each(json, function(i, item) { + item.chkbox = item.can_edit ? + '' : + ''; + item.name_display = esc(item.name) + (item.description ? '
' + esc(item.description) + '
' : ''); + item.send_display = sideCell(item.send_mode, item.send_allow || [], item.send_block || []); + item.receive_display = sideCell(item.receive_mode, item.receive_allow || [], item.receive_block || []); + item.assigned_display = assignedCell(item); + item.action = ''; + }); + return json; + }, + error: function() { + mailcow_alert_box(L('list_error'), 'danger'); + } + }, + columns: [ + { + // placeholder, so checkbox will not block child row toggle + title: '', + data: null, + searchable: false, + orderable: false, + defaultContent: '', + responsivePriority: 1 + }, + { title: '', data: 'chkbox', searchable: false, orderable: false, defaultContent: '', responsivePriority: 2 }, + { title: 'ID', data: 'id', defaultContent: '', responsivePriority: 6 }, + { title: L('name'), data: 'name_display', defaultContent: '', responsivePriority: 2 }, + { title: L('domain'), data: 'domain', defaultContent: '', responsivePriority: 5 }, + { title: L('send'), data: 'send_display', defaultContent: '', responsivePriority: 3 }, + { title: L('receive'), data: 'receive_display', defaultContent: '', responsivePriority: 3 }, + { title: L('assign'), data: 'assigned_display', defaultContent: '', responsivePriority: 4 }, + { + title: lang.active, + data: 'active', + defaultContent: '', + responsivePriority: 6, + render: function(d) { return d == 1 ? '' : ''; } + }, + { + title: lang.action, + data: 'action', + orderable: false, + className: 'dt-sm-head-hidden dt-data-w100 dtr-col-md dt-text-right', + responsivePriority: 3, + defaultContent: '' + } + ] + }); + } + + // add modal: mailboxes and groups of the chosen domain only + function filterDomain($form) { + var domain = $form.find('select.mail-policy-domain-select').val(); + $form.find('select.mail-policy-mailboxes, select.mail-policy-groups').each(function() { + $(this).find('option').each(function() { + var other = $(this).data('domain') !== domain; + $(this).prop('hidden', other); + if (other) $(this).prop('selected', false); + }); + $(this).selectpicker('refresh'); + }); + } + + function test(e) { + e.preventDefault(); + var sender = $.trim($('#mail_policy_test_sender').val()); + var recipient = $.trim($('#mail_policy_test_recipient').val()); + if (!recipient) return; + // an empty sender (bounces) is sent as "-" + var url = '/api/v1/get/mail-policy/test/' + encodeURIComponent(sender || '-') + '/' + encodeURIComponent(recipient) + + ($('#mail_policy_test_login').is(':checked') ? '/login' : ''); + $.get(url, function(r) { + var html; + if (!r || r.allowed === undefined) { + html = '
' + esc(L('test_invalid')) + '
'; + } else if (r.allowed == 1) { + html = '
' + esc(L('test_allowed')) + '
'; + } else { + var reason = r.internal_alias == 1 ? L('test_denied_internal') : + (r.policy ? L('test_denied_' + r.side).replace('%s', r.policy) : L('test_denied_other')); + html = '
' + esc(reason) + '
550 5.7.1 ' + esc(r.text) + '
'; + } + if (r && r.allowed !== undefined && sender && r.logged_in == 0 && $('#mail_policy_test_login').is(':checked')) { + html += '
' + esc(L('test_not_a_mailbox')) + '
'; + } + $('#mail_policy_test_result').html(html); + }, 'json').fail(function() { + $('#mail_policy_test_result').html('
' + esc(lang_danger.generic_server_error) + '
'); + }); + } + + $(document).ready(function() { + $('select.mail-policy-domain-select').on('change', function() { + filterDomain($(this).closest('form')); + }); + $('#addMailPolicyModal').on('show.bs.modal', function() { + filterDomain($(this).find('form')); + }); + $('#mail_policy_test').on('submit', test); + }); + + return { + drawTable: drawTable + }; +})(jQuery); diff --git a/data/web/js/site/mailbox.js b/data/web/js/site/mailbox.js index 32de2a8d1..7a5e90e23 100644 --- a/data/web/js/site/mailbox.js +++ b/data/web/js/site/mailbox.js @@ -2493,6 +2493,7 @@ jQuery(function($){ onVisible("[id^=sync_job_table]", () => draw_sync_job_table()); onVisible("[id^=imapsync_source_table]", () => mailcowImapsync.drawSourceTable(role === 'admin' || acl_data.syncjobs === 1)); onVisible("[id^=mailbox_group_table]", () => mailcowGroups.drawTable()); + onVisible("[id^=mail_policy_table]", () => mailcowMailPolicy.drawTable()); onVisible("[id^=filter_table]", () => draw_filter_table()); onVisible("[id^=bcc_table]", () => draw_bcc_table()); onVisible("[id^=recipient_map_table]", () => draw_recipient_map_table()); diff --git a/data/web/json_api.php b/data/web/json_api.php index b059ae8be..750ee731d 100644 --- a/data/web/json_api.php +++ b/data/web/json_api.php @@ -280,6 +280,9 @@ if (isset($_GET['query'])) { case "mailbox-group": process_add_return(\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->addGroup($attr)); break; + case "mail-policy": + process_add_return(\Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->api->addPolicy($attr)); + break; case "domain": switch ($object) { case "template": @@ -858,6 +861,23 @@ if (isset($_GET['query'])) { } break; + case "mail-policy": + switch ($object) { + case "all": + process_get_return(\Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->api->getPolicies($extra)); + break; + + // test//[/login]: would the mail be accepted? + case "test": + process_get_return(\Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->api->test((string)$extra, (string)($query[4] ?? ''), ($query[5] ?? '') === 'login')); + break; + + default: + process_get_return(\Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->api->getPolicy($object)); + break; + } + break; + case "logs": switch ($object) { case "dovecot": @@ -1779,6 +1799,9 @@ if (isset($_GET['query'])) { case "mailbox-group": process_delete_return(\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->deleteGroups($items)); break; + case "mail-policy": + process_delete_return(\Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->api->deletePolicies($items)); + break; case "app-passwd": process_delete_return(app_passwd('delete', array('id' => $items))); break; @@ -1953,6 +1976,9 @@ if (isset($_GET['query'])) { case "mailbox-group": process_edit_return(\Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->editGroups($items, $attr)); break; + case "mail-policy": + process_edit_return(\Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->api->editPolicies($items, $attr)); + break; case "recipient_map": process_edit_return(recipient_map('edit', array_merge(array('id' => $items), $attr))); break; diff --git a/data/web/lang/lang.de-de.json b/data/web/lang/lang.de-de.json index 60c84cc34..00ce59305 100644 --- a/data/web/lang/lang.de-de.json +++ b/data/web/lang/lang.de-de.json @@ -11,6 +11,7 @@ "extend_sender_acl": "Eingabe externer Absenderadressen erlauben", "filters": "Filter", "login_as": "Einloggen als Mailbox-Benutzer", + "mail_policies": "Mail-Richtlinien", "mailbox_groups": "Gruppen", "mailbox_relayhost": "Relayhost für eine Mailbox setzen", "prohibited": "Untersagt durch Richtlinie", @@ -66,7 +67,7 @@ "hostname": "Host", "inactive": "Inaktiv", "internal": "Intern", - "internal_info": "Interne Aliasse sind nur von der eigenen Domäne oder Alias-Domänen erreichbar.", + "internal_info": "Interne Aliasse nehmen nur E-Mails von angemeldeten Absendern ihrer Domain an (auch über Alias-Domains).", "sender_allowed": "Als dieser Alias senden erlauben", "kind": "Art", "mailbox_quota_def": "Standard-Quota einer Mailbox", @@ -582,6 +583,14 @@ "is_spam_alias": "%s lautet bereits eine temporäre Alias-Adresse (Spam-Alias-Adresse)", "last_key": "Letzter Key kann nicht gelöscht werden, bitte stattdessen die 2FA deaktivieren.", "login_failed": "Anmeldung fehlgeschlagen", + "mail_policy_description_invalid": "Ungültige Beschreibung", + "mail_policy_entries_exceeded": "Eine Liste darf höchstens %s Einträge enthalten", + "mail_policy_entry_invalid": "Ungültige Adresse, Domain oder Gruppe in der Liste: %s", + "mail_policy_member_invalid": "Mailbox oder Gruppe %s gehört nicht zur Domain der Richtlinie", + "mail_policy_mode_invalid": "Ungültiger Modus", + "mail_policy_name_invalid": "Ungültiger Name (1 bis 100 Zeichen)", + "mail_policy_reject_text_invalid": "Ungültiger Ablehnungstext: druckbare ASCII-Zeichen, höchstens 200", + "mail_policy_test_invalid": "Ungültiger Absender oder Empfänger", "mailbox_defquota_exceeds_mailbox_maxquota": "Standard-Quota überschreitet das Limit der maximal erlaubten Größe einer Mailbox", "mailbox_invalid": "Mailboxname ist ungültig", "mailbox_quota_exceeded": "Speicherplatz überschreitet das Limit (max. %d MiB)", @@ -829,7 +838,7 @@ "iam_unlink": "Verknüpfung lösen", "inactive": "Inaktiv", "internal": "Intern", - "internal_info": "Interne Aliasse sind nur von der eigenen Domäne oder Alias-Domänen erreichbar.", + "internal_info": "Interne Aliasse nehmen nur E-Mails von angemeldeten Absendern ihrer Domain an (auch über Alias-Domains).", "sender_allowed": "Als dieser Alias senden erlauben", "sender_allowed_info": "Wenn deaktiviert, kann dieser Alias nur E-Mails empfangen. Verwenden Sie Sender-ACL, um bestimmten Postfächern die Berechtigung zum Senden zu erteilen.", "kind": "Art", @@ -969,7 +978,7 @@ "imap_grants": "Postfachzugriff", "imap_grants_hint": "Mitglieder öffnen diese Mailboxen in ihrem Mailprogramm (Ordner Shared/). Lesen: lesen und als gelesen markieren. Schreiben: zusätzlich ablegen, verschieben und löschen. Vollzugriff: zusätzlich Ordner löschen. Team-Postfächer ohne eigenen Login als geteiltes Postfach markieren.", "info": "Gruppen bündeln Postfächer. Eine Gruppe kann eine eigene Adresse haben: ein Alias, der automatisch an alle Mitglieder zustellt. Mitglieder können als andere Adressen senden, auf freigegebene Postfächer, Kalender und Adressbücher zugreifen und ein Postfach-Template erhalten. Vom Identity Provider synchronisierte Mitglieder sind mit IdP markiert.", - "internal": "Nur Mails von Absendern der Domain annehmen", + "internal": "Nur E-Mails von angemeldeten Absendern der Domain annehmen", "list_error": "Gruppen konnten nicht geladen werden", "managed_alias": "Wird von Gruppe %s verwaltet", "members": "Mitglieder", @@ -1036,6 +1045,59 @@ "username": "Benutzername", "email": "E-Mail-Adresse" }, + "mail_policy": { + "add": "Mail-Richtlinie hinzufügen", + "allow_receive": "Erlaubte Absender", + "allow_send": "Erlaubte Empfänger", + "assign": "Zugewiesen an", + "assign_info": "Die Richtlinie gilt für diese Mailboxen und für die Mitglieder dieser Gruppen, bei einer Gruppe auch für E-Mails an die Gruppenadresse. E-Mails an einen Alias oder eine andere Gruppe erreichen die Mitglieder ohne deren Richtlinien.", + "assigned_none": "Nicht zugewiesen", + "block_receive": "Gesperrte Absender", + "block_send": "Gesperrte Empfänger", + "description": "Beschreibung", + "domain": "Domain", + "entries_info": "Adressen oder Domains, eine pro Zeile, und Gruppen. Adressen und Domains dieser mailcow gelten nur für angemeldete Absender.", + "groups": "Gruppen", + "info": "Mail-Richtlinien legen fest, an wen Mailboxen und Gruppen senden und von wem sie empfangen dürfen, z. B. nur intern oder nur mit bestimmten Domains. Postfix prüft sie für jeden Empfänger und lehnt nicht erlaubte Empfänger mit einem 550-Fehler ab. Eingehende E-Mails erreichen die übrigen Empfänger trotzdem, SOGo sendet eine E-Mail mit einem abgelehnten Empfänger dagegen gar nicht.", + "internal_info": "Intern: Empfänger in der Domain der Richtlinie und ihren Alias-Domains; Absender, die mit einer Mailbox dieser Domain angemeldet sind.", + "list_error": "Die Mail-Richtlinien konnten nicht geladen werden", + "mail_policies": "Mail-Richtlinien", + "mailboxes": "Mailboxen", + "mode": "Erlaubt", + "mode_receive_all": "Alle Absender", + "mode_receive_internal": "Interne Absender und die erlaubten Absender", + "mode_receive_list": "Nur die erlaubten Absender", + "mode_send_all": "Alle Empfänger", + "mode_send_internal": "Interne Empfänger und die erlaubten Empfänger", + "mode_send_list": "Nur die erlaubten Empfänger", + "name": "Name", + "policy": "Mail-Richtlinie", + "receive": "Empfangen", + "receive_info": "Für E-Mails an eine zugewiesene Mailbox oder an die Adresse einer zugewiesenen Gruppe.", + "reject_text": "Ablehnungstext", + "reject_text_info": "Text der SMTP-Ablehnung (550 5.7.1 …), nur ASCII. Leer: ein Standardtext.", + "send": "Senden", + "send_info": "Für E-Mails, die eine zugewiesene Mailbox angemeldet sendet oder die unter ihrer Adresse gesendet werden, auch Weiterleitungen per Filter und Abwesenheitsnotizen.", + "summary_all": "Alle", + "summary_allowed": "erlaubt", + "summary_blocked": "gesperrt", + "summary_internal": "Intern", + "summary_list": "Nur erlaubte", + "test": "Regel testen", + "test_allowed": "Angenommen: Keine Richtlinie lehnt diese E-Mail ab.", + "test_denied_internal": "Abgelehnt: Die Adresse nimmt nur E-Mails von angemeldeten Absendern ihrer Domain an (interner Alias oder interne Gruppe).", + "test_denied_other": "Abgelehnt durch eine Richtlinie einer anderen Domain.", + "test_denied_receive": "Abgelehnt durch den Empfangen-Teil der Richtlinie „%s“.", + "test_denied_send": "Abgelehnt durch den Senden-Teil der Richtlinie „%s“.", + "test_info": "Prüft, ob Postfix die E-Mail für diesen Empfänger annehmen würde. Für Bounces den Absender leer lassen.", + "test_invalid": "Geben Sie einen gültigen Empfänger (und Absender) Ihrer Domains ein.", + "test_logged_in": "Absender ist angemeldet", + "test_not_a_mailbox": "Der Absender ist keine Mailbox dieser mailcow und wurde als nicht angemeldet geprüft.", + "test_recipient": "Empfänger", + "test_run": "Testen", + "test_sender": "Absender", + "view": "Ansehen" + }, "mailbox": { "action": "Aktion", "activate": "Aktivieren", @@ -1134,6 +1196,9 @@ "no_record": "Kein Eintrag für Objekt %s", "no_record_single": "Kein Eintrag", "owner": "Besitzer", + "policies": "Richtlinien", + "policies_mail": "Mail", + "policies_tls": "TLS", "private_comment": "Privater Kommentar", "public_comment": "Öffentlicher Kommentar", "q_add_header": "bei Mail in Junk-Ordner", @@ -1348,6 +1413,9 @@ "learned_ham": "ID %s wurde erfolgreich als Ham gelernt", "license_modified": "Änderungen an Lizenz wurden gespeichert", "logged_in_as": "Eingeloggt als %s", + "mail_policy_added": "Mail-Richtlinie %s hinzugefügt", + "mail_policy_modified": "Mail-Richtlinie %s gespeichert", + "mail_policy_removed": "Mail-Richtlinie %s entfernt", "mailbox_added": "Mailbox %s wurde angelegt", "mailbox_modified": "Änderungen an Mailbox %s wurden gespeichert", "mailbox_removed": "Mailbox %s wurde entfernt", diff --git a/data/web/lang/lang.en-gb.json b/data/web/lang/lang.en-gb.json index d2848bcbb..7ce2df008 100644 --- a/data/web/lang/lang.en-gb.json +++ b/data/web/lang/lang.en-gb.json @@ -11,6 +11,7 @@ "extend_sender_acl": "Allow to extend sender ACL by external addresses", "filters": "Filters", "login_as": "Login as mailbox user", + "mail_policies": "Mail policies", "mailbox_groups": "Groups", "mailbox_relayhost": "Change relayhost for a mailbox", "prohibited": "Prohibited by ACL", @@ -66,7 +67,7 @@ "hostname": "Host", "inactive": "Inactive", "internal": "Internal", - "internal_info": "Internal aliases are only accessible from the own domain or alias domains.", + "internal_info": "Internal aliases only accept mail from logged-in senders of their domain (also through alias domains).", "sender_allowed": "Allow to send as this alias", "kind": "Kind", "mailbox_quota_def": "Default mailbox quota", @@ -518,6 +519,14 @@ "group_send_as_invalid": "Send-as address %s is invalid or unknown", "iam_sync_mode_invalid": "Invalid sync mode", "iam_sync_unavailable": "The identity provider cannot be synced (Keycloak and LDAP only)", + "mail_policy_description_invalid": "Invalid description", + "mail_policy_entries_exceeded": "A list may contain at most %s entries", + "mail_policy_entry_invalid": "Invalid address, domain or group in the list: %s", + "mail_policy_member_invalid": "Mailbox or group %s does not belong to the domain of the policy", + "mail_policy_mode_invalid": "Invalid mode", + "mail_policy_name_invalid": "Invalid name (1 to 100 characters)", + "mail_policy_reject_text_invalid": "Invalid rejection text: printable ASCII, at most 200 characters", + "mail_policy_test_invalid": "Invalid sender or recipient", "tfa_login_locked": "Too many failed two-factor logins, please try again later", "tfa_removal_blocked": "Two-factor authentication cannot be removed because it is required for your account.", "alias_domain_invalid": "Alias domain %s is invalid", @@ -829,7 +838,7 @@ "iam_unlink": "Unlink", "inactive": "Inactive", "internal": "Internal", - "internal_info": "Internal aliases are only accessible from the own domain or alias domains.", + "internal_info": "Internal aliases only accept mail from logged-in senders of their domain (also through alias domains).", "sender_allowed": "Allow to send as this alias", "sender_allowed_info": "If disabled, this alias can only receive mail. Use sender ACL to override and grant specific mailboxes permission to send.", "kind": "Kind", @@ -969,7 +978,7 @@ "imap_grants": "Mailbox access", "imap_grants_hint": "Members open these mailboxes in their mail client (folder Shared/). Read: read and mark as read. Write: also file, move and delete mails. Full access: also delete folders. For team mailboxes without a login of their own, mark the mailbox as shared mailbox.", "info": "Groups bundle mailboxes. A group can have its own address: an alias that delivers to all members automatically. Members can send as other addresses, access shared mailboxes, calendars and address books and get a mailbox template. Members synced from the identity provider are marked IdP.", - "internal": "Only accept mail from senders of the domain", + "internal": "Only accept mail from logged-in senders of the domain", "list_error": "Groups could not be loaded", "managed_alias": "Managed by group %s", "members": "Members", @@ -1036,6 +1045,59 @@ "username": "Username", "email": "Email address" }, + "mail_policy": { + "add": "Add mail policy", + "allow_receive": "Allowed senders", + "allow_send": "Allowed recipients", + "assign": "Assigned to", + "assign_info": "The policy applies to these mailboxes and to the members of these groups; for a group also to mail sent to the group address. Mail sent to an alias or another group reaches the members without their policies.", + "assigned_none": "Not assigned", + "block_receive": "Blocked senders", + "block_send": "Blocked recipients", + "description": "Description", + "domain": "Domain", + "entries_info": "Addresses or domains, one per line, and groups. Addresses and domains of this mailcow count for logged-in senders only.", + "groups": "Groups", + "info": "Mail policies define whom mailboxes and groups may send to and receive from, e.g. only internally or only with certain domains. Postfix checks them for every recipient and refuses recipients that are not allowed with a 550 error. Incoming mail still reaches the other recipients, whereas SOGo does not send a mail with a refused recipient at all.", + "internal_info": "Internal: recipients in the domain of the policy and its alias domains; senders logged in with a mailbox of this domain.", + "list_error": "The mail policies could not be loaded", + "mail_policies": "Mail policies", + "mailboxes": "Mailboxes", + "mode": "Allowed", + "mode_receive_all": "All senders", + "mode_receive_internal": "Internal senders and the allowed senders", + "mode_receive_list": "Only the allowed senders", + "mode_send_all": "All recipients", + "mode_send_internal": "Internal recipients and the allowed recipients", + "mode_send_list": "Only the allowed recipients", + "name": "Name", + "policy": "Mail policy", + "receive": "Receiving", + "receive_info": "For mail to an assigned mailbox or to the address of an assigned group.", + "reject_text": "Rejection text", + "reject_text_info": "Text of the SMTP rejection (550 5.7.1 ...), ASCII only. Empty: a default text.", + "send": "Sending", + "send_info": "For mail an assigned mailbox sends while logged in, or that is sent with its address, including redirects by filters and vacation replies.", + "summary_all": "All", + "summary_allowed": "allowed", + "summary_blocked": "blocked", + "summary_internal": "Internal", + "summary_list": "Allowed only", + "test": "Test a rule", + "test_allowed": "Accepted: no policy refuses this mail.", + "test_denied_internal": "Refused: the address only accepts mail from logged-in senders of its domain (internal alias or group).", + "test_denied_other": "Refused by a policy of another domain.", + "test_denied_receive": "Refused by the receiving part of the policy \"%s\".", + "test_denied_send": "Refused by the sending part of the policy \"%s\".", + "test_info": "Checks whether Postfix would accept the mail for this recipient. Leave the sender empty for bounces.", + "test_invalid": "Enter a valid recipient (and sender) of your domains.", + "test_logged_in": "Sender is logged in", + "test_not_a_mailbox": "The sender is no mailbox of this mailcow and was tested as not logged in.", + "test_recipient": "Recipient", + "test_run": "Test", + "test_sender": "Sender", + "view": "View" + }, "mailbox": { "action": "Action", "activate": "Activate", @@ -1141,6 +1203,9 @@ "no_record_single": "No record", "open_logs": "Open logs", "owner": "Owner", + "policies": "Policies", + "policies_mail": "Mail", + "policies_tls": "TLS", "private_comment": "Private comment", "public_comment": "Public comment", "q_add_header": "when moved to Junk folder", @@ -1355,6 +1420,9 @@ "learned_ham": "Successfully learned ID %s as ham", "license_modified": "Changes to license have been saved", "logged_in_as": "Logged in as %s", + "mail_policy_added": "Mail policy %s added", + "mail_policy_modified": "Mail policy %s saved", + "mail_policy_removed": "Mail policy %s removed", "mailbox_added": "Mailbox %s has been added", "mailbox_modified": "Changes to mailbox %s have been saved", "mailbox_removed": "Mailbox %s has been removed", diff --git a/data/web/templates/edit/mail-policy-fields.twig b/data/web/templates/edit/mail-policy-fields.twig new file mode 100644 index 000000000..2d45b9c69 --- /dev/null +++ b/data/web/templates/edit/mail-policy-fields.twig @@ -0,0 +1,79 @@ +{# Fields of a mail policy (add modal, edit page): policy, id (prefix of the element ids), groups (choices: id, name, + domain, address), mailboxes (choices), ro, label_class. Groups of a list are sent apart as _groups. #} +{% set entries = {} %} +{% set listed = {} %} +{% for key in ['send_allow', 'send_block', 'receive_allow', 'receive_block'] %} + {% set plain = [] %} + {% set group_ids = [] %} + {% for entry in attribute(policy, key)|default([]) %} + {% if entry starts with 'group:' %} + {% set group_ids = group_ids|merge([entry|slice(6)]) %} + {% else %} + {% set plain = plain|merge([entry]) %} + {% endif %} + {% endfor %} + {% set entries = entries|merge({(key): plain|join("\n")}) %} + {% set listed = listed|merge({(key): group_ids}) %} +{% endfor %} +{% for side in ['send', 'receive'] %} + {{ attribute(lang.mail_policy, side) }}
+

{{ attribute(lang.mail_policy, side ~ '_info') }}

+
+ +
+ + {{ lang.mail_policy.internal_info }} +
+
+{% for list in ['allow', 'block'] %} +{% set key = side ~ '_' ~ list %} +
+ +
+ + {{ lang.mail_policy.entries_info }} + {% if not ro %}{% endif %} + +
+
+{% endfor %} +{% endfor %} +
+ +
+ + {{ lang.mail_policy.reject_text_info }} +
+
+ {{ lang.mail_policy.assign }}
+

{{ lang.mail_policy.assign_info }}

+
+ +
+ {% if not ro %}{% endif %} + +
+
+
+ +
+ {% if not ro %}{% endif %} + +
+
diff --git a/data/web/templates/edit/mail-policy.twig b/data/web/templates/edit/mail-policy.twig new file mode 100644 index 000000000..eff2723fb --- /dev/null +++ b/data/web/templates/edit/mail-policy.twig @@ -0,0 +1,46 @@ +{% extends 'edit.twig' %} + +{% block inner_content %} +{% if result %} +{% set ro = not result.can_edit %} +

{{ lang.mail_policy.policy }}: {{ result.name }}

+
+ +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+ {% include 'edit/mail-policy-fields.twig' with {'policy': result, 'id': 'mail_policy', 'groups': mail_policy_groups, 'mailboxes': mailboxes, 'ro': ro, 'label_class': ''} %} +
+
+
+ +
+
+
+ {% if not ro %} +
+
+ +
+
+ {% endif %} +
+{% else %} + {{ parent() }} +{% endif %} +{% endblock %} diff --git a/data/web/templates/mailbox.twig b/data/web/templates/mailbox.twig index 6a6cbe58d..0135cc73a 100644 --- a/data/web/templates/mailbox.twig +++ b/data/web/templates/mailbox.twig @@ -36,10 +36,20 @@ {% if acl.mailbox_groups == 1 %} {% endif %} + {% if mailcow_cc_role == 'admin' %} + + {% elseif acl.mail_policies == 1 %} + + {% endif %} -
@@ -59,6 +69,9 @@ {% if acl.mailbox_groups == 1 %} {% include 'mailbox/tab-groups.twig' %} {% endif %} + {% if acl.mail_policies == 1 %} + {% include 'mailbox/tab-mail-policies.twig' %} + {% endif %} {% include 'mailbox/tab-syncjobs.twig' %} {% include 'mailbox/tab-filters.twig' %} {% include 'mailbox/tab-bcc.twig' %} @@ -75,6 +88,7 @@ var lang = {{ lang_mailbox|raw }}; lang.syncjobs = {{ lang_syncjobs|raw }}; lang.groups = {{ lang_groups|raw }}; + lang.mail_policy = {{ lang_mail_policy|raw }}; var lang_rl = {{ lang_rl|raw }}; var lang_edit = {{ lang_edit|raw }}; var lang_datatables = {{ lang_datatables|raw }}; diff --git a/data/web/templates/mailbox/tab-mail-policies.twig b/data/web/templates/mailbox/tab-mail-policies.twig new file mode 100644 index 000000000..494ce2ddb --- /dev/null +++ b/data/web/templates/mailbox/tab-mail-policies.twig @@ -0,0 +1,54 @@ +
+
+
+ + {{ lang.mail_policy.mail_policies }} +
+ +
+
+
+

{{ lang.mail_policy.info }}

+
+ + + {# Test a rule: would the mail be accepted? Filled by js/site/mail-policy.js #} +
+
{{ lang.mail_policy.test }}
+
+

{{ lang.mail_policy.test_info }}

+
+
+ +
+
+ +
+
+
+ + +
+ +
+
+
+
+
+
+
+
diff --git a/data/web/templates/modals/mail_policy.twig b/data/web/templates/modals/mail_policy.twig new file mode 100644 index 000000000..89c9e935f --- /dev/null +++ b/data/web/templates/modals/mail_policy.twig @@ -0,0 +1,50 @@ + diff --git a/data/web/templates/modals/mailbox.twig b/data/web/templates/modals/mailbox.twig index 0a886957c..6418c400b 100644 --- a/data/web/templates/modals/mailbox.twig +++ b/data/web/templates/modals/mailbox.twig @@ -1362,3 +1362,6 @@ {% if acl.mailbox_groups == 1 %} {% include "modals/mailbox_group.twig" %} {% endif %} +{% if acl.mail_policies == 1 %} +{% include "modals/mail_policy.twig" %} +{% endif %} diff --git a/docker-compose.yml b/docker-compose.yml index c6e07d7c5..843884655 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,5 @@ # Version of the database schema -x-db-version: &db_version DB_VERSION=02112026_1600 +x-db-version: &db_version DB_VERSION=07112026_1200 services: @@ -348,7 +348,7 @@ services: - dovecot postfix-mailcow: - image: ghcr.io/mailcow/postfix:3.10.12-1 + image: ghcr.io/mailcow/postfix:3.10.12-2 depends_on: mysql-mailcow: condition: service_started @@ -375,6 +375,7 @@ services: - REDISPASS=${REDISPASS} - MAILCOW_HOSTNAME=${MAILCOW_HOSTNAME} - SPAMHAUS_DQS_KEY=${SPAMHAUS_DQS_KEY:-} + - *db_version cap_add: - NET_BIND_SERVICE ports: diff --git a/tests/php/MailPolicy/Service/PolicyEngineTest.php b/tests/php/MailPolicy/Service/PolicyEngineTest.php new file mode 100644 index 000000000..6944c598d --- /dev/null +++ b/tests/php/MailPolicy/Service/PolicyEngineTest.php @@ -0,0 +1,188 @@ +lookup = new FakeMailPolicyLookup(); + $this->lookup->domains = ['firma.de', 'tochter.de']; + $this->lookup->aliasDomains = ['firma.com' => 'firma.de']; + $this->lookup->mailboxes = [ + 'azubi@firma.de' => 'firma.de', + 'kollege@firma.de' => 'firma.de', + 'chef@firma.de' => 'firma.de', + 'info@firma.de' => 'firma.de', + 'andere@tochter.de' => 'tochter.de', + ]; + $this->lookup->groups = [ + 7 => ['address' => 'azubis@firma.de', 'members' => ['azubi@firma.de']], + 8 => ['address' => 'vorstand@firma.de', 'members' => ['chef@firma.de']], + ]; + $this->engine = new PolicyEngine($this->lookup); + } + + private static function policy(int $id, array $values): Policy + { + $policy = new Policy(); + $policy->id = $id; + $policy->domain = 'firma.de'; + $policy->name = 'policy ' . $id; + foreach ($values as $property => $value) { + $policy->$property = $value; + } + return $policy; + } + + /** [allowed, side] of a check */ + private function check(string $sender, string $recipient, string $login = '', int $port = 587): array + { + $decision = $this->engine->check($sender, $recipient, $login, $port); + return [$decision->allowed, $decision->side]; + } + + public function testWithoutPoliciesEverythingPasses(): void + { + $this->assertSame([true, ''], $this->check('x@extern.example', 'azubi@firma.de')); + $this->assertSame('DUNNO', $this->engine->check('azubi@firma.de', 'x@extern.example', 'azubi@firma.de')->action()); + } + + public function testSendOnlyInternalWithAllowedAndBlockedEntries(): void + { + $this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(1, [ + 'sendMode' => 'internal', 'sendAllow' => ['partner.de', 'kunde@extern.example'], 'sendBlock' => ['chef@firma.de'], + ])]; + + $this->assertSame([true, ''], $this->check('azubi@firma.de', 'kollege@firma.de', 'azubi@firma.de')); + // alias domain and sub-address are internal + $this->assertSame([true, ''], $this->check('azubi@firma.de', 'Kollege+x@firma.com', 'azubi@firma.de')); + $this->assertSame([false, 'send'], $this->check('azubi@firma.de', 'x@extern.example', 'azubi@firma.de')); + // another domain of the same mailcow is not internal + $this->assertSame([false, 'send'], $this->check('azubi@firma.de', 'andere@tochter.de', 'azubi@firma.de')); + $this->assertSame([true, ''], $this->check('azubi@firma.de', 'jemand@partner.de', 'azubi@firma.de')); + $this->assertSame([true, ''], $this->check('azubi@firma.de', 'kunde@extern.example', 'azubi@firma.de')); + $this->assertSame([false, 'send'], $this->check('azubi@firma.de', 'chef@firma.de', 'azubi@firma.de')); + // the rules belong to the login: without it nothing is sent as azubi + $this->assertSame([true, ''], $this->check('azubi@firma.de', 'x@extern.example')); + } + + public function testSendOnlyListedRecipientsThroughAGroup(): void + { + $this->lookup->groupPolicies[7] = [self::policy(2, ['sendMode' => 'list', 'sendAllow' => ['group:8']])]; + + $this->assertSame([true, ''], $this->check('azubi@firma.de', 'vorstand@firma.de', 'azubi@firma.de')); + $this->assertSame([true, ''], $this->check('azubi@firma.de', 'chef@firma.de', 'azubi@firma.de')); + $this->assertSame([false, 'send'], $this->check('azubi@firma.de', 'kollege@firma.de', 'azubi@firma.de')); + // not a member: no rule + $this->assertSame([true, ''], $this->check('kollege@firma.de', 'x@extern.example', 'kollege@firma.de')); + } + + public function testSendingAsAnotherMailboxAppliesItsPolicyToo(): void + { + $this->lookup->mailboxPolicies['info@firma.de'] = [self::policy(3, ['sendMode' => 'internal'])]; + + $this->assertSame([false, 'send'], $this->check('info@firma.de', 'x@extern.example', 'chef@firma.de')); + $this->assertSame([true, ''], $this->check('chef@firma.de', 'x@extern.example', 'chef@firma.de')); + } + + public function testReceiveOnlyInternalCannotBeForged(): void + { + $this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(4, ['receiveMode' => 'internal', 'receiveAllow' => ['partner.de'], + 'receiveBlock' => ['chef@firma.de']])]; + + $this->assertSame([false, 'receive'], $this->check('x@extern.example', 'azubi@firma.de', '', 25)); + // a forged internal sender is external + $this->assertSame([false, 'receive'], $this->check('kollege@firma.de', 'azubi@firma.de', '', 25)); + $this->assertSame([true, ''], $this->check('kollege@firma.de', 'azubi+news@firma.com', 'kollege@firma.de')); + $this->assertSame([false, 'receive'], $this->check('andere@tochter.de', 'azubi@firma.de', 'andere@tochter.de')); + // an external domain on the list is compared with the envelope sender + $this->assertSame([true, ''], $this->check('jemand@partner.de', 'azubi@firma.de', '', 25)); + $this->assertSame([false, 'receive'], $this->check('chef@firma.de', 'azubi@firma.de', 'chef@firma.de')); + // null sender (bounces) is external + $this->assertSame([false, 'receive'], $this->check('', 'azubi@firma.de', '', 25)); + } + + public function testListedLocalSendersMustBeLoggedIn(): void + { + $this->lookup->mailboxPolicies['chef@firma.de'] = [self::policy(5, ['receiveMode' => 'list', + 'receiveAllow' => ['kollege@firma.de', 'group:7', 'tochter.de']])]; + + $this->assertSame([false, 'receive'], $this->check('kollege@firma.de', 'chef@firma.de', '', 25)); + $this->assertSame([true, ''], $this->check('kollege@firma.de', 'chef@firma.de', 'kollege@firma.de')); + $this->assertSame([true, ''], $this->check('azubi@firma.de', 'chef@firma.de', 'azubi@firma.de')); + $this->assertSame([false, 'receive'], $this->check('azubi@firma.de', 'chef@firma.de', '', 25)); + $this->assertSame([true, ''], $this->check('andere@tochter.de', 'chef@firma.de', 'andere@tochter.de')); + $this->assertSame([false, 'receive'], $this->check('andere@tochter.de', 'chef@firma.de', '', 25)); + } + + public function testGroupAddressAndMembersAsRecipients(): void + { + $this->lookup->groupPolicies[8] = [self::policy(6, ['receiveMode' => 'internal', 'receiveBlock' => ['group:7']])]; + + $this->assertSame([false, 'receive'], $this->check('x@extern.example', 'vorstand@firma.de', '', 25)); + $this->assertSame([true, ''], $this->check('kollege@firma.de', 'vorstand@firma.de', 'kollege@firma.de')); + // the policy of a group applies to its members as well + $this->assertSame([false, 'receive'], $this->check('x@extern.example', 'chef@firma.de', '', 25)); + $this->assertSame([false, 'receive'], $this->check('azubi@firma.de', 'chef@firma.de', 'azubi@firma.de')); + } + + public function testInternalFlagOfAliasesAndGroups(): void + { + $this->lookup->internalAliases['team@firma.de'] = 'firma.de'; + + $this->assertSame([false, 'receive'], $this->check('x@extern.example', 'team@firma.de', '', 25)); + // the old rspamd rule let forged senders of the domain pass + $this->assertSame([false, 'receive'], $this->check('kollege@firma.de', 'team@firma.com', '', 25)); + $this->assertSame([true, ''], $this->check('kollege@firma.de', 'team@firma.de', 'kollege@firma.de')); + $this->assertSame([false, 'receive'], $this->check('andere@tochter.de', 'team@firma.de', 'andere@tochter.de')); + } + + public function testTheStrictestPolicyWins(): void + { + $this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(9, ['sendMode' => 'all'])]; + $this->lookup->groupPolicies[7] = [self::policy(10, ['sendMode' => 'internal', 'rejectText' => 'Azubis senden nur intern'])]; + + $decision = $this->engine->check('azubi@firma.de', 'x@extern.example', 'azubi@firma.de', 587); + $this->assertSame(['send', 10, '550 5.7.1 Azubis senden nur intern'], [$decision->side, $decision->policy->id, $decision->action()]); + } + + public function testDefaultTextsAndOwnDeliveries(): void + { + $this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(11, ['sendMode' => 'internal', 'receiveMode' => 'internal'])]; + + $this->assertSame('550 5.7.1 Sending to this recipient is not allowed by the mail policy of the sender', + $this->engine->check('azubi@firma.de', 'x@extern.example', 'azubi@firma.de', 587)->action()); + $this->assertSame('550 5.7.1 This recipient does not accept mail from this sender', + $this->engine->check('x@extern.example', 'azubi@firma.de', '', 25)->action()); + // quarantine release (590) and BCC/archive copies (591) are not checked + $this->assertSame([true, ''], $this->check('x@extern.example', 'azubi@firma.de', '', 590)); + $this->assertSame([true, ''], $this->check('x@extern.example', 'azubi@firma.de', '', 591)); + } + + public function testDovecotSubmissionsCountAsTheMailbox(): void + { + $this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(12, ['sendMode' => 'internal'])]; + $this->lookup->mailboxPolicies['chef@firma.de'] = [self::policy(13, ['receiveMode' => 'internal'])]; + $this->lookup->internalAliases['team@firma.de'] = 'firma.de'; + + // a Sieve redirect of azubi to an external address follows its send rules + $this->assertSame([false, 'send'], $this->check('azubi+x@firma.de', 'x@extern.example', '', 588)); + $this->assertSame([true, ''], $this->check('azubi@firma.de', 'chef@firma.de', '', 588)); + $this->assertSame([true, ''], $this->check('kollege@firma.de', 'team@firma.de', '', 588)); + // notifications of mailcow (e.g. quota warnings) are no mailbox + $this->assertSame([true, ''], $this->check('quota-warning@localhost', 'chef@firma.de', '', 588)); + // on other ports the sender address is no login + $this->assertSame([false, 'receive'], $this->check('kollege@firma.de', 'team@firma.de', '', 25)); + } +} diff --git a/tests/php/MailPolicy/Service/PolicyServiceTest.php b/tests/php/MailPolicy/Service/PolicyServiceTest.php new file mode 100644 index 000000000..e42fd1aa7 --- /dev/null +++ b/tests/php/MailPolicy/Service/PolicyServiceTest.php @@ -0,0 +1,169 @@ +repo = $this->createMock(PolicyRepository::class); + $this->repo->method('isDomain')->willReturnCallback(fn(string $domain): bool => in_array($domain, ['firma.de', 'tochter.de'], true)); + $this->repo->method('mailboxDomain')->willReturnCallback(fn(string $username): ?string => + ['azubi@firma.de' => 'firma.de', 'chef@firma.de' => 'firma.de', 'andere@tochter.de' => 'tochter.de'][$username] ?? null); + $this->repo->method('groupDomain')->willReturnCallback(fn(int $id): ?string => [7 => 'firma.de', 9 => 'tochter.de'][$id] ?? null); + $this->repo->method('save')->willReturnCallback(function (Policy $policy): int { + $policy->id ??= 1; + $this->saved[] = clone $policy; + return $policy->id; + }); + $this->lookup = new FakeMailPolicyLookup(); + $this->lookup->domains = ['firma.de', 'tochter.de']; + $this->lookup->mailboxes = ['azubi@firma.de' => 'firma.de', 'chef@firma.de' => 'firma.de']; + } + + private function service(?Context $context = null): PolicyService + { + return new PolicyService($this->repo, new PolicyEngine($this->lookup), $context ?? FakeContext::admin()); + } + + private function stored(array $values = []): Policy + { + $policy = new Policy(); + $policy->id = 3; + $policy->domain = 'firma.de'; + $policy->name = 'Azubis'; + $policy->sendAllow = ['partner.de', 'group:7']; + foreach ($values as $property => $value) { + $policy->$property = $value; + } + return $policy; + } + + 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 testCreateNormalizesTheInput(): void + { + $policy = $this->service()->create([ + 'domain' => 'Firma.de', 'name' => 'Azubis', 'description' => 'Nur intern', 'active' => ['0', '1'], + 'send_mode' => 'internal', 'send_allow' => "@Partner.de\nkunde@extern.example, partner.de", 'send_allow_groups' => ['', '7'], + 'send_block' => '', 'receive_mode' => 'list', 'receive_allow' => ['group:7'], 'reject_text' => 'Nur intern erlaubt', + 'mailboxes' => ['', 'Azubi@firma.de'], 'groups' => ['7'], + ]); + + $this->assertSame([ + 'domain' => 'firma.de', 'name' => 'Azubis', 'description' => 'Nur intern', 'send_mode' => 'internal', + 'send_allow' => ['partner.de', 'kunde@extern.example', 'group:7'], 'send_block' => [], 'receive_mode' => 'list', + 'receive_allow' => ['group:7'], 'receive_block' => [], 'reject_text' => 'Nur intern erlaubt', 'active' => 1, + 'mailboxes' => ['azubi@firma.de'], 'groups' => [7], + ], array_intersect_key($policy->toArray(), array_flip(['domain', 'name', 'description', 'send_mode', 'send_allow', 'send_block', + 'receive_mode', 'receive_allow', 'receive_block', 'reject_text', 'active', 'mailboxes', 'groups']))); + $this->assertCount(1, $this->saved); + } + + public function testUpdateKeepsMissingValuesAndReplacesListGroups(): void + { + $this->repo->method('find')->willReturn($this->stored()); + + $policy = $this->service()->update(3, ['description' => 'neu']); + $this->assertSame(['neu', ['partner.de', 'group:7']], [$policy->description, $policy->sendAllow]); + + // the UI sends the groups apart: they replace the group entries, the other entries stay + $this->repo->method('find')->willReturn($this->stored()); + $this->assertSame(['partner.de'], $this->service()->update(3, ['send_allow_groups' => ''])->sendAllow); + } + + public static function invalidInput(): array + { + return [ + 'empty name' => [['name' => ''], 'mail_policy_name_invalid'], + 'long name' => [['name' => str_repeat('a', 101)], 'mail_policy_name_invalid'], + 'mode' => [['send_mode' => 'nobody'], 'mail_policy_mode_invalid'], + 'domain without dot' => [['send_allow' => 'localhost'], ['mail_policy_entry_invalid', 'localhost']], + 'address' => [['receive_block' => 'a@@b.de'], ['mail_policy_entry_invalid', 'a@@b.de']], + 'unknown group' => [['receive_block_groups' => ['8']], ['mail_policy_entry_invalid', 'group:8']], + 'reject text with line break' => [['reject_text' => "a\r\nb"], 'mail_policy_reject_text_invalid'], + 'reject text not ASCII' => [['reject_text' => 'Nur für Azubis'], 'mail_policy_reject_text_invalid'], + 'mailbox of another domain' => [['mailboxes' => 'andere@tochter.de'], ['mail_policy_member_invalid', 'andere@tochter.de']], + 'group of another domain' => [['groups' => ['9']], ['mail_policy_member_invalid', '9']], + 'unknown domain' => [['domain' => 'fremd.de'], ['domain_not_found', 'fremd.de']], + ]; + } + + #[DataProvider('invalidInput')] + public function testInvalidInput(array $input, string|array $error): void + { + $this->expectUserError($error, fn() => $this->service()->create($input + ['domain' => 'firma.de', 'name' => 'Test'])); + $this->assertSame([], $this->saved); + } + + public function testNamesAreUniquePerDomain(): void + { + $this->repo->method('nameExists')->willReturn(true); + $this->expectUserError(['object_exists', 'Azubis'], fn() => $this->service()->create(['domain' => 'firma.de', 'name' => 'Azubis'])); + } + + public function testPermissions(): void + { + $this->repo->method('find')->willReturn($this->stored()); + $da = FakeContext::domainAdmin([], ['firma.de'], ['mail_policies' => '1']); + + $this->assertSame('Test', $this->service($da)->create(['domain' => 'firma.de', 'name' => 'Test'])->name); + $this->assertNotNull($this->service($da)->get(3)); + // other domains, no ACL, users and read-only keys + $this->expectUserError('access_denied', fn() => $this->service($da)->create(['domain' => 'tochter.de', 'name' => 'Test'])); + $this->expectUserError(['mail_policy_entry_invalid', 'group:9'], + fn() => $this->service($da)->create(['domain' => 'firma.de', 'name' => 'Test', 'send_block_groups' => ['9']])); + $this->assertNull($this->service(FakeContext::domainAdmin([], ['tochter.de'], ['mail_policies' => '1']))->get(3)); + $this->expectUserError('access_denied', fn() => $this->service(FakeContext::domainAdmin([], ['firma.de'], []))->create(['domain' => 'firma.de', 'name' => 'Test'])); + $this->expectUserError('access_denied', fn() => $this->service(FakeContext::user('azubi@firma.de'))->update(3, [])); + $readOnly = new Context('admin', 'API', [], false, 'ro'); + $this->assertNotNull($this->service($readOnly)->get(3)); + $this->expectUserError('access_denied', fn() => $this->service($readOnly)->delete(3)); + } + + public function testRuleTest(): void + { + $policy = $this->stored(['sendMode' => 'internal', 'sendAllow' => []]); + $this->lookup->mailboxPolicies['azubi@firma.de'] = [$policy]; + + $this->assertSame(['allowed' => 0, 'side' => 'send', 'policy' => 'Azubis', 'internal_alias' => 0, 'logged_in' => 1, + 'text' => 'Sending to this recipient is not allowed by the mail policy of the sender'], + $this->service()->test('azubi@firma.de', 'x@extern.example', true)); + // not logged in: the send rules of the mailbox do not apply + $this->assertSame(1, $this->service()->test('azubi@firma.de', 'x@extern.example', false)['allowed']); + // an external sender is never logged in + $this->assertSame(0, $this->service()->test('x@extern.example', 'chef@firma.de', true)['logged_in']); + $this->expectUserError('mail_policy_test_invalid', fn() => $this->service()->test('azubi@firma.de', 'kein-empfaenger', true)); + // domain admins test mail of their domains + $da = FakeContext::domainAdmin([], ['tochter.de'], ['mail_policies' => '1']); + $this->expectUserError('access_denied', fn() => $this->service($da)->test('azubi@firma.de', 'x@extern.example', true)); + } +} diff --git a/tests/php/Support/FakeMailPolicyLookup.php b/tests/php/Support/FakeMailPolicyLookup.php new file mode 100644 index 000000000..21424db0a --- /dev/null +++ b/tests/php/Support/FakeMailPolicyLookup.php @@ -0,0 +1,90 @@ + domain */ + public array $mailboxes = []; + /** alias domain => target domain */ + public array $aliasDomains = []; + /** @var string[] domains of the mailcow (without alias domains) */ + public array $domains = []; + /** id => ['address' => ?string, 'members' => string[]] */ + public array $groups = []; + /** alias address => domain of aliases with the internal flag */ + public array $internalAliases = []; + /** username => Policy[] assigned directly */ + public array $mailboxPolicies = []; + /** group id => Policy[] */ + public array $groupPolicies = []; + + public function __construct() + { + } + + public function mailbox(string $username): ?array + { + return isset($this->mailboxes[$username]) ? ['username' => $username, 'domain' => $this->mailboxes[$username]] : null; + } + + public function aliasDomainTarget(string $domain): ?string + { + return $this->aliasDomains[$domain] ?? null; + } + + public function isLocalDomain(string $domain): bool + { + return in_array($domain, $this->domains, true) || isset($this->aliasDomains[$domain]); + } + + public function groupByAddress(string $address): ?int + { + foreach ($this->groups as $id => $group) { + if (($group['address'] ?? null) === $address) { + return $id; + } + } + return null; + } + + public function groupAddress(int $id): ?string + { + return $this->groups[$id]['address'] ?? null; + } + + public function isGroupMember(int $groupId, string $username): bool + { + return in_array($username, $this->groups[$groupId]['members'] ?? [], true); + } + + public function internalAlias(string $address): ?string + { + return $this->internalAliases[$address] ?? null; + } + + public function policiesForMailbox(string $username): array + { + $policies = $this->mailboxPolicies[$username] ?? []; + foreach ($this->groups as $id => $group) { + if (in_array($username, $group['members'] ?? [], true)) { + $policies = array_merge($policies, $this->groupPolicies[$id] ?? []); + } + } + return $policies; + } + + public function policiesForGroup(int $groupId): array + { + return $this->groupPolicies[$groupId] ?? []; + } +} From 46c9ec90b00c9804dff57ee079b806d41d7f6ba1 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Sat, 7 Nov 2026 17:51:27 +0100 Subject: [PATCH 2/5] [Web][Postfix][Dovecot] Mail policies: compare addresses like Postfix, close bypasses via alias forms, temporary aliases, Sieve and cross-domain groups --- data/Dockerfiles/postfix/mailcow-policy.py | 3 +- data/Dockerfiles/postfix/postfix.sh | 9 +- data/conf/dovecot/dovecot.conf | 2 + data/conf/postfix/main.cf | 2 +- data/conf/rspamd/dynmaps/mailpolicy.php | 2 +- data/web/api/openapi.yaml | 22 +- data/web/inc/functions.mailbox.inc.php | 6 +- data/web/inc/init_db.inc.php | 11 +- .../Controller/MailPolicyApiController.php | 12 +- .../lib/mailcow/MailPolicy/Model/Decision.php | 21 +- .../Repository/LookupRepository.php | 76 +++++-- .../Repository/PolicyRepository.php | 20 ++ .../MailPolicy/Service/PolicyEngine.php | 208 ++++++++++++++---- .../MailPolicy/Service/PolicyService.php | 106 ++++++--- data/web/js/site/mail-policy.js | 2 +- data/web/lang/lang.de-de.json | 10 +- data/web/lang/lang.en-gb.json | 10 +- .../templates/edit/mail-policy-fields.twig | 9 +- docker-compose.yml | 2 +- .../MailPolicy/Service/PolicyEngineTest.php | 120 +++++++++- .../MailPolicy/Service/PolicyServiceTest.php | 61 ++++- tests/php/Support/FakeMailPolicyLookup.php | 41 +++- 22 files changed, 614 insertions(+), 141 deletions(-) diff --git a/data/Dockerfiles/postfix/mailcow-policy.py b/data/Dockerfiles/postfix/mailcow-policy.py index 28316ac77..2043742db 100755 --- a/data/Dockerfiles/postfix/mailcow-policy.py +++ b/data/Dockerfiles/postfix/mailcow-policy.py @@ -22,7 +22,8 @@ def check(request): if nginx is None: nginx = socket.gethostbyname('nginx') # http.client sets TCP_NODELAY, so the body is not held back until the headers are acknowledged - conn = http.client.HTTPConnection(nginx, 8081, timeout=10) + # below the timeout of Postfix (main.cf), so an answer always arrives + conn = http.client.HTTPConnection(nginx, 8081, timeout=8) try: conn.request('POST', '/mailpolicy.php', body, {'Host': 'nginx', 'Content-Type': 'application/x-www-form-urlencoded'}) response = conn.getresponse() diff --git a/data/Dockerfiles/postfix/postfix.sh b/data/Dockerfiles/postfix/postfix.sh index d5a857e41..094c5a687 100755 --- a/data/Dockerfiles/postfix/postfix.sh +++ b/data/Dockerfiles/postfix/postfix.sh @@ -246,11 +246,10 @@ user = ${DBUSER} password = ${DBPASS} hosts = unix:/var/run/mysqld/mysqld.sock dbname = ${DBNAME} -query = SELECT 'mailcow_mail_policy' FROM mail_policy_member - JOIN mail_policy ON mail_policy.id = mail_policy_member.policy_id - WHERE mail_policy.active = '1' AND '%s' != '' - UNION SELECT 'mailcow_mail_policy' FROM alias WHERE internal = '1' AND active = '1' - LIMIT 1; +query = SELECT 'mailcow_mail_policy' FROM DUAL WHERE '%s' != '' + AND (EXISTS (SELECT 1 FROM mail_policy_member JOIN mail_policy ON mail_policy.id = mail_policy_member.policy_id + WHERE mail_policy.active = '1') + OR EXISTS (SELECT 1 FROM alias WHERE internal = '1' AND active IN ('1', '2'))); EOF cat < /opt/postfix/conf/sql/mysql_virtual_alias_domain_maps.cf diff --git a/data/conf/dovecot/dovecot.conf b/data/conf/dovecot/dovecot.conf index c1da4fb7c..fbdb112da 100644 --- a/data/conf/dovecot/dovecot.conf +++ b/data/conf/dovecot/dovecot.conf @@ -199,6 +199,8 @@ plugin { sieve_plugins = sieve_imapsieve sieve_extprograms sieve_vacation_send_from_recipient = yes sieve_redirect_envelope_from = recipient + # Notifications are sent with the address of the mailbox (not :from or postmaster), so mail policies apply + sieve_notify_mailto_envelope_from = recipient # From elsewhere to Spam folder imapsieve_mailbox1_name = Junk imapsieve_mailbox1_causes = COPY diff --git a/data/conf/postfix/main.cf b/data/conf/postfix/main.cf index c51713d09..79d0360bb 100644 --- a/data/conf/postfix/main.cf +++ b/data/conf/postfix/main.cf @@ -175,7 +175,7 @@ lmtp_destination_recipient_limit=1 # Mail policies: while policies or internal aliases exist, mysql_mail_policy_maps.cf returns this class and smtpd # asks the policy service for every recipient (mailcow-policy in master.cf); no answer defers the recipient smtpd_restriction_classes = mailcow_mail_policy -mailcow_mail_policy = check_policy_service { unix:private/mailcow-policy, timeout=10s, default_action=DEFER_IF_PERMIT } +mailcow_mail_policy = check_policy_service { unix:private/mailcow-policy, timeout=10s, try_limit=1, default_action=DEFER_IF_PERMIT } mailcow-policy_time_limit = 3600s # DO NOT EDIT ANYTHING BELOW # diff --git a/data/conf/rspamd/dynmaps/mailpolicy.php b/data/conf/rspamd/dynmaps/mailpolicy.php index ec5f35930..6b99d6b23 100644 --- a/data/conf/rspamd/dynmaps/mailpolicy.php +++ b/data/conf/rspamd/dynmaps/mailpolicy.php @@ -30,7 +30,7 @@ $value = fn(string $key): string => is_string($_POST[$key] ?? null) ? $_POST[$ke try { $policy = \Mailcow\MailPolicy\MailPolicyModule::create($pdo, \Mailcow\Core\Context::system()); - echo $policy->api->check($value('sender'), $value('recipient'), $value('sasl_username'), (int)$value('server_port')); + echo $policy->api->check($value('sender'), $value('recipient'), $value('sasl_username'), (int)$value('server_port'), $value('helo_name')); } catch (Throwable $e) { // the policy service answers with a temporary error diff --git a/data/web/api/openapi.yaml b/data/web/api/openapi.yaml index 245581de4..099c640a9 100644 --- a/data/web/api/openapi.yaml +++ b/data/web/api/openapi.yaml @@ -1459,7 +1459,7 @@ paths: description: all; internal = recipients in the domain of the policy and its alias domains plus send_allow; list = send_allow only type: string send_allow: - description: addresses, domains or group: (array or list separated by commas, spaces or newlines), max. 500 + description: addresses, domains or group: of a group of the policy's domain (array or list separated by commas, spaces or newlines), max. 500 type: array send_block: description: recipients that are always refused, entries as send_allow @@ -1477,10 +1477,10 @@ paths: description: text of the 550 5.7.1 rejection, printable ASCII, max. 200 characters, empty = default text type: string mailboxes: - description: mailboxes of the domain the policy applies to (replaces the list) + description: mailboxes of the domain the policy applies to (replaces the list), max. 500 type: array groups: - description: group IDs of the domain the policy applies to, also to mail to the group address (replaces the list) + description: group IDs of the domain the policy applies to (its members of that domain and mail to the group address), max. 500; domain admins need the mailbox_groups ACL, without it group IDs and group entries stay unchanged type: array active: type: boolean @@ -4731,7 +4731,7 @@ paths: description: all; internal = recipients in the domain of the policy and its alias domains plus send_allow; list = send_allow only type: string send_allow: - description: addresses, domains or group: (array or list separated by commas, spaces or newlines), max. 500 + description: addresses, domains or group: of a group of the policy's domain (array or list separated by commas, spaces or newlines), max. 500 type: array send_block: description: recipients that are always refused, entries as send_allow @@ -4749,10 +4749,10 @@ paths: description: text of the 550 5.7.1 rejection, printable ASCII, max. 200 characters, empty = default text type: string mailboxes: - description: mailboxes of the domain the policy applies to (replaces the list) + description: mailboxes of the domain the policy applies to (replaces the list), max. 500 type: array groups: - description: group IDs of the domain the policy applies to, also to mail to the group address (replaces the list) + description: group IDs of the domain the policy applies to (its members of that domain and mail to the group address), max. 500; domain admins need the mailbox_groups ACL, without it group IDs and group entries stay unchanged type: array active: type: boolean @@ -6812,10 +6812,12 @@ paths: tags: - Mail policies description: >- - Would Postfix accept the mail for this recipient? side is "send" or - "receive", policy is empty for a policy of another domain, - internal_alias = refused by the internal flag of an alias or group. - Domain admins test addresses of their domains. Empty answer for invalid + Would Postfix accept the mail for this recipient? side is "send", + "receive" or "address" (the recipient routes through another address), + policy is empty for a policy of another domain (its text is then the + default text), internal_alias = refused by the internal flag of an + alias or group. Domain admins test addresses of their domains; a sender + of another domain is tested as not logged in. Empty answer for invalid input. operationId: Test mail policies summary: Test mail policies diff --git a/data/web/inc/functions.mailbox.inc.php b/data/web/inc/functions.mailbox.inc.php index 121d43a0c..68b02b9c6 100644 --- a/data/web/inc/functions.mailbox.inc.php +++ b/data/web/inc/functions.mailbox.inc.php @@ -2432,6 +2432,7 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { if ($address != $is_now['address']) { // Send-as grants of groups follow the address \Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->api->addressRenamed($is_now['address'], $address); + \Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->api->addressRenamed($is_now['address'], $address); } } $_SESSION['return'][] = array( @@ -3295,7 +3296,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { 'mailbox_group_imap_grant' => ['owner'], 'mailbox_group_sogo_grant' => ['owner'], 'mailbox_group_sogo_acl' => ['c_uid'], - 'mailbox_group_subscription' => ['username', 'owner'] + 'mailbox_group_subscription' => ['username', 'owner'], + 'mail_policy_member' => ['username'] ]; foreach ($tables as $table => $columns) { foreach ($columns as $column) { @@ -3311,6 +3313,8 @@ function mailbox($_action, $_type, $_data = null, $_extra = null) { \Mailcow\Imapsync\ImapsyncModule::fromGlobals()->sources->onAccountRenamed($old_username, $new_username); // Group memberships and send-as grants follow, group aliases are rebuilt \Mailcow\MailboxGroup\MailboxGroupModule::fromGlobals()->hooks->onMailboxRenamed($old_username, $new_username); + // Address entries of mail policies follow + \Mailcow\MailPolicy\MailPolicyModule::fromGlobals()->api->addressRenamed($old_username, $new_username); // Update c_uid, c_name and mail in _sogo_static_view table $pdo->prepare("UPDATE _sogo_static_view SET c_uid = :new_username, c_name = :new_username2, mail = :new_username3 WHERE c_uid = :old_username") diff --git a/data/web/inc/init_db.inc.php b/data/web/inc/init_db.inc.php index 6b29256e9..02ecdcaf8 100644 --- a/data/web/inc/init_db.inc.php +++ b/data/web/inc/init_db.inc.php @@ -500,11 +500,11 @@ function init_db_schema() "description" => "VARCHAR(255) NOT NULL DEFAULT ''", // all, internal or list; the entries are JSON lists of addresses, domains and group: "send_mode" => "VARCHAR(16) NOT NULL DEFAULT 'all'", - "send_allow" => "TEXT NOT NULL", - "send_block" => "TEXT NOT NULL", + "send_allow" => "MEDIUMTEXT NOT NULL", + "send_block" => "MEDIUMTEXT NOT NULL", "receive_mode" => "VARCHAR(16) NOT NULL DEFAULT 'all'", - "receive_allow" => "TEXT NOT NULL", - "receive_block" => "TEXT NOT NULL", + "receive_allow" => "MEDIUMTEXT NOT NULL", + "receive_block" => "MEDIUMTEXT NOT NULL", "reject_text" => "VARCHAR(255) NOT NULL DEFAULT ''", "created" => "DATETIME(0) NOT NULL DEFAULT NOW(0)", "modified" => "DATETIME ON UPDATE CURRENT_TIMESTAMP", @@ -783,7 +783,8 @@ function init_db_schema() "group_id" => array("group_id") ), "key" => array( - "domain" => array("domain") + "domain" => array("domain"), + "internal" => array("internal", "active") ), "fkey" => array( "fk_alias_mailbox_group" => array( diff --git a/data/web/inc/lib/mailcow/MailPolicy/Controller/MailPolicyApiController.php b/data/web/inc/lib/mailcow/MailPolicy/Controller/MailPolicyApiController.php index 2cdb6c698..55650df16 100644 --- a/data/web/inc/lib/mailcow/MailPolicy/Controller/MailPolicyApiController.php +++ b/data/web/inc/lib/mailcow/MailPolicy/Controller/MailPolicyApiController.php @@ -75,10 +75,16 @@ class MailPolicyApiController } } - /** Answer for the Postfix policy service: DUNNO or the rejection */ - public function check(string $sender, string $recipient, string $login, int $port): string + /** functions.mailbox.inc.php: a mailbox or alias was renamed */ + public function addressRenamed(string $old, string $new): void { - return $this->engine->check($sender, $recipient, $login, $port)->action(); + $this->policies->addressRenamed($old, $new); + } + + /** Answer for the Postfix policy service: DUNNO or the rejection */ + public function check(string $sender, string $recipient, string $login, int $port, string $helo = ''): string + { + return $this->engine->check($sender, $recipient, $login, $port, $helo)->action(); } /** diff --git a/data/web/inc/lib/mailcow/MailPolicy/Model/Decision.php b/data/web/inc/lib/mailcow/MailPolicy/Model/Decision.php index d030f488c..2f0f16ebd 100644 --- a/data/web/inc/lib/mailcow/MailPolicy/Model/Decision.php +++ b/data/web/inc/lib/mailcow/MailPolicy/Model/Decision.php @@ -5,13 +5,15 @@ declare(strict_types=1); namespace Mailcow\MailPolicy\Model; /** - * Result of the policy check for one recipient: allowed, or refused by the send or receive part of a policy. + * Result of the policy check for one recipient: allowed, refused by the send or receive part of a policy, or refused + * because the recipient is written in a form the check cannot follow (routing through another address). */ class Decision { private const TEXT = [ 'send' => 'Sending to this recipient is not allowed by the mail policy of the sender', 'receive' => 'This recipient does not accept mail from this sender', + 'address' => 'This form of the recipient address is not allowed by the mail policy', ]; private function __construct(public readonly bool $allowed, public readonly string $side = '', public readonly ?Policy $policy = null) @@ -23,16 +25,27 @@ class Decision return new self(true); } - /** $side send or receive */ - public static function deny(string $side, Policy $policy): self + /** $side send or receive; without $policy the default text is used */ + public static function deny(string $side, ?Policy $policy = null): self { return new self(false, $side, $policy); } + /** The recipient routes through another address (user%domain, domain!user, a second @) */ + public static function routedAddress(): self + { + return new self(false, 'address'); + } + /** Text of the rejection: the text of the policy or the default */ public function text(): string { - return ($this->policy !== null && $this->policy->rejectText !== '') ? $this->policy->rejectText : (self::TEXT[$this->side] ?? ''); + return ($this->policy !== null && $this->policy->rejectText !== '') ? $this->policy->rejectText : self::defaultText($this->side); + } + + public static function defaultText(string $side): string + { + return self::TEXT[$side] ?? ''; } /** Answer for the Postfix policy protocol */ diff --git a/data/web/inc/lib/mailcow/MailPolicy/Repository/LookupRepository.php b/data/web/inc/lib/mailcow/MailPolicy/Repository/LookupRepository.php index 042b80654..eb53e0a79 100644 --- a/data/web/inc/lib/mailcow/MailPolicy/Repository/LookupRepository.php +++ b/data/web/inc/lib/mailcow/MailPolicy/Repository/LookupRepository.php @@ -8,8 +8,8 @@ use Mailcow\Core\Repository; use Mailcow\MailPolicy\Model\Policy; /** - * Read-only lookups of the policy check: mailboxes, alias domains, groups and the active policies of a mailbox - * or group. Addresses are passed in lower case. + * Read-only lookups of the policy check: mailboxes, aliases, alias domains, groups and the active policies of a + * mailbox or group. Addresses are passed in lower case. */ class LookupRepository extends Repository { @@ -29,13 +29,33 @@ class LookupRepository extends Repository return is_string($target) ? strtolower($target) : null; } - /** A domain or alias domain of this mailcow */ + /** A domain or alias domain Postfix delivers itself (as mysql_virtual_domains_maps.cf: active, no backup MX) */ public function isLocalDomain(string $domain): bool { - return (bool)$this->query("SELECT 1 FROM `domain` WHERE `domain` = :d1 UNION SELECT 1 FROM `alias_domain` WHERE `alias_domain` = :d2", + return (bool)$this->query("SELECT 1 FROM `alias_domain` WHERE `alias_domain` = :d1 AND `active` = '1' + UNION SELECT 1 FROM `domain` WHERE `domain` = :d2 AND `active` = '1' AND `backupmx` = '0'", ['d1' => $domain, 'd2' => $domain])->fetchColumn(); } + /** + * Stored spelling of a mailbox, alias or temporary alias address. MySQL compares without case and accents, so + * Postfix delivers mail for chéf@ to chef@. + */ + public function storedAddress(string $address): ?string + { + $stored = $this->query("SELECT `username` FROM `mailbox` WHERE `username` = :a1 UNION SELECT `address` FROM `alias` WHERE `address` = :a2 + UNION SELECT `address` FROM `spamalias` WHERE `address` = :a3 LIMIT 1", ['a1' => $address, 'a2' => $address, 'a3' => $address])->fetchColumn(); + return is_string($stored) ? strtolower($stored) : null; + } + + /** Stored spelling of a domain or alias domain, see storedAddress() */ + public function storedDomain(string $domain): ?string + { + $stored = $this->query("SELECT `domain` FROM `domain` WHERE `domain` = :d1 UNION SELECT `alias_domain` FROM `alias_domain` WHERE `alias_domain` = :d2 LIMIT 1", + ['d1' => $domain, 'd2' => $domain])->fetchColumn(); + return is_string($stored) ? strtolower($stored) : null; + } + /** Active group with this distribution address */ public function groupByAddress(string $address): ?int { @@ -49,29 +69,59 @@ class LookupRepository extends Repository return is_string($address) && $address !== '' ? strtolower($address) : null; } + /** Member of an active group */ public function isGroupMember(int $groupId, string $username): bool { - return (bool)$this->query("SELECT 1 FROM `mailbox_group_member` WHERE `group_id` = :id AND `username` = :username", + return (bool)$this->query("SELECT 1 FROM `mailbox_group_member` `gm` JOIN `mailbox_group` `g` ON `g`.`id` = `gm`.`group_id` + WHERE `gm`.`group_id` = :id AND `gm`.`username` = :username AND `g`.`active` = '1'", ['id' => $groupId, 'username' => $username])->fetchColumn(); } - /** Domain of an active alias that only accepts mail from its own domain (alias and group flag `internal`) */ - public function internalAlias(string $address): ?string + /** + * What Postfix' virtual alias maps find for this key, in their order: an alias (also the own alias of a mailbox + * and the alias of a group address) or a temporary alias. + * + * @return array{kind: string, domain?: string, internal?: bool, owner?: string}|null + */ + public function aliasTarget(string $address): ?array { - $domain = $this->query("SELECT `domain` FROM `alias` WHERE `address` = :address AND `internal` = '1' AND `active` = '1'", + $alias = $this->query("SELECT `domain`, `internal` FROM `alias` WHERE `address` = :address AND `active` IN ('1', '2')", + ['address' => $address])->fetch(); + if (is_array($alias)) { + return ['kind' => 'alias', 'domain' => strtolower((string)$alias['domain']), 'internal' => $alias['internal'] == '1']; + } + $owner = $this->query("SELECT `goto` FROM `spamalias` WHERE `address` = :address AND (`validity` >= UNIX_TIMESTAMP() OR `permanent` != 0)", ['address' => $address])->fetchColumn(); - return is_string($domain) ? strtolower($domain) : null; + return is_string($owner) ? ['kind' => 'temporary', 'owner' => strtolower($owner)] : null; } - /** @return Policy[] active policies of the mailbox: assigned to it or to an active group it is a member of */ + /** An active catch-all alias of the domain has the internal flag (applies to the whole domain) */ + public function internalCatchAll(string $domain): bool + { + return (bool)$this->query("SELECT 1 FROM `alias` WHERE `address` = :address AND `internal` = '1' AND `active` IN ('1', '2')", + ['address' => '@' . $domain])->fetchColumn(); + } + + /** An active policy with members restricts sending */ + public function hasSendRules(): bool + { + return (bool)$this->query("SELECT 1 FROM `mail_policy` `p` JOIN `mail_policy_member` `pm` ON `pm`.`policy_id` = `p`.`id` + WHERE `p`.`active` = '1' AND (`p`.`send_mode` != 'all' OR `p`.`send_block` != '[]') LIMIT 1")->fetchColumn(); + } + + /** + * @return Policy[] active policies of the mailbox: assigned to it or to an active group it is a member of; only + * policies of its own domain, so a group with members of other domains cannot restrict them + */ public function policiesForMailbox(string $username): array { return $this->policies("SELECT DISTINCT `p`.* FROM `mail_policy` `p` JOIN `mail_policy_member` `pm` ON `pm`.`policy_id` = `p`.`id` - WHERE `p`.`active` = '1' AND (`pm`.`username` = :u1 OR `pm`.`group_id` IN ( + JOIN `mailbox` `m` ON `m`.`username` = :u1 AND `m`.`domain` = `p`.`domain` + WHERE `p`.`active` = '1' AND (`pm`.`username` = :u2 OR `pm`.`group_id` IN ( SELECT `gm`.`group_id` FROM `mailbox_group_member` `gm` JOIN `mailbox_group` `g` ON `g`.`id` = `gm`.`group_id` - WHERE `gm`.`username` = :u2 AND `g`.`active` = '1')) - ORDER BY `p`.`id`", ['u1' => $username, 'u2' => $username]); + WHERE `gm`.`username` = :u3 AND `g`.`active` = '1')) + ORDER BY `p`.`id`", ['u1' => $username, 'u2' => $username, 'u3' => $username]); } /** @return Policy[] active policies assigned to the group */ diff --git a/data/web/inc/lib/mailcow/MailPolicy/Repository/PolicyRepository.php b/data/web/inc/lib/mailcow/MailPolicy/Repository/PolicyRepository.php index 15d99840c..508bea1da 100644 --- a/data/web/inc/lib/mailcow/MailPolicy/Repository/PolicyRepository.php +++ b/data/web/inc/lib/mailcow/MailPolicy/Repository/PolicyRepository.php @@ -78,6 +78,26 @@ class PolicyRepository extends Repository $this->query("DELETE FROM `mail_policy` WHERE `id` = :id", ['id' => $id]); } + /** Address entries of all lists follow a renamed mailbox or alias */ + public function renameAddress(string $old, string $new): void + { + $this->transaction(function () use ($old, $new): void { + $columns = ['send_allow', 'send_block', 'receive_allow', 'receive_block']; + foreach ($this->query("SELECT `id`, `" . implode('`, `', $columns) . "` FROM `mail_policy`")->fetchAll() as $row) { + $changed = []; + foreach ($columns as $column) { + $entries = json_decode((string)$row[$column], true); + if (is_array($entries) && in_array($old, $entries, true)) { + $changed[$column] = json_encode(array_values(array_unique(array_map(fn(string $entry): string => ($entry === $old) ? $new : $entry, $entries)))); + } + } + if ($changed !== []) { + $this->updateRow('mail_policy', (int)$row['id'], $changed); + } + } + }); + } + /** A primary domain (policies never belong to alias domains) */ public function isDomain(string $domain): bool { diff --git a/data/web/inc/lib/mailcow/MailPolicy/Service/PolicyEngine.php b/data/web/inc/lib/mailcow/MailPolicy/Service/PolicyEngine.php index 565cf491f..89faa03d1 100644 --- a/data/web/inc/lib/mailcow/MailPolicy/Service/PolicyEngine.php +++ b/data/web/inc/lib/mailcow/MailPolicy/Service/PolicyEngine.php @@ -12,14 +12,23 @@ use Mailcow\MailPolicy\Repository\LookupRepository; * Policy check of one recipient, asked by Postfix at RCPT TO (data/conf/rspamd/dynmaps/mailpolicy.php). * * Send part: the policies of the logged-in mailbox (assigned to it or to its groups) and of the mailbox the - * sender address belongs to. Receive part: the policies of the addressed mailbox (+tag and alias domains - * resolved), of the addressed group and the `internal` flag of the addressed alias or group. Mail sent to an - * alias or group reaches its members without their own policies. Every policy must allow the mail. + * sender address belongs to. Receive part: what Postfix delivers the recipient to, looked up in the order of its + * virtual alias maps (address, address without +tag, mailbox of an alias domain): the policies of the mailbox, of + * the group address or of the owner of a temporary alias, and the `internal` flag of the alias (a catch-all alias + * with the flag covers its whole domain). Mail sent to an alias or group reaches its members without their own + * policies. Every policy must allow the mail. * * Internal: a recipient in the domain of the policy or one of its alias domains; a sender logged in with a * mailbox of that domain. Entries of a local domain or address count for logged-in senders only, so they cannot - * be forged; external entries are compared with the envelope sender. Dovecot submits Sieve redirects and - * vacation replies of a mailbox without login on port 588: they count as sent by that mailbox. + * be forged; block entries also match the envelope sender. External entries are compared with the envelope sender. + * Addresses are compared the way Postfix finds them: lower case, without trailing dot, in their stored spelling + * (MySQL ignores accents). Recipients routed through localhost (user%domain@localhost, domain!user@localhost, + * user@domain@localhost) are refused, Postfix would deliver them to the routed address. + * + * Dovecot submits Sieve redirects, vacation replies and notifications on port 588 without login, SOGo sends + * reminders with a service login: mail with the address of a mailbox counts as sent by that mailbox. Other mail of + * these services (bounces redirected with the null sender, Sieve rejects) has no known sender: it is checked like + * mail from outside and does not leave the mailcow while any policy restricts sending. Quota warnings pass. */ class PolicyEngine { @@ -27,27 +36,49 @@ class PolicyEngine private const INTERNAL_PORTS = [590, 591]; /** Submission of SOGo and Dovecot, without login reachable from mailcow's own network only */ private const SERVICE_PORT = 588; + /** Domain of the Dovecot master login of mailcow's services (SOGo reminders) */ + private const SERVICE_LOGIN_DOMAIN = 'mailcow.local'; + /** HELO of the quota warning copies of Dovecot (quota_notify.py), configured by admins */ + private const QUOTA_HELO = 'quotanotification'; + /** Recipient without domain every server accepts (RFC 5321) */ + private const POSTMASTER = 'postmaster'; + /** Postfix delivers these destinations itself (mydestination), user%domain and domain!user route from there */ + private const LOCAL_DESTINATIONS = ['localhost', 'localhost.localdomain']; + + /** Lookups of the current check */ + private array $cache = []; public function __construct(private LookupRepository $lookup) { } - /** $login: SASL username, '' if not logged in; $port: smtpd port of the request, 0 if unknown */ - public function check(string $sender, string $recipient, string $login, int $port = 0): Decision + /** $login: SASL username, '' if not logged in; $port: smtpd port of the request, 0 if unknown; $helo: HELO name */ + public function check(string $sender, string $recipient, string $login, int $port = 0, string $helo = ''): Decision { - $sender = self::address($sender); + $this->cache = []; $recipient = self::address($recipient); - $login = self::address($login); - if (in_array($port, self::INTERNAL_PORTS, true) || $recipient === '') { + if (in_array($port, self::INTERNAL_PORTS, true) || $recipient === '' || $recipient === self::POSTMASTER) { return Decision::allow(); } - $account = ($login !== '') ? $this->lookup->mailbox($login) : null; - if ($login === '' && $port === self::SERVICE_PORT) { - // Sieve redirects and vacation replies use the address of the mailbox, other mail of mailcow passes - $account = $this->lookup->mailbox($this->resolve(self::untagged($sender))); - if ($account === null) { + if (self::isRouted($recipient)) { + return Decision::routedAddress(); + } + $recipient = $this->stored($recipient); + $sender = $this->stored(self::address($sender)); + $login = self::address($login); + $serviceLogin = self::domainOf($login) === self::SERVICE_LOGIN_DOMAIN; + $account = null; + if ($login !== '' && !$serviceLogin) { + $account = $this->cached('mailbox', $login); + } elseif ($serviceLogin || $port === self::SERVICE_PORT) { + if ($port === self::SERVICE_PORT && !$serviceLogin && strtolower($helo) === self::QUOTA_HELO) { return Decision::allow(); } + // without a mailbox sender the sending mailbox is unknown + $account = $this->senderMailbox($sender); + if ($account === null && !$this->cached('isLocalDomain', self::domainOf($recipient)) && $this->lookup->hasSendRules()) { + return Decision::deny('send'); + } } foreach ($this->sendingPolicies($account, $sender) as $policy) { if (!$this->allows($policy->sendMode, $policy->sendAllow, $policy->sendBlock, $this->isInternalRecipient($recipient, $policy->domain), @@ -58,18 +89,18 @@ class PolicyEngine foreach ($this->receivingPolicies($recipient) as $policy) { if (!$this->allows($policy->receiveMode, $policy->receiveAllow, $policy->receiveBlock, $account !== null && $account['domain'] === $policy->domain, - fn(string $entry): bool => $this->senderMatches($entry, $sender, $account))) { + fn(string $entry, bool $block): bool => $this->senderMatches($entry, $sender, $account, $block))) { return Decision::deny('receive', $policy); } } return Decision::allow(); } - /** Blocked entries first; then the mode decides */ + /** Blocked entries first; then the mode decides. $matches($entry, $block) */ private function allows(string $mode, array $allow, array $block, bool $internal, callable $matches): bool { foreach ($block as $entry) { - if ($matches($entry)) { + if ($matches($entry, true)) { return false; } } @@ -83,13 +114,19 @@ class PolicyEngine private function any(array $entries, callable $matches): bool { foreach ($entries as $entry) { - if ($matches($entry)) { + if ($matches($entry, false)) { return true; } } return false; } + /** @return array{username: string, domain: string}|null mailbox of the sender address */ + private function senderMailbox(string $sender): ?array + { + return str_contains($sender, '@') ? $this->cached('mailbox', $this->resolve(self::untagged($sender))) : null; + } + /** @return Policy[] of the logged-in mailbox and of the mailbox it sends as */ private function sendingPolicies(?array $account, string $sender): array { @@ -97,36 +134,65 @@ class PolicyEngine return []; } $policies = $this->lookup->policiesForMailbox($account['username']); - $sentAs = $this->lookup->mailbox($this->resolve(self::untagged($sender))); + $sentAs = $this->senderMailbox($sender); if ($sentAs !== null && $sentAs['username'] !== $account['username']) { $policies = array_merge($policies, $this->lookup->policiesForMailbox($sentAs['username'])); } return self::unique($policies); } - /** @return Policy[] of the addressed mailbox, group and internal alias */ + /** @return Policy[] of what Postfix delivers the recipient to, see the class comment */ private function receivingPolicies(string $recipient): array { - $address = $this->resolve(self::untagged($recipient)); - $policies = []; - if ($this->lookup->mailbox($address) !== null) { - $policies = $this->lookup->policiesForMailbox($address); + // the address, then without +tag; an alias domain maps to the mailbox in its target domain and Postfix + // looks up the result again with the +tag + [$address, $target] = $this->aliasTarget([$recipient, self::untagged($recipient)]); + $resolved = $this->resolve($recipient); + if ($target === null && $resolved !== $recipient) { + [$address, $target] = $this->aliasTarget([$resolved, self::untagged($resolved)]); } + $address ??= self::untagged($resolved); + if (($target['kind'] ?? '') === 'temporary') { + $address = $target['owner']; + } + $policies = ($this->cached('mailbox', $address) !== null) ? $this->lookup->policiesForMailbox($address) : []; $group = $this->lookup->groupByAddress($address); if ($group !== null) { $policies = array_merge($policies, $this->lookup->policiesForGroup($group)); } - $aliasDomain = $this->lookup->internalAlias($address); - if ($aliasDomain !== null) { - // the `internal` flag of an alias or group: only senders of its domain - $internal = new Policy(); - $internal->domain = $aliasDomain; - $internal->receiveMode = 'internal'; - $policies[] = $internal; + // the `internal` flag of an alias or group: only senders of its domain + if (!empty($target['internal'])) { + $policies[] = $this->internalRule($target['domain']); + } + foreach (array_unique([self::domainOf($recipient), $this->resolveDomain(self::domainOf($recipient))]) as $domain) { + if ($this->lookup->internalCatchAll($domain)) { + $policies[] = $this->internalRule($domain); + } } return self::unique($policies); } + /** @return array{0: ?string, 1: ?array} the first key Postfix' virtual alias maps find, with what they find */ + private function aliasTarget(array $keys): array + { + foreach (array_unique($keys) as $key) { + $target = $this->lookup->aliasTarget($key); + if ($target !== null) { + return [$key, $target]; + } + } + return [null, null]; + } + + /** Receive rule of the `internal` flag */ + private function internalRule(string $domain): Policy + { + $policy = new Policy(); + $policy->domain = $this->resolveDomain($domain); + $policy->receiveMode = 'internal'; + return $policy; + } + private function isInternalRecipient(string $recipient, string $domain): bool { return $this->resolveDomain(self::domainOf($recipient)) === $domain; @@ -137,8 +203,8 @@ class PolicyEngine $address = $this->resolve(self::untagged($recipient)); if (str_starts_with($entry, 'group:')) { $id = (int)substr($entry, 6); - return $this->lookup->groupAddress($id) === $address - || ($this->lookup->mailbox($address) !== null && $this->lookup->isGroupMember($id, $address)); + return $this->cached('groupAddress', $id) === $address + || ($this->cached('mailbox', $address) !== null && $this->cached('isGroupMember', $id, $address)); } if (str_contains($entry, '@')) { return $this->resolve($entry) === $address; @@ -146,35 +212,69 @@ class PolicyEngine return $this->resolveDomain(self::domainOf($address)) === $this->resolveDomain($entry); } - private function senderMatches(string $entry, string $sender, ?array $account): bool + /** $block: a block entry, which also matches the envelope sender (refusing a forged sender is no risk) */ + private function senderMatches(string $entry, string $sender, ?array $account, bool $block): bool { $address = $this->resolve(self::untagged($sender)); + // the sender address of a local entry only counts when logged in: Postfix checked that the login may use it + $useAddress = $account !== null || $block; if (str_starts_with($entry, 'group:')) { $id = (int)substr($entry, 6); - return $account !== null && ($this->lookup->isGroupMember($id, $account['username']) || $this->lookup->groupAddress($id) === $address); + return ($account !== null && $this->cached('isGroupMember', $id, $account['username'])) + || ($useAddress && ($this->cached('groupAddress', $id) === $address + || ($this->cached('mailbox', $address) !== null && $this->cached('isGroupMember', $id, $address)))); } if (str_contains($entry, '@')) { - // a local address only for a logged-in sender (Postfix checked that the login may use it) - return $this->lookup->isLocalDomain(self::domainOf($entry)) - ? $account !== null && ($account['username'] === $this->resolve($entry) || $address === $this->resolve($entry)) + return $this->cached('isLocalDomain', self::domainOf($entry)) + ? ($account !== null && $account['username'] === $this->resolve($entry)) || ($useAddress && $address === $this->resolve($entry)) : self::untagged($sender) === $entry; } - return $this->lookup->isLocalDomain($entry) - ? $account !== null && ($account['domain'] === $this->resolveDomain($entry) || $this->resolveDomain(self::domainOf($address)) === $this->resolveDomain($entry)) + return $this->cached('isLocalDomain', $entry) + ? ($account !== null && $account['domain'] === $this->resolveDomain($entry)) + || ($useAddress && $this->resolveDomain(self::domainOf($address)) === $this->resolveDomain($entry)) : self::domainOf($sender) === $entry; } + /** + * Stored spelling of an address of this mailcow: Postfix finds chéf@ as chef@ (MySQL ignores accents). Only + * addresses with non-ASCII characters can differ, as they are already in lower case. + */ + private function stored(string $address): string + { + if (!preg_match('/[\x80-\xff]/', $address) || !str_contains($address, '@')) { + return $address; + } + foreach (array_unique([$address, self::untagged($address)]) as $key) { + $stored = $this->cached('storedAddress', $key); + if ($stored !== null) { + return $stored; + } + } + $domain = $this->cached('storedDomain', self::domainOf($address)); + return ($domain === null) ? $address : substr($address, 0, strrpos($address, '@') + 1) . $domain; + } + /** Address in an alias domain as the address in its target domain */ private function resolve(string $address): string { $domain = self::domainOf($address); - $target = ($domain !== '') ? $this->lookup->aliasDomainTarget($domain) : null; + $target = ($domain !== '') ? $this->cached('aliasDomainTarget', $domain) : null; return ($target === null) ? $address : substr($address, 0, strrpos($address, '@') + 1) . $target; } private function resolveDomain(string $domain): string { - return ($domain === '') ? '' : ($this->lookup->aliasDomainTarget($domain) ?? $domain); + return ($domain === '') ? '' : ($this->cached('aliasDomainTarget', $domain) ?? $domain); + } + + /** A lookup, done once per check: long lists ask the same domains and groups again */ + private function cached(string $method, int|string ...$args): mixed + { + $key = $method . "\0" . implode("\0", $args); + if (!array_key_exists($key, $this->cache)) { + $this->cache[$key] = $this->lookup->$method(...$args); + } + return $this->cache[$key]; } /** @param Policy[] $policies @return Policy[] without repeated policies */ @@ -182,16 +282,32 @@ class PolicyEngine { $unique = []; foreach ($policies as $policy) { - $unique[$policy->id ?? spl_object_id($policy)] = $policy; + $unique[$policy->id ?? 'internal:' . $policy->domain] = $policy; } return array_values($unique); } - /** Lower case address without angle brackets; '' unless local@domain */ + /** Lower case, without angle brackets, quotes and trailing dot of the domain, as Postfix resolves it; '' = null sender */ private static function address(string $value): string { $value = strtolower(trim($value, " \t\r\n<>")); - return preg_match('/^[^@\s,<>]+@[^@\s,<>]+$/', $value) ? $value : ''; + $at = strrpos($value, '@'); + return ($at === false) ? $value : str_replace('"', '', substr($value, 0, $at)) . '@' . rtrim(substr($value, $at + 1), '.'); + } + + /** + * Postfix would deliver the recipient elsewhere: no domain, or routing through a local destination + * (localhost, the IP of the server) with %, ! or a second @ in the local part + */ + private static function isRouted(string $address): bool + { + $at = strrpos($address, '@'); + if ($at === false || $at === 0 || $at === strlen($address) - 1) { + return true; + } + $domain = substr($address, $at + 1); + return (in_array($domain, self::LOCAL_DESTINATIONS, true) || str_starts_with($domain, '[')) + && strpbrk(substr($address, 0, $at), '@%!') !== false; } private static function untagged(string $address): string diff --git a/data/web/inc/lib/mailcow/MailPolicy/Service/PolicyService.php b/data/web/inc/lib/mailcow/MailPolicy/Service/PolicyService.php index 0516451aa..2379edba8 100644 --- a/data/web/inc/lib/mailcow/MailPolicy/Service/PolicyService.php +++ b/data/web/inc/lib/mailcow/MailPolicy/Service/PolicyService.php @@ -8,6 +8,7 @@ use Mailcow\Core\AccessDenied; use Mailcow\Core\Context; use Mailcow\Core\Input; use Mailcow\Core\UserError; +use Mailcow\MailPolicy\Model\Decision; use Mailcow\MailPolicy\Model\Policy; use Mailcow\MailPolicy\Repository\PolicyRepository; @@ -17,7 +18,8 @@ use Mailcow\MailPolicy\Repository\PolicyRepository; * Read: admins all policies (also read-only API keys); domain admins with the `mail_policies` ACL the policies * of their domains. * Edit: admins; domain admins with the ACL for policies of their domains. A policy is assigned to mailboxes and - * groups of its own domain; `group:` entries name groups the editor may access. + * groups of its own domain; `group:` entries name groups of that domain. Groups need the `mailbox_groups` + * ACL as well: without it a domain admin sees no groups and keeps the group entries and assignments. */ class PolicyService { @@ -94,10 +96,21 @@ class PolicyService return $policy; } + /** Address entries of the lists follow a renamed mailbox or alias; called after the rename, without permission check */ + public function addressRenamed(string $old, string $new): void + { + $this->repo->renameAddress(strtolower($old), strtolower($new)); + } + /** @return array[] id, name, domain, address of the groups in these domains (choices of the forms) */ public function groupChoices(array $domains): array { - return $this->canRead() ? $this->repo->groupsIn($domains) : []; + return ($this->canRead() && $this->canUseGroups()) ? $this->repo->groupsIn($domains) : []; + } + + private function canUseGroups(): bool + { + return $this->context->hasAdminRights() || $this->context->hasAcl('mailbox_groups'); } /** @@ -116,21 +129,23 @@ class PolicyService if (($sender !== '' && !filter_var($sender, FILTER_VALIDATE_EMAIL)) || !filter_var($recipient, FILTER_VALIDATE_EMAIL)) { throw new UserError('mail_policy_test_invalid'); } - // domain admins test mail of their domains - if (!$this->context->hasAdminRights() && !$this->context->canAccessDomain(self::domainOf($sender)) - && !$this->context->canAccessDomain(self::domainOf($recipient))) { + // domain admins test mail of their domains; a sender of another domain counts as not logged in + $ownSender = $this->context->hasAdminRights() || $this->context->canAccessDomain(self::domainOf($sender)); + if (!$ownSender && !$this->context->canAccessDomain(self::domainOf($recipient))) { throw new AccessDenied(); } - $login = ($loggedIn && $this->repo->mailboxDomain($sender) !== null) ? $sender : ''; + $login = ($loggedIn && $ownSender && $this->repo->mailboxDomain($sender) !== null) ? $sender : ''; $decision = $this->engine->check($sender, $recipient, $login); $policy = $decision->policy; + $hidden = $policy !== null && $policy->id !== null && !$this->visible($policy); return [ 'allowed' => (int)$decision->allowed, 'side' => $decision->side, - 'policy' => ($policy !== null && $policy->id !== null && $this->visible($policy)) ? $policy->name : null, + 'policy' => ($policy !== null && $policy->id !== null && !$hidden) ? $policy->name : null, 'internal_alias' => (int)($policy !== null && $policy->id === null), 'logged_in' => (int)($login !== ''), - 'text' => $decision->allowed ? '' : $decision->text(), + // the text of a policy of another domain stays hidden + 'text' => $decision->allowed ? '' : ($hidden ? Decision::defaultText($decision->side) : $decision->text()), ]; } @@ -189,17 +204,26 @@ class PolicyService $policy->$property = in_array($mode, Policy::MODES, true) ? $mode : throw new UserError('mail_policy_mode_invalid'); } } + $groupAccess = $this->canUseGroups(); foreach (self::LISTS as $key => $property) { // the UI sends the groups of a list separately (_groups); the API may name them as group: - if (array_key_exists($key, $input) || array_key_exists($key . '_groups', $input)) { - $entries = array_key_exists($key, $input) ? self::values($input[$key]) - : array_filter($policy->$property, fn(string $entry): bool => !str_starts_with($entry, 'group:')); - if (array_key_exists($key . '_groups', $input)) { - $entries = array_filter($entries, fn(string $entry): bool => !str_starts_with(strtolower($entry), 'group:')); - $entries = array_merge($entries, array_map(fn(string $id): string => 'group:' . $id, self::values($input[$key . '_groups']))); - } - $policy->$property = $this->entries(array_values($entries)); + if (!array_key_exists($key, $input) && !array_key_exists($key . '_groups', $input)) { + continue; } + $entries = array_key_exists($key, $input) ? self::values($input[$key]) : self::withoutGroups($policy->$property); + $kept = []; + if (!$groupAccess) { + // without access to groups the group entries stay (also when sent back unchanged); the form shows no group choices + $kept = array_values(array_diff($policy->$property, self::withoutGroups($policy->$property))); + $entries = array_values(array_diff(array_map('strtolower', $entries), $kept)); + } elseif (array_key_exists($key . '_groups', $input)) { + $entries = array_merge(self::withoutGroups($entries), array_map(fn(string $id): string => 'group:' . $id, self::values($input[$key . '_groups']))); + } + $entries = array_values(array_unique(array_merge($this->entries($entries, $policy->domain, $groupAccess), $kept))); + if (count($entries) > self::MAX_ENTRIES) { + throw new UserError('mail_policy_entries_exceeded', [self::MAX_ENTRIES]); + } + $policy->$property = $entries; } if (array_key_exists('reject_text', $input)) { $text = Input::text($input['reject_text']); @@ -213,7 +237,7 @@ class PolicyService } if (array_key_exists('mailboxes', $input)) { $policy->mailboxes = []; - foreach (self::values($input['mailboxes']) as $username) { + foreach (self::limited($input['mailboxes']) as $username) { $username = strtolower($username); if ($this->repo->mailboxDomain($username) !== $policy->domain) { throw new UserError('mail_policy_member_invalid', [htmlspecialchars($username)]); @@ -222,9 +246,19 @@ class PolicyService } $policy->mailboxes = array_values($policy->mailboxes); } - if (array_key_exists('groups', $input)) { + // without access to groups the assignments stay; only sending them back unchanged is accepted + if (array_key_exists('groups', $input) && !$groupAccess && self::values($input['groups']) !== []) { + $sent = array_map('intval', self::values($input['groups'])); + $stored = $policy->groups; + sort($sent); + sort($stored); + if (array_values(array_unique($sent)) !== $stored) { + throw new AccessDenied(); + } + } + if (array_key_exists('groups', $input) && $groupAccess) { $policy->groups = []; - foreach (self::values($input['groups']) as $value) { + foreach (self::limited($input['groups']) as $value) { $id = Input::intInRange($value, 1, PHP_INT_MAX); if ($id === null || $this->repo->groupDomain($id) !== $policy->domain) { throw new UserError('mail_policy_member_invalid', [htmlspecialchars($value)]); @@ -236,20 +270,20 @@ class PolicyService } /** - * Entries of a list: addresses, domains (a leading @ is dropped) and group:, lower case, without repeats. + * Entries of a list: addresses, domains (a leading @ is dropped) and group: of a group of the policy's + * domain, lower case, without repeats. * + * @param string[] $values * @return string[] * @throws UserError */ - private function entries(mixed $value): array + private function entries(array $values, string $domain, bool $groupAccess): array { $entries = []; - foreach (self::values($value) as $entry) { - $entry = strtolower($entry); + foreach (self::limited(array_map('strtolower', $values)) as $entry) { if (str_starts_with($entry, 'group:')) { $id = Input::intInRange(substr($entry, 6), 1, PHP_INT_MAX); - $domain = ($id === null) ? null : $this->repo->groupDomain($id); - if ($domain === null || (!$this->context->hasAdminRights() && !$this->context->canAccessDomain($domain))) { + if (!$groupAccess || $id === null || $this->repo->groupDomain($id) !== $domain) { throw new UserError('mail_policy_entry_invalid', [htmlspecialchars($entry)]); } $entry = 'group:' . $id; @@ -265,10 +299,28 @@ class PolicyService } $entries[$entry] = $entry; } - if (count($entries) > self::MAX_ENTRIES) { + return array_values($entries); + } + + /** + * Values of a list field without repeats, at most MAX_ENTRIES (checked before anything is looked up) + * + * @return string[] + * @throws UserError + */ + private static function limited(mixed $value): array + { + $values = array_values(array_unique(self::values($value))); + if (count($values) > self::MAX_ENTRIES) { throw new UserError('mail_policy_entries_exceeded', [self::MAX_ENTRIES]); } - return array_values($entries); + return $values; + } + + /** @return string[] entries without group: */ + private static function withoutGroups(array $entries): array + { + return array_values(array_filter($entries, fn(string $entry): bool => !str_starts_with(strtolower($entry), 'group:'))); } /** diff --git a/data/web/js/site/mail-policy.js b/data/web/js/site/mail-policy.js index 8b728e2a6..c33df3ef2 100644 --- a/data/web/js/site/mail-policy.js +++ b/data/web/js/site/mail-policy.js @@ -130,7 +130,7 @@ var mailcowMailPolicy = (function($) { } else if (r.allowed == 1) { html = '
' + esc(L('test_allowed')) + '
'; } else { - var reason = r.internal_alias == 1 ? L('test_denied_internal') : + var reason = r.internal_alias == 1 ? L('test_denied_internal') : r.side === 'address' ? L('test_denied_address') : (r.policy ? L('test_denied_' + r.side).replace('%s', r.policy) : L('test_denied_other')); html = '
' + esc(reason) + '
550 5.7.1 ' + esc(r.text) + '
'; } diff --git a/data/web/lang/lang.de-de.json b/data/web/lang/lang.de-de.json index 00ce59305..bbd09bd17 100644 --- a/data/web/lang/lang.de-de.json +++ b/data/web/lang/lang.de-de.json @@ -584,7 +584,7 @@ "last_key": "Letzter Key kann nicht gelöscht werden, bitte stattdessen die 2FA deaktivieren.", "login_failed": "Anmeldung fehlgeschlagen", "mail_policy_description_invalid": "Ungültige Beschreibung", - "mail_policy_entries_exceeded": "Eine Liste darf höchstens %s Einträge enthalten", + "mail_policy_entries_exceeded": "Höchstens %s Einträge pro Liste oder Auswahl möglich", "mail_policy_entry_invalid": "Ungültige Adresse, Domain oder Gruppe in der Liste: %s", "mail_policy_member_invalid": "Mailbox oder Gruppe %s gehört nicht zur Domain der Richtlinie", "mail_policy_mode_invalid": "Ungültiger Modus", @@ -1050,13 +1050,14 @@ "allow_receive": "Erlaubte Absender", "allow_send": "Erlaubte Empfänger", "assign": "Zugewiesen an", - "assign_info": "Die Richtlinie gilt für diese Mailboxen und für die Mitglieder dieser Gruppen, bei einer Gruppe auch für E-Mails an die Gruppenadresse. E-Mails an einen Alias oder eine andere Gruppe erreichen die Mitglieder ohne deren Richtlinien.", + "assign_info": "Die Richtlinie gilt für diese Mailboxen und für die Mitglieder dieser Gruppen aus der Domain der Richtlinie, bei einer Gruppe auch für E-Mails an die Gruppenadresse. E-Mails an einen Alias oder eine andere Gruppe erreichen die Mitglieder ohne deren Richtlinien.", "assigned_none": "Nicht zugewiesen", "block_receive": "Gesperrte Absender", "block_send": "Gesperrte Empfänger", "description": "Beschreibung", "domain": "Domain", - "entries_info": "Adressen oder Domains, eine pro Zeile, und Gruppen. Adressen und Domains dieser mailcow gelten nur für angemeldete Absender.", + "entries_info_receive": "Adressen oder Domains, eine pro Zeile, und Gruppen der Domain. Erlaubte Adressen und Domains dieser mailcow gelten nur für angemeldete Absender, gesperrte auch für die Absenderadresse im Umschlag. Externe Absender werden an der Absenderadresse im Umschlag erkannt.", + "entries_info_send": "Adressen oder Domains, eine pro Zeile, und Gruppen der Domain.", "groups": "Gruppen", "info": "Mail-Richtlinien legen fest, an wen Mailboxen und Gruppen senden und von wem sie empfangen dürfen, z. B. nur intern oder nur mit bestimmten Domains. Postfix prüft sie für jeden Empfänger und lehnt nicht erlaubte Empfänger mit einem 550-Fehler ab. Eingehende E-Mails erreichen die übrigen Empfänger trotzdem, SOGo sendet eine E-Mail mit einem abgelehnten Empfänger dagegen gar nicht.", "internal_info": "Intern: Empfänger in der Domain der Richtlinie und ihren Alias-Domains; Absender, die mit einer Mailbox dieser Domain angemeldet sind.", @@ -1085,6 +1086,7 @@ "summary_list": "Nur erlaubte", "test": "Regel testen", "test_allowed": "Angenommen: Keine Richtlinie lehnt diese E-Mail ab.", + "test_denied_address": "Abgelehnt: Diese Schreibweise leitet über eine andere Adresse um und ist nicht erlaubt.", "test_denied_internal": "Abgelehnt: Die Adresse nimmt nur E-Mails von angemeldeten Absendern ihrer Domain an (interner Alias oder interne Gruppe).", "test_denied_other": "Abgelehnt durch eine Richtlinie einer anderen Domain.", "test_denied_receive": "Abgelehnt durch den Empfangen-Teil der Richtlinie „%s“.", @@ -1092,7 +1094,7 @@ "test_info": "Prüft, ob Postfix die E-Mail für diesen Empfänger annehmen würde. Für Bounces den Absender leer lassen.", "test_invalid": "Geben Sie einen gültigen Empfänger (und Absender) Ihrer Domains ein.", "test_logged_in": "Absender ist angemeldet", - "test_not_a_mailbox": "Der Absender ist keine Mailbox dieser mailcow und wurde als nicht angemeldet geprüft.", + "test_not_a_mailbox": "Der Absender ist keine Mailbox Ihrer Domains und wurde als nicht angemeldet geprüft.", "test_recipient": "Empfänger", "test_run": "Testen", "test_sender": "Absender", diff --git a/data/web/lang/lang.en-gb.json b/data/web/lang/lang.en-gb.json index 7ce2df008..8cd0373bb 100644 --- a/data/web/lang/lang.en-gb.json +++ b/data/web/lang/lang.en-gb.json @@ -520,7 +520,7 @@ "iam_sync_mode_invalid": "Invalid sync mode", "iam_sync_unavailable": "The identity provider cannot be synced (Keycloak and LDAP only)", "mail_policy_description_invalid": "Invalid description", - "mail_policy_entries_exceeded": "A list may contain at most %s entries", + "mail_policy_entries_exceeded": "At most %s entries per list or selection are possible", "mail_policy_entry_invalid": "Invalid address, domain or group in the list: %s", "mail_policy_member_invalid": "Mailbox or group %s does not belong to the domain of the policy", "mail_policy_mode_invalid": "Invalid mode", @@ -1050,13 +1050,14 @@ "allow_receive": "Allowed senders", "allow_send": "Allowed recipients", "assign": "Assigned to", - "assign_info": "The policy applies to these mailboxes and to the members of these groups; for a group also to mail sent to the group address. Mail sent to an alias or another group reaches the members without their policies.", + "assign_info": "The policy applies to these mailboxes and to the members of these groups in the policy's domain; for a group also to mail sent to the group address. Mail sent to an alias or another group reaches the members without their policies.", "assigned_none": "Not assigned", "block_receive": "Blocked senders", "block_send": "Blocked recipients", "description": "Description", "domain": "Domain", - "entries_info": "Addresses or domains, one per line, and groups. Addresses and domains of this mailcow count for logged-in senders only.", + "entries_info_receive": "Addresses or domains, one per line, and groups of the domain. Allowed addresses and domains of this mailcow count for logged-in senders only, blocked ones also for the envelope sender address. External senders are recognised by the envelope sender address.", + "entries_info_send": "Addresses or domains, one per line, and groups of the domain.", "groups": "Groups", "info": "Mail policies define whom mailboxes and groups may send to and receive from, e.g. only internally or only with certain domains. Postfix checks them for every recipient and refuses recipients that are not allowed with a 550 error. Incoming mail still reaches the other recipients, whereas SOGo does not send a mail with a refused recipient at all.", "internal_info": "Internal: recipients in the domain of the policy and its alias domains; senders logged in with a mailbox of this domain.", @@ -1085,6 +1086,7 @@ "summary_list": "Allowed only", "test": "Test a rule", "test_allowed": "Accepted: no policy refuses this mail.", + "test_denied_address": "Refused: this form of the address routes through another address and is not allowed.", "test_denied_internal": "Refused: the address only accepts mail from logged-in senders of its domain (internal alias or group).", "test_denied_other": "Refused by a policy of another domain.", "test_denied_receive": "Refused by the receiving part of the policy \"%s\".", @@ -1092,7 +1094,7 @@ "test_info": "Checks whether Postfix would accept the mail for this recipient. Leave the sender empty for bounces.", "test_invalid": "Enter a valid recipient (and sender) of your domains.", "test_logged_in": "Sender is logged in", - "test_not_a_mailbox": "The sender is no mailbox of this mailcow and was tested as not logged in.", + "test_not_a_mailbox": "The sender is no mailbox of your domains and was tested as not logged in.", "test_recipient": "Recipient", "test_run": "Test", "test_sender": "Sender", diff --git a/data/web/templates/edit/mail-policy-fields.twig b/data/web/templates/edit/mail-policy-fields.twig index 2d45b9c69..58aabc3bc 100644 --- a/data/web/templates/edit/mail-policy-fields.twig +++ b/data/web/templates/edit/mail-policy-fields.twig @@ -1,5 +1,6 @@ {# Fields of a mail policy (add modal, edit page): policy, id (prefix of the element ids), groups (choices: id, name, - domain, address), mailboxes (choices), ro, label_class. Groups of a list are sent apart as _groups. #} + domain, address), mailboxes (choices), ro, label_class. Groups of a list are sent apart as _groups; without + group choices (none in the domain, no access to groups) the group fields are left out and stay unchanged. #} {% set entries = {} %} {% set listed = {} %} {% for key in ['send_allow', 'send_block', 'receive_allow', 'receive_block'] %} @@ -35,13 +36,15 @@
- {{ lang.mail_policy.entries_info }} + {{ attribute(lang.mail_policy, 'entries_info_' ~ side) }} + {% if groups %} {% if not ro %}{% endif %} + {% endif %}
{% endfor %} @@ -66,6 +69,7 @@ +{% if groups %}
@@ -77,3 +81,4 @@
+{% endif %} diff --git a/docker-compose.yml b/docker-compose.yml index 843884655..183bcbe42 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,5 @@ # Version of the database schema -x-db-version: &db_version DB_VERSION=07112026_1200 +x-db-version: &db_version DB_VERSION=07112026_1800 services: diff --git a/tests/php/MailPolicy/Service/PolicyEngineTest.php b/tests/php/MailPolicy/Service/PolicyEngineTest.php index 6944c598d..57b77f4bd 100644 --- a/tests/php/MailPolicy/Service/PolicyEngineTest.php +++ b/tests/php/MailPolicy/Service/PolicyEngineTest.php @@ -46,9 +46,9 @@ class PolicyEngineTest extends TestCase } /** [allowed, side] of a check */ - private function check(string $sender, string $recipient, string $login = '', int $port = 587): array + private function check(string $sender, string $recipient, string $login = '', int $port = 587, string $helo = ''): array { - $decision = $this->engine->check($sender, $recipient, $login, $port); + $decision = $this->engine->check($sender, $recipient, $login, $port, $helo); return [$decision->allowed, $decision->side]; } @@ -139,7 +139,7 @@ class PolicyEngineTest extends TestCase public function testInternalFlagOfAliasesAndGroups(): void { - $this->lookup->internalAliases['team@firma.de'] = 'firma.de'; + $this->lookup->aliases['team@firma.de'] = ['domain' => 'firma.de', 'internal' => true]; $this->assertSame([false, 'receive'], $this->check('x@extern.example', 'team@firma.de', '', 25)); // the old rspamd rule let forged senders of the domain pass @@ -174,15 +174,123 @@ class PolicyEngineTest extends TestCase { $this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(12, ['sendMode' => 'internal'])]; $this->lookup->mailboxPolicies['chef@firma.de'] = [self::policy(13, ['receiveMode' => 'internal'])]; - $this->lookup->internalAliases['team@firma.de'] = 'firma.de'; + $this->lookup->aliases['team@firma.de'] = ['domain' => 'firma.de', 'internal' => true]; // a Sieve redirect of azubi to an external address follows its send rules $this->assertSame([false, 'send'], $this->check('azubi+x@firma.de', 'x@extern.example', '', 588)); $this->assertSame([true, ''], $this->check('azubi@firma.de', 'chef@firma.de', '', 588)); $this->assertSame([true, ''], $this->check('kollege@firma.de', 'team@firma.de', '', 588)); - // notifications of mailcow (e.g. quota warnings) are no mailbox - $this->assertSame([true, ''], $this->check('quota-warning@localhost', 'chef@firma.de', '', 588)); + // other senders are checked like mail from outside + $this->assertSame([false, 'receive'], $this->check('quota-warning@localhost', 'chef@firma.de', '', 588)); + // the quota warning copies of Dovecot carry the address of the mailbox over its quota + $this->assertSame([true, ''], $this->check('azubi@firma.de', 'x@extern.example', '', 588, 'quotanotification')); // on other ports the sender address is no login $this->assertSame([false, 'receive'], $this->check('kollege@firma.de', 'team@firma.de', '', 25)); } + + public function testDovecotMailWithoutMailboxSender(): void + { + // bounces redirected with the null sender, notifications of other senders: local recipients only while + // a policy restricts sending, as the sending mailbox is unknown + $this->assertSame([true, ''], $this->check('', 'x@extern.example', '', 588)); + $this->lookup->sendRules = true; + $this->assertSame([false, 'send'], $this->check('', 'x@extern.example', '', 588)); + $this->assertSame([false, 'send'], $this->check('irgendwer@nirgends.example', 'x@extern.example', '', 588)); + $this->assertSame([true, ''], $this->check('', 'kollege@firma.com', '', 588)); + // local recipients: receive rules as for mail from outside + $this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(14, ['sendMode' => 'internal', 'receiveMode' => 'internal'])]; + $this->assertSame([false, 'receive'], $this->check('', 'azubi@firma.de', '', 588)); + // SOGo reminders log in with the Dovecot master user + $this->assertSame([false, 'send'], $this->check('azubi@firma.de', 'x@extern.example', 'abc@mailcow.local', 588)); + $this->assertSame([false, 'send'], $this->check('', 'x@extern.example', 'abc@mailcow.local', 588)); + } + + public function testRecipientsAreComparedAsPostfixDeliversThem(): void + { + $this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(15, ['receiveMode' => 'internal'])]; + + // trailing dot and quotes are dropped by Postfix + $this->assertSame([false, 'receive'], $this->check('x@extern.example', 'Azubi@Firma.de.', '', 25)); + $this->assertSame([false, 'receive'], $this->check('x@extern.example', '"azubi+a b"@firma.com', '', 25)); + // routes through another address are refused, Postfix would deliver them to the routed address + foreach (['azubi@firma.de@localhost', 'azubi%firma.de@localhost', 'firma.de!azubi@localhost.localdomain', 'x@extern.example@localhost', + 'azubi%firma.de@[192.0.2.1]', 'abuse'] as $recipient) { + $this->assertSame([false, 'address'], $this->check('x@extern.example', $recipient, '', 25), $recipient); + } + // in other domains %, ! and @ in the local part are ordinary characters + $this->assertSame([true, ''], $this->check('kollege@firma.de', 'a!b@extern.example', 'kollege@firma.de')); + $this->assertSame([true, ''], $this->check('x@extern.example', 'sales%x@firma.de', '', 25)); + $this->assertSame([true, ''], $this->check('x@extern.example', 'postmaster', '', 25)); + $this->assertSame('550 5.7.1 This form of the recipient address is not allowed by the mail policy', + $this->engine->check('azubi@firma.de', 'x%extern.example@localhost', 'azubi@firma.de', 587)->action()); + } + + public function testSendersAreComparedAsPostfixResolvesThem(): void + { + $this->lookup->mailboxPolicies['kollege@firma.de'] = [self::policy(16, ['sendBlock' => ['konkurrenz.example']])]; + $this->lookup->mailboxPolicies['info@firma.de'] = [self::policy(17, ['sendMode' => 'internal'])]; + + $this->assertSame([false, 'send'], $this->check('kollege@firma.de', 'x@konkurrenz.example.', 'kollege@firma.de')); + // sending as info@ with a trailing dot keeps the policy of info@ + $this->assertSame([false, 'send'], $this->check('Info@firma.de.', 'x@extern.example', 'chef@firma.de')); + } + + public function testTemporaryAliasesFollowTheirOwner(): void + { + $this->lookup->temporaryAliases['einmal.xyz@firma.de'] = 'azubi@firma.de'; + $this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(18, ['receiveMode' => 'internal'])]; + + $this->assertSame([false, 'receive'], $this->check('x@extern.example', 'einmal.xyz@firma.de', '', 25)); + $this->assertSame([true, ''], $this->check('kollege@firma.de', 'einmal.xyz@firma.de', 'kollege@firma.de')); + } + + public function testInternalFlagFollowsThePostfixLookup(): void + { + // an alias of its own in an alias domain; senders of the target domain are internal + $this->lookup->aliases['team2@firma.com'] = ['domain' => 'firma.com', 'internal' => true]; + $this->assertSame([false, 'receive'], $this->check('x@extern.example', 'team2@firma.com', '', 25)); + $this->assertSame([true, ''], $this->check('kollege@firma.de', 'team2@firma.com', 'kollege@firma.de')); + // the full address first, then without +tag + $this->lookup->aliases['team+ext@firma.de'] = ['domain' => 'firma.de', 'internal' => true]; + $this->lookup->aliases['team@firma.de'] = ['domain' => 'firma.de', 'internal' => false]; + $this->assertSame([false, 'receive'], $this->check('x@extern.example', 'team+ext@firma.de', '', 25)); + $this->assertSame([true, ''], $this->check('x@extern.example', 'team+other@firma.de', '', 25)); + // an internal catch-all alias covers the whole domain, also through its alias domain + $this->lookup->internalCatchAlls = ['tochter.de']; + $this->lookup->aliasDomains['tochter.com'] = 'tochter.de'; + $this->assertSame([false, 'receive'], $this->check('x@extern.example', 'andere@tochter.de', '', 25)); + $this->assertSame([false, 'receive'], $this->check('x@extern.example', 'unbekannt@tochter.com', '', 25)); + $this->assertSame([true, ''], $this->check('andere@tochter.de', 'unbekannt@tochter.de', 'andere@tochter.de')); + } + + public function testAddressesAreComparedInTheirStoredSpelling(): void + { + // MySQL ignores accents: Postfix delivers chéf@ to chef@ and töchter.de to tochter.de + $this->lookup->spellings = ['chéf@firma.de' => 'chef@firma.de', 'töchter.de' => 'tochter.de', 'köllege@firma.de' => 'kollege@firma.de']; + $this->lookup->mailboxPolicies['kollege@firma.de'] = [self::policy(20, ['sendBlock' => ['chef@firma.de', 'tochter.de']])]; + $this->lookup->mailboxPolicies['chef@firma.de'] = [self::policy(21, ['receiveBlock' => ['kollege@firma.de']])]; + + $this->assertSame([false, 'send'], $this->check('kollege@firma.de', 'chéf@firma.de', 'kollege@firma.de')); + $this->assertSame([false, 'send'], $this->check('kollege@firma.de', 'x@töchter.de', 'kollege@firma.de')); + $this->assertSame([false, 'receive'], $this->check('köllege@firma.de', 'chef@firma.de', '', 25)); + } + + public function testTaggedAliasThroughAnAliasDomain(): void + { + // Postfix maps chef@firma.com to chef@firma.de and looks up chef+intern@firma.de again + $this->lookup->aliases['chef+intern@firma.de'] = ['domain' => 'firma.de', 'internal' => true]; + + $this->assertSame([false, 'receive'], $this->check('x@extern.example', 'chef+intern@firma.com', '', 25)); + $this->assertSame([true, ''], $this->check('x@extern.example', 'chef+other@firma.com', '', 25)); + } + + public function testBlockEntriesAlsoMatchTheEnvelopeSender(): void + { + $this->lookup->mailboxPolicies['chef@firma.de'] = [self::policy(19, ['receiveBlock' => ['kollege@firma.de', 'group:7', 'tochter.de']])]; + + $this->assertSame([false, 'receive'], $this->check('kollege@firma.de', 'chef@firma.de', '', 25)); + $this->assertSame([false, 'receive'], $this->check('azubi@firma.de', 'chef@firma.de', '', 25)); + $this->assertSame([false, 'receive'], $this->check('andere@tochter.de', 'chef@firma.de', '', 25)); + $this->assertSame([true, ''], $this->check('info@firma.de', 'chef@firma.de', '', 25)); + } } diff --git a/tests/php/MailPolicy/Service/PolicyServiceTest.php b/tests/php/MailPolicy/Service/PolicyServiceTest.php index e42fd1aa7..6dae83f67 100644 --- a/tests/php/MailPolicy/Service/PolicyServiceTest.php +++ b/tests/php/MailPolicy/Service/PolicyServiceTest.php @@ -23,6 +23,7 @@ class PolicyServiceTest extends TestCase private FakeMailPolicyLookup $lookup; /** @var Policy[] saved policies */ private array $saved = []; + private int $groupLookups = 0; protected function setUp(): void { @@ -30,7 +31,11 @@ class PolicyServiceTest extends TestCase $this->repo->method('isDomain')->willReturnCallback(fn(string $domain): bool => in_array($domain, ['firma.de', 'tochter.de'], true)); $this->repo->method('mailboxDomain')->willReturnCallback(fn(string $username): ?string => ['azubi@firma.de' => 'firma.de', 'chef@firma.de' => 'firma.de', 'andere@tochter.de' => 'tochter.de'][$username] ?? null); - $this->repo->method('groupDomain')->willReturnCallback(fn(int $id): ?string => [7 => 'firma.de', 9 => 'tochter.de'][$id] ?? null); + $this->repo->method('groupDomain')->willReturnCallback(function (int $id): ?string { + $this->groupLookups++; + return [7 => 'firma.de', 9 => 'tochter.de'][$id] ?? null; + }); + $this->repo->method('groupsIn')->willReturn([['id' => 7, 'name' => 'Azubis', 'domain' => 'firma.de', 'address' => null]]); $this->repo->method('save')->willReturnCallback(function (Policy $policy): int { $policy->id ??= 1; $this->saved[] = clone $policy; @@ -133,7 +138,7 @@ class PolicyServiceTest extends TestCase public function testPermissions(): void { $this->repo->method('find')->willReturn($this->stored()); - $da = FakeContext::domainAdmin([], ['firma.de'], ['mail_policies' => '1']); + $da = FakeContext::domainAdmin([], ['firma.de'], ['mail_policies' => '1', 'mailbox_groups' => '1']); $this->assertSame('Test', $this->service($da)->create(['domain' => 'firma.de', 'name' => 'Test'])->name); $this->assertNotNull($this->service($da)->get(3)); @@ -149,6 +154,58 @@ class PolicyServiceTest extends TestCase $this->expectUserError('access_denied', fn() => $this->service($readOnly)->delete(3)); } + public function testGroupsNeedTheGroupAcl(): void + { + $this->repo->method('find')->willReturn($this->stored(['groups' => [7]])); + $da = FakeContext::domainAdmin([], ['firma.de'], ['mail_policies' => '1']); + + $this->assertSame([], $this->service($da)->groupChoices(['firma.de'])); + // the form sends empty group fields: the group entries and assignments stay + $policy = $this->service($da)->update(3, ['send_allow' => 'kunde.example', 'send_allow_groups' => '', 'groups' => '']); + $this->assertSame([['kunde.example', 'group:7'], [7]], [$policy->sendAllow, $policy->groups]); + // the stored values sent back unchanged, as returned by the API + $policy = $this->service($da)->update(3, ['send_allow' => ['partner.de', 'group:7'], 'groups' => ['7']]); + $this->assertSame([['partner.de', 'group:7'], [7]], [$policy->sendAllow, $policy->groups]); + $this->expectUserError('access_denied', fn() => $this->service($da)->update(3, ['groups' => ['7', '8']])); + $this->expectUserError('access_denied', fn() => $this->service($da)->create(['domain' => 'firma.de', 'name' => 'Test', 'groups' => ['7']])); + $this->expectUserError(['mail_policy_entry_invalid', 'group:7'], + fn() => $this->service($da)->create(['domain' => 'firma.de', 'name' => 'Test', 'send_block' => 'group:7'])); + $this->assertCount(1, $this->service(FakeContext::domainAdmin([], ['firma.de'], ['mail_policies' => '1', 'mailbox_groups' => '1']))->groupChoices(['firma.de'])); + } + + public function testGroupEntriesBelongToThePolicyDomain(): void + { + // also for admins: the policy of firma.de cannot name a group of tochter.de + $this->expectUserError(['mail_policy_entry_invalid', 'group:9'], + fn() => $this->service()->create(['domain' => 'firma.de', 'name' => 'Test', 'receive_block' => 'group:9'])); + } + + public function testListsAreLimitedBeforeAnythingIsLookedUp(): void + { + $groups = array_map(fn(int $i): string => 'group:' . $i, range(1, 501)); + $this->expectUserError(['mail_policy_entries_exceeded', 500], fn() => $this->service()->create(['domain' => 'firma.de', 'name' => 'Test', 'send_block' => $groups])); + $this->assertSame(0, $this->groupLookups); + // repeats count once + $this->assertSame(['group:7'], $this->service()->create(['domain' => 'firma.de', 'name' => 'Test', 'send_block' => array_fill(0, 600, 'group:7')])->sendBlock); + $this->expectUserError(['mail_policy_entries_exceeded', 500], + fn() => $this->service()->create(['domain' => 'firma.de', 'name' => 'Test', 'mailboxes' => array_map(fn(int $i): string => "m$i@firma.de", range(1, 501))])); + } + + public function testRuleTestShowsNothingOfOtherDomains(): void + { + $this->lookup->mailboxes['andere@tochter.de'] = 'tochter.de'; + $this->lookup->mailboxPolicies['andere@tochter.de'] = [$this->stored(['id' => 5, 'domain' => 'tochter.de', 'name' => 'Geheim', + 'sendAllow' => [], 'receiveMode' => 'internal', 'rejectText' => 'Interner Text'])]; + $this->repo->method('mailboxDomain')->willReturnCallback(fn(string $username): ?string => $this->lookup->mailboxes[$username] ?? null); + $da = FakeContext::domainAdmin([], ['firma.de'], ['mail_policies' => '1']); + + // a policy of another domain: neither name nor text + $this->assertSame(['allowed' => 0, 'side' => 'receive', 'policy' => null, 'internal_alias' => 0, 'logged_in' => 1, + 'text' => 'This recipient does not accept mail from this sender'], $this->service($da)->test('azubi@firma.de', 'andere@tochter.de', true)); + // a sender of another domain is not tested as logged in, so its mailbox and its rules stay hidden + $this->assertSame(0, $this->service($da)->test('andere@tochter.de', 'chef@firma.de', true)['logged_in']); + } + public function testRuleTest(): void { $policy = $this->stored(['sendMode' => 'internal', 'sendAllow' => []]); diff --git a/tests/php/Support/FakeMailPolicyLookup.php b/tests/php/Support/FakeMailPolicyLookup.php index 21424db0a..0bbc6b664 100644 --- a/tests/php/Support/FakeMailPolicyLookup.php +++ b/tests/php/Support/FakeMailPolicyLookup.php @@ -21,8 +21,16 @@ class FakeMailPolicyLookup extends LookupRepository public array $domains = []; /** id => ['address' => ?string, 'members' => string[]] */ public array $groups = []; - /** alias address => domain of aliases with the internal flag */ - public array $internalAliases = []; + /** alias address => ['domain' => string, 'internal' => bool] (active aliases, also own aliases of mailboxes) */ + public array $aliases = []; + /** temporary alias => owner mailbox */ + public array $temporaryAliases = []; + /** @var string[] domains with an internal catch-all alias */ + public array $internalCatchAlls = []; + /** an active policy restricts sending */ + public bool $sendRules = false; + /** other spelling (MySQL ignores accents) => stored address or domain */ + public array $spellings = []; /** username => Policy[] assigned directly */ public array $mailboxPolicies = []; /** group id => Policy[] */ @@ -67,9 +75,34 @@ class FakeMailPolicyLookup extends LookupRepository return in_array($username, $this->groups[$groupId]['members'] ?? [], true); } - public function internalAlias(string $address): ?string + public function aliasTarget(string $address): ?array { - return $this->internalAliases[$address] ?? null; + if (isset($this->aliases[$address])) { + return ['kind' => 'alias', 'domain' => $this->aliases[$address]['domain'], 'internal' => $this->aliases[$address]['internal']]; + } + return isset($this->temporaryAliases[$address]) ? ['kind' => 'temporary', 'owner' => $this->temporaryAliases[$address]] : null; + } + + public function internalCatchAll(string $domain): bool + { + return in_array($domain, $this->internalCatchAlls, true); + } + + public function hasSendRules(): bool + { + return $this->sendRules; + } + + public function storedAddress(string $address): ?string + { + $address = $this->spellings[$address] ?? $address; + return (isset($this->mailboxes[$address]) || isset($this->aliases[$address]) || isset($this->temporaryAliases[$address])) ? $address : null; + } + + public function storedDomain(string $domain): ?string + { + $domain = $this->spellings[$domain] ?? $domain; + return (in_array($domain, $this->domains, true) || isset($this->aliasDomains[$domain])) ? $domain : null; } public function policiesForMailbox(string $username): array From ce2062db5f11d269c8bd05e3c17cedcd2abd6641 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Mon, 9 Nov 2026 02:52:08 +0100 Subject: [PATCH 3/5] [Dovecot] Disable the old Sieve notify extension, it sends as postmaster past the mail policies --- data/conf/dovecot/dovecot.conf | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/data/conf/dovecot/dovecot.conf b/data/conf/dovecot/dovecot.conf index fbdb112da..274b79442 100644 --- a/data/conf/dovecot/dovecot.conf +++ b/data/conf/dovecot/dovecot.conf @@ -199,7 +199,8 @@ plugin { sieve_plugins = sieve_imapsieve sieve_extprograms sieve_vacation_send_from_recipient = yes sieve_redirect_envelope_from = recipient - # Notifications are sent with the address of the mailbox (not :from or postmaster), so mail policies apply + # Notifications are sent with the address of the mailbox (not :from or postmaster), so mail policies apply; + # the old notify extension always sends as postmaster and is on by default, so sieve_extensions disables it sieve_notify_mailto_envelope_from = recipient # From elsewhere to Spam folder imapsieve_mailbox1_name = Junk @@ -217,7 +218,7 @@ plugin { quota_warning2 = storage=80%% quota-warning 80 %u sieve_pipe_bin_dir = /usr/lib/dovecot/sieve sieve_global_extensions = +vnd.dovecot.pipe +vnd.dovecot.execute - sieve_extensions = +notify +imapflags +vacation-seconds +editheader + sieve_extensions = -notify +imapflags +vacation-seconds +editheader sieve_max_script_size = 1M sieve_max_redirects = 100 sieve_max_actions = 101 From 7a1b68c5e24a742d99b90ae6d0af57e7aa5683a0 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:04:54 +0100 Subject: [PATCH 4/5] [Postfix] No mail policy check on the watchdog port 589 --- data/conf/postfix/master.cf | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/data/conf/postfix/master.cf b/data/conf/postfix/master.cf index 745c42f2a..af7bff001 100644 --- a/data/conf/postfix/master.cf +++ b/data/conf/postfix/master.cf @@ -121,9 +121,10 @@ smtp_sender_cleanup unix n - y - 0 cleanup mailcow-policy unix - n n - 0 spawn user=nobody argv=/usr/local/bin/mailcow-policy.py # start watchdog-specific -# logs to local7 (hidden) +# logs to local7 (hidden), no mail policy check 589 inet n - n - - smtpd -o smtpd_client_restrictions=permit_mynetworks,reject + -o smtpd_recipient_restrictions=permit_mynetworks,reject -o syslog_name=watchdog -o syslog_facility=local7 -o smtpd_milters= From c30404014095a13a5ccde3a495a77da3c3d5b2a1 Mon Sep 17 00:00:00 2001 From: FreddleSpl0it <75116288+FreddleSpl0it@users.noreply.github.com> Date: Mon, 9 Nov 2026 03:06:19 +0100 Subject: [PATCH 5/5] [Web] Mail policies: store list entries the way they are compared, refuse unknown modes --- .../inc/lib/mailcow/MailPolicy/Service/PolicyEngine.php | 4 +++- .../inc/lib/mailcow/MailPolicy/Service/PolicyService.php | 8 ++++++-- tests/php/MailPolicy/Service/PolicyEngineTest.php | 7 +++++++ tests/php/MailPolicy/Service/PolicyServiceTest.php | 9 +++++++++ 4 files changed, 25 insertions(+), 3 deletions(-) diff --git a/data/web/inc/lib/mailcow/MailPolicy/Service/PolicyEngine.php b/data/web/inc/lib/mailcow/MailPolicy/Service/PolicyEngine.php index 89faa03d1..066732fb1 100644 --- a/data/web/inc/lib/mailcow/MailPolicy/Service/PolicyEngine.php +++ b/data/web/inc/lib/mailcow/MailPolicy/Service/PolicyEngine.php @@ -105,9 +105,11 @@ class PolicyEngine } } return match ($mode) { + 'all' => true, 'internal' => $internal || $this->any($allow, $matches), 'list' => $this->any($allow, $matches), - default => true, + // an unknown mode lets nothing through + default => false, }; } diff --git a/data/web/inc/lib/mailcow/MailPolicy/Service/PolicyService.php b/data/web/inc/lib/mailcow/MailPolicy/Service/PolicyService.php index 2379edba8..3ffafc6e9 100644 --- a/data/web/inc/lib/mailcow/MailPolicy/Service/PolicyService.php +++ b/data/web/inc/lib/mailcow/MailPolicy/Service/PolicyService.php @@ -288,11 +288,15 @@ class PolicyService } $entry = 'group:' . $id; } elseif (str_contains(ltrim($entry, '@'), '@')) { - if (!filter_var($entry, FILTER_VALIDATE_EMAIL)) { + // in the form the engine compares: domain without the final dot, never a +tag or an IP literal + $at = strrpos($entry, '@'); + $local = substr($entry, 0, $at); + $entry = $local . '@' . rtrim(substr($entry, $at + 1), '.'); + if (!filter_var($entry, FILTER_VALIDATE_EMAIL) || str_contains(substr($local, 1), '+') || str_contains($entry, '@[')) { throw new UserError('mail_policy_entry_invalid', [htmlspecialchars($entry)]); } } else { - $entry = ltrim($entry, '@'); + $entry = rtrim(ltrim($entry, '@'), '.'); if (!str_contains($entry, '.') || !filter_var($entry, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME)) { throw new UserError('mail_policy_entry_invalid', [htmlspecialchars($entry)]); } diff --git a/tests/php/MailPolicy/Service/PolicyEngineTest.php b/tests/php/MailPolicy/Service/PolicyEngineTest.php index 57b77f4bd..79f2aeade 100644 --- a/tests/php/MailPolicy/Service/PolicyEngineTest.php +++ b/tests/php/MailPolicy/Service/PolicyEngineTest.php @@ -58,6 +58,13 @@ class PolicyEngineTest extends TestCase $this->assertSame('DUNNO', $this->engine->check('azubi@firma.de', 'x@extern.example', 'azubi@firma.de')->action()); } + public function testUnknownModeLetsNothingThrough(): void + { + $this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(1, ['sendMode' => 'nobody', 'receiveMode' => ''])]; + $this->assertSame([false, 'send'], $this->check('azubi@firma.de', 'kollege@firma.de', 'azubi@firma.de')); + $this->assertSame([false, 'receive'], $this->check('kollege@firma.de', 'azubi@firma.de', 'kollege@firma.de')); + } + public function testSendOnlyInternalWithAllowedAndBlockedEntries(): void { $this->lookup->mailboxPolicies['azubi@firma.de'] = [self::policy(1, [ diff --git a/tests/php/MailPolicy/Service/PolicyServiceTest.php b/tests/php/MailPolicy/Service/PolicyServiceTest.php index 6dae83f67..e870b8eb2 100644 --- a/tests/php/MailPolicy/Service/PolicyServiceTest.php +++ b/tests/php/MailPolicy/Service/PolicyServiceTest.php @@ -113,6 +113,9 @@ class PolicyServiceTest extends TestCase 'mode' => [['send_mode' => 'nobody'], 'mail_policy_mode_invalid'], 'domain without dot' => [['send_allow' => 'localhost'], ['mail_policy_entry_invalid', 'localhost']], 'address' => [['receive_block' => 'a@@b.de'], ['mail_policy_entry_invalid', 'a@@b.de']], + 'address with +tag' => [['receive_block' => 'news+promo@vendor.example'], ['mail_policy_entry_invalid', 'news+promo@vendor.example']], + 'IP literal' => [['send_allow' => 'u@[127.0.0.1]'], ['mail_policy_entry_invalid', 'u@[127.0.0.1]']], + 'top level domain' => [['send_block' => 'com.'], ['mail_policy_entry_invalid', 'com']], 'unknown group' => [['receive_block_groups' => ['8']], ['mail_policy_entry_invalid', 'group:8']], 'reject text with line break' => [['reject_text' => "a\r\nb"], 'mail_policy_reject_text_invalid'], 'reject text not ASCII' => [['reject_text' => 'Nur für Azubis'], 'mail_policy_reject_text_invalid'], @@ -122,6 +125,12 @@ class PolicyServiceTest extends TestCase ]; } + public function testEntriesInTheFormTheEngineCompares(): void + { + $policy = $this->service()->create(['domain' => 'firma.de', 'name' => 'Test', 'receive_block' => "Spam.Example.\n@news.example\nBoss@Ext.Example.\n+x@ext.example"]); + $this->assertSame(['spam.example', 'news.example', 'boss@ext.example', '+x@ext.example'], $policy->receiveBlock); + } + #[DataProvider('invalidInput')] public function testInvalidInput(array $input, string|array $error): void {