# Self-hosted mail stack runbook — Postfix + Dovecot + SOGo + rspamd **Audience:** another Claude Code agent, working with root SSH on a fresh DigitalOcean droplet. **Goal:** stand up a complete, production mail service for one domain — SMTP send/receive, IMAP, webmail, calendars/contacts, spam filtering, DKIM signing, and client autoconfiguration. This is a transcription of a stack that is live and delivering mail today for `borz.global` on a DigitalOcean droplet. Every config block below is the real one from that host with the domain, hostnames and secrets replaced by placeholders. Nothing here is theoretical. --- ## 0. What you are building | Component | Role | Listens on | |---|---|---| | **Postfix** | MTA — receives from the internet, relays authenticated submissions | 25, 465 (SMTPS), 587 (submission) | | **Dovecot** | IMAP/POP3 store + SASL auth provider for Postfix + LMTP delivery + Sieve | 143, 993, 4190 (managesieve), unix sockets | | **SOGo** | Primary webmail with calendar, contacts, ActiveSync | 127.0.0.1:20000 (behind nginx) | | **rspamd** | Spam filtering (milter) **and** outbound DKIM signing | 127.0.0.1:11332 | | **Redis** | rspamd's stats/greylist backend | 127.0.0.1:6379 | | **memcached** | SOGo session cache | 127.0.0.1:11211 | | **MySQL** | SOGo's own storage + the user/password table Dovecot authenticates against | 127.0.0.1:3306 | | **nginx + certbot** | TLS termination, reverse proxy to SOGo, serves Roundcube + admin panel | 80, 443 | | **Roundcube** *(optional)* | Lightweight fallback webmail on the same vhost | via php-fpm | Mail lands as Maildir in `/var/vmail///`. Plan for roughly 1.5 GB per 15–20 active mailboxes after a year. --- ## 1. Ask the user for these before you touch anything Do not start until you have all of **Required**. Ask for them in one message, as a checklist. ### Required | # | What to ask | Why you need it | Example | |---|---|---|---| | 1 | **The mail domain** | Every config, cert and DNS record keys off it | `acme.com` | | 2 | **The mail hostname** | Postfix `myhostname`, cert CN, what clients connect to | `mail.acme.com` | | 3 | **Droplet IP (v4)** and root SSH access | Everything | `203.0.113.10` | | 4 | **Where DNS is hosted** and confirmation they can add records | You need MX, A, SPF, DKIM, DMARC. If DNS is at DigitalOcean, you may be able to do it via `doctl`; otherwise the user adds them by hand and tells you when they've propagated | DigitalOcean / Cloudflare / GoDaddy / Namecheap | | 5 | **Has port 25 been unblocked on this DigitalOcean account?** | **This is the single most common blocker.** See §2 | yes / no / unknown | | 6 | **Initial mailbox list** — local parts + display names | You create these at the end | `hector`, `sales`, `billing`… | | 7 | **Which address receives `postmaster@` / `abuse@` / `root@`** | Required aliases; abuse reports and DMARC reports go here | `hector@acme.com` | | 8 | **Admin address(es) for the panel** | Only these can log into `/admin/` | `hector@acme.com` | | 9 | **Timezone and default UI language** | SOGo config | `America/Mexico_City`, `English` | ### Ask, but you can default | What | Sensible default | |---|---| | Max message size | 50 MB (`52428800` bytes) | | Is this domain already receiving mail somewhere else (Google Workspace, cPanel)? | If **yes**, this is a **migration** — do not flip MX until mailboxes are provisioned and tested; plan an IMAP sync with `imapsync` and warn about the cutover window | | Do they want Roundcube as a fallback UI? | Yes — it costs nothing and it is useful when SOGo misbehaves | | Do they want the PHP admin panel? | Yes if a non-technical person will add mailboxes; otherwise CLI is fine | | DMARC policy to start at | `p=none` — never start at `quarantine`/`reject` | ### Things to warn the user about, out loud, before starting - **Reputation is earned, not configured.** A brand-new IP sends into spam folders at first. Correct SPF/DKIM/DMARC/PTR gets them to neutral; volume and engagement do the rest. Tell them to warm up gradually and not to blast a newsletter on day one. - **This is now infrastructure they own.** Certs renew automatically, but disk fills, spam rules drift, and a droplet that dies takes the mail with it. Agree on backups of `/var/vmail` and the `sogo` MySQL database. - **A droplet snapshot is not a mail backup** — it's a point in time. Mail arrives continuously. --- ## 2. DigitalOcean-specific prerequisites ### 2.1 Port 25 is blocked by default — this will stop you cold DigitalOcean blocks outbound TCP/25 on new accounts and droplets. Inbound mail still arrives; **outbound delivery silently fails or defers**. There is no config fix — it is a network-level block. To resolve: the account owner opens a DigitalOcean support ticket from the account that owns the droplet, requesting SMTP/port 25 be unblocked. They will ask what the mail is for. A short, honest answer works — a company's own business email for N employees, transactional and correspondence only, no bulk marketing. **Have the user file this ticket first.** It can take from hours to a few days. You can build the entire stack while waiting; just don't let anyone conclude "the mail server is broken" when outbound is queued behind an unresolved ticket. Verify from the droplet once they say it's done: ```bash # Should connect and show a 220 banner. Hanging = still blocked. timeout 10 bash -c 'cat < /dev/null > /dev/tcp/gmail-smtp-in.l.google.com/25' && echo "port 25 OPEN" || echo "port 25 BLOCKED" ``` ### 2.2 Reverse DNS (PTR) Receiving mail servers check that the sending IP's PTR record matches its HELO name. On DigitalOcean, **the PTR record is the droplet's name** — there is no separate PTR field. Rename the droplet in the DO control panel to exactly the mail hostname (`mail.acme.com`), then confirm: ```bash dig +short -x # must return mail.acme.com. ``` A missing or mismatched PTR is an instant spam-folder verdict at Google and Microsoft. ### 2.3 Droplet sizing and firewall - **2 GB RAM minimum.** rspamd + SOGo + MySQL + Redis on 1 GB will OOM under load. 4 GB if the mailbox count goes past ~20. - Ubuntu 22.04 or 24.04 LTS. *(The reference host runs 20.04, which is now past standard support — do not choose it for a new build.)* - Open ports: `22, 25, 80, 443, 465, 587, 993, 143`. If a DO Cloud Firewall is attached, add them there **too** — cloud firewall rules sit in front of `ufw` and are the second-most-common cause of "why is nothing arriving". ```bash ufw allow 22,25,80,443,465,587,993,143/tcp ufw enable ``` --- ## 3. DNS records Create these before requesting certificates. The DKIM value comes from §7 — do the rest now, add DKIM when you have the key. | Type | Name | Value | Notes | |---|---|---|---| | A | `mail` | `` | | | A | `autoconfig` | `` | Thunderbird/Outlook auto-setup | | MX | `@` | `10 mail.acme.com.` | Trailing dot matters in most DNS UIs | | TXT | `@` | `v=spf1 mx ~all` | `mx` authorizes whatever the MX resolves to | | TXT | `mail._domainkey` | `v=DKIM1; k=rsa; p=` | From §7 | | TXT | `_dmarc` | `v=DMARC1; p=none; rua=mailto:postmaster@acme.com` | Start at `p=none`, tighten later | Verify each one before moving on — `dig +short MX acme.com`, etc. Propagation is not instant; if the user just created them, wait and re-check rather than debugging a config that is fine. --- ## 4. Install packages ```bash export MAILDOMAIN="acme.com" export MAILHOST="mail.acme.com" apt update && apt upgrade -y DEBIAN_FRONTEND=noninteractive apt install -y \ postfix postfix-mysql \ dovecot-core dovecot-imapd dovecot-pop3d dovecot-lmtpd dovecot-mysql \ dovecot-sieve dovecot-managesieved \ mysql-server redis-server memcached \ rspamd \ nginx certbot python3-certbot-nginx \ php-fpm php-mysql php-mbstring php-xml php-intl php-zip php-imagick \ zip unzip git dnsutils swaks ``` When the Postfix installer asks: choose **Internet Site**, system mail name = `mail.acme.com`. SOGo is not in the Ubuntu repos — add the vendor repo (`jammy` for 22.04, `noble` for 24.04): ```bash apt install -y gnupg2 curl -fsSL https://keys.openpgp.org/vks/v1/by-fingerprint/74FFC6D72B925A34B5D356BDF8A27B36A6E2EAE9 \ | gpg --dearmor -o /usr/share/keyrings/sogo.gpg echo "deb [signed-by=/usr/share/keyrings/sogo.gpg] https://packages.sogo.nu/nightly/5/ubuntu/ jammy jammy" \ > /etc/apt/sources.list.d/sogo.list apt update && apt install -y sogo sogo-common ``` > If the vendor key fingerprint has rotated, get the current one from `https://www.sogo.nu/support/faq/how-to-install-sogo-on-ubuntu.html` rather than skipping verification. Create the mail storage user — the UID is referenced in the Dovecot config, keep it at 5000: ```bash groupadd -g 5000 vmail useradd -g vmail -u 5000 vmail -d /var/vmail -m mkdir -p /var/vmail/$MAILDOMAIN chown -R vmail:vmail /var/vmail chmod 770 /var/vmail ``` --- ## 5. TLS certificates nginx must be running and the A records live. Get certs before configuring Postfix/Dovecot, since both point at the cert paths. ```bash mkdir -p /var/www/mail-acme certbot certonly --webroot -w /var/www/mail-acme -d $MAILHOST \ --agree-tos -m postmaster@$MAILDOMAIN --non-interactive certbot certonly --webroot -w /var/www/autoconfig -d autoconfig.$MAILDOMAIN \ --agree-tos -m postmaster@$MAILDOMAIN --non-interactive ``` *(If nginx isn't configured yet, use `--standalone` and stop nginx for the moment.)* Certificate renewal must reload the mail daemons, or clients start seeing an expired cert 90 days from now while the website looks fine: ```bash cat > /etc/letsencrypt/renewal-hooks/deploy/reload-mail.sh <<'EOF' #!/bin/bash systemctl reload postfix dovecot nginx EOF chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-mail.sh ``` --- ## 6. Postfix ### 6.1 `/etc/postfix/main.cf` Working config from the reference host — substitute the domain and hostname: ```ini smtpd_banner = $myhostname ESMTP $mail_name (Ubuntu) biff = no append_dot_mydomain = no readme_directory = no compatibility_level = 2 # --- TLS --- smtpd_tls_cert_file = /etc/letsencrypt/live/mail.acme.com/fullchain.pem smtpd_tls_key_file = /etc/letsencrypt/live/mail.acme.com/privkey.pem smtpd_tls_security_level = may smtpd_tls_auth_only = yes smtp_tls_CApath = /etc/ssl/certs smtp_tls_security_level = may smtp_tls_session_cache_database = btree:${data_directory}/smtp_scache # --- Identity --- myhostname = mail.acme.com myorigin = /etc/mailname mydestination = $myhostname, localhost.localdomain, localhost mynetworks = 127.0.0.0/8 [::ffff:127.0.0.0]/104 [::1]/128 relayhost = inet_interfaces = all inet_protocols = ipv4 alias_maps = hash:/etc/aliases alias_database = hash:/etc/aliases # --- Virtual domain --- virtual_mailbox_domains = acme.com virtual_mailbox_maps = hash:/etc/postfix/vmailbox virtual_alias_maps = hash:/etc/postfix/virtual virtual_transport = lmtp:unix:private/dovecot-lmtp # --- SASL auth via Dovecot --- smtpd_sasl_type = dovecot smtpd_sasl_path = private/auth smtpd_sasl_auth_enable = yes smtpd_relay_restrictions = permit_mynetworks, permit_sasl_authenticated, reject_unauth_destination # --- rspamd milter --- smtpd_milters = inet:localhost:11332 non_smtpd_milters = inet:localhost:11332 milter_protocol = 6 milter_default_action = accept milter_mail_macros = i {mail_addr} {client_addr} {client_name} {auth_authen} # --- Limits --- mailbox_size_limit = 0 message_size_limit = 52428800 recipient_delimiter = + ``` Also set `echo "mail.acme.com" > /etc/mailname`. ### 6.2 `/etc/postfix/master.cf` — enable 587 and 465 Uncomment/add these two service blocks. The indented `-o` lines must stay indented: ``` submission inet n - y - - smtpd -o syslog_name=postfix/submission -o smtpd_tls_security_level=encrypt -o smtpd_sasl_auth_enable=yes -o smtpd_client_restrictions=permit_sasl_authenticated,reject -o smtpd_relay_restrictions=permit_sasl_authenticated,reject -o milter_macro_daemon_name=ORIGINATING smtps inet n - y - - smtpd -o syslog_name=postfix/smtps -o smtpd_tls_wrappermode=yes -o smtpd_sasl_auth_enable=yes -o smtpd_client_restrictions=permit_sasl_authenticated,reject -o smtpd_relay_restrictions=permit_sasl_authenticated,reject -o milter_macro_daemon_name=ORIGINATING ``` > **Check for duplicates.** If `submission`/`smtps` already exist further down the file, Postfix uses one and silently ignores the other — a real source of "why are my `-o` overrides not applying". Grep before you add: `grep -n "^submission\|^smtps" /etc/postfix/master.cf` ### 6.3 Mailbox and alias maps ```bash # One line per mailbox. The "OK" is a placeholder value — presence is what matters. cat > /etc/postfix/vmailbox < /etc/postfix/virtual < default_pass_scheme = SHA512-CRYPT password_query = SELECT c_uid AS user, c_password AS password FROM sogo_users WHERE c_uid = '%u' ``` ```bash chmod 600 /etc/dovecot/dovecot-sql.conf.ext ``` ### 7.3 `/etc/dovecot/local.conf` Put everything here rather than editing the files in `conf.d/` — `local.conf` loads last and wins, which keeps your changes in one readable place and survives package upgrades. ```ini protocols = imap pop3 lmtp sieve mail_location = maildir:/var/vmail/%d/%n mail_uid = vmail mail_gid = vmail mail_privileged_group = vmail first_valid_uid = 5000 last_valid_uid = 5000 disable_plaintext_auth = yes auth_mechanisms = plain login passdb { driver = sql args = /etc/dovecot/dovecot-sql.conf.ext } userdb { driver = static args = uid=vmail gid=vmail home=/var/vmail/%d/%n } ssl = yes ssl_cert = /var/lib/dovecot/sieve/global/spam-to-junk.sieve <<'EOF' require ["fileinto","mailbox"]; if anyof ( header :contains "X-Spam" "Yes", header :contains "X-Spam-Flag" "YES" ) { fileinto :create "Junk"; stop; } EOF sievec /var/lib/dovecot/sieve/global/spam-to-junk.sieve chown -R vmail:vmail /var/lib/dovecot/sieve systemctl restart dovecot ``` --- ## 8. rspamd — filtering and DKIM signing ```bash mkdir -p /etc/rspamd/local.d cat > /etc/rspamd/local.d/redis.conf <<'EOF' servers = "127.0.0.1:6379"; EOF cat > /etc/rspamd/local.d/milter_headers.conf <<'EOF' extended_spam_headers = true; use = ["x-spam-header", "x-spam-level", "authentication-results"]; EOF cat > /etc/rspamd/local.d/dkim_signing.conf <<'EOF' selector = "mail"; path = "/var/lib/rspamd/dkim/$domain.$selector.key"; allow_username_mismatch = true; use_esld = true; EOF # Microsoft/Outlook ranges are greylist-hostile; whitelisting them avoids delivery delays cat > /etc/rspamd/local.d/greylist.conf <<'EOF' whitelisted_ip = [ "40.92.0.0/15", "40.104.0.0/14", "52.100.0.0/14" ]; EOF ``` Generate the DKIM keypair: ```bash mkdir -p /var/lib/rspamd/dkim rspamadm dkim_keygen -s mail -b 2048 -d $MAILDOMAIN \ -k /var/lib/rspamd/dkim/$MAILDOMAIN.mail.key > /var/lib/rspamd/dkim/$MAILDOMAIN.mail.pub.txt chown -R _rspamd:_rspamd /var/lib/rspamd/dkim chmod 640 /var/lib/rspamd/dkim/$MAILDOMAIN.mail.key cat /var/lib/rspamd/dkim/$MAILDOMAIN.mail.pub.txt ``` Give the user the `p=` value for the `mail._domainkey` TXT record from §3. DNS TXT records have a 255-character-per-string limit; a 2048-bit key exceeds it. Most DNS UIs split it automatically — if yours doesn't, split into quoted chunks or use a 1024-bit key (`-b 1024`), which is what the reference host runs and is still widely accepted. Set a password for the rspamd web UI (reachable via `ssh -L 11334:127.0.0.1:11334`, not exposed publicly): ```bash rspamadm pw # paste the resulting hash as `password = "$2$..."` in /etc/rspamd/local.d/worker-controller.inc systemctl restart rspamd ``` --- ## 9. SOGo ### 9.1 `/etc/sogo/sogo.conf` Replace ``, the domain, timezone and title: ``` { /* ===== Session ===== */ SOGoMaximumPingInterval = 28800; WOSessionTimeOut = 28800; SOGoWebAuthKeyRenewSeconds = 0; SOGoMaximumMessageSizeLimit = 46080; /* KB — friendly warning below the Postfix hard limit */ /* ===== SOGo's own storage ===== */ SOGoProfileURL = "mysql://sogo:@127.0.0.1:3306/sogo/sogo_user_profile"; OCSFolderInfoURL = "mysql://sogo:@127.0.0.1:3306/sogo/sogo_folder_info"; OCSSessionsFolderURL = "mysql://sogo:@127.0.0.1:3306/sogo/sogo_sessions_folder"; OCSEMailAlarmsFolderURL = "mysql://sogo:@127.0.0.1:3306/sogo/sogo_alarms_folder"; OCSStoreURL = "mysql://sogo:@127.0.0.1:3306/sogo/sogo_store"; OCSAclURL = "mysql://sogo:@127.0.0.1:3306/sogo/sogo_acl"; OCSCacheFolderURL = "mysql://sogo:@127.0.0.1:3306/sogo/sogo_cache_folder"; /* ===== Mail backend ===== */ SOGoMailDomain = "acme.com"; SOGoIMAPServer = "imap://127.0.0.1:143/?tls=NO"; SOGoSMTPServer = "smtp://127.0.0.1:25"; SOGoSieveServer = "sieve://127.0.0.1:4190/?tls=NO"; SOGoMailingMechanism = "smtp"; SOGoForceExternalLoginWithEmail = NO; SOGoMailShowSubscribedFoldersOnly = NO; SOGoMailAddOutgoingAddresses = YES; /* auto-collect recipients into the address book */ /* ===== Auth / user directory — same SQL table Dovecot uses ===== */ SOGoUserSources = ( { type = "sql"; id = "users"; viewURL = "mysql://sogo:@127.0.0.1:3306/sogo/sogo_users"; canAuthenticate = YES; isAddressBook = YES; displayName = "Acme Directory"; userPasswordAlgorithm = "crypt"; } ); /* ===== General ===== */ SOGoLanguage = "English"; SOGoTimeZone = "America/Mexico_City"; SOGoMemcachedHost = "127.0.0.1"; SOGoPageTitle = "acme.com"; SOGoVacationEnabled = YES; SOGoForwardEnabled = YES; SOGoSieveScriptsEnabled = YES; SOGoMailAuxiliaryUserAccountsEnabled = YES; WOWorkersCount = 3; SxVMemLimit = 384; WOPidFile = "/run/sogo/sogo.pid"; SOGoZipPath = "/usr/bin/zip"; } ``` ```bash chown root:sogo /etc/sogo/sogo.conf && chmod 640 /etc/sogo/sogo.conf systemctl enable --now memcached sogo ``` SOGo creates its remaining tables on first run. Watch `journalctl -u sogo -f` on first start — a bad `mysql://` URL fails there and nowhere else. ### 9.2 Note on `WOWorkersCount` 3 workers ≈ 15–20 concurrent users. Raise it for more, but each worker holds memory — this is the setting to revisit if the droplet starts swapping. --- ## 10. nginx vhost `/etc/nginx/sites-available/mail.acme.com` — the full working vhost, serving SOGo at `/SOGo`, Roundcube at the root, and the admin panel at `/admin/`: ```nginx server { listen 80; listen [::]:80; server_name mail.acme.com; root /var/www/mail-acme; location /.well-known/acme-challenge/ { allow all; default_type "text/plain"; } location / { return 301 https://$host$request_uri; } } server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name mail.acme.com; ssl_certificate /etc/letsencrypt/live/mail.acme.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/mail.acme.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; root /var/www/roundcube; index index.php; client_max_body_size 60M; # Static assets for email signatures (logos referenced by ) location ^~ /sig/ { types { image/png png; image/jpeg jpg jpeg; image/svg+xml svg; } default_type image/png; alias /var/www/mailassets/; allow all; expires 30d; access_log off; } # ---- SOGo (primary webmail) ---- location ^~ /SOGo { proxy_pass http://127.0.0.1:20000; proxy_redirect http://127.0.0.1:20000/SOGo /SOGo; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header x-webobjects-remote-host $remote_addr; proxy_set_header x-webobjects-server-protocol HTTP/1.0; proxy_set_header x-webobjects-server-name $server_name; proxy_set_header x-webobjects-server-url $scheme://$host; proxy_set_header x-webobjects-server-port $server_port; proxy_connect_timeout 90; proxy_send_timeout 90; proxy_read_timeout 90; proxy_buffer_size 4k; proxy_buffers 4 32k; proxy_busy_buffers_size 64k; proxy_temp_file_write_size 64k; client_body_buffer_size 128k; break; } location ^~ /SOGo/WebServerResources/ { alias /usr/lib/GNUstep/SOGo/WebServerResources/; allow all; expires max; } location ^~ /SOGo.woa/WebServerResources/ { alias /usr/lib/GNUstep/SOGo/WebServerResources/; allow all; expires max; } # ---- Mail admin panel ---- location = /admin { return 301 /admin/; } location ^~ /admin/ { alias /var/www/mailadmin/; index index.php; location ~ \.php$ { fastcgi_split_path_info ^(.+\.php)(/.+)$; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $request_filename; fastcgi_pass unix:/run/php/php8.1-fpm.sock; } } # ---- Bare host opens SOGo ---- location = / { return 302 /SOGo/; } # ---- Roundcube fallback ---- location / { try_files $uri $uri/ /index.php$is_args$args; } location ~ \.php$ { fastcgi_split_path_info ^(.+\.php)(/.+)$; fastcgi_pass unix:/run/php/php8.1-fpm.sock; fastcgi_index index.php; include fastcgi_params; fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; fastcgi_param PATH_INFO $fastcgi_path_info; } location ~ ^/(config|temp|logs|SQL|bin|vendor)/ { deny all; } location ~ ^/(README|INSTALL|LICENSE|CHANGELOG|UPGRADING|composer\.json|composer\.lock) { deny all; } location ~ /\. { deny all; } } ``` > **Confirm the php-fpm socket path** — `ls /run/php/*.sock`. Hardcoding a version that isn't installed yields `502 Bad Gateway` on every PHP page while SOGo works fine, which reads as "the admin panel is broken" rather than "the socket is wrong". The reference host still points at `php7.4-fpm.sock` from an older install; on 22.04 it's `php8.1-fpm.sock`, on 24.04 `php8.3-fpm.sock`. ```bash ln -s /etc/nginx/sites-available/mail.acme.com /etc/nginx/sites-enabled/ nginx -t && systemctl reload nginx ``` ### Autoconfig vhost `/etc/nginx/sites-available/autoconfig.acme.com`: ```nginx server { listen 80; listen [::]:80; server_name autoconfig.acme.com; root /var/www/autoconfig; location /.well-known/acme-challenge/ { allow all; default_type "text/plain"; } location = /mail/config-v1.1.xml { default_type application/xml; } location / { return 301 https://$host$request_uri; } } server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name autoconfig.acme.com; ssl_certificate /etc/letsencrypt/live/autoconfig.acme.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/autoconfig.acme.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; root /var/www/autoconfig; location = /mail/config-v1.1.xml { default_type application/xml; } location / { return 404; } } ``` `/var/www/autoconfig/mail/config-v1.1.xml` — Thunderbird and Outlook fetch this and configure themselves from an email address alone: ```xml acme.com Acme Mail Acme mail.acme.com 993 SSL password-cleartext %EMAILADDRESS% mail.acme.com 465 SSL password-cleartext %EMAILADDRESS% ``` --- ## 11. Creating mailboxes A user exists in **two** places — the SQL table (authentication, and SOGo's directory) and the Postfix map (which addresses this server accepts). Miss either one and you get a half-working account: login works but mail bounces, or mail is accepted into a mailbox nobody can open. ```bash #!/bin/bash # /usr/local/bin/mailuser-add — usage: mailuser-add hector "Hector Lastname" set -euo pipefail MAILDOMAIN="acme.com" LOCAL="$1"; CN="${2:-$1}" EMAIL="${LOCAL}@${MAILDOMAIN}" PASS=$(openssl rand -base64 18 | tr -d '/+=' | head -c 16) HASH=$(doveadm pw -s SHA512-CRYPT -p "$PASS" | sed 's/^{SHA512-CRYPT}//') mysql sogo -e "INSERT INTO sogo_users (c_uid,c_name,c_password,c_cn,mail) VALUES ('$EMAIL','$LOCAL','$HASH','$CN','$EMAIL');" grep -q "^$EMAIL" /etc/postfix/vmailbox || echo -e "$EMAIL\tOK" >> /etc/postfix/vmailbox postmap /etc/postfix/vmailbox systemctl reload postfix echo "Created $EMAIL" echo "Temporary password: $PASS" ``` Hand passwords over through something that isn't email, and have people change them at first login. **To delete a user:** remove the SQL row, remove the `vmailbox` line, `postmap` again, and archive `/var/vmail///` — deleting the Maildir destroys their mail irreversibly, so confirm with the user first. ### The admin panel (optional) The reference host has a ~270-line single-file PHP app at `/var/www/mailadmin/` doing exactly the above through a web form. If you build one, mirror its shape: - `config.php` returns `['db_host','db_user','db_pass','db_name','domain','admins'=>[...]]`, mode `640`, owned by `www-data` — it holds the DB password - Login authenticates against `sogo_users` with `crypt()` verification, and **only** addresses listed in `admins` may proceed - CSRF token on every POST (`hash_equals`, not `==`) - Password hashing with `crypt($plain, '$6$'.$salt.'$')` and a 16-char random salt, so hashes match what Dovecot expects - Session cookie: `secure`, `httponly`, `samesite=Lax`, path scoped to `/admin/` - Creating a user must write **both** the SQL row and the `vmailbox` entry, then run `postmap` — otherwise you have reintroduced the exact half-working state described above --- ## 12. Verification checklist Work through all of it. Do not report success on a subset. ```bash # Services systemctl is-active postfix dovecot sogo rspamd nginx mysql redis-server memcached # Ports listening ss -ltnp | grep -E ':25|:465|:587|:993|:143|:4190|:20000' # Auth against Dovecot doveadm auth test hector@acme.com # Local delivery swaks --to hector@acme.com --from postmaster@acme.com --server 127.0.0.1 ls -la /var/vmail/acme.com/hector/new/ # a file should appear # Authenticated submission over TLS swaks --to --from hector@acme.com \ --server mail.acme.com:587 -tls --auth-user hector@acme.com --auth-password '' # Queue must be empty; anything stuck here means outbound trouble (see §2.1) mailq # Live log — the ground truth for every delivery question tail -f /var/log/mail.log ``` **Then the two external tests that actually matter:** 1. **Send to `check-auth@verifier.port25.com`** from a mailbox on the new server. The bounce-back report must show `SPF: pass`, `DKIM: pass`, `DMARC: pass`. 2. **Go to [mail-tester.com](https://www.mail-tester.com)**, send to the address shown, check the score. Aim for 9–10/10. Anything below 8 — read what it flags and fix it before handing over. The usual culprits are a missing PTR, a DKIM record not yet propagated, or the IP appearing on a fresh-IP blocklist. Also confirm in a browser: `https://mail.acme.com/` redirects to SOGo, login works, sending from the SOGo UI works, and `/admin/` loads. --- ## 13. Gotchas learned from the reference deployment - **Port 25 outbound on DigitalOcean.** Bears repeating — it is the most likely reason everything looks perfect and nothing gets delivered. §2.1. - **`postmap` after every map edit.** §6.3. - **Message size is set in three places and they must agree.** `message_size_limit` in Postfix (bytes), `SOGoMaximumMessageSizeLimit` (kilobytes), and `client_max_body_size` in nginx. If a user reports "attachment fails at ~40 MB", one of the three is lower than the others. Set SOGo's slightly *below* the Postfix limit so users get a friendly in-app warning instead of a bounce after upload. The reference host uses 50 MB / 45 MB / 60 MB. - **One vhost file, many editors.** On a droplet hosting several apps, a later edit to a shared nginx vhost has more than once clobbered a sibling `location` block — producing "Not Found" while every service is healthy. Give mail its **own** vhost file, and `cp` a timestamped backup before editing. - **`nginx -t` before every reload.** A syntax error takes down every site on the box, not just mail. - **SOGo runs as `sogo`, not root.** `sogod` warns and misbehaves if started as root. Use `systemctl`, never a bare `sogod`. - **Dovecot config precedence.** Package files in `conf.d/` still contain defaults (e.g. `mail_location = mbox:~/mail`). `local.conf` loads after and overrides them — this is why the stack works despite `10-mail.conf` looking wrong. Don't "fix" `conf.d/`; put everything in `local.conf`. - **Certificate renewal must reload Postfix and Dovecot,** not just nginx. §5. - **Backups.** `/var/vmail` (the mail itself), the `sogo` MySQL database (calendars, contacts, preferences), `/etc/postfix`, `/etc/dovecot`, `/etc/sogo`, and `/var/lib/rspamd/dkim` (regenerating the DKIM key means a DNS change and a signing gap). Set this up before handing over, not after the first incident. --- ## 14. Migrating from an existing provider If the domain currently receives mail at Google Workspace, Microsoft 365 or cPanel: 1. Build and fully verify this stack **first**, testing with a subdomain or a spare domain. 2. Create every mailbox here with matching addresses. 3. Copy existing mail with `imapsync` per user, old server → new server. Run it once while the old server is still live. 4. Lower the MX TTL to 300 a day ahead of the cutover. 5. Switch MX. Both servers will receive mail for a few hours while DNS propagates. 6. Run `imapsync` a second time to catch anything delivered to the old server during the window. 7. Leave the old service alive but MX-less for a couple of weeks as a safety net. **Update SPF before the cutover**, not after — if the old provider's `include:` is removed while mail still flows from them, their mail starts failing SPF. --- ## Quick reference | Thing | Path / command | |---|---| | Mail log — start every diagnosis here | `tail -f /var/log/mail.log` | | Queue | `mailq`, flush with `postqueue -f` | | Test auth | `doveadm auth test user@domain` | | Add user | `/usr/local/bin/mailuser-add ""` | | Maildirs | `/var/vmail///` | | Postfix config | `/etc/postfix/main.cf`, `master.cf`, `vmailbox`, `virtual` | | Dovecot config | `/etc/dovecot/local.conf`, `dovecot-sql.conf.ext` | | SOGo config | `/etc/sogo/sogo.conf` — log via `journalctl -u sogo -f` | | rspamd config | `/etc/rspamd/local.d/*.conf` — UI on `127.0.0.1:11334` | | DKIM key | `/var/lib/rspamd/dkim/.mail.key` | | nginx vhost | `/etc/nginx/sites-available/mail.` | | Certs | `certbot certificates` | --- *Transcribed from a live Postfix 3.4 / Dovecot 2.3 / SOGo 5 / rspamd deployment on a DigitalOcean droplet. All domains, hostnames, IPs and credentials in this document are placeholders — generate fresh secrets for every deployment and never reuse the ones from the source host.*