Your mailing lists,
actually manageable

Self-hosted SMTP receiver + AI triage + Matrix notifications. Urgent threads surface immediately; everything else lands in a daily digest.

inbound
SMTP
deliver
Postfix
triage
ollama
classify
Claude
notify
Matrix

Services

📮

mail-receiver

Postfix accepts mail for one address, rejects everything else. Writes Maildir files.

⚙️

digestor

Watches Maildir with inotify. Runs ollama triage, then Claude for urgent items. Stores everything in SQLite.

🤖

notifier

Matrix bot. Delivers urgent alerts immediately and daily digests on schedule. Accepts !cancel commands.

🧠

ollama

Runs gemma3:4b locally. Fast first-pass filter — only flagged messages go to Claude.

Quick start

Clone and copy templates

git clone https://github.com/jmq/mailing-list-digestor
cd mailing-list-digestor
cp .env.example .env
cp config/context.md.example config/context.md
cp config/lists.yaml.example config/lists.yaml

Fill in .env

# Required
SMTP_RECIPIENT=digest@yourdomain.example
SMTP_HOSTNAME=mail.yourdomain.example
ANTHROPIC_API_KEY=sk-ant-...

# Matrix
MATRIX_HOMESERVER=https://matrix.example.com
MATRIX_USERNAME=@digestbot:example.com
MATRIX_PASSWORD=your-bot-password
MATRIX_WHITELIST=@you:example.com
MATRIX_ROOM_ID=!roomid:example.com

Describe your interests in config/context.md

Plain markdown. Tell the digestor which working groups you follow, what counts as urgent, and what to ignore. Re-read on every processing cycle — edit any time without restarting.

Map lists to working groups in config/lists.yaml

working_groups:
  QUIC:
    - quic@ietf.org
  TLS:
    - tls@ietf.org

Daily digests are grouped by these names.

Start everything

podman-compose up -d

On first start, the ollama container pulls gemma3:4b — this takes a few minutes. After that, subscribe $SMTP_RECIPIENT to your mailing lists. Subscription confirmations arrive as Matrix notifications.

Configuration reference

Environment variables

VariableDefaultDescription
SMTP_RECIPIENTAddress to accept mail for
SMTP_HOSTNAMEmail-receiverPostfix hostname / SMTP banner
SMTP_PORT25Host port mapped to container port 25
ANTHROPIC_API_KEYAnthropic API key
OLLAMA_MODELgemma3:4bLocal triage model name
DIGEST_TIME07:00Daily digest time (HH:MM, container TZ)
MATRIX_HOMESERVERMatrix homeserver URL
MATRIX_USERNAMEBot Matrix ID
MATRIX_PASSWORDBot password
MATRIX_WHITELISTComma-separated Matrix IDs for the bot
MATRIX_ROOM_IDRoom to post notifications to
POLL_INTERVAL_S30Notifier poll interval in seconds

Matrix bot commands

CommandEffect
!track <interval> <url> [for <description>]Track a URL; intervals: hourly, daily, weekly, Nh, Nd, Nw
!untrack <id>Stop tracking a URL by its ID (shown in !list)
!listList active trackings and pending notification count
!cancel <id>Cancel a specific repeating notification
!cancel-allCancel all active repeating notifications
anything elseStored as a reply; digestor updates context.md next cycle

The tracker always renders pages with headless Chromium and only calls Claude when the text content changes.

Deployment

Requirements

A Linux host with:

cgroups note: On Alpine/OpenRC, cgroups aren't mounted automatically. Run sudo rc-update add cgroups default && sudo rc-service cgroups start once.

systemd user service

A unit file is included at ~/.config/systemd/user/mailing-list-digestor.service:

systemctl --user daemon-reload
systemctl --user enable --now mailing-list-digestor
loginctl enable-linger   # start at boot without a session

Health check

podman exec $(podman ps -qf name=digestor) \
  curl -s http://localhost:8080/healthz

Returns {"status": "ok", "db": "ok"}.

Smoke test

Tests the mail delivery path without any AI credentials:

SMTP_RECIPIENT=smoketest@mail-receiver.test ./smoke_test.sh