From 51d6229b038b7a57dac5017cbbe8477221f8c25c Mon Sep 17 00:00:00 2001 From: Alexandre Pires Date: Sun, 16 Mar 2025 19:37:44 +0100 Subject: [PATCH] Enhance mail configuration: add envelope support in Sieve, create SMTP and IMAP SRV records, update Dovecot settings, and implement Postfix startup script --- ansible/playbook_mail.yml | 16 + terraform/iac/alexpires.me.tf | 22 ++ terraform/k8sapps/config.tf | 38 +-- terraform/k8sapps/mail.tf | 280 +++++++++++++++++- terraform/k8sapps/resources/mail/dovecot.conf | 16 +- terraform/k8sapps/resources/mail/run.config | 81 +++++ terraform/k8sapps/resources/mail/sieve | 4 +- 7 files changed, 406 insertions(+), 51 deletions(-) create mode 100644 terraform/k8sapps/resources/mail/run.config diff --git a/ansible/playbook_mail.yml b/ansible/playbook_mail.yml index 85eb112..94cb6e3 100644 --- a/ansible/playbook_mail.yml +++ b/ansible/playbook_mail.yml @@ -10,6 +10,8 @@ mail_home: "{{ persistent_data | default('/var/lib') }}/mail" mail_user_id: 1000 mail_group_id: 1000 + postfix_user_id: 100 + postfix_group_id: 101 tags: always - name: Set required facts @@ -43,3 +45,17 @@ tags: - tasks - tasks::folders + + - name: Create required postfix folders + become: true + ansible.builtin.file: + state: directory + path: "{{ item }}" + mode: "0750" + owner: "{{ postfix_user_id }}" + group: "{{ postfix_group_id }}" + loop: + - "{{ mail_home }}/postfix" + tags: + - tasks + - tasks::folders diff --git a/terraform/iac/alexpires.me.tf b/terraform/iac/alexpires.me.tf index d479366..e7e2d7e 100644 --- a/terraform/iac/alexpires.me.tf +++ b/terraform/iac/alexpires.me.tf @@ -126,3 +126,25 @@ resource "cloudns_dns_record" "alexpires_me_mail" { value = "mail-pt.securemail.pro" ttl = "3600" } + +resource "cloudns_dns_record" "alexpires_me_mail_smtp_srv" { + name = "_smtp._tcp" + zone = local.alexpires_me_zone + type = "SRV" + priority = 0 + weight = 1 + port = 465 + value = "smtp.${local.alexpires_me_zone}" + ttl = "3600" +} + +resource "cloudns_dns_record" "alexpires_me_mail_imap_srv" { + name = "_imap._tcp" + zone = local.alexpires_me_zone + type = "SRV" + priority = 0 + weight = 1 + port = 993 + value = "imap.${local.alexpires_me_zone}" + ttl = "3600" +} diff --git a/terraform/k8sapps/config.tf b/terraform/k8sapps/config.tf index 4c868c1..033e738 100644 --- a/terraform/k8sapps/config.tf +++ b/terraform/k8sapps/config.tf @@ -53,42 +53,8 @@ locals { scaleway_smtp_port = 587 # mail settings - getmail_version = "v0.0.1-6.19.07" - fetch_emails_schedule = "*/2 * * * *" - email_accounts = [ - { - domain = "alexpires.me" - recipient = "c.alexandre.pires" - receive = { - type = "imap" - server = "mail-pt.securemail.pro" - port = 993 - } - send = { - server = "smtp-pt.securemail.pro" - port = 465 - } - username = var.alexpires_me_email_username - password = var.alexpires_me_email_password - mailboxes = ["INBOX", "Sent", "Drafts", "Trash", "Spam"] - } - ] - - getmail_jobs = flatten([ - for account in local.email_accounts : [ - for mailbox in account.mailboxes : { - name = "${account.recipient}-${mailbox}" - recipient = "${account.recipient}+${mailbox}" - domain = account.domain - server = account.receive.server - port = account.receive.port - username = account.username - password = account.password - mailbox = mailbox - delete_after = try(account.delete_after, 30) - } - ] - ]) + getmail_version = "v0.0.1-6.19.07" + fetch_emails_schedule = "*/2 * * * *" mail_files_backup_cron_schedule = "0 3 * * *" # storage diff --git a/terraform/k8sapps/mail.tf b/terraform/k8sapps/mail.tf index 5d1c1f5..099675d 100644 --- a/terraform/k8sapps/mail.tf +++ b/terraform/k8sapps/mail.tf @@ -2,7 +2,69 @@ locals { mail_path = "${local.persistent_folder}/mail" mailboxes_path = "${local.mail_path}/mailboxes" getmail_path = "${local.mail_path}/getmail" + postfix_path = "${local.mail_path}/postfix" mail_backup_path = "${local.persistent_folder}/backup.mail" + + email_accounts = [ + { + domain = "alexpires.me" + recipient = "c.alexandre.pires" + receive = { + type = "imap" + server = "mail-pt.securemail.pro" + port = 993 + } + send = { + server = "smtp-pt.securemail.pro" + port = 465 + } + username = var.alexpires_me_email_username + password = var.alexpires_me_email_password + mailboxes = ["INBOX", "Sent", "Drafts", "Trash", "Spam"] + } + ] + + getmail_jobs = flatten([ + for account in local.email_accounts : [ + for mailbox in account.mailboxes : { + name = "${account.recipient}-${mailbox}" + recipient = "${account.recipient}+${mailbox}" + domain = account.domain + server = account.receive.server + port = account.receive.port + username = account.username + password = account.password + mailbox = mailbox + delete_after = try(account.delete_after, 30) + } + ] + ]) + + postfix_env_vars = { + # Email configuration + SMTP_HOST = local.scaleway_smtp_host + SMTP_PORT = 465 + MY_DESTINATION = "alexpires.me" + MY_NETWORKS = "127.0.0.0/8" + MAILNAME = "smtp.alexpires.me" + DOVECOT_AUTH_ADDRESS = "dovecot-auth.mail.svc.cluster.local" + DOVECOT_AUTH_PORT = 31000 + DOVECOT_LMTP_ADDRESS = "dovecot-lmtp.mail.svc.cluster.local" + DOVECOT_LMTP_PORT = 31024 + LOG_TO_STDOUT = 1 + } + + postfix_secrets = { + SMTP_USER = { + key = "username" + name = kubernetes_secret.relay_mail_credentials.metadata[0].name + } + + SMTP_PASSWORD = { + key = "password" + name = kubernetes_secret.relay_mail_credentials.metadata[0].name + } + } } resource "kubernetes_namespace" "mail" { @@ -27,7 +89,19 @@ resource "kubernetes_secret" "dovecot_users" { } } -resource "kubernetes_service_account" "mail_service_account" { +resource "kubernetes_secret" "relay_mail_credentials" { + metadata { + name = "relay-mail-credentials" + namespace = kubernetes_namespace.mail.metadata[0].name + } + data = { + username = var.scaleway_project_id + password = scaleway_iam_api_key.mail_app_api.secret_key + } + type = "Opaque" +} + +resource "kubernetes_service_account" "mail_app" { metadata { name = "mail-app-sa" @@ -37,6 +111,28 @@ resource "kubernetes_service_account" "mail_service_account" { automount_service_account_token = false } +resource "kubernetes_config_map" "postfix_config" { + metadata { + name = "postfix-config" + namespace = kubernetes_namespace.mail.metadata[0].name + } + + data = { + "run.config" = file("${path.module}/resources/mail/run.config") + } +} + + +resource "kubernetes_secret" "bcc_map" { + metadata { + name = "bcc-map" + namespace = kubernetes_namespace.mail.metadata[0].name + } + data = { + "bcc_map" = join("\n", [for account in local.email_accounts : "/([^@]+)@${replace(account.domain, ".", "\\.")}/ $${1}+Sent@${account.domain}"]) + } +} + resource "kubernetes_config_map" "getmail_config" { metadata { name = "getmail-config" @@ -194,19 +290,19 @@ resource "kubernetes_cron_job_v1" "fetch_emails" { } } -resource "kubernetes_deployment" "mail" { +resource "kubernetes_deployment" "dovecot" { metadata { namespace = kubernetes_namespace.mail.metadata[0].name - name = "mail" + name = "dovecot" labels = { - app = "mail" + app = "dovecot" } } spec { replicas = 1 selector { match_labels = { - app = "mail" + app = "dovecot" } } strategy { @@ -215,11 +311,11 @@ resource "kubernetes_deployment" "mail" { template { metadata { labels = { - app = "mail" + app = "dovecot" } } spec { - service_account_name = kubernetes_service_account.mail_service_account.metadata[0].name + service_account_name = kubernetes_service_account.mail_app.metadata[0].name container { name = "dovecot" image = "dovecot/dovecot:latest" @@ -230,6 +326,9 @@ resource "kubernetes_deployment" "mail" { port { container_port = 31024 } + port { + container_port = 31000 + } volume_mount { name = "email-storage" mount_path = "/var/mail" @@ -291,7 +390,7 @@ resource "kubernetes_deployment" "mail" { } } } - depends_on = [kubernetes_manifest.mail_certificate, kubernetes_service_account.mail_service_account] + depends_on = [kubernetes_manifest.mail_certificate, kubernetes_service_account.mail_app] lifecycle { replace_triggered_by = [ kubernetes_config_map.dovecot_config, @@ -301,15 +400,130 @@ resource "kubernetes_deployment" "mail" { } } +resource "kubernetes_deployment" "postfix" { + metadata { + namespace = kubernetes_namespace.mail.metadata[0].name + name = "postfix" + labels = { + app = "postfix" + } + } + spec { + replicas = 1 + selector { + match_labels = { + app = "postfix" + } + } + strategy { + type = "Recreate" + } + template { + metadata { + labels = { + app = "postfix" + } + } + spec { + service_account_name = kubernetes_service_account.mail_app.metadata[0].name + container { + name = "postfix" + image = "tozd/postfix:alpine-316" + port { + container_port = 465 + host_port = 465 + } + dynamic "env" { + for_each = local.postfix_env_vars + content { + name = env.key + value = env.value + } + } + dynamic "env" { + for_each = local.postfix_secrets + content { + name = env.key + value_from { + secret_key_ref { + key = env.value.key + name = env.value.name + } + } + } + } + + volume_mount { + name = "email-storage" + mount_path = "/var/spool/postfix" + } + volume_mount { + name = "postfix-run-config" + mount_path = "/etc/service/postfix/run.config" + sub_path = "run.config" + read_only = true + } + volume_mount { + name = "bcc-map" + mount_path = "/etc/postfix/bcc_map" + sub_path = "bcc_map" + read_only = true + } + volume_mount { + name = "certs" + mount_path = "/etc/letsencrypt" + read_only = true + } + } + volume { + name = "email-storage" + host_path { + path = local.postfix_path + } + } + volume { + name = "postfix-run-config" + config_map { + name = kubernetes_config_map.postfix_config.metadata[0].name + } + } + volume { + name = "bcc-map" + secret { + secret_name = kubernetes_secret.bcc_map.metadata[0].name + } + } + volume { + name = "certs" + secret { + secret_name = "mail-tls" + } + } + } + } + } + depends_on = [kubernetes_manifest.mail_certificate, kubernetes_service_account.mail_app] + lifecycle { + replace_triggered_by = [ + kubernetes_config_map.postfix_config, + kubernetes_secret.relay_mail_credentials, + kubernetes_secret.bcc_map, + ] + } +} + + resource "kubernetes_service" "dovecot" { metadata { namespace = kubernetes_namespace.mail.metadata[0].name - name = "mail" + name = "dovecot" } + spec { selector = { - app = "mail" + app = "dovecot" } + port { name = "imap" port = 993 @@ -334,7 +548,7 @@ resource "kubernetes_service" "dovecot_lmtp" { spec { selector = { - app = "mail" + app = "dovecot" } port { port = 31024 @@ -342,3 +556,47 @@ resource "kubernetes_service" "dovecot_lmtp" { cluster_ip = "None" } } + +resource "kubernetes_service" "dovecot_auth" { + metadata { + name = "dovecot-auth" + namespace = kubernetes_namespace.mail.metadata[0].name + } + + spec { + selector = { + app = "dovecot" + } + port { + port = 31000 + } + cluster_ip = "None" + } +} + +resource "kubernetes_service" "postfix" { + metadata { + namespace = kubernetes_namespace.mail.metadata[0].name + name = "postfix" + } + + spec { + selector = { + app = "postfix" + } + + port { + name = "smtp" + port = 465 + target_port = 465 + } + + type = "LoadBalancer" + } + + lifecycle { + ignore_changes = [ + metadata[0].annotations + ] + } +} diff --git a/terraform/k8sapps/resources/mail/dovecot.conf b/terraform/k8sapps/resources/mail/dovecot.conf index 1a00547..f12f6d8 100644 --- a/terraform/k8sapps/resources/mail/dovecot.conf +++ b/terraform/k8sapps/resources/mail/dovecot.conf @@ -30,8 +30,8 @@ namespace inbox { auto = subscribe } - mailbox Spam { - special_use = \Spam + mailbox Junk { + special_use = \Junk auto = create auto = subscribe } @@ -136,6 +136,8 @@ service stats { } } +auth_mechanisms = plain login + service auth { inet_listener auth { port = 31000 @@ -230,3 +232,13 @@ metric mail_delivery { sieve_script default { path = /etc/dovecot/sieve/default.sieve } + +log_path = /dev/stdout +info_log_path = /dev/stdout +debug_log_path = /dev/stdout + +# Enable verbose logging +# auth_verbose = yes +# auth_debug = yes +# auth_debug_passwords = yes +# mail_debug = yes \ No newline at end of file diff --git a/terraform/k8sapps/resources/mail/run.config b/terraform/k8sapps/resources/mail/run.config new file mode 100644 index 0000000..af95b44 --- /dev/null +++ b/terraform/k8sapps/resources/mail/run.config @@ -0,0 +1,81 @@ +#!/bin/bash +if [ -z "$SMTP_HOST" ]; then + echo "SMTP_HOST is required" + exit 1 +fi +if [ -z "$SMTP_PORT" ]; then + echo "SMTP_PORT is required" + exit 1 +fi +if [ -z "$SMTP_USER" ]; then + echo "SMTP_USER is required" + exit 1 +fi +if [ -z "$SMTP_PASSWORD" ]; then + echo "SMTP_PASSWORD is required" + exit 1 +fi +if [ -z "$DOVECOT_AUTH_ADDRESS" ]; then + echo "DOVECOT_AUTH_ADDRESS is required" + exit 1 +fi +if [ -z "$DOVECOT_AUTH_PORT" ]; then + echo "DOVECOT_AUTH_PORT is required" + exit 1 +fi + +postconf -e "myhostname=$MAILNAME" +postconf -e "mydomain=$MY_DESTINATION" +postconf -e "myorigin=$MY_DESTINATION" +postconf -e "smtpd_banner = \$myhostname ESMTP \$mail_name" + +# Relay configuration +postconf -e "relayhost=[$SMTP_HOST]:$SMTP_PORT" +postconf -e "smtp_sasl_auth_enable=yes" +postconf -e "smtp_sasl_password_maps=static:$SMTP_USER:$SMTP_PASSWORD" +postconf -e "smtp_sasl_security_options=noanonymous" +postconf -e "smtp_tls_security_level=encrypt" +postconf -e "smtp_use_tls=yes" +postconf -e "smtp_tls_CAfile=/etc/ssl/certs/ca-certificates.crt" + +# SASL configuration +postconf -e "smtpd_sasl_type=dovecot" +postconf -e "smtpd_sasl_path=inet:$DOVECOT_AUTH_ADDRESS:$DOVECOT_AUTH_PORT" +postconf -e "smtpd_sasl_auth_enable=yes" +postconf -e "smtpd_sasl_security_options=noanonymous" +postconf -e "smtpd_recipient_restrictions=permit_sasl_authenticated, reject" +postconf -e "smtpd_relay_restrictions=permit_sasl_authenticated, reject_unauth_destination" +postconf -e "smtpd_sasl_authenticated_header=yes" +postconf -e "smtpd_tls_wrappermode=yes" +postconf -e "smtpd_sasl_local_domain=$MY_DESTINATION" + +# TLS configuration +postconf -e "smtpd_tls_cert_file=/etc/letsencrypt/tls.crt" +postconf -e "smtpd_tls_key_file=/etc/letsencrypt/tls.key" +postconf -e "smtpd_tls_security_level=encrypt" +postconf -e "smtp_tls_wrappermode=yes" + +# BCC configuration +postconf -e "virtual_transport=lmtp:[$DOVECOT_LMTP_ADDRESS]:$DOVECOT_LMTP_PORT" + +# Other configuration +postconf -e "inet_interfaces=all" +postconf -e "inet_protocols=ipv4" +postconf -e "queue_directory=/var/spool/postfix" +postconf -e "compatibility_level=2" + +# Debugging +# postconf -e "debug_peer_list=127.0.0.1" +# postconf -e "debug_peer_level=3" + +echo " +smtps inet n - n - - smtpd + -o syslog_name=postfix/smtps + -o smtpd_tls_wrappermode=yes + -o smtpd_sasl_auth_enable=yes + -o smtpd_recipient_restrictions=permit_sasl_authenticated,reject +" | sudo tee -a /etc/postfix/master.cf + +/usr/sbin/postfix -c /etc/postfix start +/usr/sbin/postfix -c /etc/postfix abort +exec "$POSTFIX_PATH" -c /etc/postfix -s \ No newline at end of file diff --git a/terraform/k8sapps/resources/mail/sieve b/terraform/k8sapps/resources/mail/sieve index db5db81..7a12f4b 100644 --- a/terraform/k8sapps/resources/mail/sieve +++ b/terraform/k8sapps/resources/mail/sieve @@ -1,5 +1,5 @@ -require ["fileinto"]; +require ["fileinto", "envelope"]; -if envelope :matches "from" "*" { +if envelope :is "from" "${user}" { fileinto "Sent"; } \ No newline at end of file