diff --git a/.ansible/.lock b/.ansible/.lock new file mode 100644 index 0000000..e69de29 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..9d9173b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,4 @@ +# AGENTS.md + +- Only use `ansible.builtin` +- Always update `README.md` \ No newline at end of file diff --git a/README.md b/README.md index eaf998e..a6c250d 100644 --- a/README.md +++ b/README.md @@ -1,72 +1,114 @@ -# **Ansible Role: Postfix** +# Ansible Role: Mail -An Ansible role to install and configure Postfix on Debian-based systems. +An Ansible role to deploy and configure an internal mail server on Debian-based systems. -## **Description** +## Description -This role sets up Postfix to function as a local mail server designed for internal use. Its primary function is to accept mail from local services and relay all outbound messages through a configured **smarthost**. +This role sets up a complete internal mail server stack, currently including: -This is the perfect setup for environments where internal applications (like cron, monitoring systems, or web applications) need to send email notifications without the complexity of managing a full, internet-facing mail server. +- **Postfix** - Mail Transfer Agent (MTA) for sending and receiving mail +- **Dovecot** - IMAP/POP3 server for mail retrieval -This role performs the following actions: +The role is designed for **internal use cases** where applications, services, and users within your infrastructure need to send and receive email. Outbound mail is relayed through a configured smarthost (e.g., SendGrid, Mailgun, or your ISP's SMTP server). -* Installs the Postfix package and necessary SASL modules on Debian/Ubuntu. -* Manages the main Postfix configuration file (/etc/postfix/main.cf) via a template. -* Manages the /etc/mailname file for defining the mail domain. -* Configures Postfix to route all outgoing mail through a specified smarthost. -* Securely configures SASL authentication for the smarthost if credentials are provided. +### Use Cases -## **Requirements** +- Internal applications sending notifications (cron jobs, monitoring, CI/CD pipelines) +- Service accounts that need to receive and process email +- Development and testing environments +- Private mail infrastructure for small teams -* **Target OS**: This role is designed exclusively for **Debian-based** distributions (e.g., Debian, Ubuntu). -* **Ansible**: Version 2.10 or newer. +### What This Role Does NOT Include -## **Role Variables** +This role intentionally omits antispam and antivirus components. Since it's designed for internal mail that doesn't interact with external/untrusted sources, these features are unnecessary and would add complexity. -The role's behavior can be customized using the following variables. The default values are defined in defaults/main.yml. +## Requirements -| Variable | Default Value | Description | -| :---- | :---- | :---- | -| postfix_relayhost | "" (empty string) | **Required.** The smarthost for relaying all mail. Use square brackets [] to prevent MX lookups (e.g., \[smtp.sendgrid.net\]:587). | -| postfix_relayhost_user | (undefined) | The username for SASL authentication with the smarthost. If defined with a password, SASL auth will be enabled. | -| postfix_relayhost_password | (undefined) | The password or API key for the smarthost user. **It** is strongly recommended to store this in Ansible **Vault.** | -| postfix_mail_domain | `{{ ansible_domain \| default('internal.local') }}` | The primary mail domain for this server | -| postfix_myhostname | `mail.{{ postfix_mail_domain }}` | The fully qualified domain name (FQDN) of the mail server itself (e.g., mail.example.com). | -| postfix_mydestination | `$myhostname, localhost.{{ postfix_mail_domain }}, localhost, {{ postfix_mail_domain }}` | A comma-separated list of domains this server will accept mail for. The default is usually sufficient for an internal relay. | -| postfix_mynetworks | `"127.0.0.0/8 [::1]/128"` | The list of "trusted" remote SMTP clients that have more privileges than "strangers"| -| postfix_inet_interfaces | all | The network interfaces Postfix listens on. Set to loopback-only to only accept mail from the server itself. | -| postfix_inet_protocols | all | The IP protocols to use (ipv4, ipv6, or all). | +- **Target OS**: Debian-based distributions (Debian, Ubuntu) +- **Ansible**: Version 2.10 or newer -### **SASL Authentication** +## Role Variables -SASL authentication for the smarthost is **automatically enabled** if both postfix_relayhost_user and postfix_relayhost_password are defined. If they are not defined, Postfix will attempt to send mail without authentication. +Default values are defined in `defaults/main.yml`. -## **Dependencies** +### General Settings -This role has no dependencies on other Ansible roles or collections beyond the standard ansible.builtin modules. +| Variable | Default | Description | +| :--- | :--- | :--- | +| mail_ssl_cert | snakeoil | Path to SSL certificate (shared by Postfix and Dovecot). | +| mail_ssl_key | snakeoil | Path to SSL private key (shared by Postfix and Dovecot). | -## **Example Playbook** +### Postfix Configuration -Here is a basic example of how to use this role in your playbook. You must define postfix_relayhost. It is also highly recommended to use Ansible Vault to encrypt the smarthost password. +| Variable | Default | Description | +| :--- | :--- | :--- | +| postfix_relayhost | "" | **Required.** Smarthost for relaying outbound mail. Use brackets to skip MX lookups (e.g., `[smtp.sendgrid.net]:587`). | +| postfix_relayhost_user | (undefined) | Username for smarthost SASL authentication. | +| postfix_relayhost_password | (undefined) | Password/API key for smarthost. Store in Ansible Vault. | +| postfix_mail_domain | `{{ ansible_domain }}` | Primary mail domain for this server. | +| postfix_myhostname | `mail.{{ postfix_mail_domain }}` | FQDN of the mail server. | +| postfix_mydestination | `$myhostname, localhost...` | Domains accepted for local delivery. | +| postfix_mynetworks | `127.0.0.0/8 [::1]/128` | Trusted networks allowed to relay. | +| postfix_inet_interfaces | all | Network interfaces to listen on. Use `loopback-only` for local-only access. | +| postfix_inet_protocols | all | IP protocols to use (ipv4, ipv6, or all). | +SASL authentication for the smarthost is automatically enabled when both `postfix_relayhost_user` and `postfix_relayhost_password` are defined. + +### Dovecot Configuration + +| Variable | Default | Description | +| :--- | :--- | :--- | +| dovecot_enabled | true | Install and configure Dovecot. | +| dovecot_protocols | "imap pop3 lmtp" | Protocols to enable. | +| dovecot_mail_location | "maildir:~/Maildir" | Mail storage format and location. | +| dovecot_ssl | "yes" | SSL/TLS mode: `yes`, `no`, or `required`. | +| dovecot_auth_mechanisms | "plain login" | Allowed authentication mechanisms. | +| dovecot_postfix_sasl_enable | true | Allow Postfix to authenticate users via Dovecot. | +| dovecot_postfix_lmtp_enable | true | Deliver mail to Dovecot via LMTP. | +| dovecot_imap_capability | "" | Adjust advertised IMAP capabilities (e.g., `+IMAP4rev1 -LITERAL+`). | +| dovecot_users | [] | List of virtual mailbox users. See below. | + +### Virtual Mailbox Users + +Define users for Dovecot virtual mailboxes: + +```yaml +dovecot_users: + - name: "service1" + pass: "mysecretpassword" ``` ---- -- hosts: all - become: true - roles: - - role: your_username.postfix - vars: - postfix_relayhost: "[smtp.mailgun.org\]:587" - postfix_relayhost_user: "postmaster@mg.example.com" - postfix_relayhost_password: "{{ vaulted_mailgun_password }}" # Stored in Ansible Vault - postfix_inet_interfaces: "loopback-only" + +For security, the role generates a random 16-character token on the server (stored in `/etc/dovecot/dovecot_token`). The actual password is `token + password`. For example, if the token is `He5rN5SPH33AbFLn`, the user must authenticate with `He5rN5SPH33AbFLnmysecretpassword`. + +## Dependencies + +None. + +## Example Playbook + +```yaml +--- +- hosts: mail_servers + become: true + roles: + - role: giacchetta.mail + vars: postfix_mail_domain: "example.com" + postfix_relayhost: "[smtp.mailgun.org]:587" + postfix_relayhost_user: "postmaster@mg.example.com" + postfix_relayhost_password: "{{ vault_mailgun_password }}" + mail_ssl_cert: "/etc/letsencrypt/live/mail.example.com/fullchain.pem" + mail_ssl_key: "/etc/letsencrypt/live/mail.example.com/privkey.pem" + dovecot_ssl: "required" + dovecot_users: + - name: "alerts" + pass: "{{ vault_alerts_password }}" ``` -## **License** +## License GPL-3.0-only -## **Author Information** +## Author Information -This role was created by Giacchetta Networks. \ No newline at end of file +This role was created by Giacchetta Networks. diff --git a/defaults/main.yml b/defaults/main.yml index 4a95e62..63694d3 100644 --- a/defaults/main.yml +++ b/defaults/main.yml @@ -18,9 +18,10 @@ postfix_mail_domain: "{{ ansible_domain | default('internal.local') }}" # The Fully Qualified Domain Name of the mail server. postfix_myhostname: "mail.{{ postfix_mail_domain }}" -# Comma-separated list of domains this server accepts mail for. -# It's critical that this includes the server's own hostname and mail domain. -postfix_mydestination: "$myhostname, localhost.{{ postfix_mail_domain }}, localhost, {{ postfix_mail_domain }}" +# Comma-separated list of domains this server accepts mail for locally. +# When using Dovecot with LMTP (virtual mailboxes), the mail domain is handled +# separately via virtual_mailbox_domains, so it should NOT be included here. +postfix_mydestination: "$myhostname, localhost.{{ postfix_mail_domain }}, localhost" # The list of "trusted" remote SMTP clients that have more privileges than "strangers". postfix_mynetworks: "127.0.0.0/8 [::1]/128" @@ -34,4 +35,42 @@ postfix_relayhost: "" # Optional credentials for the relayhost. If these are defined, # SASL authentication will be automatically configured. # postfix_relayhost_user: "apikey" -# postfix_relayhost_password: "YourVeryLongAndComplexApiKey" \ No newline at end of file +# postfix_relayhost_password: "YourVeryLongAndComplexApiKey" + +# --- Dovecot Configuration --- + +# Whether to install and configure Dovecot +dovecot_enabled: true + +# Protocols to enable (imap, pop3, lmtp) +dovecot_protocols: "imap pop3 lmtp" + +# IMAP capability adjustments. Set to modify advertised IMAP capabilities. +# Use +CAPABILITY to add, -CAPABILITY to remove. +# Example: "+IMAP4rev1 -LITERAL+ -NOTIFY" removes modern extensions that +# might suppress standard untagged responses. +# Leave empty to use Dovecot defaults. +dovecot_imap_capability: "" + +# Mail storage location. Using Maildir in the user's home directory. +dovecot_mail_location: "maildir:~/Maildir" + +# SSL/TLS configuration +# Use 'yes', 'no' or 'required'. 'required' is recommended for production. +dovecot_ssl: "yes" +mail_ssl_cert: "/etc/ssl/certs/ssl-cert-snakeoil.pem" +mail_ssl_key: "/etc/ssl/private/ssl-cert-snakeoil.key" + +# Authentication mechanisms +dovecot_auth_mechanisms: "plain login" + +# Postfix integration +dovecot_postfix_sasl_enable: true +dovecot_postfix_lmtp_enable: true + +# Local Dovecot Users +# Example: +# dovecot_users: +# - name: "service1" +# pass: "secret123" +dovecot_users: [] \ No newline at end of file diff --git a/handlers/main.yml b/handlers/main.yml index bf37d43..59b226e 100644 --- a/handlers/main.yml +++ b/handlers/main.yml @@ -2,4 +2,9 @@ - name: Restart Postfix ansible.builtin.service: name: postfix + state: restarted + +- name: Restart Dovecot + ansible.builtin.service: + name: dovecot state: restarted \ No newline at end of file diff --git a/tasks/main.yml b/tasks/main.yml index e24e5df..441b1ba 100644 --- a/tasks/main.yml +++ b/tasks/main.yml @@ -52,4 +52,148 @@ notify: Restart Postfix tags: - postfix_config - - postfix_smarthost \ No newline at end of file + - postfix_smarthost + +- name: "POSTFIX | Configure virtual mailbox maps" + when: dovecot_enabled | default(false) and dovecot_postfix_lmtp_enable | default(false) + ansible.builtin.template: + src: virtual_mailbox_maps.j2 + dest: /etc/postfix/virtual_mailbox_maps + owner: root + group: root + mode: '0644' + notify: Restart Postfix + tags: + - postfix_config + - dovecot_config + +- name: "POSTFIX | Create hash map for virtual mailbox maps" + when: dovecot_enabled | default(false) and dovecot_postfix_lmtp_enable | default(false) + ansible.builtin.command: + cmd: postmap hash:/etc/postfix/virtual_mailbox_maps + changed_when: true + notify: Restart Postfix + tags: + - postfix_config + - dovecot_config + +- name: "DOVECOT | Install Dovecot packages" + when: dovecot_enabled | default(false) + ansible.builtin.apt: + name: "{{ ['dovecot-core', 'dovecot-imapd', 'dovecot-pop3d', 'openssl'] + (['dovecot-lmtpd'] if dovecot_postfix_lmtp_enable | default(false) else []) }}" + state: present + tags: + - dovecot_install + +- name: "DOVECOT | Install pwgen" + when: dovecot_enabled | default(false) + ansible.builtin.apt: + name: pwgen + state: present + tags: + - dovecot_install + +- name: "DOVECOT | Generate Dovecot token" + when: dovecot_enabled | default(false) + ansible.builtin.shell: + cmd: "pwgen -s 16 1 > /etc/dovecot/dovecot_token" + creates: /etc/dovecot/dovecot_token + tags: + - dovecot_config + +- name: "DOVECOT | Read Dovecot token" + when: dovecot_enabled | default(false) + ansible.builtin.slurp: + src: /etc/dovecot/dovecot_token + register: dovecot_token_file + tags: + - dovecot_config + +- name: "DOVECOT | Create vmail group" + when: dovecot_enabled | default(false) + ansible.builtin.group: + name: vmail + gid: 5000 + state: present + tags: + - dovecot_config + +- name: "DOVECOT | Create vmail user" + when: dovecot_enabled | default(false) + ansible.builtin.user: + name: vmail + uid: 5000 + group: vmail + home: /var/vmail + create_home: true + system: true + shell: /usr/sbin/nologin + tags: + - dovecot_config + +- name: "DOVECOT | Ensure vmail directory permissions" + when: dovecot_enabled | default(false) + ansible.builtin.file: + path: /var/vmail + state: directory + owner: vmail + group: vmail + mode: '0700' + tags: + - dovecot_config + +- name: "DOVECOT | Generate user password hashes" + when: dovecot_enabled | default(false) and dovecot_users | length > 0 + ansible.builtin.command: + cmd: "openssl passwd -6 -salt {{ dovecot_token_value | quote }} {{ (dovecot_token_value + item.pass) | quote }}" + loop: "{{ dovecot_users }}" + register: dovecot_user_hashes + changed_when: false + vars: + dovecot_token_value: "{{ dovecot_token_file['content'] | b64decode | trim }}" + tags: + - dovecot_config + +- name: "DOVECOT | Create users password file" + when: dovecot_enabled | default(false) + ansible.builtin.template: + src: dovecot-users.j2 + dest: /etc/dovecot/users + owner: root + group: dovecot + mode: '0640' + vars: + dovecot_token_value: "{{ dovecot_token_file['content'] | b64decode | trim }}" + notify: Restart Dovecot + tags: + - dovecot_config + +- name: "DOVECOT | Configure dovecot.conf" + when: dovecot_enabled | default(false) + ansible.builtin.template: + src: dovecot.conf.j2 + dest: /etc/dovecot/dovecot.conf + owner: root + group: dovecot + mode: '0644' + notify: Restart Dovecot + tags: + - dovecot_config + +- name: "DOVECOT | Configure conf.d files" + when: dovecot_enabled | default(false) + ansible.builtin.template: + src: "{{ item.src }}" + dest: "/etc/dovecot/conf.d/{{ item.dest }}" + owner: root + group: dovecot + mode: '0644' + loop: + - { src: '10-auth.conf.j2', dest: '10-auth.conf' } + - { src: 'auth-dovecot-users.conf.ext.j2', dest: 'auth-dovecot-users.conf.ext' } + - { src: '10-master.conf.j2', dest: '10-master.conf' } + - { src: '10-ssl.conf.j2', dest: '10-ssl.conf' } + - { src: '10-mail.conf.j2', dest: '10-mail.conf' } + notify: Restart Dovecot + tags: + - dovecot_config \ No newline at end of file diff --git a/templates/10-auth.conf.j2 b/templates/10-auth.conf.j2 new file mode 100644 index 0000000..b7e36ac --- /dev/null +++ b/templates/10-auth.conf.j2 @@ -0,0 +1,8 @@ +# Dovecot authentication configuration +# Ansible managed: {{ ansible_managed }} + +disable_plaintext_auth = {{ 'yes' if dovecot_ssl == 'required' else 'no' }} +auth_mechanisms = {{ dovecot_auth_mechanisms }} + +!include auth-dovecot-users.conf.ext +!include auth-system.conf.ext diff --git a/templates/10-mail.conf.j2 b/templates/10-mail.conf.j2 new file mode 100644 index 0000000..edddf85 --- /dev/null +++ b/templates/10-mail.conf.j2 @@ -0,0 +1,27 @@ +# Dovecot mail location configuration +# Ansible managed: {{ ansible_managed }} + +mail_location = {{ dovecot_mail_location }} +namespace inbox { + inbox = yes + + mailbox Drafts { + special_use = \Drafts + auto = subscribe + } + mailbox Junk { + special_use = \Junk + auto = subscribe + } + mailbox Trash { + special_use = \Trash + auto = subscribe + } + mailbox Sent { + special_use = \Sent + auto = subscribe + } + mailbox "Sent Messages" { + special_use = \Sent + } +} diff --git a/templates/10-master.conf.j2 b/templates/10-master.conf.j2 new file mode 100644 index 0000000..5131440 --- /dev/null +++ b/templates/10-master.conf.j2 @@ -0,0 +1,52 @@ +# Dovecot master configuration +# Ansible managed: {{ ansible_managed }} + +service imap-login { + inet_listener imap { + port = 143 + } + inet_listener imaps { + port = 993 + ssl = yes + } +} + +service pop3-login { + inet_listener pop3 { + port = 110 + } + inet_listener pop3s { + port = 995 + ssl = yes + } +} + +service lmtp { + unix_listener /var/spool/postfix/private/dovecot-lmtp { + mode = 0600 + user = postfix + group = postfix + } +} + +service auth { + unix_listener /var/spool/postfix/private/auth { + mode = 0660 + user = postfix + group = postfix + } + + unix_listener auth-userdb { + mode = 0600 + user = postfix + group = postfix + } +} + +service auth-worker { +} + +service dict { + unix_listener dict { + } +} diff --git a/templates/10-ssl.conf.j2 b/templates/10-ssl.conf.j2 new file mode 100644 index 0000000..9141113 --- /dev/null +++ b/templates/10-ssl.conf.j2 @@ -0,0 +1,6 @@ +# Dovecot SSL configuration +# Ansible managed: {{ ansible_managed }} + +ssl = {{ dovecot_ssl }} +ssl_cert = <{{ mail_ssl_cert }} +ssl_key = <{{ mail_ssl_key }} diff --git a/templates/auth-dovecot-users.conf.ext.j2 b/templates/auth-dovecot-users.conf.ext.j2 new file mode 100644 index 0000000..cc72d53 --- /dev/null +++ b/templates/auth-dovecot-users.conf.ext.j2 @@ -0,0 +1,12 @@ +# Dovecot local users authentication +# Ansible managed: {{ ansible_managed }} + +passdb { + driver = passwd-file + args = scheme=SHA512-CRYPT username_format=%n /etc/dovecot/users +} + +userdb { + driver = static + args = uid=vmail gid=vmail home=/var/vmail/%n +} diff --git a/templates/dovecot-users.j2 b/templates/dovecot-users.j2 new file mode 100644 index 0000000..1f6db51 --- /dev/null +++ b/templates/dovecot-users.j2 @@ -0,0 +1,8 @@ +# Dovecot users file +# Ansible managed: {{ ansible_managed }} +# user:{scheme}hash:uid:gid:gecos:home:shell:extra_fields +{% if dovecot_user_hashes.results is defined %} +{% for res in dovecot_user_hashes.results %} +{{ res.item.name }}:{SHA512-CRYPT}{{ res.stdout | trim }}:::::: +{% endfor %} +{% endif %} \ No newline at end of file diff --git a/templates/dovecot.conf.j2 b/templates/dovecot.conf.j2 new file mode 100644 index 0000000..36604db --- /dev/null +++ b/templates/dovecot.conf.j2 @@ -0,0 +1,13 @@ +# Dovecot configuration file +# Ansible managed: {{ ansible_managed }} + +protocols = {{ dovecot_protocols }} +{% if dovecot_imap_capability | default('') | length > 0 %} + +protocol imap { + imap_capability = {{ dovecot_imap_capability }} +} +{% endif %} + +# Dictionary of configuration files +!include conf.d/*.conf diff --git a/templates/main.cf.j2 b/templates/main.cf.j2 index cab9889..4ca6e1a 100644 --- a/templates/main.cf.j2 +++ b/templates/main.cf.j2 @@ -16,9 +16,8 @@ inet_interfaces = {{ postfix_inet_interfaces }} recipient_delimiter = + # TLS parameters for incoming connections -# For a production server, replace snakeoil with real certificates. -smtpd_tls_cert_file=/etc/ssl/certs/ssl-cert-snakeoil.pem -smtpd_tls_key_file=/etc/ssl/private/ssl-cert-snakeoil.key +smtpd_tls_cert_file={{ mail_ssl_cert }} +smtpd_tls_key_file={{ mail_ssl_key }} smtpd_tls_security_level=may smtpd_use_tls=yes @@ -48,3 +47,20 @@ smtp_tls_security_level = may # Other settings alias_maps = hash:/etc/aliases alias_database = hash:/etc/aliases + +# Dovecot Integration +{% if dovecot_enabled | default(false) %} +{% if dovecot_postfix_sasl_enable | default(false) %} +# SASL Authentication via Dovecot +smtpd_sasl_type = dovecot +smtpd_sasl_path = private/auth +smtpd_sasl_auth_enable = yes +{% endif %} + +{% if dovecot_postfix_lmtp_enable | default(false) %} +# Virtual mailbox configuration for Dovecot users +virtual_mailbox_domains = {{ postfix_mail_domain }} +virtual_mailbox_maps = hash:/etc/postfix/virtual_mailbox_maps +virtual_transport = lmtp:unix:private/dovecot-lmtp +{% endif %} +{% endif %} diff --git a/templates/virtual_mailbox_maps.j2 b/templates/virtual_mailbox_maps.j2 new file mode 100644 index 0000000..50fdbf6 --- /dev/null +++ b/templates/virtual_mailbox_maps.j2 @@ -0,0 +1,8 @@ +# Virtual mailbox maps for Postfix +# Ansible managed: {{ ansible_managed }} +# Format: user@domain OK +{% if dovecot_users is defined and dovecot_users | length > 0 %} +{% for user in dovecot_users %} +{{ user.name }}@{{ postfix_mail_domain }} OK +{% endfor %} +{% endif %} diff --git a/tests/test.yml b/tests/test.yml index bf8503c..42a1ba3 100644 --- a/tests/test.yml +++ b/tests/test.yml @@ -2,4 +2,4 @@ - hosts: localhost remote_user: root roles: - - ansible_role_mailserver + - ansible_role_mail