Skip to main content

Postfix Setup Guide for Postchi

Architecture Overview

┌─────────────┐      ┌─────────────┐      ┌─────────────┐      ┌─────────────┐
│ API │─────▶│ Redis Queue │─────▶│ Worker │─────▶│ Postfix │
│ (Node.js) │ │ (BullMQ) │ │ (Node.js) │ │ (SMTP) │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
│ │ │
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ Internet
│ PostgreSQL │ │ DKIM Signing│
│ (Domains, │ │ (Nodemailer)│
│ DKIM Keys)│ └─────────────┘
└─────────────┘

Flow:

  1. User sends email via API → Domain validation
  2. API creates Message record in DB with DKIM keys from Domain table
  3. Email queued in Redis (BullMQ)
  4. Worker picks up job, signs email with DKIM (Nodemailer handles signing)
  5. Worker sends to Postfix via SMTP (authenticated)
  6. Postfix relays to recipient's mail server

Key Points:

  • DKIM signing happens in Nodemailer (worker), NOT in Postfix
  • Postfix acts as a relay server, not a signer
  • Each organization's domains can have their own DKIM keys

1. Postfix Server Configuration

Installation

sudo apt update
sudo apt install postfix postfix-pcre sasl2-bin libsasl2-modules

Select "Internet Site" during installation.


Main Configuration (/etc/postfix/main.cf)

# ============================================
# BASIC IDENTIFICATION
# ============================================
myhostname = mail.postchi.io
mydomain = postchi.io
myorigin = $mydomain

# Network
inet_interfaces = all
inet_protocols = ipv4

# ============================================
# RELAY CONFIGURATION (IMPORTANT)
# ============================================
# Only accept mail from authenticated clients
mynetworks = 127.0.0.0/8, [::1]/128
relay_domains =
smtpd_relay_restrictions =
permit_mynetworks,
permit_sasl_authenticated,
reject_unauth_destination

# Accept mail from anywhere (for receiving bounces)
smtpd_recipient_restrictions =
permit_mynetworks,
permit_sasl_authenticated,
reject_invalid_hostname,
reject_non_fqdn_hostname,
reject_non_fqdn_sender,
reject_non_fqdn_recipient,
reject_unknown_sender_domain,
reject_unknown_recipient_domain,
reject_rbl_client zen.spamhaus.org,
permit

# ============================================
# AUTHENTICATION (SASL)
# ============================================
smtpd_sasl_auth_enable = yes
smtpd_sasl_type = dovecot
smtpd_sasl_path = private/auth
smtpd_sasl_security_options = noanonymous
smtpd_sasl_local_domain = $myhostname
broken_sasl_auth_clients = yes

# ============================================
# TLS/SSL CONFIGURATION
# ============================================
# For development - use self-signed
smtpd_tls_cert_file = /etc/ssl/certs/postfix-selfsigned.crt
smtpd_tls_key_file = /etc/ssl/private/postfix-selfsigned.key

# For production - use Let's Encrypt
# smtpd_tls_cert_file = /etc/letsencrypt/live/mail.postchi.io/fullchain.pem
# smtpd_tls_key_file = /etc/letsencrypt/live/mail.postchi.io/privkey.pem

smtpd_tls_security_level = may
smtpd_tls_auth_only = yes
smtpd_tls_loglevel = 1
smtpd_tls_received_header = yes
smtpd_tls_session_cache_timeout = 3600s

# SMTP client TLS (outbound)
smtp_tls_security_level = may
smtp_tls_loglevel = 1

# ============================================
# MESSAGE SIZE & LIMITS
# ============================================
message_size_limit = 26214400 # 25MB (matches your worker config)
mailbox_size_limit = 0

# ============================================
# RATE LIMITING (ANTI-ABUSE)
# ============================================
smtpd_client_connection_rate_limit = 100
smtpd_client_message_rate_limit = 100
smtpd_client_recipient_rate_limit = 200

# Per-recipient limits
anvil_rate_time_unit = 60s
smtpd_client_event_limit_exceptions = $mynetworks

# ============================================
# LOGGING
# ============================================
maillog_file = /var/log/postfix.log

# ============================================
# BOUNCE HANDLING
# ============================================
# Send bounces back to your system for processing
notify_classes = bounce, 2bounce, delay, policy, protocol, resource, software
bounce_notice_recipient = bounces@postchi.io

Master Configuration (/etc/postfix/master.cf)

# ============================================
# SMTP SERVICE (Port 25) - For receiving bounces
# ============================================
smtp inet n - y - - smtpd

# ============================================
# SUBMISSION SERVICE (Port 587) - For your worker
# ============================================
submission inet n - y - - smtpd
-o syslog_name=postfix/submission
-o smtpd_tls_security_level=encrypt
-o smtpd_sasl_auth_enable=yes
-o smtpd_tls_auth_only=yes
-o smtpd_reject_unlisted_recipient=no
-o smtpd_relay_restrictions=permit_sasl_authenticated,reject
-o milter_macro_daemon_name=ORIGINATING
-o smtpd_client_restrictions=permit_sasl_authenticated,reject

# ============================================
# SMTPS SERVICE (Port 465) - Optional (legacy)
# ============================================
# smtps inet n - y - - smtpd
# -o syslog_name=postfix/smtps
# -o smtpd_tls_wrappermode=yes
# -o smtpd_sasl_auth_enable=yes
# -o smtpd_relay_restrictions=permit_sasl_authenticated,reject

2. Authentication Setup (SASL)

Install Dovecot:

sudo apt install dovecot-core

Configure /etc/dovecot/conf.d/10-master.conf:

service auth {
unix_listener /var/spool/postfix/private/auth {
mode = 0660
user = postfix
group = postfix
}
}

Create user database /etc/dovecot/users:

postchi_worker:{PLAIN}your_secure_password

Set permissions:

sudo chmod 640 /etc/dovecot/users
sudo chown root:dovecot /etc/dovecot/users

Option B: Cyrus SASL (Simpler for Testing)

Create /etc/postfix/sasl/smtpd.conf:

pwcheck_method: auxprop
auxprop_plugin: sasldb
mech_list: PLAIN LOGIN

Add user:

sudo saslpasswd2 -c -u postchi.io postchi_worker
# Enter password when prompted

Verify:

sudo sasldblistusers2

Option C: No Auth (Local Testing Only)

If worker runs on same machine:

# In main.cf
mynetworks = 127.0.0.0/8
smtpd_relay_restrictions = permit_mynetworks, reject

3. TLS/SSL Certificate Setup

Development (Self-Signed)

sudo openssl req -new -x509 -days 365 -nodes \
-out /etc/ssl/certs/postfix-selfsigned.crt \
-keyout /etc/ssl/private/postfix-selfsigned.key

sudo chmod 600 /etc/ssl/private/postfix-selfsigned.key

Production (Let's Encrypt)

sudo apt install certbot
sudo certbot certonly --standalone -d mail.postchi.io

# Update main.cf to use:
# smtpd_tls_cert_file = /etc/letsencrypt/live/mail.postchi.io/fullchain.pem
# smtpd_tls_key_file = /etc/letsencrypt/live/mail.postchi.io/privkey.pem

4. Firewall Configuration

# Allow SMTP ports
sudo ufw allow 25/tcp # SMTP (receiving)
sudo ufw allow 587/tcp # Submission (your worker)
sudo ufw allow 465/tcp # SMTPS (optional)

# Check status
sudo ufw status

5. DNS Records Configuration

For each domain your users add, they need to configure these DNS records:

Required Records

1. MX Record (for receiving bounces)

Type: MX
Host: @
Value: mail.postchi.io
Priority: 10
TTL: 3600

2. SPF Record (sender policy framework)

Type: TXT
Host: @
Value: v=spf1 ip4:YOUR_SERVER_IP include:mail.postchi.io ~all
TTL: 3600

Your code generates this at /packages/api/src/utils/dkim.ts:52-78

3. DKIM Record (generated per domain)

Type: TXT
Host: default._domainkey
Value: v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA4G...
TTL: 3600

Your code generates this at /packages/api/src/utils/dkim.ts:38-47

Selector: Stored in Domain.dkimSelector (default: "default")

4. DMARC Record (email authentication policy)

Type: TXT
Host: _dmarc
Value: v=DMARC1; p=quarantine; rua=mailto:dmarc@postchi.io
TTL: 3600

Your code generates this at /packages/api/src/utils/dkim.ts:83-121

Example DNS Configuration for user domain example.com:

; MX Record
example.com. 3600 IN MX 10 mail.postchi.io.

; SPF Record
example.com. 3600 IN TXT "v=spf1 ip4:203.0.113.1 include:mail.postchi.io ~all"

; DKIM Record
default._domainkey.example.com. 3600 IN TXT "v=DKIM1; k=rsa; p=MIGfMA0GCSqGSIb3DQEBAQUAA..."

; DMARC Record
_dmarc.example.com. 3600 IN TXT "v=DMARC1; p=quarantine; rua=mailto:dmarc@postchi.io"

; Domain verification (temporary)
example.com. 3600 IN TXT "postchi-verify-abc123def456..."

6. Environment Variables Configuration

Update your .env file:

Development (Local Testing)

# SMTP Configuration - Postfix
SMTP_HOST=localhost
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=

# For local testing without auth
# SMTP_PORT=25 # or use submission port

Production

# SMTP Configuration - Postfix
SMTP_HOST=mail.postchi.io
SMTP_PORT=587
SMTP_SECURE=true
SMTP_USER=postchi_worker
SMTP_PASS=your_secure_password_from_sasl_setup

7. Testing Plan

Step 1: Test Postfix Basics

# Check Postfix status
sudo systemctl status postfix

# View logs
sudo tail -f /var/log/postfix.log

# Test SMTP connection
telnet localhost 25

Step 2: Test SASL Authentication

# Install test tool
sudo apt install swaks

# Test authenticated submission
swaks --to test@example.com \
--from noreply@postchi.io \
--server localhost:587 \
--auth LOGIN \
--auth-user postchi_worker \
--auth-password your_password \
--tls

Step 3: Test from Worker

Update your worker .env:

SMTP_HOST=localhost  # or your server IP
SMTP_PORT=587
SMTP_USER=postchi_worker
SMTP_PASS=your_password

Send test email via API:

curl -X POST http://localhost:3000/api/v1/send \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"from": {"email": "test@yourdomain.com"},
"to": [{"email": "recipient@example.com"}],
"subject": "Test Email",
"html": "<p>This is a test email</p>"
}'

Check logs:

# Worker logs
npm run worker # or check Docker logs

# Postfix logs
sudo tail -f /var/log/postfix.log

Step 4: Verify DKIM Signing

Send email to a Gmail address, then:

  1. Open the email in Gmail
  2. Click "Show original"
  3. Check for:
    • DKIM: 'PASS'
    • SPF: 'PASS'
    • DMARC: 'PASS'

Or use online tools:

Step 5: Test Bounce Handling

Send to invalid address:

curl -X POST http://localhost:3000/api/v1/send \
-H "Authorization: Bearer YOUR_API_KEY" \
-d '{
"from": {"email": "test@yourdomain.com"},
"to": [{"email": "invalid@nonexistent-domain-12345.com"}],
"subject": "Bounce Test",
"text": "This should bounce"
}'

Check:

  1. Postfix logs for bounce message
  2. Database MessageEvent table for BOUNCED event

8. DKIM Key Management Workflow

Your application already has the utilities, here's how it works:

When User Adds Domain (POST /api/domains)

// In domain.service.ts
import { generateDkimKeys, formatDkimPublicKeyForDns, generateSpfRecord, generateDmarcRecord, generateVerificationToken } from '../utils/dkim.js';

// 1. Generate DKIM keys
const dkimKeys = generateDkimKeys('default');

// 2. Generate DNS records
const spfRecord = generateSpfRecord({
ip4Addresses: [process.env.SERVER_IP],
includeDomains: ['mail.postchi.io']
});

const dmarcRecord = generateDmarcRecord({
policy: 'quarantine',
rua: ['dmarc@postchi.io']
});

const dkimRecord = formatDkimPublicKeyForDns(dkimKeys.publicKey);

// 3. Store in database
await prisma.domain.create({
data: {
domain: 'example.com',
organizationId,
dkimSelector: 'default',
dkimPrivateKey: dkimKeys.privateKey, // TODO: Encrypt this!
dkimPublicKey: dkimKeys.publicKey,
spfRecord,
dmarcRecord,
dkimRecord,
verificationToken: generateVerificationToken(),
verificationStatus: 'PENDING'
}
});

Domain Verification

User adds TXT record:

Type: TXT
Host: @
Value: postchi-verify-abc123def456...

Your API checks:

// Verify DNS TXT record exists
const dns = require('dns').promises;
const records = await dns.resolveTxt(domain);
const verified = records.some(r => r.includes(verificationToken));

When Sending Email

Worker retrieves DKIM keys from database:

// email.worker.ts already does this on lines 168-170
const transporter = createSmtpTransporter({
domainName: data.domain,
keySelector: data.dkimSelector,
privateKey: data.dkimPrivateKey // Retrieved from DB
});

Nodemailer signs the email automatically.


9. Security Considerations

MUST DO:

  1. Encrypt DKIM private keys in database

    • Use crypto.publicEncrypt() with a master key
    • Store master key in secure environment variable
    • Decrypt only in worker when sending
  2. Use TLS for SMTP connections (port 587)

    • Set SMTP_SECURE=true in production
  3. Implement rate limiting per organization

    • Already in schema: emailsPerHour, emailsPerDay
    • Enforce in API before queueing
  4. Firewall rules

    • Only allow port 587 from your worker server IPs
    • Use SSH key auth, disable password login
  5. Monitor bounce rates

    • High bounce = possible spam
    • Auto-pause domains with >10% bounce rate

NICE TO HAVE:

  • IP warm-up strategy (gradual increase in sending volume)
  • Dedicated IPs for high-volume senders
  • Feedback Loop (FBL) setup with major ISPs
  • Regular DKIM key rotation (every 6 months)

10. Postfix Commands Cheatsheet

# Service management
sudo systemctl start postfix
sudo systemctl stop postfix
sudo systemctl restart postfix
sudo systemctl status postfix

# View mail queue
mailq
postqueue -p

# Flush queue (retry delivery)
postqueue -f

# Delete all mail in queue
sudo postsuper -d ALL

# Delete deferred mail
sudo postsuper -d ALL deferred

# View logs
sudo tail -f /var/log/postfix.log
sudo grep "ERROR" /var/log/postfix.log

# Test configuration
sudo postfix check

# Reload after config change
sudo postfix reload

# View active connections
sudo postconf -n | grep smtp

# Check SASL users (Cyrus)
sudo sasldblistusers2

# Test TLS
openssl s_client -connect localhost:587 -starttls smtp

11. Troubleshooting

Worker can't connect to Postfix

# Check Postfix is listening
sudo netstat -tlnp | grep master

# Check SASL users exist
sudo sasldblistusers2

# Test connection manually
telnet localhost 587

# Check firewall
sudo ufw status

Email sent but not received

# Check queue
mailq

# Check recipient's mail server
nslookup -type=MX recipient-domain.com

# Check SPF/DKIM/DMARC
# Send to mail-tester.com and check score

DKIM signature fails

  1. Check public key in DNS matches database
  2. Verify selector is correct (default._domainkey)
  3. Check private key format (PEM)
  4. Test with online DKIM validator

High bounce rate

  1. Check DNS records (SPF, DKIM, DMARC)
  2. Verify sending IP not blacklisted: https://mxtoolbox.com/blacklists.aspx
  3. Implement double opt-in for contacts
  4. Clean your contact lists regularly

Summary

Your Current Setup:

  • ✅ DKIM signing in Nodemailer (worker)
  • ✅ DKIM key generation utilities
  • ✅ Database schema for domain management
  • ✅ Queue-based email processing
  • ❌ DKIM key encryption (TODO)
  • ❌ Automated domain verification (TODO)

Postfix Role:

  • Acts as authenticated SMTP relay
  • Does NOT sign emails (Nodemailer does)
  • Receives bounces and feedback
  • Handles TLS/authentication

Next Steps:

  1. Set up Postfix on your server with above config
  2. Configure SASL authentication
  3. Add your worker's credentials to .env
  4. Test sending with your domain
  5. Verify DKIM/SPF/DMARC pass
  6. Implement DKIM key encryption before production