Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .copier-answers.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
8 changes: 4 additions & 4 deletions common.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 7 additions & 2 deletions inverseproxy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand All @@ -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"
Expand Down Expand Up @@ -76,6 +80,7 @@ networks:
internal: true
driver_opts:
encrypted: 1
com.docker.network.bridge.name: "inv_shared_br"
private:
internal: true
driver_opts:
Expand Down
118 changes: 118 additions & 0 deletions mail-prod-config/user-patches.sh
Original file line number Diff line number Diff line change
@@ -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 <<EOF > /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
1 change: 1 addition & 0 deletions odoo/custom/src/repos.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
merges:
- ocb $ODOO_VERSION
- odoo $ODOO_VERSION
- px-odoo 19.0-mail_disable_relay_notification

./web:
defaults:
Expand Down
60 changes: 60 additions & 0 deletions prod.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -67,13 +71,15 @@ 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,
man18-19-0-prod-override-ws-headers
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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -138,6 +150,7 @@ services:
- odoo
- --workers=4
- --max-cron-threads=1
- --from-filter=manmanufacturing.com

db:
extends:
Expand All @@ -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-<randnum> 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:
Expand Down Expand Up @@ -194,3 +251,6 @@ volumes:
maillogs:
maillogssupervisord:
mailstate:
mailgate:
inverseproxy_acme:
external: true
5 changes: 5 additions & 0 deletions traefik_dynamic.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
tcp:
serversTransports:
proxyprotocolv2:
proxyProtocol:
version: 2