Skip to main content

Spam-Digest

Spam-Digest is a Docker-first tool that reads the spam folder of one or more IMAP mailboxes and emails you an HTML digest of what landed there. An optional AI step (Anthropic Claude) labels each email probably legitimate, uncertain or spam, and signed links in the digest let you rescue, trust or delete emails from a web page, without opening a mail client.

It is the companion of Clean Mail Automation: Spam-Digest helps you catch real email wrongly flagged as spam, Clean Mail Automation deletes old email from folders you don't need to keep.

Spam-Digest review page

Overview​

Key features​

  • Daily digest email per mailbox, on a cron schedule. No email when the spam folder is empty, unless SEND_IF_EMPTY=true.
  • Multi-mailbox: any number of IMAP accounts in one container (MAILBOX_CONFIGS), each with its own spam folder and digest recipient.
  • Spam folder detection: if the configured folder doesn't exist, common names are tried (Spam, Junk, INBOX.Spam, INBOX.Junk, [Gmail]/Spam).
  • AI triage (optional): only sender and subject are sent to Claude, never the body.
  • Review page: move an email to INBOX (optionally adding the sender to the allowlist) or delete it, one at a time or a whole section at once.
  • Blocklist and allowlist rules (sender, domain, subject keyword): blocked emails are deleted and allowed ones moved to INBOX at each run, and both are listed in the digest.
  • Delete confirmed spam with one button, after a confirmation page.
  • Status dashboard on port 8080, with buttons to run a digest now.
  • SMTP or Resend for sending the digest.
  • Pure Python standard library, no dependencies.

Architecture at a glance​

  • Digest (spam_digest.py): IMAP fetch, blocklist/allowlist rules, AI classification, digest HTML, email delivery, state file. Run by cron.
  • Web server (status_server.py): dashboard and the signed pages (/review, /filters, /action/delete-spam). Started by entrypoint.sh.
  • Shared module (shared.py): link signing and rotation, rules and allowlist storage, email delivery, file locking.
  • Entrypoint (entrypoint.sh): writes the cron schedule, starts the web server, optionally runs a digest at startup (RUN_ON_START).
  • Data volume (/data): last-run state, signing secret, link nonces, rules, allowlist and the actions log. Mount it as a volume so links and rules survive container updates.

Quick start​

Docker Compose: single mailbox​

services:
spam-digest:
image: ghcr.io/gioxx/spam-digest:latest
container_name: spam-digest
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- spam_digest_data:/data
environment:
TZ: Europe/Rome
IMAP_SERVER: imap.example.com
EMAIL_USER: you@example.com
EMAIL_PASS: your-imap-password
SPAM_FOLDER: Junk
SMTP_HOST: smtp.example.com
SMTP_PORT: "587"
SMTP_USER: you@example.com
SMTP_PASS: your-smtp-password
# Optional: AI triage
AI_PROVIDER: anthropic
AI_API_KEY: sk-ant-...
# Optional: review, filters and delete links in the digest
WEB_BASE_URL: https://spamdigest.example.com
SCHEDULE_HOUR: "8"
SCHEDULE_MIN: "0"

volumes:
spam_digest_data:
docker compose up -d

The digest runs every day at 08:00 (TZ timezone). It does not run at container start unless RUN_ON_START=true. The dashboard is at http://localhost:8080.

The repository also ships a docker-compose.yml and an .env.example to start from.

Docker Compose: multi-mailbox​

Set MAILBOX_CONFIGS to a JSON array. It replaces the single-mailbox IMAP variables; delivery, AI and schedule variables stay global.

environment:
MAILBOX_CONFIGS: |
[
{"imap_server":"imap.contoso.com","email_user":"alex@contoso.com","email_pass":"app-password","spam_folder":"Junk"},
{"imap_server":"imap.example.com","email_user":"team@example.com","email_pass":"secret","spam_folder":"Spam","digest_to":"it@example.com"},
{"imap_server":"mail.example.org","email_user":"info@example.org","email_pass":"secret","imap_use_ssl":false,"imap_port":143}
]
SMTP_HOST: smtp.example.com
SMTP_USER: alerts@example.com
SMTP_PASS: your-smtp-password

Fields of each entry:

FieldDefaultDescription
imap_servernoneIMAP hostname. Required.
imap_port993IMAP port.
imap_use_ssltruefalse for a plain connection (e.g. port 143).
email_usernoneIMAP username. Required.
email_passnoneIMAP password. Required.
email_addressemail_userMailbox identifier used in the digest, the dashboard and the links.
spam_folderJunkSpam folder name.
max_emails100Newest emails read per run.
digest_toemail_addressDigest recipient for this mailbox.

Building locally​

git clone https://github.com/gioxx/spam-digest.git
cd spam-digest
cp .env.example .env # then edit .env
docker build -t spam-digest .
docker run -d --env-file .env -p 8080:8080 -v spam_digest_data:/data spam-digest

Environment variables​

Mailbox (single-mailbox mode)​

VariableDefaultDescription
IMAP_SERVERnoneIMAP hostname. Required unless MAILBOX_CONFIGS is set.
IMAP_PORT993IMAP port.
IMAP_USE_SSLtruefalse to connect without SSL/TLS.
EMAIL_USERnoneIMAP username. Required.
EMAIL_PASSnoneIMAP password. Required.
EMAIL_ADDRESSEMAIL_USERMailbox identifier in the digest, dashboard and links.
SPAM_FOLDERJunkSpam folder name. Common aliases are tried if it doesn't exist.
MAX_EMAILS100Newest emails read per run.
DIGEST_TOEMAIL_ADDRESSDigest recipient.
MAILBOX_CONFIGSnoneJSON array for multi-mailbox mode (see above).

Email delivery​

VariableDefaultDescription
EMAIL_PROVIDERsmtpsmtp or resend.
SMTP_HOSTnoneSMTP hostname. Required with smtp.
SMTP_PORT587465 uses implicit SSL, any other port uses STARTTLS.
SMTP_USER / SMTP_PASSnoneSMTP credentials (login is skipped if either is empty).
RESEND_API_KEYnoneResend API key (re_...). Required with resend.
DIGEST_FROMSMTP_USERSender: a bare address or Name <address>. Required with Resend, on a verified domain (or onboarding@resend.dev for tests).
SEND_IF_EMPTYfalseSend a digest even when the spam folder is empty.

AI​

VariableDefaultDescription
AI_PROVIDERnoneanthropic to enable AI triage.
AI_API_KEYnoneAnthropic API key. Required with anthropic.
AI_MODELclaude-haiku-4-5-20251001Claude model used for classification.
AI_MAX_EMAILS50Maximum emails sent to the AI per run, across all mailboxes. The rest appear as Not classified.

Schedule and web​

VariableDefaultDescription
SCHEDULE_MIN0Cron minute.
SCHEDULE_HOUR8Cron hour.
SCHEDULE_DAY*Cron weekday: * every day, 0 Sunday … 6 Saturday. Ranges and lists (1-5, 1,3) work too.
RUN_ON_STARTfalseAlso run a digest when the container starts.
TZUTCContainer timezone, used by the schedule and timestamps.
WEB_PORT8080Port of the dashboard inside the container.
WEB_BASE_URLnonePublic address of the dashboard, e.g. https://spamdigest.example.com. Without it the digest has no review, filters or delete links.

Email delivery​

SMTP (default)​

EMAIL_PROVIDER=smtp
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=you@example.com
SMTP_PASS=your-smtp-password
DIGEST_FROM=Spam Digest <spam-digest@example.com>

Resend​

Resend sends over HTTPS with just an API key, no SMTP server needed. Resend sees the envelope and content of the digest.

EMAIL_PROVIDER=resend
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxx
DIGEST_FROM=Spam Digest <digest@yourdomain.com>

DIGEST_FROM must be on a domain verified in Resend (SPF and DKIM records). The free tier is plenty for a personal digest.

AI classification​

With AI_PROVIDER=anthropic, Spam-Digest sends Claude only the sender and subject of each email (never the body) and gets back a label and a short reason:

LabelDigest sectionMeaning
safe🟢 Probably legitimateLikely real email wrongly flagged as spam.
uncertain🟡 UncertainCan't tell; needs a human look.
spam🔴 Confirmed spamClearly unwanted, commercial or malicious.

Emails beyond AI_MAX_EMAILS, or left out of a malformed answer, go to Not classified. If the AI call fails, the digest is still sent, with every email under Not classified.

Cost is very low: one request per run with a short prompt. With Claude Haiku and 100 emails, expect a fraction of a cent per run.

The digest email​

Spam-Digest digest email

Each mailbox gets its own digest with:

  • A summary of totals per label.
  • Auto-deleted and Auto-moved to INBOX sections, listing what your blocklist and allowlist handled in this run.
  • The emails grouped by AI label, with date, sender, subject and reason.
  • Buttons, when WEB_BASE_URL is set: Review under the probably legitimate and uncertain sections, Delete N confirmed spam email(s) under confirmed spam, and Manage filters & allowlist.

The review, filters and delete pages have no login: each link carries an HMAC signature, made with a secret stored in /data. Each page is limited to one mailbox and to what the last digest listed.

Review page (/review)​

Lists the uncertain and probably legitimate emails from the last digest.

ActionEffect
Trust & move to INBOXMoves the email to INBOX and adds the sender to the allowlist, so future emails from them skip the spam folder.
Move to INBOX (probably legitimate only)Moves the email without trusting the sender.
DeleteDeletes the email permanently.
Delete all (N) (uncertain, 2+ emails)Deletes every uncertain email in the list.
Move all to INBOX (N) (probably legitimate, 2+ emails)Moves every probably legitimate email, without trusting the senders.

Every action asks for confirmation. The link rotates with every digest: the one in an older digest stops working when a new digest is sent.

Filters page (/filters)​

Spam-Digest filters page

Manage two rule lists for the mailbox. Rule types: sender_exact (full address), sender_domain, subject_contains (case-insensitive).

  • Blocklist: matching emails are deleted at the next digest run.
  • Allowlist: matching emails are moved to INBOX at the next digest run. Senders trusted from the review page are applied too.

Preview matches shows which emails currently in the spam folder a rule would hit, before you save it. A fresh filters link is in every digest; the dashboard's Filters button rotates it and emails the new link to the mailbox's digest recipient (the old one stops working immediately).

Delete confirmed spam (/action/delete-spam)​

The button in the digest opens a confirmation page: nothing is deleted until you confirm there, so mail security scanners that open links can't delete anything. The link:

  • only deletes the emails the AI labelled spam in that digest;
  • stops working once a newer digest with spam has been sent (use the newest one);
  • expires after 7 days.

Limits and safety​

  • Web actions only touch the emails listed in the last digest, never other messages in the mailbox.
  • Signed routes are rate-limited (30 requests per minute per IP) and every action is logged as JSON lines in /data/actions.log.
  • Pages are sent with Referrer-Policy: no-referrer and can't be framed, so links don't leak to other sites.

Dashboard​

Spam-Digest dashboard

http://<host>:8080/ shows the schedule, the configuration status (AI, email provider), the configured mailboxes and the last run of each (status, emails found, duration, digest sent, errors). It refreshes every 60 seconds. From here you can:

  • Run all: send a digest for every mailbox now (even if a spam folder is empty);
  • Run (per mailbox): send a digest for that mailbox now;
  • Filters (per mailbox, needs WEB_BASE_URL and email delivery): rotate the filters link and email it to the mailbox's digest recipient. Links are never shown on the dashboard.
No built-in authentication

The dashboard has no login. Keep port 8080 on a trusted network or behind a reverse proxy with authentication. Its actions only accept POST requests and reject requests coming from other websites, but anyone who can reach the port can use them.

CLI​

Inside the container (docker exec -it spam-digest python3 /app/spam_digest.py ...) or with the variables exported in your shell:

OptionDescription
--dry-runFetch and classify, build the digest and save it to /tmp/spam_digest_dry_run_<recipient>.html, without sending. Nothing changes: rules are evaluated but no email is deleted or moved, links are not rotated, the state file is not updated.
--force-sendSend the digest even when the spam folder is empty (overrides SEND_IF_EMPTY=false).
--only EMAILRun only for the mailbox with this address.

Outside Docker the script needs Python 3 and a writable /data directory. Scheduling is up to you (cron, systemd timer), and the web server is not started.

Rescuing an email by hand​

Without the web pages, open the mailbox in any IMAP client, go to the spam/junk folder and move the email to Inbox (or mark it Not spam). This also helps your mail server's spam filter learn.

Troubleshooting​

"Could not open any spam folder"​

The configured SPAM_FOLDER and all the common names failed. Check the folder's exact name in your mail client (Gmail: [Gmail]/Spam, some servers: INBOX.Junk) and set it explicitly. The folder actually used is shown in the dashboard and the digest.

SSL errors when connecting to IMAP​

The server doesn't offer SSL/TLS on that port. Use IMAP_USE_SSL=false (or "imap_use_ssl": false in MAILBOX_CONFIGS) together with the right port, usually 143.

The digest is not sent​

  • Check the container logs: docker logs spam-digest.
  • With an empty spam folder, no digest is sent unless SEND_IF_EMPTY=true (or you use Run on the dashboard).
  • SMTP: port 465 uses implicit SSL, any other port STARTTLS; check SMTP_HOST, SMTP_USER, SMTP_PASS.
  • Resend: DIGEST_FROM must be on a verified domain.

The digest has no Review / Delete / Filters buttons​

Set WEB_BASE_URL to the address where you reach the dashboard. Buttons only appear for sections that have emails, and Review / Delete need AI triage.

Links rotate with every digest. Use the most recent digest email, or rotate the filters link from the dashboard.

The signing secret and the link nonces live in /data. Without a volume they are recreated at every container update, which invalidates old links. Mount /data as a volume.

A run failed and the review list is empty​

When a mailbox can't be read (IMAP error), its review and delete lists are cleared on purpose: the previous email IDs can't be verified against the mailbox. The next successful run fills them again.