Configure direct-delivery incoming email for self-hosted sites with Mail-Receiver

How exactly does one disable the DMARC support?

I.e., adding INCLUDE_DMARC: false to the env section of mail-receiver.yml does not seem to do it. This does appear to cause the opendkim and opendmarc daemons to not run (leading to a warning in the logs), but SPF checking is still being performed.

Edited to add:
I think I managed to disable the SPF checks by also adding the following POSTCONF_ line to the env section:

env:
  ...
  INCLUDE_DMARC: false
  POSTCONF_smtpd_recipient_restrictions: check_policy_service unix:private/policy
  ...

I got this by looking at the commit which introduced the DMARC checks, and seeing what should happen when INCLUDE_DMARC is false.

I know next-to-nothing about how docker images are built, but I am getting the impression that the INCLUDE_DMARC flag is something meant to be set by someone else, somewhere else, at some other time — not something that can be done in mail-receiver.yml.

2 лайка

I’ve found the need to open port 443 on ufw — I got API Request Preparation Failed in the logs otherwise. I thought this is best mentioned because the standard installation instructions mention enabling ufw.

Port 25 is mentioned in the mail-receiver.yml and seems to bypass ufw.

1 лайк

Should the GitHub repo be in the OP?

3 лайка

Users of mail-receiver, please see Remove smtp_should_reject & discourse-smtp-fast-rejection

We’re going to remove fast-rejection entirely as the original feature was broken and causing problems for users, specifically this kind of thing:

and it also affects forwarded mail as the pre-delivery test was checking the envelope-from and envelope-to, whereas Discourse uses only the values in headers.

1 лайк

Я только что отправил PR по удалению лишних кавычек вокруг значения DISCOURSE_BASE_URL в примере файла mail-receiver.yml. Кавычки нарушали мою настройку. Удаление кавычек позволяет успешно завершить работу с этим документом.

Не могли бы вы объяснить, как именно? Наличие или отсутствие кавычек вокруг этого значения не вносит никаких различий:

[2] pry(main)> YAML::load("env:\n  DISCOURSE_BASE_URL: 'https://discourse.example.com'")
=> {"env"=>{"DISCOURSE_BASE_URL"=>"https://discourse.example.com"}}

[3] pry(main)> YAML::load("env:\n  DISCOURSE_BASE_URL: https://discourse.example.com")
=> {"env"=>{"DISCOURSE_BASE_URL"=>"https://discourse.example.com"}}

Когда я отслеживал логи этого контейнера и отправлял ему сообщения, я видел множество ошибок с упоминанием чего-то вроде «discourse.example.com не входит в MX-записи» или подобного. Я убрал кавычки, пересобрал контейнер, и всё заработало :person_shrugging:

Последовательность событий тоже может иметь значение:

  1. Я настроил и запустил контейнер mail-receiver.
  2. Через несколько дней я настроил MX DNS-записи.
  3. Я убедился, что MX-записи установлены правильно, и начал тестирование. Это не работало — postfix получал сообщения, но не доставлял их в Discourse, жалуясь на MX.
  4. Убрал кавычки, пересобрал контейнер, и всё заработало.

Поэтому я не уверен, связано ли решение с удалением кавычек или с пересборкой контейнера после создания MX-записей.

В худшем случае этот PR делает файл yml более последовательным :slight_smile:

1 лайк

Похоже, что предполагается, что получатель почты всегда находится в том же домене, что и основной форум. Что делать, если это не так, как настроить TLS?

Например:
forum => forum.domain.tld
mail-receiver => mail.domain.tld

В файле mail-receiver.yml TLS указывает на сертификаты основного форума. Есть ли способ заставить mail-receiver получить свои собственные сертификаты?

Я не знаю точного ответа, хотя подозреваю, что это потребует дополнительных опций в YAML-файле для внесения изменений в контейнер во время сборки.

Об этом будет сказано подробнее, но мне интересно, по какой причине вы хотите запустить его на другом домене. Mail-receiver сильно заточен под работу с парным экземпляром Discourse и без модификаций работает исключительно для получения писем для этого экземпляра, поэтому обычно разумно запускать его на том же домене.


Если вы посмотрите на некоторые шаблоны для включения в ваш YAML-файл Discourse, некоторые из которых уже используются, вы сможете найти подсказки о том, как запускать команды и изменять файлы через YAML (во время сборки контейнера).

web.onion.template.yml содержит примеры замены строк внутри файлов, а web.letsencrypt.ssl.template.yml — это тот, который добавляет Let’s Encrypt в основной контейнер Discourse.

Я не знаю, насколько сильно это зависит от элементов базового образа, поэтому, возможно, будет проще заставить основной контейнер Discourse получить второй сертификат, а затем просто изменить пути к сертификату и ключу в mail-receiver.yml.

Будьте осторожны с такими изменениями, если решите пойти этим путём, убедившись, что вы точно знаете, какой эффект они окажут. Ошибочное изменение в настройках Let’s Encrypt может привести к тихому отказу в обновлении сертификатов, например, что вы можете заметить только примерно через 3 месяца, когда посетители начнут получать ошибки истёкшего сертификата.

Configuring Mail-Receiver with Cloudflare

If you use Cloudflare’s proxy service with your self-hosted Discourse forum, additional configuration is required to receive incoming email. Cloudflare does not forward SMTP traffic (port 25) for proxied domains — mail sent to your domain will be silently dropped unless your DNS is configured correctly. The good news is there are several ways to solve this, depending on your security requirements and whether you want to keep everything on a single server or isolate your mail infrastructure.

The following table summarizes your three options at a high level:

Option 1 Option 2 Option 3
Description mail-receiver on current host, separate mail subdomain Option 1 + Certbot with DNS validation for TLS Dedicated VPS for mail-receiver
Works with Cloudflare proxy :white_check_mark: :white_check_mark: :white_check_mark:
Server IP exposed in DNS :white_check_mark: :white_check_mark: :white_check_mark: (mail VPS only - Discourse is hidden)
TLS encrypted SMTP :cross_mark: :white_check_mark: :white_check_mark:
Isolates mail from web server :cross_mark: :cross_mark: :white_check_mark:
Additional cost :cross_mark: :cross_mark: :white_check_mark:
Complexity Low Medium Medium

Option 1 is the quickest path to get mail-receiver running. You add a dedicated mail subdomain (e.g. mail.yourdomain.com) as a DNS-only record in Cloudflare pointing to your existing server, bypassing the proxy for SMTP traffic while leaving your main domain fully proxied. The trade-off is that your existing server IP becomes visible in DNS and there is no TLS on the SMTP connection.

Option 2 builds on Option 1 by adding a Let’s Encrypt certificate via Certbot’s Cloudflare DNS challenge, enabling TLS encrypted SMTP. Since the DNS challenge requires no port 80 and no web server downtime, this can be added to an existing server without disruption.

Option 3 moves mail-receiver onto a separate low-cost VPS (typically $4–6/month on providers like DigitalOcean). This fully isolates your mail infrastructure from your web server, meaning your main server IP is never exposed. It also gives you a clean environment with no port conflicts and is the recommended approach for production setups where security and separation of concerns matter.


Option 1 — mail-receiver on Current Host with a Separate Mail Subdomain

Since Cloudflare does not forward SMTP traffic for proxied domains, mail-receiver requires its own subdomain that bypasses the Cloudflare proxy entirely. Your main domain (e.g. forums.domain.tld) can remain fully proxied — only the mail subdomain needs to be DNS only.

DNS Configuration in Cloudflare

You will need to create two new DNS records. Both use the same IP address as your existing server.

1. Create an A record for the mail subdomain:

Type Name Value Proxy Status
A mail YOUR.SERVER.IP :radio_button: DNS Only (grey cloud)

It is critical that this record is set to DNS Only. If the orange proxy cloud is enabled, Cloudflare will intercept the traffic and SMTP will not work.

2. Create an MX record pointing to the new subdomain:

Type Name Value Priority
MX @ mail.domain.tld 10

Installation

Follow the main mail-receiver installation instructions with one change — set MAIL_DOMAIN in your configuration to the new mail subdomain rather than your main forum domain:

MAIL_DOMAIN: mail.domain.tld

Trade-offs

This option gets mail-receiver running with minimal effort and no additional cost. The two things to be aware of are that your server’s IP address will be publicly visible in DNS through the mail subdomain, and SMTP connections will not be TLS encrypted. If TLS is a requirement, see Option 2 which builds directly on this setup.


Option 2 — Option 1 with TLS Encrypted SMTP

Option 2 builds directly on Option 1 by adding a Let’s Encrypt certificate to enable TLS encrypted SMTP. All certificate work is done on the host server, not inside the Discourse or mail-receiver containers.

Because Discourse already occupies port 80, the standard Certbot HTTP validation method is not available. Instead, we use the DNS challenge method, which validates domain ownership by creating a temporary TXT record in Cloudflare. This requires no port 80 access, no web server downtime, and can be fully automated using a Cloudflare API token.

How the DNS Challenge Works

Certbot → Creates _acme-challenge.mail.domain.tld TXT record in Cloudflare
Let's Encrypt → Looks up that TXT record → Validates → Issues cert
Certbot → Deletes the TXT record automatically

Once the certificate is issued, the cert files are copied into the mail-receiver shared folder so the container can use them. Certbot handles renewals automatically in the background via a systemd timer — the only additional step is a deploy hook that copies the renewed cert files and restarts the container after each renewal.

Prerequisites

Before starting, complete all steps in Option 1. The DNS records and mail-receiver configuration from Option 1 remain unchanged — this option only adds the TLS certificate layer on top.

Trade-offs

This option gives you TLS encrypted SMTP at no additional cost while keeping everything on your existing server. The main consideration is that your server IP remains visible in DNS, the same as Option 1. If full infrastructure isolation is a requirement, see Option 3.

Setup

1 — Install the cerbot and the Cloudflare certbot plugin:

bash

apt install certbot python3-certbot-dns-cloudflare -y

2 — Create a Cloudflare API token:

  1. Go to Cloudflare → My Profile → API Tokens → Create Token
  2. Use the “Edit zone DNS” template
  3. Permissions: Zone → DNS → Edit
  4. Zone Resources: Include → Specific zone → lotuselan.net
  5. IP Restrictions: Set up only to allow from your servers ip address
  6. Copy the token

3 — Save the token to a credentials file:

bash

mkdir -p /etc/letsencrypt/cloudflare
nano /etc/letsencrypt/cloudflare/credentials.ini

Paste:

dns_cloudflare_api_token = YOUR_CLOUDFLARE_API_TOKEN

Lock down the file:

bash

chmod 600 /etc/letsencrypt/cloudflare/credentials.ini

4 — Request the cert:

Update the following command with your admin email and domain name.

bash

certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials /etc/letsencrypt/cloudflare/credentials.ini \
  --non-interactive \
  --agree-tos \
  --email youremailadress@domain.tld \
  -d mail.domain.tld

In your results, should be a statement that says:

Certbot has set up a scheduled task to automatically renew this certificate in the background.

Certbot will setup a cron to check the certs expiration twice a day. It will renew the certs when they are within 30 days of expiring. You can validate this by:

# Check if systemd timer is active (most modern Ubuntu systems)
systemctl status certbot.timer

# Or check if a cron job was added
cat /etc/cron.d/certbot

You now have the TLS certs on your server for the new mail-receiver domain name. They are not in a place that can be used.

5 — Setup a deployment script for moving files
Since certbot auto-renews, you only need your script to handle the Discourse-specific parts — copying the renewed certs and rebuilding the mail-receiver. You can simplify the script significantly by using certbot’s built-in deploy hook, which runs automatically after a successful renewal.

Create a deploy hook file:

bash

nano /etc/letsencrypt/renewal-hooks/deploy/mail-receiver-deploy.sh
chmod +x /etc/letsencrypt/renewal-hooks/deploy/mail-receiver-deploy.sh

Paste this into the file. Update the critical variables in the top section with your data (domain name and email address):

bash

#!/bin/bash
DOMAIN="mail.domain.tld"
DISCOURSE_DIR="/var/discourse"
CERT_SRC="/etc/letsencrypt/live/${DOMAIN}"
CERT_DEST_1="${DISCOURSE_DIR}/shared/mail-receiver/letsencrypt/${DOMAIN}"
CERT_DEST_2="${DISCOURSE_DIR}/shared/mail-receiver/letsencrypt/${DOMAIN}_ecc"
ADMIN_EMAIL="admin email address"
LOG_FILE="/var/log/mail-cert-renewal.log"

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}

log "=== Certbot deploy hook triggered for ${DOMAIN} ==="

# Copy certs (use -L to resolve symlinks)
for DEST in "$CERT_DEST_1" "$CERT_DEST_2"; do
    mkdir -p "$DEST"
    cp -L "${CERT_SRC}/fullchain.pem" "${DEST}/fullchain.pem"
    cp -L "${CERT_SRC}/privkey.pem"   "${DEST}/privkey.pem"
    cp -L "${CERT_SRC}/cert.pem"      "${DEST}/cert.pem"
    cp -L "${CERT_SRC}/chain.pem"     "${DEST}/chain.pem"
    chmod 644 "${DEST}/fullchain.pem" "${DEST}/cert.pem" "${DEST}/chain.pem"
    chmod 600 "${DEST}/privkey.pem"
    log "Certs copied to ${DEST}"
done

# Rebuild mail-receiver
cd "$DISCOURSE_DIR" || { echo "Cannot cd to ${DISCOURSE_DIR}" | mail -s "[FAILURE] Mail cert deploy hook failed" "$ADMIN_EMAIL"; exit 1; }
log "Rebuilding mail-receiver..."
if ./launcher rebuild mail-receiver >> "$LOG_FILE" 2>&1; then
    log "mail-receiver rebuilt successfully"
else
    log "ERROR: rebuild failed"
    echo "mail-receiver rebuild failed after cert renewal. Check ${LOG_FILE}" | \
        mail -s "[FAILURE] Mail cert deploy hook failed" "$ADMIN_EMAIL"
    exit 1
fi

log "=== Deploy hook completed successfully ==="

No manual cron job needed at all — certbot orchestrates the entire process. The deploy hook only fires when a renewal actually happens, so your mail-receiver won’t get unnecessary rebuilds on days certbot checks but doesn’t renew.

To test the renewal hook, run the following:

bash

bash /etc/letsencrypt/renewal-hooks/deploy/mail-receiver-deploy.sh

If everything was set up correctly, this will
→ copies certs to Discourse dirs
→ rebuilds mail-receiver
→ logs everything

6 — Setup TLS in mail-receiver.yml
You can now update the mail.receiver.yml file. Notice that the directory and volume locations are different

nano /var/discourse/containers/mail-receiver.yml

With the following parameters. Uncomment and enter your specific data.

   POSTCONF_smtpd_tls_key_file:  /letsencrypt/mail.domain.tld/privkey.pem
   POSTCONF_smtpd_tls_cert_file: /letsencrypt/mail.domain.tld/fullchain.pem
   POSTCONF_smtpd_tls_security_level: may

  - volume:
      host: /var/discourse/shared/mail-receiver/letsencrypt
      guest: /letsencrypt

You can rebuild the mail-receiver and validate everything works.

./launcher rebuild mail-receiver

To validate the logs

./launcher logs mail-receiver

The logs should come out clean. Start testing the receiving of replies directly to your server.

As an extra bonus, this will rebuild the mail-receiver ~ every 60 days. When you rebuild your main Discourse app, it pulls down the latest software. This way your mail-receiver stays current on a regular basis.


Option 3 — Dedicated VPS for mail-receiver

Option 3 moves mail-receiver off your main Discourse server entirely and onto a dedicated VPS. This is the recommended approach for production setups where security and separation of concerns matter. At approximately $4–6 per month on providers such as Hetzner, Vultr, or DigitalOcean, it is the only option with an additional cost but offers the cleanest and most isolated setup of the three.

Because this is a standalone server with no other services running, there are no port conflicts with Discourse and no constraints around port 80. This means you can use standard Certbot standalone mode for certificate issuance rather than the DNS challenge method required in Option 2, simplifying the certificate setup considerably.

How it Works

mail-receiver is deployed as a standalone Docker container on the new VPS. It listens on port 25, accepts inbound SMTP from the internet, and forwards processed email to your Discourse instance over HTTPS using the Discourse API — exactly the same as Options 1 and 2, just running on separate hardware.

Key Advantages Over Options 1 and 2

Your main server IP address is never exposed in DNS. The mail subdomain A record points to the new VPS IP rather than your Discourse server, meaning even if the mail subdomain is looked up, it reveals only the dedicated mail server. Your Discourse server remains fully hidden behind Cloudflare.

Additionally, any issues with the mail server — heavy spam load, a misconfiguration, or a required restart — have zero impact on your Discourse forum, and vice versa.

Trade-offs

This is the most involved setup of the three options as it requires provisioning and maintaining a second server. However, once configured it is largely self-managing — the container restarts automatically on failure, Certbot handles certificate renewals, and the quarterly update script keeps the image current. The ongoing administrative overhead is minimal.

Here’s the complete Option 3 instruction set written for a general audience:


Option 3 — Instructions

What You Will Need

  • A Discourse forum that is up and running
  • A domain managed in Cloudflare
  • A Discourse API key (instructions below)
  • A new VPS server running Ubuntu 24.04 — approximately $4–6/month from Hetzner, Vultr, or DigitalOcean
  • SSH access to the new VPS as root

Step 1 — Choose and Provision Your VPS

Sign up with a VPS provider of your choice. The minimum recommended specs are 1 vCPU and 1GB RAM — mail-receiver is lightweight and does not need much resources.

Recommended providers and approximate monthly cost:

Provider Plan Cost
Hetzner CX22 (2 vCPU, 4GB RAM) ~$4/month
Vultr Cloud Compute 1GB ~$6/month
DigitalOcean Basic Droplet 1GB ~$6/month

When provisioning the server:

  • Select Ubuntu 24.04 LTS as the operating system
  • Add your SSH public key during setup
  • Note the public IPv4 address assigned to the server — you will need it for DNS

Step 2 — Configure DNS in Cloudflare

Before touching the server, set up your DNS records. This gives DNS time to propagate while you work through the rest of the steps.

Log into Cloudflare and add the following two records for your domain:

A Record — points your mail subdomain to the new VPS:

Type Name Value Proxy Status
A mail YOUR.VPS.IP :radio_button: DNS Only (grey cloud)

MX Record — tells the internet where to deliver your email:

Type Name Value Priority
MX @ mail.yourdomain.tld 10

The A record must be set to DNS Only (grey cloud). If the Cloudflare proxy is enabled on this record, SMTP traffic will be blocked and mail will not be delivered.

Verify the records are resolving correctly before continuing:

# Run from any machine — should return your VPS IP, not a Cloudflare IP
nslookup mail.yourdomain.tld

# Should show mail.yourdomain.tld as the mail server
nslookup -type=MX yourdomain.tld

Cloudflare IPs always start with 104., 172.64–68., or 162.158. — if you see those, the A record is still proxied.


Step 3 — Initial Server Setup

SSH into your new VPS:

ssh root@YOUR.VPS.IP

Update the system:

apt update && apt upgrade -y

Set the server hostname to match your mail subdomain:

hostnamectl set-hostname mail.yourdomain.tld
hostname

Step 4 — Configure the Firewall

apt install ufw -y

# Allow SSH first — do this before enabling UFW or you will lock yourself out
ufw allow 22/tcp

# Allow inbound SMTP
ufw allow 25/tcp

# Enable the firewall
ufw enable

# Confirm the rules
ufw status

Step 5 — Disable the System Postfix

Ubuntu 24.04 sometimes installs Postfix by default. The mail-receiver container runs its own Postfix instance inside Docker, so the system one must be disabled to free up port 25.

# Check if anything is using port 25
ss -tlnp | grep :25

# If Postfix is listed, disable it
systemctl stop postfix
systemctl disable postfix

# Confirm port 25 is now free
ss -tlnp | grep :25

Step 6 — Install Docker

Remove any old Docker packages that may have come from Ubuntu’s default repository:

apt remove docker docker.io docker-compose docker-doc podman-docker -y

Install Docker from the official Docker repository:

# Install prerequisites
apt install ca-certificates curl gnupg -y

# Add Docker's official GPG key
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
  -o /etc/apt/keyrings/docker.asc
chmod a+r /etc/apt/keyrings/docker.asc

# Add the Docker apt repository
echo \
  "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] \
  https://download.docker.com/linux/ubuntu \
  $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
  tee /etc/apt/sources.list.d/docker.list > /dev/null

# Update apt and install Docker Engine with the Compose plugin
apt update
apt install -y docker-ce docker-ce-cli containerd.io \
  docker-buildx-plugin docker-compose-plugin

# Enable Docker to start automatically on boot
systemctl enable docker
systemctl start docker

# Verify both Docker and Compose are working
docker --version
docker compose version

Step 7 — Install Certbot

apt install certbot -y

# Verify
certbot --version

Step 8 — Open Port 80 and Obtain the TLS Certificate

This server has no web server running, so Certbot can use standalone mode — it temporarily spins up its own web server on port 80 just long enough to complete the Let’s Encrypt challenge, then shuts it down.

# Open port 80 temporarily
ufw allow 80/tcp

# Request the certificate
certbot certonly \
  --standalone \
  --non-interactive \
  --agree-tos \
  --email admin@yourdomain.tld \
  -d mail.yourdomain.tld

# Close port 80 — no longer needed
ufw delete allow 80/tcp

# Confirm the cert was issued
certbot certificates

# Confirm Certbot auto renewal setup
systemctl status certbot.timer

You should see output confirming the certificate path:

Certificate Name: mail.yourdomain.tld
Expiry Date: YYYY-MM-DD
Certificate Path: /etc/letsencrypt/live/mail.yourdomain.tld/fullchain.pem
Private Key Path: /etc/letsencrypt/live/mail.yourdomain.tld/privkey.pem

Configure Port 80 for Auto-Renewals

Certbot renews certificates automatically via a background systemd timer. Since port 80 is normally closed on this server, you need to tell Certbot to open and close it automatically during each renewal. Edit the renewal configuration:

nano /etc/letsencrypt/renewal/mail.yourdomain.tld.conf

Add these two lines under the [renewalparams] section:

pre_hook = ufw allow 80/tcp
post_hook = ufw delete allow 80/tcp

Test the auto-renewal configuration with a dry run:

certbot renew --dry-run

You should see the pre-hook open port 80, the challenge succeed, and the post-hook close port 80. The deploy hook will be shown as skipped — this is normal for dry runs, as no actual certificate is issued.


Step 9 — Create the Working Directory and Copy Certs

# Create the working directory structure
mkdir -p /opt/mail-receiver/certs/mail.yourdomain.tld
mkdir -p /opt/mail-receiver/postfix-spool

# Copy certs — the -L flag resolves Let's Encrypt symlinks to real files
cp -L /etc/letsencrypt/live/mail.yourdomain.tld/fullchain.pem \
       /opt/mail-receiver/certs/mail.yourdomain.tld/
cp -L /etc/letsencrypt/live/mail.yourdomain.tld/privkey.pem \
       /opt/mail-receiver/certs/mail.yourdomain.tld/
cp -L /etc/letsencrypt/live/mail.yourdomain.tld/cert.pem \
       /opt/mail-receiver/certs/mail.yourdomain.tld/
cp -L /etc/letsencrypt/live/mail.yourdomain.tld/chain.pem \
       /opt/mail-receiver/certs/mail.yourdomain.tld/

# Lock down the private key
chmod 600 /opt/mail-receiver/certs/mail.yourdomain.tld/privkey.pem

# Verify all four files are present
ls -la /opt/mail-receiver/certs/mail.yourdomain.tld/

Step 10 — Get Your Discourse API Key

On your Discourse forum:

  1. Log in as an admin
  2. Go to Admin → API → New API Key
  3. Set the following:
    • Description: mail-receiver
    • User Level: All Users
    • Scope: Global
  4. Click Save and copy the generated key — you will need it in the next step

Step 11 — Create the docker-compose.yml

nano /opt/mail-receiver/docker-compose.yml

Paste the following, replacing all values shown in capitals with your own:

services:
  mail-receiver:
    image: discourse/mail-receiver:release
    restart: always
    ports:
      - "25:25"
    volumes:
      # Postfix mail spool — persists mail across container restarts
      - ./postfix-spool:/var/spool/postfix
      # TLS certificates mounted into the container
      - ./certs:/letsencrypt
    environment:
      LC_ALL: en_US.UTF-8
      LANG: en_US.UTF-8
      LANGUAGE: en_US.UTF-8

      # The domain to accept mail for
      MAIL_DOMAIN: mail.yourdomain.tld

      # Your Discourse instance handle_mail endpoint
      DISCOURSE_MAIL_ENDPOINT: 'https://forums.yourdomain.tld/admin/email/handle_mail'
      DISCOURSE_BASE_URL: ''https://forums.yourdomain.tld'

      # API key from Step 10
      DISCOURSE_API_KEY: YOUR_API_KEY_HERE

      # Leave as system unless you have renamed that user in Discourse
      DISCOURSE_API_USERNAME: system

      # TLS certificate paths — these are paths inside the container
      POSTCONF_smtpd_tls_key_file:  /letsencrypt/mail.yourdomain.tld/privkey.pem
      POSTCONF_smtpd_tls_cert_file: /letsencrypt/mail.yourdomain.tld/fullchain.pem
      POSTCONF_smtpd_tls_security_level: may

      # Postfix hostname announcement
      POSTCONF_myhostname: mail.yourdomain.tld

Step 12 — Start the Container

cd /opt/mail-receiver

# Pull the latest image
docker compose pull

# Start the container in detached mode
docker compose up -d

# Confirm it is running
docker compose ps

# Watch the startup logs — look for 'daemon started' with no TLS errors
docker compose logs -f

A healthy startup looks like this:

postfix/master[1]: daemon started -- version 3.x.x, configuration /etc/postfix

Press Ctrl+C to stop following the logs.


Step 13 — Configure Discourse to Accept Incoming Mail

On your Discourse forum:

  1. Go to Admin → Settings → Email
  2. Enable reply by email
  3. Set reply by email address to: reply+%{reply_key}@mail.yourdomain.tld
  4. Go to Admin → Email → Incoming to monitor arriving mail

Step 14 — Test the Setup

Run these tests from your local machine, not the VPS:

# Test that port 25 is reachable and Postfix responds
telnet mail.yourdomain.tld 25
# Expected response: 220 mail.yourdomain.tld ESMTP Postfix

# Test the TLS handshake
openssl s_client -connect mail.yourdomain.tld:25 -starttls smtp
# Should display your Let's Encrypt certificate details with no errors

Then send a test email to any address at your mail domain (e.g. test@mail.yourdomain.tld) and watch the container logs:

docker compose -f /opt/mail-receiver/docker-compose.yml logs -f

A successful delivery looks like:

postfix/smtpd[x]: connect from mail-server.example.com
postfix/smtpd[x]: starttls=1
postfix/pipe[x]: status=sent (delivered via discourse service)

Step 15 — Set Up the Certbot Deploy Hook

Create a deploy hook so that after each automatic certificate renewal, the new cert files are copied into the container mount path and the container is restarted to load them:

nano /etc/letsencrypt/renewal-hooks/deploy/mail-receiver-deploy.sh
chmod +x /etc/letsencrypt/renewal-hooks/deploy/mail-receiver-deploy.sh

Paste the following, replacing the domain and email with your own:

#!/bin/bash
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

DOMAIN="mail.yourdomain.tld"
CERT_SRC="/etc/letsencrypt/live/${DOMAIN}"
CERT_DEST="/opt/mail-receiver/certs/${DOMAIN}"
LOG="/var/log/mail-cert-renewal.log"
ADMIN_EMAIL="admin@yourdomain.tld"

echo "[$(date '+%Y-%m-%d %H:%M:%S')] Cert renewal deploy hook triggered" >> "$LOG"

# Copy renewed certs
cp -L "${CERT_SRC}/fullchain.pem" "${CERT_DEST}/" && \
cp -L "${CERT_SRC}/privkey.pem"   "${CERT_DEST}/" && \
cp -L "${CERT_SRC}/cert.pem"      "${CERT_DEST}/" && \
cp -L "${CERT_SRC}/chain.pem"     "${CERT_DEST}/" || {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: cert copy failed" >> "$LOG"
    echo "mail-receiver cert copy failed" | \
      mail -s "[FAILURE] Cert renewal" "$ADMIN_EMAIL"
    exit 1
}

chmod 600 "${CERT_DEST}/privkey.pem"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Certs copied, restarting container..." >> "$LOG"

cd /opt/mail-receiver && docker compose restart mail-receiver >> "$LOG" 2>&1 || {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: container restart failed" >> "$LOG"
    echo "mail-receiver restart failed after cert renewal" | \
      mail -s "[FAILURE] Cert renewal restart" "$ADMIN_EMAIL"
    exit 1
}

echo "[$(date '+%Y-%m-%d %H:%M:%S')] Deploy hook completed successfully" >> "$LOG"

Step 16 — Set Up the Quarterly Update Script

Create the update script directly on the server:

nano /usr/local/bin/update-mail-receiver.sh
chmod +x /usr/local/bin/update-mail-receiver.sh

Paste the following, replacing the admin email with your own:

#!/bin/bash
# =============================================================================
# update-mail-receiver.sh
# Pulls the latest discourse/mail-receiver image and restarts the container
# Intended to be run quarterly via cron
# =============================================================================

# --- Configuration -----------------------------------------------------------
COMPOSE_DIR="/opt/mail-receiver"
COMPOSE_FILE="${COMPOSE_DIR}/docker-compose.yml"
ADMIN_EMAIL="admin@yourdomain.tld"        # <-- Change to your admin email
LOG_FILE="/var/log/mail-receiver-update.log"
IMAGE="discourse/mail-receiver:release"

# --- Helpers -----------------------------------------------------------------
SCRIPT_START=$(date '+%Y-%m-%d %H:%M:%S')
ERRORS=()

export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

log() {
    echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE"
}

log_error() {
    log "ERROR: $1"
    ERRORS+=("$1")
}

send_notification() {
    local subject="$1"
    local body="$2"
    if command -v mail &>/dev/null; then
        echo -e "$body" | mail -s "$subject" "$ADMIN_EMAIL"
        log "Notification sent to ${ADMIN_EMAIL}"
    else
        log "WARNING: mail command not available — install mailutils to enable notifications"
    fi
}

check_root() {
    if [[ $EUID -ne 0 ]]; then
        log_error "Script must be run as root"
        exit 1
    fi
}

check_dependencies() {
    log "--- Checking dependencies ---"
    for cmd in docker ufw; do
        if ! command -v "$cmd" &>/dev/null; then
            log_error "Required command not found: ${cmd}"
            return 1
        fi
    done
    if [ ! -f "$COMPOSE_FILE" ]; then
        log_error "docker-compose.yml not found at ${COMPOSE_FILE}"
        return 1
    fi
    log "All dependencies found"
}

# --- Step functions ----------------------------------------------------------

step_get_current_image_id() {
    log "--- Getting current image digest ---"
    CURRENT_IMAGE_ID=$(docker inspect --format='{{.Id}}' "$IMAGE" 2>/dev/null || echo "none")
    log "Current image ID: ${CURRENT_IMAGE_ID}"
}

step_pull_latest_image() {
    log "--- Pulling latest image: ${IMAGE} ---"
    if docker pull "$IMAGE" >> "$LOG_FILE" 2>&1; then
        log "Image pull completed"
    else
        log_error "Failed to pull latest image"
        return 1
    fi
}

step_check_if_updated() {
    log "--- Checking if image was updated ---"
    NEW_IMAGE_ID=$(docker inspect --format='{{.Id}}' "$IMAGE" 2>/dev/null || echo "none")
    log "New image ID:     ${NEW_IMAGE_ID}"

    if [ "$CURRENT_IMAGE_ID" = "$NEW_IMAGE_ID" ]; then
        log "Image is already up to date — no restart needed"
        IMAGE_UPDATED=false
    else
        log "New image detected — container will be restarted"
        IMAGE_UPDATED=true
    fi
}

step_restart_container() {
    if [ "$IMAGE_UPDATED" = false ]; then
        log "--- Skipping restart (image unchanged) ---"
        return 0
    fi

    log "--- Restarting container with new image ---"
    cd "$COMPOSE_DIR" || { log_error "Cannot cd to ${COMPOSE_DIR}"; return 1; }

    log "Stopping current container..."
    if docker compose down >> "$LOG_FILE" 2>&1; then
        log "Container stopped"
    else
        log_error "Failed to stop container"
        return 1
    fi

    log "Starting container with new image..."
    if docker compose up -d >> "$LOG_FILE" 2>&1; then
        log "Container started successfully"
    else
        log_error "Failed to start container"
        return 1
    fi

    # Brief pause to let postfix initialise
    sleep 5

    # Verify container is actually running
    if docker compose ps | grep -q "running\|Up"; then
        log "Container is confirmed running"
    else
        log_error "Container does not appear to be running after restart"
        return 1
    fi
}

step_cleanup_old_images() {
    log "--- Cleaning up unused Docker images ---"
    if docker image prune -f >> "$LOG_FILE" 2>&1; then
        log "Unused images cleaned up"
    else
        log "WARNING: Image cleanup failed — non-critical, continuing"
    fi
}

step_verify_port_25() {
    log "--- Verifying port 25 is listening ---"
    sleep 3
    if ss -tlnp | grep -q ':25'; then
        log "Port 25 is listening — Postfix is up"
    else
        log_error "Port 25 is NOT listening — Postfix may have failed to start"
        log "Run: docker compose -f ${COMPOSE_FILE} logs --tail=50"
        return 1
    fi
}

# --- Main --------------------------------------------------------------------
main() {
    log "============================================================"
    log "  mail-receiver update started: ${SCRIPT_START}"
    log "  Image: ${IMAGE}"
    log "============================================================"

    check_root
    check_dependencies  || { send_notification "[FAILURE] mail-receiver update failed" \
                              "Dependency check failed.\n\nCheck log: ${LOG_FILE}"; exit 1; }

    step_get_current_image_id
    step_pull_latest_image    || { send_notification "[FAILURE] mail-receiver update failed" \
                                   "Image pull failed.\n\nCheck log: ${LOG_FILE}"; exit 1; }
    step_check_if_updated
    step_restart_container    || { send_notification "[FAILURE] mail-receiver update failed" \
                                   "Container restart failed.\n\nCheck log: ${LOG_FILE}"; exit 1; }
    step_cleanup_old_images
    step_verify_port_25       || { send_notification "[FAILURE] mail-receiver update failed" \
                                   "Port 25 not listening after update.\n\nCheck log: ${LOG_FILE}"; exit 1; }

    # Final summary
    log "============================================================"
    if [ "$IMAGE_UPDATED" = true ]; then
        log "  UPDATE COMPLETED SUCCESSFULLY"
        log "  Old image: ${CURRENT_IMAGE_ID:0:20}..."
        log "  New image: ${NEW_IMAGE_ID:0:20}..."
        send_notification "[SUCCESS] mail-receiver updated" \
            "mail-receiver was successfully updated to a new image on ${SCRIPT_START}.\n\nCheck log: ${LOG_FILE}"
    else
        log "  CHECK COMPLETED — IMAGE ALREADY UP TO DATE"
    fi
    log "============================================================"
}

main "$@"

Test it manually before relying on cron:

/usr/local/bin/update-mail-receiver.sh

# Watch the log
tail -f /var/log/mail-receiver-update.log

Then add the cron job to run on the 1st of January, April, July and October at 2am:

crontab -e
0 2 1 1,4,7,10 * /usr/local/bin/update-mail-receiver.sh

The script will only email you and restart the container if a newer image is actually available. If the image is already up to date it completes silently with no disruption to mail delivery.


Useful Commands Reference

# View live logs
docker compose -f /opt/mail-receiver/docker-compose.yml logs -f

# Restart the container
docker compose -f /opt/mail-receiver/docker-compose.yml restart

# Stop and start
docker compose -f /opt/mail-receiver/docker-compose.yml stop
docker compose -f /opt/mail-receiver/docker-compose.yml up -d

# Update to the latest image manually
docker compose -f /opt/mail-receiver/docker-compose.yml pull
docker compose -f /opt/mail-receiver/docker-compose.yml up -d

# Enter the container for debugging
docker exec -it mail-receiver-mail-receiver-1 bash

# Check certbot timer is active
systemctl status certbot.timer

# View the cert renewal log
tail -f /var/log/mail-cert-renewal.log

# View the update log
tail -f /var/log/mail-receiver-update.log

Основная проблема здесь, по-видимому, заключается в том, что A-запись для forum.domain.tld скрыта прокси, а не в том, что явно требуется разместить почтовый сервер на отдельном домене.

При согласовании TLS общее имя сертификата сравнивается с именем хоста записи MX, то есть с именем хоста, к которому пытается подключиться клиент (которым может быть другой почтовый сервер), а не с A-записью, на которую он ссылается. Это означает, что вы можете создать A-запись mail.domain.tld с режимом «Только DNS», затем создать MX-запись для forum.domain.tld, указывающую на mail.domain.tld, и в такой конфигурации никаких дополнительных специальных действий не требуется.

Да, для A-записи вашего основного форума можно использовать только режим DNS. Однако такой подход лишает вас возможностей обратного глобального прокси CloudFlare. (Для моей установки Discourse этот вариант не был доступен.)

Именно поэтому в первой строке упоминается решение для сайтов, использующих прокси CloudFlare.

Я имел в виду, что A-запись mail.domain.tld установлена в режим «Только DNS», а не A-запись forum.domain.tld. Однако я понял, что неправильно интерпретировал способ аутентификации TLS-сертификатов SMTP-клиентами.

Наблюдаемое мной поведение было артефактом метода по умолчанию (оппортунистический режим), который не проверяет имя хоста. Поэтому моё утверждение о том, что проверяется имя хоста MX-записи, а не целевой сервер этой записи, было неверным. В большинстве случаев это работает, но не в ситуациях, когда для принудительной аутентификации TLS-идентичности используются DANE или MTA-STS.

Когда A-запись находится в режиме «Прокси», а MX-запись — в режиме «Только DNS», это не работает. В документации Cloudflare указано, что для любого домена, у которого A-запись проксируется, весь SMTP-трафик блокируется.

Я подтвердил это несколькими циклами тестирования. Как только вы отключаете прокси для A-записи, SMTP-данные начинают передаваться. Включите прокси — и SMTP-данные никогда не будут передаваться. (Тесты проводились с использованием TELNET на порт 25.)

Таким образом, если вы хотите:

  • Чтобы ваш форум Discourse использовал услуги прокси Cloudflare
  • Чтобы ваш SMTP-сервер для получения почты принимал письма

вам необходимо использовать разные домены для входящей почты.

Если вы хотите использовать TLS для вашего SMTP-сервера получения почты:

  • вам нужно настроить LetsEncrypt с проверкой через DNS.

Инструкции могут показаться пугающими, но на их написание ушло больше времени, чем на реализацию решения.

Я имел в виду не это. Конкретно я предлагал создать три DNS-записи:
A: forum.domain.tld → IP-адрес хоста (прокси включено)
A: mail.domain.tld → IP-адрес хоста (режим «Только DNS»)
MX: forum.domain.tldmail.domain.tld

Однако, как уже упоминалось, позже я понял, что это сработает только в режиме по умолчанию «Оптимальный TLS». Это не сработает, если вы (кто-либо) также захотите включить DANE или MTA-STS для принудительной аутентификации идентификаторов (чтобы гарантировать подключение к правильному серверу, а не просто шифрование трафика).

Они выглядят очень хорошо, легко следуют логике и выполняют всё вне контейнера, поэтому нет риска, что они могут нарушить работу при обновлениях Discourse. Мне особенно нравится использование хука обновления certbot, с которым я раньше не был знаком.

1 лайк

Обратите внимание, что это раскрывает IP-адрес вашего форума и позволяет обходить механизмы защиты Cloudflare, такие как защита от DDoS-атак и WAF. Лучше запускать почтовый приёмник на отдельном сервере.

Моя первоначальная цель состояла в том, чтобы запустить mail-receiver на другом сервере именно по этой причине. Каждый раз, когда я пытался запустить приложение-лаунчер для запуска mail-receiver, оно требовало установки полной системы Discourse. Есть ли простой способ запустить и выполнить mail-receiver только на отдельном сервере Docker?

Я не очень хорошо знаком с приложением-лаунчером, так как мы разрабатываем собственную инфраструктуру, но моя идея заключается в том, чтобы не использовать приложение-лаунчер, а запускать его как любой другой контейнер. Необходимые переменные окружения указаны в readme.

@Simon_Manning и @RGJ — Запись о Cloudflare была обновлена, чтобы предоставить три основных варианта и их компромиссы. Надеемся, это решит различные вопросы, которые вы подняли относительно первых предложенных вариантов.

@kelv, возможно, стоит добавить сноску в основное описание записи о CloudFlare. Это сэкономит время тем, кого это касается. Configure direct-delivery incoming email for self-hosted sites with Mail-Receiver - #541 by LotusJeff

2 лайка