raw Software

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

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:

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

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