From 525c90398dffc8879436e056ce980d6e5d95a7f3 Mon Sep 17 00:00:00 2001 From: Liam Noonan Date: Tue, 3 Mar 2026 07:06:28 +0000 Subject: [PATCH 1/5] [IMP] Add incoming mailgate Using the odoo mailgate script, pipe maip directly into odoo without having to wait for the IMAP cronjob. In order to do this, some adjustments had to be made to the inverse proxy because TLS needs to be terminated in postfix. --- inverseproxy.yaml | 8 +++++-- mail-prod-config/user-patches.sh | 41 ++++++++++++++++++++++++++++++++ prod.yaml | 36 ++++++++++++++++++++++++++++ traefik_dynamic.yaml | 5 ++++ 4 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 mail-prod-config/user-patches.sh create mode 100644 traefik_dynamic.yaml diff --git a/inverseproxy.yaml b/inverseproxy.yaml index 54849d4..56da001 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" diff --git a/mail-prod-config/user-patches.sh b/mail-prod-config/user-patches.sh new file mode 100644 index 0000000..b9f7708 --- /dev/null +++ b/mail-prod-config/user-patches.sh @@ -0,0 +1,41 @@ +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.sh" + +# 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.sh +#!/bin/sh +if ! /usr/bin/python3 /usr/local/bin/odoo-mailgate.py -d $ODOO_DB -u $ODOO_USER_ID -p $ADMIN_PASSWORD --host odoo --port 8069; then + echo "Odoo mailgate failed. Discarding message." | logger -t odoo-mailgate -p mail.warn + exit 0 +fi +EOF +chmod 755 /usr/local/bin/odoo-mailgate-wrapper.sh diff --git a/prod.yaml b/prod.yaml index 2060729..7b5d171 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,11 +172,31 @@ 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 backup: extends: @@ -194,3 +227,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 From 4e302efe821c19f50e5d8f6534955f50d7b4c228 Mon Sep 17 00:00:00 2001 From: Liam Noonan Date: Tue, 3 Mar 2026 06:44:49 +0000 Subject: [PATCH 2/5] [FIX] Switch SMTP to smtp2go Brevo changed the Message-ID and had no way of disabling this behavior. Without Message-ID we cannot track messages on replies in some cases. Smtp2go does not do this --- .copier-answers.yml | 6 +++--- common.yaml | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) 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" From efc416592b3dc18e4954055beeaeba9c1b72b0c9 Mon Sep 17 00:00:00 2001 From: Liam Noonan Date: Tue, 3 Mar 2026 06:50:41 +0000 Subject: [PATCH 3/5] [IMP] Add docker-spf-whitelisting This tool I built allows us to only accept connections on 12525 from servers listed in google's SPF record as well as from their special unverified sending domain unverified-forwarding.1e100.net --- inverseproxy.yaml | 1 + prod.yaml | 24 ++++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/inverseproxy.yaml b/inverseproxy.yaml index 56da001..e1f592e 100644 --- a/inverseproxy.yaml +++ b/inverseproxy.yaml @@ -80,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/prod.yaml b/prod.yaml index 7b5d171..7b8fb9a 100644 --- a/prod.yaml +++ b/prod.yaml @@ -198,6 +198,30 @@ services: 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: extends: file: common.yaml From 660f0e4324b14361a0e75fa6f1eeec979ad3bcfd Mon Sep 17 00:00:00 2001 From: Liam Noonan Date: Tue, 3 Mar 2026 07:25:51 +0000 Subject: [PATCH 4/5] [IMP] Add authentication to mail hitting the mailgate We already have blocked all IPs but google's, but if someone really wanted to flood us with spam and knew what port is designated for incoming mail, he could set up his own mail route in google workspace pointing at our server/port and there is nothing we could do about it. Now we can check every email for a secret header that we add in our google workspace split delivery rule and only accept mail that has it, and therefore is guaranteed to be from our workspace setup. --- mail-prod-config/user-patches.sh | 93 +++++++++++++++++++++++++++++--- 1 file changed, 85 insertions(+), 8 deletions(-) diff --git a/mail-prod-config/user-patches.sh b/mail-prod-config/user-patches.sh index b9f7708..5eeea0c 100644 --- a/mail-prod-config/user-patches.sh +++ b/mail-prod-config/user-patches.sh @@ -26,16 +26,93 @@ postconf -Pe \ "$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.sh" +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.sh -#!/bin/sh -if ! /usr/bin/python3 /usr/local/bin/odoo-mailgate.py -d $ODOO_DB -u $ODOO_USER_ID -p $ADMIN_PASSWORD --host odoo --port 8069; then - echo "Odoo mailgate failed. Discarding message." | logger -t odoo-mailgate -p mail.warn - exit 0 -fi +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.sh +chmod 755 /usr/local/bin/odoo-mailgate-wrapper.py From 1be78ca8d8cee7d8809bc982dc6923366d4e9619 Mon Sep 17 00:00:00 2001 From: Liam Noonan Date: Tue, 3 Mar 2026 07:27:10 +0000 Subject: [PATCH 5/5] [FIX] Prevent odoo acting as open relay Add my patch to odoo: https://github.com/Pyxiris/odoo/tree/19.0-mail_disable_relay_notification Which prevents odoo from notifying external users of received external mail. --- odoo/custom/src/repos.yaml | 1 + 1 file changed, 1 insertion(+) 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: