From 983c14733609a38675ffa31a0a84cd7ed6c868c3 Mon Sep 17 00:00:00 2001 From: DerLinkman Date: Wed, 29 Jan 2025 13:46:53 +0100 Subject: [PATCH 1/8] compose: rollback clamd version until next major... accidentally pushed --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index f38b8937c..b3688c0be 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -65,7 +65,7 @@ services: - redis clamd-mailcow: - image: ghcr.io/mailcow/clamd:1.71 + image: mailcow/clamd:1.66 restart: always depends_on: unbound-mailcow: From e1b4b03ea2b2cbf11814195491b4037bdf7485d5 Mon Sep 17 00:00:00 2001 From: Philipp Dreimann Date: Sun, 26 Oct 2025 17:12:24 +0100 Subject: [PATCH 2/8] introduce DISABLE_IPV6_SMTP_SENDING variable --- generate_config.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/generate_config.sh b/generate_config.sh index 393d2fced..8dd57e6db 100755 --- a/generate_config.sh +++ b/generate_config.sh @@ -436,6 +436,12 @@ SPAMHAUS_DQS_KEY= # A COMPLETE DOCKER STACK REBUILD (compose down && compose up -d) IS NEEDED TO APPLY THIS. ENABLE_IPV6=${IPV6_BOOL} +# Disable IPv6 for outgoing SMTP connections - y/n +# When set to 'y', forces Postfix to use IPv4 only for outgoing mail delivery +# This can help prevent delivery issues when IPv6 configuration is problematic +# Defaults to 'n' (IPv6 enabled for SMTP sending) +DISABLE_IPV6_SMTP_SENDING=n + # Prevent netfilter from setting an iptables/nftables rule to isolate the mailcow docker network - y/n # CAUTION: Disabling this may expose container ports to other neighbors on the same subnet, even if the ports are bound to localhost DISABLE_NETFILTER_ISOLATION_RULE=n From 0518654046a5729155981ed6dc24fb49945e0e8f Mon Sep 17 00:00:00 2001 From: Philipp Dreimann Date: Sun, 26 Oct 2025 18:12:08 +0100 Subject: [PATCH 3/8] initial version of the ipv6 smtp controller --- .../postfix/ipv6_smtp_controller.sh | 606 ++++++++++++++++++ 1 file changed, 606 insertions(+) create mode 100644 data/Dockerfiles/postfix/ipv6_smtp_controller.sh diff --git a/data/Dockerfiles/postfix/ipv6_smtp_controller.sh b/data/Dockerfiles/postfix/ipv6_smtp_controller.sh new file mode 100644 index 000000000..3499622bc --- /dev/null +++ b/data/Dockerfiles/postfix/ipv6_smtp_controller.sh @@ -0,0 +1,606 @@ +#!/usr/bin/env bash +# ipv6_smtp_controller.sh +# IPv6 SMTP sending eligibility controller for mailcow Postfix +# This script determines whether IPv6 should be disabled for outgoing SMTP connections +# based on configuration parameters, rDNS validation, and Spamhaus blocklist checks. + +# Color codes for logging +RED='\033[0;31m' +YELLOW='\033[1;33m' +GREEN='\033[0;32m' +LIGHT_GREEN='\033[1;32m' +LIGHT_RED='\033[1;31m' +NC='\033[0m' # No Color + +# Logging function +log_info() { + echo -e "${YELLOW}[IPv6 SMTP Controller]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[IPv6 SMTP Controller]${NC} $1" +} + +log_error() { + echo -e "${RED}[IPv6 SMTP Controller]${NC} $1" +} + +log_warning() { + echo -e "${LIGHT_RED}[IPv6 SMTP Controller]${NC} $1" +} + +# Extract IPv6 addresses from the system +# Returns global scope IPv6 addresses suitable for SMTP sending +# This function integrates with the existing IPv6 detection logic +get_ipv6_addresses() { + local ipv6_addresses=() + + # Check if IPv6 is available at all + if [[ ! -f /proc/net/if_inet6 ]] || grep -qs '^1' /proc/sys/net/ipv6/conf/all/disable_ipv6 2>/dev/null; then + log_info "IPv6 is not available or administratively disabled on the system" + return 1 + fi + + # Get all global IPv6 addresses (excluding link-local and loopback) + local all_addresses + all_addresses=$(ip -6 addr show scope global 2>/dev/null | grep 'inet6' | awk '{print $2}' | cut -d'/' -f1) + + if [[ -z "$all_addresses" ]]; then + log_info "No global IPv6 addresses found on the system" + return 1 + fi + + # Filter out any remaining non-global addresses + while IFS= read -r ipv6_addr; do + # Skip empty lines + [[ -z "$ipv6_addr" ]] && continue + + # Skip link-local (fe80::/10) and loopback (::1) + if [[ "$ipv6_addr" =~ ^fe80: ]] || [[ "$ipv6_addr" =~ ^::1$ ]]; then + continue + fi + + # Skip ULA (Unique Local Addresses - fc00::/7) if desired + # Uncomment the following line to skip ULA addresses: + # [[ "$ipv6_addr" =~ ^f[cd] ]] && continue + + ipv6_addresses+=("$ipv6_addr") + done <<< "$all_addresses" + + if [[ ${#ipv6_addresses[@]} -eq 0 ]]; then + log_info "No suitable global IPv6 addresses found for SMTP sending" + return 1 + fi + + # Export addresses for use by calling functions + printf '%s\n' "${ipv6_addresses[@]}" + return 0 +} + +# Check if IPv6 is supported and available for SMTP sending +# This function reuses logic from the existing ipv6_controller.sh +# Returns 0 if IPv6 is available, 1 otherwise +check_ipv6_availability() { + log_info "Checking IPv6 availability on the system..." + + # Check 1: IPv6 kernel support and administrative status + if [[ ! -f /proc/net/if_inet6 ]] || grep -qs '^1' /proc/sys/net/ipv6/conf/all/disable_ipv6 2>/dev/null; then + log_info "IPv6 is not available or administratively disabled on the system" + return 1 + fi + + # Check 2: Global IPv6 addresses + if ! ip -6 addr show scope global 2>/dev/null | grep -q 'inet6'; then + log_info "No global IPv6 addresses found on the system" + return 1 + fi + + # Check 3: IPv6 default route (optional but recommended) + if ! ip -6 route show default 2>/dev/null | grep -qE '^default'; then + log_warning "No default IPv6 route found - IPv6 connectivity may be limited" + # Don't fail here - we may still have working IPv6 without a default route + fi + + log_success "IPv6 is available on the system" + return 0 +} + +# Check rDNS resolution for IPv6 addresses +# Validates that IPv6 addresses resolve back to MAILCOW_HOSTNAME +# Returns 0 if rDNS is valid, 1 if invalid or check fails +check_rdns_resolution() { + local ipv6_address="$1" + local expected_hostname="${MAILCOW_HOSTNAME}" + + # Validate input parameters + if [[ -z "$ipv6_address" ]]; then + log_error "check_rdns_resolution: No IPv6 address provided" + return 1 + fi + + if [[ -z "$expected_hostname" ]]; then + log_error "check_rdns_resolution: MAILCOW_HOSTNAME is not set" + return 1 + fi + + log_info "Checking rDNS for IPv6 address: $ipv6_address" + log_info "Expected hostname: $expected_hostname" + + # Perform reverse DNS lookup with timeout + # Use host command with timeout to prevent hanging + local rdns_result + local timeout_seconds=5 + + # Try to resolve the IPv6 address to hostname + if command -v timeout >/dev/null 2>&1; then + rdns_result=$(timeout "$timeout_seconds" host -W "$timeout_seconds" "$ipv6_address" 2>&1) + else + # Fallback if timeout command is not available + rdns_result=$(host -W "$timeout_seconds" "$ipv6_address" 2>&1) + fi + + local host_exit_code=$? + + # Check if the command succeeded + if [[ $host_exit_code -ne 0 ]]; then + if [[ $host_exit_code -eq 124 ]] || [[ "$rdns_result" == *"timed out"* ]]; then + log_warning "rDNS lookup timed out for $ipv6_address" + else + log_warning "rDNS lookup failed for $ipv6_address: $rdns_result" + fi + return 1 + fi + + # Parse the result to extract hostname + # host command output format: "x.x.x.x.ip6.arpa domain name pointer hostname." + local resolved_hostname + resolved_hostname=$(echo "$rdns_result" | grep -i "domain name pointer" | awk '{print $NF}' | sed 's/\.$//') + + if [[ -z "$resolved_hostname" ]]; then + log_warning "No rDNS record found for $ipv6_address" + return 1 + fi + + log_info "Resolved hostname: $resolved_hostname" + + # Compare resolved hostname with expected hostname (case-insensitive) + if [[ "${resolved_hostname,,}" == "${expected_hostname,,}" ]]; then + log_success "rDNS validation passed: $resolved_hostname matches $expected_hostname" + return 0 + else + log_warning "rDNS validation failed: $resolved_hostname does not match $expected_hostname" + return 1 + fi +} + +# Check if IPv6 address is listed on Spamhaus blocklist +# Queries zen.spamhaus.org with optional DQS key authentication +# Returns 0 if NOT listed (clean), 1 if listed or check fails +check_spamhaus_listing() { + local ipv6_address="$1" + + # Validate input parameter + if [[ -z "$ipv6_address" ]]; then + log_error "check_spamhaus_listing: No IPv6 address provided" + return 1 + fi + + log_info "Checking Spamhaus blocklist for IPv6 address: $ipv6_address" + + # Convert IPv6 address to reverse DNS format for Spamhaus query + # IPv6 addresses need to be converted to nibble format (reverse hex digits with dots) + # Example: 2001:db8::1 becomes 1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2 + + # Expand IPv6 address to full format first + local expanded_ipv6 + if command -v python3 >/dev/null 2>&1; then + expanded_ipv6=$(python3 -c "import ipaddress; print(ipaddress.IPv6Address('$ipv6_address').exploded)" 2>/dev/null) + elif command -v python >/dev/null 2>&1; then + expanded_ipv6=$(python -c "import ipaddress; print(ipaddress.IPv6Address('$ipv6_address').exploded)" 2>/dev/null) + else + log_warning "Python not available for IPv6 address expansion, skipping Spamhaus check" + return 0 # Treat as not listed if we can't check + fi + + if [[ -z "$expanded_ipv6" ]]; then + log_warning "Failed to expand IPv6 address: $ipv6_address" + return 0 # Treat as not listed if expansion fails + fi + + # Convert expanded IPv6 to nibble format (reverse hex digits) + # Remove colons and reverse the string, then add dots between each character + local nibble_format + nibble_format=$(echo "$expanded_ipv6" | tr -d ':' | rev | sed 's/./&./g' | sed 's/\.$//') + + if [[ -z "$nibble_format" ]]; then + log_warning "Failed to convert IPv6 address to nibble format" + return 0 # Treat as not listed if conversion fails + fi + + # Determine which Spamhaus zone to query + local spamhaus_zone + if [[ -n "${SPAMHAUS_DQS_KEY}" ]]; then + # Use authenticated DQS endpoint + spamhaus_zone="${nibble_format}.${SPAMHAUS_DQS_KEY}.zen.dq.spamhaus.net" + log_info "Using authenticated Spamhaus DQS query" + else + # Use public endpoint + spamhaus_zone="${nibble_format}.zen.spamhaus.org" + log_info "Using public Spamhaus query (unauthenticated)" + fi + + # Perform DNS query with timeout and retry logic + local timeout_seconds=5 + local max_retries=2 + local retry_count=0 + local query_result + local query_exit_code + + while [[ $retry_count -lt $max_retries ]]; do + log_info "Querying Spamhaus (attempt $((retry_count + 1))/$max_retries): $spamhaus_zone" + + # Use host command to query the blocklist + if command -v timeout >/dev/null 2>&1; then + query_result=$(timeout "$timeout_seconds" host -W "$timeout_seconds" -t A "$spamhaus_zone" 2>&1) + else + query_result=$(host -W "$timeout_seconds" -t A "$spamhaus_zone" 2>&1) + fi + + query_exit_code=$? + + # Check the result + if [[ $query_exit_code -eq 0 ]]; then + # Query succeeded - check if address is listed + if echo "$query_result" | grep -q "has address"; then + # Extract the return code (127.0.0.x format) + local return_code + return_code=$(echo "$query_result" | grep "has address" | awk '{print $NF}' | head -n1) + + log_warning "IPv6 address IS LISTED on Spamhaus: $ipv6_address (return code: $return_code)" + return 1 # Listed - should disable IPv6 + else + log_success "IPv6 address is NOT listed on Spamhaus: $ipv6_address" + return 0 # Not listed - OK to use IPv6 + fi + elif [[ $query_exit_code -eq 1 ]] || echo "$query_result" | grep -qi "NXDOMAIN\|not found"; then + # NXDOMAIN means not listed - this is good + log_success "IPv6 address is NOT listed on Spamhaus: $ipv6_address" + return 0 # Not listed - OK to use IPv6 + elif [[ $query_exit_code -eq 124 ]] || echo "$query_result" | grep -qi "timed out"; then + # Timeout - retry + log_warning "Spamhaus query timed out (attempt $((retry_count + 1))/$max_retries)" + retry_count=$((retry_count + 1)) + + if [[ $retry_count -lt $max_retries ]]; then + sleep 2 # Wait before retry + fi + else + # Other error - could be rate limiting or API issue + log_warning "Spamhaus query failed: $query_result" + retry_count=$((retry_count + 1)) + + if [[ $retry_count -lt $max_retries ]]; then + sleep 2 # Wait before retry + fi + fi + done + + # If we exhausted retries, treat as check failure + # Default to allowing IPv6 (fail open) to avoid false positives + log_warning "Spamhaus check failed after $max_retries attempts, treating as NOT listed (fail-open)" + return 0 # Treat as not listed if check fails +} + +# Main function to check IPv6 SMTP sending eligibility +# Sets DISABLE_IPV6_SMTP_SENDING environment variable based on checks +# Implements comprehensive decision logic with fallback mechanisms +check_ipv6_smtp_sending_eligibility() { + echo "========================================================================" + log_info "Starting IPv6 SMTP sending eligibility check..." + log_info "Timestamp: $(date '+%Y-%m-%d %H:%M:%S %Z')" + echo "========================================================================" + + # Initialize decision tracking variables + local should_disable_ipv6=false + local disable_reason="" + local check_results=() + + # Track individual check outcomes for comprehensive logging + local config_check_result="not_checked" + local ipv6_availability_result="not_checked" + local ipv6_addresses_result="not_checked" + local rdns_check_result="not_checked" + local spamhaus_check_result="not_checked" + + # ============================================================================ + # Check 1: Read configuration parameter (highest priority) + # ============================================================================ + log_info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + log_info "CHECK 1: Configuration Parameter" + log_info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + if [[ -n "${DISABLE_IPV6_SMTP_SENDING}" ]]; then + log_info "DISABLE_IPV6_SMTP_SENDING is set to: '${DISABLE_IPV6_SMTP_SENDING}'" + + if [[ "${DISABLE_IPV6_SMTP_SENDING}" =~ ^([yY][eE][sS]|[yY]|[tT][rR][uU][eE]|1)$ ]]; then + log_warning "IPv6 SMTP sending is EXPLICITLY DISABLED via configuration parameter" + should_disable_ipv6=true + disable_reason="Explicitly disabled in configuration (DISABLE_IPV6_SMTP_SENDING=${DISABLE_IPV6_SMTP_SENDING})" + config_check_result="disabled_by_config" + check_results+=("CONFIG: Explicitly disabled") + + # Export and return immediately - configuration override takes precedence + export DISABLE_IPV6_SMTP_SENDING="true" + + echo "========================================================================" + log_warning "FINAL DECISION: IPv6 SMTP sending DISABLED" + log_warning "Reason: ${disable_reason}" + log_info "All checks bypassed due to explicit configuration" + echo "========================================================================" + return 0 + + elif [[ "${DISABLE_IPV6_SMTP_SENDING}" =~ ^([nN][oO]|[nN]|[fF][aA][lL][sS][eE]|0)$ ]]; then + log_success "Configuration parameter explicitly ALLOWS IPv6 SMTP sending" + config_check_result="enabled_by_config" + check_results+=("CONFIG: Explicitly enabled, proceeding with validation checks") + else + log_warning "Invalid DISABLE_IPV6_SMTP_SENDING value: '${DISABLE_IPV6_SMTP_SENDING}'" + log_warning "Valid values: yes/y/true/1 (disable) or no/n/false/0 (enable)" + log_info "Treating invalid value as 'no' (IPv6 enabled) and continuing checks" + config_check_result="invalid_value_treated_as_no" + check_results+=("CONFIG: Invalid value, defaulting to enabled") + fi + else + log_info "DISABLE_IPV6_SMTP_SENDING not set in configuration" + log_info "Defaulting to 'no' (IPv6 enabled) - will perform validation checks" + config_check_result="not_set_default_enabled" + check_results+=("CONFIG: Not set, defaulting to enabled") + fi + + # ============================================================================ + # Check 2: Verify IPv6 is available on the system + # ============================================================================ + log_info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + log_info "CHECK 2: IPv6 System Availability" + log_info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + if ! check_ipv6_availability; then + log_warning "IPv6 is NOT available on the system" + should_disable_ipv6=true + disable_reason="IPv6 not available on system (no kernel support or administratively disabled)" + ipv6_availability_result="not_available" + check_results+=("AVAILABILITY: IPv6 not available on system") + + # Export and return - no point in further checks + export DISABLE_IPV6_SMTP_SENDING="true" + + echo "========================================================================" + log_warning "FINAL DECISION: IPv6 SMTP sending DISABLED" + log_warning "Reason: ${disable_reason}" + log_info "Remaining checks skipped (IPv6 not available)" + echo "========================================================================" + return 0 + fi + + log_success "IPv6 is available on the system" + ipv6_availability_result="available" + check_results+=("AVAILABILITY: IPv6 available") + + # ============================================================================ + # Check 3: Extract IPv6 addresses from the system + # ============================================================================ + log_info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + log_info "CHECK 3: IPv6 Address Detection" + log_info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + local ipv6_addresses + ipv6_addresses=$(get_ipv6_addresses) + local get_addresses_result=$? + + if [[ $get_addresses_result -ne 0 ]] || [[ -z "$ipv6_addresses" ]]; then + log_warning "No suitable IPv6 addresses found for SMTP sending" + should_disable_ipv6=true + disable_reason="No global IPv6 addresses found on system" + ipv6_addresses_result="no_addresses_found" + check_results+=("ADDRESSES: No global IPv6 addresses found") + + # Export and return - no addresses to validate + export DISABLE_IPV6_SMTP_SENDING="true" + + echo "========================================================================" + log_warning "FINAL DECISION: IPv6 SMTP sending DISABLED" + log_warning "Reason: ${disable_reason}" + log_info "Remaining checks skipped (no IPv6 addresses)" + echo "========================================================================" + return 0 + fi + + # Count and display found addresses + local ipv6_address_count + ipv6_address_count=$(echo "$ipv6_addresses" | wc -l) + log_success "Found ${ipv6_address_count} global IPv6 address(es) for validation:" + + while IFS= read -r ipv6_addr; do + log_info " ➜ $ipv6_addr" + done <<< "$ipv6_addresses" + + ipv6_addresses_result="found_${ipv6_address_count}_addresses" + check_results+=("ADDRESSES: Found ${ipv6_address_count} global IPv6 address(es)") + + # ============================================================================ + # Check 4: Validate rDNS for IPv6 addresses + # ============================================================================ + log_info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + log_info "CHECK 4: Reverse DNS (rDNS) Validation" + log_info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + local rdns_check_passed=false + local rdns_check_attempted=false + local rdns_passed_count=0 + local rdns_failed_count=0 + + # Check rDNS for each IPv6 address + while IFS= read -r ipv6_addr; do + [[ -z "$ipv6_addr" ]] && continue + + rdns_check_attempted=true + + if check_rdns_resolution "$ipv6_addr"; then + rdns_check_passed=true + rdns_passed_count=$((rdns_passed_count + 1)) + log_success "✓ rDNS validation PASSED for: $ipv6_addr" + else + rdns_failed_count=$((rdns_failed_count + 1)) + log_warning "✗ rDNS validation FAILED for: $ipv6_addr" + fi + done <<< "$ipv6_addresses" + + # Evaluate rDNS check results + if [[ "$rdns_check_attempted" == "true" ]]; then + if [[ "$rdns_check_passed" == "false" ]]; then + log_error "rDNS validation FAILED for ALL ${rdns_failed_count} IPv6 address(es)" + log_error "This indicates improper reverse DNS configuration" + should_disable_ipv6=true + disable_reason="rDNS validation failed for all IPv6 addresses (${rdns_failed_count} failed)" + rdns_check_result="all_failed" + check_results+=("rDNS: FAILED for all ${rdns_failed_count} address(es)") + else + log_success "rDNS validation PASSED for ${rdns_passed_count} of ${ipv6_address_count} IPv6 address(es)" + if [[ $rdns_failed_count -gt 0 ]]; then + log_info "Note: ${rdns_failed_count} address(es) failed rDNS, but at least one passed" + fi + rdns_check_result="passed_${rdns_passed_count}_of_${ipv6_address_count}" + check_results+=("rDNS: PASSED for ${rdns_passed_count}/${ipv6_address_count} address(es)") + fi + else + log_warning "rDNS check was not attempted (no addresses to check)" + rdns_check_result="not_attempted" + check_results+=("rDNS: Not attempted") + fi + + # ============================================================================ + # Check 5: Query Spamhaus blocklist for IPv6 addresses + # ============================================================================ + log_info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + log_info "CHECK 5: Spamhaus Blocklist Validation" + log_info "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + + local spamhaus_check_passed=true + local spamhaus_check_attempted=false + local spamhaus_clean_count=0 + local spamhaus_listed_count=0 + + # Check Spamhaus for each IPv6 address + while IFS= read -r ipv6_addr; do + [[ -z "$ipv6_addr" ]] && continue + + spamhaus_check_attempted=true + + # check_spamhaus_listing returns 0 if NOT listed (clean), 1 if listed + if check_spamhaus_listing "$ipv6_addr"; then + spamhaus_clean_count=$((spamhaus_clean_count + 1)) + log_success "✓ Spamhaus check PASSED (not listed): $ipv6_addr" + else + spamhaus_listed_count=$((spamhaus_listed_count + 1)) + log_error "✗ Spamhaus check FAILED (LISTED): $ipv6_addr" + spamhaus_check_passed=false + # Don't break - check all addresses for complete logging + fi + done <<< "$ipv6_addresses" + + # Evaluate Spamhaus check results + if [[ "$spamhaus_check_attempted" == "true" ]]; then + if [[ "$spamhaus_check_passed" == "false" ]]; then + log_error "Spamhaus blocklist check FAILED: ${spamhaus_listed_count} address(es) are LISTED" + log_error "This indicates the IPv6 address(es) have poor reputation" + should_disable_ipv6=true + + # Update disable reason if not already set by rDNS + if [[ -z "$disable_reason" ]]; then + disable_reason="IPv6 address(es) listed on Spamhaus blocklist (${spamhaus_listed_count} listed)" + else + disable_reason="${disable_reason}; IPv6 address(es) listed on Spamhaus (${spamhaus_listed_count} listed)" + fi + + spamhaus_check_result="listed_${spamhaus_listed_count}_of_${ipv6_address_count}" + check_results+=("SPAMHAUS: FAILED - ${spamhaus_listed_count}/${ipv6_address_count} address(es) LISTED") + else + log_success "Spamhaus blocklist check PASSED for all ${spamhaus_clean_count} IPv6 address(es)" + spamhaus_check_result="all_clean" + check_results+=("SPAMHAUS: PASSED - all ${spamhaus_clean_count} address(es) clean") + fi + else + log_warning "Spamhaus check was not attempted (no addresses to check)" + spamhaus_check_result="not_attempted" + check_results+=("SPAMHAUS: Not attempted") + fi + + # ============================================================================ + # Final Decision Logic with Comprehensive Logging + # ============================================================================ + echo "========================================================================" + log_info "DECISION SUMMARY" + echo "========================================================================" + + # Log all check results + log_info "Check Results:" + for result in "${check_results[@]}"; do + log_info " • $result" + done + + echo "------------------------------------------------------------------------" + + # Make final decision and export result + if [[ "$should_disable_ipv6" == "true" ]]; then + log_error "FINAL DECISION: IPv6 SMTP sending will be DISABLED" + log_error "Reason: ${disable_reason}" + export DISABLE_IPV6_SMTP_SENDING="true" + + # Log troubleshooting information + echo "------------------------------------------------------------------------" + log_info "Troubleshooting Information:" + log_info " • Configuration: ${config_check_result}" + log_info " • IPv6 Availability: ${ipv6_availability_result}" + log_info " • IPv6 Addresses: ${ipv6_addresses_result}" + log_info " • rDNS Validation: ${rdns_check_result}" + log_info " • Spamhaus Check: ${spamhaus_check_result}" + + if [[ "$rdns_check_result" == "all_failed" ]]; then + echo "------------------------------------------------------------------------" + log_info "rDNS Fix Recommendations:" + log_info " 1. Ensure PTR records are configured for all IPv6 addresses" + log_info " 2. Verify PTR records resolve to: ${MAILCOW_HOSTNAME}" + log_info " 3. Check with your hosting provider or DNS administrator" + log_info " 4. Test with: host " + fi + + if [[ "$spamhaus_check_result" =~ ^listed_ ]]; then + echo "------------------------------------------------------------------------" + log_info "Spamhaus Listing Recommendations:" + log_info " 1. Check listing details at: https://check.spamhaus.org/" + log_info " 2. Follow Spamhaus delisting procedures if incorrectly listed" + log_info " 3. Consider using different IPv6 addresses" + log_info " 4. Review email sending practices and security" + fi + + else + log_success "FINAL DECISION: IPv6 SMTP sending will be ENABLED" + log_success "All validation checks passed successfully" + export DISABLE_IPV6_SMTP_SENDING="false" + + # Log success details + echo "------------------------------------------------------------------------" + log_info "Validation Summary:" + log_info " • Configuration: ${config_check_result}" + log_info " • IPv6 Availability: ${ipv6_availability_result}" + log_info " • IPv6 Addresses: ${ipv6_addresses_result}" + log_info " • rDNS Validation: ${rdns_check_result}" + log_info " • Spamhaus Check: ${spamhaus_check_result}" + fi + + echo "========================================================================" + log_info "IPv6 SMTP eligibility check completed at: $(date '+%Y-%m-%d %H:%M:%S %Z')" + echo "========================================================================" + + return 0 +} From de7e26f829309910f9a74e02682866c3452b061e Mon Sep 17 00:00:00 2001 From: Philipp Dreimann Date: Sun, 26 Oct 2025 18:19:41 +0100 Subject: [PATCH 4/8] adjust postfix.sh and master.cf to use the ipv6_smtp_controller.sh code --- data/Dockerfiles/postfix/postfix.sh | 15 +++++++++++++++ data/conf/postfix/master.cf | 3 +++ 2 files changed, 18 insertions(+) diff --git a/data/Dockerfiles/postfix/postfix.sh b/data/Dockerfiles/postfix/postfix.sh index 0a6494ed6..98c2a95cb 100755 --- a/data/Dockerfiles/postfix/postfix.sh +++ b/data/Dockerfiles/postfix/postfix.sh @@ -487,6 +487,21 @@ sed -i '/\$myhostname/! { /myhostname/d }' /opt/postfix/conf/extra.cf echo -e "myhostname = ${MAILCOW_HOSTNAME}\n$(cat /opt/postfix/conf/extra.cf)" > /opt/postfix/conf/extra.cf cat /opt/postfix/conf/extra.cf >> /opt/postfix/conf/main.cf +# Check IPv6 SMTP sending eligibility +source /usr/local/bin/ipv6_smtp_controller.sh +check_ipv6_smtp_sending_eligibility + +# Reset master.cf to base configuration +sed -i '/Overrides/q' /opt/postfix/conf/master.cf +echo >> /opt/postfix/conf/master.cf + +# Append IPv6 SMTP override if needed +if [[ "${DISABLE_IPV6_SMTP_SENDING}" == "true" ]]; then + echo -e "\n# IPv6 SMTP Override" >> /opt/postfix/conf/master.cf + echo "smtp unix - - n - - smtp" >> /opt/postfix/conf/master.cf + echo " -o inet_protocols=ipv4" >> /opt/postfix/conf/master.cf +fi + if [ ! -f /opt/postfix/conf/custom_transport.pcre ]; then echo "Creating dummy custom_transport.pcre" touch /opt/postfix/conf/custom_transport.pcre diff --git a/data/conf/postfix/master.cf b/data/conf/postfix/master.cf index d5114df28..1aa97161a 100644 --- a/data/conf/postfix/master.cf +++ b/data/conf/postfix/master.cf @@ -144,3 +144,6 @@ watchdog_discard unix - - n - - discard -o syslog_facility=local7 -o syslog_name=watchdog # end watchdog-specific + +# DO NOT EDIT ANYTHING BELOW # +# Overrides # From 886eef8a371ecb5cc6f92f1f1ec6516e3e9661b5 Mon Sep 17 00:00:00 2001 From: Philipp Dreimann Date: Wed, 29 Oct 2025 21:40:39 +0100 Subject: [PATCH 5/8] fix outgoing smtp configuration in postfix.sh --- data/Dockerfiles/postfix/postfix.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/Dockerfiles/postfix/postfix.sh b/data/Dockerfiles/postfix/postfix.sh index 98c2a95cb..e2a90667c 100755 --- a/data/Dockerfiles/postfix/postfix.sh +++ b/data/Dockerfiles/postfix/postfix.sh @@ -498,7 +498,7 @@ echo >> /opt/postfix/conf/master.cf # Append IPv6 SMTP override if needed if [[ "${DISABLE_IPV6_SMTP_SENDING}" == "true" ]]; then echo -e "\n# IPv6 SMTP Override" >> /opt/postfix/conf/master.cf - echo "smtp unix - - n - - smtp" >> /opt/postfix/conf/master.cf + echo "smtp inet n - n - 1 postscreen" >> /opt/postfix/conf/master.cf echo " -o inet_protocols=ipv4" >> /opt/postfix/conf/master.cf fi From 9ceee5d2573dda705d2ab0c55bc0255090047bfd Mon Sep 17 00:00:00 2001 From: Philipp Dreimann Date: Wed, 29 Oct 2025 21:43:48 +0100 Subject: [PATCH 6/8] include placeholders in master.cf --- data/conf/postfix/master.cf | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/data/conf/postfix/master.cf b/data/conf/postfix/master.cf index 1aa97161a..9f59f88a8 100644 --- a/data/conf/postfix/master.cf +++ b/data/conf/postfix/master.cf @@ -1,5 +1,7 @@ # inter-mx with postscreen on 25/tcp smtp inet n - n - 1 postscreen +# Override: IPv4 Only # + 10025 inet n - n - 1 postscreen -o postscreen_upstream_proxy_protocol=haproxy -o syslog_name=haproxy @@ -77,11 +79,13 @@ smtp_enforced_tls unix - - n - - smtp -o smtp_tls_security_level=encrypt -o syslog_name=enforced-tls-smtp -o smtp_delivery_status_filter=pcre:/opt/postfix/conf/smtp_dsn_filter +# Override: IPv4 Only # # smtp connector used, when a transport map matched # this helps to have different sasl maps than we have with sender dependent transport maps smtp_via_transport_maps unix - - n - - smtp -o smtp_sasl_password_maps=proxy:mysql:/opt/postfix/conf/sql/mysql_sasl_passwd_maps_transport_maps.cf +# Override: IPv4 Only # tlsproxy unix - - n - 0 tlsproxy dnsblog unix - - n - 0 dnsblog From 4d92f586487db3b6ac799bd0c7fe7d3655fd607e Mon Sep 17 00:00:00 2001 From: Philipp Dreimann Date: Wed, 29 Oct 2025 23:02:26 +0100 Subject: [PATCH 7/8] catch more cases for ipv4 only delivery --- data/Dockerfiles/postfix/postfix.sh | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/data/Dockerfiles/postfix/postfix.sh b/data/Dockerfiles/postfix/postfix.sh index e2a90667c..a2c1dc3ea 100755 --- a/data/Dockerfiles/postfix/postfix.sh +++ b/data/Dockerfiles/postfix/postfix.sh @@ -491,15 +491,13 @@ cat /opt/postfix/conf/extra.cf >> /opt/postfix/conf/main.cf source /usr/local/bin/ipv6_smtp_controller.sh check_ipv6_smtp_sending_eligibility -# Reset master.cf to base configuration -sed -i '/Overrides/q' /opt/postfix/conf/master.cf -echo >> /opt/postfix/conf/master.cf - -# Append IPv6 SMTP override if needed +# Process tags in master.cf based on IPv6 eligibility if [[ "${DISABLE_IPV6_SMTP_SENDING}" == "true" ]]; then - echo -e "\n# IPv6 SMTP Override" >> /opt/postfix/conf/master.cf - echo "smtp inet n - n - 1 postscreen" >> /opt/postfix/conf/master.cf - echo " -o inet_protocols=ipv4" >> /opt/postfix/conf/master.cf + # Convert magic tags to actual IPv4-only configuration + sed -i 's/# Override: IPv4 Only #/ -o inet_protocols=ipv4/' /opt/postfix/conf/master.cf +else + # Remove magic tags to allow IPv6 + sed -i '/# Override: IPv4 Only #/d' /opt/postfix/conf/master.cf fi if [ ! -f /opt/postfix/conf/custom_transport.pcre ]; then From faa5fc0e1aec974b5b6ac7e630cd8a212459ba2e Mon Sep 17 00:00:00 2001 From: Philipp Dreimann Date: Wed, 29 Oct 2025 23:32:39 +0100 Subject: [PATCH 8/8] remove python(2) call --- data/Dockerfiles/postfix/ipv6_smtp_controller.sh | 2 -- 1 file changed, 2 deletions(-) diff --git a/data/Dockerfiles/postfix/ipv6_smtp_controller.sh b/data/Dockerfiles/postfix/ipv6_smtp_controller.sh index 3499622bc..25676d39a 100644 --- a/data/Dockerfiles/postfix/ipv6_smtp_controller.sh +++ b/data/Dockerfiles/postfix/ipv6_smtp_controller.sh @@ -195,8 +195,6 @@ check_spamhaus_listing() { local expanded_ipv6 if command -v python3 >/dev/null 2>&1; then expanded_ipv6=$(python3 -c "import ipaddress; print(ipaddress.IPv6Address('$ipv6_address').exploded)" 2>/dev/null) - elif command -v python >/dev/null 2>&1; then - expanded_ipv6=$(python -c "import ipaddress; print(ipaddress.IPv6Address('$ipv6_address').exploded)" 2>/dev/null) else log_warning "Python not available for IPv6 address expansion, skipping Spamhaus check" return 0 # Treat as not listed if we can't check