diff --git a/.copier-answers.yml b/.copier-answers.yml index 0680e9d..d402e00 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -45,8 +45,8 @@ project_name: man18 smtp_canonical_default: manmanufacturing.com smtp_canonical_domains: [] smtp_default_from: notifications@manmanufacturing.com -smtp_relay_host: smtp-relay.brevo.com -smtp_relay_port: 587 -smtp_relay_user: 95fbdc001@smtp-brevo.com +smtp_relay_host: mail.smtp2go.com +smtp_relay_port: 2525 +smtp_relay_user: manmanufacturing.com smtp_relay_version: latest traefik_version: 3 diff --git a/common.yaml b/common.yaml index c6c887a..5105306 100644 --- a/common.yaml +++ b/common.yaml @@ -91,16 +91,16 @@ services: cap_add: - SYS_PTRACE environment: - DEFAULT_RELAY_HOST: "[smtp-relay.brevo.com]:587" + DEFAULT_RELAY_HOST: "[mail.smtp2go.com]:2525" DMS_DEBUG: 0 ENABLE_SRS: 1 ONE_DIR: 1 PERMIT_DOCKER: connected-networks POSTFIX_INET_PROTOCOLS: ipv4 POSTFIX_MESSAGE_SIZE_LIMIT: 52428800 # 50 MiB - RELAY_HOST: "smtp-relay.brevo.com" - RELAY_PORT: "587" - RELAY_USER: "95fbdc001@smtp-brevo.com" + RELAY_HOST: "mail.smtp2go.com" + RELAY_PORT: "2525" + RELAY_USER: "manmanufacturing.com" SMTP_ONLY: 1 SRS_DOMAINNAME: "manmanufacturing.com" SRS_EXCLUDE_DOMAINS: "manmanufacturing.com" diff --git a/inverseproxy.yaml b/inverseproxy.yaml index 54849d4..e1f592e 100644 --- a/inverseproxy.yaml +++ b/inverseproxy.yaml @@ -8,10 +8,12 @@ services: private: public: volumes: - - acme:/etc/traefik/acme:rw,Z + - ./traefik_dynamic.yaml:/etc/traefik/traefik_dynamic.yaml:ro + - acme:/etc/traefik/acme:rw,z ports: - 80:80 - 443:443 + - 12525:12525 depends_on: - dockersocket restart: unless-stopped @@ -21,6 +23,7 @@ services: LEGO_EXPERIMENTAL_CNAME_SUPPORT: "true" tty: true command: + - "--entrypoints.smtp-incoming.address=:12525" - "--entrypoints.web-insecure.address=:80" - "--entrypoints.web-main.transport.respondingTimeouts.idleTimeout=60s" - "--entrypoints.web-main.http.middlewares=global-error-502@docker" @@ -29,11 +32,12 @@ services: - "--providers.docker.exposedbydefault=false" - "--providers.docker.network=inverseproxy_shared" - "--providers.docker=true" + - "--providers.file.filename=/etc/traefik/traefik_dynamic.yaml" - "--entrypoints.web-main.address=:443" - "--entrypoints.web-main.http.tls.certResolver=letsencrypt" - "--certificatesresolvers.letsencrypt.acme.caserver=https://acme-v02.api.letsencrypt.org/directory" - "--certificatesresolvers.letsencrypt.acme.email=admin@manmanufacturing.com" - - "--certificatesresolvers.letsencrypt.acme.storage=/etc/traefik/acme/acme-v2.json" + - "--certificatesresolvers.letsencrypt.acme.storage=/etc/traefik/acme/acme.json" - "--entrypoints.web-insecure.http.redirections.entryPoint.to=web-main" - "--entrypoints.web-insecure.http.redirections.entryPoint.scheme=https" - "--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web-insecure" @@ -76,6 +80,7 @@ networks: internal: true driver_opts: encrypted: 1 + com.docker.network.bridge.name: "inv_shared_br" private: internal: true driver_opts: diff --git a/mail-prod-config/user-patches.sh b/mail-prod-config/user-patches.sh new file mode 100644 index 0000000..5eeea0c --- /dev/null +++ b/mail-prod-config/user-patches.sh @@ -0,0 +1,118 @@ +echo "Applying user patches..." + +# This lets postfix know that it is ok to relay these domains. Otherwise +# it would just reject the incoming mail with "Relay access denied". +postconf -e "relay_domains = $ODOO_RECEIVING_DOMAINS" + +# Copy the standard smtp service and make a new one on port 12525 +PORT=${MAILGATE_SMTP_PORT:-12525} + +# Define a custom cleanup service that disables SRS (canonical maps) +# If we do not do this SRS rewrites the from emal making a bit of a mess. +# ** Note: This is only set on the smtpd-incoming-odoo service, +# so outgoing will continue to work with SRS. ** +postconf -Me "cleanup-odoo/unix=cleanup-odoo unix n - n - 0 cleanup" +postconf -Pe \ + "cleanup-odoo/unix/sender_canonical_maps=" \ + "cleanup-odoo/unix/recipient_canonical_maps=" + +postconf -Me "$PORT/inet=$PORT inet n - n - - smtpd" +# Add configs to this new smtp service +postconf -Pe \ + "$PORT/inet/syslog_name=postfix/smtpd-incoming-odoo" \ + "$PORT/inet/cleanup_service_name=cleanup-odoo" \ + "$PORT/inet/smtpd_upstream_proxy_protocol=haproxy" \ + "$PORT/inet/content_filter=odoo_mailgate:dummy" \ + "$PORT/inet/local_recipient_maps=" \ + "$PORT/inet/smtpd_recipient_restrictions=permit_mynetworks,permit_auth_destination,reject" + +postconf -Me "odoo_mailgate/unix=odoo_mailgate unix - n n - - pipe user=nobody argv=/usr/local/bin/odoo-mailgate-wrapper.py" + +# Setup Odoo mailgate script +cp /tmp/mailgate/odoo-mailgate.py /usr/local/bin/odoo-mailgate.py +chmod 755 /usr/local/bin/odoo-mailgate.py +cat < /usr/local/bin/odoo-mailgate-wrapper.py +#!/usr/bin/env python3 +import sys +import subprocess +import shutil +import os + +def log_message(level, message): + """Logs a message to syslog via the logger command.""" + subprocess.run(['logger', '-t', 'odoo-mailgate', '-p', f'mail.{level}'], input=message.encode()) + +try: + # Configuration from environment variables + DB = "$ODOO_DB" + USER = "$ODOO_USER_ID" + PASSWORD = "$ADMIN_PASSWORD" + SECRET = "$HEADER_SECRET" + + if not all([DB, USER, PASSWORD, SECRET]): + log_message("err", "Odoo mailgate: Missing one or more required environment variables.") + sys.exit(78) # EX_CONFIG + + if ':' not in SECRET: + log_message("err", "Odoo mailgate: HEADER_SECRET format is invalid. Expected 'Key:Value'.") + sys.exit(78) # EX_CONFIG + + headers = [] + authorized = False + input_stream = sys.stdin.buffer + + secret_key, secret_val = [part.strip() for part in SECRET.split(':', 1)] + secret_key = secret_key.lower() + + # Read headers line by line to avoid loading full message + while True: + line = input_stream.readline() + if not line: + break + # An empty line signifies the end of the headers + if line.strip() == b'': + headers.append(line) + break + + try: + # Decode safely to check string content + line_str = line.decode('utf-8', errors='ignore') + if ':' in line_str: + key, val = line_str.split(':', 1) + if key.strip().lower() == secret_key: + if val.strip() == secret_val: + authorized = True + continue + except (ValueError, IndexError): + pass + headers.append(line) + + if not authorized: + log_message("warn", "Odoo mailgate: Unauthorized email dropped (secret header not found or invalid).") + sys.exit(0) + + # Pipe to odoo-mailgate + cmd = ['/usr/bin/python3', '/usr/local/bin/odoo-mailgate.py', '-d', DB, '-u', USER, '-p', PASSWORD, '--host', 'odoo', '--port', '8069'] + proc = subprocess.Popen(cmd, stdin=subprocess.PIPE, text=False) + try: + proc.stdin.writelines(headers) + shutil.copyfileobj(input_stream, proc.stdin) + except BrokenPipeError: + pass + finally: + if proc.stdin: + proc.stdin.close() + + return_code = proc.wait() + + if return_code != 0: + log_message("warn", f"Odoo mailgate script failed with exit code {return_code}.") + sys.exit(return_code) + +except Exception as e: + # Fail safe: log the error and exit with a temporary failure code + # so the Mail Transfer Agent retries later. + log_message("err", f"Odoo mailgate wrapper failed with an unexpected error: {e}") + sys.exit(75) # EX_TEMPFAIL +EOF +chmod 755 /usr/local/bin/odoo-mailgate-wrapper.py diff --git a/odoo/custom/src/repos.yaml b/odoo/custom/src/repos.yaml index ad49ecc..f0a3155 100644 --- a/odoo/custom/src/repos.yaml +++ b/odoo/custom/src/repos.yaml @@ -14,6 +14,7 @@ merges: - ocb $ODOO_VERSION - odoo $ODOO_VERSION + - px-odoo 19.0-mail_disable_relay_notification ./web: defaults: diff --git a/prod.yaml b/prod.yaml index 2060729..7b8fb9a 100644 --- a/prod.yaml +++ b/prod.yaml @@ -7,6 +7,8 @@ services: service: odoo restart: unless-stopped hostname: "manmanufacturing.com" + volumes: + - mailgate:/opt/odoo/custom/src/odoo/addons/mail/static/scripts:rw,z env_file: - .docker/odoo.env - .docker/db-access.env @@ -52,12 +54,14 @@ services: traefik.http.services.man18-19-0-prod-main.loadbalancer.server.port: 8069 traefik.http.services.man18-19-0-prod-longpolling.loadbalancer.server.port: 8072 traefik.http.routers.man18-19-0-prod-main-0.rule: Host(`manmanufacturing.com`) + traefik.http.routers.man18-19-0-prod-main-0.entrypoints: web-insecure traefik.http.routers.man18-19-0-prod-main-0.service: man18-19-0-prod-main traefik.http.routers.man18-19-0-prod-main-0.middlewares: man18-19-0-prod-addSTS, man18-19-0-prod-buffering, man18-19-0-prod-compress, man18-19-0-prod-forceSecure traefik.http.routers.man18-19-0-prod-main-0.priority: 1 traefik.http.routers.man18-19-0-prod-main-secure-0.rule: Host(`manmanufacturing.com`) + traefik.http.routers.man18-19-0-prod-main-secure-0.entrypoints: web-main traefik.http.routers.man18-19-0-prod-main-secure-0.service: man18-19-0-prod-main traefik.http.routers.man18-19-0-prod-main-secure-0.middlewares: man18-19-0-prod-addSTS, man18-19-0-prod-buffering, man18-19-0-prod-compress, @@ -67,6 +71,7 @@ services: traefik.http.routers.man18-19-0-prod-main-secure-0.tls.certResolver: letsencrypt traefik.http.routers.man18-19-0-prod-longpolling-0.rule: Host(`manmanufacturing.com`) && Path(`/websocket`) + traefik.http.routers.man18-19-0-prod-longpolling-0.entrypoints: web-insecure traefik.http.routers.man18-19-0-prod-longpolling-0.service: man18-19-0-prod-longpolling traefik.http.routers.man18-19-0-prod-longpolling-0.middlewares: man18-19-0-prod-addSTS, man18-19-0-prod-forceSecure, @@ -74,6 +79,7 @@ services: traefik.http.routers.man18-19-0-prod-longpolling-0.priority: 20 traefik.http.routers.man18-19-0-prod-longpolling-secure-0.rule: Host(`manmanufacturing.com`) && Path(`/websocket`) + traefik.http.routers.man18-19-0-prod-longpolling-secure-0.entrypoints: web-main traefik.http.routers.man18-19-0-prod-longpolling-secure-0.service: man18-19-0-prod-longpolling traefik.http.routers.man18-19-0-prod-longpolling-secure-0.middlewares: man18-19-0-prod-addSTS, man18-19-0-prod-forceSecure, @@ -84,6 +90,7 @@ services: traefik.http.routers.man18-19-0-prod-forbiddenCrawlers-0.rule: Host(`manmanufacturing.com`) && (PathPrefix(`/web/`) || PathPrefix(`/website/info/`) || Path(`/web`) || Path(`/website/info`)) + traefik.http.routers.man18-19-0-prod-forbiddenCrawlers-0.entrypoints: web-insecure traefik.http.routers.man18-19-0-prod-forbiddenCrawlers-0.service: man18-19-0-prod-main traefik.http.routers.man18-19-0-prod-forbiddenCrawlers-0.middlewares: man18-19-0-prod-addSTS, man18-19-0-prod-buffering, man18-19-0-prod-compress, @@ -92,6 +99,7 @@ services: traefik.http.routers.man18-19-0-prod-forbiddenCrawlers-secure-0.rule: Host(`manmanufacturing.com`) && (PathPrefix(`/web/`) || PathPrefix(`/website/info/`) || Path(`/web`) || Path(`/website/info`)) + traefik.http.routers.man18-19-0-prod-forbiddenCrawlers-secure-0.entrypoints: web-main traefik.http.routers.man18-19-0-prod-forbiddenCrawlers-secure-0.service: man18-19-0-prod-main traefik.http.routers.man18-19-0-prod-forbiddenCrawlers-secure-0.middlewares: man18-19-0-prod-addSTS, man18-19-0-prod-buffering, man18-19-0-prod-compress, @@ -102,6 +110,7 @@ services: traefik.http.routers.man18-19-0-prod-allowedCrawlers-0.rule: Host(`manmanufacturing.com`) && (PathPrefix(`/web/image/website/`) || Path(`/web/image/website`)) + traefik.http.routers.man18-19-0-prod-allowedCrawlers-0.entrypoints: web-insecure traefik.http.routers.man18-19-0-prod-allowedCrawlers-0.service: man18-19-0-prod-main traefik.http.routers.man18-19-0-prod-allowedCrawlers-0.middlewares: man18-19-0-prod-addSTS, man18-19-0-prod-buffering, man18-19-0-prod-compress, @@ -110,6 +119,7 @@ services: traefik.http.routers.man18-19-0-prod-allowedCrawlers-secure-0.rule: Host(`manmanufacturing.com`) && (PathPrefix(`/web/image/website/`) || Path(`/web/image/website`)) + traefik.http.routers.man18-19-0-prod-allowedCrawlers-secure-0.entrypoints: web-main traefik.http.routers.man18-19-0-prod-allowedCrawlers-secure-0.service: man18-19-0-prod-main traefik.http.routers.man18-19-0-prod-allowedCrawlers-secure-0.middlewares: man18-19-0-prod-addSTS, man18-19-0-prod-buffering, man18-19-0-prod-compress, @@ -121,12 +131,14 @@ services: traefik.http.middlewares.man18-19-0-prod-redirect-1.redirectRegex.regex: ^(.*)://([^/]+)/(.*)$$ traefik.http.middlewares.man18-19-0-prod-redirect-1.redirectRegex.replacement: $$1://manmanufacturing.com/$$3 traefik.http.routers.man18-19-0-prod-redirect-1.rule: Host(`www.manmanufacturing.com`) + traefik.http.routers.man18-19-0-prod-redirect-1.entrypoints: web-insecure traefik.http.routers.man18-19-0-prod-redirect-1.service: man18-19-0-prod-main traefik.http.routers.man18-19-0-prod-redirect-1.middlewares: man18-19-0-prod-addSTS, man18-19-0-prod-compress, man18-19-0-prod-forceSecure, man18-19-0-prod-redirect-1 traefik.http.routers.man18-19-0-prod-redirect-1.priority: 10 traefik.http.routers.man18-19-0-prod-redirect-secure-1.rule: Host(`www.manmanufacturing.com`) + traefik.http.routers.man18-19-0-prod-redirect-secure-1.entrypoints: web-main traefik.http.routers.man18-19-0-prod-redirect-secure-1.service: man18-19-0-prod-main traefik.http.routers.man18-19-0-prod-redirect-secure-1.middlewares: man18-19-0-prod-addSTS, man18-19-0-prod-compress, man18-19-0-prod-forceSecure, @@ -138,6 +150,7 @@ services: - odoo - --workers=4 - --max-cron-threads=1 + - --from-filter=manmanufacturing.com db: extends: @@ -159,10 +172,54 @@ services: service: smtpreal env_file: - .docker/smtp.env + - .docker/odoo.env + volumes: + - inverseproxy_acme:/etc/letsencrypt:rw,z + - mailgate:/tmp/mailgate:ro,z + - ./mail-prod-config/user-patches.sh:/tmp/docker-mailserver/user-patches.sh + environment: + SSL_TYPE: letsencrypt + SSL_DOMAIN: manmanufacturing.com + MAILGATE_SMTP_PORT: &incoming_smtp_port 12525 + ODOO_DB: prod + ODOO_USER_ID: 2 + ODOO_RECEIVING_DOMAINS: manmanufacturing.com networks: default: aliases: - smtplocal + inverseproxy_shared: + restart: unless-stopped + labels: + traefik.enable: "true" + traefik.tcp.routers.man18-19-0-prod-smtp-tcp.entrypoints: smtp-incoming + traefik.tcp.routers.man18-19-0-prod-smtp-tcp.rule: HostSNI(`*`) + traefik.tcp.routers.man18-19-0-prod-smtp-tcp.service: man18-19-0-prod-smtp-tcp + traefik.tcp.services.man18-19-0-prod-smtp-tcp.loadbalancer.serverstransport: proxyprotocolv2@file + traefik.tcp.services.man18-19-0-prod-smtp-tcp.loadbalancer.server.port: 12525 + + email-gatekeeper: + image: ghcr.io/pyxiris/docker-spf-whitelisting:latest + privileged: false + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + - NET_ADMIN + network_mode: "host" + environment: + WATCHED_PORT: *incoming_smtp_port + # In order to whitelist inter container traffic from Traefik to the SMTP server, + # we need to whitelist the inverseproxy_shared subnet which is usually named + # br- and changes on restart. We give it a name using com.docker.network.bridge.name: + # so that we can find it in iptables without dangerouus permissions + BRIDGE_INTERFACE: inv_shared_br + WHITELISTED_SPF: _spf.google.com + WHITELISTED_REVERSE_DNS_ROOT_DOMAIN: unverified-forwarding.1e100.net + ENABLE_REVERSE_DNS: 1 + SPF_CHECK_INTERVAL: 86400 + CLEAN_ON_EXIT: 1 restart: unless-stopped backup: @@ -194,3 +251,6 @@ volumes: maillogs: maillogssupervisord: mailstate: + mailgate: + inverseproxy_acme: + external: true diff --git a/traefik_dynamic.yaml b/traefik_dynamic.yaml new file mode 100644 index 0000000..caec8e1 --- /dev/null +++ b/traefik_dynamic.yaml @@ -0,0 +1,5 @@ +tcp: + serversTransports: + proxyprotocolv2: + proxyProtocol: + version: 2