Running an Internet-facing mail server is not a single-package installation. DNS, SMTP transport, mailbox delivery, authentication, TLS, reputation, filtering, and monitoring form one system, and a mistake at any boundary can cause rejected mail, an open relay, leaked credentials, or silent delivery failures.
This guide builds a small production mail system on Debian or Ubuntu. Postfix receives and submits mail, Dovecot provides IMAP and LMTP delivery, and MariaDB or MySQL stores virtual domains, mailboxes, and aliases. OpenDKIM adds message signatures; SPF and DMARC complete the basic domain-authentication policy. Optional sections add autodiscovery, Sieve vacation replies, and SpamAssassin.
Architecture and Trust Boundaries
- Postfix SMTP on port 25 accepts mail for hosted domains from other mail servers.
- Postfix submission on port 587 accepts authenticated outgoing mail from users and requires TLS.
- Dovecot LMTP receives local deliveries from Postfix through a Unix socket.
- Dovecot IMAPS on port 993 gives users encrypted mailbox access.
- MariaDB/MySQL is reachable only from localhost and stores account metadata, not messages.
- Maildir under /var/vmail stores messages as the unprivileged vmail user.
Port 25 must never require user authentication: remote mail servers use it to deliver inbound messages. Port 587 is the opposite trust boundary: it is for authenticated users, must not accept anonymous relaying, and should require TLS before credentials are sent.
Prerequisites
Before installing packages, confirm that the server has:
- A stable public IPv4 address; IPv6 should be enabled only when forward and reverse DNS work for it.
- Outbound and inbound TCP port 25 permitted by the hosting provider.
- Control of forward DNS and a provider-configured PTR record.
- A hostname whose forward and reverse records agree.
- Accurate time synchronization, current security updates, and reliable backups.
Set the fully qualified hostname and verify it:
hostnamectl set-hostname mail.example.org
hostname -f
getent hosts mail.example.org Ensure /etc/hosts maps the server address to the FQDN before the short hostname. Do not map the public hostname only to 127.0.1.1 on a host where services depend on resolving its public identity.
DNS Before Installation
Create the address and MX records first:
mail 3600 IN A 203.0.113.10
@ 3600 IN MX 10 mail.example.org.
@ 3600 IN TXT "v=spf1 mx -all"
_dmarc 3600 IN TXT "v=DMARC1; p=none; rua=mailto:dmarc@example.org" Ask the hosting provider to set the PTR record for 203.0.113.10 to mail.example.org. The A record must resolve back to the same address. This forward-confirmed reverse DNS is one of the first checks made by large receivers.
Start DMARC with p=none, inspect aggregate reports, and move to quarantine or reject only after every legitimate sender passes aligned SPF or DKIM. Publishing a strict policy before inventorying newsletters, ticket systems, and third-party senders can reject valid mail.
Check public DNS from an external resolver:
dig +short A mail.example.org @1.1.1.1
dig +short MX example.org @1.1.1.1
dig +short -x 203.0.113.10 @1.1.1.1
dig +short TXT example.org @1.1.1.1
dig +short TXT _dmarc.example.org @1.1.1.1 Install the Core Packages
apt update apt full-upgrade apt install postfix postfix-mysql mariadb-server \ dovecot-core dovecot-imapd dovecot-lmtpd dovecot-mysql \ certbot swaks dnsutils
Select Internet Site when Postfix asks for a configuration type, and enter example.org as the system mail name. On systems using Oracle MySQL, install mysql-server instead of mariadb-server. The SQL schema and lookup configuration below work with both.
Expose only the required public services:
ufw allow 25/tcp
ufw allow 587/tcp
ufw allow 993/tcp Port 80 must also be reachable while using Certbot's standalone HTTP-01 challenge. Database port 3306 must remain private.
Obtain the TLS Certificate
After the public A record resolves to the server, request a certificate for the mail hostname:
certbot certonly --standalone --preferred-challenges http \
--cert-name mail.example.org -d mail.example.org The files used by Postfix and Dovecot are:
/etc/letsencrypt/live/mail.example.org/fullchain.pem
/etc/letsencrypt/live/mail.example.org/privkey.pem If another web server owns port 80, use its Certbot plugin or webroot mode instead. Test renewal before relying on it:
certbot renew --dry-run
Create a deploy hook so renewed certificates are loaded without interrupting active sessions:
cat >/etc/letsencrypt/renewal-hooks/deploy/reload-mail-services <<'EOF'
#!/bin/sh
systemctl reload postfix
systemctl reload dovecot
EOF
chmod 750 /etc/letsencrypt/renewal-hooks/deploy/reload-mail-services Create the Virtual-Mail Database
Secure the database installation, then open a local administrative session:
mariadb-secure-installation mariadb
Create a dedicated database and a read-only lookup account:
CREATE DATABASE mailserver
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
CREATE USER 'mailreader'@'127.0.0.1'
IDENTIFIED BY 'REPLACE_WITH_A_LONG_RANDOM_PASSWORD';
GRANT SELECT ON mailserver.* TO 'mailreader'@'127.0.0.1';
FLUSH PRIVILEGES; Create normalized tables for domains, mailboxes, and aliases:
USE mailserver;
CREATE TABLE domains (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(255) NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
PRIMARY KEY (id),
UNIQUE KEY domains_name_uq (name)
) ENGINE=InnoDB;
CREATE TABLE users (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
domain_id BIGINT UNSIGNED NOT NULL,
email VARCHAR(320) NOT NULL,
password VARCHAR(255) NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
PRIMARY KEY (id),
UNIQUE KEY users_email_uq (email),
CONSTRAINT users_domain_fk
FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE
) ENGINE=InnoDB;
CREATE TABLE aliases (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
domain_id BIGINT UNSIGNED NOT NULL,
source VARCHAR(320) NOT NULL,
destination VARCHAR(320) NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
PRIMARY KEY (id),
KEY aliases_source_idx (source),
CONSTRAINT aliases_domain_fk
FOREIGN KEY (domain_id) REFERENCES domains(id) ON DELETE CASCADE
) ENGINE=InnoDB; The non-unique source index intentionally allows one alias to expand to multiple destinations. Keep addresses in lowercase and normalize them in the provisioning layer.
Create the First Domain and Mailbox
INSERT INTO domains (name) VALUES ('example.org'); Generate a password hash with Dovecot. Enter the password at the prompt rather than placing it in shell history:
doveadm pw -s SHA512-CRYPT Insert the returned {SHA512-CRYPT}$6$... value:
INSERT INTO users (domain_id, email, password)
SELECT id, 'postmaster@example.org', '{SHA512-CRYPT}$6$REPLACE_WITH_HASH'
FROM domains
WHERE name = 'example.org';
INSERT INTO aliases (domain_id, source, destination)
SELECT id, 'abuse@example.org', 'postmaster@example.org'
FROM domains
WHERE name = 'example.org'; Create working postmaster@ and abuse@ addresses for every hosted domain. Do not use MySQL's generic hash functions or PHP's password_hash() unless the selected Dovecot password scheme explicitly supports the resulting format.
Create the Virtual Mail User
groupadd --system --gid 5000 vmail
useradd --system --uid 5000 --gid vmail \
--home-dir /var/vmail --create-home --shell /usr/sbin/nologin vmail
chown -R vmail:vmail /var/vmail
chmod 750 /var/vmail A dedicated UID prevents mailboxes from being owned by Postfix, Dovecot, database, or interactive login accounts.
Configure Postfix SQL Lookups
Create /etc/postfix/mysql-virtual-domains.cf:
user = mailreader
password = REPLACE_WITH_A_LONG_RANDOM_PASSWORD
hosts = 127.0.0.1
dbname = mailserver
query = SELECT 1 FROM domains WHERE name = '%s' AND active = 1 Create /etc/postfix/mysql-virtual-mailboxes.cf:
user = mailreader
password = REPLACE_WITH_A_LONG_RANDOM_PASSWORD
hosts = 127.0.0.1
dbname = mailserver
query = SELECT 1 FROM users WHERE email = '%s' AND active = 1 Create /etc/postfix/mysql-virtual-aliases.cf:
user = mailreader
password = REPLACE_WITH_A_LONG_RANDOM_PASSWORD
hosts = 127.0.0.1
dbname = mailserver
query = SELECT destination FROM aliases WHERE source = '%s' AND active = 1 Direct mailbox delivery also needs an identity mapping so a mailbox address is accepted as its own final destination. Create /etc/postfix/mysql-virtual-email2email.cf:
user = mailreader
password = REPLACE_WITH_A_LONG_RANDOM_PASSWORD
hosts = 127.0.0.1
dbname = mailserver
query = SELECT email FROM users WHERE email = '%s' AND active = 1 Restrict files containing the database password:
chown root:postfix /etc/postfix/mysql-virtual-*.cf
chmod 640 /etc/postfix/mysql-virtual-*.cf Configure Postfix
Back up /etc/postfix/main.cf, then use postconf -e so every setting is written in valid Postfix syntax:
cp /etc/postfix/main.cf /etc/postfix/main.cf.bak
postconf -e 'myhostname = mail.example.org'
postconf -e 'mydomain = example.org'
postconf -e 'myorigin = $mydomain'
postconf -e 'mydestination = localhost'
postconf -e 'inet_interfaces = all'
postconf -e 'inet_protocols = all'
postconf -e 'mynetworks = 127.0.0.0/8 [::1]/128'
postconf -e 'recipient_delimiter = +'
postconf -e 'mailbox_size_limit = 0'
postconf -e 'disable_vrfy_command = yes'
postconf -e 'smtpd_helo_required = yes'
postconf -e 'virtual_mailbox_domains = mysql:/etc/postfix/mysql-virtual-domains.cf'
postconf -e 'virtual_mailbox_maps = mysql:/etc/postfix/mysql-virtual-mailboxes.cf'
postconf -e 'virtual_alias_maps = mysql:/etc/postfix/mysql-virtual-aliases.cf, mysql:/etc/postfix/mysql-virtual-email2email.cf'
postconf -e 'virtual_transport = lmtp:unix:private/dovecot-lmtp'
postconf -e 'smtpd_tls_cert_file = /etc/letsencrypt/live/mail.example.org/fullchain.pem'
postconf -e 'smtpd_tls_key_file = /etc/letsencrypt/live/mail.example.org/privkey.pem'
postconf -e 'smtpd_tls_security_level = may'
postconf -e 'smtpd_tls_auth_only = yes'
postconf -e 'smtp_tls_security_level = may'
postconf -e 'smtp_tls_CAfile = /etc/ssl/certs/ca-certificates.crt'
postconf -e 'smtpd_sasl_type = dovecot'
postconf -e 'smtpd_sasl_path = private/auth'
postconf -e 'smtpd_sasl_auth_enable = yes'
postconf -e 'smtpd_sasl_security_options = noanonymous'
postconf -e 'smtpd_relay_restrictions = permit_mynetworks, permit_sasl_authenticated, defer_unauth_destination'
postconf -e 'smtpd_recipient_restrictions = reject_non_fqdn_recipient, reject_unknown_recipient_domain, reject_unlisted_recipient' The relay restriction is the critical open-relay boundary. Never replace it with a rule that permits arbitrary destinations before authentication. A domain listed in virtual_mailbox_domains must not also appear in mydestination or virtual_alias_domains.
Enable Authenticated Submission
In /etc/postfix/master.cf, enable the submission service and add these service-specific overrides. Keep the columns of the service declaration intact:
submission inet n - y - - smtpd
-o syslog_name=postfix/submission
-o smtpd_tls_security_level=encrypt
-o smtpd_sasl_auth_enable=yes
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-o smtpd_recipient_restrictions=permit_sasl_authenticated,reject
-o milter_macro_daemon_name=ORIGINATING Port 465 can be enabled separately with smtpd_tls_wrappermode=yes when clients require implicit TLS. Port 587 with STARTTLS remains the standard submission path in this setup.
Configure Dovecot
Back up the files that will be changed:
cp -a /etc/dovecot /etc/dovecot.backup Set the enabled protocols in /etc/dovecot/dovecot.conf:
protocols = imap lmtp
postmaster_address = postmaster@example.org Set Maildir storage in /etc/dovecot/conf.d/10-mail.conf:
mail_home = /var/vmail/%d/%n
mail_location = maildir:~/Maildir
mail_uid = 5000
mail_gid = 5000
first_valid_uid = 5000
last_valid_uid = 5000 Use SQL authentication in /etc/dovecot/conf.d/10-auth.conf:
disable_plaintext_auth = yes
auth_mechanisms = plain login
auth_username_format = %Lu
#!include auth-system.conf.ext
!include auth-sql.conf.ext Set /etc/dovecot/conf.d/auth-sql.conf.ext to:
passdb {
driver = sql
args = /etc/dovecot/dovecot-sql.conf.ext
}
userdb {
driver = sql
args = /etc/dovecot/dovecot-sql.conf.ext
} Create /etc/dovecot/dovecot-sql.conf.ext:
driver = mysql
connect = host=127.0.0.1 dbname=mailserver user=mailreader password=REPLACE_WITH_A_LONG_RANDOM_PASSWORD
default_pass_scheme = SHA512-CRYPT
password_query = SELECT email AS user, password \
FROM users WHERE email = '%u' AND active = 1
user_query = SELECT 5000 AS uid, 5000 AS gid, \
CONCAT('/var/vmail/', SUBSTRING_INDEX(email, '@', -1), '/', \
SUBSTRING_INDEX(email, '@', 1)) AS home \
FROM users WHERE email = '%u' AND active = 1
iterate_query = SELECT email AS username FROM users WHERE active = 1 chown root:dovecot /etc/dovecot/dovecot-sql.conf.ext
chmod 640 /etc/dovecot/dovecot-sql.conf.ext Configure LMTP and Authentication Sockets
Add or update these services in /etc/dovecot/conf.d/10-master.conf:
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
}
}
service imap-login {
inet_listener imap {
port = 0
}
inet_listener imaps {
port = 993
ssl = yes
}
} Require TLS for Mailbox Access
Set /etc/dovecot/conf.d/10-ssl.conf:
ssl = required
ssl_cert = </etc/letsencrypt/live/mail.example.org/fullchain.pem
ssl_key = </etc/letsencrypt/live/mail.example.org/privkey.pem
ssl_min_protocol = TLSv1.2 Validate the Core Configuration
Check syntax before restarting anything:
postfix check postconf -n doveconf -n
Verify all SQL lookup paths:
postmap -q example.org mysql:/etc/postfix/mysql-virtual-domains.cf
postmap -q postmaster@example.org mysql:/etc/postfix/mysql-virtual-mailboxes.cf
postmap -q abuse@example.org mysql:/etc/postfix/mysql-virtual-aliases.cf
doveadm auth test postmaster@example.org The first two Postfix queries should return 1; the alias query should return its destination.
systemctl restart dovecot
systemctl restart postfix
systemctl --no-pager --full status dovecot postfix
ss -ltnp | grep -E ':(25|587|993)\b' Configure DKIM Signing
apt install opendkim opendkim-tools
install -d -m 0750 -o opendkim -g opendkim /etc/opendkim/keys/example.org
opendkim-genkey -b 2048 -d example.org -D /etc/opendkim/keys/example.org -s mail
chown opendkim:opendkim /etc/opendkim/keys/example.org/mail.private
chmod 0600 /etc/opendkim/keys/example.org/mail.private Use a dedicated local TCP socket in /etc/opendkim.conf to avoid Postfix chroot path mismatches:
Syslog yes
UMask 007
Mode sv
Canonicalization relaxed/simple
OversignHeaders From
Socket inet:8891@localhost
UserID opendkim:opendkim
KeyTable refile:/etc/opendkim/KeyTable
SigningTable refile:/etc/opendkim/SigningTable
ExternalIgnoreList refile:/etc/opendkim/TrustedHosts
InternalHosts refile:/etc/opendkim/TrustedHosts Create /etc/opendkim/TrustedHosts:
127.0.0.1
::1
localhost
mail.example.org
example.org Create /etc/opendkim/KeyTable and /etc/opendkim/SigningTable:
mail._domainkey.example.org example.org:mail:/etc/opendkim/keys/example.org/mail.private *@example.org mail._domainkey.example.org Connect Postfix to the milter:
postconf -e 'milter_default_action = tempfail'
postconf -e 'milter_protocol = 6'
postconf -e 'smtpd_milters = inet:127.0.0.1:8891'
postconf -e 'non_smtpd_milters = inet:127.0.0.1:8891'
systemctl restart opendkim
systemctl reload postfix Publish the TXT value generated in /etc/opendkim/keys/example.org/mail.txt at mail._domainkey.example.org. Preserve the quoted fragments exactly as one DNS TXT record. Then verify it:
dig +short TXT mail._domainkey.example.org
opendkim-testkey -d example.org -s mail -vvv Configure Client Autodiscovery
SRV records help clients locate IMAP and submission services:
_imaps._tcp 3600 IN SRV 0 1 993 mail.example.org.
_submission._tcp 3600 IN SRV 0 1 587 mail.example.org. Thunderbird autoconfiguration is more reliable when served over HTTPS from https://autoconfig.example.org/mail/config-v1.1.xml or the provider's well-known endpoint. A minimal file is:
<?xml version="1.0" encoding="UTF-8"?>
<clientConfig version="1.1">
<emailProvider id="example.org">
<domain>example.org</domain>
<incomingServer type="imap">
<hostname>mail.example.org</hostname>
<port>993</port>
<socketType>SSL</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</incomingServer>
<outgoingServer type="smtp">
<hostname>mail.example.org</hostname>
<port>587</port>
<socketType>STARTTLS</socketType>
<authentication>password-cleartext</authentication>
<username>%EMAILADDRESS%</username>
</outgoingServer>
</emailProvider>
</clientConfig> Here password-cleartext describes the SASL mechanism inside a TLS-protected connection; the password is not sent over an unencrypted network. Outlook's Exchange Autodiscover schema is not a generic standard for arbitrary IMAP servers, so test each supported client rather than publishing an unverified Exchange response.
Manage Vacation Replies in SQL
Pigeonhole can load a user's active Sieve script from a Dovecot dictionary. Backing that dictionary with SQL keeps the enabled state, UTC time window, reply interval, subject, and plain-text or HTML body in one administrative data model. No per-user script file needs to be written when vacation settings change.
A dict-backed script is resolved in two steps. Dovecot first reads priv/sieve/name/active to obtain a data ID, then reads priv/sieve/data/<ID> to obtain the Sieve source. The data ID is also the bytecode-cache version. It must change whenever the generated source changes.
apt install dovecot-sieve
Store Vacation Settings
Store all timestamps as UTC. The unique key permits one vacation policy per mailbox, while the foreign key removes it with the mailbox:
USE mailserver;
CREATE TABLE vacation_settings (
user_id BIGINT UNSIGNED NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT FALSE,
starts_at DATETIME(6) NOT NULL,
ends_at DATETIME(6) NOT NULL,
period_days SMALLINT UNSIGNED NOT NULL DEFAULT 7,
subject VARCHAR(255) NOT NULL,
body TEXT NOT NULL,
html MEDIUMTEXT NULL,
updated_at TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
ON UPDATE CURRENT_TIMESTAMP(6),
PRIMARY KEY (user_id),
CONSTRAINT vacation_user_fk
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT vacation_period_ck CHECK (period_days BETWEEN 1 AND 365),
CONSTRAINT vacation_window_ck CHECK (starts_at < ends_at)
) ENGINE=InnoDB DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; Generate the Sieve Source
The first view prepares escaped values. Sieve quoted strings require backslashes and double quotes to be escaped. Multiline text: literals require dot-stuffing when a line contains only a period. Removing carriage returns normalizes stored text to LF before that transformation.
CREATE OR REPLACE VIEW vacation_sieve_values AS
SELECT
u.email AS username,
v.enabled,
v.starts_at,
v.ends_at,
LEAST(GREATEST(v.period_days, 1), 365) AS period_days,
REPLACE(
REPLACE(COALESCE(v.subject, ''), '\\', '\\\\'),
'"', '\\"'
) AS subject_escaped,
REPLACE(
REPLACE(TO_BASE64(COALESCE(v.body, '')), CHAR(13), ''),
CHAR(10), '${hex:0D 0A}'
) AS body_b64,
REPLACE(
REPLACE(TO_BASE64(COALESCE(v.html, '')), CHAR(13), ''),
CHAR(10), '${hex:0D 0A}'
) AS html_b64,
NULLIF(v.html, '') IS NOT NULL AS has_html,
CONCAT('vac_', SHA2(CONCAT(u.email, ':', v.updated_at), 256)) AS mime_boundary,
v.updated_at
FROM users u
JOIN vacation_settings v ON v.user_id = u.id
WHERE u.active = 1; The second view emits one script per configured mailbox. The script uses UTC ISO-8601 comparisons, ignores common automated and list messages, and always preserves normal delivery with keep. Pigeonhole's vacation extension additionally applies its RFC-defined recipient, sender, and duplicate-response safeguards.
CREATE OR REPLACE VIEW user_sieve_active AS
SELECT
username,
'active' AS script_name,
SHA2(CONCAT_WS(CHAR(0),
username, enabled, starts_at, ends_at, period_days,
subject_escaped, body_b64, html_b64, updated_at
), 256) AS id,
CASE
WHEN enabled = 0 THEN 'keep;'
ELSE CONCAT(
'require ["date","encoded-character","relational","vacation"]; ',
'if anyof(',
'header :matches "Auto-Submitted" "auto-*",',
'header :contains "Precedence" ["bulk","junk","list"],',
'exists "List-Id"',
') { keep; stop; } ',
'if allof(',
'currentdate :zone "+0000" :value "ge" "iso8601" "',
DATE_FORMAT(starts_at, '%Y-%m-%dT%H:%i:%sZ'), '",',
'currentdate :zone "+0000" :value "le" "iso8601" "',
DATE_FORMAT(ends_at, '%Y-%m-%dT%H:%i:%sZ'), '"',
') { vacation :days ', period_days,
' :subject "', subject_escaped, '"',
IF(has_html,
CONCAT(
' :mime "',
'MIME-Version: 1.0${hex:0D 0A}',
'Content-Type: multipart/alternative; boundary=\\"', mime_boundary,
'\\"${hex:0D 0A 0D 0A}',
'--', mime_boundary, '${hex:0D 0A}',
'Content-Type: text/plain; charset=UTF-8${hex:0D 0A}',
'Content-Transfer-Encoding: base64${hex:0D 0A 0D 0A}',
body_b64, '${hex:0D 0A}',
'--', mime_boundary, '${hex:0D 0A}',
'Content-Type: text/html; charset=UTF-8${hex:0D 0A}',
'Content-Transfer-Encoding: base64${hex:0D 0A 0D 0A}',
html_b64, '${hex:0D 0A}',
'--', mime_boundary, '--${hex:0D 0A}"; '
),
CONCAT(
' :mime "',
'MIME-Version: 1.0${hex:0D 0A}',
'Content-Type: text/plain; charset=UTF-8${hex:0D 0A}',
'Content-Transfer-Encoding: base64${hex:0D 0A 0D 0A}',
body_b64, '${hex:0D 0A}"; '
)
),
'} keep;'
)
END AS script_data
FROM vacation_sieve_values; The SHA-256 data ID includes every source input and the microsecond update timestamp. Any policy edit therefore invalidates the compiled Sieve binary without relying on a manual version counter or second-resolution timestamp.
Map Dovecot Dict Keys to the View
Create /etc/dovecot/dict-sieve-sql.conf.ext:
connect = host=127.0.0.1 dbname=mailserver user=mailreader password=REPLACE_WITH_A_LONG_RANDOM_PASSWORD
map {
pattern = priv/sieve/name/$script_name
table = user_sieve_active
username_field = username
value_field = id
fields {
script_name = $script_name
}
}
map {
pattern = priv/sieve/data/$id
table = user_sieve_active
username_field = username
value_field = script_data
fields {
id = $id
}
} chown root:dovecot /etc/dovecot/dict-sieve-sql.conf.ext
chmod 640 /etc/dovecot/dict-sieve-sql.conf.ext Register the SQL dictionary and its private proxy socket in /etc/dovecot/dovecot.conf:
dict {
sieve = mysql:/etc/dovecot/dict-sieve-sql.conf.ext
}
service dict {
unix_listener dict {
mode = 0600
user = vmail
}
} Enable Sieve during LMTP delivery in /etc/dovecot/conf.d/20-lmtp.conf:
protocol lmtp {
mail_plugins = $mail_plugins sieve
} Point the personal Sieve location at the proxied dictionary in /etc/dovecot/conf.d/90-sieve.conf:
plugin {
sieve = dict:proxy::sieve;name=active;bindir=/var/lib/dovecot/sieve/%u
sieve_vacation_min_period = 1d
sieve_vacation_default_period = 7d
sieve_vacation_max_period = 365d
}
postmaster_address = postmaster@example.org
sendmail_path = /usr/sbin/sendmail install -d -m 0700 -o vmail -g vmail /var/lib/dovecot/sieve
doveconf -n
systemctl restart dovecot Enable and Update a Vacation Policy
Administrative code only writes the settings row. It never writes Sieve files and does not need to restart Dovecot:
INSERT INTO vacation_settings
(user_id, enabled, starts_at, ends_at, period_days, subject, body, html)
VALUES (
(SELECT id FROM users WHERE email = 'postmaster@example.org'),
TRUE,
'2026-08-12 08:00:00',
'2026-08-24 18:00:00',
7,
'Out of office',
'I am currently away and will reply after I return.',
'<p>I am currently away and will reply after I return.</p>'
)
ON DUPLICATE KEY UPDATE
enabled = VALUES(enabled),
starts_at = VALUES(starts_at),
ends_at = VALUES(ends_at),
period_days = VALUES(period_days),
subject = VALUES(subject),
body = VALUES(body),
html = VALUES(html); Disable replies without deleting the retained policy:
UPDATE vacation_settings
SET enabled = FALSE
WHERE user_id = (
SELECT id FROM users WHERE email = 'postmaster@example.org'
); Validate the Generated Script
Test both dictionary lookups as the mail user:
script_id=$(sudo -u vmail doveadm dict get \
-u postmaster@example.org proxy::sieve priv/sieve/name/active)
sudo -u vmail doveadm dict get \
-u postmaster@example.org proxy::sieve priv/sieve/data/$script_id \
>/tmp/postmaster-vacation.sieve
sievec /tmp/postmaster-vacation.sieve
rm -f /tmp/postmaster-vacation.sieve /tmp/postmaster-vacation.svbin Send two messages from the same external address and confirm that only the first receives a reply during the configured interval. Also test a message carrying Auto-Submitted: auto-generated and one carrying List-Id; neither should receive a vacation response. Keep the default null envelope sender for replies to reduce loop risk, and do not disable Pigeonhole's recipient checks globally.
Semantic classification can instead run as an inbound ChatGPT or Ollama spam milter. In that design, Postfix accepts ordinary spam after tagging it, while a global Dovecot Sieve policy assigns the standardized junk role and keyword and delivers the message to a dedicated Spam mailbox before vacation processing.
Optional SpamAssassin Filtering
Spam filtering should be added only after core mail flow is stable. The following simple Postfix pipe is suitable for a small server, but it processes mail synchronously and must be capacity-tested. Larger systems should use a queue-aware content filter.
apt install spamassassin spamc systemctl enable --now spamassassin
Add a dedicated service to /etc/postfix/master.cf:
spamassassin unix - n n - - pipe
flags=Rq user=debian-spamd argv=/usr/bin/spamc -f -e
/usr/sbin/sendmail -oi -f ${sender} -- ${recipient} Add the filter override to the public SMTP service only:
smtp inet n - y - - smtpd -o content_filter=spamassassin
Confirm the package's daemon account with systemctl cat spamassassin; distributions may use debian-spamd or another account. Start with header tagging rather than message rejection, monitor false positives, and reload Postfix only after postfix check succeeds.
End-to-End Tests
Test SMTP and Submission TLS
openssl s_client -connect mail.example.org:25 -starttls smtp \
-servername mail.example.org
openssl s_client -connect mail.example.org:587 -starttls smtp \
-servername mail.example.org Send an authenticated test through submission:
swaks --server mail.example.org --port 587 --tls \
--auth LOGIN --auth-user postmaster@example.org \
--from postmaster@example.org --to recipient@example.net Omit --auth-password so Swaks prompts instead of recording the secret in shell history. Repeat the test from a network outside the server.
Test IMAP TLS and Authentication
openssl s_client -connect mail.example.org:993 \
-servername mail.example.org
doveadm auth test postmaster@example.org
doveadm user postmaster@example.org Test Relay Protection
From an unauthenticated external host, attempt to send from an unrelated domain to another unrelated domain. Postfix must reject the recipient with Relay access denied. A server that accepts this transaction is an open relay and must be taken offline until corrected.
Inspect Delivery and Authentication Results
journalctl -u postfix -u dovecot -u opendkim --since today
postqueue -p
doveadm mailbox list -u postmaster@example.org Send messages in both directions and inspect the received headers. Confirm SPF=pass, DKIM=pass, and DMARC=pass, verify that the DKIM signing domain aligns with the visible From domain, and check that replies use authenticated submission rather than direct port 25 delivery from clients.
Operations and Maintenance
- Apply security updates promptly and subscribe to Postfix, Dovecot, database, and distribution advisories.
- Monitor queue growth, disk space, certificate expiry, authentication failures, and DNS blocklist reputation.
- Back up the SQL database, /var/vmail, DKIM private keys, and service configuration as one recoverable set.
- Test restores on a separate host; an untested mail backup is not a recovery plan.
- Rate-limit authenticated submission and investigate compromised accounts immediately.
- Keep DMARC aggregate reporting active and review new sending sources before tightening policy.
- Use postconf -n and doveconf -n in configuration reviews to see effective non-default values.
A mail server is ready for production only when DNS identity, relay policy, TLS, authentication, local delivery, outbound signing, inbound delivery, backup restoration, and monitoring have all been tested independently. Package installation is the shortest part of that process; repeatable verification is what makes the system reliable.
References
- [Postfix] Postfix Documentation.
- [Dovecot] Dovecot 2.3 Documentation.
- [RFC6409] RFC 6409: Message Submission for Mail.
- [RFC6376] RFC 6376: DomainKeys Identified Mail Signatures.
- [RFC7208] RFC 7208: Sender Policy Framework.
- [RFC7489] RFC 7489: Domain-based Message Authentication, Reporting, and Conformance.