SnappyMail defaulted to localhost:143 for IMAP. Create csrx.ru.json domain config pointing to the mailserver container (shared front network): - IMAP: mailserver:993 SSL - SMTP: mailserver:587 STARTTLS with auth Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
340 lines
11 KiB
YAML
340 lines
11 KiB
YAML
---
|
|
- name: Create tools root directory
|
|
ansible.builtin.file:
|
|
path: "{{ tools_root }}"
|
|
state: directory
|
|
owner: "{{ deploy_user }}"
|
|
group: "{{ deploy_group }}"
|
|
mode: "0750"
|
|
|
|
- name: Create mailserver directories
|
|
ansible.builtin.file:
|
|
path: "{{ tools_root }}/mailserver/{{ item }}"
|
|
state: directory
|
|
owner: root
|
|
group: root
|
|
mode: "0755"
|
|
loop:
|
|
- mail-data
|
|
- mail-state
|
|
- mail-logs
|
|
- config
|
|
|
|
- name: Create snappymail data directory
|
|
ansible.builtin.file:
|
|
path: "{{ tools_root }}/snappymail/data"
|
|
state: directory
|
|
owner: "82" # www-data uid in Alpine (SnappyMail container user)
|
|
group: "82"
|
|
mode: "0755"
|
|
|
|
# ── TLS certificate for mail.csrx.ru (via certbot + Cloudflare DNS-01) ───────
|
|
- name: Install certbot and Cloudflare DNS plugin
|
|
ansible.builtin.apt:
|
|
name:
|
|
- certbot
|
|
- python3-certbot-dns-cloudflare
|
|
state: present
|
|
update_cache: false
|
|
|
|
- name: Deploy Cloudflare credentials for certbot
|
|
ansible.builtin.copy:
|
|
content: |
|
|
dns_cloudflare_api_token = {{ cloudflare_dns_api_token }}
|
|
dest: /etc/letsencrypt/cloudflare.ini
|
|
mode: "0600"
|
|
owner: root
|
|
group: root
|
|
|
|
- name: Obtain TLS certificate for mx.{{ domain_base }}
|
|
ansible.builtin.command: >
|
|
certbot certonly
|
|
--dns-cloudflare
|
|
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini
|
|
--email {{ acme_email }}
|
|
--agree-tos --no-eff-email
|
|
-d mx.{{ domain_base }}
|
|
--non-interactive
|
|
register: certbot_result
|
|
changed_when: "'Certificate not yet due for renewal' not in certbot_result.stdout"
|
|
failed_when:
|
|
- certbot_result.rc != 0
|
|
- "'Certificate not yet due for renewal' not in certbot_result.stdout"
|
|
- "'Certificate not yet due for renewal' not in certbot_result.stderr"
|
|
|
|
- name: Deploy certbot renewal deploy-hook (reload mailserver after cert renewal)
|
|
ansible.builtin.copy:
|
|
dest: /etc/letsencrypt/renewal-hooks/deploy/reload-mailserver.sh
|
|
mode: "0750"
|
|
owner: root
|
|
group: root
|
|
content: |
|
|
#!/usr/bin/env bash
|
|
# Triggered by certbot after successful cert renewal
|
|
# Reloads Postfix + Dovecot TLS without full restart
|
|
set -euo pipefail
|
|
if docker ps --format '{{ '{{' }}.Names{{ '}}' }}' | grep -q '^mailserver$'; then
|
|
docker exec mailserver supervisorctl restart postfix dovecot
|
|
echo "[$(date)] mailserver TLS reloaded after cert renewal"
|
|
fi
|
|
|
|
- name: Schedule certbot auto-renewal (twice daily, certbot renews only when needed)
|
|
ansible.builtin.cron:
|
|
name: "certbot renew"
|
|
minute: "15"
|
|
hour: "3,15"
|
|
job: "certbot renew --quiet --deploy-hooks 2>&1 | logger -t certbot"
|
|
user: root
|
|
state: present
|
|
|
|
# ── Open mail ports in UFW ────────────────────────────────────────────────────
|
|
- name: Allow SMTP inbound (port 25)
|
|
community.general.ufw:
|
|
rule: allow
|
|
port: "25"
|
|
proto: tcp
|
|
|
|
- name: Allow SMTP submission (port 587)
|
|
community.general.ufw:
|
|
rule: allow
|
|
port: "587"
|
|
proto: tcp
|
|
|
|
- name: Allow IMAPS (port 993)
|
|
community.general.ufw:
|
|
rule: allow
|
|
port: "993"
|
|
proto: tcp
|
|
|
|
- name: Allow SMTPS (port 465)
|
|
community.general.ufw:
|
|
rule: allow
|
|
port: "465"
|
|
proto: tcp
|
|
|
|
# ── Deploy configs and start stack ────────────────────────────────────────────
|
|
- name: Deploy docker-compose.yml
|
|
ansible.builtin.template:
|
|
src: docker-compose.yml.j2
|
|
dest: "{{ tools_root }}/docker-compose.yml"
|
|
owner: "{{ deploy_user }}"
|
|
group: "{{ deploy_group }}"
|
|
mode: "0640"
|
|
|
|
- name: Deploy .env
|
|
ansible.builtin.template:
|
|
src: env.j2
|
|
dest: "{{ tools_root }}/.env"
|
|
owner: "{{ deploy_user }}"
|
|
group: "{{ deploy_group }}"
|
|
mode: "0600"
|
|
|
|
- name: Pull images
|
|
community.docker.docker_image:
|
|
name: "{{ item }}"
|
|
source: pull
|
|
loop:
|
|
- "{{ outline_image }}"
|
|
- "{{ outline_db_image }}"
|
|
- "{{ outline_redis_image }}"
|
|
- "{{ n8n_image }}"
|
|
- "{{ mailserver_image }}"
|
|
- "{{ snappymail_image }}"
|
|
|
|
- name: Start tools stack
|
|
community.docker.docker_compose_v2:
|
|
project_src: "{{ tools_root }}"
|
|
state: present
|
|
pull: missing
|
|
|
|
# ── SnappyMail admin password — write bcrypt hash directly to application.ini ──
|
|
# djmaze/snappymail does not reliably apply SNAPPYMAIL_ADMIN_PASSWORD env var;
|
|
# instead we verify and update the hash in the config file on every deploy.
|
|
- name: Ensure SnappyMail admin password is set correctly
|
|
ansible.builtin.shell: |
|
|
python3 << 'PYEOF'
|
|
import subprocess, re, sys
|
|
|
|
config_path = "{{ tools_root }}/snappymail/data/_data_/_default_/configs/application.ini"
|
|
password = "{{ snappymail_admin_password }}"
|
|
|
|
try:
|
|
with open(config_path) as f:
|
|
content = f.read()
|
|
except FileNotFoundError:
|
|
print("CONFIG_NOT_FOUND")
|
|
sys.exit(1)
|
|
|
|
# Extract current hash (bcrypt hashes start with $2y$)
|
|
m = re.search(r'^admin_password\s*=\s*"?(\$2y\$[^"\n]+)', content, re.M)
|
|
current_hash = m.group(1).strip() if m else ""
|
|
|
|
if current_hash:
|
|
r = subprocess.run(
|
|
["docker", "exec", "snappymail", "php", "-r",
|
|
f"echo password_verify('{password}', '{current_hash}') ? 'yes' : 'no';"],
|
|
capture_output=True, text=True
|
|
)
|
|
if r.stdout.strip() == "yes":
|
|
print("ALREADY_SET")
|
|
sys.exit(0)
|
|
|
|
# Generate a new bcrypt hash using PHP inside the container
|
|
r = subprocess.run(
|
|
["docker", "exec", "snappymail", "php", "-r",
|
|
f"echo password_hash('{password}', PASSWORD_BCRYPT);"],
|
|
capture_output=True, text=True
|
|
)
|
|
new_hash = r.stdout.strip()
|
|
if not new_hash.startswith("$2y$"):
|
|
print(f"HASH_ERROR: {r.stderr}")
|
|
sys.exit(1)
|
|
|
|
new_content = re.sub(
|
|
r'^admin_password\s*=.*$',
|
|
f'admin_password = "{new_hash}"',
|
|
content, flags=re.M
|
|
)
|
|
with open(config_path, "w") as f:
|
|
f.write(new_content)
|
|
print("UPDATED")
|
|
PYEOF
|
|
register: snappymail_pw_result
|
|
changed_when: "'UPDATED' in snappymail_pw_result.stdout"
|
|
failed_when: >
|
|
snappymail_pw_result.rc != 0 or
|
|
'CONFIG_NOT_FOUND' in snappymail_pw_result.stdout or
|
|
'HASH_ERROR' in snappymail_pw_result.stdout
|
|
|
|
- name: Restart SnappyMail after password update
|
|
ansible.builtin.command: docker restart snappymail
|
|
when: snappymail_pw_result.changed
|
|
changed_when: true
|
|
|
|
# ── SnappyMail domain config for csrx.ru ─────────────────────────────────────
|
|
# Points IMAP/SMTP to the mailserver container (shared `front` Docker network).
|
|
# type: 0=plain, 1=SSL, 2=STARTTLS
|
|
- name: Deploy SnappyMail domain config for {{ domain_base }}
|
|
ansible.builtin.copy:
|
|
content: |
|
|
{
|
|
"IMAP": {
|
|
"host": "mailserver",
|
|
"port": 993,
|
|
"type": 1,
|
|
"timeout": 300,
|
|
"shortLogin": false,
|
|
"lowerLogin": true,
|
|
"sasl": ["PLAIN", "LOGIN"],
|
|
"ssl": {
|
|
"verify_peer": false,
|
|
"verify_peer_name": false,
|
|
"allow_self_signed": true,
|
|
"SNI_enabled": true,
|
|
"disable_compression": true,
|
|
"security_level": 0
|
|
},
|
|
"disabled_capabilities": [],
|
|
"use_expunge_all_on_delete": false,
|
|
"fast_simple_search": true,
|
|
"force_select": false,
|
|
"message_all_headers": false,
|
|
"message_list_limit": 10000,
|
|
"search_filter": ""
|
|
},
|
|
"SMTP": {
|
|
"host": "mailserver",
|
|
"port": 587,
|
|
"type": 2,
|
|
"timeout": 60,
|
|
"shortLogin": false,
|
|
"lowerLogin": true,
|
|
"sasl": ["PLAIN", "LOGIN"],
|
|
"ssl": {
|
|
"verify_peer": false,
|
|
"verify_peer_name": false,
|
|
"allow_self_signed": true,
|
|
"SNI_enabled": true,
|
|
"disable_compression": true,
|
|
"security_level": 0
|
|
},
|
|
"useAuth": true,
|
|
"setSender": true,
|
|
"usePhpMail": false
|
|
},
|
|
"Sieve": {
|
|
"enabled": false,
|
|
"host": "mailserver",
|
|
"port": 4190,
|
|
"type": 0,
|
|
"timeout": 10,
|
|
"shortLogin": false,
|
|
"lowerLogin": true,
|
|
"sasl": ["PLAIN"],
|
|
"ssl": {
|
|
"verify_peer": false,
|
|
"verify_peer_name": false,
|
|
"allow_self_signed": true
|
|
}
|
|
},
|
|
"whiteList": ""
|
|
}
|
|
dest: "{{ tools_root }}/snappymail/data/_data_/_default_/domains/{{ domain_base }}.json"
|
|
owner: "82"
|
|
group: "82"
|
|
mode: "0640"
|
|
register: snappymail_domain_result
|
|
|
|
- name: Restart SnappyMail after domain config update
|
|
ansible.builtin.command: docker restart snappymail
|
|
when: snappymail_domain_result.changed
|
|
changed_when: true
|
|
|
|
# ── Mail accounts (idempotent: check host-side config file) ──────────────────
|
|
- name: Wait for mailserver to be ready
|
|
ansible.builtin.command: docker exec mailserver postfix status
|
|
register: postfix_status
|
|
changed_when: false
|
|
retries: 18
|
|
delay: 10
|
|
until: postfix_status.rc == 0
|
|
|
|
- name: Create mail accounts
|
|
ansible.builtin.command: >
|
|
docker exec mailserver setup email add {{ item.address }} {{ item.password }}
|
|
loop:
|
|
- { address: "noreply@{{ domain_base }}", password: "{{ mailserver_noreply_password }}" }
|
|
- { address: "admin@{{ domain_base }}", password: "{{ mailserver_admin_password }}" }
|
|
- { address: "jack@{{ domain_base }}", password: "{{ mailserver_jack_password }}" }
|
|
register: mail_account_result
|
|
changed_when: mail_account_result.rc == 0
|
|
failed_when: >
|
|
mail_account_result.rc != 0 and
|
|
'already exists' not in mail_account_result.stderr
|
|
|
|
# ── DKIM ─────────────────────────────────────────────────────────────────────
|
|
- name: Check if DKIM key exists
|
|
ansible.builtin.stat:
|
|
path: "{{ tools_root }}/mailserver/config/opendkim/keys/{{ domain_base }}/mail.private"
|
|
register: dkim_key
|
|
|
|
- name: Generate DKIM key
|
|
ansible.builtin.command: >
|
|
docker exec mailserver setup config dkim domain {{ domain_base }}
|
|
when: not dkim_key.stat.exists
|
|
register: dkim_generated
|
|
|
|
- name: Read DKIM DNS record
|
|
ansible.builtin.command: >
|
|
cat {{ tools_root }}/mailserver/config/opendkim/keys/{{ domain_base }}/mail.txt
|
|
when: dkim_generated is changed
|
|
register: dkim_record
|
|
|
|
- name: Print DKIM DNS instructions
|
|
ansible.builtin.debug:
|
|
msg: |
|
|
══════════════════════════════════════════════════════════════════
|
|
DKIM key generated! Add this TXT record to Cloudflare DNS:
|
|
{{ dkim_record.stdout }}
|
|
══════════════════════════════════════════════════════════════════
|
|
when: dkim_generated is changed
|