A transparent, rules-driven donor communication engine built to replace opaque Salesforce flows. Upload a CSV of donor events, watch every message get matched to a rule and previewed before delivery, then commit — emails go out via SES, postal letters are dispatched via Pingen, and a Salesforce-importable send-log drops as a CSV.
Built in 24 hours at a hackathon by Team Mailroom.
Live demo: https://mailroom.nalam.chat/
War Child's donor communications were locked inside opaque Salesforce automation — hard to understand, harder to change, and impossible to audit. Mailroom replaces that with a system the team can actually own:
- Solves the real problem — donor communication logic is now visible, versioned, and editable by the War Child team without touching Salesforce configuration or involving a developer.
- Rules in plain English — describe a rule in natural language ("send a formal welcome letter to new donors giving more than €100 per month") and the LLM interprets it into a structured rule instantly. No JSON required.
- Template editor anyone can use — a clean in-browser editor lets non-technical staff write and update email and letter content, with live merge-field highlighting and a per-donor preview before anything goes out.
- See exactly what will be sent before you send it — every CSV upload shows a full dry-run: which rule matched each donor, which template they'll receive, and a condition-by-condition trace explaining why. No surprises.
- Low-friction Salesforce migration — input is a standard Salesforce CSV export, output is a Salesforce-importable send-log. No API credentials, no custom connectors — just the export file you already have.
- Rules engine — JSON-defined rules with AND/OR condition trees, evaluated in priority order (first match wins). Create rules through the UI or describe them in plain English and let the LLM interpret them.
- Template engine — Liquid templates with Dutch merge fields (
{{ contact.first_name }},{{ agreement.amount | money }}). Supports{{ ai: "Write a warm opener for {{ contact.first_name }}" }}variables resolved by Claude Haiku at send time. - Dry-run preview — Before any message goes out, every CSV row is matched, rendered, and shown in a table with per-row trace (which conditions passed/failed and why).
- Commit & send — Email via AWS SES, postal letters via Pingen (opt-in), send-log written to Postgres and downloadable as CSV.
- Idempotency — Re-uploading the same CSV skips already-processed rows; same run ID returns the original log.
| Layer | Choice |
|---|---|
| Backend | Python 3.12, FastAPI, SQLAlchemy 2 + asyncpg |
| Database | PostgreSQL (Supabase in prod, Docker locally) |
| Templating | python-liquid + custom money / date_nl filters |
| WeasyPrint | |
| AWS SES (SMTP fallback for local dev) | |
| Postal | Pingen API (opt-in, staging by default) |
| LLM | Anthropic SDK — Sonnet 4.6 (NL→rule), Haiku 4.5 (AI template vars) |
| Frontend | Vite + React + TypeScript |
| Deploy | AWS Lambda (ARM64) + API Gateway + S3/CloudFront via SAM |
- Docker + Docker Compose
- An Anthropic API key (optional — AI features degrade gracefully without it)
# 1. Clone and enter
git clone https://github.com/Femi236/Team-9-Hackathon
cd Team-9-Hackathon
# 2. Configure environment
cp .env.example .env
# Edit .env — at minimum set ANTHROPIC_API_KEY
# 3. Start API + Postgres
docker compose up --build
# API is now at http://localhost:8001
# Frontend dev server (separate):
cd frontend && npm install && npm run dev
# Frontend at http://localhost:5173# Load 8 Dutch templates + 4 sample rules (idempotent, safe to run multiple times)
curl -X POST http://localhost:8001/api/v1/seed# Upload the sample CSV (7 rows, all 4 event types)
curl -X POST http://localhost:8001/api/v1/runs?dry_run=true \
-F file=@tests/fixtures/sample.csv | python3 -m json.toolOr use the web UI: upload tests/fixtures/sample.csv, review the dry-run table, click Send all.
All endpoints are under /api/v1.
| Method | Path | Description |
|---|---|---|
POST |
/runs?dry_run=true |
Preview: parse CSV, match rules, return per-row trace |
POST |
/runs |
Commit: send emails/letters, write send-log |
GET |
/runs/{run_id} |
Run details + per-row log |
GET |
/runs/{run_id}/send-log.csv |
Download Salesforce-importable CSV |
GET |
/rules |
List rules (priority order) |
POST |
/rules |
Create rule |
PUT |
/rules/{id} |
Update rule |
DELETE |
/rules/{id} |
Delete rule |
POST |
/rules/parse-nl |
Natural language → rule JSON (Claude Sonnet) |
GET |
/templates |
List templates |
POST |
/templates |
Create template |
PUT |
/templates/{id} |
Update template |
GET |
/schema |
Full field schema (DonorContext paths) |
POST |
/seed |
Load demo templates + rules |
POST |
/gdpr/erase |
Anonymise all send-log records for a donor (GDPR Art. 17) |
Rules are stored as JSON with three sections: trigger, conditions, action.
{
"name": "High-value new agreement — formal postal",
"enabled": true,
"priority": 200,
"trigger": { "event_type": "agreement.new" },
"conditions": {
"type": "group",
"op": "AND",
"conditions": [
{ "type": "field_condition", "field": "agreement.amount_cents", "operator": "gte", "value": 10000 },
{ "type": "field_condition", "field": "contact.communication_style", "operator": "eq", "value": "formal" }
]
},
"action": { "channel": "postal", "template_id": "welcome_formal" }
}Supported operators: eq, neq, gt, gte, lt, lte, in, not_in, contains, starts_with, exists, not_exists
Event types: agreement.new, agreement.change, agreement.resignation, payment.confirmation
Mailroom exposes an MCP (Model Context Protocol) server at /mcp — attach Claude Desktop or Cursor and create rules and templates with natural language directly from your AI assistant.
See MCP_SETUP.md for connection instructions and available tools.
mailroom/
├── api/routers/ # FastAPI routers (rules, templates, runs, brand, auth, seed, webhooks)
├── engine/ # Rule evaluator, condition tree, operator registry
├── ingest/ # CSV reader + mapper (Salesforce columns → DonorContext)
├── actions/ # send_email, generate_pdf, registry
├── templates/ # Liquid renderer with AI variable resolution
├── models/ # SQLAlchemy ORM (Rule, Template, SendLog, BrandSettings)
└── tests/ # 87 test cases (evaluator, conditions, operators, mapper, idempotency)
frontend/src/
├── screens/ # RulesScreen, TemplatesScreen, RunScreen, BrandScreen
├── components/ # Shell, primitives (Button, Badge, Toggle, ChannelIcon…)
└── api.ts # Typed API client
See .env.example for the full list with documentation. Key vars:
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
Yes | PostgreSQL connection string (asyncpg) |
ANTHROPIC_API_KEY |
Recommended | Powers NL→rule and AI template vars; degrades gracefully without it |
EMAIL_BACKEND |
Yes | ses (prod) or smtp (local) |
SES_FROM_ADDRESS |
If SES | Verified SES sender address |
PINGEN_ENABLED |
No | Set true to enable physical postal dispatch |
pip install -e ".[dev]"
pytest mailroom/tests/ -v87 tests covering: rule evaluation, condition operators, CSV mapper, template rendering, idempotency.
Mailroom is designed to handle donor PII responsibly and comply with Dutch data protection law (AVG/GDPR).
Right to erasure (Art. 17 GDPR)
The GDPR screen in the dashboard accepts a Salesforce Donor ID and anonymises all send log records for that donor on request:
donor_idis replaced with a one-way SHA-256 hash (anon:{first 12 hex chars})agreement_id,payment_id, rule trace data, and error messages are nulled out- The audit row itself (run ID, event type, channel, template, status, timestamps) is retained — required for financial audit compliance under Dutch law (7-year retention obligation)
anonymised_atis recorded as a tamper-evident timestamp
API endpoint:
POST /api/v1/gdpr/erase
Content-Type: application/json
{ "donor_id": "003Wc00000B4e5fGH6" }Response:
{
"donor_id_requested": "003Wc00000B4e5fGH6",
"rows_anonymised": 4,
"message": "Successfully anonymised 4 record(s). Donor ID replaced with anon:7f3c9a1b2e8d."
}What PII Mailroom stores
Donor PII only flows into send_log during a run — it is never written to rules or templates. The fields stored per row are: donor_id, agreement_id, payment_id, rule trace (which may contain field values), and any error messages. All of these are erased by the erasure endpoint above.
Retention defaults
| Data | Retention | Reason |
|---|---|---|
| send_log audit row | 7 years | Dutch financial compliance |
| PII fields in send_log | Until erasure request | Erased on Art. 17 request |
| Templates / rules | Indefinite | No PII content |
Opt-in physical postal dispatch. When PINGEN_ENABLED=true, the postal branch renders the template to PDF (WeasyPrint) and uploads to Pingen. Staging endpoints are used by default — a misconfigured run cannot hit the production API.
On failure: a warning is logged and the row is still recorded as success (PDF is on disk).
Setup: sign up at app-staging.pingen.com, create an OAuth2 client, copy the 9 PINGEN_* vars from .env.example.