Simple Mail Cleaner
State-of-the-art mail cleanup tool with multi-tenant support, newsletter unsubscribe automation, and a modern web UI.
Stack
- Backend: Node.js + TypeScript + Fastify
- Frontend: React + Vite + TypeScript + i18n
- DB: PostgreSQL
- Queue: Redis + BullMQ worker
Node.js:
- Docker images use Node.js 24.13.0 (LTS)
Quick start
docker compose up --build
- Web UI:
http://localhost:${WEB_PORT}(see root.env) - API:
http://localhost:${API_PORT} - API Docs:
http://localhost:${API_PORT}/docs(only ifENABLE_SWAGGER=true)
API (initial)
POST /auth/register{ tenantName, email, password }POST /auth/login{ email, password }GET /tenants/me(auth)GET /mail/accounts(auth)POST /mail/accounts(auth)PUT /mail/accounts/:id(auth)DELETE /mail/accounts/:id(auth)POST /mail/cleanup(auth){ mailboxAccountId, dryRun, unsubscribeEnabled, routingEnabled }GET /jobs(auth)GET /jobs/:id/events(auth)GET /jobs/:id/stream-token(auth) -> short-lived SSE tokenGET /jobs/:id/stream?token=...(SSE using short-lived token)GET /rules(auth)POST /rules(auth)PUT /rules/:id(auth)DELETE /rules/:id(auth)GET /admin/tenants(admin)PUT /admin/tenants/:id(admin)GET /admin/users(admin)PUT /admin/users/:id(admin)PUT /admin/users/:id/role(admin)POST /admin/users/:id/reset(admin)GET /admin/accounts(admin)PUT /admin/accounts/:id(admin)GET /admin/jobs(admin)GET /admin/jobs/:id/events(admin)POST /admin/jobs/:id/cancel(admin)POST /admin/jobs/:id/retry(admin)DELETE /admin/jobs/:id(admin)POST /admin/impersonate/:userId(admin)GET /admin/tenants/:id/export(admin)GET /admin/tenants/:id/export?scope=users|accounts|jobs|rules&format=csv|zip(admin, zip returns jobId)GET /admin/exports(admin)GET /admin/exports/:id(admin)GET /admin/exports/:id/download(admin)POST /admin/exports/purge(admin)DELETE /admin/exports/:id(admin)GET /jobs/exports/:id/stream-token(admin) -> short-lived SSE tokenGET /jobs/exports/:id/stream?token=...(SSE using short-lived token)
Export queue:
- ZIP exports are queued via Redis/BullMQ and processed by the worker container.
DELETE /admin/tenants/:id(admin)
OAuth:
POST /oauth/gmail/url(auth)GET /oauth/gmail/callback(Google redirect)GET /oauth/gmail/status/:accountId(auth)GET /oauth/gmail/ping/:accountId(auth)
UI:
- Admin panel supports password reset, job cancel/retry, tenant export/delete, and impersonation.
Notes
- Newsletter detection will use
List-Unsubscribeheaders + heuristics. - Weblink unsubscribe uses HTTP first, mailto fallback (SMTP required).
- Worker scans headers and applies routing rules (MOVE/DELETE) when not in dry run.
Cleanup job behavior (what the button does)
When you click “Bereinigung starten / Start cleanup” a cleanup job is created and queued. The worker connects to the selected mailbox and:
- Opens the INBOX (or first mailbox matching “inbox”).
- Fetches recent message headers (subject/from/headers).
- Detects newsletter candidates (List‑Unsubscribe, List‑Id, heuristics).
- Applies your routing rules (MOVE/ARCHIVE/LABEL/DELETE) if enabled.
- Attempts to unsubscribe using
List‑Unsubscribe(HTTP one‑click or mailto). - Logs all actions and progress as job events (visible in the UI).
The three checkboxes explained
Dry run (keine Änderungen)
Runs the full scan and logs what would happen, but does not move/delete/unsubscribe any mail and does not send unsubscribe emails. Useful for testing rules safely.
Unsubscribe aktiv
Enables List‑Unsubscribe handling.
- HTTP links are called (one‑click POST when supported).
- Mailto links are sent via SMTP (requires SMTP host + app password).
Routing aktiv
Applies your configured rules (conditions → actions).
- MOVE/ARCHIVE/LABEL/DELETE will be executed when not in dry run.
- If disabled, no rule actions are executed (only detection + optional unsubscribe).
Seed data
cd backend
DATABASE_URL=postgresql://mailcleaner:mailcleaner@localhost:5432/mailcleaner \\
SEED_ADMIN_EMAIL=admin@simplemailcleaner.local \\
SEED_ADMIN_PASSWORD=change-me-now \\
SEED_TENANT=Default Tenant \\
SEED_TENANT_ID=seed-tenant \\
npm run prisma:seed
- DSGVO: tenant isolation supported; sensitive secrets are encrypted at rest when
ENCRYPTION_KEYis set.
Prisma 7 config
Prisma 7 moved datasource URLs out of the schema into backend/prisma.config.ts.
DATABASE_URLmust be set when running Prisma CLI commands (generate/migrate).- The config loads the repo root
.envautomatically when run frombackend/.
Admin password reset (CLI)
Reset an admin password via CLI:
docker compose exec api npm run admin:reset -- admin@simplemailcleaner.local NEW_PASSWORD
Generate a temporary password (forces change on next login):
docker compose exec api npm run admin:reset -- admin@simplemailcleaner.local
Security hardening (public hosting)
The app includes a security hardening pass for public deployments. Highlights:
- No public DB/Redis ports by default (only API/Web are bound, DB/Redis are internal to Docker).
- CORS locked down via
CORS_ORIGINS. - Rate limiting globally and stricter on auth endpoints.
- Short‑lived SSE tokens instead of using the user JWT in URLs.
- OAuth state signed to prevent token injection.
- SSRF protections for List‑Unsubscribe HTTP and custom mail hosts.
- Secrets encrypted at rest (OAuth tokens, app passwords, Google client secret).
- Swagger disabled by default in production.
- Production env validation rejects default secrets and missing encryption key.
Findings and fixes (audit log)
- Open DB/Redis ports → removed public port bindings in
docker-compose.yml. - Default secrets in production → config validation blocks default JWT/seed secrets in
NODE_ENV=production. - Tokens/app passwords stored in plain text → encrypted at rest with
ENCRYPTION_KEY. - SSRF via unsubscribe URLs / custom hosts → private network block + scheme validation + timeouts.
- OAuth state not verifiable → state is now a signed, expiring JWT.
- JWT in SSE URL → replaced with short‑lived stream tokens.
- CORS allow‑all → restricted by
CORS_ORIGINS. - Swagger exposed → disabled by default in production.
- No rate limiting → global and auth‑specific rate limits added.
Required production settings
Set these in .env before going public:
NODE_ENV=productionJWT_SECRET=<strong secret>ENCRYPTION_KEY=<min 32 chars>CORS_ORIGINS=https://your-domain.tldTRUST_PROXY=true(when behind nginx)ENABLE_SWAGGER=falseSEED_ENABLED=false(after initial setup)
Optional hardening
ALLOW_CUSTOM_MAIL_HOSTS=false(default) to force provider defaultsBLOCK_PRIVATE_NETWORKS=true(default) to block private IPs in unsubscribe URLs
Environment
All config lives in the repo root .env (see .env.example).
Export settings:
EXPORT_DIR(default/tmp/mailcleaner-exports)EXPORT_TTL_HOURS(default24)
Cleanup settings:
CLEANUP_SCAN_LIMIT(default0= no limit). Set to a number to cap how many recent emails are scanned per run.
Proxy settings (Nginx Proxy Manager):
TRUST_PROXY=trueVITE_API_URL=https://your-domain.tldGOOGLE_REDIRECT_URI=https://your-domain.tld/oauth/gmail/callbackCORS_ORIGINS=https://your-domain.tld
Local ports (override via .env in repo root):
BIND_IP(default127.0.0.1)API_PORT(default8000, now set to8201in.env)WEB_PORT(default3000, now set to3201in.env)
Reverse proxy notes (Nginx)
- Terminate TLS at nginx.
- Only expose nginx (80/443) publicly.
- Keep API/Web bound to
127.0.0.1(or internal Docker network). - Set
TRUST_PROXY=trueso the app honorsX-Forwarded-*headers.
Nginx Proxy Manager (NPM) setup
Minimal steps to run behind Nginx Proxy Manager with limited nginx customization.
1) Bind services locally
In .env:
BIND_IP=127.0.0.1
API_PORT=8201
WEB_PORT=3201
2) Put NPM and Mailcleaner in the same Docker network
If NPM runs in Docker, attach both stacks to a shared network (example: proxy).
Create network once:
docker network create proxy
Add to docker-compose.yml:
networks:
proxy:
external: true
Then attach services:
services:
api:
networks: [proxy]
web:
networks: [proxy]
3) Create proxy hosts in NPM
Create two Proxy Hosts:
Frontend
- Domain:
app.your-domain.tld - Scheme:
http - Forward Hostname/IP:
mailcleaner-web - Forward Port:
3000 - Websockets: ON
- Block Common Exploits: ON
- SSL: Let’s Encrypt, Force SSL
API
- Domain:
api.your-domain.tld - Scheme:
http - Forward Hostname/IP:
mailcleaner-api - Forward Port:
8201 - Websockets: ON
- Block Common Exploits: ON
- SSL: Let’s Encrypt, Force SSL
4) Environment for public hosting
Set in .env:
NODE_ENV=production
TRUST_PROXY=true
CORS_ORIGINS=https://app.your-domain.tld
VITE_API_URL=https://api.your-domain.tld
GOOGLE_REDIRECT_URI=https://api.your-domain.tld/oauth/gmail/callback
ENABLE_SWAGGER=false
JWT_SECRET=<strong secret>
ENCRYPTION_KEY=<min 32 chars>
SEED_ENABLED=false