LinkedIn Sourceforge

Vincent's Blog

Pleasure in the job puts perfection in the work (Aristote)

Create a local email server with strong spam filter

Posted on 2026-04-05 17:52:00 from Vincent in OpenBSD

Give a shoutout to Andrey Matveevi on unsplash.com

This blog provides the precise configuration required to build a localized, self-learning mail server on OpenBSD. We assume your mail is hosted on a remote IMAP service and you want to filter it locally before your best email client (K-9 Mail in my case) ever rings.

This is an intermediary setup where I keep my provider as MX records, but I'm allowed to perform my own spam filtering.

Then by using imap server, I can publish cleaned inbox.


Introduction

In an era of bloated webmail and aggressive tracking, there is a certain quiet satisfaction in pulling your digital life back onto your own hardware. This guide explains how to build a localized mail processing pipeline using three Unix-centric tools: mbsync, Dovecot, and Bogofilter.

The Architecture

Our "Mail Stack" functions as a three-stage pipeline:
1. mbsync (The Retriever): Replicates from remote IMAP mailbox (dns provider, email provider, ...) to my local disk.
2. Dovecot (The Librarian): Serves those local files back to your devices (like K-9 Mail on Android) via a local IMAP server.
3. Bogofilter (The Sentry): A Bayesian filter that "learns" from your habits to automatically sort spam.

1. The Retriever: mbsync

mbsync (part of the isync package) is faster and more robust than the older offlineimap. It treats your remote mail and local mail as two "Stores" that must remain identical.
Install the isync package. mbsync is our synchronization engine.

$ doas pkg_add isync
$ mkdir -p ~/Mail/myfolder

Create a file ~/.mbsyncrc like this

# isync/mbsync 1.5.1 config for my email's provider
# Replace the placeholders with your actual values.

##############################################
# Global settings
##############################################

# Store your password in a GPG-encrypted file
# or use a PassCmd. See the "Passwords" section below.

##############################################
# IMAP Account
##############################################

IMAPAccount cloud
    Host imap.cloud.com
    Port 993
    User user@yourdomain.com
    PassCmd "gpg2 --quiet --for-your-eyes-only --no-tty --decrypt ~/.config/mbsync/cloud.gpg"
    TLSType IMAPS
    CertificateFile /etc/ssl/cert.pem

##############################################
# Remote store (IMAP side)
##############################################

IMAPStore cloud-remote
    Account cloud

##############################################
# Local store (Maildir on disk)
##############################################

MaildirStore cloud-local
    Path ~/Mail/myfolder/
    Inbox ~/Mailmyfolder/

##############################################
# Channel: ties remote and local together
##############################################

Channel cloud
    Far :cloud-remote:
    Near :cloud-local:
    Patterns *
    Create Both
    Expunge Both
    SyncState *

You can heck it by doing:

$ mbsync -a

You should not see errors

2. The Librarian: Dovecot

Why run a local server? It allows you to process mail (filter spam) on my OpenBSD box before my phone ever sees it.
Dovecot serves my local ~/Mail.

File: /etc/dovecot/local.conf (Or equivalent in conf.d) must regroup those parameters

those are spread into different configuration file located at /etc/dovecot/conf.d

# in 10-mail.conf makes sure you have at least this
first_valid_uid = 1000
mail_location = maildir:~/Mail/myfolder:LAYOUT=fs

# in 10-ssl.conf
ssl_cert = </etc/ssl/server.fullchain.pem
ssl_key = </etc/ssl/private/server.key

# in 10-auth.conf 
#!include auth-system.conf.ext  #commented to avoid unix like auth
!include auth-passwdfile.conf.ext  #uncomment to force /etc/dovecot/users

# auth-passwdfile.conf.ext must looks like this
passdb {
  driver = passwd-file
  args = scheme=BLF-CRYPT username_format=%u /etc/dovecot/users
}

userdb {
  driver = passwd-file
  args = username_format=%u /etc/dovecot/users
}

# in 10-logging.conf makes sure you have 
log_path = /var/log/dovecot.log 

In this case, I'm using a dedicated user file called /etc/dovecot/users.
This file has the same structure of /etc/passwd file.
You must have it like this:

<user>:{BLF-CRYPT}$2y$05$K...:1000:1000::/home/<user>

Where the hash key is create by this command:

$ doas doveadm pw -s BLF-CRYPT -p password >> /etc/dovecot/users

To avoid limitation errors, add this to the end of the /etc/login.conf file:

_dovecot:\
    :openfiles-cur=1024:\
    :openfiles-max=2048:\
    :tc=daemon:

Then you an enable and start dovecot:

$ doas rcctl enable dovecot 
$ doas rcctl start dovecot

You can follow details in /var/log/dovecot.log

In my specific case public key is managed by acme-client.
So, do not forget to reload dovecot after each recreation. This is either in cron or in /etc/daily.local or any other file triggering the certificate renewal.

acme-client -v mydomain.come && rcctl reload dovecot

3. The Brain: Bogofilter

Bogofilter doesn't use static rules. It calculates the "Spamicity" of an email based on words it has seen before.
On OpenBSD, use the LMDB version for high-speed database access.

$ doas pkg_add bogofilter-lmdb

Training Logic: Bogofilter is "Bayesian." It needs to know what Spam looks like AND what Good (Ham) mail looks like. Since you send mail via your phone's SMTP, your best "Good" data is your existing Inbox.

This can be done by doing bogofilter -s -B /path/to/spam or bogofilter -n -B /path/to/good/mails

So to simplify, here a small script doing all in once:

File: ~/.local/bin/process-mail.sh

#!/bin/sh

# Paths
MAILDIR="$HOME/Mail/myfolder"
SPAMDIR="$MAILDIR/Spam"
BOGOFILTER="/usr/local/bin/bogofilter"

mv_to_spam()
{
    msg=$1
    # mktemp creates a unique file; we then rename it with a .spam suffix
    dest=$(mktemp "$SPAMDIR/new/$(date +%s).XXXXXX.spam")
    mv "$msg" "$dest"
}

# 1. Sync Remote to Local
/usr/local/bin/mbsync -a -q

# 2. Learn from what you manually moved to Spam (only new/unprocessed ones)
for f in "$SPAMDIR/new/"*; do
    [ -f "$f" ] || continue
    $BOGOFILTER -s < "$f" 2>/dev/null
    mv "$f" "$SPAMDIR/cur/$(basename "$f")"
done

# 3. Filtering Phase (Processing New Mail)
if [ -d "$MAILDIR/new" ]; then
    for msg in "$MAILDIR/new/"*; do
        [ -f "$msg" ] || continue

        # -u: unsupervised learning (auto-updates wordlist)
        # -e: exit code reflects spam/ham/unsure
        # -p: passthrough, appending X-Bogosity header
        RESULT=$($BOGOFILTER -u -e -p < "$msg" 2>/dev/null)
        EXIT_CODE=$?

        # Exit code 3 = error (e.g. malformed/Base64 crash)
        # Treat as spam to be safe
        if [ $EXIT_CODE -eq 3 ]; then
            mv_to_spam "$msg"
            continue
        fi

        if echo "$RESULT" | grep -q "X-Bogosity: Spam"; then
            echo "$RESULT" > "$msg"
            mv_to_spam "$msg"
        else
            # Ham or Unsure: keep in new, update header in place
            echo "$RESULT" > "$msg"
        fi
    done
fi

To keep your inbox clean, run this every 5 minutes.

Command: crontab -e

*/5 * * * * $HOME/.local/bin/process-mail.sh

If you have lot of email already "read", you can train bogofilter by doing this:

# Learn from what you have in Spam
$BOGOFILTER -s -B "$SPAMDIR/cur/"* 2>/dev/null

# Learn good emails
$BOGOFILTER -n -B "$MAILDIR/cur/"* 2>/dev/null

Why This Setup is Superior

  • The Error Catcher: The EXIT_CODE check is vital on OpenBSD. Malformed phishing (massive Base64 blocks) can trigger memory faults in many parsers. By treating a crash as "Spam," you prevent your script from hanging.
  • The "Ham" Baseline: By training on your cur/ inbox after you've manually cleared out the junk, you provide Bogofilter with a high-quality "Good" baseline.
  • Notifications: Because the script runs on the server, K-9 Mail on your phone only triggers a notification for emails that passed the Bogofilter test.

How to verify your "Brain"

Run this command to see your message counts:

$ bogoutil -w ~/.bogofilter/wordlist.lmdb .MSG_COUNT
                                 spam   good
.MSG_COUNT                       1533     31

A healthy setup will show hundreds of spams and dozens (or hundreds) of good emails. As those numbers grow, the "Unsure" results will vanish.

On the k9-mail client app

You can just provide this new machine name/ip with the user and password as defined in /etc/dovecot/users
It should display your emails without Spam. In case you have some, you can move those emails to the "Spam" folder.
And every 5 minutes Bogofilter will take it into account and increase his spam Bayesian algorithm.

Conclusion

My OpenBSD mail stack acts as a "Sovereign Filter," using mbsync to create a bidirectional mirror between the cloud and my local disk. By running a script every five minutes, Bogofilter inspects new arrivals and "rebirths" spam into the Spam folder.

Because mbsync synchronizes these changes back to my email's provider, my remote inbox is automatically scrubbed of spams. While I could point my phone directly at my email provider for an "auto-cleaning" experience, routing K-9 Mail through a local Dovecot instance ensures a truly silent inbox where spam is intercepted before a notification ever pings. This architecture transforms a passive email account into a private, self-learning system controlled entirely by my own hardware.



👍 0, 👎 0
displayed: 175



What is the last letter of the word Python?