From 2a77a4c9ded7f69a6538ec72ead32f34616e1cbb Mon Sep 17 00:00:00 2001 From: Mario Fetka Date: Mon, 27 Jul 2026 14:31:41 +0200 Subject: [PATCH] smtp: preserve explicit DSN request semantics --- contrib/testing/smtp-dsn-check.py | 616 +++++++++++++++++++++++ include/nmap.h | 11 +- src/agents/queue/dsn-format.c | 6 + src/agents/queue/dsn-format.h | 1 + src/agents/queue/queue.c | 8 +- src/agents/queue/tests/dsn-format-test.c | 4 + src/agents/rules/rules.c | 8 +- src/agents/smtp/protocol.c | 6 + src/agents/smtp/smtpc.c | 57 ++- src/agents/smtp/smtpd.c | 97 ++-- src/agents/smtp/tests/protocol-test.c | 8 + 11 files changed, 743 insertions(+), 79 deletions(-) create mode 100755 contrib/testing/smtp-dsn-check.py diff --git a/contrib/testing/smtp-dsn-check.py b/contrib/testing/smtp-dsn-check.py new file mode 100755 index 0000000..a561879 --- /dev/null +++ b/contrib/testing/smtp-dsn-check.py @@ -0,0 +1,616 @@ +#!/usr/bin/env python3 +# This program is free software, licensed under the terms of the GNU GPL. +# See the Bongo COPYING file for full details. +# Copyright (c) 2026 Bongo Project contributors + +"""Verify RFC 3461 DSN syntax, relay propagation, and generated reports.""" + +from __future__ import annotations + +import copy +import json +import os +from pathlib import Path +import re +import socket +import socketserver +import subprocess +import threading +import time + +from bongo.store.StoreClient import StoreClient + + +ADMIN = os.environ.get("BONGO_TEST_ADMIN", "/usr/bin/bongo-admin") +SYSTEMCTL = os.environ.get("BONGO_TEST_SYSTEMCTL", "/usr/bin/systemctl") +QUEUE_TOOL = os.environ.get( + "BONGO_TEST_QUEUE_TOOL", "/usr/bin/bongo-queuetool" +) +HOST = os.environ.get("BONGO_TEST_HOST", "127.0.0.1") +PORT = int(os.environ.get("BONGO_TEST_SMTP_PORT", "25")) +STORE_PORT = int(os.environ.get("BONGO_TEST_STORE_PORT", "689")) +USER = os.environ.get("BONGO_TEST_USER", "test1") +DOMAIN = os.environ.get("BONGO_TEST_DOMAIN", "bongo.test") +PASSWORD = os.environ.get("BONGO_TEST_PASSWORD", "") +TIMEOUT = float(os.environ.get("BONGO_TEST_TIMEOUT", "45")) +ALLOW_LIVE = os.environ.get("BONGO_ALLOW_LIVE_SMTP_TEST") == "1" +TOKEN = f"smtp14-{os.getpid()}-{time.time_ns()}" +TOKEN_PREFIX = b"smtp14-" +RESPONSE = re.compile(rb"^([0-9]{3})([- ])(.*)\r\n$") +QUEUE_ID = re.compile(r"^[0-9A-Fa-f]{3}-[0-9A-Fa-f]+$") + + +class SMTP14Error(RuntimeError): + """Raised when the DSN contract is not satisfied.""" + + +def run( + arguments: list[str], + *, + input_text: str | None = None, + check: bool = True, +) -> subprocess.CompletedProcess[str]: + completed = subprocess.run( + arguments, + input=input_text, + capture_output=True, + text=True, + check=False, + ) + if check and completed.returncode: + raise SMTP14Error( + f"{' '.join(arguments)} failed: " + f"{completed.stderr.strip() or completed.stdout.strip()}" + ) + return completed + + +def admin(*arguments: str, input_text: str | None = None) -> str: + return run( + ["sudo", "-n", ADMIN, *arguments], input_text=input_text + ).stdout + + +def queue( + *arguments: str, check: bool = True +) -> subprocess.CompletedProcess[str]: + return run( + ["sudo", "-n", "-u", "bongo", QUEUE_TOOL, *arguments], + check=check, + ) + + +def read_configuration() -> dict: + try: + configuration = json.loads(admin("__config-read", "smtp")) + except json.JSONDecodeError as error: + raise SMTP14Error("invalid SMTP Store configuration") from error + if not isinstance(configuration, dict): + raise SMTP14Error("SMTP Store configuration is not an object") + return configuration + + +def replace_configuration(configuration: dict) -> None: + admin( + "__config-replace", + "smtp", + input_text=json.dumps(configuration, separators=(",", ":")), + ) + + +def restart_bongo(host: str, port: int) -> None: + run(["sudo", "-n", SYSTEMCTL, "restart", "bongo.service"]) + time.sleep(2) + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + try: + client = SMTPConnection(host, port) + try: + client.require(220, "readiness greeting") + client.command("QUIT", 221) + return + finally: + client.close() + except (OSError, SMTP14Error): + time.sleep(0.25) + raise SMTP14Error(f"Bongo did not become ready at {host}:{port}") + + +class SMTPConnection: + def __init__(self, host: str = HOST, port: int = PORT) -> None: + self.socket = socket.create_connection((host, port), timeout=TIMEOUT) + self.socket.settimeout(TIMEOUT) + self.reader = self.socket.makefile("rb") + + def close(self) -> None: + try: + self.reader.close() + finally: + self.socket.close() + + def response(self) -> tuple[int, list[bytes]]: + code: int | None = None + lines: list[bytes] = [] + while True: + line = self.reader.readline(8194) + if not line: + raise SMTP14Error("connection closed while awaiting response") + match = RESPONSE.match(line) + if match is None: + raise SMTP14Error(f"malformed SMTP response: {line!r}") + current = int(match.group(1)) + if code is None: + code = current + elif current != code: + raise SMTP14Error(f"mixed multiline response: {line!r}") + lines.append(match.group(3)) + if match.group(2) == b" ": + return current, lines + + def require(self, expected: int, context: str) -> list[bytes]: + code, lines = self.response() + if code != expected: + raise SMTP14Error( + f"{context}: expected {expected}, got {code}: {lines!r}" + ) + return lines + + def command(self, command: str, expected: int) -> list[bytes]: + self.socket.sendall(command.encode("ascii") + b"\r\n") + return self.require(expected, command) + + +class Capture: + def __init__(self) -> None: + self.lock = threading.Lock() + self.entries: list[dict[str, object]] = [] + + def add(self, mail: str, rcpt: str, data: bytes | None) -> None: + with self.lock: + self.entries.append({"mail": mail, "rcpt": rcpt, "data": data}) + + def matching(self, marker: str) -> list[dict[str, object]]: + needle = marker.encode() + with self.lock: + return [ + dict(entry) + for entry in self.entries + if marker in str(entry["mail"]) + or marker in str(entry["rcpt"]) + or ( + isinstance(entry["data"], bytes) + and needle in entry["data"] + ) + ] + + +CAPTURE = Capture() + + +class DSNHandler(socketserver.StreamRequestHandler): + def handle(self) -> None: + mail = "" + rcpt = "" + self.wfile.write(b"220 smtp14-fixture ESMTP\r\n") + self.wfile.flush() + while True: + raw = self.rfile.readline(8194) + if not raw: + return + command = raw.rstrip(b"\r\n").decode("ascii", "strict") + upper = command.upper() + if upper.startswith("EHLO "): + self.wfile.write( + b"250-smtp14-fixture\r\n" + b"250-PIPELINING\r\n" + b"250-DSN\r\n" + b"250 SIZE 10485760\r\n" + ) + elif upper.startswith("HELO "): + self.wfile.write(b"250 smtp14-fixture\r\n") + elif upper.startswith("MAIL FROM:"): + mail = command + self.wfile.write(b"250 2.1.0 Sender accepted\r\n") + elif upper.startswith("RCPT TO:"): + rcpt = command + if "reject-" in command.lower(): + CAPTURE.add(mail, rcpt, None) + self.wfile.write(b"550 5.1.1 No such user\r\n") + else: + self.wfile.write(b"250 2.1.5 Recipient accepted\r\n") + elif upper == "DATA": + self.wfile.write(b"354 End data with .\r\n") + self.wfile.flush() + body = bytearray() + while True: + line = self.rfile.readline(65538) + if not line: + return + if line == b".\r\n": + break + if line.startswith(b".."): + line = line[1:] + body.extend(line) + CAPTURE.add(mail, rcpt, bytes(body)) + self.wfile.write(b"250 2.0.0 Queued\r\n") + elif upper == "RSET": + mail = rcpt = "" + self.wfile.write(b"250 2.0.0 Reset\r\n") + elif upper == "NOOP": + self.wfile.write(b"250 2.0.0 Ok\r\n") + elif upper == "QUIT": + self.wfile.write(b"221 2.0.0 Bye\r\n") + self.wfile.flush() + return + else: + self.wfile.write(b"500 5.5.2 Unknown command\r\n") + self.wfile.flush() + + +class FixtureServer(socketserver.ThreadingTCPServer): + allow_reuse_address = True + daemon_threads = True + + +def syntax_checks() -> None: + client = SMTPConnection() + try: + client.require(220, "syntax greeting") + client.command("HELO smtp14-client.example", 250) + client.command( + "MAIL FROM: RET=HDRS", 555 + ) + capabilities = client.command("EHLO smtp14-client.example", 250) + if b"DSN" not in capabilities: + raise SMTP14Error(f"EHLO omitted DSN: {capabilities!r}") + for command in ( + "MAIL FROM: RET=BAD", + "MAIL FROM: RET=HDRS RET=FULL", + "MAIL FROM: ENVID=bad+XX", + "MAIL FROM: ENVID=bad+00", + ): + client.command(command, 501) + client.command( + "MAIL FROM: " + "RET=HDRS ENVID=syntax+2Bcase", + 250, + ) + for command in ( + f"RCPT TO:<{USER}@{DOMAIN}> NOTIFY=NEVER,FAILURE", + f"RCPT TO:<{USER}@{DOMAIN}> NOTIFY=FAILURE,FAILURE", + f"RCPT TO:<{USER}@{DOMAIN}> ORCPT=missing-type", + f"RCPT TO:<{USER}@{DOMAIN}> ORCPT=rfc822;bad+00", + ): + client.command(command, 501) + client.command( + f"RCPT TO:<{USER}@{DOMAIN}> " + f"NOTIFY=SUCCESS,FAILURE,DELAY " + f"ORCPT=rfc822;{USER}+40{DOMAIN}", + 250, + ) + client.command("RSET", 250) + client.command("QUIT", 221) + finally: + client.close() + + +def message(marker: str, secret: str) -> bytes: + return ( + f"From: SMTP-14 <{USER}@{DOMAIN}>\r\n" + f"To: {marker}@example.net\r\n" + f"Subject: {marker}\r\n" + f"Message-ID: <{marker}@{DOMAIN}>\r\n" + f"X-Bongo-Test: {marker}\r\n" + "\r\n" + f"{secret}\r\n" + ).encode("ascii") + + +def submit( + host: str, + port: int, + marker: str, + *, + mail_parameters: str = "", + rcpt_parameters: str = "", + reject: bool = False, +) -> str: + local = f"{'reject-' if reject else ''}{marker}" + recipient = f"{local}@example.net" + secret = f"SMTP14-SECRET-BODY-{marker}" + client = SMTPConnection(host, port) + try: + client.require(220, f"{marker} greeting") + client.command(f"EHLO {marker}.bongo.test", 250) + client.command( + f"MAIL FROM:<{USER}@{DOMAIN}>{mail_parameters}", 250 + ) + client.command( + f"RCPT TO:<{recipient}>{rcpt_parameters}", 250 + ) + client.command("DATA", 354) + client.socket.sendall(message(marker, secret) + b".\r\n") + client.require(250, f"{marker} DATA") + client.command("QUIT", 221) + finally: + client.close() + return secret + + +def wait_capture(marker: str) -> dict[str, object]: + deadline = time.monotonic() + TIMEOUT + while time.monotonic() < deadline: + matches = CAPTURE.matching(marker) + if len(matches) == 1: + return matches[0] + if len(matches) > 1: + raise SMTP14Error(f"duplicate fixture capture for {marker}") + time.sleep(0.2) + raise SMTP14Error(f"no fixture capture for {marker}") + + +def open_store() -> StoreClient: + return StoreClient( + USER, USER, authPassword=PASSWORD, host=HOST, port=STORE_PORT + ) + + +def store_matches(store: StoreClient, marker: str) -> dict[str, bytes]: + matches: dict[str, bytes] = {} + for item in store.List("/mail/INBOX"): + try: + payload = store.Read(item.uid) + except Exception: + continue + if marker.encode() in payload: + matches[item.uid] = payload + return matches + + +def wait_store(store: StoreClient, marker: str) -> tuple[str, bytes]: + deadline = time.monotonic() + TIMEOUT + while time.monotonic() < deadline: + matches = store_matches(store, marker) + if len(matches) == 1: + return next(iter(matches.items())) + if len(matches) > 1: + raise SMTP14Error(f"duplicate DSN for {marker}") + time.sleep(0.25) + raise SMTP14Error(f"no generated DSN for {marker}") + + +def no_store_message(store: StoreClient, marker: str) -> None: + deadline = time.monotonic() + 4 + while time.monotonic() < deadline: + if store_matches(store, marker): + raise SMTP14Error(f"NOTIFY=NEVER generated a DSN for {marker}") + time.sleep(0.25) + + +def queue_token_ids() -> list[str]: + matches: list[str] = [] + for line in queue("list").stdout.splitlines(): + fields = line.split() + if not fields or QUEUE_ID.fullmatch(fields[0]) is None: + continue + payload = queue("message", fields[0], check=False) + if payload.returncode == 0 and TOKEN in payload.stdout: + matches.append(fields[0]) + return matches + + +def cleanup(store: StoreClient) -> None: + deadline = time.monotonic() + 20 + quiet_since: float | None = None + while time.monotonic() < deadline: + found = False + for item in store.List("/mail/INBOX"): + try: + payload = store.Read(item.uid) + except Exception: + continue + if TOKEN_PREFIX in payload: + store.Delete(item.uid) + found = True + for identifier in queue_token_ids(): + queue("delete", identifier, check=False) + found = True + if found: + quiet_since = None + elif quiet_since is None: + quiet_since = time.monotonic() + elif time.monotonic() - quiet_since >= 1: + return + time.sleep(0.25) + + +def require_text(payload: bytes, text: str, marker: str) -> None: + if text.encode() not in payload: + raise SMTP14Error(f"{marker} DSN omitted {text!r}") + + +def main() -> int: + if not ALLOW_LIVE: + raise SMTP14Error( + "set BONGO_ALLOW_LIVE_SMTP_TEST=1 for the live DSN test" + ) + if not PASSWORD: + raise SMTP14Error("BONGO_TEST_PASSWORD is required") + + syntax_checks() + original = read_configuration() + internal_host = str( + original.get("internal_relay_bind_address", "127.0.0.1") + ) + internal_port = int(original.get("internal_relay_port", 26)) + store = open_store() + server = FixtureServer(("127.0.0.1", 0), DSNHandler) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + restored = False + documents: list[str] = [] + try: + cleanup(store) + configured = copy.deepcopy(original) + configured.update( + { + "use_relay_host": True, + "relay_host": "localhost", + "relay_port": int(server.server_address[1]), + "relay_tls_security_level": "none", + "relay_tls_wrapper_mode": False, + "relay_username": "", + "relay_password_file": "", + } + ) + replace_configuration(configured) + restart_bongo(internal_host, internal_port) + + explicit = f"{TOKEN}-explicit" + explicit_secret = submit( + internal_host, + internal_port, + explicit, + mail_parameters=f" RET=HDRS ENVID={explicit}+2Benv", + rcpt_parameters=( + " NOTIFY=SUCCESS,FAILURE,DELAY " + f"ORCPT=rfc822;{explicit}+40example.net" + ), + ) + capture = wait_capture(explicit) + mail = str(capture["mail"]) + rcpt = str(capture["rcpt"]) + if f"RET=HDRS ENVID={explicit}+2Benv" not in mail: + raise SMTP14Error(f"explicit MAIL parameters changed: {mail}") + if ( + f"ORCPT=rfc822;{explicit}+40example.net" not in rcpt + or "NOTIFY=SUCCESS,FAILURE,DELAY" not in rcpt + ): + raise SMTP14Error(f"explicit RCPT parameters changed: {rcpt}") + document, payload = wait_store(store, explicit) + documents.append(document) + require_text(payload, "Action: delivered", explicit) + require_text(payload, "Status: 2.0.0", explicit) + require_text(payload, f"Original-Envelope-Id: {explicit}+env", explicit) + if explicit_secret.encode() in payload: + raise SMTP14Error("success DSN returned the original body") + + omitted = f"{TOKEN}-omitted" + omitted_secret = submit( + internal_host, internal_port, omitted, reject=True + ) + capture = wait_capture(omitted) + mail = str(capture["mail"]) + rcpt = str(capture["rcpt"]) + if " RET=" in mail or " ENVID=" in mail or " NOTIFY=" in rcpt: + raise SMTP14Error( + f"omitted DSN parameters were invented: {mail!r} {rcpt!r}" + ) + document, payload = wait_store(store, omitted) + documents.append(document) + require_text(payload, "Action: failed", omitted) + require_text(payload, "Status: 5.1.1", omitted) + require_text(payload, omitted_secret, omitted) + if b"Original-Envelope-Id:" in payload: + raise SMTP14Error("omitted ENVID produced Original-Envelope-Id") + + hdrs = f"{TOKEN}-hdrs" + hdrs_secret = submit( + internal_host, + internal_port, + hdrs, + reject=True, + mail_parameters=f" RET=HDRS ENVID={hdrs}+2Benv", + rcpt_parameters=( + " NOTIFY=FAILURE " + f"ORCPT=rfc822;reject-{hdrs}+40example.net" + ), + ) + wait_capture(hdrs) + document, payload = wait_store(store, hdrs) + documents.append(document) + require_text(payload, f"Original-Envelope-Id: {hdrs}+env", hdrs) + require_text( + payload, + f"Original-Recipient: rfc822;reject-{hdrs}@example.net", + hdrs, + ) + if hdrs_secret.encode() in payload: + raise SMTP14Error("RET=HDRS returned the original body") + + full = f"{TOKEN}-full" + full_secret = submit( + internal_host, + internal_port, + full, + reject=True, + mail_parameters=" RET=FULL", + rcpt_parameters=" NOTIFY=FAILURE", + ) + capture = wait_capture(full) + if " RET=FULL" not in str(capture["mail"]): + raise SMTP14Error("RET=FULL was not relayed") + document, payload = wait_store(store, full) + documents.append(document) + require_text(payload, full_secret, full) + + never = f"{TOKEN}-never" + submit( + internal_host, + internal_port, + never, + reject=True, + rcpt_parameters=" NOTIFY=NEVER", + ) + capture = wait_capture(never) + if " NOTIFY=NEVER" not in str(capture["rcpt"]): + raise SMTP14Error("NOTIFY=NEVER was not relayed") + no_store_message(store, never) + + for document in documents: + store.Delete(document) + documents.clear() + cleanup(store) + if queue_token_ids(): + raise SMTP14Error("marked Queue entries remain") + + replace_configuration(original) + restart_bongo(internal_host, internal_port) + restored = True + finally: + for document in documents: + try: + store.Delete(document) + except Exception: + pass + try: + cleanup(store) + except Exception: + pass + if not restored: + try: + replace_configuration(original) + restart_bongo(internal_host, internal_port) + except Exception as error: + print(f"SMTP-14 restore warning: {error}") + store.Quit() + server.shutdown() + server.server_close() + thread.join(timeout=2) + + print( + "SMTP-14 PASS syntax=postfix-aligned " + "ret=omitted-default-full/hdrs/full " + "envid=relayed/xtext-decoded notify=omitted/success/failure/never " + "orcpt=relayed/decoded dsn=2.0.0/5.1.1 " + "queue-clean=yes store-clean=yes config-restored=yes" + ) + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (OSError, SMTP14Error, ValueError) as error: + print(f"SMTP-14 FAIL: {type(error).__name__}: {error}") + raise SystemExit(1) diff --git a/include/nmap.h b/include/nmap.h index d83ce93..e10489c 100644 --- a/include/nmap.h +++ b/include/nmap.h @@ -368,8 +368,6 @@ typedef enum _NMAPMessageFlags { #define Q_HOLD 999 /* Delivery Status Notification and other Control File Flags */ -#define DSN_FLAGS (0x0000001F) -#define DSN_DEFAULT (DSN_FAILURE | DSN_HEADER | DSN_BODY) #define DSN_SUCCESS (1<<0) #define DSN_TIMEOUT (1<<1) #define DSN_FAILURE (1<<2) @@ -380,6 +378,15 @@ typedef enum _NMAPMessageFlags { #define NO_CALENDARPROCESSING (1<<7) #define NO_VIRUSSCANNING (1<<8) #define NO_PLUSPACK (1 << 9) +/* + * RFC 3461 section 5.2.1 distinguishes an omitted RET or NOTIFY parameter + * from the corresponding local default. Preserve that distinction in the + * Queue envelope so relaying cannot invent parameters on the next hop. + */ +#define DSN_RET_EXPLICIT (1<<20) +#define DSN_NOTIFY_EXPLICIT (1<<21) +#define DSN_FLAGS (0x0030001F) +#define DSN_DEFAULT (DSN_FAILURE | DSN_TIMEOUT | DSN_HEADER | DSN_BODY) /* Delivery states */ #define DELIVER_SUCCESS 1 diff --git a/src/agents/queue/dsn-format.c b/src/agents/queue/dsn-format.c index 9568ea6..e2acbe7 100644 --- a/src/agents/queue/dsn-format.c +++ b/src/agents/queue/dsn-format.c @@ -127,6 +127,12 @@ DecodeXtext(const char *value, char *result, size_t result_size) return used != 0U; } +int +BongoDsnEnvelopeId(const char *envid, char *result, size_t result_size) +{ + return DecodeXtext(envid, result, result_size); +} + int BongoDsnWriteBase64(FILE *input, FILE *output) { diff --git a/src/agents/queue/dsn-format.h b/src/agents/queue/dsn-format.h index 27cbf2b..21070b7 100644 --- a/src/agents/queue/dsn-format.h +++ b/src/agents/queue/dsn-format.h @@ -28,6 +28,7 @@ #define BONGO_DSN_ADDRESS_MAX 4096U int BongoDsnWriteBase64(FILE *input, FILE *output); +int BongoDsnEnvelopeId(const char *envid, char *result, size_t result_size); int BongoDsnAddress(const char *address, int international, char *result, size_t result_size); int BongoDsnOriginalAddress(const char *orcpt, int international, char *result, diff --git a/src/agents/queue/queue.c b/src/agents/queue/queue.c index 27fd49b..035c855 100644 --- a/src/agents/queue/queue.c +++ b/src/agents/queue/queue.c @@ -2686,7 +2686,13 @@ CreateDSNMessage(FILE *data, FILE *control, FILE *rtsData, FILE *rtsControl, BOO /* Per message fields */ if (envID[0]!='\0') { - fprintf(statusPart, "Original-Envelope-Id: %s\r\n", envID); + char decodedEnvID[128]; + + if (BongoDsnEnvelopeId(envID, decodedEnvID, + sizeof(decodedEnvID))) { + fprintf(statusPart, "Original-Envelope-Id: %s\r\n", + decodedEnvID); + } } fprintf(statusPart, "Reporting-MTA: dns; %s\r\n", BongoGlobals.hostname); diff --git a/src/agents/queue/tests/dsn-format-test.c b/src/agents/queue/tests/dsn-format-test.c index 3b19668..575e1c3 100644 --- a/src/agents/queue/tests/dsn-format-test.c +++ b/src/agents/queue/tests/dsn-format-test.c @@ -55,6 +55,10 @@ main(void) const char *action; TestBase64(); + assert(BongoDsnEnvelopeId("job+2B42+3Dretry", address, + sizeof(address))); + assert(!strcmp(address, "job+42=retry")); + assert(!BongoDsnEnvelopeId("job+XX42", address, sizeof(address))); assert(BongoDsnAddress("user@example.test", 0, address, sizeof(address))); assert(!strcmp(address, "rfc822;user@example.test")); diff --git a/src/agents/rules/rules.c b/src/agents/rules/rules.c index 9257ad5..c2e44cf 100644 --- a/src/agents/rules/rules.c +++ b/src/agents/rules/rules.c @@ -349,8 +349,8 @@ SieveAction(const BongoSieveResult *action_result, void *data) if (!SieveAddress(argument, safe)) return 0; execution->result = NMAPSendCommandF(client->conn, "QMOD TO %s %s %lu\r\n", safe, client->recipient, - (unsigned long)((client->flags & ~(DSN_SUCCESS | DSN_TIMEOUT | - DSN_FAILURE | DSN_HEADER | DSN_BODY)) | NO_RULEPROCESSING)); + (unsigned long)((client->flags & ~DSN_FLAGS) | + NO_RULEPROCESSING)); execution->written = TRUE; break; case BONGO_SIEVE_REJECT: @@ -834,7 +834,7 @@ ProcessRules(RulesClient *client) case RULE_ACT_FORWARD: { Log(LOG_DEBUG, "User %s forward rule to %s", client->recipient, action->string[0]); - client->flags &= ~(DSN_SUCCESS | DSN_TIMEOUT | DSN_FAILURE | DSN_HEADER | DSN_BODY); + client->flags &= ~DSN_FLAGS; client->flags |= NO_RULEPROCESSING; ccode = NMAPSendCommandF(client->conn, "QMOD TO %s %s %lu\r\n", action->string[0], client->recipient, (unsigned long)client->flags); @@ -845,7 +845,7 @@ ProcessRules(RulesClient *client) case RULE_ACT_COPY: { Log(LOG_DEBUG, "User %s copy rule to %s", client->recipient, action->string[0]); - client->flags &= ~(DSN_SUCCESS | DSN_TIMEOUT | DSN_FAILURE | DSN_HEADER | DSN_BODY); + client->flags &= ~DSN_FLAGS; client->flags |= NO_RULEPROCESSING; ccode = NMAPSendCommandF(client->conn, "QMOD TO %s %s %lu\r\n", action->string[0], client->recipient, (unsigned long)client->flags); diff --git a/src/agents/smtp/protocol.c b/src/agents/smtp/protocol.c index 7d0c974..5eec5d4 100644 --- a/src/agents/smtp/protocol.c +++ b/src/agents/smtp/protocol.c @@ -368,8 +368,14 @@ XtextValid(const char *value, size_t length, size_t maximum) unsigned char current = (unsigned char)value[i]; if (current < 0x21U || current > 0x7eU || current == '=') return 0; if (current == '+') { + unsigned int decoded; + if (length - i < 3U || !HexDigit((unsigned char)value[i + 1]) || !HexDigit((unsigned char)value[i + 2])) return 0; + decoded = (unsigned int)( + HexValue((unsigned char)value[i + 1]) * 16 + + HexValue((unsigned char)value[i + 2])); + if (decoded < 0x20U || decoded > 0x7eU) return 0; i += 2U; } } diff --git a/src/agents/smtp/smtpc.c b/src/agents/smtp/smtpc.c index b9d8dac..6f867f5 100755 --- a/src/agents/smtp/smtpc.c +++ b/src/agents/smtp/smtpc.c @@ -1698,11 +1698,12 @@ beginConversation: } if (Extensions & EXT_DSN) { - /* were there any dsn requests for this mail? */ - if (Recip->Flags & DSN_BODY) { - Ret += ConnWriteStr(Remote->conn, " RET=FULL"); - } else if (Recip->Flags & DSN_HEADER) { - Ret += ConnWriteStr(Remote->conn, " RET=HDRS"); + if (Recip->Flags & DSN_RET_EXPLICIT) { + if (Recip->Flags & DSN_BODY) { + Ret += ConnWriteStr(Remote->conn, " RET=FULL"); + } else if (Recip->Flags & DSN_HEADER) { + Ret += ConnWriteStr(Remote->conn, " RET=HDRS"); + } } if (Queue->envid != NULL && Queue->envid[0] != '\0') { @@ -1748,33 +1749,35 @@ beginConversation: ConnWriteF(Remote->conn, " ORCPT=%s", orcpt); } - /* NOTIFY section */ - ConnWriteStr(Remote->conn, " NOTIFY="); - if (Recip->Flags & (DSN_SUCCESS | DSN_FAILURE | DSN_TIMEOUT)) { - BOOL started=FALSE; + /* RFC 3461: do not invent NOTIFY when it was omitted on input. */ + if (Recip->Flags & DSN_NOTIFY_EXPLICIT) { + ConnWriteStr(Remote->conn, " NOTIFY="); + if (Recip->Flags & + (DSN_SUCCESS | DSN_FAILURE | DSN_TIMEOUT)) { + BOOL started=FALSE; - if (Recip->Flags & DSN_SUCCESS) { - ConnWriteStr(Remote->conn, "SUCCESS"); - started = TRUE; - } - - if (Recip->Flags & DSN_FAILURE) { - if (started) { - ConnWriteStr(Remote->conn, ","); + if (Recip->Flags & DSN_SUCCESS) { + ConnWriteStr(Remote->conn, "SUCCESS"); + started = TRUE; } - ConnWriteStr(Remote->conn, "FAILURE"); - started = TRUE; - } - if (Recip->Flags & DSN_TIMEOUT) { - if (started) { - ConnWriteStr(Remote->conn, ","); + if (Recip->Flags & DSN_FAILURE) { + if (started) { + ConnWriteStr(Remote->conn, ","); + } + ConnWriteStr(Remote->conn, "FAILURE"); + started = TRUE; } - ConnWriteStr(Remote->conn, "DELAY"); + + if (Recip->Flags & DSN_TIMEOUT) { + if (started) { + ConnWriteStr(Remote->conn, ","); + } + ConnWriteStr(Remote->conn, "DELAY"); + } + } else { + ConnWriteStr(Remote->conn, "NEVER"); } - } else { - /* no notification requested */ - ConnWriteStr(Remote->conn, "NEVER"); } } diff --git a/src/agents/smtp/smtpd.c b/src/agents/smtp/smtpd.c index adeca54..09af205 100644 --- a/src/agents/smtp/smtpd.c +++ b/src/agents/smtp/smtpd.c @@ -1464,16 +1464,19 @@ HandleConnection (void *param) goto QuitRcpt; } RecipientDSNFlags = Client->Flags & - (DSN_HEADER | DSN_BODY); + (DSN_HEADER | DSN_BODY | DSN_RET_EXPLICIT); if (!RcptParameters.notify_supplied) { - RecipientDSNFlags |= DSN_FAILURE; + RecipientDSNFlags |= DSN_FAILURE | DSN_TIMEOUT; } else if (RcptParameters.notify != SMTP_NOTIFY_NEVER) { + RecipientDSNFlags |= DSN_NOTIFY_EXPLICIT; if (RcptParameters.notify & SMTP_NOTIFY_SUCCESS) RecipientDSNFlags |= DSN_SUCCESS; if (RcptParameters.notify & SMTP_NOTIFY_FAILURE) RecipientDSNFlags |= DSN_FAILURE; if (RcptParameters.notify & SMTP_NOTIFY_DELAY) RecipientDSNFlags |= DSN_TIMEOUT; + } else { + RecipientDSNFlags |= DSN_NOTIFY_EXPLICIT; } if (RcptParameters.orcpt_supplied) { Orcpt = MemStrdup(RcptParameters.orcpt); @@ -1893,7 +1896,7 @@ HandleConnection (void *param) Client->MsgFlags = MSG_FLAG_ENCODING_8BITM; } if (MailParameters.ret_supplied) { - Client->Flags |= DSN_HEADER; + Client->Flags |= DSN_HEADER | DSN_RET_EXPLICIT; if (MailParameters.ret_full) Client->Flags |= DSN_BODY; } size = MailParameters.size; @@ -2128,7 +2131,7 @@ HandleConnection (void *param) if (!(Client->Flags & DSN_HEADER) && !(Client->Flags & DSN_BODY)) - Client->Flags |= DSN_HEADER; + Client->Flags |= DSN_HEADER | DSN_BODY; Client->State = STATE_FROM; ConnWrite (Client->client.conn, MSG250SENDEROK, MSG250SENDEROK_LEN); QuitMail: @@ -3121,14 +3124,16 @@ DeliverSMTPMessage (ConnectionStruct * Client, char *Sender, } if (Extensions & EXT_DSN) { - if (Recips[0].Flags & DSN_BODY) { - if (ConnWrite(Client->remotesmtp.conn, " RET=FULL", 9) < 1) { - DELIVER_ERROR (DELIVER_TRY_LATER); + if (Recips[0].Flags & DSN_RET_EXPLICIT) { + if (Recips[0].Flags & DSN_BODY) { + if (ConnWrite(Client->remotesmtp.conn, " RET=FULL", 9) < 1) { + DELIVER_ERROR (DELIVER_TRY_LATER); + } } - } - else if (Recips[0].Flags & DSN_HEADER) { - if (ConnWrite(Client->remotesmtp.conn, " RET=HDRS", 9) < 1) { - DELIVER_ERROR (DELIVER_TRY_LATER); + else if (Recips[0].Flags & DSN_HEADER) { + if (ConnWrite(Client->remotesmtp.conn, " RET=HDRS", 9) < 1) { + DELIVER_ERROR (DELIVER_TRY_LATER); + } } } if (EnvID) { @@ -3208,46 +3213,48 @@ DeliverSMTPMessage (ConnectionStruct * Client, char *Sender, } } } - if (Recips[i].Flags & (DSN_SUCCESS | DSN_FAILURE | DSN_TIMEOUT)) { + if (Recips[i].Flags & DSN_NOTIFY_EXPLICIT) { BOOL Comma = FALSE; if (ConnWrite(Client->remotesmtp.conn, " NOTIFY=", 8) < 1) { DELIVER_ERROR (DELIVER_TRY_LATER); } - if (Recips[i].Flags & DSN_SUCCESS) { - if (ConnWrite(Client->remotesmtp.conn, "SUCCESS", 7) < 1) { + if (Recips[i].Flags & + (DSN_SUCCESS | DSN_FAILURE | DSN_TIMEOUT)) { + if (Recips[i].Flags & DSN_SUCCESS) { + if (ConnWrite(Client->remotesmtp.conn, "SUCCESS", 7) < 1) { + DELIVER_ERROR (DELIVER_TRY_LATER); + } + Comma = TRUE; + } + if (Recips[i].Flags & DSN_TIMEOUT) { + if (Comma) { + if (ConnWrite(Client->remotesmtp.conn, ",DELAY", 6) < 1) { + DELIVER_ERROR (DELIVER_TRY_LATER); + } + } + else { + if (ConnWrite(Client->remotesmtp.conn, "DELAY", 5) < 1) { + DELIVER_ERROR (DELIVER_TRY_LATER); + } + } + Comma = TRUE; + } + if (Recips[i].Flags & DSN_FAILURE) { + if (Comma) { + if (ConnWrite(Client->remotesmtp.conn, ",FAILURE", 8) < 1) { + DELIVER_ERROR (DELIVER_TRY_LATER); + } + } + else { + if (ConnWrite(Client->remotesmtp.conn, "FAILURE", 7) < 1) { + DELIVER_ERROR (DELIVER_TRY_LATER); + } + } + } + } else { + if (ConnWrite(Client->remotesmtp.conn, "NEVER", 5) < 1) { DELIVER_ERROR (DELIVER_TRY_LATER); } - Comma = TRUE; - } - if (Recips[i].Flags & DSN_TIMEOUT) { - if (Comma) { - if (ConnWrite(Client->remotesmtp.conn, ",DELAY", 6) < 1) { - DELIVER_ERROR (DELIVER_TRY_LATER); - } - } - else { - if (ConnWrite(Client->remotesmtp.conn, "DELAY", 5) < 1) { - DELIVER_ERROR (DELIVER_TRY_LATER); - } - } - Comma = TRUE; - } - if (Recips[i].Flags & DSN_FAILURE) { - if (Comma) { - if (ConnWrite(Client->remotesmtp.conn, ",FAILURE", 8) < 1) { - DELIVER_ERROR (DELIVER_TRY_LATER); - } - } - else { - if (ConnWrite(Client->remotesmtp.conn, "FAILURE", 7) < 1) { - DELIVER_ERROR (DELIVER_TRY_LATER); - } - } - } - } - else { - if (ConnWrite(Client->remotesmtp.conn, " NOTIFY=NEVER", 13) < 1) { - DELIVER_ERROR (DELIVER_TRY_LATER); } } } diff --git a/src/agents/smtp/tests/protocol-test.c b/src/agents/smtp/tests/protocol-test.c index d9b82d0..6202e52 100644 --- a/src/agents/smtp/tests/protocol-test.c +++ b/src/agents/smtp/tests/protocol-test.c @@ -250,6 +250,12 @@ TestMailParameters(void) SMTP_PARAMETER_SYNTAX); assert(SMTPParseMailParameters("ENVID=bad+xx", ¶meters) == SMTP_PARAMETER_SYNTAX); + assert(SMTPParseMailParameters("ENVID=bad+00", ¶meters) == + SMTP_PARAMETER_SYNTAX); + assert(SMTPParseMailParameters("ENVID=bad+0A", ¶meters) == + SMTP_PARAMETER_SYNTAX); + assert(SMTPParseMailParameters("ENVID=space+20ok", ¶meters) == + SMTP_PARAMETER_OK); assert(SMTPParseMailParameters("UNKNOWN=value", ¶meters) == SMTP_PARAMETER_UNSUPPORTED); } @@ -282,6 +288,8 @@ TestRcptParameters(void) SMTP_PARAMETER_SYNTAX); assert(SMTPParseRcptParameters("ORCPT=rfc822;bad+XX", 0, ¶meters) == SMTP_PARAMETER_SYNTAX); + assert(SMTPParseRcptParameters("ORCPT=rfc822;bad+00", 0, ¶meters) == + SMTP_PARAMETER_SYNTAX); assert(SMTPParseRcptParameters("ORCPT=rfc822;a ORCPT=rfc822;b", 0, ¶meters) == SMTP_PARAMETER_SYNTAX); assert(SMTPParseRcptParameters("FUTURE=value", 0, ¶meters) ==