--- - 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