Files
multi-seal-mail/README.md
2026-06-08 15:57:11 +02:00

925 lines
22 KiB
Markdown

# MultiMailer skeleton
Local-first bulk mailer backend skeleton:
- FastAPI API
- Celery worker(s)
- Redis queue
- Postgres database
- Garage S3-compatible object storage
- Optional local Traefik
- Modular Compose layout so you can reuse existing infrastructure
## Quick start: full local development stack
```bash
cp .env.example .env
./scripts/up-local.sh
```
Equivalent explicit command:
```bash
docker compose -f compose.yml -f compose.local.yml up --build
```
Health check:
```bash
curl http://localhost:8080/health
# or direct API port:
curl http://localhost:8000/health
```
Garage S3 local endpoint:
```text
http://localhost:3900
```
## App-only mode with existing Postgres, Redis, Garage, Traefik
1. Copy `.env.example` to `.env`
2. Point these values to your existing services:
```dotenv
DATABASE_URL=postgresql+psycopg://user:password@existing-postgres:5432/multimailer
REDIS_URL=redis://existing-redis:6379/0
S3_ENDPOINT_URL=https://garage.example.org
S3_REGION=garage
S3_BUCKET=attachments
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
```
3. Start app services only:
```bash
docker compose -f compose.yml up --build
```
Or use the example external Traefik override:
```bash
docker compose -f compose.yml -f compose.external.example.yml up --build
```
## Scale within one worker container
For small PDFs zipped per recipient, start with long-lived Celery worker processes:
```dotenv
CELERY_CONCURRENCY=4
CELERY_PREFETCH_MULTIPLIER=1
CELERY_MAX_TASKS_PER_CHILD=200
```
## Scale with more containers
```bash
docker compose -f compose.yml -f compose.local.yml up --build --scale worker=3 --scale api=2
```
## Notes
- Set `MASTER_KEY_B64` before storing real SMTP/IMAP credentials.
- Garage 2.3+ can create the local default bucket and key from environment variables when started with `--single-node --default-bucket`.
- The local Garage config uses `replication_factor = 1`; this is fine for development, not a production storage strategy.
## Campaign JSON
The project now includes a first schema and example campaign file:
```text
server/app/mailer/schemas/campaign.schema.json
server/app/mailer/examples/campaign.json
```
The schema preserves the Java object model concepts: global recipients, individual recipients, combine flags, campaign-level `attachments.base_path`, global and entry-specific attachment configs, validation behavior for missing/ambiguous attachments, rate limits, and optional IMAP append-to-Sent handling.
## Attachment resolver dry run
The attachment resolver loads the campaign JSON, normalizes inline or external entries, renders `${global::...}` and `${local::...}` placeholders in `base_dir` and `file_filter`, and reports missing or ambiguous attachment matches without sending mail.
```bash
cd server
python -m app.mailer.commands.resolve_attachments \
--campaign app/mailer/examples/campaign.json \
--verbose
```
Machine-readable output for the future web UI:
```bash
python -m app.mailer.commands.resolve_attachments \
--campaign app/mailer/examples/campaign.json \
--json
```
## Build message drafts without sending
After validating the campaign and resolving attachments, build reviewable message drafts:
```bash
cd server
python -m app.mailer.commands.build_messages \
--campaign app/mailer/examples/campaign.json \
--output-dir ./build/messages \
--verbose
```
This writes `.eml` files for review and prints per-entry status:
- `ready`: queueable
- `warning`: queueable, but with non-blocking issues
- `needs_review`: user decision required before queueing
- `blocked`: must not be queued
- `excluded`: excluded by policy, but still reported
- `inactive`: inactive entry
Machine-readable report:
```bash
python -m app.mailer.commands.build_messages \
--campaign app/mailer/examples/campaign.json \
--json
```
## SMTP test send
After building messages, you can send one generated message to a test recipient without changing campaign/job status:
```bash
cd server
python -m app.mailer.commands.send_test_message \
--campaign app/mailer/examples/campaign.json \
--to your.test.address@example.org \
--dry-run \
--write-eml ./build/test-message.eml
```
When ready to actually connect to SMTP, remove `--dry-run`. SMTP settings are read from `campaign.json`, but can be overridden without editing the file:
```bash
MULTIMAILER_SMTP_PASSWORD='...' \
python -m app.mailer.commands.send_test_message \
--campaign app/mailer/examples/campaign.json \
--to your.test.address@example.org \
--smtp-host smtp.example.org \
--smtp-port 587 \
--smtp-security starttls \
--smtp-username user@example.org
```
Supported override environment variables:
```text
MULTIMAILER_SMTP_HOST
MULTIMAILER_SMTP_PORT
MULTIMAILER_SMTP_SECURITY # plain | tls | starttls
MULTIMAILER_SMTP_USERNAME
MULTIMAILER_SMTP_PASSWORD
```
The test-send command always replaces the real To/Cc/Bcc recipients with the test recipient before sending.
## Persistence/API-key milestone
This version adds the first persistent backend layer:
- SQLAlchemy models for tenants, users, roles, API keys, campaigns, campaign versions, jobs, issues, attachments, send attempts, IMAP append attempts and audit log entries.
- Alembic setup under `server/alembic`.
- Development database bootstrap command.
- API-key authentication for campaign API endpoints.
- Campaign JSON import into versioned DB snapshots.
- Campaign validation/build results persisted as campaign jobs.
### Local DB quick start without Docker
From `server/`:
```bash
python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
# SQLite is the default when DATABASE_URL is not set.
python -m app.mailer.commands.init_db --with-dev-data
python -m app.mailer.commands.import_campaign \
--campaign app/mailer/examples/campaign.json \
--api-key dev-multimailer-api-key \
--validate \
--build
python -m app.mailer.commands.list_db_campaigns \
--api-key dev-multimailer-api-key
```
### API quick smoke test
Start the API:
```bash
uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
```
The dev bootstrap runs automatically in `APP_ENV=dev` and creates this API key unless disabled:
```text
dev-multimailer-api-key
```
List campaigns:
```bash
curl -H 'X-API-Key: dev-multimailer-api-key' \
http://127.0.0.1:8000/api/v1/campaigns
```
Create a campaign from JSON:
```bash
python - <<'PY'
import json, requests
config = json.load(open('app/mailer/examples/campaign.json'))
response = requests.post(
'http://127.0.0.1:8000/api/v1/campaigns',
headers={'X-API-Key': 'dev-multimailer-api-key'},
json={'config': config, 'source_filename': 'app/mailer/examples/campaign.json'},
)
print(response.status_code)
print(response.text)
PY
```
### API endpoints added
```text
GET /api/v1/campaigns
POST /api/v1/campaigns
GET /api/v1/campaigns/{campaign_id}
GET /api/v1/campaigns/{campaign_id}/versions
POST /api/v1/campaigns/versions/{version_id}/validate
POST /api/v1/campaigns/versions/{version_id}/build
GET /api/v1/campaigns/{campaign_id}/jobs
POST /api/v1/admin/api-keys
```
All campaign/admin endpoints require an API key via either:
```text
X-API-Key: ...
Authorization: Bearer ...
```
### Note about local file paths
When importing a file-based campaign JSON, pass `source_filename` or use the CLI importer. Relative paths inside the campaign are normalized to absolute paths at import time so the DB snapshot can still find CSV files, templates and attachment folders later.
## Queueing and sending step
This version adds the first real bulk-delivery transition:
```text
built campaign jobs -> queued jobs -> SMTP worker/direct sender -> send attempts
```
### Queue jobs without starting Celery
```bash
cd server
python -m app.mailer.commands.queue_campaign \
--campaign-id <campaign-db-uuid> \
--api-key dev-multimailer-api-key \
--no-celery
```
Use `--dry-run` to see what would be queued without modifying the database.
### Process queued jobs directly from CLI
This is useful before the web UI and before running a Celery worker:
```bash
python -m app.mailer.commands.send_queued_jobs \
--campaign-id <campaign-db-uuid> \
--api-key dev-multimailer-api-key \
--dry-run
```
Remove `--dry-run` only after SMTP credentials in the campaign JSON point to a real server.
### Queue jobs and send through Celery
```bash
python -m app.mailer.commands.queue_campaign \
--campaign-id <campaign-db-uuid> \
--api-key dev-multimailer-api-key
celery -A app.celery_app.celery worker \
--loglevel=INFO \
--queues=send_email,append_sent,default \
--concurrency=1 \
--prefetch-multiplier=1
```
### API endpoints added
```text
POST /api/v1/campaigns/{campaign_id}/queue
POST /api/v1/campaigns/{campaign_id}/pause
POST /api/v1/campaigns/{campaign_id}/resume
POST /api/v1/campaigns/{campaign_id}/cancel
```
Queueing still respects the review gate: only `ready` and, by default, `warning` jobs are queued. `needs_review`, `blocked`, `excluded`, and `inactive` jobs are not queued.
### Rate limiting
SMTP sending now applies campaign-level rate limiting from the campaign JSON:
```json
"delivery": {
"rate_limit": {
"messages_per_minute": 5,
"concurrency": 1
}
}
```
The worker uses Redis for distributed throttling when available. If Redis is unavailable in local development, the sender falls back gracefully, but production should use Redis.
### IMAP status
SMTP success now keeps IMAP state separate:
```text
send_status = sent
imap_status = pending | not_requested
```
Actual IMAP APPEND is still the next implementation step.
## IMAP append-to-Sent
SMTP sending and IMAP Sent-folder append are intentionally separate states. A job can be `send_status=sent` while `imap_status=failed`; the email was still sent, but saving the copy to Sent needs attention.
When Celery is used, successful SMTP delivery automatically enqueues an `append_sent` task if the campaign has:
```json
"delivery": {
"imap_append_sent": { "enabled": true, "folder": "auto" }
}
```
and `server.imap.enabled=true`.
Direct CLI sending can append immediately in the same process:
```bash
python -m app.mailer.commands.send_queued_jobs \
--campaign-id <campaign-db-uuid> \
--api-key dev-multimailer-api-key \
--append-sent
```
Or process pending/failed appends separately:
```bash
python -m app.mailer.commands.append_pending_sent \
--campaign-id <campaign-db-uuid> \
--api-key dev-multimailer-api-key
```
Dry-run IMAP append check:
```bash
python -m app.mailer.commands.append_pending_sent \
--campaign-id <campaign-db-uuid> \
--api-key dev-multimailer-api-key \
--dry-run
```
API endpoint:
```text
POST /api/v1/campaigns/{campaign_id}/append-sent
```
with body:
```json
{
"enqueue_celery": true,
"dry_run": false
}
```
Folder resolution order:
1. `delivery.imap_append_sent.folder` if not `auto`
2. `server.imap.sent_folder` if not `auto`
3. IMAP `LIST` discovery using `\\Sent` special-use flags
4. common fallback names such as `Sent`, `Sent Items`, `Gesendet`
## Campaign reporting / dashboard data
This version adds dashboard/report payloads that are intended to feed the first web interface.
They summarize persisted jobs, validation issues, attachment resolution, sending attempts, IMAP append state, and rate-limit estimates.
CLI report:
```bash
cd server
python -m app.mailer.commands.campaign_report \
--campaign-id <campaign-db-uuid> \
--api-key dev-multimailer-api-key
```
Machine-readable report:
```bash
python -m app.mailer.commands.campaign_report \
--campaign-id <campaign-db-uuid> \
--api-key dev-multimailer-api-key \
--json \
--include-jobs
```
Per-job CSV export:
```bash
python -m app.mailer.commands.campaign_report \
--campaign-id <campaign-db-uuid> \
--api-key dev-multimailer-api-key \
--jobs-csv ./build/campaign-jobs.csv
```
API endpoints:
```text
GET /api/v1/campaigns/{campaign_id}/summary
GET /api/v1/campaigns/{campaign_id}/report
GET /api/v1/campaigns/{campaign_id}/report/jobs.csv
```
These endpoints are API-key protected with the `campaign:read` scope and return data shaped for a web dashboard: top-level cards, status counters, attachment summary, issue summary, send/IMAP attempts, recent failures, and optional per-job rows.
## Audit log and report emailing
This version adds a tenant-scoped audit log and a first report-emailing flow.
Audit log entries are written for API key creation, campaign import/create, validation, message building, queue actions, IMAP append enqueueing and report email sending/dry runs.
List audit entries from the CLI:
```bash
cd server
python -m app.mailer.commands.audit_log \
--api-key dev-multimailer-api-key \
--limit 50
```
Machine-readable audit output:
```bash
python -m app.mailer.commands.audit_log \
--api-key dev-multimailer-api-key \
--json
```
API endpoint:
```text
GET /api/v1/audit
```
Optional filters:
```text
?action=campaign.queued&object_type=campaign&object_id=<campaign-db-uuid>&limit=100&offset=0
```
Email a campaign report:
```bash
python -m app.mailer.commands.email_campaign_report \
--campaign-id <campaign-db-uuid> \
--api-key dev-multimailer-api-key \
--to recipient@example.org \
--dry-run
```
Actual sending uses the campaign's `server.smtp` configuration and the configured campaign sender. Remove `--dry-run` only once SMTP is configured.
Attach the full JSON report as well as the default per-job CSV:
```bash
python -m app.mailer.commands.email_campaign_report \
--campaign-id <campaign-db-uuid> \
--api-key dev-multimailer-api-key \
--to recipient@example.org \
--attach-report-json
```
API endpoint:
```text
POST /api/v1/campaigns/{campaign_id}/report/email
```
Body:
```json
{
"to": ["recipient@example.org"],
"include_jobs": false,
"attach_jobs_csv": true,
"attach_report_json": false,
"dry_run": true
}
```
Required API scopes:
```text
audit:read
reports:send
```
## Supported CLI commands
All commands are run from `server/`.
### Database and API-key setup
```bash
python -m app.mailer.commands.init_db --with-dev-data
```
Creates local tables and development data, including the default development API key if enabled.
### Campaign JSON validation
```bash
python -m app.mailer.commands.validate_campaign \
--campaign app/mailer/examples/campaign.json
```
Use `--check-files` to verify referenced CSV/template/attachment paths.
### Attachment resolution dry run
```bash
python -m app.mailer.commands.resolve_attachments \
--campaign app/mailer/examples/campaign.json \
--verbose
```
Use `--json` for machine-readable output.
### Build message drafts from a campaign JSON file
```bash
python -m app.mailer.commands.build_messages \
--campaign app/mailer/examples/campaign.json \
--output-dir ./build/messages \
--verbose
```
Builds reviewable `.eml` files without sending. Use `--json` for machine-readable output.
### SMTP test-send one generated message
```bash
python -m app.mailer.commands.send_test_message \
--campaign app/mailer/examples/campaign.json \
--to your.test.address@example.org \
--dry-run \
--write-eml ./build/test-message.eml
```
Supports SMTP overrides via CLI flags or these environment variables:
```text
MULTIMAILER_SMTP_HOST
MULTIMAILER_SMTP_PORT
MULTIMAILER_SMTP_SECURITY
MULTIMAILER_SMTP_USERNAME
MULTIMAILER_SMTP_PASSWORD
```
### Import campaign JSON into the database
```bash
python -m app.mailer.commands.import_campaign \
--campaign app/mailer/examples/campaign.json \
--api-key dev-multimailer-api-key \
--validate \
--build
```
Creates a versioned DB campaign snapshot, optionally validates and builds DB-backed jobs.
### List database campaigns
```bash
python -m app.mailer.commands.list_db_campaigns \
--api-key dev-multimailer-api-key
```
### Queue built campaign jobs
```bash
python -m app.mailer.commands.queue_campaign \
--campaign-id <campaign-db-uuid> \
--api-key dev-multimailer-api-key \
--no-celery
```
Useful flags:
```text
--dry-run
--exclude-warnings
--version-id <campaign-version-uuid>
--json
```
### Process queued jobs directly without Celery
```bash
python -m app.mailer.commands.send_queued_jobs \
--campaign-id <campaign-db-uuid> \
--api-key dev-multimailer-api-key \
--dry-run
```
Useful flags:
```text
--limit <n>
--no-rate-limit
--append-sent
--json
```
### Append already-sent messages to IMAP Sent
```bash
python -m app.mailer.commands.append_pending_sent \
--campaign-id <campaign-db-uuid> \
--api-key dev-multimailer-api-key \
--dry-run
```
Useful flags:
```text
--limit <n>
--include-failed
--json
```
### Generate campaign report
```bash
python -m app.mailer.commands.campaign_report \
--campaign-id <campaign-db-uuid> \
--api-key dev-multimailer-api-key
```
Useful flags:
```text
--json
--include-jobs
--jobs-csv ./build/campaign-jobs.csv
```
### Email campaign report
```bash
python -m app.mailer.commands.email_campaign_report \
--campaign-id <campaign-db-uuid> \
--api-key dev-multimailer-api-key \
--to recipient@example.org \
--dry-run
```
Useful flags:
```text
--to <email> # repeat for multiple recipients
--include-jobs
--no-jobs-csv
--attach-report-json
--json
```
### List audit log
```bash
python -m app.mailer.commands.audit_log \
--api-key dev-multimailer-api-key
```
Useful filters:
```text
--action campaign.queued
--object-type campaign
--object-id <campaign-db-uuid>
--limit 100
--offset 0
--json
```
## Web UI MVP
The project now includes a first React/Vite WebUI in `webui/`. It is intentionally API-first: the browser talks to the same FastAPI endpoints that the CLI uses.
### Run API locally and WebUI in VSCodium/dev mode
Terminal 1, from `server/`:
```bash
python -m uvicorn app.main:app --reload --host 127.0.0.1 --port 8000
```
Terminal 2, from `webui/`:
```bash
npm install
npm run dev
```
Open:
```text
http://127.0.0.1:5173
```
Use the development API key:
```text
dev-multimailer-api-key
```
The Vite dev server proxies `/api` to `http://127.0.0.1:8000` by default. Override this with:
```bash
VITE_DEV_API_PROXY_TARGET=http://127.0.0.1:8000 npm run dev
```
### Run full local stack with Docker Compose
```bash
cp .env.example .env
./scripts/up-local.sh
```
Open:
```text
http://localhost:8080
```
Traefik routes:
```text
/api/* and /health -> FastAPI
/* -> WebUI
```
Direct ports are also published by default:
```text
API: http://localhost:8000
WebUI: http://localhost:5173
```
### WebUI features included in this MVP
- API-key based connection screen
- Campaign list and selection
- Campaign JSON import from file or textarea
- Version listing
- Campaign validation
- Message building
- Queue dry-run and queue action
- Pause/resume/cancel actions
- Report dashboard cards
- Job table with build/validation/queue/send/IMAP states
- Jobs CSV download
- IMAP append dry-run/action
- Campaign report email dry-run/action
- Audit log view
- Local WebUI settings
### Important security note
For this MVP, the WebUI stores the API base URL and API key in browser `localStorage`. This is acceptable for local development, but production should move to proper login/session handling and short-lived browser tokens. External tools and desktop clients can continue using explicit scoped API keys.
## WebUI source layout
```text
webui/
├─ Dockerfile
├─ nginx.conf
├─ package.json
├─ vite.config.ts
├─ index.html
└─ src/
├─ api.ts
├─ main.tsx
├─ styles.css
└─ types.ts
```
## WebUI next steps
Recommended next UI iterations:
1. Add a dedicated campaign configuration editor instead of JSON-only import.
2. Add a review screen for `needs_review`, `blocked`, missing attachments and ambiguous attachments.
3. Add mail-account administration with encrypted credential storage.
4. Add users/groups/RBAC/API-key administration.
5. Add attachment upload and Garage browser.
6. Add housekeeping/retention/quota screens.
7. Add backup/export screens.
8. Add scheduled campaign runs.
## Editable campaign versions / WebUI editor API
Campaigns now use normal `Campaign` + `CampaignVersion` records for draft/edit/autosave workflows. There is no separate `CampaignDraft` table: an editable campaign is simply a campaign version with `workflow_state=editing` and editor metadata.
New version/editor fields on `campaign_versions`:
```text
source_base_path
workflow_state # editing, under_review, approved, built, queued, sending, completed, cancelled, archived
current_flow # create, review, send, manual, json
current_step # basics, sender, fields, recipients, template, attachments, review, send, json, ...
is_complete
editor_state
autosaved_at
published_at
locked_at
locked_by_user_id
```
New API endpoints:
```text
GET /api/v1/schemas/campaign
POST /api/v1/campaigns/new
GET /api/v1/campaigns/{campaign_id}/versions/{version_id}
PUT /api/v1/campaigns/{campaign_id}/versions/{version_id}
POST /api/v1/campaigns/{campaign_id}/versions/{version_id}/autosave
POST /api/v1/campaigns/{campaign_id}/versions/{version_id}/set-step
POST /api/v1/campaigns/{campaign_id}/versions/{version_id}/validate-partial
POST /api/v1/campaigns/{campaign_id}/versions/{version_id}/publish
```
Minimal campaign creation example:
```bash
curl -X POST 'http://127.0.0.1:8000/api/v1/campaigns/new' \
-H 'Content-Type: application/json' \
-H 'X-API-Key: dev-multimailer-api-key' \
-d '{
"external_id": "my-new-campaign",
"name": "My New Campaign",
"description": "Created from the WebUI wizard",
"current_flow": "create",
"current_step": "basics"
}'
```
Autosave example:
```bash
curl -X POST 'http://127.0.0.1:8000/api/v1/campaigns/<campaign-id>/versions/<version-id>/autosave' \
-H 'Content-Type: application/json' \
-H 'X-API-Key: dev-multimailer-api-key' \
-d '{
"campaign_json": {"version": "1.0", "campaign": {"id": "my-new-campaign", "name": "My New Campaign", "mode": "draft"}},
"current_flow": "create",
"current_step": "recipients",
"is_complete": false
}'
```
Partial validation example:
```bash
curl -X POST 'http://127.0.0.1:8000/api/v1/campaigns/<campaign-id>/versions/<version-id>/validate-partial' \
-H 'Content-Type: application/json' \
-H 'X-API-Key: dev-multimailer-api-key' \
-d '{"section": "recipients"}'
```
Strict validation/build/send endpoints are unchanged. The WebUI should use partial validation while editing, and only call strict validation/build when the user reaches Review/Send.