initial commit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Matthias Johnson 2026-02-27 15:09:25 -07:00
commit 75891c3271
129 changed files with 8046 additions and 0 deletions

View file

@ -0,0 +1,6 @@
---
# Rainloop webmail port (internal to docker network)
rainloop_port: 8888
# Rspamd web UI port (internal to docker network)
rspamd_port: 11334

View file

@ -0,0 +1,21 @@
- name: Restart mailserver
community.docker.docker_compose_v2:
project_src: /srv/mail
services:
- mailserver
state: restarted
build: never
- name: Restart Rainloop
community.docker.docker_compose_v2:
project_src: /srv/mail
services:
- rainloop
state: restarted
build: never
- name: Restart mail stack
community.docker.docker_compose_v2:
project_src: /srv/mail
state: restarted
build: never

View file

@ -0,0 +1,19 @@
# read-only docker exec always reports changed; changed_when: false suppresses spurious output
- name: List existing mail aliases
command: docker exec mailserver setup alias list
register: mail_alias_list
changed_when: false
tags:
- users
- name: Create mail aliases if missing
command: >
docker exec mailserver
setup alias add {{ item.from }}
{{ item.to if item.to is string else item.to | join(',') }}
loop: "{{ mail_aliases }}"
when: item.from not in mail_alias_list.stdout
tags:
- users

139
roles/mail/tasks/main.yml Normal file
View file

@ -0,0 +1,139 @@
- name: Allow SMTP traffic
ufw:
rule: allow
port: 25
proto: tcp
- name: Allow mail submission traffic
ufw:
rule: allow
port: 587
proto: tcp
- name: Allow IMAP over TLS traffic
ufw:
rule: allow
port: 993
proto: tcp
- name: Create docker-mailserver directory
file:
path: "/srv/mail"
state: directory
owner: root
group: docker # to allow access to the compose file
mode: '0755'
- name: Create docker-mailserver directories
file:
path: "/srv/mail/{{ item }}"
state: directory
owner: root
group: docker
mode: '0750'
loop:
- env
- config/rspamd/override.d
- name: Create maillogs directory
file:
path: /srv/mail/maillogs
state: directory
mode: '0755' # container startup script needs to traverse and chown subdirs
# stat+chown: avoids UID/GID lookup warnings for container-internal UIDs not present on host
- name: Stat maillogs directory
stat:
path: /srv/mail/maillogs
register: maillogs_stat
- name: Set maillogs directory ownership
command: chown 113:0 /srv/mail/maillogs
when: maillogs_stat.stat.uid != 113 or maillogs_stat.stat.gid != 0
- name: Create mailstate directory
file:
path: /srv/mail/mailstate
state: directory
owner: root
group: root
mode: '0755' # container startup script needs to traverse and chown subdirs
- name: Create maildata directory
file:
path: /srv/mail/maildata
state: directory
mode: '0751' # container startup script needs to traverse and chown subdirs
- name: Create config directory
file:
path: /srv/mail/config
state: directory
mode: '0751' # container startup script needs to traverse and chown subdirs
- name: Create rainloop data directory
file:
path: /srv/mail/rainloop/data
state: directory
mode: '0755'
# stat+chown: avoids UID/GID lookup warnings for container-internal UIDs not present on host
- name: Stat rainloop data directory
stat:
path: /srv/mail/rainloop/data
register: rainloop_data_stat
- name: Set rainloop data directory ownership
command: chown 991:991 /srv/mail/rainloop/data
when: rainloop_data_stat.stat.uid != 991 or rainloop_data_stat.stat.gid != 991
- name: Ensure certbot is installed
apt:
name: certbot
state: present
- name: Obtain a Let's Encrypt certificate for {{ mail_hostname }}
command: >
certbot certonly --standalone
-d {{ mail_hostname }}
--non-interactive --agree-tos -m postmaster@{{ domain }}
args:
creates: /etc/letsencrypt/live/{{ mail_hostname }}/fullchain.pem
tags: config
- name: Deploy mail compose file
template:
src: compose.yml.j2
dest: /srv/mail/compose.yml
notify: Restart mail stack
tags: config
- name: Deploy mailserver environment file
template:
src: mailserver.env.j2
dest: /srv/mail/env/mailserver.env
mode: '0640'
owner: root
group: docker
notify: Restart mailserver
tags: config
- name: Deploy rspamd web UI config
template:
src: worker-controller.inc.j2
dest: /srv/mail/config/rspamd/override.d/worker-controller.inc
mode: '0644'
notify: Restart mailserver
tags: config
- name: Start mailserver
community.docker.docker_compose_v2:
project_src: /srv/mail
state: present
build: never
tags: config
- import_tasks: users.yml
- import_tasks: aliases.yml
# webmail interface
- import_tasks: rainloop.yml

View file

@ -0,0 +1,21 @@
- name: Ensure Rainloop allowed domains are set
ini_file:
path: /srv/mail/rainloop/data/_data_/_default_/configs/application.ini
section: security
option: AllowedDomains
value: "{{ mail_domains | join(',') }}"
backup: yes
notify:
- Restart Rainloop
- name: Set proper mode of Rainloop data directory
file:
path: /srv/mail/rainloop
state: directory
recurse: yes
mode: u+rwX,g+rX
# chown -R always exits 0; changed_when: false suppresses spurious "changed" in playbook output
- name: Set proper ownership of Rainloop data directory
command: chown -R 991:991 /srv/mail/rainloop
changed_when: false

View file

@ -0,0 +1,26 @@
# read-only docker exec always reports changed; changed_when: false suppresses spurious output
- name: Check if mail user exists
command: docker exec mailserver setup email list
register: mail_user_list
changed_when: false
tags:
- users
- name: Create mail users if missing
ansible.builtin.command:
argv:
- docker
- exec
- mailserver
- setup
- email
- add
- "{{ item.address }}"
- "{{ item.password }}"
loop: "{{ mail_users }}"
when: item.address not in mail_user_list.stdout
no_log: true
ignore_errors: yes
tags:
- users

View file

@ -0,0 +1,60 @@
services:
mailserver:
image: docker.io/mailserver/docker-mailserver:{{ mailserver_version }}
container_name: mailserver
hostname: {{ mail_hostname.split('.')[0] }}
domainname: {{ domain }}
env_file: env/mailserver.env
ports:
- "25:25"
- "587:587"
- "993:993"
healthcheck:
test: ["CMD-SHELL", "supervisorctl status | grep -E 'postfix|dovecot' | grep -q RUNNING"]
interval: 30s
timeout: 10s
retries: 5
start_period: 120s
volumes:
- /srv/mail/config:/tmp/docker-mailserver
- /srv/mail/maildata:/var/mail
- /srv/mail/mailstate:/var/mail-state
- /srv/mail/maillogs:/var/log/mail
- /etc/localtime:/etc/localtime:ro
- /etc/letsencrypt:/etc/letsencrypt:ro
restart: unless-stopped
cap_add:
- NET_ADMIN
networks:
- mail
- webmail
rainloop:
image: hardware/rainloop:{{ rainloop_version }}
container_name: rainloop
restart: unless-stopped
depends_on:
mailserver:
condition: service_healthy
healthcheck:
test: ["CMD-SHELL", "wget -q --spider http://localhost:{{ rainloop_port }}/ || exit 1"]
interval: 10s
timeout: 5s
retries: 3
start_period: 30s
environment:
- RAINLOOP_ADMIN=admin
- RAINLOOP_ADMIN_PASSWORD={{ rainloop_admin_password }}
volumes:
- /srv/mail/rainloop/data:/rainloop/data
ports: [] # no host ports, only accessible via Docker network
networks:
- mail
- webmail
networks:
mail:
external: true
webmail:
external: true

View file

@ -0,0 +1,33 @@
DMS_DEBUG=0
# PERMIT_DOCKER=network
# Hostname + primary domain (split from mail_hostname variable)
HOSTNAME={{ mail_hostname.split('.')[0] }}
DOMAINNAME={{ domain }}
# Let's Encrypt
ENABLE_TLS=1
SSL_TYPE=letsencrypt
LETSENCRYPT_DOMAIN={{ mail_hostname }}
LETSENCRYPT_EMAIL={{ admin_user }}@{{ domain }}
# Override hostname (FQDN) and domains handled by this server
OVERRIDE_HOSTNAME={{ mail_hostname }}
OVERRIDE_DOMAIN={{ mail_domains | join(',') }}
# Other docker-mailserver options
POSTMASTER_ADDRESS=postmaster@{{ domain }}
ONE_DIR=1
ENABLE_OPENDKIM=0
# rspamd handles DMARC and SPF natively; enabling these would duplicate validation
ENABLE_OPENDMARC=0
ENABLE_POLICYD_SPF=0
ENABLE_AMAVIS=0
ENABLE_CLAMAV=0
ENABLE_FAIL2BAN=0
ENABLE_SPAMASSASSIN=0
ENABLE_RSPAMD=1
RSPAMD_LEARN=1
POSTFIX_MESSAGE_SIZE_LIMIT=26214400

View file

@ -0,0 +1,2 @@
# Rspamd web UI password
password = "{{ rspamd_web_password }}";