From d9ca48addc60bd4cd87cc2821c6ba8b7ef5a563d Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Mon, 8 Jun 2026 15:57:11 +0200 Subject: [PATCH] inital commit --- .env.example | 68 ++ .gitignore | 2 + JAVA_PORT_NOTES.md | 57 ++ README.md | 924 +++++++++++++++++- compose.external.example.yml | 42 + compose.local.yml | 115 +++ compose.yml | 78 ++ infra/garage/garage.toml | 18 + ...a_port_with_editable_campaign_versions.zip | Bin 0 -> 165410 bytes scripts/down-local.sh | 3 + scripts/generate-master-key.sh | 2 + scripts/up-local.sh | 3 + server/Dockerfile | 22 + server/alembic.ini | 38 + server/alembic/env.py | 45 + server/alembic/script.py.mako | 24 + ...1f8d4c2a0b7e_editable_campaign_versions.py | 58 ++ ...b57c5b216bce_initial_persistence_models.py | 346 +++++++ server/app/__init__.py | 0 server/app/api/__init__.py | 0 server/app/api/v1/__init__.py | 12 + server/app/api/v1/admin.py | 37 + server/app/api/v1/audit.py | 32 + server/app/api/v1/campaigns.py | 651 ++++++++++++ server/app/api/v1/schemas.py | 202 ++++ server/app/api/v1/system.py | 25 + server/app/audit/__init__.py | 1 + server/app/audit/logging.py | 91 ++ server/app/auth/__init__.py | 0 server/app/auth/dependencies.py | 52 + server/app/celery_app.py | 81 ++ server/app/core/__init__.py | 0 server/app/db/__init__.py | 0 server/app/db/base.py | 36 + server/app/db/bootstrap.py | 96 ++ server/app/db/models.py | 335 +++++++ server/app/db/session.py | 17 + server/app/mailer/__init__.py | 0 server/app/mailer/attachments/__init__.py | 0 server/app/mailer/attachments/resolver.py | 318 ++++++ server/app/mailer/campaign/__init__.py | 14 + server/app/mailer/campaign/entries.py | 215 ++++ server/app/mailer/campaign/loader.py | 79 ++ server/app/mailer/campaign/models.py | 363 +++++++ server/app/mailer/campaign/validation.py | 261 +++++ server/app/mailer/commands/__init__.py | 1 + .../mailer/commands/append_pending_sent.py | 69 ++ server/app/mailer/commands/audit_log.py | 65 ++ server/app/mailer/commands/build_messages.py | 99 ++ server/app/mailer/commands/campaign_report.py | 78 ++ .../mailer/commands/email_campaign_report.py | 74 ++ server/app/mailer/commands/import_campaign.py | 67 ++ server/app/mailer/commands/init_db.py | 32 + .../app/mailer/commands/list_db_campaigns.py | 29 + server/app/mailer/commands/queue_campaign.py | 54 + .../mailer/commands/resolve_attachments.py | 69 ++ .../app/mailer/commands/send_queued_jobs.py | 71 ++ .../app/mailer/commands/send_test_message.py | 226 +++++ .../app/mailer/commands/validate_campaign.py | 71 ++ server/app/mailer/domain/__init__.py | 0 server/app/mailer/domain/campaign.py | 210 ++++ server/app/mailer/domain/fields.py | 125 +++ server/app/mailer/domain/queue.py | 29 + server/app/mailer/domain/recipients.py | 43 + server/app/mailer/domain/template.py | 28 + server/app/mailer/examples/__init__.py | 0 server/app/mailer/examples/campaign.json | 180 ++++ .../attachments/xls/ab0000-123456-demo.XLSX | 1 + .../app/mailer/examples/data/recipients.csv | 2 + .../examples/rechnungslegung_2026_05.py | 73 ++ server/app/mailer/messages/__init__.py | 12 + server/app/mailer/messages/builder.py | 547 +++++++++++ server/app/mailer/messages/models.py | 139 +++ server/app/mailer/persistence/__init__.py | 0 server/app/mailer/persistence/campaigns.py | 309 ++++++ server/app/mailer/persistence/versions.py | 346 +++++++ server/app/mailer/reports/__init__.py | 1 + server/app/mailer/reports/campaigns.py | 351 +++++++ server/app/mailer/reports/emailing.py | 210 ++++ server/app/mailer/schema/campaign.schema.json | 804 +++++++++++++++ server/app/mailer/sending/__init__.py | 1 + server/app/mailer/sending/imap.py | 195 ++++ server/app/mailer/sending/jobs.py | 606 ++++++++++++ server/app/mailer/sending/rate_limit.py | 57 ++ server/app/mailer/sending/smtp.py | 157 +++ server/app/mailer/services/__init__.py | 0 .../mailer/services/attachment_matching.py | 13 + .../app/mailer/services/campaign_executor.py | 135 +++ server/app/mailer/services/zip_service.py | 25 + server/app/main.py | 47 + server/app/security/__init__.py | 0 server/app/security/api_keys.py | 80 ++ server/app/settings.py | 33 + server/entrypoint.sh | 21 + server/multimailer-dev.db | Bin 0 -> 356352 bytes server/requirements.txt | 20 + .../05721f39-b5f4-4f8c-9c6f-5f44b06b9459.json | 180 ++++ .../06246903-c0ef-492f-9170-755e8e922bcf.json | 180 ++++ .../29079dd3-e93d-487b-a58b-73329d3c05de.json | 180 ++++ .../2becd95b-6657-4979-a8cd-0333d0231788.json | 180 ++++ .../3253bd83-b934-4e5e-ab82-fb73590f6e93.json | 180 ++++ .../_incoming_campaign.json | 180 ++++ .../cd95fa4a-4635-496b-808d-c385e70963a8.json | 180 ++++ .../dbc9cd27-54c3-43b1-b152-d9560d27eb79.json | 180 ++++ .../_zip/Rechnungslegung_ab0000.zip | Bin 0 -> 244 bytes .../entry-001.eml | 33 + .../_zip/Rechnungslegung_ab0000.zip | Bin 0 -> 244 bytes .../entry-001.eml | 33 + .../_zip/Rechnungslegung_ab0000.zip | Bin 0 -> 244 bytes .../entry-001.eml | 33 + .../_zip/Rechnungslegung_ab0000.zip | Bin 0 -> 244 bytes .../entry-001.eml | 33 + .../_zip/Rechnungslegung_ab0000.zip | Bin 0 -> 244 bytes .../entry-001.eml | 33 + 114 files changed, 12172 insertions(+), 1 deletion(-) create mode 100644 .env.example create mode 100644 JAVA_PORT_NOTES.md create mode 100644 compose.external.example.yml create mode 100644 compose.local.yml create mode 100644 compose.yml create mode 100644 infra/garage/garage.toml create mode 100644 multimailer_python_java_port_with_editable_campaign_versions.zip create mode 100644 scripts/down-local.sh create mode 100644 scripts/generate-master-key.sh create mode 100644 scripts/up-local.sh create mode 100644 server/Dockerfile create mode 100644 server/alembic.ini create mode 100644 server/alembic/env.py create mode 100644 server/alembic/script.py.mako create mode 100644 server/alembic/versions/1f8d4c2a0b7e_editable_campaign_versions.py create mode 100644 server/alembic/versions/b57c5b216bce_initial_persistence_models.py create mode 100644 server/app/__init__.py create mode 100644 server/app/api/__init__.py create mode 100644 server/app/api/v1/__init__.py create mode 100644 server/app/api/v1/admin.py create mode 100644 server/app/api/v1/audit.py create mode 100644 server/app/api/v1/campaigns.py create mode 100644 server/app/api/v1/schemas.py create mode 100644 server/app/api/v1/system.py create mode 100644 server/app/audit/__init__.py create mode 100644 server/app/audit/logging.py create mode 100644 server/app/auth/__init__.py create mode 100644 server/app/auth/dependencies.py create mode 100644 server/app/celery_app.py create mode 100644 server/app/core/__init__.py create mode 100644 server/app/db/__init__.py create mode 100644 server/app/db/base.py create mode 100644 server/app/db/bootstrap.py create mode 100644 server/app/db/models.py create mode 100644 server/app/db/session.py create mode 100644 server/app/mailer/__init__.py create mode 100644 server/app/mailer/attachments/__init__.py create mode 100644 server/app/mailer/attachments/resolver.py create mode 100644 server/app/mailer/campaign/__init__.py create mode 100644 server/app/mailer/campaign/entries.py create mode 100644 server/app/mailer/campaign/loader.py create mode 100644 server/app/mailer/campaign/models.py create mode 100644 server/app/mailer/campaign/validation.py create mode 100644 server/app/mailer/commands/__init__.py create mode 100644 server/app/mailer/commands/append_pending_sent.py create mode 100644 server/app/mailer/commands/audit_log.py create mode 100644 server/app/mailer/commands/build_messages.py create mode 100644 server/app/mailer/commands/campaign_report.py create mode 100644 server/app/mailer/commands/email_campaign_report.py create mode 100644 server/app/mailer/commands/import_campaign.py create mode 100644 server/app/mailer/commands/init_db.py create mode 100644 server/app/mailer/commands/list_db_campaigns.py create mode 100644 server/app/mailer/commands/queue_campaign.py create mode 100644 server/app/mailer/commands/resolve_attachments.py create mode 100644 server/app/mailer/commands/send_queued_jobs.py create mode 100644 server/app/mailer/commands/send_test_message.py create mode 100644 server/app/mailer/commands/validate_campaign.py create mode 100644 server/app/mailer/domain/__init__.py create mode 100644 server/app/mailer/domain/campaign.py create mode 100644 server/app/mailer/domain/fields.py create mode 100644 server/app/mailer/domain/queue.py create mode 100644 server/app/mailer/domain/recipients.py create mode 100644 server/app/mailer/domain/template.py create mode 100644 server/app/mailer/examples/__init__.py create mode 100644 server/app/mailer/examples/campaign.json create mode 100644 server/app/mailer/examples/data/attachments/xls/ab0000-123456-demo.XLSX create mode 100644 server/app/mailer/examples/data/recipients.csv create mode 100644 server/app/mailer/examples/rechnungslegung_2026_05.py create mode 100644 server/app/mailer/messages/__init__.py create mode 100644 server/app/mailer/messages/builder.py create mode 100644 server/app/mailer/messages/models.py create mode 100644 server/app/mailer/persistence/__init__.py create mode 100644 server/app/mailer/persistence/campaigns.py create mode 100644 server/app/mailer/persistence/versions.py create mode 100644 server/app/mailer/reports/__init__.py create mode 100644 server/app/mailer/reports/campaigns.py create mode 100644 server/app/mailer/reports/emailing.py create mode 100644 server/app/mailer/schema/campaign.schema.json create mode 100644 server/app/mailer/sending/__init__.py create mode 100644 server/app/mailer/sending/imap.py create mode 100644 server/app/mailer/sending/jobs.py create mode 100644 server/app/mailer/sending/rate_limit.py create mode 100644 server/app/mailer/sending/smtp.py create mode 100644 server/app/mailer/services/__init__.py create mode 100644 server/app/mailer/services/attachment_matching.py create mode 100644 server/app/mailer/services/campaign_executor.py create mode 100644 server/app/mailer/services/zip_service.py create mode 100644 server/app/main.py create mode 100644 server/app/security/__init__.py create mode 100644 server/app/security/api_keys.py create mode 100644 server/app/settings.py create mode 100644 server/entrypoint.sh create mode 100644 server/multimailer-dev.db create mode 100644 server/requirements.txt create mode 100644 server/runtime/campaign_snapshots/05721f39-b5f4-4f8c-9c6f-5f44b06b9459.json create mode 100644 server/runtime/campaign_snapshots/06246903-c0ef-492f-9170-755e8e922bcf.json create mode 100644 server/runtime/campaign_snapshots/29079dd3-e93d-487b-a58b-73329d3c05de.json create mode 100644 server/runtime/campaign_snapshots/2becd95b-6657-4979-a8cd-0333d0231788.json create mode 100644 server/runtime/campaign_snapshots/3253bd83-b934-4e5e-ab82-fb73590f6e93.json create mode 100644 server/runtime/campaign_snapshots/_incoming_campaign.json create mode 100644 server/runtime/campaign_snapshots/cd95fa4a-4635-496b-808d-c385e70963a8.json create mode 100644 server/runtime/campaign_snapshots/dbc9cd27-54c3-43b1-b152-d9560d27eb79.json create mode 100644 server/runtime/generated_eml/1615e1fb-20c8-409e-86d8-d6999e5d43da/06246903-c0ef-492f-9170-755e8e922bcf/_zip/Rechnungslegung_ab0000.zip create mode 100644 server/runtime/generated_eml/1615e1fb-20c8-409e-86d8-d6999e5d43da/06246903-c0ef-492f-9170-755e8e922bcf/entry-001.eml create mode 100644 server/runtime/generated_eml/24be6ff0-ba66-4c56-ba95-525466fc1cd3/05721f39-b5f4-4f8c-9c6f-5f44b06b9459/_zip/Rechnungslegung_ab0000.zip create mode 100644 server/runtime/generated_eml/24be6ff0-ba66-4c56-ba95-525466fc1cd3/05721f39-b5f4-4f8c-9c6f-5f44b06b9459/entry-001.eml create mode 100644 server/runtime/generated_eml/3f42af9b-8ddd-42a2-bf77-7bd72e46a8ab/29079dd3-e93d-487b-a58b-73329d3c05de/_zip/Rechnungslegung_ab0000.zip create mode 100644 server/runtime/generated_eml/3f42af9b-8ddd-42a2-bf77-7bd72e46a8ab/29079dd3-e93d-487b-a58b-73329d3c05de/entry-001.eml create mode 100644 server/runtime/generated_eml/9e239ef8-3ca2-454b-a336-ca697063cc1d/3253bd83-b934-4e5e-ab82-fb73590f6e93/_zip/Rechnungslegung_ab0000.zip create mode 100644 server/runtime/generated_eml/9e239ef8-3ca2-454b-a336-ca697063cc1d/3253bd83-b934-4e5e-ab82-fb73590f6e93/entry-001.eml create mode 100644 server/runtime/generated_eml/c87a8bb0-fd10-46d4-95e1-5e1f57d3d3ca/dbc9cd27-54c3-43b1-b152-d9560d27eb79/_zip/Rechnungslegung_ab0000.zip create mode 100644 server/runtime/generated_eml/c87a8bb0-fd10-46d4-95e1-5e1f57d3d3ca/dbc9cd27-54c3-43b1-b152-d9560d27eb79/entry-001.eml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..dbd768b --- /dev/null +++ b/.env.example @@ -0,0 +1,68 @@ +# Copy to .env and adjust. +COMPOSE_PROJECT_NAME=multimailer +APP_ENV=dev + +# API +APP_HOST=0.0.0.0 +APP_PORT=8000 +API_PUBLISHED_PORT=8000 + +# Local Traefik +TRAEFIK_WEB_PORT=8080 +TRAEFIK_DASHBOARD_PORT=8081 +TRAEFIK_DASHBOARD_INSECURE=true +TRAEFIK_ROUTER_NAME=multimailer +TRAEFIK_SERVICE_NAME=multimailer +TRAEFIK_RULE=PathPrefix(`/`) + +# Database +DATABASE_URL=postgresql+psycopg://multimailer:multimailer@postgres:5432/multimailer +POSTGRES_DB=multimailer +POSTGRES_USER=multimailer +POSTGRES_PASSWORD=multimailer + +# Redis +REDIS_URL=redis://redis:6379/0 + +# Garage / S3-compatible object storage +S3_ENDPOINT_URL=http://garage:3900 +S3_REGION=garage +S3_BUCKET=attachments +S3_ACCESS_KEY_ID=GKmultimailerdev0000000000000000 +S3_SECRET_ACCESS_KEY=multimailer-dev-secret-change-me +GARAGE_S3_PORT=3900 +GARAGE_ADMIN_PORT=3903 + +# Crypto: required before storing real SMTP/IMAP credentials. +# Generate: +# python -c "import os,base64; print(base64.b64encode(os.urandom(32)).decode())" +MASTER_KEY_B64= + +# Limits +MAX_UPLOAD_MB=50 +MAX_ATTACHMENTS_PER_JOB=50 +DEFAULT_SEND_RATE_PER_MIN=30 +DEFAULT_CONCURRENCY=2 + +# Worker tuning +CELERY_QUEUES=send_email,append_sent,default +CELERY_CONCURRENCY=4 +CELERY_PREFETCH_MULTIPLIER=1 +CELERY_MAX_TASKS_PER_CHILD=200 +CELERY_LOGLEVEL=INFO + +# Existing Traefik/proxy network example +EXTERNAL_PROXY_NETWORK=proxy +TRAEFIK_ENTRYPOINT=websecure + +# Web UI +WEBUI_PUBLISHED_PORT=5173 +VITE_API_BASE_URL=/api/v1 +# For local Vite development outside Docker: +# VITE_DEV_API_PROXY_TARGET=http://127.0.0.1:8000 +CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173,http://localhost:8080 +MULTIMAILER_HOST=multimailer.localhost +TRAEFIK_API_ROUTER_NAME=multimailer-api +TRAEFIK_API_SERVICE_NAME=multimailer-api +TRAEFIK_WEBUI_ROUTER_NAME=multimailer-webui +TRAEFIK_WEBUI_SERVICE_NAME=multimailer-webui diff --git a/.gitignore b/.gitignore index 36b13f1..9d66d51 100644 --- a/.gitignore +++ b/.gitignore @@ -174,3 +174,5 @@ cython_debug/ # PyPI configuration file .pypirc +.vscode +.vscode/** \ No newline at end of file diff --git a/JAVA_PORT_NOTES.md b/JAVA_PORT_NOTES.md new file mode 100644 index 0000000..6c8d6a7 --- /dev/null +++ b/JAVA_PORT_NOTES.md @@ -0,0 +1,57 @@ +# Java MultiMailer port notes + +This skeleton now contains a first Python port of the important Java domain logic: + +- `Field`, `FieldDescription`, `FieldConfiguration`, `FieldContents` +- `MailTemplate` with `${global::field}` / `${local::field}` replacement +- `Recipient`, `RecipientList` +- `MailServerSettings` +- `MailAttachmentConfig` +- `MailCampaign`, `MailEntry`, `MailQueue` +- attachment glob matching +- encrypted ZIP creation with `pyzipper` +- MIME assembly using Python's `email.message.EmailMessage` +- basic SMTP queue sending using `smtplib` + +The Java Swing UI was not ported, because the planned UI is React. +The SimpleJavaMail dependency was replaced by stdlib `email` / `smtplib` equivalents. + +Example port of the provided `MultiMailerSettings.java.example`: + +```bash +cd server +python -m app.mailer.examples.rechnungslegung_2026_05 +``` + +Current limitations: + +- IMAP append-to-Sent is not implemented yet. +- Server-side persisted campaign/job models are not implemented yet. +- Attachment storage still uses local filesystem paths in the Java-compatible domain layer; Garage object storage integration is the next layer. +- ZIP password encryption uses AES via `pyzipper`, not the Java zip4j implementation. +- The Java `getNotSentFiles` behavior is approximated and should be revisited once we define the new campaign/job data model. + +## Added after persistence foundation + +- Queue transition for built jobs. +- Campaign-level queue/pause/resume/cancel API endpoints. +- Direct CLI queued-job processor for development without Celery. +- Celery `multimailer.send_email` task now sends DB-backed jobs. +- Per-campaign Redis-backed rate limiting. +- Send attempts persisted in `send_attempts`. +- IMAP status kept independent from SMTP status; actual IMAP APPEND remains next. + +## IMAP append implementation + +The Python port now implements the Java-era "save to sent folder" concept as a separate post-send worker action: + +- SMTP success updates `send_status=sent`. +- If configured, the job receives `imap_status=pending`. +- A separate `append_sent` task or CLI command appends the exact generated EML to the IMAP Sent folder. +- IMAP failures do not undo SMTP success. + +This preserves the important distinction between delivery and mailbox archival. + +## Reporting/dashboard milestone + +Added campaign reporting helpers and API endpoints. This is the backend bridge toward the first web UI: the report contains card-friendly counts, grouped statuses, attachment and issue summaries, delivery/rate-limit estimates, recent failures, and optional per-job rows. No new DB schema is required; the report is derived from persisted campaigns/jobs/attempts. diff --git a/README.md b/README.md index a335d49..32f4e42 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,924 @@ -# multimailer +# 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 \ + --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 \ + --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 \ + --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 \ + --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 \ + --api-key dev-multimailer-api-key +``` + +Dry-run IMAP append check: + +```bash +python -m app.mailer.commands.append_pending_sent \ + --campaign-id \ + --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 \ + --api-key dev-multimailer-api-key +``` + +Machine-readable report: + +```bash +python -m app.mailer.commands.campaign_report \ + --campaign-id \ + --api-key dev-multimailer-api-key \ + --json \ + --include-jobs +``` + +Per-job CSV export: + +```bash +python -m app.mailer.commands.campaign_report \ + --campaign-id \ + --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=&limit=100&offset=0 +``` + +Email a campaign report: + +```bash +python -m app.mailer.commands.email_campaign_report \ + --campaign-id \ + --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 \ + --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 \ + --api-key dev-multimailer-api-key \ + --no-celery +``` + +Useful flags: + +```text +--dry-run +--exclude-warnings +--version-id +--json +``` + +### Process queued jobs directly without Celery + +```bash +python -m app.mailer.commands.send_queued_jobs \ + --campaign-id \ + --api-key dev-multimailer-api-key \ + --dry-run +``` + +Useful flags: + +```text +--limit +--no-rate-limit +--append-sent +--json +``` + +### Append already-sent messages to IMAP Sent + +```bash +python -m app.mailer.commands.append_pending_sent \ + --campaign-id \ + --api-key dev-multimailer-api-key \ + --dry-run +``` + +Useful flags: + +```text +--limit +--include-failed +--json +``` + +### Generate campaign report + +```bash +python -m app.mailer.commands.campaign_report \ + --campaign-id \ + --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 \ + --api-key dev-multimailer-api-key \ + --to recipient@example.org \ + --dry-run +``` + +Useful flags: + +```text +--to # 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 +--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//versions//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//versions//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. diff --git a/compose.external.example.yml b/compose.external.example.yml new file mode 100644 index 0000000..916fa8b --- /dev/null +++ b/compose.external.example.yml @@ -0,0 +1,42 @@ +# Optional override example when Postgres, Redis, Garage and Traefik already exist. +# +# Usage: +# docker compose -f compose.yml -f compose.external.example.yml up --build +# +# Adjust EXTERNAL_PROXY_NETWORK and the .env service URLs. + +services: + api: + ports: [] + networks: + - app + - proxy + labels: + - traefik.enable=true + - traefik.docker.network=${EXTERNAL_PROXY_NETWORK:-proxy} + - traefik.http.routers.${TRAEFIK_API_ROUTER_NAME:-multimailer-api}.rule=Host(`${MULTIMAILER_HOST:-multimailer.localhost}`) && (PathPrefix(`/api`) || Path(`/health`)) + - traefik.http.routers.${TRAEFIK_API_ROUTER_NAME:-multimailer-api}.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure} + - traefik.http.routers.${TRAEFIK_API_ROUTER_NAME:-multimailer-api}.priority=100 + - traefik.http.services.${TRAEFIK_API_SERVICE_NAME:-multimailer-api}.loadbalancer.server.port=8000 + + webui: + ports: [] + networks: + - app + - proxy + labels: + - traefik.enable=true + - traefik.docker.network=${EXTERNAL_PROXY_NETWORK:-proxy} + - traefik.http.routers.${TRAEFIK_WEBUI_ROUTER_NAME:-multimailer-webui}.rule=Host(`${MULTIMAILER_HOST:-multimailer.localhost}`) + - traefik.http.routers.${TRAEFIK_WEBUI_ROUTER_NAME:-multimailer-webui}.entrypoints=${TRAEFIK_ENTRYPOINT:-websecure} + - traefik.http.routers.${TRAEFIK_WEBUI_ROUTER_NAME:-multimailer-webui}.priority=1 + - traefik.http.services.${TRAEFIK_WEBUI_SERVICE_NAME:-multimailer-webui}.loadbalancer.server.port=80 + + worker: + networks: + - app + +networks: + proxy: + external: true + name: ${EXTERNAL_PROXY_NETWORK:-proxy} diff --git a/compose.local.yml b/compose.local.yml new file mode 100644 index 0000000..25327ea --- /dev/null +++ b/compose.local.yml @@ -0,0 +1,115 @@ +services: + api: + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + garage: + condition: service_started + networks: + - app + - edge + labels: + - traefik.enable=true + - traefik.http.routers.${TRAEFIK_API_ROUTER_NAME:-multimailer-api}.rule=PathPrefix(`/api`) || Path(`/health`) + - traefik.http.routers.${TRAEFIK_API_ROUTER_NAME:-multimailer-api}.entrypoints=web + - traefik.http.routers.${TRAEFIK_API_ROUTER_NAME:-multimailer-api}.priority=100 + - traefik.http.services.${TRAEFIK_API_SERVICE_NAME:-multimailer-api}.loadbalancer.server.port=8000 + + worker: + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + garage: + condition: service_started + + + webui: + depends_on: + api: + condition: service_started + networks: + - app + - edge + labels: + - traefik.enable=true + - traefik.http.routers.${TRAEFIK_WEBUI_ROUTER_NAME:-multimailer-webui}.rule=PathPrefix(`/`) + - traefik.http.routers.${TRAEFIK_WEBUI_ROUTER_NAME:-multimailer-webui}.entrypoints=web + - traefik.http.routers.${TRAEFIK_WEBUI_ROUTER_NAME:-multimailer-webui}.priority=1 + - traefik.http.services.${TRAEFIK_WEBUI_SERVICE_NAME:-multimailer-webui}.loadbalancer.server.port=80 + + traefik: + image: traefik:v3.2 + command: + - --api.dashboard=true + - --api.insecure=${TRAEFIK_DASHBOARD_INSECURE:-true} + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --entrypoints.web.address=:${TRAEFIK_WEB_PORT:-8080} + ports: + - "${TRAEFIK_WEB_PORT:-8080}:${TRAEFIK_WEB_PORT:-8080}" + - "${TRAEFIK_DASHBOARD_PORT:-8081}:8080" + volumes: + - /var/run/docker.sock:/var/run/docker.sock:ro + networks: + - edge + - app + + postgres: + image: postgres:16 + environment: + POSTGRES_DB: ${POSTGRES_DB:-multimailer} + POSTGRES_USER: ${POSTGRES_USER:-multimailer} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-multimailer} + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - app + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-multimailer} -d ${POSTGRES_DB:-multimailer}"] + interval: 10s + timeout: 5s + retries: 10 + + redis: + image: redis:7-alpine + command: ["redis-server", "--appendonly", "yes"] + volumes: + - redis_data:/data + networks: + - app + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 10 + + garage: + image: dxflrs/garage:v2.3.0 + command: ["garage", "-c", "/etc/garage.toml", "server", "--single-node", "--default-bucket"] + environment: + GARAGE_DEFAULT_ACCESS_KEY: ${S3_ACCESS_KEY_ID:-GKmultimailerdev0000000000000000} + GARAGE_DEFAULT_SECRET_KEY: ${S3_SECRET_ACCESS_KEY:-multimailer-dev-secret-change-me} + GARAGE_DEFAULT_BUCKET: ${S3_BUCKET:-attachments} + ports: + - "${GARAGE_S3_PORT:-3900}:3900" + - "${GARAGE_ADMIN_PORT:-3903}:3903" + volumes: + - ./infra/garage/garage.toml:/etc/garage.toml:ro + - garage_meta:/var/lib/garage/meta + - garage_data:/var/lib/garage/data + networks: + - app + +networks: + edge: + name: ${COMPOSE_PROJECT_NAME:-multimailer}_edge + +volumes: + postgres_data: + redis_data: + garage_meta: + garage_data: diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..2e5c4d4 --- /dev/null +++ b/compose.yml @@ -0,0 +1,78 @@ +services: + api: + build: + context: ./server + dockerfile: Dockerfile + environment: + APP_ROLE: api + APP_ENV: ${APP_ENV:-dev} + APP_HOST: ${APP_HOST:-0.0.0.0} + APP_PORT: ${APP_PORT:-8000} + CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:5173,http://127.0.0.1:5173,http://localhost:8080} + + DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://multimailer:multimailer@postgres:5432/multimailer} + REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} + + S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-http://garage:3900} + S3_REGION: ${S3_REGION:-garage} + S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-GKmultimailerdev0000000000000000} + S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-multimailer-dev-secret-change-me} + S3_BUCKET: ${S3_BUCKET:-attachments} + + MASTER_KEY_B64: ${MASTER_KEY_B64:-} + + MAX_UPLOAD_MB: ${MAX_UPLOAD_MB:-50} + MAX_ATTACHMENTS_PER_JOB: ${MAX_ATTACHMENTS_PER_JOB:-50} + DEFAULT_SEND_RATE_PER_MIN: ${DEFAULT_SEND_RATE_PER_MIN:-30} + DEFAULT_CONCURRENCY: ${DEFAULT_CONCURRENCY:-2} + ports: + - "${API_PUBLISHED_PORT:-8000}:8000" + networks: + - app + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health').read()"] + interval: 10s + timeout: 3s + retries: 10 + + worker: + build: + context: ./server + dockerfile: Dockerfile + environment: + APP_ROLE: worker + APP_ENV: ${APP_ENV:-dev} + + DATABASE_URL: ${DATABASE_URL:-postgresql+psycopg://multimailer:multimailer@postgres:5432/multimailer} + REDIS_URL: ${REDIS_URL:-redis://redis:6379/0} + + S3_ENDPOINT_URL: ${S3_ENDPOINT_URL:-http://garage:3900} + S3_REGION: ${S3_REGION:-garage} + S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-GKmultimailerdev0000000000000000} + S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-multimailer-dev-secret-change-me} + S3_BUCKET: ${S3_BUCKET:-attachments} + + MASTER_KEY_B64: ${MASTER_KEY_B64:-} + + CELERY_QUEUES: ${CELERY_QUEUES:-send_email,append_sent,default} + CELERY_CONCURRENCY: ${CELERY_CONCURRENCY:-4} + CELERY_PREFETCH_MULTIPLIER: ${CELERY_PREFETCH_MULTIPLIER:-1} + CELERY_MAX_TASKS_PER_CHILD: ${CELERY_MAX_TASKS_PER_CHILD:-200} + CELERY_LOGLEVEL: ${CELERY_LOGLEVEL:-INFO} + networks: + - app + + webui: + build: + context: ./webui + dockerfile: Dockerfile + args: + VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api/v1} + ports: + - "${WEBUI_PUBLISHED_PORT:-5173}:80" + networks: + - app + +networks: + app: + name: ${COMPOSE_PROJECT_NAME:-multimailer}_app diff --git a/infra/garage/garage.toml b/infra/garage/garage.toml new file mode 100644 index 0000000..67d710d --- /dev/null +++ b/infra/garage/garage.toml @@ -0,0 +1,18 @@ +metadata_dir = "/var/lib/garage/meta" +data_dir = "/var/lib/garage/data" + +db_engine = "sqlite" +replication_factor = 1 +compression_level = 1 + +rpc_bind_addr = "[::]:3901" +rpc_secret = "change-this-rpc-secret-minimum-32-chars" + +[s3_api] +s3_region = "garage" +api_bind_addr = "[::]:3900" +root_domain = "localhost" + +[admin] +api_bind_addr = "[::]:3903" +admin_token = "change-this-admin-token" diff --git a/multimailer_python_java_port_with_editable_campaign_versions.zip b/multimailer_python_java_port_with_editable_campaign_versions.zip new file mode 100644 index 0000000000000000000000000000000000000000..ca70024a0f35e4eddc2e4161c8e4b971ce594205 GIT binary patch literal 165410 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

C3=CEmM?FhD5Ci)+pzeblt6Mp!(_ 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

!w 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%0Aje}sj5)~h%3ZIYO|Y;uDJ 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


4$@ryYx-Sp6zAZM{eoSuT&w1^FHng$C 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 literal 0 HcmV?d00001 diff --git a/scripts/down-local.sh b/scripts/down-local.sh new file mode 100644 index 0000000..f7c49f0 --- /dev/null +++ b/scripts/down-local.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env sh +set -e +docker compose -f compose.yml -f compose.local.yml down diff --git a/scripts/generate-master-key.sh b/scripts/generate-master-key.sh new file mode 100644 index 0000000..1c6dc86 --- /dev/null +++ b/scripts/generate-master-key.sh @@ -0,0 +1,2 @@ +#!/usr/bin/env sh +python -c "import os,base64; print(base64.b64encode(os.urandom(32)).decode())" diff --git a/scripts/up-local.sh b/scripts/up-local.sh new file mode 100644 index 0000000..e2a1fa8 --- /dev/null +++ b/scripts/up-local.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env sh +set -e +docker compose -f compose.yml -f compose.local.yml up --build diff --git a/server/Dockerfile b/server/Dockerfile new file mode 100644 index 0000000..456bcd2 --- /dev/null +++ b/server/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.12-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + curl \ + build-essential \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt /app/requirements.txt +RUN pip install --no-cache-dir -r /app/requirements.txt + +COPY entrypoint.sh /app/entrypoint.sh +RUN chmod +x /app/entrypoint.sh + +COPY app /app/app + +EXPOSE 8000 +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/server/alembic.ini b/server/alembic.ini new file mode 100644 index 0000000..df23acf --- /dev/null +++ b/server/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = alembic +prepend_sys_path = . +sqlalchemy.url = sqlite:///./multimailer-dev.db + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/server/alembic/env.py b/server/alembic/env.py new file mode 100644 index 0000000..9d1137f --- /dev/null +++ b/server/alembic/env.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.db.base import Base +from app.db import models # noqa: F401 - ensure models are imported +from app.settings import settings + +config = context.config +config.set_main_option("sqlalchemy.url", settings.database_url) + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + url = config.get_main_option("sqlalchemy.url") + context.configure(url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/server/alembic/script.py.mako b/server/alembic/script.py.mako new file mode 100644 index 0000000..c6284cb --- /dev/null +++ b/server/alembic/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/server/alembic/versions/1f8d4c2a0b7e_editable_campaign_versions.py b/server/alembic/versions/1f8d4c2a0b7e_editable_campaign_versions.py new file mode 100644 index 0000000..33a085d --- /dev/null +++ b/server/alembic/versions/1f8d4c2a0b7e_editable_campaign_versions.py @@ -0,0 +1,58 @@ +"""editable campaign versions + +Revision ID: 1f8d4c2a0b7e +Revises: b57c5b216bce +Create Date: 2026-06-08 08:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "1f8d4c2a0b7e" +down_revision = "b57c5b216bce" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + with op.batch_alter_table("campaign_versions") as batch_op: + batch_op.add_column(sa.Column("source_base_path", sa.String(length=1000), nullable=True)) + batch_op.add_column(sa.Column("workflow_state", sa.String(length=50), nullable=False, server_default="editing")) + batch_op.add_column(sa.Column("current_flow", sa.String(length=50), nullable=False, server_default="manual")) + batch_op.add_column(sa.Column("current_step", sa.String(length=100), nullable=True)) + batch_op.add_column(sa.Column("is_complete", sa.Boolean(), nullable=False, server_default=sa.false())) + batch_op.add_column(sa.Column("editor_state", sa.JSON(), nullable=False, server_default=sa.text("'{}'"))) + batch_op.add_column(sa.Column("autosaved_at", sa.DateTime(timezone=True), nullable=True)) + batch_op.add_column(sa.Column("published_at", sa.DateTime(timezone=True), nullable=True)) + batch_op.add_column(sa.Column("locked_at", sa.DateTime(timezone=True), nullable=True)) + batch_op.add_column(sa.Column("locked_by_user_id", sa.String(length=36), nullable=True)) + batch_op.create_foreign_key( + op.f("fk_campaign_versions_locked_by_user_id_users"), + "users", + ["locked_by_user_id"], + ["id"], + ondelete="SET NULL", + ) + batch_op.create_index(op.f("ix_campaign_versions_workflow_state"), ["workflow_state"], unique=False) + batch_op.create_index(op.f("ix_campaign_versions_current_flow"), ["current_flow"], unique=False) + batch_op.create_index(op.f("ix_campaign_versions_locked_by_user_id"), ["locked_by_user_id"], unique=False) + + +def downgrade() -> None: + with op.batch_alter_table("campaign_versions") as batch_op: + batch_op.drop_index(op.f("ix_campaign_versions_locked_by_user_id")) + batch_op.drop_index(op.f("ix_campaign_versions_current_flow")) + batch_op.drop_index(op.f("ix_campaign_versions_workflow_state")) + batch_op.drop_constraint(op.f("fk_campaign_versions_locked_by_user_id_users"), type_="foreignkey") + batch_op.drop_column("locked_by_user_id") + batch_op.drop_column("locked_at") + batch_op.drop_column("published_at") + batch_op.drop_column("autosaved_at") + batch_op.drop_column("editor_state") + batch_op.drop_column("is_complete") + batch_op.drop_column("current_step") + batch_op.drop_column("current_flow") + batch_op.drop_column("workflow_state") + batch_op.drop_column("source_base_path") diff --git a/server/alembic/versions/b57c5b216bce_initial_persistence_models.py b/server/alembic/versions/b57c5b216bce_initial_persistence_models.py new file mode 100644 index 0000000..3f4f933 --- /dev/null +++ b/server/alembic/versions/b57c5b216bce_initial_persistence_models.py @@ -0,0 +1,346 @@ +"""initial persistence models + +Revision ID: b57c5b216bce +Revises: +Create Date: 2026-06-07 19:29:10.222504 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = 'b57c5b216bce' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('tenants', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('slug', sa.String(length=100), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id', name=op.f('pk_tenants')) + ) + op.create_index(op.f('ix_tenants_slug'), 'tenants', ['slug'], unique=True) + op.create_table('attachment_blobs', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('sha256', sa.String(length=64), nullable=False), + sa.Column('size_bytes', sa.Integer(), nullable=False), + sa.Column('mime_type', sa.String(length=255), nullable=True), + sa.Column('storage_bucket', sa.String(length=255), nullable=False), + sa.Column('storage_key', sa.String(length=1000), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name=op.f('fk_attachment_blobs_tenant_id_tenants'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_attachment_blobs')), + sa.UniqueConstraint('tenant_id', 'sha256', name='uq_attachment_blobs_tenant_sha256') + ) + op.create_index(op.f('ix_attachment_blobs_sha256'), 'attachment_blobs', ['sha256'], unique=False) + op.create_index(op.f('ix_attachment_blobs_tenant_id'), 'attachment_blobs', ['tenant_id'], unique=False) + op.create_table('groups', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('slug', sa.String(length=100), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name=op.f('fk_groups_tenant_id_tenants'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_groups')), + sa.UniqueConstraint('tenant_id', 'slug', name='uq_groups_tenant_slug') + ) + op.create_index(op.f('ix_groups_tenant_id'), 'groups', ['tenant_id'], unique=False) + op.create_table('roles', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=True), + sa.Column('slug', sa.String(length=100), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('permissions', sa.JSON(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name=op.f('fk_roles_tenant_id_tenants'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_roles')), + sa.UniqueConstraint('tenant_id', 'slug', name='uq_roles_tenant_slug') + ) + op.create_index(op.f('ix_roles_tenant_id'), 'roles', ['tenant_id'], unique=False) + op.create_table('users', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('email', sa.String(length=320), nullable=False), + sa.Column('display_name', sa.String(length=255), nullable=True), + sa.Column('is_active', sa.Boolean(), nullable=False), + sa.Column('is_tenant_admin', sa.Boolean(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name=op.f('fk_users_tenant_id_tenants'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_users')), + sa.UniqueConstraint('tenant_id', 'email', name='uq_users_tenant_email') + ) + op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=False) + op.create_index(op.f('ix_users_tenant_id'), 'users', ['tenant_id'], unique=False) + op.create_table('api_keys', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('user_id', sa.String(length=36), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('prefix', sa.String(length=16), nullable=False), + sa.Column('key_hash', sa.String(length=128), nullable=False), + sa.Column('scopes', sa.JSON(), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('last_used_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name=op.f('fk_api_keys_tenant_id_tenants'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_api_keys_user_id_users'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_api_keys')) + ) + op.create_index(op.f('ix_api_keys_prefix'), 'api_keys', ['prefix'], unique=False) + op.create_index(op.f('ix_api_keys_tenant_id'), 'api_keys', ['tenant_id'], unique=False) + op.create_index(op.f('ix_api_keys_user_id'), 'api_keys', ['user_id'], unique=False) + op.create_table('campaigns', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('created_by_user_id', sa.String(length=36), nullable=True), + sa.Column('external_id', sa.String(length=255), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('status', sa.String(length=50), nullable=False), + sa.Column('current_version_id', sa.String(length=36), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['created_by_user_id'], ['users.id'], name=op.f('fk_campaigns_created_by_user_id_users'), ondelete='SET NULL'), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name=op.f('fk_campaigns_tenant_id_tenants'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_campaigns')), + sa.UniqueConstraint('tenant_id', 'external_id', name='uq_campaigns_tenant_external_id') + ) + op.create_index(op.f('ix_campaigns_created_by_user_id'), 'campaigns', ['created_by_user_id'], unique=False) + op.create_index(op.f('ix_campaigns_external_id'), 'campaigns', ['external_id'], unique=False) + op.create_index(op.f('ix_campaigns_status'), 'campaigns', ['status'], unique=False) + op.create_index(op.f('ix_campaigns_tenant_id'), 'campaigns', ['tenant_id'], unique=False) + op.create_table('attachment_instances', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('owner_user_id', sa.String(length=36), nullable=True), + sa.Column('campaign_id', sa.String(length=36), nullable=True), + sa.Column('blob_id', sa.String(length=36), nullable=False), + sa.Column('logical_name', sa.String(length=500), nullable=True), + sa.Column('filename', sa.String(length=500), nullable=False), + sa.Column('tags', sa.JSON(), nullable=False), + sa.Column('metadata', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['blob_id'], ['attachment_blobs.id'], name=op.f('fk_attachment_instances_blob_id_attachment_blobs'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['campaign_id'], ['campaigns.id'], name=op.f('fk_attachment_instances_campaign_id_campaigns'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['owner_user_id'], ['users.id'], name=op.f('fk_attachment_instances_owner_user_id_users'), ondelete='SET NULL'), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name=op.f('fk_attachment_instances_tenant_id_tenants'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_attachment_instances')) + ) + op.create_index(op.f('ix_attachment_instances_blob_id'), 'attachment_instances', ['blob_id'], unique=False) + op.create_index(op.f('ix_attachment_instances_campaign_id'), 'attachment_instances', ['campaign_id'], unique=False) + op.create_index(op.f('ix_attachment_instances_owner_user_id'), 'attachment_instances', ['owner_user_id'], unique=False) + op.create_index(op.f('ix_attachment_instances_tenant_id'), 'attachment_instances', ['tenant_id'], unique=False) + op.create_table('audit_log', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=True), + sa.Column('user_id', sa.String(length=36), nullable=True), + sa.Column('api_key_id', sa.String(length=36), nullable=True), + sa.Column('action', sa.String(length=100), nullable=False), + sa.Column('object_type', sa.String(length=100), nullable=True), + sa.Column('object_id', sa.String(length=100), nullable=True), + sa.Column('details', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['api_key_id'], ['api_keys.id'], name=op.f('fk_audit_log_api_key_id_api_keys'), ondelete='SET NULL'), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name=op.f('fk_audit_log_tenant_id_tenants'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], name=op.f('fk_audit_log_user_id_users'), ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_audit_log')) + ) + op.create_index(op.f('ix_audit_log_action'), 'audit_log', ['action'], unique=False) + op.create_index(op.f('ix_audit_log_api_key_id'), 'audit_log', ['api_key_id'], unique=False) + op.create_index(op.f('ix_audit_log_object_id'), 'audit_log', ['object_id'], unique=False) + op.create_index(op.f('ix_audit_log_object_type'), 'audit_log', ['object_type'], unique=False) + op.create_index(op.f('ix_audit_log_tenant_id'), 'audit_log', ['tenant_id'], unique=False) + op.create_index(op.f('ix_audit_log_user_id'), 'audit_log', ['user_id'], unique=False) + op.create_table('campaign_versions', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('campaign_id', sa.String(length=36), nullable=False), + sa.Column('version_number', sa.Integer(), nullable=False), + sa.Column('raw_json', sa.JSON(), nullable=False), + sa.Column('schema_version', sa.String(length=50), nullable=False), + sa.Column('source_filename', sa.String(length=500), nullable=True), + sa.Column('validation_summary', sa.JSON(), nullable=True), + sa.Column('build_summary', sa.JSON(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['campaign_id'], ['campaigns.id'], name=op.f('fk_campaign_versions_campaign_id_campaigns'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_campaign_versions')), + sa.UniqueConstraint('campaign_id', 'version_number', name='uq_campaign_versions_campaign_number') + ) + op.create_index(op.f('ix_campaign_versions_campaign_id'), 'campaign_versions', ['campaign_id'], unique=False) + op.create_table('campaign_jobs', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('campaign_id', sa.String(length=36), nullable=False), + sa.Column('campaign_version_id', sa.String(length=36), nullable=False), + sa.Column('entry_index', sa.Integer(), nullable=False), + sa.Column('entry_id', sa.String(length=255), nullable=True), + sa.Column('recipient_email', sa.String(length=320), nullable=True), + sa.Column('subject', sa.String(length=998), nullable=True), + sa.Column('message_id_header', sa.String(length=255), nullable=True), + sa.Column('eml_storage_key', sa.String(length=1000), nullable=True), + sa.Column('eml_local_path', sa.String(length=1000), nullable=True), + sa.Column('eml_size_bytes', sa.Integer(), nullable=True), + sa.Column('build_status', sa.String(length=50), nullable=False), + sa.Column('validation_status', sa.String(length=50), nullable=False), + sa.Column('queue_status', sa.String(length=50), nullable=False), + sa.Column('send_status', sa.String(length=50), nullable=False), + sa.Column('imap_status', sa.String(length=50), nullable=False), + sa.Column('attempt_count', sa.Integer(), nullable=False), + sa.Column('last_error', sa.Text(), nullable=True), + sa.Column('queued_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('sent_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('resolved_recipients', sa.JSON(), nullable=True), + sa.Column('resolved_attachments', sa.JSON(), nullable=False), + sa.Column('issues_snapshot', sa.JSON(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['campaign_id'], ['campaigns.id'], name=op.f('fk_campaign_jobs_campaign_id_campaigns'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['campaign_version_id'], ['campaign_versions.id'], name=op.f('fk_campaign_jobs_campaign_version_id_campaign_versions'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name=op.f('fk_campaign_jobs_tenant_id_tenants'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_campaign_jobs')), + sa.UniqueConstraint('campaign_version_id', 'entry_index', name='uq_campaign_jobs_version_entry') + ) + op.create_index(op.f('ix_campaign_jobs_build_status'), 'campaign_jobs', ['build_status'], unique=False) + op.create_index(op.f('ix_campaign_jobs_campaign_id'), 'campaign_jobs', ['campaign_id'], unique=False) + op.create_index(op.f('ix_campaign_jobs_campaign_version_id'), 'campaign_jobs', ['campaign_version_id'], unique=False) + op.create_index(op.f('ix_campaign_jobs_entry_id'), 'campaign_jobs', ['entry_id'], unique=False) + op.create_index(op.f('ix_campaign_jobs_imap_status'), 'campaign_jobs', ['imap_status'], unique=False) + op.create_index(op.f('ix_campaign_jobs_queue_status'), 'campaign_jobs', ['queue_status'], unique=False) + op.create_index(op.f('ix_campaign_jobs_recipient_email'), 'campaign_jobs', ['recipient_email'], unique=False) + op.create_index(op.f('ix_campaign_jobs_send_status'), 'campaign_jobs', ['send_status'], unique=False) + op.create_index(op.f('ix_campaign_jobs_tenant_id'), 'campaign_jobs', ['tenant_id'], unique=False) + op.create_index(op.f('ix_campaign_jobs_validation_status'), 'campaign_jobs', ['validation_status'], unique=False) + op.create_table('campaign_issues', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('tenant_id', sa.String(length=36), nullable=False), + sa.Column('campaign_id', sa.String(length=36), nullable=False), + sa.Column('campaign_version_id', sa.String(length=36), nullable=True), + sa.Column('job_id', sa.String(length=36), nullable=True), + sa.Column('severity', sa.String(length=20), nullable=False), + sa.Column('code', sa.String(length=100), nullable=False), + sa.Column('message', sa.Text(), nullable=False), + sa.Column('source', sa.String(length=255), nullable=True), + sa.Column('behavior', sa.String(length=50), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['campaign_id'], ['campaigns.id'], name=op.f('fk_campaign_issues_campaign_id_campaigns'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['campaign_version_id'], ['campaign_versions.id'], name=op.f('fk_campaign_issues_campaign_version_id_campaign_versions'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['job_id'], ['campaign_jobs.id'], name=op.f('fk_campaign_issues_job_id_campaign_jobs'), ondelete='CASCADE'), + sa.ForeignKeyConstraint(['tenant_id'], ['tenants.id'], name=op.f('fk_campaign_issues_tenant_id_tenants'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_campaign_issues')) + ) + op.create_index(op.f('ix_campaign_issues_campaign_id'), 'campaign_issues', ['campaign_id'], unique=False) + op.create_index(op.f('ix_campaign_issues_campaign_version_id'), 'campaign_issues', ['campaign_version_id'], unique=False) + op.create_index(op.f('ix_campaign_issues_code'), 'campaign_issues', ['code'], unique=False) + op.create_index(op.f('ix_campaign_issues_job_id'), 'campaign_issues', ['job_id'], unique=False) + op.create_index(op.f('ix_campaign_issues_severity'), 'campaign_issues', ['severity'], unique=False) + op.create_index(op.f('ix_campaign_issues_tenant_id'), 'campaign_issues', ['tenant_id'], unique=False) + op.create_table('imap_append_attempts', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('job_id', sa.String(length=36), nullable=False), + sa.Column('attempt_number', sa.Integer(), nullable=False), + sa.Column('folder', sa.String(length=500), nullable=True), + sa.Column('status', sa.String(length=50), nullable=False), + sa.Column('error_message', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['job_id'], ['campaign_jobs.id'], name=op.f('fk_imap_append_attempts_job_id_campaign_jobs'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_imap_append_attempts')) + ) + op.create_index(op.f('ix_imap_append_attempts_job_id'), 'imap_append_attempts', ['job_id'], unique=False) + op.create_table('send_attempts', + sa.Column('id', sa.String(length=36), nullable=False), + sa.Column('job_id', sa.String(length=36), nullable=False), + sa.Column('attempt_number', sa.Integer(), nullable=False), + sa.Column('smtp_status_code', sa.Integer(), nullable=True), + sa.Column('smtp_response', sa.Text(), nullable=True), + sa.Column('error_type', sa.String(length=255), nullable=True), + sa.Column('error_message', sa.Text(), nullable=True), + sa.Column('started_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['job_id'], ['campaign_jobs.id'], name=op.f('fk_send_attempts_job_id_campaign_jobs'), ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id', name=op.f('pk_send_attempts')) + ) + op.create_index(op.f('ix_send_attempts_job_id'), 'send_attempts', ['job_id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_send_attempts_job_id'), table_name='send_attempts') + op.drop_table('send_attempts') + op.drop_index(op.f('ix_imap_append_attempts_job_id'), table_name='imap_append_attempts') + op.drop_table('imap_append_attempts') + op.drop_index(op.f('ix_campaign_issues_tenant_id'), table_name='campaign_issues') + op.drop_index(op.f('ix_campaign_issues_severity'), table_name='campaign_issues') + op.drop_index(op.f('ix_campaign_issues_job_id'), table_name='campaign_issues') + op.drop_index(op.f('ix_campaign_issues_code'), table_name='campaign_issues') + op.drop_index(op.f('ix_campaign_issues_campaign_version_id'), table_name='campaign_issues') + op.drop_index(op.f('ix_campaign_issues_campaign_id'), table_name='campaign_issues') + op.drop_table('campaign_issues') + op.drop_index(op.f('ix_campaign_jobs_validation_status'), table_name='campaign_jobs') + op.drop_index(op.f('ix_campaign_jobs_tenant_id'), table_name='campaign_jobs') + op.drop_index(op.f('ix_campaign_jobs_send_status'), table_name='campaign_jobs') + op.drop_index(op.f('ix_campaign_jobs_recipient_email'), table_name='campaign_jobs') + op.drop_index(op.f('ix_campaign_jobs_queue_status'), table_name='campaign_jobs') + op.drop_index(op.f('ix_campaign_jobs_imap_status'), table_name='campaign_jobs') + op.drop_index(op.f('ix_campaign_jobs_entry_id'), table_name='campaign_jobs') + op.drop_index(op.f('ix_campaign_jobs_campaign_version_id'), table_name='campaign_jobs') + op.drop_index(op.f('ix_campaign_jobs_campaign_id'), table_name='campaign_jobs') + op.drop_index(op.f('ix_campaign_jobs_build_status'), table_name='campaign_jobs') + op.drop_table('campaign_jobs') + op.drop_index(op.f('ix_campaign_versions_campaign_id'), table_name='campaign_versions') + op.drop_table('campaign_versions') + op.drop_index(op.f('ix_audit_log_user_id'), table_name='audit_log') + op.drop_index(op.f('ix_audit_log_tenant_id'), table_name='audit_log') + op.drop_index(op.f('ix_audit_log_object_type'), table_name='audit_log') + op.drop_index(op.f('ix_audit_log_object_id'), table_name='audit_log') + op.drop_index(op.f('ix_audit_log_api_key_id'), table_name='audit_log') + op.drop_index(op.f('ix_audit_log_action'), table_name='audit_log') + op.drop_table('audit_log') + op.drop_index(op.f('ix_attachment_instances_tenant_id'), table_name='attachment_instances') + op.drop_index(op.f('ix_attachment_instances_owner_user_id'), table_name='attachment_instances') + op.drop_index(op.f('ix_attachment_instances_campaign_id'), table_name='attachment_instances') + op.drop_index(op.f('ix_attachment_instances_blob_id'), table_name='attachment_instances') + op.drop_table('attachment_instances') + op.drop_index(op.f('ix_campaigns_tenant_id'), table_name='campaigns') + op.drop_index(op.f('ix_campaigns_status'), table_name='campaigns') + op.drop_index(op.f('ix_campaigns_external_id'), table_name='campaigns') + op.drop_index(op.f('ix_campaigns_created_by_user_id'), table_name='campaigns') + op.drop_table('campaigns') + op.drop_index(op.f('ix_api_keys_user_id'), table_name='api_keys') + op.drop_index(op.f('ix_api_keys_tenant_id'), table_name='api_keys') + op.drop_index(op.f('ix_api_keys_prefix'), table_name='api_keys') + op.drop_table('api_keys') + op.drop_index(op.f('ix_users_tenant_id'), table_name='users') + op.drop_index(op.f('ix_users_email'), table_name='users') + op.drop_table('users') + op.drop_index(op.f('ix_roles_tenant_id'), table_name='roles') + op.drop_table('roles') + op.drop_index(op.f('ix_groups_tenant_id'), table_name='groups') + op.drop_table('groups') + op.drop_index(op.f('ix_attachment_blobs_tenant_id'), table_name='attachment_blobs') + op.drop_index(op.f('ix_attachment_blobs_sha256'), table_name='attachment_blobs') + op.drop_table('attachment_blobs') + op.drop_index(op.f('ix_tenants_slug'), table_name='tenants') + op.drop_table('tenants') + # ### end Alembic commands ### diff --git a/server/app/__init__.py b/server/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app/api/__init__.py b/server/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app/api/v1/__init__.py b/server/app/api/v1/__init__.py new file mode 100644 index 0000000..cfd035d --- /dev/null +++ b/server/app/api/v1/__init__.py @@ -0,0 +1,12 @@ +from fastapi import APIRouter + +from .admin import router as admin_router +from .campaigns import router as campaigns_router +from .audit import router as audit_router +from .system import router as system_router + +router = APIRouter(prefix="/api/v1") +router.include_router(campaigns_router) +router.include_router(admin_router) +router.include_router(audit_router) +router.include_router(system_router) diff --git a/server/app/api/v1/admin.py b/server/app/api/v1/admin.py new file mode 100644 index 0000000..3e85d2f --- /dev/null +++ b/server/app/api/v1/admin.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from app.api.v1.schemas import ApiKeyCreateRequest, ApiKeyCreateResponse +from app.auth.dependencies import ApiPrincipal, require_scope +from app.audit.logging import audit_from_principal +from app.db.session import get_session +from app.security.api_keys import create_api_key + +router = APIRouter(prefix="/admin", tags=["admin"]) + + +@router.post("/api-keys", response_model=ApiKeyCreateResponse) +def create_personal_api_key( + payload: ApiKeyCreateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("admin:settings")), +): + created = create_api_key(session, user=principal.user, name=payload.name, scopes=payload.scopes or ["campaign:read"]) + audit_from_principal( + session, + principal, + action="api_key.created", + object_type="api_key", + object_id=created.model.id, + details={"name": created.model.name, "prefix": created.model.prefix, "scopes": created.model.scopes}, + commit=True, + ) + return ApiKeyCreateResponse( + id=created.model.id, + name=created.model.name, + prefix=created.model.prefix, + scopes=created.model.scopes, + secret=created.secret, + ) diff --git a/server/app/api/v1/audit.py b/server/app/api/v1/audit.py new file mode 100644 index 0000000..8077781 --- /dev/null +++ b/server/app/api/v1/audit.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from app.api.v1.schemas import AuditLogItemResponse, AuditLogListResponse +from app.auth.dependencies import ApiPrincipal, require_scope +from app.db.models import AuditLog +from app.db.session import get_session + +router = APIRouter(prefix="/audit", tags=["audit"]) + + +@router.get("", response_model=AuditLogListResponse) +def list_audit_log( + limit: int = Query(default=100, ge=1, le=500), + offset: int = Query(default=0, ge=0), + action: str | None = None, + object_type: str | None = None, + object_id: str | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("audit:read")), +): + query = session.query(AuditLog).filter(AuditLog.tenant_id == principal.tenant_id) + if action: + query = query.filter(AuditLog.action == action) + if object_type: + query = query.filter(AuditLog.object_type == object_type) + if object_id: + query = query.filter(AuditLog.object_id == object_id) + items = query.order_by(AuditLog.created_at.desc()).offset(offset).limit(limit).all() + return AuditLogListResponse(items=[AuditLogItemResponse.model_validate(item) for item in items]) diff --git a/server/app/api/v1/campaigns.py b/server/app/api/v1/campaigns.py new file mode 100644 index 0000000..d5ca98b --- /dev/null +++ b/server/app/api/v1/campaigns.py @@ -0,0 +1,651 @@ +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException, Response, status +from sqlalchemy.orm import Session + +from app.api.v1.schemas import ( + BuildCampaignRequest, + CampaignCreateRequest, + CampaignCreateResponse, + CampaignCreateMinimalRequest, + CampaignJobsResponse, + CampaignListResponse, + CampaignResponse, + CampaignVersionDetailResponse, + CampaignVersionResponse, + CampaignVersionSetStepRequest, + CampaignVersionUpdateRequest, + CampaignPartialValidationRequest, + CampaignPartialValidationResponse, + ValidateCampaignRequest, + ReportEmailRequest, + ReportEmailResponse, +) +from app.auth.dependencies import ApiPrincipal, require_scope +from app.audit.logging import audit_from_principal +from app.db.models import Campaign, CampaignJob, CampaignVersion +from app.db.session import get_session +from app.mailer.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv +from app.mailer.reports.emailing import CampaignReportEmailError, send_campaign_report_email +from app.mailer.persistence.campaigns import ( + CampaignPersistenceError, + build_campaign_version, + create_campaign_version_from_json, + validate_campaign_version, +) +from app.mailer.persistence.versions import ( + create_minimal_campaign, + get_campaign_version_for_tenant, + publish_campaign_version, + update_campaign_version, + validate_campaign_partial, +) + +router = APIRouter(prefix="/campaigns", tags=["campaigns"]) + + +def _get_campaign_for_tenant(session: Session, campaign_id: str, tenant_id: str) -> Campaign: + campaign = session.get(Campaign, campaign_id) + if not campaign or campaign.tenant_id != tenant_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign not found") + return campaign + + +def _get_version_for_tenant(session: Session, version_id: str, tenant_id: str) -> CampaignVersion: + version = session.get(CampaignVersion, version_id) + if not version: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found") + campaign = session.get(Campaign, version.campaign_id) + if not campaign or campaign.tenant_id != tenant_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Campaign version not found") + return version + + +@router.post("", response_model=CampaignCreateResponse) +def create_campaign( + payload: CampaignCreateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaign:write")), +): + try: + campaign, version = create_campaign_version_from_json( + session, + tenant_id=principal.tenant_id, + user_id=principal.user.id, + raw_json=payload.config, + source_filename=payload.source_filename, + source_base_path=payload.source_base_path, + ) + audit_from_principal( + session, + principal, + action="campaign.created", + object_type="campaign", + object_id=campaign.id, + details={"version_id": version.id, "external_id": campaign.external_id}, + commit=True, + ) + except Exception as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + return CampaignCreateResponse(campaign=CampaignResponse.model_validate(campaign), version=CampaignVersionResponse.model_validate(version)) + + +@router.post("/new", response_model=CampaignCreateResponse) +def create_minimal_campaign_endpoint( + payload: CampaignCreateMinimalRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaign:write")), +): + """Create a minimal editable campaign/version for the WebUI wizard. + + This is intentionally different from importing a complete campaign JSON. It + returns a normal Campaign + CampaignVersion whose version is a working copy + and can be autosaved while incomplete. + """ + + try: + campaign, version = create_minimal_campaign( + session, + tenant_id=principal.tenant_id, + user_id=principal.user.id, + external_id=payload.external_id, + name=payload.name, + description=payload.description, + current_flow=payload.current_flow, + current_step=payload.current_step, + ) + audit_from_principal( + session, + principal, + action="campaign.created_minimal", + object_type="campaign", + object_id=campaign.id, + details={"version_id": version.id, "external_id": campaign.external_id}, + commit=True, + ) + return CampaignCreateResponse(campaign=CampaignResponse.model_validate(campaign), version=CampaignVersionResponse.model_validate(version)) + except CampaignPersistenceError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + + +@router.get("", response_model=CampaignListResponse) +def list_campaigns( + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaign:read")), +): + campaigns = ( + session.query(Campaign) + .filter(Campaign.tenant_id == principal.tenant_id) + .order_by(Campaign.updated_at.desc()) + .all() + ) + return CampaignListResponse(campaigns=[CampaignResponse.model_validate(item) for item in campaigns]) + + +@router.get("/{campaign_id}", response_model=CampaignResponse) +def get_campaign( + campaign_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaign:read")), +): + return CampaignResponse.model_validate(_get_campaign_for_tenant(session, campaign_id, principal.tenant_id)) + + +@router.get("/{campaign_id}/versions", response_model=list[CampaignVersionResponse]) +def list_versions( + campaign_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaign:read")), +): + campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) + versions = ( + session.query(CampaignVersion) + .filter(CampaignVersion.campaign_id == campaign.id) + .order_by(CampaignVersion.version_number.desc()) + .all() + ) + return [CampaignVersionResponse.model_validate(item) for item in versions] + + +@router.get("/{campaign_id}/versions/{version_id}", response_model=CampaignVersionDetailResponse) +def get_version_detail( + campaign_id: str, + version_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaign:read")), +): + try: + version = get_campaign_version_for_tenant( + session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id + ) + return CampaignVersionDetailResponse.model_validate(version) + except CampaignPersistenceError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + +@router.put("/{campaign_id}/versions/{version_id}", response_model=CampaignVersionDetailResponse) +def update_version_detail( + campaign_id: str, + version_id: str, + payload: CampaignVersionUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaign:write")), +): + try: + version = update_campaign_version( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + raw_json=payload.campaign_json, + current_flow=payload.current_flow, + current_step=payload.current_step, + workflow_state=payload.workflow_state, + is_complete=payload.is_complete, + editor_state=payload.editor_state, + source_filename=payload.source_filename, + source_base_path=payload.source_base_path, + autosave=False, + ) + audit_from_principal( + session, + principal, + action="campaign.version_updated", + object_type="campaign_version", + object_id=version.id, + details={"campaign_id": campaign_id, "current_flow": version.current_flow, "current_step": version.current_step}, + commit=True, + ) + return CampaignVersionDetailResponse.model_validate(version) + except CampaignPersistenceError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + +@router.post("/{campaign_id}/versions/{version_id}/autosave", response_model=CampaignVersionDetailResponse) +def autosave_version( + campaign_id: str, + version_id: str, + payload: CampaignVersionUpdateRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaign:write")), +): + try: + version = update_campaign_version( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + raw_json=payload.campaign_json, + current_flow=payload.current_flow, + current_step=payload.current_step, + workflow_state=payload.workflow_state, + is_complete=payload.is_complete, + editor_state=payload.editor_state, + source_filename=payload.source_filename, + source_base_path=payload.source_base_path, + autosave=True, + ) + audit_from_principal( + session, + principal, + action="campaign.version_autosaved", + object_type="campaign_version", + object_id=version.id, + details={"campaign_id": campaign_id, "current_flow": version.current_flow, "current_step": version.current_step}, + commit=True, + ) + return CampaignVersionDetailResponse.model_validate(version) + except CampaignPersistenceError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + +@router.post("/{campaign_id}/versions/{version_id}/set-step", response_model=CampaignVersionDetailResponse) +def set_version_step( + campaign_id: str, + version_id: str, + payload: CampaignVersionSetStepRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaign:write")), +): + try: + version = update_campaign_version( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=version_id, + current_flow=payload.current_flow, + current_step=payload.current_step, + autosave=True, + ) + return CampaignVersionDetailResponse.model_validate(version) + except CampaignPersistenceError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + +@router.post("/{campaign_id}/versions/{version_id}/validate-partial", response_model=CampaignPartialValidationResponse) +def validate_version_partial( + campaign_id: str, + version_id: str, + payload: CampaignPartialValidationRequest | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaign:validate")), +): + try: + version = get_campaign_version_for_tenant( + session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id + ) + campaign_json = payload.campaign_json if payload and payload.campaign_json is not None else version.raw_json + result = validate_campaign_partial(campaign_json, section=payload.section if payload else None) + audit_from_principal( + session, + principal, + action="campaign.version_partially_validated", + object_type="campaign_version", + object_id=version.id, + details={"campaign_id": campaign_id, "section": result.get("section"), "ok": result.get("ok")}, + commit=True, + ) + return CampaignPartialValidationResponse(**result) + except CampaignPersistenceError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + +@router.post("/{campaign_id}/versions/{version_id}/publish", response_model=CampaignVersionDetailResponse) +def publish_version( + campaign_id: str, + version_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaign:write")), +): + try: + version = publish_campaign_version(session, tenant_id=principal.tenant_id, campaign_id=campaign_id, version_id=version_id) + audit_from_principal( + session, + principal, + action="campaign.version_published", + object_type="campaign_version", + object_id=version.id, + details={"campaign_id": campaign_id}, + commit=True, + ) + return CampaignVersionDetailResponse.model_validate(version) + except CampaignPersistenceError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + +@router.post("/versions/{version_id}/validate") +def validate_version( + version_id: str, + payload: ValidateCampaignRequest | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaign:validate")), +): + try: + result = validate_campaign_version( + session, + tenant_id=principal.tenant_id, + version_id=version_id, + check_files=payload.check_files if payload else False, + ) + audit_from_principal( + session, + principal, + action="campaign.validated", + object_type="campaign_version", + object_id=version_id, + details={"check_files": payload.check_files if payload else False, "ok": result.get("ok")}, + commit=True, + ) + return result + except CampaignPersistenceError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + +@router.post("/versions/{version_id}/build") +def build_version( + version_id: str, + payload: BuildCampaignRequest | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaign:build")), +): + try: + result = build_campaign_version( + session, + tenant_id=principal.tenant_id, + version_id=version_id, + write_eml=payload.write_eml if payload else True, + ) + audit_from_principal( + session, + principal, + action="campaign.messages_built", + object_type="campaign_version", + object_id=version_id, + details={"write_eml": payload.write_eml if payload else True, "built_count": result.get("built_count")}, + commit=True, + ) + return result + except CampaignPersistenceError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except Exception as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + +@router.get("/{campaign_id}/jobs", response_model=CampaignJobsResponse) +def list_jobs( + campaign_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaign:read")), +): + campaign = _get_campaign_for_tenant(session, campaign_id, principal.tenant_id) + jobs = ( + session.query(CampaignJob) + .filter(CampaignJob.campaign_id == campaign.id) + .order_by(CampaignJob.entry_index.asc()) + .all() + ) + return CampaignJobsResponse( + jobs=[ + { + "id": job.id, + "entry_index": job.entry_index, + "entry_id": job.entry_id, + "recipient_email": job.recipient_email, + "subject": job.subject, + "build_status": job.build_status, + "validation_status": job.validation_status, + "queue_status": job.queue_status, + "send_status": job.send_status, + "imap_status": job.imap_status, + "eml_local_path": job.eml_local_path, + "eml_size_bytes": job.eml_size_bytes, + "attempt_count": job.attempt_count, + "last_error": job.last_error, + "queued_at": job.queued_at, + "sent_at": job.sent_at, + "issues": job.issues_snapshot, + "attachments": job.resolved_attachments, + } + for job in jobs + ] + ) + + +@router.get("/{campaign_id}/summary") +def campaign_summary( + campaign_id: str, + include_jobs: bool = False, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaign:read")), +): + """Return dashboard-friendly campaign status counters and summaries.""" + + try: + return generate_campaign_report( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + include_jobs=include_jobs, + ) + except CampaignReportError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + +@router.get("/{campaign_id}/report") +def campaign_report( + campaign_id: str, + include_jobs: bool = True, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaign:read")), +): + """Return the full JSON report for one campaign.""" + + try: + return generate_campaign_report( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + include_jobs=include_jobs, + ) + except CampaignReportError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + + +@router.get("/{campaign_id}/report/jobs.csv") +def campaign_jobs_csv( + campaign_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaign:read")), +): + """Export per-job campaign status as CSV.""" + + try: + csv_text = generate_jobs_csv(session, tenant_id=principal.tenant_id, campaign_id=campaign_id) + except CampaignReportError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + return Response( + content=csv_text, + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": f'attachment; filename="campaign-{campaign_id}-jobs.csv"'}, + ) + + + + +@router.post("/{campaign_id}/report/email", response_model=ReportEmailResponse) +def email_campaign_report( + campaign_id: str, + payload: ReportEmailRequest, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("reports:send")), +): + """Generate a campaign report and send it to one or more email addresses.""" + + try: + result = send_campaign_report_email( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + to=payload.to, + include_jobs=payload.include_jobs, + attach_jobs_csv=payload.attach_jobs_csv, + attach_report_json=payload.attach_report_json, + dry_run=payload.dry_run, + ) + audit_from_principal( + session, + principal, + action="report.email_sent" if not payload.dry_run else "report.email_dry_run", + object_type="campaign", + object_id=campaign_id, + details=result.as_dict(), + commit=True, + ) + return ReportEmailResponse(result=result.as_dict()) + except CampaignReportError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc + except (CampaignReportEmailError, Exception) as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + +# Queue / delivery control ------------------------------------------------- +from app.api.v1.schemas import AppendSentRequest, CampaignActionResponse, QueueCampaignRequest, QueueCampaignResponse +from app.mailer.sending.jobs import ( + QueueingError, + cancel_campaign_jobs, + enqueue_pending_imap_appends, + pause_campaign_jobs, + queue_campaign_jobs, + resume_campaign_jobs, +) + + +@router.post("/{campaign_id}/queue", response_model=QueueCampaignResponse) +def queue_campaign( + campaign_id: str, + payload: QueueCampaignRequest | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaign:queue")), +): + payload = payload or QueueCampaignRequest() + try: + result = queue_campaign_jobs( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + version_id=payload.version_id, + include_warnings=payload.include_warnings, + enqueue_celery=payload.enqueue_celery, + dry_run=payload.dry_run, + ) + audit_from_principal( + session, + principal, + action="campaign.queued" if not payload.dry_run else "campaign.queue_dry_run", + object_type="campaign", + object_id=campaign_id, + details=result.as_dict(), + commit=True, + ) + return QueueCampaignResponse(**result.as_dict()) + except QueueingError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + +@router.post("/{campaign_id}/pause", response_model=CampaignActionResponse) +def pause_campaign( + campaign_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaign:queue")), +): + try: + result = pause_campaign_jobs(session, tenant_id=principal.tenant_id, campaign_id=campaign_id) + audit_from_principal(session, principal, action="campaign.paused", object_type="campaign", object_id=campaign_id, details=result, commit=True) + return CampaignActionResponse(result=result) + except QueueingError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + +@router.post("/{campaign_id}/resume", response_model=CampaignActionResponse) +def resume_campaign( + campaign_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaign:queue")), +): + try: + result = resume_campaign_jobs(session, tenant_id=principal.tenant_id, campaign_id=campaign_id) + audit_from_principal(session, principal, action="campaign.resumed", object_type="campaign", object_id=campaign_id, details=result, commit=True) + return CampaignActionResponse(result=result) + except QueueingError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + +@router.post("/{campaign_id}/cancel", response_model=CampaignActionResponse) +def cancel_campaign( + campaign_id: str, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaign:queue")), +): + try: + result = cancel_campaign_jobs(session, tenant_id=principal.tenant_id, campaign_id=campaign_id) + audit_from_principal(session, principal, action="campaign.cancelled", object_type="campaign", object_id=campaign_id, details=result, commit=True) + return CampaignActionResponse(result=result) + except QueueingError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc + + +@router.post("/{campaign_id}/append-sent", response_model=CampaignActionResponse) +def append_sent( + campaign_id: str, + payload: AppendSentRequest | None = None, + session: Session = Depends(get_session), + principal: ApiPrincipal = Depends(require_scope("campaign:send")), +): + payload = payload or AppendSentRequest() + try: + result = enqueue_pending_imap_appends( + session, + tenant_id=principal.tenant_id, + campaign_id=campaign_id, + enqueue_celery=payload.enqueue_celery, + dry_run=payload.dry_run, + ) + audit_from_principal( + session, + principal, + action="campaign.append_sent_enqueued" if not payload.dry_run else "campaign.append_sent_dry_run", + object_type="campaign", + object_id=campaign_id, + details=result, + commit=True, + ) + return CampaignActionResponse(result=result) + except QueueingError as exc: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc diff --git a/server/app/api/v1/schemas.py b/server/app/api/v1/schemas.py new file mode 100644 index 0000000..603e5fc --- /dev/null +++ b/server/app/api/v1/schemas.py @@ -0,0 +1,202 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +class CampaignCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + config: dict[str, Any] + source_filename: str | None = None + source_base_path: str | None = None + + +class CampaignCreateMinimalRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + external_id: str + name: str + description: str | None = None + current_flow: str = "create" + current_step: str = "basics" + + +class CampaignVersionUpdateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + campaign_json: dict[str, Any] | None = None + current_flow: str | None = None + current_step: str | None = None + workflow_state: str | None = None + is_complete: bool | None = None + editor_state: dict[str, Any] | None = None + source_filename: str | None = None + source_base_path: str | None = None + + +class CampaignVersionSetStepRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + current_flow: str | None = None + current_step: str + + +class CampaignPartialValidationRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + campaign_json: dict[str, Any] | None = None + section: str | None = None + + +class CampaignVersionResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + campaign_id: str + version_number: int + schema_version: str + source_filename: str | None = None + source_base_path: str | None = None + workflow_state: str = "editing" + current_flow: str = "manual" + current_step: str | None = None + is_complete: bool = False + editor_state: dict[str, Any] = Field(default_factory=dict) + autosaved_at: datetime | None = None + published_at: datetime | None = None + locked_at: datetime | None = None + locked_by_user_id: str | None = None + created_at: datetime + updated_at: datetime + validation_summary: dict[str, Any] | None = None + build_summary: dict[str, Any] | None = None + + +class CampaignVersionDetailResponse(CampaignVersionResponse): + raw_json: dict[str, Any] + + +class CampaignPartialValidationResponse(BaseModel): + ok: bool + section: str | None = None + error_count: int + warning_count: int + info_count: int + issues: list[dict[str, Any]] + + +class CampaignResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + external_id: str + name: str + description: str | None = None + status: str + current_version_id: str | None = None + created_at: datetime + updated_at: datetime + + +class CampaignCreateResponse(BaseModel): + campaign: CampaignResponse + version: CampaignVersionResponse + + +class CampaignListResponse(BaseModel): + campaigns: list[CampaignResponse] + + +class CampaignJobsResponse(BaseModel): + jobs: list[dict[str, Any]] + + +class ValidateCampaignRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + check_files: bool = False + + +class BuildCampaignRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + write_eml: bool = True + + +class ApiKeyCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str + scopes: list[str] = Field(default_factory=list) + + +class ApiKeyCreateResponse(BaseModel): + id: str + name: str + prefix: str + scopes: list[str] + secret: str + + +class QueueCampaignRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + version_id: str | None = None + include_warnings: bool = True + enqueue_celery: bool = True + dry_run: bool = False + + +class QueueCampaignResponse(BaseModel): + campaign_id: str + version_id: str + queued_count: int + skipped_count: int + blocked_count: int + enqueued_count: int + dry_run: bool = False + + +class AppendSentRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + enqueue_celery: bool = True + dry_run: bool = False + + +class CampaignActionResponse(BaseModel): + result: dict[str, Any] + +class ReportEmailRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + to: list[str] + include_jobs: bool = False + attach_jobs_csv: bool = True + attach_report_json: bool = False + dry_run: bool = False + + +class ReportEmailResponse(BaseModel): + result: dict[str, Any] + + +class AuditLogItemResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: str + tenant_id: str | None = None + user_id: str | None = None + api_key_id: str | None = None + action: str + object_type: str | None = None + object_id: str | None = None + details: dict[str, Any] | None = None + created_at: datetime + + +class AuditLogListResponse(BaseModel): + items: list[AuditLogItemResponse] diff --git a/server/app/api/v1/system.py b/server/app/api/v1/system.py new file mode 100644 index 0000000..8639936 --- /dev/null +++ b/server/app/api/v1/system.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from fastapi import APIRouter, Depends + +from app.auth.dependencies import ApiPrincipal, require_scope + +router = APIRouter(prefix="/schemas", tags=["schemas"]) + + +@router.get("/campaign") +def get_campaign_schema( + principal: ApiPrincipal = Depends(require_scope("campaign:read")), +) -> dict[str, Any]: + """Return the authoritative campaign JSON Schema used by the backend. + + The WebUI can fetch this instead of carrying a stale copy. A future UI + schema can be served alongside this endpoint. + """ + + schema_path = Path(__file__).resolve().parents[2] / "mailer" / "schemas" / "campaign.schema.json" + return json.loads(schema_path.read_text(encoding="utf-8")) diff --git a/server/app/audit/__init__.py b/server/app/audit/__init__.py new file mode 100644 index 0000000..f0ceae4 --- /dev/null +++ b/server/app/audit/__init__.py @@ -0,0 +1 @@ +"""Audit logging helpers.""" diff --git a/server/app/audit/logging.py b/server/app/audit/logging.py new file mode 100644 index 0000000..fe6b698 --- /dev/null +++ b/server/app/audit/logging.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from typing import Any + +from sqlalchemy.orm import Session + +from app.auth.dependencies import ApiPrincipal +from app.db.models import AuditLog + + +SENSITIVE_DETAIL_KEYS = { + "password", + "smtp_password", + "imap_password", + "secret", + "api_key", + "token", +} + + +def _sanitize_details(value: Any) -> Any: + if isinstance(value, dict): + sanitized: dict[str, Any] = {} + for key, item in value.items(): + if str(key).lower() in SENSITIVE_DETAIL_KEYS: + sanitized[key] = "" + else: + sanitized[key] = _sanitize_details(item) + return sanitized + if isinstance(value, list): + return [_sanitize_details(item) for item in value] + return value + + +def audit_event( + session: Session, + *, + tenant_id: str | None, + action: str, + user_id: str | None = None, + api_key_id: str | None = None, + object_type: str | None = None, + object_id: str | None = None, + details: dict[str, Any] | None = None, + commit: bool = False, +) -> AuditLog: + """Persist one audit event. + + The function deliberately accepts primitive IDs so it can be used from API + handlers, CLI commands and worker code without coupling the lower-level + services to FastAPI request objects. + """ + + item = AuditLog( + tenant_id=tenant_id, + user_id=user_id, + api_key_id=api_key_id, + action=action, + object_type=object_type, + object_id=object_id, + details=_sanitize_details(details or {}), + ) + session.add(item) + if commit: + session.commit() + else: + session.flush() + return item + + +def audit_from_principal( + session: Session, + principal: ApiPrincipal, + *, + action: str, + object_type: str | None = None, + object_id: str | None = None, + details: dict[str, Any] | None = None, + commit: bool = False, +) -> AuditLog: + return audit_event( + session, + tenant_id=principal.tenant_id, + user_id=principal.user.id, + api_key_id=principal.api_key.id, + action=action, + object_type=object_type, + object_id=object_id, + details=details, + commit=commit, + ) diff --git a/server/app/auth/__init__.py b/server/app/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app/auth/dependencies.py b/server/app/auth/dependencies.py new file mode 100644 index 0000000..9a62a0c --- /dev/null +++ b/server/app/auth/dependencies.py @@ -0,0 +1,52 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from fastapi import Depends, Header, HTTPException, status +from sqlalchemy.orm import Session + +from app.db.models import ApiKey, User +from app.db.session import get_session +from app.security.api_keys import authenticate_api_key, has_scope + + +@dataclass(slots=True) +class ApiPrincipal: + api_key: ApiKey + user: User + tenant_id: str + + +def _extract_api_key(authorization: str | None, x_api_key: str | None) -> str | None: + if x_api_key: + return x_api_key.strip() + if authorization and authorization.lower().startswith("bearer "): + return authorization[7:].strip() + return None + + +def get_api_principal( + session: Session = Depends(get_session), + authorization: str | None = Header(default=None), + x_api_key: str | None = Header(default=None, alias="X-API-Key"), +) -> ApiPrincipal: + secret = _extract_api_key(authorization, x_api_key) + if not secret: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing API key") + api_key = authenticate_api_key(session, secret) + if not api_key: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key") + user = session.get(User, api_key.user_id) + if not user or not user.is_active: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Inactive user") + session.commit() + return ApiPrincipal(api_key=api_key, user=user, tenant_id=api_key.tenant_id) + + +def require_scope(required_scope: str): + def dependency(principal: ApiPrincipal = Depends(get_api_principal)) -> ApiPrincipal: + if not has_scope(principal.api_key, required_scope): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=f"Missing scope: {required_scope}") + return principal + + return dependency diff --git a/server/app/celery_app.py b/server/app/celery_app.py new file mode 100644 index 0000000..a3b044a --- /dev/null +++ b/server/app/celery_app.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from celery import Celery + +from .settings import settings + +celery = Celery( + "multimailer", + broker=settings.redis_url, + backend=settings.redis_url, +) + +celery.conf.update( + task_default_queue="default", + task_routes={ + "multimailer.send_email": {"queue": "send_email"}, + "multimailer.append_sent": {"queue": "append_sent"}, + }, + worker_prefetch_multiplier=1, + task_acks_late=True, + task_reject_on_worker_lost=True, +) + + +@celery.task(name="multimailer.ping") +def ping(): + return "pong" + + +@celery.task(name="multimailer.send_email", bind=True, max_retries=None) +def send_email(self, job_id: str): + """Send one queued campaign job. + + The task records all state changes in the database. Temporary SMTP/network + failures are retried with the campaign's configured backoff. + """ + + from app.db.models import CampaignVersion, JobSendStatus + from app.db.session import SessionLocal + from app.mailer.persistence.campaigns import load_version_config + from app.mailer.sending.jobs import SendJobError, next_retry_delay, send_campaign_job + from app.mailer.sending.smtp import SmtpSendError + + with SessionLocal() as session: + try: + return send_campaign_job(session, job_id=job_id, enqueue_imap_task=True).as_dict() + except SmtpSendError as exc: + # send_campaign_job has already updated the job attempt/status. + from app.db.models import CampaignJob + + job = session.get(CampaignJob, job_id) + if job and job.send_status == JobSendStatus.FAILED_TEMPORARY.value: + version = session.get(CampaignVersion, job.campaign_version_id) + delay = 60 + if version: + try: + _, _, config = load_version_config(session, version.id) + delay = next_retry_delay(config, job.attempt_count) + except Exception: + delay = 60 + raise self.retry(exc=exc, countdown=delay) + raise + except SendJobError: + raise + + +@celery.task(name="multimailer.append_sent", bind=True, max_retries=None) +def append_sent(self, job_id: str): + """Append the exact sent MIME to the configured IMAP Sent folder.""" + + from app.db.session import SessionLocal + from app.mailer.sending.imap import ImapAppendError + from app.mailer.sending.jobs import append_sent_for_job + + with SessionLocal() as session: + try: + return append_sent_for_job(session, job_id=job_id).as_dict() + except ImapAppendError as exc: + if getattr(exc, "temporary", None) is True: + raise self.retry(exc=exc, countdown=300) + raise diff --git a/server/app/core/__init__.py b/server/app/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app/db/__init__.py b/server/app/db/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app/db/base.py b/server/app/db/base.py new file mode 100644 index 0000000..f19a692 --- /dev/null +++ b/server/app/db/base.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy import MetaData +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column +from sqlalchemy.types import DateTime + + +NAMING_CONVENTION = { + "ix": "ix_%(column_0_label)s", + "uq": "uq_%(table_name)s_%(column_0_name)s", + "ck": "ck_%(table_name)s_%(constraint_name)s", + "fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s", + "pk": "pk_%(table_name)s", +} + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class Base(DeclarativeBase): + metadata = MetaData(naming_convention=NAMING_CONVENTION) + + type_annotation_map = { + dict[str, Any]: __import__("sqlalchemy").JSON, + list[dict[str, Any]]: __import__("sqlalchemy").JSON, + list[str]: __import__("sqlalchemy").JSON, + } + + +class TimestampMixin: + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, nullable=False) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False) diff --git a/server/app/db/bootstrap.py b/server/app/db/bootstrap.py new file mode 100644 index 0000000..7bd844c --- /dev/null +++ b/server/app/db/bootstrap.py @@ -0,0 +1,96 @@ +from __future__ import annotations + +from dataclasses import dataclass + +from sqlalchemy.orm import Session + +from app.db.base import Base +from app.db.models import Role, Tenant, User +from app.db.session import engine +from app.security.api_keys import CreatedApiKey, create_api_key + +DEFAULT_SCOPES = [ + "campaign:read", + "campaign:write", + "campaign:validate", + "campaign:build", + "campaign:queue", + "campaign:send_test", + "campaign:send", + "attachments:read", + "attachments:write", + "reports:read", + "reports:send", + "audit:read", + "admin:users", + "admin:settings", +] + +DEFAULT_ROLES = { + "owner": ["*"], + "admin": [ + "campaign:read", "campaign:write", "campaign:validate", "campaign:build", + "campaign:queue", "campaign:send_test", "campaign:send", + "attachments:read", "attachments:write", "reports:read", "reports:send", "audit:read", + "admin:users", "admin:settings", + ], + "campaign_manager": ["campaign:read", "campaign:write", "campaign:validate", "campaign:build", "reports:read"], + "sender": ["campaign:read", "campaign:queue", "campaign:send_test", "campaign:send", "reports:read", "reports:send"], + "reviewer": ["campaign:read", "campaign:validate", "reports:read"], + "viewer": ["campaign:read", "reports:read"], + "auditor": ["campaign:read", "reports:read", "audit:read"], +} + + +@dataclass(slots=True) +class BootstrapResult: + tenant: Tenant + user: User + created_api_key: CreatedApiKey | None + + +def create_all_tables() -> None: + # Import models so SQLAlchemy sees all tables. + from app.db import models # noqa: F401 + + Base.metadata.create_all(bind=engine) + + +def bootstrap_dev_data( + session: Session, + *, + api_key_secret: str | None = None, + tenant_slug: str = "default", + user_email: str = "admin@example.local", +) -> BootstrapResult: + tenant = session.query(Tenant).filter(Tenant.slug == tenant_slug).one_or_none() + if tenant is None: + tenant = Tenant(slug=tenant_slug, name="Default Tenant") + session.add(tenant) + session.flush() + + for slug, permissions in DEFAULT_ROLES.items(): + role = session.query(Role).filter(Role.tenant_id == tenant.id, Role.slug == slug).one_or_none() + if role is None: + session.add(Role(tenant_id=tenant.id, slug=slug, name=slug.replace("_", " ").title(), permissions=permissions)) + + user = session.query(User).filter(User.tenant_id == tenant.id, User.email == user_email).one_or_none() + if user is None: + user = User(tenant_id=tenant.id, email=user_email, display_name="Development Admin", is_tenant_admin=True) + session.add(user) + session.flush() + + created_api_key = None + if api_key_secret: + existing = [key for key in user.api_keys if key.name == "Development API key" and key.revoked_at is None] + if not existing: + created_api_key = create_api_key( + session, + user=user, + name="Development API key", + scopes=["*"], + secret=api_key_secret, + ) + + session.commit() + return BootstrapResult(tenant=tenant, user=user, created_api_key=created_api_key) diff --git a/server/app/db/models.py b/server/app/db/models.py new file mode 100644 index 0000000..f0dfa47 --- /dev/null +++ b/server/app/db/models.py @@ -0,0 +1,335 @@ +from __future__ import annotations + +import uuid +from datetime import datetime +from enum import StrEnum +from typing import Any + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, JSON +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .base import Base, TimestampMixin + + +def new_uuid() -> str: + return str(uuid.uuid4()) + + +class CampaignStatus(StrEnum): + DRAFT = "draft" + VALIDATED = "validated" + NEEDS_REVIEW = "needs_review" + READY_TO_QUEUE = "ready_to_queue" + QUEUED = "queued" + SENDING = "sending" + SENT = "sent" + FAILED = "failed" + CANCELLED = "cancelled" + ARCHIVED = "archived" + + +class CampaignVersionWorkflowState(StrEnum): + EDITING = "editing" + UNDER_REVIEW = "under_review" + APPROVED = "approved" + BUILT = "built" + QUEUED = "queued" + SENDING = "sending" + COMPLETED = "completed" + CANCELLED = "cancelled" + ARCHIVED = "archived" + + +class CampaignVersionFlow(StrEnum): + CREATE = "create" + REVIEW = "review" + SEND = "send" + MANUAL = "manual" + JSON = "json" + + +class JobBuildStatus(StrEnum): + PENDING = "pending" + BUILT = "built" + BUILD_FAILED = "build_failed" + + +class JobValidationStatus(StrEnum): + READY = "ready" + WARNING = "warning" + NEEDS_REVIEW = "needs_review" + BLOCKED = "blocked" + EXCLUDED = "excluded" + INACTIVE = "inactive" + + +class JobQueueStatus(StrEnum): + DRAFT = "draft" + QUEUED = "queued" + SENDING = "sending" + PAUSED = "paused" + CANCELLED = "cancelled" + + +class JobSendStatus(StrEnum): + NOT_QUEUED = "not_queued" + QUEUED = "queued" + SENDING = "sending" + SENT = "sent" + FAILED_TEMPORARY = "failed_temporary" + FAILED_PERMANENT = "failed_permanent" + CANCELLED = "cancelled" + + +class JobImapStatus(StrEnum): + NOT_REQUESTED = "not_requested" + PENDING = "pending" + APPENDED = "appended" + FAILED = "failed" + SKIPPED = "skipped" + + +class IssueSeverity(StrEnum): + INFO = "info" + WARNING = "warning" + ERROR = "error" + + +class Tenant(Base, TimestampMixin): + __tablename__ = "tenants" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + slug: Mapped[str] = mapped_column(String(100), unique=True, nullable=False, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + users: Mapped[list[User]] = relationship(back_populates="tenant", cascade="all, delete-orphan") + campaigns: Mapped[list[Campaign]] = relationship(back_populates="tenant", cascade="all, delete-orphan") + + +class User(Base, TimestampMixin): + __tablename__ = "users" + __table_args__ = (UniqueConstraint("tenant_id", "email", name="uq_users_tenant_email"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + email: Mapped[str] = mapped_column(String(320), nullable=False, index=True) + display_name: Mapped[str | None] = mapped_column(String(255)) + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + is_tenant_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + tenant: Mapped[Tenant] = relationship(back_populates="users") + api_keys: Mapped[list[ApiKey]] = relationship(back_populates="user", cascade="all, delete-orphan") + + +class Group(Base, TimestampMixin): + __tablename__ = "groups" + __table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_groups_tenant_slug"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + slug: Mapped[str] = mapped_column(String(100), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + + +class Role(Base, TimestampMixin): + __tablename__ = "roles" + __table_args__ = (UniqueConstraint("tenant_id", "slug", name="uq_roles_tenant_slug"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True) + slug: Mapped[str] = mapped_column(String(100), nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + permissions: Mapped[list[str]] = mapped_column(JSON, default=list) + + +class ApiKey(Base, TimestampMixin): + __tablename__ = "api_keys" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + user_id: Mapped[str] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + prefix: Mapped[str] = mapped_column(String(16), nullable=False, index=True) + key_hash: Mapped[str] = mapped_column(String(128), nullable=False) + scopes: Mapped[list[str]] = mapped_column(JSON, default=list) + expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + revoked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + user: Mapped[User] = relationship(back_populates="api_keys") + + +class Campaign(Base, TimestampMixin): + __tablename__ = "campaigns" + __table_args__ = (UniqueConstraint("tenant_id", "external_id", name="uq_campaigns_tenant_external_id"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + created_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + external_id: Mapped[str] = mapped_column(String(255), nullable=False, index=True) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(Text) + status: Mapped[str] = mapped_column(String(50), default=CampaignStatus.DRAFT.value, nullable=False, index=True) + current_version_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + + tenant: Mapped[Tenant] = relationship(back_populates="campaigns") + versions: Mapped[list[CampaignVersion]] = relationship(back_populates="campaign", cascade="all, delete-orphan") + jobs: Mapped[list[CampaignJob]] = relationship(back_populates="campaign", cascade="all, delete-orphan") + + +class CampaignVersion(Base, TimestampMixin): + __tablename__ = "campaign_versions" + __table_args__ = (UniqueConstraint("campaign_id", "version_number", name="uq_campaign_versions_campaign_number"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True) + version_number: Mapped[int] = mapped_column(Integer, nullable=False) + raw_json: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + schema_version: Mapped[str] = mapped_column(String(50), default="1.0", nullable=False) + source_filename: Mapped[str | None] = mapped_column(String(500)) + source_base_path: Mapped[str | None] = mapped_column(String(1000)) + + # Editor/workflow metadata used by the WebUI and future desktop clients. + # A campaign version can be the autosaved working copy of a new or existing + # campaign, so no separate CampaignDraft entity is needed. + workflow_state: Mapped[str] = mapped_column( + String(50), + default=CampaignVersionWorkflowState.EDITING.value, + nullable=False, + index=True, + ) + current_flow: Mapped[str] = mapped_column( + String(50), + default=CampaignVersionFlow.MANUAL.value, + nullable=False, + index=True, + ) + current_step: Mapped[str | None] = mapped_column(String(100)) + is_complete: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + editor_state: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False) + autosaved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + locked_by_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + + validation_summary: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) + build_summary: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) + + campaign: Mapped[Campaign] = relationship(back_populates="versions") + + +class CampaignJob(Base, TimestampMixin): + __tablename__ = "campaign_jobs" + __table_args__ = (UniqueConstraint("campaign_version_id", "entry_index", name="uq_campaign_jobs_version_entry"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True) + campaign_version_id: Mapped[str] = mapped_column(ForeignKey("campaign_versions.id", ondelete="CASCADE"), nullable=False, index=True) + entry_index: Mapped[int] = mapped_column(Integer, nullable=False) + entry_id: Mapped[str | None] = mapped_column(String(255), index=True) + + recipient_email: Mapped[str | None] = mapped_column(String(320), index=True) + subject: Mapped[str | None] = mapped_column(String(998)) + message_id_header: Mapped[str | None] = mapped_column(String(255)) + eml_storage_key: Mapped[str | None] = mapped_column(String(1000)) + eml_local_path: Mapped[str | None] = mapped_column(String(1000)) + eml_size_bytes: Mapped[int | None] = mapped_column(Integer) + + build_status: Mapped[str] = mapped_column(String(50), default=JobBuildStatus.PENDING.value, nullable=False, index=True) + validation_status: Mapped[str] = mapped_column(String(50), default=JobValidationStatus.NEEDS_REVIEW.value, nullable=False, index=True) + queue_status: Mapped[str] = mapped_column(String(50), default=JobQueueStatus.DRAFT.value, nullable=False, index=True) + send_status: Mapped[str] = mapped_column(String(50), default=JobSendStatus.NOT_QUEUED.value, nullable=False, index=True) + imap_status: Mapped[str] = mapped_column(String(50), default=JobImapStatus.NOT_REQUESTED.value, nullable=False, index=True) + + attempt_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False) + last_error: Mapped[str | None] = mapped_column(Text) + queued_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + resolved_recipients: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) + resolved_attachments: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list) + issues_snapshot: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list) + + campaign: Mapped[Campaign] = relationship(back_populates="jobs") + + +class CampaignIssue(Base, TimestampMixin): + __tablename__ = "campaign_issues" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + campaign_id: Mapped[str] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=False, index=True) + campaign_version_id: Mapped[str | None] = mapped_column(ForeignKey("campaign_versions.id", ondelete="CASCADE"), nullable=True, index=True) + job_id: Mapped[str | None] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="CASCADE"), nullable=True, index=True) + severity: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + code: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + message: Mapped[str] = mapped_column(Text, nullable=False) + source: Mapped[str | None] = mapped_column(String(255)) + behavior: Mapped[str | None] = mapped_column(String(50)) + + +class AttachmentBlob(Base, TimestampMixin): + __tablename__ = "attachment_blobs" + __table_args__ = (UniqueConstraint("tenant_id", "sha256", name="uq_attachment_blobs_tenant_sha256"),) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + size_bytes: Mapped[int] = mapped_column(Integer, nullable=False) + mime_type: Mapped[str | None] = mapped_column(String(255)) + storage_bucket: Mapped[str] = mapped_column(String(255), nullable=False) + storage_key: Mapped[str] = mapped_column(String(1000), nullable=False) + + +class AttachmentInstance(Base, TimestampMixin): + __tablename__ = "attachment_instances" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + owner_user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + campaign_id: Mapped[str | None] = mapped_column(ForeignKey("campaigns.id", ondelete="CASCADE"), nullable=True, index=True) + blob_id: Mapped[str] = mapped_column(ForeignKey("attachment_blobs.id", ondelete="CASCADE"), nullable=False, index=True) + logical_name: Mapped[str | None] = mapped_column(String(500)) + filename: Mapped[str] = mapped_column(String(500), nullable=False) + tags: Mapped[list[str]] = mapped_column(JSON, default=list) + metadata_: Mapped[dict[str, Any] | None] = mapped_column("metadata", JSON, nullable=True) + + +class SendAttempt(Base, TimestampMixin): + __tablename__ = "send_attempts" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + job_id: Mapped[str] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="CASCADE"), nullable=False, index=True) + attempt_number: Mapped[int] = mapped_column(Integer, nullable=False) + smtp_status_code: Mapped[int | None] = mapped_column(Integer) + smtp_response: Mapped[str | None] = mapped_column(Text) + error_type: Mapped[str | None] = mapped_column(String(255)) + error_message: Mapped[str | None] = mapped_column(Text) + started_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + + +class ImapAppendAttempt(Base, TimestampMixin): + __tablename__ = "imap_append_attempts" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + job_id: Mapped[str] = mapped_column(ForeignKey("campaign_jobs.id", ondelete="CASCADE"), nullable=False, index=True) + attempt_number: Mapped[int] = mapped_column(Integer, nullable=False) + folder: Mapped[str | None] = mapped_column(String(500)) + status: Mapped[str] = mapped_column(String(50), nullable=False) + error_message: Mapped[str | None] = mapped_column(Text) + + +class AuditLog(Base, TimestampMixin): + __tablename__ = "audit_log" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + tenant_id: Mapped[str | None] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=True, index=True) + user_id: Mapped[str | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True) + api_key_id: Mapped[str | None] = mapped_column(ForeignKey("api_keys.id", ondelete="SET NULL"), nullable=True, index=True) + action: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + object_type: Mapped[str | None] = mapped_column(String(100), index=True) + object_id: Mapped[str | None] = mapped_column(String(100), index=True) + details: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) diff --git a/server/app/db/session.py b/server/app/db/session.py new file mode 100644 index 0000000..4f7d312 --- /dev/null +++ b/server/app/db/session.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from collections.abc import Generator + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session, sessionmaker + +from app.settings import settings + +connect_args = {"check_same_thread": False} if settings.database_url.startswith("sqlite") else {} +engine = create_engine(settings.database_url, pool_pre_ping=True, connect_args=connect_args) +SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, expire_on_commit=False) + + +def get_session() -> Generator[Session, None, None]: + with SessionLocal() as session: + yield session diff --git a/server/app/mailer/__init__.py b/server/app/mailer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app/mailer/attachments/__init__.py b/server/app/mailer/attachments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app/mailer/attachments/resolver.py b/server/app/mailer/attachments/resolver.py new file mode 100644 index 0000000..9d10f98 --- /dev/null +++ b/server/app/mailer/attachments/resolver.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +import fnmatch +from enum import StrEnum +from pathlib import Path +from typing import Any, Iterable + +from pydantic import BaseModel, ConfigDict, Field + +from app.mailer.campaign.entries import load_campaign_entries +from app.mailer.campaign.models import AttachmentConfig, Behavior, CampaignConfig, EntryConfig + + +class AttachmentScope(StrEnum): + GLOBAL = "global" + ENTRY = "entry" + + +class AttachmentMatchStatus(StrEnum): + OK = "ok" + MISSING = "missing" + AMBIGUOUS = "ambiguous" + + +class MessageAttachmentStatus(StrEnum): + READY = "ready" + WARNING = "warning" + NEEDS_REVIEW = "needs_review" + BLOCKED = "blocked" + EXCLUDED = "excluded" + INACTIVE = "inactive" + + +class ResolutionSeverity(StrEnum): + INFO = "info" + WARNING = "warning" + ERROR = "error" + + +class AttachmentIssue(BaseModel): + model_config = ConfigDict(extra="forbid") + + severity: ResolutionSeverity + code: str + message: str + behavior: Behavior | None = None + + +class ResolvedAttachment(BaseModel): + model_config = ConfigDict(extra="forbid") + + scope: AttachmentScope + index: int + attachment_id: str | None = None + label: str | None = None + base_dir_template: str + file_filter_template: str + base_dir: str + file_filter: str + directory: str + include_subdirs: bool + required: bool + allow_multiple: bool + zip_enabled: bool + status: AttachmentMatchStatus + behavior: Behavior | None = None + matches: list[str] = Field(default_factory=list) + issues: list[AttachmentIssue] = Field(default_factory=list) + + +class EntryAttachmentResolution(BaseModel): + model_config = ConfigDict(extra="forbid") + + entry_index: int + entry_id: str | None = None + active: bool + status: MessageAttachmentStatus + attachments: list[ResolvedAttachment] = Field(default_factory=list) + issues: list[AttachmentIssue] = Field(default_factory=list) + + @property + def match_count(self) -> int: + return sum(len(item.matches) for item in self.attachments) + + +class AttachmentResolutionReport(BaseModel): + model_config = ConfigDict(extra="forbid") + + campaign_id: str + campaign_name: str + campaign_file: str + attachments_base_path: str + entries_count: int + entries: list[EntryAttachmentResolution] = Field(default_factory=list) + + @property + def ready_count(self) -> int: + return sum(1 for entry in self.entries if entry.status == MessageAttachmentStatus.READY) + + @property + def warning_count(self) -> int: + return sum(1 for entry in self.entries if entry.status == MessageAttachmentStatus.WARNING) + + @property + def needs_review_count(self) -> int: + return sum(1 for entry in self.entries if entry.status == MessageAttachmentStatus.NEEDS_REVIEW) + + @property + def blocked_count(self) -> int: + return sum(1 for entry in self.entries if entry.status == MessageAttachmentStatus.BLOCKED) + + @property + def excluded_count(self) -> int: + return sum(1 for entry in self.entries if entry.status == MessageAttachmentStatus.EXCLUDED) + + @property + def inactive_count(self) -> int: + return sum(1 for entry in self.entries if entry.status == MessageAttachmentStatus.INACTIVE) + + +def _resolve_path(campaign_file: str | Path, raw_path: str) -> Path: + campaign_path = Path(campaign_file).resolve() + path = Path(raw_path).expanduser() + if path.is_absolute(): + return path + return (campaign_path.parent / path).resolve() + + +def _render_template(template: str, values: dict[str, Any]) -> str: + rendered = template + for key, value in values.items(): + rendered = rendered.replace("${" + key + "}", "" if value is None else str(value)) + return rendered + + +def _recipient_values(entry: EntryConfig) -> dict[str, str]: + values: dict[str, str] = {} + for list_name in ["to", "cc", "bcc", "reply_to", "bounce_to", "disposition_notification_to"]: + recipients = getattr(entry, list_name) + for index, recipient in enumerate(recipients): + prefix = f"{list_name}.{index}" + values[f"local::{prefix}.email"] = recipient.email + values[f"local::{prefix}.name"] = recipient.name or "" + values[f"local::{prefix}.type"] = recipient.recipient_type.value + if entry.from_: + values["local::from.email"] = entry.from_.email + values["local::from.name"] = entry.from_.name or "" + values["local::from.type"] = entry.from_.recipient_type.value + return values + + +def _template_values(config: CampaignConfig, entry: EntryConfig) -> dict[str, Any]: + values: dict[str, Any] = {} + for key, value in config.global_values.items(): + values[f"global::{key}"] = value + for key, value in entry.fields.items(): + values[f"local::{key}"] = value + if entry.id: + values["local::id"] = entry.id + values["local::active"] = entry.active + values.update(_recipient_values(entry)) + return values + + +def _iter_effective_attachment_configs(config: CampaignConfig, entry: EntryConfig) -> Iterable[tuple[AttachmentScope, int, AttachmentConfig]]: + if entry.combine_attachments: + for index, attachment_config in enumerate(config.attachments.global_): + yield AttachmentScope.GLOBAL, index, attachment_config + if config.attachments.allow_individual: + for index, attachment_config in enumerate(entry.attachments): + yield AttachmentScope.ENTRY, index, attachment_config + + +def _match_files(directory: Path, file_filter: str, include_subdirs: bool) -> list[Path]: + if not directory.exists() or not directory.is_dir(): + return [] + if include_subdirs: + # pathlib.rglob accepts glob patterns, but fnmatch keeps behavior predictable + # when file_filter is supplied as the Java-style filter portion only. + return sorted(path for path in directory.rglob("*") if path.is_file() and fnmatch.fnmatch(path.name, file_filter)) + return sorted(path for path in directory.glob(file_filter) if path.is_file()) + + +def _issue_for_missing(config: AttachmentConfig, behavior: Behavior) -> AttachmentIssue: + code = "missing_required_attachment" if config.required else "missing_optional_attachment" + severity = ResolutionSeverity.ERROR if config.required and behavior == Behavior.BLOCK else ResolutionSeverity.WARNING + return AttachmentIssue( + severity=severity, + code=code, + message=f"No file matched attachment filter {config.file_filter!r}", + behavior=behavior, + ) + + +def _issue_for_ambiguous(config: AttachmentConfig, behavior: Behavior, match_count: int) -> AttachmentIssue: + severity = ResolutionSeverity.ERROR if behavior == Behavior.BLOCK else ResolutionSeverity.WARNING + return AttachmentIssue( + severity=severity, + code="ambiguous_attachment_match", + message=f"Attachment filter {config.file_filter!r} matched {match_count} files, but allow_multiple is false", + behavior=behavior, + ) + + +def _resolve_one_config( + *, + campaign_file: str | Path, + attachments_base_path: Path, + values: dict[str, Any], + scope: AttachmentScope, + index: int, + config: AttachmentConfig, +) -> ResolvedAttachment: + rendered_base_dir = _render_template(config.base_dir, values) + rendered_file_filter = _render_template(config.file_filter, values) + directory = (attachments_base_path / rendered_base_dir).resolve() + matches = _match_files(directory, rendered_file_filter, config.include_subdirs) + + issues: list[AttachmentIssue] = [] + behavior: Behavior | None = None + + if not matches: + status = AttachmentMatchStatus.MISSING + behavior = config.missing_behavior + issues.append(_issue_for_missing(config, behavior)) + elif len(matches) > 1 and not config.allow_multiple: + status = AttachmentMatchStatus.AMBIGUOUS + behavior = config.ambiguous_behavior + issues.append(_issue_for_ambiguous(config, behavior, len(matches))) + else: + status = AttachmentMatchStatus.OK + + return ResolvedAttachment( + scope=scope, + index=index, + attachment_id=config.id, + label=config.label, + base_dir_template=config.base_dir, + file_filter_template=config.file_filter, + base_dir=rendered_base_dir, + file_filter=rendered_file_filter, + directory=str(directory), + include_subdirs=config.include_subdirs, + required=config.required, + allow_multiple=config.allow_multiple, + zip_enabled=config.zip.enabled, + status=status, + behavior=behavior, + matches=[str(path) for path in matches], + issues=issues, + ) + + +def _status_from_issues(active: bool, issues: list[AttachmentIssue]) -> MessageAttachmentStatus: + if not active: + return MessageAttachmentStatus.INACTIVE + behaviors = {issue.behavior for issue in issues if issue.behavior is not None} + if Behavior.BLOCK in behaviors: + return MessageAttachmentStatus.BLOCKED + if Behavior.DROP in behaviors: + return MessageAttachmentStatus.EXCLUDED + if Behavior.ASK in behaviors: + return MessageAttachmentStatus.NEEDS_REVIEW + if Behavior.WARN in behaviors: + return MessageAttachmentStatus.WARNING + return MessageAttachmentStatus.READY + + +def resolve_entry_attachments( + *, + config: CampaignConfig, + campaign_file: str | Path, + entry: EntryConfig, + entry_index: int, +) -> EntryAttachmentResolution: + attachments_base_path = _resolve_path(campaign_file, config.attachments.base_path) + values = _template_values(config, entry) + resolved: list[ResolvedAttachment] = [] + + if entry.active: + for scope, index, attachment_config in _iter_effective_attachment_configs(config, entry): + resolved.append( + _resolve_one_config( + campaign_file=campaign_file, + attachments_base_path=attachments_base_path, + values=values, + scope=scope, + index=index, + config=attachment_config, + ) + ) + + issues = [issue for item in resolved for issue in item.issues] + return EntryAttachmentResolution( + entry_index=entry_index, + entry_id=entry.id, + active=entry.active, + status=_status_from_issues(entry.active, issues), + attachments=resolved, + issues=issues, + ) + + +def resolve_campaign_attachments(config: CampaignConfig, *, campaign_file: str | Path) -> AttachmentResolutionReport: + entries = load_campaign_entries(config, campaign_file=campaign_file) + base_path = _resolve_path(campaign_file, config.attachments.base_path) + resolved_entries = [ + resolve_entry_attachments(config=config, campaign_file=campaign_file, entry=entry, entry_index=index) + for index, entry in enumerate(entries, start=1) + ] + return AttachmentResolutionReport( + campaign_id=config.campaign.id, + campaign_name=config.campaign.name, + campaign_file=str(Path(campaign_file).resolve()), + attachments_base_path=str(base_path), + entries_count=len(entries), + entries=resolved_entries, + ) diff --git a/server/app/mailer/campaign/__init__.py b/server/app/mailer/campaign/__init__.py new file mode 100644 index 0000000..96c2d09 --- /dev/null +++ b/server/app/mailer/campaign/__init__.py @@ -0,0 +1,14 @@ +"""Campaign JSON model, loading and validation helpers.""" + +from .models import CampaignConfig +from .loader import load_campaign_config, load_campaign_json +from .validation import validate_campaign_config, SemanticIssue, SemanticReport + +__all__ = [ + "CampaignConfig", + "load_campaign_config", + "load_campaign_json", + "validate_campaign_config", + "SemanticIssue", + "SemanticReport", +] diff --git a/server/app/mailer/campaign/entries.py b/server/app/mailer/campaign/entries.py new file mode 100644 index 0000000..e92847a --- /dev/null +++ b/server/app/mailer/campaign/entries.py @@ -0,0 +1,215 @@ +from __future__ import annotations + +import copy +import csv +import json +from pathlib import Path +from typing import Any, Iterable + +from .models import AttachmentConfig, CampaignConfig, EntryConfig, RecipientConfig, SourceType + + +class EntryLoadError(ValueError): + """Raised when campaign entries cannot be loaded from inline or external sources.""" + + +def _resolve(campaign_file: str | Path, raw_path: str) -> Path: + campaign_path = Path(campaign_file).resolve() + path = Path(raw_path).expanduser() + if path.is_absolute(): + return path + return (campaign_path.parent / path).resolve() + + +def _parse_bool(value: Any) -> bool: + if isinstance(value, bool): + return value + if value is None: + return False + text = str(value).strip().lower() + if text in {"1", "true", "yes", "y", "ja", "j", "x", "active", "aktiv"}: + return True + if text in {"0", "false", "no", "n", "nein", "", "inactive", "inaktiv"}: + return False + raise EntryLoadError(f"cannot parse boolean value: {value!r}") + + +def _parse_scalar_for_target(target: str, value: Any) -> Any: + bool_targets = { + "active", + "combine_to", + "combine_cc", + "combine_bcc", + "combine_reply_to", + "combine_bounce_to", + "combine_disposition_notification_to", + "combine_attachments", + } + if target in bool_targets: + return _parse_bool(value) + if target.endswith(".include_subdirs") or target.endswith(".required") or target.endswith(".allow_multiple"): + return _parse_bool(value) + if target.endswith(".zip.enabled"): + return _parse_bool(value) + return value + + +def _ensure_list_length(values: list[Any], index: int, factory: Any) -> None: + while len(values) <= index: + values.append(factory()) + + +def _set_recipient_value(entry_data: dict[str, Any], target: str, value: Any) -> bool: + # Examples: from.email, to.0.email, cc.0.name + if target.startswith("from."): + entry_data.setdefault("from", {}) + _, field = target.split(".", 1) + if field == "type": + field = "type" + entry_data["from"][field] = value + return True + + for recipient_list_name in ["to", "cc", "bcc", "reply_to", "bounce_to", "disposition_notification_to"]: + prefix = recipient_list_name + "." + if not target.startswith(prefix): + continue + parts = target.split(".") + if len(parts) != 3 or not parts[1].isdigit(): + raise EntryLoadError(f"invalid recipient mapping target: {target}") + index = int(parts[1]) + field = parts[2] + recipients = entry_data.setdefault(recipient_list_name, []) + _ensure_list_length(recipients, index, dict) + recipients[index][field] = value + return True + return False + + +def _set_attachment_value(entry_data: dict[str, Any], target: str, value: Any) -> bool: + if not target.startswith("attachments."): + return False + parts = target.split(".") + if len(parts) < 3 or not parts[1].isdigit(): + raise EntryLoadError(f"invalid attachment mapping target: {target}") + + index = int(parts[1]) + attachments = entry_data.setdefault("attachments", []) + _ensure_list_length(attachments, index, dict) + attachment = attachments[index] + + if parts[2] == "zip": + if len(parts) != 4: + raise EntryLoadError(f"invalid zip attachment mapping target: {target}") + attachment.setdefault("zip", {})[parts[3]] = value + return True + + if len(parts) != 3: + raise EntryLoadError(f"invalid attachment mapping target: {target}") + attachment[parts[2]] = value + return True + + +def _set_entry_value(entry_data: dict[str, Any], target: str, value: Any) -> None: + value = _parse_scalar_for_target(target, value) + if value is None: + return + if isinstance(value, str) and value == "": + return + + if target.startswith("fields."): + _, field_name = target.split(".", 1) + entry_data.setdefault("fields", {})[field_name] = value + return + + if _set_recipient_value(entry_data, target, value): + return + if _set_attachment_value(entry_data, target, value): + return + + entry_data[target] = value + + +def _entry_defaults_data(config: CampaignConfig) -> dict[str, Any]: + if config.entries.defaults is None: + return {} + return config.entries.defaults.model_dump(mode="json", by_alias=True, exclude_none=True) + + +def _load_csv_rows(path: Path, *, delimiter: str, encoding: str) -> list[dict[str, Any]]: + try: + with path.open("r", encoding=encoding, newline="") as handle: + reader = csv.DictReader(handle, delimiter=delimiter) + return [dict(row) for row in reader] + except OSError as exc: + raise EntryLoadError(f"could not read CSV entries source {path}: {exc}") from exc + + +def _load_json_rows(path: Path, *, encoding: str) -> list[dict[str, Any]]: + try: + with path.open("r", encoding=encoding) as handle: + data = json.load(handle) + except OSError as exc: + raise EntryLoadError(f"could not read JSON entries source {path}: {exc}") from exc + except json.JSONDecodeError as exc: + raise EntryLoadError(f"invalid JSON entries source {path}: {exc}") from exc + + if isinstance(data, list): + rows = data + elif isinstance(data, dict) and isinstance(data.get("entries"), list): + rows = data["entries"] + else: + raise EntryLoadError("JSON entries source must be a list or an object with an 'entries' list") + + if not all(isinstance(row, dict) for row in rows): + raise EntryLoadError("JSON entries source rows must be objects") + return [dict(row) for row in rows] + + +def _row_to_entry(defaults_data: dict[str, Any], mapping: dict[str, str], row: dict[str, Any], row_number: int) -> EntryConfig: + entry_data = copy.deepcopy(defaults_data) + for target, source_name in mapping.items(): + if source_name not in row: + # Detailed missing-column validation is handled in semantic validation. + continue + try: + _set_entry_value(entry_data, target, row[source_name]) + except EntryLoadError: + raise + except Exception as exc: + raise EntryLoadError(f"row {row_number}: could not map {source_name!r} to {target!r}: {exc}") from exc + try: + return EntryConfig.model_validate(entry_data) + except Exception as exc: + raise EntryLoadError(f"row {row_number}: mapped entry is invalid: {exc}") from exc + + +def load_campaign_entries(config: CampaignConfig, *, campaign_file: str | Path) -> list[EntryConfig]: + """Load and normalize campaign entries from inline data or external CSV/JSON source. + + The normalized output is always a list of EntryConfig instances. This is intentionally + UI/API friendly: a future web interface can generate the same JSON structure and use the + same resolver without code changes. + """ + + if config.entries.inline is not None: + return list(config.entries.inline) + + if config.entries.source is None or config.entries.mapping is None: + raise EntryLoadError("external entries require source and mapping") + + source = config.entries.source + path = _resolve(campaign_file, source.path) + if not path.exists(): + raise EntryLoadError(f"entries source file does not exist: {path}") + + if source.type == SourceType.CSV: + if not source.has_header: + raise EntryLoadError("CSV entries currently require has_header=true") + rows = _load_csv_rows(path, delimiter=source.delimiter, encoding=source.encoding) + elif source.type == SourceType.JSON: + rows = _load_json_rows(path, encoding=source.encoding) + else: # pragma: no cover - defensive; Pydantic constrains this already. + raise EntryLoadError(f"unsupported entries source type: {source.type}") + + defaults_data = _entry_defaults_data(config) + return [_row_to_entry(defaults_data, config.entries.mapping, row, index + 2) for index, row in enumerate(rows)] diff --git a/server/app/mailer/campaign/loader.py b/server/app/mailer/campaign/loader.py new file mode 100644 index 0000000..7721810 --- /dev/null +++ b/server/app/mailer/campaign/loader.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from jsonschema import Draft202012Validator, FormatChecker + +from .models import CampaignConfig + + +class CampaignLoadError(ValueError): + """Raised when the campaign JSON cannot be loaded or parsed.""" + + +@dataclass(frozen=True) +class SchemaValidationError: + path: str + message: str + + +class CampaignSchemaError(CampaignLoadError): + def __init__(self, errors: list[SchemaValidationError]) -> None: + self.errors = errors + details = "; ".join(f"{error.path}: {error.message}" for error in errors[:5]) + if len(errors) > 5: + details += f"; ... and {len(errors) - 5} more" + super().__init__(f"campaign schema validation failed: {details}") + + +def load_campaign_json(path: str | Path) -> dict[str, Any]: + campaign_path = Path(path) + try: + with campaign_path.open("r", encoding="utf-8") as handle: + data = json.load(handle) + except OSError as exc: + raise CampaignLoadError(f"could not read campaign JSON {campaign_path}: {exc}") from exc + except json.JSONDecodeError as exc: + raise CampaignLoadError(f"invalid campaign JSON {campaign_path}: {exc}") from exc + if not isinstance(data, dict): + raise CampaignLoadError("campaign JSON root must be an object") + return data + + +def _default_schema_path() -> Path: + return Path(__file__).resolve().parents[1] / "schema" / "campaign.schema.json" + + +def load_campaign_schema(schema_path: str | Path | None = None) -> dict[str, Any]: + path = Path(schema_path) if schema_path else _default_schema_path() + return load_campaign_json(path) + + +def validate_against_schema(data: dict[str, Any], schema_path: str | Path | None = None) -> None: + schema = load_campaign_schema(schema_path) + validator = Draft202012Validator(schema, format_checker=FormatChecker()) + errors = sorted(validator.iter_errors(data), key=lambda error: list(error.path)) + if errors: + normalized = [ + SchemaValidationError( + path="/" + "/".join(str(part) for part in error.absolute_path), + message=error.message, + ) + for error in errors + ] + raise CampaignSchemaError(normalized) + + +def load_campaign_config( + path: str | Path, + *, + validate_schema: bool = True, + schema_path: str | Path | None = None, +) -> CampaignConfig: + data = load_campaign_json(path) + if validate_schema: + validate_against_schema(data, schema_path=schema_path) + return CampaignConfig.model_validate(data) diff --git a/server/app/mailer/campaign/models.py b/server/app/mailer/campaign/models.py new file mode 100644 index 0000000..93e24f1 --- /dev/null +++ b/server/app/mailer/campaign/models.py @@ -0,0 +1,363 @@ +from __future__ import annotations + +from enum import StrEnum +from pathlib import Path +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator + + +class StrictModel(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + +class CampaignMode(StrEnum): + DRAFT = "draft" + TEST = "test" + SEND = "send" + + +class FieldType(StrEnum): + STRING = "string" + INTEGER = "integer" + DOUBLE = "double" + DATE = "date" + PASSWORD = "password" + + +class TransportSecurity(StrEnum): + PLAIN = "plain" + TLS = "tls" + STARTTLS = "starttls" + + +class RecipientType(StrEnum): + TO = "to" + CC = "cc" + BCC = "bcc" + REPLY_TO = "reply_to" + BOUNCE_TO = "bounce_to" + DISPOSITION_NOTIFICATION_TO = "disposition_notification_to" + + +class Behavior(StrEnum): + BLOCK = "block" + ASK = "ask" + DROP = "drop" + CONTINUE = "continue" + WARN = "warn" + + +class MissingAddressBehavior(StrEnum): + BLOCK = "block" + DROP = "drop" + + +class InactiveEntryBehavior(StrEnum): + DROP = "drop" + BLOCK = "block" + WARN = "warn" + + +class SourceType(StrEnum): + CSV = "csv" + JSON = "json" + + +class ZipMethod(StrEnum): + ZIP_STANDARD = "zip_standard" + AES = "aes" + + +class BuildStatus(StrEnum): + BUILT = "built" + BUILD_FAILED = "build_failed" + + +class SendStatus(StrEnum): + DRAFT = "draft" + QUEUED = "queued" + + +class CampaignMeta(StrictModel): + id: str + name: str + description: str | None = None + mode: CampaignMode = CampaignMode.DRAFT + + +class FieldDefinition(StrictModel): + name: str + type: FieldType = FieldType.STRING + label: str | None = None + required: bool = False + + +class SmtpConfig(StrictModel): + host: str | None = None + port: int | None = Field(default=None, ge=1, le=65535) + username: str | None = None + password: str | None = None + security: TransportSecurity = TransportSecurity.STARTTLS + timeout_seconds: int = Field(default=30, ge=1) + + +class ImapConfig(StrictModel): + enabled: bool = False + host: str | None = None + port: int | None = Field(default=None, ge=1, le=65535) + username: str | None = None + password: str | None = None + security: TransportSecurity = TransportSecurity.TLS + sent_folder: str = "auto" + timeout_seconds: int = Field(default=30, ge=1) + + +class ServerConfig(StrictModel): + smtp: SmtpConfig | None = None + imap: ImapConfig | None = None + + +class RecipientConfig(StrictModel): + email: str + name: str | None = None + recipient_type: RecipientType = Field(default=RecipientType.TO, alias="type") + + @field_validator("email") + @classmethod + def email_should_look_like_address(cls, value: str) -> str: + # JSON Schema's format=email remains the stricter validation layer. + # Keep this deliberately lightweight to avoid an extra email-validator dependency. + if "@" not in value: + raise ValueError("email must contain '@'") + return value + + +class RecipientsConfig(StrictModel): + from_: RecipientConfig | None = Field(default=None, alias="from") + allow_individual_from: bool = False + + to: list[RecipientConfig] = Field(default_factory=list) + allow_individual_to: bool = False + + cc: list[RecipientConfig] = Field(default_factory=list) + allow_individual_cc: bool = False + + bcc: list[RecipientConfig] = Field(default_factory=list) + allow_individual_bcc: bool = False + + reply_to: list[RecipientConfig] = Field(default_factory=list) + allow_individual_reply_to: bool = False + + bounce_to: list[RecipientConfig] = Field(default_factory=list) + allow_individual_bounce_to: bool = False + + disposition_notification_to: list[RecipientConfig] = Field(default_factory=list) + allow_individual_disposition_notification_to: bool = False + + +class TemplateSourceConfig(StrictModel): + type: Literal["files"] = "files" + subject_path: str | None = None + text_path: str | None = None + html_path: str | None = None + encoding: str = "utf-8" + + @model_validator(mode="after") + def at_least_one_path(self) -> "TemplateSourceConfig": + if not any([self.subject_path, self.text_path, self.html_path]): + raise ValueError("template.source must define subject_path, text_path or html_path") + return self + + +class TemplateConfig(StrictModel): + subject: str | None = None + text: str | None = None + html: str | None = None + source: TemplateSourceConfig | None = None + + @model_validator(mode="after") + def inline_or_source(self) -> "TemplateConfig": + inline_values = any(value is not None for value in [self.subject, self.text, self.html]) + if self.source and inline_values: + raise ValueError("template must be either inline or source-based, not both") + if self.source: + return self + if not self.subject: + raise ValueError("inline template requires subject") + return self + + @property + def is_external(self) -> bool: + return self.source is not None + + +class ZipConfig(StrictModel): + enabled: bool = False + filename_template: str | None = None + password_template: str | None = None + method: ZipMethod = ZipMethod.AES + + +class AttachmentConfig(StrictModel): + id: str | None = None + label: str | None = None + base_dir: str + file_filter: str + include_subdirs: bool = False + required: bool = True + allow_multiple: bool = False + missing_behavior: Behavior = Behavior.ASK + ambiguous_behavior: Behavior = Behavior.ASK + zip: ZipConfig = Field(default_factory=ZipConfig) + + +class AttachmentsConfig(StrictModel): + base_path: str = "." + allow_individual: bool = False + send_without_attachments: bool = True + global_: list[AttachmentConfig] = Field(default_factory=list, alias="global") + missing_behavior: Behavior = Behavior.ASK + ambiguous_behavior: Behavior = Behavior.ASK + + +class EntryConfig(StrictModel): + id: str | None = None + active: bool = True + + from_: RecipientConfig | None = Field(default=None, alias="from") + + to: list[RecipientConfig] = Field(default_factory=list) + combine_to: bool = True + + cc: list[RecipientConfig] = Field(default_factory=list) + combine_cc: bool = True + + bcc: list[RecipientConfig] = Field(default_factory=list) + combine_bcc: bool = True + + reply_to: list[RecipientConfig] = Field(default_factory=list) + combine_reply_to: bool = True + + bounce_to: list[RecipientConfig] = Field(default_factory=list) + combine_bounce_to: bool = True + + disposition_notification_to: list[RecipientConfig] = Field(default_factory=list) + combine_disposition_notification_to: bool = True + + attachments: list[AttachmentConfig] = Field(default_factory=list) + combine_attachments: bool = True + + fields: dict[str, Any] = Field(default_factory=dict) + last_sent: str | None = None + + +class SourceConfig(StrictModel): + type: SourceType + path: str + delimiter: str = ";" + encoding: str = "utf-8" + has_header: bool = True + + +class EntriesConfig(StrictModel): + inline: list[EntryConfig] | None = None + source: SourceConfig | None = None + mapping: dict[str, str] | None = None + defaults: EntryConfig | None = None + + @model_validator(mode="after") + def inline_or_external(self) -> "EntriesConfig": + has_inline = self.inline is not None + has_external = self.source is not None or self.mapping is not None or self.defaults is not None + if has_inline and has_external: + raise ValueError("entries must be either inline or source-based, not both") + if has_inline: + return self + if self.source is None or self.mapping is None: + raise ValueError("external entries require source and mapping") + return self + + @property + def is_inline(self) -> bool: + return self.inline is not None + + @property + def is_external(self) -> bool: + return self.source is not None + + +class ValidationPolicy(StrictModel): + missing_required_attachment: Behavior = Behavior.ASK + missing_optional_attachment: Behavior = Behavior.WARN + ambiguous_attachment_match: Behavior = Behavior.ASK + missing_email: MissingAddressBehavior = MissingAddressBehavior.BLOCK + template_error: MissingAddressBehavior = MissingAddressBehavior.BLOCK + inactive_entry: InactiveEntryBehavior = InactiveEntryBehavior.DROP + + +class RateLimitConfig(StrictModel): + messages_per_minute: int = Field(default=5, ge=1) + concurrency: int = Field(default=1, ge=1) + + +class ImapAppendSentConfig(StrictModel): + enabled: bool = False + folder: str = "auto" + + +class RetryConfig(StrictModel): + max_attempts: int = Field(default=3, ge=1) + backoff_seconds: list[int] = Field(default_factory=lambda: [60, 300, 900]) + + @field_validator("backoff_seconds") + @classmethod + def backoff_values_must_be_positive(cls, values: list[int]) -> list[int]: + if any(value < 1 for value in values): + raise ValueError("backoff_seconds values must be >= 1") + return values + + +class DeliveryConfig(StrictModel): + rate_limit: RateLimitConfig = Field(default_factory=RateLimitConfig) + imap_append_sent: ImapAppendSentConfig = Field(default_factory=ImapAppendSentConfig) + retry: RetryConfig = Field(default_factory=RetryConfig) + + +class StatusTrackingConfig(StrictModel): + enabled: bool = True + initial_build_status: BuildStatus = BuildStatus.BUILT + initial_send_status: SendStatus = SendStatus.DRAFT + + +class CampaignConfig(StrictModel): + version: Literal["1.0"] + campaign: CampaignMeta + fields: list[FieldDefinition] = Field(default_factory=list) + global_values: dict[str, Any] = Field(default_factory=dict) + server: ServerConfig = Field(default_factory=ServerConfig) + recipients: RecipientsConfig = Field(default_factory=RecipientsConfig) + template: TemplateConfig + attachments: AttachmentsConfig = Field(default_factory=AttachmentsConfig) + entries: EntriesConfig + validation_policy: ValidationPolicy = Field(default_factory=ValidationPolicy) + delivery: DeliveryConfig = Field(default_factory=DeliveryConfig) + status_tracking: StatusTrackingConfig = Field(default_factory=StatusTrackingConfig) + + @model_validator(mode="after") + def field_names_must_be_unique(self) -> "CampaignConfig": + names = [field.name for field in self.fields] + duplicates = sorted({name for name in names if names.count(name) > 1}) + if duplicates: + raise ValueError(f"duplicate field definitions: {', '.join(duplicates)}") + return self + + @property + def field_names(self) -> set[str]: + return {field.name for field in self.fields} + + def resolve_relative_path(self, campaign_file: Path, raw_path: str) -> Path: + path = Path(raw_path).expanduser() + if path.is_absolute(): + return path + return (campaign_file.parent / path).resolve() diff --git a/server/app/mailer/campaign/validation.py b/server/app/mailer/campaign/validation.py new file mode 100644 index 0000000..12135e2 --- /dev/null +++ b/server/app/mailer/campaign/validation.py @@ -0,0 +1,261 @@ +from __future__ import annotations + +import csv +from enum import StrEnum +from pathlib import Path +from typing import Iterable + +from pydantic import BaseModel, ConfigDict, Field + +from .models import CampaignConfig, SourceType + + +class Severity(StrEnum): + INFO = "info" + WARNING = "warning" + ERROR = "error" + + +class SemanticIssue(BaseModel): + model_config = ConfigDict(extra="forbid") + + severity: Severity + code: str + message: str + path: str | None = None + + +class SemanticReport(BaseModel): + model_config = ConfigDict(extra="forbid") + + campaign_id: str + campaign_name: str + issues: list[SemanticIssue] = Field(default_factory=list) + entries_mode: str + entries_count: int | None = None + attachments_base_path: str + rate_limit: str + imap_append_enabled: bool + + @property + def error_count(self) -> int: + return sum(1 for issue in self.issues if issue.severity == Severity.ERROR) + + @property + def warning_count(self) -> int: + return sum(1 for issue in self.issues if issue.severity == Severity.WARNING) + + @property + def ok(self) -> bool: + return self.error_count == 0 + + +def _issue(severity: Severity, code: str, message: str, path: str | None = None) -> SemanticIssue: + return SemanticIssue(severity=severity, code=code, message=message, path=path) + + +def _resolve(campaign_file: Path, raw_path: str) -> Path: + path = Path(raw_path).expanduser() + if path.is_absolute(): + return path + return (campaign_file.parent / path).resolve() + + +def _mapping_target_known(target: str, field_names: set[str]) -> bool: + direct_targets = { + "id", + "active", + "last_sent", + "combine_to", + "combine_cc", + "combine_bcc", + "combine_reply_to", + "combine_bounce_to", + "combine_disposition_notification_to", + "combine_attachments", + } + if target in direct_targets: + return True + if target.startswith("fields."): + name = target.split(".", 1)[1] + return not field_names or name in field_names + if target.startswith("from."): + return target in {"from.email", "from.name", "from.type"} + for prefix in ["to", "cc", "bcc", "reply_to", "bounce_to", "disposition_notification_to"]: + if target.startswith(prefix + "."): + parts = target.split(".") + return len(parts) == 3 and parts[1].isdigit() and parts[2] in {"email", "name", "type"} + if target.startswith("attachments."): + parts = target.split(".") + # attachments.0.zip.filename_template etc. + if len(parts) >= 3 and parts[1].isdigit(): + if parts[2] in { + "id", + "label", + "base_dir", + "file_filter", + "include_subdirs", + "required", + "allow_multiple", + "missing_behavior", + "ambiguous_behavior", + }: + return len(parts) == 3 + if parts[2] == "zip" and len(parts) == 4: + return parts[3] in {"enabled", "filename_template", "password_template", "method"} + return False + + +def _csv_header(path: Path, delimiter: str, encoding: str) -> list[str] | None: + with path.open("r", encoding=encoding, newline="") as handle: + reader = csv.reader(handle, delimiter=delimiter) + try: + return next(reader) + except StopIteration: + return [] + + +def _iter_template_source_paths(config: CampaignConfig) -> Iterable[tuple[str, str]]: + if not config.template.source: + return [] + source = config.template.source + paths: list[tuple[str, str]] = [] + if source.subject_path: + paths.append(("/template/source/subject_path", source.subject_path)) + if source.text_path: + paths.append(("/template/source/text_path", source.text_path)) + if source.html_path: + paths.append(("/template/source/html_path", source.html_path)) + return paths + + +def validate_campaign_config( + config: CampaignConfig, + *, + campaign_file: str | Path | None = None, + check_files: bool = False, +) -> SemanticReport: + campaign_path = Path(campaign_file).resolve() if campaign_file else Path.cwd() / "campaign.json" + issues: list[SemanticIssue] = [] + + field_names = config.field_names + declared_names = {field.name for field in config.fields} + + for key in config.global_values: + if declared_names and key not in declared_names: + issues.append(_issue( + Severity.WARNING, + "unknown_global_value", + f"global_values contains {key!r}, but it is not declared in fields", + f"/global_values/{key}", + )) + + if config.server.imap and config.server.imap.enabled: + missing = [name for name in ["host", "port", "username", "password"] if getattr(config.server.imap, name) in (None, "")] + if missing: + issues.append(_issue( + Severity.ERROR, + "incomplete_imap_config", + "IMAP append is enabled, but these IMAP settings are missing: " + ", ".join(missing), + "/server/imap", + )) + + if config.delivery.imap_append_sent.enabled and not (config.server.imap and config.server.imap.enabled): + issues.append(_issue( + Severity.WARNING, + "delivery_imap_enabled_without_server_imap", + "delivery.imap_append_sent is enabled, but server.imap.enabled is not true", + "/delivery/imap_append_sent/enabled", + )) + + if config.campaign.mode == "send" and not config.server.smtp: + issues.append(_issue( + Severity.ERROR, + "missing_smtp_config", + "campaign mode is 'send', but no server.smtp configuration is present", + "/server/smtp", + )) + + if config.server.smtp: + missing = [name for name in ["host", "port"] if getattr(config.server.smtp, name) in (None, "")] + if missing: + issues.append(_issue( + Severity.WARNING, + "incomplete_smtp_config", + "SMTP settings are present, but these settings are missing: " + ", ".join(missing), + "/server/smtp", + )) + + if config.entries.is_inline: + entries_count = len(config.entries.inline or []) + entries_mode = "inline" + if entries_count == 0: + issues.append(_issue(Severity.WARNING, "no_inline_entries", "entries.inline is empty", "/entries/inline")) + else: + entries_count = None + entries_mode = f"external:{config.entries.source.type.value if config.entries.source else 'unknown'}" + mapping = config.entries.mapping or {} + if not mapping: + issues.append(_issue(Severity.ERROR, "empty_mapping", "external entries require a non-empty mapping", "/entries/mapping")) + for target in mapping: + if not _mapping_target_known(target, field_names): + issues.append(_issue( + Severity.WARNING, + "unknown_mapping_target", + f"mapping target {target!r} is not recognized by the current campaign model", + f"/entries/mapping/{target}", + )) + if check_files and config.entries.source: + source_path = _resolve(campaign_path, config.entries.source.path) + if not source_path.exists(): + issues.append(_issue( + Severity.ERROR, + "entries_source_not_found", + f"entries source file does not exist: {source_path}", + "/entries/source/path", + )) + elif config.entries.source.type == SourceType.CSV and config.entries.source.has_header: + try: + header = _csv_header(source_path, config.entries.source.delimiter, config.entries.source.encoding) + header_set = set(header or []) + missing_columns = sorted({source_name for source_name in mapping.values() if source_name not in header_set}) + if missing_columns: + issues.append(_issue( + Severity.ERROR, + "mapping_columns_missing", + "CSV mapping refers to missing columns: " + ", ".join(missing_columns), + "/entries/mapping", + )) + except OSError as exc: + issues.append(_issue(Severity.ERROR, "entries_source_read_error", str(exc), "/entries/source/path")) + + if check_files: + attachments_base_path = _resolve(campaign_path, config.attachments.base_path) + if not attachments_base_path.exists(): + issues.append(_issue( + Severity.WARNING, + "attachments_base_path_not_found", + f"attachments.base_path does not exist: {attachments_base_path}", + "/attachments/base_path", + )) + for schema_path, raw_path in _iter_template_source_paths(config): + path = _resolve(campaign_path, raw_path) + if not path.exists(): + issues.append(_issue( + Severity.ERROR, + "template_source_not_found", + f"template source file does not exist: {path}", + schema_path, + )) + + report = SemanticReport( + campaign_id=config.campaign.id, + campaign_name=config.campaign.name, + issues=issues, + entries_mode=entries_mode, + entries_count=entries_count, + attachments_base_path=config.attachments.base_path, + rate_limit=f"{config.delivery.rate_limit.messages_per_minute}/min, concurrency {config.delivery.rate_limit.concurrency}", + imap_append_enabled=config.delivery.imap_append_sent.enabled, + ) + return report diff --git a/server/app/mailer/commands/__init__.py b/server/app/mailer/commands/__init__.py new file mode 100644 index 0000000..4e79186 --- /dev/null +++ b/server/app/mailer/commands/__init__.py @@ -0,0 +1 @@ +"""CLI commands for MultiMailer development workflows.""" diff --git a/server/app/mailer/commands/append_pending_sent.py b/server/app/mailer/commands/append_pending_sent.py new file mode 100644 index 0000000..b876e8b --- /dev/null +++ b/server/app/mailer/commands/append_pending_sent.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import argparse +import json +from time import sleep + +from app.db.bootstrap import create_all_tables +from app.db.models import CampaignJob, JobImapStatus, JobSendStatus +from app.db.session import SessionLocal +from app.mailer.sending.jobs import append_sent_for_job +from app.security.api_keys import authenticate_api_key +from app.settings import settings + + +def main() -> None: + parser = argparse.ArgumentParser(description="Append sent campaign messages to the configured IMAP Sent folder.") + parser.add_argument("--campaign-id", required=True, help="Database campaign UUID") + parser.add_argument("--api-key", default=settings.dev_bootstrap_api_key) + parser.add_argument("--limit", type=int, default=0, help="Maximum jobs to process; 0 means all pending/failed IMAP appends") + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--include-failed", action="store_true", help="Also retry jobs with imap_status=failed") + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + + create_all_tables() + results = [] + with SessionLocal() as session: + api_key = authenticate_api_key(session, args.api_key) + if not api_key: + raise SystemExit("Invalid API key") + + statuses = [JobImapStatus.PENDING.value] + if args.include_failed: + statuses.append(JobImapStatus.FAILED.value) + + query = ( + session.query(CampaignJob) + .filter( + CampaignJob.tenant_id == api_key.tenant_id, + CampaignJob.campaign_id == args.campaign_id, + CampaignJob.send_status == JobSendStatus.SENT.value, + CampaignJob.imap_status.in_(statuses), + ) + .order_by(CampaignJob.entry_index.asc()) + ) + if args.limit > 0: + query = query.limit(args.limit) + + jobs = query.all() + for job in jobs: + try: + result = append_sent_for_job(session, job_id=job.id, dry_run=args.dry_run) + results.append(result.as_dict()) + if not args.json: + print(f"{job.entry_index}: {result.status} ({job.recipient_email}) folder={result.folder or '-'}") + except Exception as exc: + results.append({"job_id": job.id, "status": "error", "error": str(exc)}) + if not args.json: + print(f"{job.entry_index}: ERROR {exc} ({job.recipient_email})") + sleep(0.1) + + if args.json: + print(json.dumps({"processed": len(results), "results": results}, indent=2)) + elif not jobs: + print("No pending IMAP append jobs found") + + +if __name__ == "__main__": + main() diff --git a/server/app/mailer/commands/audit_log.py b/server/app/mailer/commands/audit_log.py new file mode 100644 index 0000000..1ab82d9 --- /dev/null +++ b/server/app/mailer/commands/audit_log.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import argparse +import json + +from app.db.bootstrap import create_all_tables +from app.db.models import AuditLog +from app.db.session import SessionLocal +from app.security.api_keys import authenticate_api_key +from app.settings import settings + + +def _row(item: AuditLog) -> dict: + return { + "id": item.id, + "created_at": item.created_at.isoformat() if item.created_at else None, + "tenant_id": item.tenant_id, + "user_id": item.user_id, + "api_key_id": item.api_key_id, + "action": item.action, + "object_type": item.object_type, + "object_id": item.object_id, + "details": item.details, + } + + +def main() -> None: + parser = argparse.ArgumentParser(description="List audit log entries.") + parser.add_argument("--api-key", default=settings.dev_bootstrap_api_key) + parser.add_argument("--limit", type=int, default=50) + parser.add_argument("--offset", type=int, default=0) + parser.add_argument("--action") + parser.add_argument("--object-type") + parser.add_argument("--object-id") + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + + create_all_tables() + with SessionLocal() as session: + api_key = authenticate_api_key(session, args.api_key) + if not api_key: + raise SystemExit("Invalid API key") + query = session.query(AuditLog).filter(AuditLog.tenant_id == api_key.tenant_id) + if args.action: + query = query.filter(AuditLog.action == args.action) + if args.object_type: + query = query.filter(AuditLog.object_type == args.object_type) + if args.object_id: + query = query.filter(AuditLog.object_id == args.object_id) + items = query.order_by(AuditLog.created_at.desc()).offset(args.offset).limit(args.limit).all() + rows = [_row(item) for item in items] + + if args.json: + print(json.dumps({"items": rows}, indent=2, ensure_ascii=False, default=str)) + return + + for row in rows: + print( + f"{row['created_at']} | {row['action']} | " + f"{row['object_type'] or '-'}:{row['object_id'] or '-'} | {row['details'] or {}}" + ) + + +if __name__ == "__main__": + main() diff --git a/server/app/mailer/commands/build_messages.py b/server/app/mailer/commands/build_messages.py new file mode 100644 index 0000000..f55f1b7 --- /dev/null +++ b/server/app/mailer/commands/build_messages.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from app.mailer.campaign.entries import EntryLoadError +from app.mailer.campaign.loader import CampaignLoadError, load_campaign_config +from app.mailer.messages.builder import build_campaign_messages +from app.mailer.messages.models import CampaignBuildReport + + +def _print_report(report: CampaignBuildReport, *, verbose: bool = False) -> None: + print(f"Campaign: {report.campaign_name} ({report.campaign_id})") + print(f"Campaign file: {report.campaign_file}") + print(f"Entries: {report.entries_count}") + print( + "Build: " + f"built={report.built_count}, " + f"failed={report.build_failed_count}, " + f"queueable={report.queueable_count}" + ) + print( + "Validation: " + f"ready={report.ready_count}, " + f"warning={report.warning_count}, " + f"needs_review={report.needs_review_count}, " + f"blocked={report.blocked_count}, " + f"excluded={report.excluded_count}, " + f"inactive={report.inactive_count}" + ) + + for message in report.messages: + print("---") + label = message.entry_id or f"#{message.entry_index}" + eml = f", eml={message.eml_path}" if message.eml_path else "" + print( + f"Entry {label}: " + f"build={message.build_status.value}, " + f"validation={message.validation_status.value}, " + f"send={message.send_status.value}, " + f"imap={message.imap_status.value}, " + f"attachments={message.attachment_count}{eml}" + ) + if message.subject: + print(f" Subject: {message.subject}") + if message.to: + print(" To: " + ", ".join(item.email for item in message.to)) + for issue in message.issues: + behavior = f", behavior={issue.behavior}" if issue.behavior else "" + source = f", source={issue.source}" if issue.source else "" + print(f" [{issue.severity}] {issue.code}{behavior}{source}: {issue.message}") + if verbose: + for attachment in message.attachments: + print( + f" - attachment {attachment.attachment_id or ''}: " + f"{attachment.status}, matches={len(attachment.matches)}, " + f"zip={attachment.zip_enabled}, filter={attachment.directory}/{attachment.file_filter}" + ) + for match in attachment.matches: + print(f" {match}") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Build campaign message drafts and review statuses without sending.") + parser.add_argument("--campaign", required=True, help="Path to campaign.json") + parser.add_argument("--output-dir", default=None, help="Optional directory for generated .eml files") + parser.add_argument("--write-eml", action="store_true", help="Write generated messages as .eml files") + parser.add_argument("--json", action="store_true", help="Output machine-readable JSON report") + parser.add_argument("--verbose", "-v", action="store_true", help="Print attachment details") + args = parser.parse_args(argv) + + campaign_path = Path(args.campaign).resolve() + output_dir = Path(args.output_dir).resolve() if args.output_dir else None + write_eml = args.write_eml or output_dir is not None + + try: + config = load_campaign_config(campaign_path) + result = build_campaign_messages( + config, + campaign_file=campaign_path, + output_dir=output_dir, + write_eml=write_eml, + ) + except (CampaignLoadError, EntryLoadError, ValueError, OSError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 2 + + if args.json: + print(json.dumps(result.report.model_dump(mode="json", by_alias=True), ensure_ascii=False, indent=2)) + else: + _print_report(result.report, verbose=args.verbose) + + return 0 if result.report.build_failed_count == 0 else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/server/app/mailer/commands/campaign_report.py b/server/app/mailer/commands/campaign_report.py new file mode 100644 index 0000000..c3e9c4c --- /dev/null +++ b/server/app/mailer/commands/campaign_report.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from app.db.bootstrap import create_all_tables +from app.db.session import SessionLocal +from app.mailer.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv +from app.security.api_keys import authenticate_api_key +from app.settings import settings + + +def _print_text_report(report: dict) -> None: + campaign = report["campaign"] + cards = report["cards"] + delivery = report["delivery"] + print(f"Campaign: {campaign['name']} ({campaign['id']})") + print(f"Status: {campaign['status']}") + print(f"Jobs: {cards['jobs_total']} total | {cards['queueable']} queueable | {cards['needs_attention']} need attention") + print(f"Sending: {cards['sent']} sent | {cards['failed']} failed") + print(f"IMAP: {cards['imap_appended']} appended | {cards['imap_failed']} failed") + if delivery.get("rate_limit", {}).get("messages_per_minute"): + print( + "Rate: " + f"{delivery['rate_limit']['messages_per_minute']}/min, concurrency {delivery['rate_limit']['concurrency']}" + ) + if delivery.get("estimated_remaining_send_human"): + print(f"ETA: {delivery['estimated_remaining_send_human']}") + print("Validation counts:", report["status_counts"]["validation"]) + print("Send counts: ", report["status_counts"]["send"]) + print("Issue codes: ", report["issues"]["by_code"]) + print("Attachments: ", report["attachments"]) + failures = report.get("recent_failures") or [] + if failures: + print("Recent failures:") + for failure in failures[:10]: + print( + f" - entry={failure['entry_index']} recipient={failure['recipient_email']} " + f"send={failure['send_status']} imap={failure['imap_status']} error={failure['last_error']}" + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Generate a campaign status/report payload.") + parser.add_argument("--campaign-id", required=True, help="Database campaign UUID") + parser.add_argument("--api-key", default=settings.dev_bootstrap_api_key) + parser.add_argument("--json", action="store_true", help="Print machine-readable JSON") + parser.add_argument("--include-jobs", action="store_true", help="Include per-job rows in JSON output") + parser.add_argument("--jobs-csv", help="Write per-job report CSV to this path") + args = parser.parse_args() + + create_all_tables() + with SessionLocal() as session: + api_key = authenticate_api_key(session, args.api_key) + if not api_key: + raise SystemExit("Invalid API key") + try: + report = generate_campaign_report( + session, + tenant_id=api_key.tenant_id, + campaign_id=args.campaign_id, + include_jobs=args.include_jobs, + ) + if args.jobs_csv: + csv_text = generate_jobs_csv(session, tenant_id=api_key.tenant_id, campaign_id=args.campaign_id) + Path(args.jobs_csv).write_text(csv_text, encoding="utf-8") + print(f"Wrote {args.jobs_csv}") + if args.json: + print(json.dumps(report, indent=2, ensure_ascii=False, default=str)) + else: + _print_text_report(report) + except CampaignReportError as exc: + raise SystemExit(str(exc)) from exc + + +if __name__ == "__main__": + main() diff --git a/server/app/mailer/commands/email_campaign_report.py b/server/app/mailer/commands/email_campaign_report.py new file mode 100644 index 0000000..e7e5a83 --- /dev/null +++ b/server/app/mailer/commands/email_campaign_report.py @@ -0,0 +1,74 @@ +from __future__ import annotations + +import argparse +import json + +from app.audit.logging import audit_event +from app.db.bootstrap import create_all_tables +from app.db.session import SessionLocal +from app.mailer.reports.emailing import CampaignReportEmailError, send_campaign_report_email +from app.mailer.reports.campaigns import CampaignReportError +from app.security.api_keys import authenticate_api_key +from app.settings import settings + + +def main() -> None: + parser = argparse.ArgumentParser(description="Email a campaign report to one or more recipients.") + parser.add_argument("--campaign-id", required=True, help="Database campaign UUID") + parser.add_argument("--to", action="append", required=True, help="Report recipient. Repeat for multiple recipients.") + parser.add_argument("--api-key", default=settings.dev_bootstrap_api_key) + parser.add_argument("--include-jobs", action="store_true", help="Include per-job rows in the JSON report payload before rendering") + parser.add_argument("--no-jobs-csv", action="store_true", help="Do not attach the per-job CSV report") + parser.add_argument("--attach-report-json", action="store_true", help="Attach the complete report JSON") + parser.add_argument("--dry-run", action="store_true", help="Build and validate the report email without SMTP sending") + parser.add_argument("--json", action="store_true", help="Print machine-readable JSON") + args = parser.parse_args() + + create_all_tables() + with SessionLocal() as session: + api_key = authenticate_api_key(session, args.api_key) + if not api_key: + raise SystemExit("Invalid API key") + try: + result = send_campaign_report_email( + session, + tenant_id=api_key.tenant_id, + campaign_id=args.campaign_id, + to=args.to, + include_jobs=args.include_jobs, + attach_jobs_csv=not args.no_jobs_csv, + attach_report_json=args.attach_report_json, + dry_run=args.dry_run, + ) + audit_event( + session, + tenant_id=api_key.tenant_id, + user_id=api_key.user_id, + api_key_id=api_key.id, + action="report.email_sent" if not args.dry_run else "report.email_dry_run", + object_type="campaign", + object_id=args.campaign_id, + details=result.as_dict(), + commit=True, + ) + except (CampaignReportError, CampaignReportEmailError) as exc: + raise SystemExit(str(exc)) from exc + except Exception as exc: + raise SystemExit(f"Could not email campaign report: {exc}") from exc + + if args.json: + print(json.dumps(result.as_dict(), indent=2, ensure_ascii=False)) + else: + print(f"Campaign: {result.campaign_id}") + print(f"To: {', '.join(result.to)}") + print(f"Subject: {result.subject}") + print(f"Dry run: {result.dry_run}") + print(f"Sent: {result.sent}") + print(f"CSV: {result.attached_jobs_csv}") + print(f"JSON: {result.attached_report_json}") + if result.smtp_host: + print(f"SMTP: {result.smtp_host}:{result.smtp_port}") + + +if __name__ == "__main__": + main() diff --git a/server/app/mailer/commands/import_campaign.py b/server/app/mailer/commands/import_campaign.py new file mode 100644 index 0000000..1e062d1 --- /dev/null +++ b/server/app/mailer/commands/import_campaign.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import argparse +import json +from pathlib import Path + +from app.audit.logging import audit_event +from app.db.bootstrap import create_all_tables +from app.db.session import SessionLocal +from app.db.models import User +from app.security.api_keys import authenticate_api_key +from app.settings import settings +from app.mailer.persistence.campaigns import create_campaign_version_from_json, validate_campaign_version, build_campaign_version + + +def main() -> None: + parser = argparse.ArgumentParser(description="Import a campaign JSON into the database and optionally validate/build it.") + parser.add_argument("--campaign", required=True, help="Path to campaign.json") + parser.add_argument("--api-key", default=settings.dev_bootstrap_api_key, help="API key used as the importing principal") + parser.add_argument("--validate", action="store_true", help="Run semantic validation after import") + parser.add_argument("--build", action="store_true", help="Build message jobs after import") + parser.add_argument("--no-eml", action="store_true", help="Do not write generated .eml files during build") + args = parser.parse_args() + + create_all_tables() + campaign_path = Path(args.campaign).resolve() + raw_json = json.loads(campaign_path.read_text(encoding="utf-8")) + + with SessionLocal() as session: + api_key = authenticate_api_key(session, args.api_key) + if not api_key: + raise SystemExit("Invalid API key. Run init_db --with-dev-data first or pass --api-key.") + user = session.get(User, api_key.user_id) + campaign, version = create_campaign_version_from_json( + session, + tenant_id=api_key.tenant_id, + user_id=user.id if user else None, + raw_json=raw_json, + source_filename=str(campaign_path), + ) + audit_event( + session, + tenant_id=api_key.tenant_id, + user_id=api_key.user_id, + api_key_id=api_key.id, + action="campaign.imported", + object_type="campaign", + object_id=campaign.id, + details={"version_id": version.id, "source_filename": str(campaign_path)}, + commit=True, + ) + print(f"Campaign: {campaign.name} ({campaign.id})") + print(f"Version: {version.version_number} ({version.id})") + + if args.validate: + report = validate_campaign_version(session, tenant_id=api_key.tenant_id, version_id=version.id) + audit_event(session, tenant_id=api_key.tenant_id, user_id=api_key.user_id, api_key_id=api_key.id, action="campaign.validated", object_type="campaign_version", object_id=version.id, details={"ok": report.get("ok")}, commit=True) + print(f"Validation: ok={report['ok']}, errors={report['error_count']}, warnings={report['warning_count']}") + + if args.build: + report = build_campaign_version(session, tenant_id=api_key.tenant_id, version_id=version.id, write_eml=not args.no_eml) + audit_event(session, tenant_id=api_key.tenant_id, user_id=api_key.user_id, api_key_id=api_key.id, action="campaign.messages_built", object_type="campaign_version", object_id=version.id, details={"built_count": report.get("built_count"), "write_eml": not args.no_eml}, commit=True) + print(f"Build: built={report['built_count']}, queueable={report['queueable_count']}, needs_review={report['needs_review_count']}") + + +if __name__ == "__main__": + main() diff --git a/server/app/mailer/commands/init_db.py b/server/app/mailer/commands/init_db.py new file mode 100644 index 0000000..ec70584 --- /dev/null +++ b/server/app/mailer/commands/init_db.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +import argparse + +from app.db.bootstrap import bootstrap_dev_data, create_all_tables +from app.db.session import SessionLocal +from app.settings import settings + + +def main() -> None: + parser = argparse.ArgumentParser(description="Initialize the MultiMailer database") + parser.add_argument("--with-dev-data", action="store_true", help="Create default tenant/user/roles and a development API key") + parser.add_argument("--dev-api-key", default=settings.dev_bootstrap_api_key, help="Development API key secret to create") + args = parser.parse_args() + + create_all_tables() + print("Database tables ensured.") + + if args.with_dev_data: + with SessionLocal() as session: + result = bootstrap_dev_data(session, api_key_secret=args.dev_api_key) + print(f"Tenant: {result.tenant.slug} ({result.tenant.id})") + print(f"User: {result.user.email} ({result.user.id})") + if result.created_api_key: + print("Development API key created:") + print(result.created_api_key.secret) + else: + print("Development API key already exists or was not requested.") + + +if __name__ == "__main__": + main() diff --git a/server/app/mailer/commands/list_db_campaigns.py b/server/app/mailer/commands/list_db_campaigns.py new file mode 100644 index 0000000..f85b9b9 --- /dev/null +++ b/server/app/mailer/commands/list_db_campaigns.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +import argparse + +from app.db.bootstrap import create_all_tables +from app.db.models import Campaign, CampaignJob +from app.db.session import SessionLocal +from app.security.api_keys import authenticate_api_key +from app.settings import settings + + +def main() -> None: + parser = argparse.ArgumentParser(description="List persisted campaigns and job counts.") + parser.add_argument("--api-key", default=settings.dev_bootstrap_api_key) + args = parser.parse_args() + + create_all_tables() + with SessionLocal() as session: + api_key = authenticate_api_key(session, args.api_key) + if not api_key: + raise SystemExit("Invalid API key") + campaigns = session.query(Campaign).filter(Campaign.tenant_id == api_key.tenant_id).order_by(Campaign.updated_at.desc()).all() + for campaign in campaigns: + jobs = session.query(CampaignJob).filter(CampaignJob.campaign_id == campaign.id).count() + print(f"{campaign.id} | {campaign.status:15s} | jobs={jobs:4d} | {campaign.name}") + + +if __name__ == "__main__": + main() diff --git a/server/app/mailer/commands/queue_campaign.py b/server/app/mailer/commands/queue_campaign.py new file mode 100644 index 0000000..9675414 --- /dev/null +++ b/server/app/mailer/commands/queue_campaign.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import argparse +import json + +from app.db.bootstrap import create_all_tables +from app.db.models import Campaign +from app.db.session import SessionLocal +from app.mailer.sending.jobs import queue_campaign_jobs +from app.security.api_keys import authenticate_api_key +from app.settings import settings + + +def main() -> None: + parser = argparse.ArgumentParser(description="Queue built campaign jobs for sending.") + parser.add_argument("--campaign-id", required=True, help="Database campaign UUID, not external campaign id") + parser.add_argument("--version-id", default=None, help="Optional campaign version UUID; defaults to current version") + parser.add_argument("--api-key", default=settings.dev_bootstrap_api_key) + parser.add_argument("--no-celery", action="store_true", help="Only mark jobs as queued; do not enqueue Celery tasks") + parser.add_argument("--exclude-warnings", action="store_true", help="Queue only validation_status=ready, not warnings") + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + + create_all_tables() + with SessionLocal() as session: + api_key = authenticate_api_key(session, args.api_key) + if not api_key: + raise SystemExit("Invalid API key") + result = queue_campaign_jobs( + session, + tenant_id=api_key.tenant_id, + campaign_id=args.campaign_id, + version_id=args.version_id, + enqueue_celery=not args.no_celery, + include_warnings=not args.exclude_warnings, + dry_run=args.dry_run, + ) + if args.json: + print(json.dumps(result.as_dict(), indent=2)) + return + campaign = session.get(Campaign, args.campaign_id) + print(f"Campaign: {campaign.name if campaign else args.campaign_id}") + print(f"Version: {result.version_id}") + print(f"Queued: {result.queued_count}") + print(f"Skipped: {result.skipped_count}") + print(f"Blocked: {result.blocked_count}") + print(f"Enqueued Celery tasks: {result.enqueued_count}") + if result.dry_run: + print("Dry run: no database changes were committed") + + +if __name__ == "__main__": + main() diff --git a/server/app/mailer/commands/resolve_attachments.py b/server/app/mailer/commands/resolve_attachments.py new file mode 100644 index 0000000..5868fd2 --- /dev/null +++ b/server/app/mailer/commands/resolve_attachments.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from app.mailer.attachments.resolver import AttachmentResolutionReport, resolve_campaign_attachments +from app.mailer.campaign.loader import CampaignLoadError, load_campaign_config +from app.mailer.campaign.entries import EntryLoadError + + +def _print_report(report: AttachmentResolutionReport, *, verbose: bool = False) -> None: + print(f"Campaign: {report.campaign_name} ({report.campaign_id})") + print(f"Campaign file: {report.campaign_file}") + print(f"Attachments base path: {report.attachments_base_path}") + print(f"Entries: {report.entries_count}") + print( + "Status: " + f"ready={report.ready_count}, " + f"warning={report.warning_count}, " + f"needs_review={report.needs_review_count}, " + f"blocked={report.blocked_count}, " + f"excluded={report.excluded_count}, " + f"inactive={report.inactive_count}" + ) + + for entry in report.entries: + print("---") + label = entry.entry_id or f"#{entry.entry_index}" + print(f"Entry {label}: {entry.status.value}, matches={entry.match_count}") + for issue in entry.issues: + behavior = f", behavior={issue.behavior.value}" if issue.behavior else "" + print(f" [{issue.severity.value}] {issue.code}{behavior}: {issue.message}") + if verbose: + for attachment in entry.attachments: + print( + f" - {attachment.scope.value}[{attachment.index}] " + f"{attachment.attachment_id or ''} " + f"{attachment.status.value}: {attachment.directory}/{attachment.file_filter}" + ) + for match in attachment.matches: + print(f" {match}") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Resolve campaign attachment patterns and report missing/ambiguous matches.") + parser.add_argument("--campaign", required=True, help="Path to campaign.json") + parser.add_argument("--json", action="store_true", help="Output machine-readable JSON") + parser.add_argument("--verbose", "-v", action="store_true", help="Print resolved configs and matched files") + args = parser.parse_args(argv) + + campaign_path = Path(args.campaign) + try: + config = load_campaign_config(campaign_path) + report = resolve_campaign_attachments(config, campaign_file=campaign_path) + except (CampaignLoadError, EntryLoadError, ValueError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 2 + + if args.json: + print(json.dumps(report.model_dump(mode="json"), ensure_ascii=False, indent=2)) + else: + _print_report(report, verbose=args.verbose) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/server/app/mailer/commands/send_queued_jobs.py b/server/app/mailer/commands/send_queued_jobs.py new file mode 100644 index 0000000..3b29245 --- /dev/null +++ b/server/app/mailer/commands/send_queued_jobs.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import argparse +import json +from time import sleep + +from app.db.bootstrap import create_all_tables +from app.db.models import CampaignJob, JobQueueStatus, JobSendStatus +from app.db.session import SessionLocal +from app.mailer.sending.jobs import append_sent_for_job, send_campaign_job +from app.security.api_keys import authenticate_api_key +from app.settings import settings + + +def main() -> None: + parser = argparse.ArgumentParser(description="Process queued campaign jobs directly, without a Celery worker.") + parser.add_argument("--campaign-id", required=True) + parser.add_argument("--api-key", default=settings.dev_bootstrap_api_key) + parser.add_argument("--limit", type=int, default=0, help="Maximum jobs to process; 0 means all queued jobs") + parser.add_argument("--dry-run", action="store_true", help="Validate/send path without SMTP delivery or status mutation to SENT") + parser.add_argument("--no-rate-limit", action="store_true") + parser.add_argument("--append-sent", action="store_true", help="After successful SMTP delivery, immediately run IMAP append-to-Sent in this CLI process") + parser.add_argument("--json", action="store_true") + args = parser.parse_args() + + create_all_tables() + results = [] + with SessionLocal() as session: + api_key = authenticate_api_key(session, args.api_key) + if not api_key: + raise SystemExit("Invalid API key") + query = ( + session.query(CampaignJob) + .filter( + CampaignJob.tenant_id == api_key.tenant_id, + CampaignJob.campaign_id == args.campaign_id, + CampaignJob.queue_status == JobQueueStatus.QUEUED.value, + CampaignJob.send_status.in_([JobSendStatus.QUEUED.value, JobSendStatus.FAILED_TEMPORARY.value]), + ) + .order_by(CampaignJob.entry_index.asc()) + ) + if args.limit > 0: + query = query.limit(args.limit) + jobs = query.all() + for job in jobs: + try: + result = send_campaign_job(session, job_id=job.id, dry_run=args.dry_run, use_rate_limit=not args.no_rate_limit) + result_dict = result.as_dict() + if args.append_sent and result.status == "sent": + append_result = append_sent_for_job(session, job_id=job.id, dry_run=args.dry_run) + result_dict["imap_append"] = append_result.as_dict() + results.append(result_dict) + if not args.json: + line = f"{job.entry_index}: {result.status} ({job.recipient_email})" + if "imap_append" in result_dict: + line += f"; IMAP: {result_dict['imap_append']['status']}" + print(line) + except Exception as exc: + results.append({"job_id": job.id, "status": "error", "error": str(exc)}) + if not args.json: + print(f"{job.entry_index}: ERROR {exc} ({job.recipient_email})") + # Continue with the next job; individual attempts/statuses are recorded. + sleep(0.1) + if args.json: + print(json.dumps({"processed": len(results), "results": results}, indent=2)) + elif not jobs: + print("No queued jobs found") + + +if __name__ == "__main__": + main() diff --git a/server/app/mailer/commands/send_test_message.py b/server/app/mailer/commands/send_test_message.py new file mode 100644 index 0000000..9035559 --- /dev/null +++ b/server/app/mailer/commands/send_test_message.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +import argparse +import getpass +import json +import os +import sys +from pathlib import Path + +from app.mailer.campaign.entries import EntryLoadError +from app.mailer.campaign.loader import CampaignLoadError, load_campaign_config +from app.mailer.campaign.models import SmtpConfig, TransportSecurity +from app.mailer.messages.builder import BuiltMessage, build_campaign_messages +from app.mailer.messages.models import MessageDraft +from app.mailer.sending.smtp import ( + SmtpConfigurationError, + SmtpSendError, + prepare_test_message, + send_email_message, +) + + +def _env(name: str) -> str | None: + value = os.getenv(name) + return value if value not in {None, ""} else None + + +def _parse_security(value: str | None, fallback: TransportSecurity) -> TransportSecurity: + if not value: + return fallback + try: + return TransportSecurity(value.lower()) + except ValueError as exc: + allowed = ", ".join(item.value for item in TransportSecurity) + raise ValueError(f"invalid SMTP security '{value}', expected one of: {allowed}") from exc + + +def _parse_port(value: str | None, fallback: int | None) -> int | None: + if not value: + return fallback + try: + return int(value) + except ValueError as exc: + raise ValueError(f"invalid SMTP port '{value}'") from exc + + +def _smtp_config_with_overrides(args: argparse.Namespace, base: SmtpConfig | None) -> SmtpConfig: + config = base or SmtpConfig() + + password = args.smtp_password + if args.smtp_password_env: + password = _env(args.smtp_password_env) + if password is None: + raise ValueError(f"environment variable {args.smtp_password_env} is empty or not set") + if args.ask_password: + password = getpass.getpass("SMTP password: ") + + return SmtpConfig( + host=args.smtp_host or _env("MULTIMAILER_SMTP_HOST") or config.host, + port=_parse_port(args.smtp_port or _env("MULTIMAILER_SMTP_PORT"), config.port), + username=args.smtp_username or _env("MULTIMAILER_SMTP_USERNAME") or config.username, + password=password or _env("MULTIMAILER_SMTP_PASSWORD") or config.password, + security=_parse_security(args.smtp_security or _env("MULTIMAILER_SMTP_SECURITY"), config.security), + timeout_seconds=args.smtp_timeout or config.timeout_seconds, + ) + + +def _select_message( + messages: list[BuiltMessage], + *, + entry_id: str | None, + entry_index: int | None, + allow_non_queueable: bool, +) -> BuiltMessage: + candidates = messages + if entry_id is not None: + candidates = [item for item in candidates if item.draft.entry_id == entry_id] + if not candidates: + raise ValueError(f"no generated message found for entry id '{entry_id}'") + if entry_index is not None: + candidates = [item for item in candidates if item.draft.entry_index == entry_index] + if not candidates: + raise ValueError(f"no generated message found for entry index {entry_index}") + + if not allow_non_queueable: + queueable = [item for item in candidates if item.draft.is_queueable and item.mime is not None] + if queueable: + return queueable[0] + raise ValueError( + "no queueable built message found. Fix validation issues or pass --allow-non-queueable for a deliberate test send." + ) + + built = [item for item in candidates if item.mime is not None] + if not built: + raise ValueError("no built MIME message found") + return built[0] + + +def _envelope_from(draft: MessageDraft) -> str: + if draft.bounce_to: + return draft.bounce_to[0].email + if draft.from_: + return draft.from_.email + raise SmtpConfigurationError("message has no sender; cannot determine SMTP envelope sender") + + +def _write_test_eml(path: Path, message) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(bytes(message)) + + +def _print_summary(*, draft: MessageDraft, test_to: str, smtp_config: SmtpConfig, envelope_from: str) -> None: + print(f"Entry: {draft.entry_id or '#' + str(draft.entry_index)}") + print(f"Subject: {draft.subject or ''}") + print(f"Original validation: {draft.validation_status.value}") + print(f"Original send status: {draft.send_status.value}") + print(f"Test recipient: {test_to}") + print(f"Envelope sender: {envelope_from}") + print(f"SMTP: {smtp_config.host}:{smtp_config.port} ({smtp_config.security.value})") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Send one generated campaign message to a test recipient. Does not mutate campaign/job status." + ) + parser.add_argument("--campaign", required=True, help="Path to campaign.json") + parser.add_argument("--to", required=True, help="Test recipient email address. Real campaign recipients are never used.") + parser.add_argument("--to-name", default=None, help="Optional display name for the test recipient") + parser.add_argument("--entry-id", default=None, help="Select a specific entry by id") + parser.add_argument("--entry-index", type=int, default=None, help="Select a specific entry by 1-based index") + parser.add_argument( + "--allow-non-queueable", + action="store_true", + help="Allow test-send of a built message whose validation status is needs_review/warning/blocked/excluded", + ) + parser.add_argument("--write-eml", default=None, help="Write the prepared test .eml to this path") + parser.add_argument("--dry-run", action="store_true", help="Build and prepare the test message but do not connect to SMTP") + parser.add_argument("--json", action="store_true", help="Output machine-readable JSON") + + parser.add_argument("--smtp-host", default=None, help="Override SMTP host") + parser.add_argument("--smtp-port", default=None, help="Override SMTP port") + parser.add_argument("--smtp-security", default=None, choices=[item.value for item in TransportSecurity], help="Override SMTP security") + parser.add_argument("--smtp-username", default=None, help="Override SMTP username") + parser.add_argument("--smtp-password", default=None, help="Override SMTP password (prefer --smtp-password-env or --ask-password)") + parser.add_argument("--smtp-password-env", default=None, help="Read SMTP password from this environment variable") + parser.add_argument("--ask-password", action="store_true", help="Prompt for SMTP password") + parser.add_argument("--smtp-timeout", type=int, default=None, help="Override SMTP timeout in seconds") + args = parser.parse_args(argv) + + campaign_path = Path(args.campaign).resolve() + + try: + config = load_campaign_config(campaign_path) + smtp_config = _smtp_config_with_overrides(args, config.server.smtp) + result = build_campaign_messages(config, campaign_file=campaign_path) + selected = _select_message( + result.built_messages, + entry_id=args.entry_id, + entry_index=args.entry_index, + allow_non_queueable=args.allow_non_queueable, + ) + assert selected.mime is not None + + envelope_from = _envelope_from(selected.draft) + test_message = prepare_test_message(selected.mime, test_recipient=args.to, test_recipient_name=args.to_name) + + if args.write_eml: + _write_test_eml(Path(args.write_eml).resolve(), test_message) + + if not args.json: + _print_summary(draft=selected.draft, test_to=args.to, smtp_config=smtp_config, envelope_from=envelope_from) + + send_result = None + if not args.dry_run: + send_result = send_email_message( + test_message, + smtp_config=smtp_config, + envelope_from=envelope_from, + envelope_recipients=[args.to], + ) + + except (CampaignLoadError, EntryLoadError, ValueError, SmtpConfigurationError, SmtpSendError, OSError) as exc: + if args.json: + print(json.dumps({"ok": False, "error": str(exc)}, ensure_ascii=False, indent=2)) + else: + print(f"Error: {exc}", file=sys.stderr) + return 2 + + if args.json: + payload = { + "ok": True, + "dry_run": args.dry_run, + "campaign_id": config.campaign.id, + "entry_id": selected.draft.entry_id, + "entry_index": selected.draft.entry_index, + "test_recipient": args.to, + "validation_status": selected.draft.validation_status.value, + "smtp": { + "host": smtp_config.host, + "port": smtp_config.port, + "security": smtp_config.security.value, + }, + "send_result": None + if send_result is None + else { + "accepted_count": send_result.accepted_count, + "refused_recipients": send_result.refused_recipients, + }, + } + print(json.dumps(payload, ensure_ascii=False, indent=2)) + else: + if args.dry_run: + print("Dry run only; no SMTP connection attempted.") + else: + assert send_result is not None + print(f"SMTP accepted recipients: {send_result.accepted_count}/{len(send_result.envelope_recipients)}") + if send_result.refused_recipients: + print(f"SMTP refused recipients: {send_result.refused_recipients}") + else: + print("Test message sent.") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/server/app/mailer/commands/validate_campaign.py b/server/app/mailer/commands/validate_campaign.py new file mode 100644 index 0000000..125507d --- /dev/null +++ b/server/app/mailer/commands/validate_campaign.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from app.mailer.campaign.loader import CampaignLoadError, CampaignSchemaError, load_campaign_config +from app.mailer.campaign.validation import Severity, validate_campaign_config + + +def _default_campaign_path() -> Path: + return Path(__file__).resolve().parents[1] / "examples" / "campaign.json" + + +def _print_text_report(report) -> None: + print(f"Campaign: {report.campaign_name} ({report.campaign_id})") + print(f"Entries: {report.entries_mode}" + (f", {report.entries_count} item(s)" if report.entries_count is not None else "")) + print(f"Attachments base path: {report.attachments_base_path}") + print(f"Rate limit: {report.rate_limit}") + print(f"IMAP append: {'enabled' if report.imap_append_enabled else 'disabled'}") + print(f"Issues: {report.error_count} error(s), {report.warning_count} warning(s)") + if report.issues: + print() + for issue in report.issues: + location = f" [{issue.path}]" if issue.path else "" + print(f"- {issue.severity.upper()} {issue.code}{location}: {issue.message}") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="Validate a MultiMailer campaign JSON file.") + parser.add_argument("--campaign", default=str(_default_campaign_path()), help="Path to campaign JSON file") + parser.add_argument("--schema", default=None, help="Optional path to campaign.schema.json") + parser.add_argument("--no-schema", action="store_true", help="Skip JSON Schema validation") + parser.add_argument("--check-files", action="store_true", help="Check referenced local files and CSV headers") + parser.add_argument("--json", action="store_true", help="Print machine-readable validation report") + args = parser.parse_args(argv) + + campaign_path = Path(args.campaign).resolve() + try: + config = load_campaign_config( + campaign_path, + validate_schema=not args.no_schema, + schema_path=args.schema, + ) + report = validate_campaign_config(config, campaign_file=campaign_path, check_files=args.check_files) + except CampaignSchemaError as exc: + if args.json: + print(json.dumps({"ok": False, "schema_errors": [error.__dict__ for error in exc.errors]}, indent=2), file=sys.stdout) + else: + print(str(exc), file=sys.stderr) + for error in exc.errors: + print(f"- {error.path}: {error.message}", file=sys.stderr) + return 2 + except CampaignLoadError as exc: + print(str(exc), file=sys.stderr) + return 2 + except Exception as exc: + print(f"campaign validation failed: {exc}", file=sys.stderr) + return 2 + + if args.json: + print(report.model_dump_json(indent=2)) + else: + _print_text_report(report) + + return 0 if report.ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/server/app/mailer/domain/__init__.py b/server/app/mailer/domain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app/mailer/domain/campaign.py b/server/app/mailer/domain/campaign.py new file mode 100644 index 0000000..8e278f5 --- /dev/null +++ b/server/app/mailer/domain/campaign.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import StrEnum +from pathlib import Path + +from .fields import Field, FieldConfiguration, FieldContents +from .recipients import Recipient, RecipientList, RecipientType +from .template import MailTemplate + + +class TransportSecurity(StrEnum): + PLAIN = "plain" + TLS = "tls" + STARTTLS = "starttls" + + @property + def standard_port(self) -> int: + return 465 if self == TransportSecurity.TLS else 587 + + +@dataclass +class MailServerSettings: + server: str = "" + port: int | None = None + username: str = "" + password: str = "" + transport_security: TransportSecurity = TransportSecurity.PLAIN + + def use_plain(self) -> "MailServerSettings": + self.transport_security = TransportSecurity.PLAIN + self.port = self.port or self.transport_security.standard_port + return self + + def use_tls(self) -> "MailServerSettings": + self.transport_security = TransportSecurity.TLS + self.port = self.port or self.transport_security.standard_port + return self + + def use_starttls(self) -> "MailServerSettings": + self.transport_security = TransportSecurity.STARTTLS + self.port = self.port or self.transport_security.standard_port + return self + + def resolved_port(self) -> int: + return self.port or self.transport_security.standard_port + + +@dataclass(frozen=True, slots=True) +class MailAttachmentConfig: + base_dir: Path + file_filter: str = "*" + include_subdirs: bool = False + + +@dataclass +class MailEntry: + field_config: FieldConfiguration + is_active: bool = True + from_recipient: Recipient | None = None + to: RecipientList = field(default_factory=RecipientList) + cc: RecipientList = field(default_factory=RecipientList) + bcc: RecipientList = field(default_factory=RecipientList) + combine_to: bool = True + combine_cc: bool = True + combine_bcc: bool = True + attachment_configs: list[MailAttachmentConfig] = field(default_factory=list) + combine_attachments: bool = True + field_contents: FieldContents = field(init=False) + + def __post_init__(self) -> None: + self.field_contents = FieldContents(self.field_config) + + def add_to(self, recipient: Recipient) -> "MailEntry": + self.to.add_recipient(recipient) + return self + + def add_cc(self, recipient: Recipient) -> "MailEntry": + self.cc.add_recipient(recipient) + return self + + def add_bcc(self, recipient: Recipient) -> "MailEntry": + self.bcc.add_recipient(recipient) + return self + + def no_combine_to(self) -> "MailEntry": + self.combine_to = False + return self + + def combine_to_recipients(self) -> "MailEntry": + self.combine_to = True + return self + + def no_combine_attachments(self) -> "MailEntry": + self.combine_attachments = False + return self + + def combine_attachments_with_global(self) -> "MailEntry": + self.combine_attachments = True + return self + + def add_mail_attachment_config(self, config: MailAttachmentConfig) -> "MailEntry": + self.attachment_configs.append(config) + return self + + def set_field_content_for_name(self, name: str, value: Field | object) -> "MailEntry": + if not self.field_contents.set_field_content_for_name(name, value): + raise KeyError(f"unknown field: {name}") + return self + + def get_field_content_from_name(self, name: str) -> Field: + return self.field_contents.get_field_content_from_name(name) + + +@dataclass +class MailCampaign: + mail_server_settings: MailServerSettings | None = None + global_from: Recipient | None = None + global_to: RecipientList = field(default_factory=RecipientList) + global_cc: RecipientList = field(default_factory=RecipientList) + global_bcc: RecipientList = field(default_factory=RecipientList) + individual_from: bool = False + individual_to: bool = False + individual_cc: bool = False + individual_bcc: bool = False + base_attachment_path: Path = Path(".") + global_attachment_configs: list[MailAttachmentConfig] = field(default_factory=list) + individual_attachments: bool = False + send_without_attachments: bool = True + field_config: FieldConfiguration = field(default_factory=FieldConfiguration) + field_contents: FieldContents = field(init=False) + subject_template: MailTemplate = field(default_factory=MailTemplate) + mail_template: MailTemplate = field(default_factory=MailTemplate) + mail_entries: list[MailEntry] = field(default_factory=list) + + def __post_init__(self) -> None: + self.field_contents = FieldContents(self.field_config) + + @classmethod + def with_server_settings(cls, settings: MailServerSettings) -> "MailCampaign": + return cls(mail_server_settings=settings) + + def add_field(self, name: str, field_type) -> "MailCampaign": + from .fields import FieldDescription + self.field_config.add_field_at_end(FieldDescription(name, field_type)) + self.field_contents.ensure_field(self.field_config.get_field_description(name)) # type: ignore[arg-type] + for entry in self.mail_entries: + entry.field_contents.ensure_field(self.field_config.get_field_description(name)) # type: ignore[arg-type] + return self + + def set_from(self, recipient: Recipient) -> "MailCampaign": + self.global_from = recipient + return self + + def add_to(self, recipient: Recipient) -> "MailCampaign": + self.global_to.add_recipient(recipient) + return self + + def allow_individual_to(self) -> "MailCampaign": + self.individual_to = True + return self + + def disallow_individual_to(self) -> "MailCampaign": + self.individual_to = False + return self + + def allow_individual_attachments(self) -> "MailCampaign": + self.individual_attachments = True + return self + + def disallow_individual_attachments(self) -> "MailCampaign": + self.individual_attachments = False + return self + + def dont_send_without_attachments(self) -> "MailCampaign": + self.send_without_attachments = False + return self + + def send_without_attachments_allowed(self) -> "MailCampaign": + self.send_without_attachments = True + return self + + def add_new_mail_entry(self) -> MailEntry: + entry = MailEntry(self.field_config) + self.mail_entries.append(entry) + return entry + + def set_field_content_for_name(self, name: str, value: Field | object) -> "MailCampaign": + if not self.field_contents.set_field_content_for_name(name, value): + raise KeyError(f"unknown field: {name}") + return self + + def get_field_content_from_name(self, name: str) -> Field: + return self.field_contents.get_field_content_from_name(name) + + def all_recipients_for(self, entry: MailEntry) -> list[Recipient]: + recipients: list[Recipient] = [] + if not self.individual_to or entry.combine_to: + recipients.extend(self.global_to.recipients) + if not self.individual_cc or entry.combine_cc: + recipients.extend(self.global_cc.recipients) + if not self.individual_bcc or entry.combine_bcc: + recipients.extend(self.global_bcc.recipients) + if self.individual_to: + recipients.extend(entry.to.recipients) + if self.individual_cc: + recipients.extend(entry.cc.recipients) + if self.individual_bcc: + recipients.extend(entry.bcc.recipients) + return recipients diff --git a/server/app/mailer/domain/fields.py b/server/app/mailer/domain/fields.py new file mode 100644 index 0000000..fb0f112 --- /dev/null +++ b/server/app/mailer/domain/fields.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import date, datetime +from enum import StrEnum +from typing import Any + + +class FieldType(StrEnum): + STRING = "string" + INTEGER = "integer" + DOUBLE = "double" + DATE = "date" + PASSWORD = "password" + + +@dataclass(slots=True) +class FieldDescription: + name: str + type: FieldType = FieldType.STRING + + +@dataclass(slots=True) +class Field: + content: Any + + @classmethod + def with_content(cls, content: Any) -> "Field": + if content is None: + raise ValueError("content must not be None") + return cls(content=content) + + @property + def type(self) -> FieldType: + if isinstance(self.content, bool): + return FieldType.STRING + if isinstance(self.content, int): + return FieldType.INTEGER + if isinstance(self.content, float): + return FieldType.DOUBLE + if isinstance(self.content, (date, datetime)): + return FieldType.DATE + if isinstance(self.content, (bytes, bytearray)): + return FieldType.PASSWORD + return FieldType.STRING + + def as_string(self) -> str: + if isinstance(self.content, (bytes, bytearray)): + return self.content.decode("utf-8") + if isinstance(self.content, (date, datetime)): + return self.content.isoformat() + return str(self.content) + + +@dataclass +class FieldConfiguration: + fields: list[FieldDescription] = field(default_factory=list) + + def add_field_at_end(self, field_description: FieldDescription) -> "FieldConfiguration": + return self.add_field_at_position(len(self.fields), field_description) + + def add_field_at_start(self, field_description: FieldDescription) -> "FieldConfiguration": + return self.add_field_at_position(0, field_description) + + def add_field_at_position(self, position: int, field_description: FieldDescription) -> "FieldConfiguration": + if self.has_field(field_description.name): + raise ValueError(f"field already exists: {field_description.name}") + position = max(0, min(position, len(self.fields))) + self.fields.insert(position, field_description) + return self + + def has_field(self, name: str) -> bool: + return any(f.name == name for f in self.fields) + + def get_field_description(self, name: str) -> FieldDescription | None: + return next((f for f in self.fields if f.name == name), None) + + def get_field_names(self) -> list[str]: + return [f.name for f in self.fields] + + +@dataclass +class FieldContents: + field_config: FieldConfiguration + field_map: dict[str, Field] = field(default_factory=dict) + + def __post_init__(self) -> None: + for field_description in self.field_config.fields: + self.ensure_field(field_description) + + def ensure_field(self, field_description: FieldDescription) -> None: + if field_description.name in self.field_map: + return + match field_description.type: + case FieldType.INTEGER: + value = 0 + case FieldType.DOUBLE: + value = 0.0 + case FieldType.DATE: + value = date.today() + case FieldType.PASSWORD: + value = b"" + case _: + value = "" + self.field_map[field_description.name] = Field.with_content(value) + + def get_field_content_from_name(self, name: str) -> Field: + try: + return self.field_map[name] + except KeyError as exc: + raise KeyError(f"unknown field: {name}") from exc + + def set_field_content_for_name(self, name: str, value: Field | Any) -> bool: + if name not in self.field_map: + return False + if not isinstance(value, Field): + value = Field.with_content(value) + expected = self.field_map[name].type + if expected != value.type and expected != FieldType.PASSWORD: + raise TypeError(f"field {name!r} expects {expected}, got {value.type}") + self.field_map[name] = value + return True + + def as_value_map(self, prefix: str) -> dict[str, str]: + return {f"{prefix}::{name}": field.as_string() for name, field in self.field_map.items()} diff --git a/server/app/mailer/domain/queue.py b/server/app/mailer/domain/queue.py new file mode 100644 index 0000000..aad26fe --- /dev/null +++ b/server/app/mailer/domain/queue.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from email.message import EmailMessage +from typing import Iterator + + +@dataclass +class MailQueue: + messages: list[EmailMessage] = field(default_factory=list) + + def add_mail(self, message: EmailMessage) -> None: + self.messages.append(message) + + def remove_mail(self, message: EmailMessage) -> bool: + if message in self.messages: + self.messages.remove(message) + return True + return False + + @property + def mail_count(self) -> int: + return len(self.messages) + + def is_empty(self) -> bool: + return not self.messages + + def __iter__(self) -> Iterator[EmailMessage]: + return iter(self.messages) diff --git a/server/app/mailer/domain/recipients.py b/server/app/mailer/domain/recipients.py new file mode 100644 index 0000000..790b00b --- /dev/null +++ b/server/app/mailer/domain/recipients.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from email.utils import formataddr +from enum import StrEnum + + +class RecipientType(StrEnum): + TO = "to" + CC = "cc" + BCC = "bcc" + + +@dataclass(frozen=True, slots=True) +class Recipient: + address: str + name: str | None = None + type: RecipientType = RecipientType.TO + + def formatted(self) -> str: + return formataddr((self.name or self.address, self.address)) + + +@dataclass +class RecipientList: + recipients: list[Recipient] = field(default_factory=list) + + def add_recipient(self, recipient: Recipient) -> "RecipientList": + if recipient not in self.recipients: + self.recipients.append(recipient) + return self + + def add_multiple_recipients(self, recipients: list[Recipient] | tuple[Recipient, ...]) -> "RecipientList": + for recipient in recipients: + self.add_recipient(recipient) + return self + + def clear_all_recipients(self) -> "RecipientList": + self.recipients.clear() + return self + + def by_type(self, recipient_type: RecipientType) -> list[Recipient]: + return [r for r in self.recipients if r.type == recipient_type] diff --git a/server/app/mailer/domain/template.py b/server/app/mailer/domain/template.py new file mode 100644 index 0000000..3055dd0 --- /dev/null +++ b/server/app/mailer/domain/template.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +import re +from dataclasses import dataclass + +_FIELD_PATTERN = re.compile(r"(? "MailTemplate": + self.template_string = template + return self + + def get_used_fields(self) -> set[str]: + return set(_FIELD_PATTERN.findall(self.template_string)) + + def apply_values(self, values: dict[str, str], *, keep_missing: bool = True) -> str: + def replace(match: re.Match[str]) -> str: + key = match.group(1) + if key in values: + return values[key] + return match.group(0) if keep_missing else "" + + rendered = _FIELD_PATTERN.sub(replace, self.template_string) + return rendered.replace(r"\${", "${").replace(r"\}", "}") diff --git a/server/app/mailer/examples/__init__.py b/server/app/mailer/examples/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app/mailer/examples/campaign.json b/server/app/mailer/examples/campaign.json new file mode 100644 index 0000000..f3a0867 --- /dev/null +++ b/server/app/mailer/examples/campaign.json @@ -0,0 +1,180 @@ +{ + "version": "1.0", + "campaign": { + "id": "rechnungslegung-2026-05", + "name": "Rechnungslegung 2026-05", + "mode": "draft", + "description": "Example campaign migrated from the Java object model." + }, + "fields": [ + { + "name": "monthyear", + "type": "string", + "label": "Month/year", + "required": true + }, + { + "name": "number", + "type": "string", + "label": "Dienststelle", + "required": true + }, + { + "name": "anrede", + "type": "string", + "label": "Salutation" + }, + { + "name": "zip_password", + "type": "password", + "label": "ZIP password", + "required": true + } + ], + "global_values": { + "monthyear": "05 / 2026" + }, + "server": { + "smtp": { + "host": "smtp.example.org", + "port": 587, + "username": "user@example.org", + "password": "CHANGE_ME_OR_REFERENCE_SECRET", + "security": "starttls" + }, + "imap": { + "enabled": true, + "host": "imap.example.org", + "port": 993, + "username": "user@example.org", + "password": "CHANGE_ME_OR_REFERENCE_SECRET", + "security": "tls", + "sent_folder": "auto" + } + }, + "recipients": { + "from": { + "name": "Rechnungslegung D5", + "email": "d5-rechnungslegung@example.org", + "type": "to" + }, + "allow_individual_from": false, + "to": [], + "allow_individual_to": true, + "cc": [], + "allow_individual_cc": false, + "bcc": [], + "allow_individual_bcc": false, + "reply_to": [ + { + "name": "Rechnungslegung D5", + "email": "d5-rechnungslegung@example.org", + "type": "reply_to" + } + ], + "allow_individual_reply_to": false, + "bounce_to": [], + "allow_individual_bounce_to": false, + "disposition_notification_to": [], + "allow_individual_disposition_notification_to": false + }, + "template": { + "subject": "Rechnungslegungslisten für ${global::monthyear} und Dienststelle ${local::number}", + "text": "${local::anrede},\n\nbeigefügt erhalten Sie die Rechnungslegungslisten für ${global::monthyear}.\n\nMit freundlichen Grüßen" + }, + "attachments": { + "base_path": "./data/attachments", + "allow_individual": true, + "send_without_attachments": false, + "global": [], + "missing_behavior": "ask", + "ambiguous_behavior": "ask" + }, + "entries": { + "source": { + "type": "csv", + "path": "./data/recipients.csv", + "delimiter": ";", + "encoding": "utf-8", + "has_header": true + }, + "mapping": { + "id": "ID", + "active": "Aktiv", + "to.0.email": "E-Mail", + "to.0.name": "Name", + "fields.number": "Dienststelle", + "fields.anrede": "Anrede", + "fields.zip_password": "ZIP-Passwort", + "attachments.0.base_dir": "Unterordner", + "attachments.0.file_filter": "Dateimuster" + }, + "defaults": { + "active": true, + "to": [], + "combine_to": false, + "cc": [], + "combine_cc": true, + "bcc": [], + "combine_bcc": true, + "reply_to": [], + "combine_reply_to": true, + "bounce_to": [], + "combine_bounce_to": true, + "disposition_notification_to": [], + "combine_disposition_notification_to": true, + "attachments": [ + { + "id": "individual-documents", + "label": "Personalized PDF bundle", + "base_dir": ".", + "file_filter": "${local::number}_*.pdf", + "include_subdirs": false, + "required": true, + "allow_multiple": true, + "missing_behavior": "ask", + "ambiguous_behavior": "continue", + "zip": { + "enabled": true, + "filename_template": "Rechnungslegung_${local::number}.zip", + "password_template": "${local::zip_password}", + "method": "aes" + } + } + ], + "combine_attachments": true, + "fields": {} + } + }, + "validation_policy": { + "missing_required_attachment": "ask", + "missing_optional_attachment": "warn", + "ambiguous_attachment_match": "ask", + "missing_email": "block", + "template_error": "block", + "inactive_entry": "drop" + }, + "delivery": { + "rate_limit": { + "messages_per_minute": 5, + "concurrency": 1 + }, + "imap_append_sent": { + "enabled": true, + "folder": "auto" + }, + "retry": { + "max_attempts": 3, + "backoff_seconds": [ + 60, + 300, + 900 + ] + } + }, + "status_tracking": { + "enabled": true, + "initial_build_status": "built", + "initial_send_status": "draft" + } +} diff --git a/server/app/mailer/examples/data/attachments/xls/ab0000-123456-demo.XLSX b/server/app/mailer/examples/data/attachments/xls/ab0000-123456-demo.XLSX new file mode 100644 index 0000000..33262e5 --- /dev/null +++ b/server/app/mailer/examples/data/attachments/xls/ab0000-123456-demo.XLSX @@ -0,0 +1 @@ +dummy example attachment for resolver smoke tests diff --git a/server/app/mailer/examples/data/recipients.csv b/server/app/mailer/examples/data/recipients.csv new file mode 100644 index 0000000..c1d06d1 --- /dev/null +++ b/server/app/mailer/examples/data/recipients.csv @@ -0,0 +1,2 @@ +ID;Aktiv;E-Mail;Name;Dienststelle;Anrede;ZIP-Passwort;Unterordner;Dateimuster +entry-001;true;mail@example.com;Example Recipient;ab0000;Sehr geehrte Damen und Herren;secret-demo;xls;ab????-123456-*.XLSX diff --git a/server/app/mailer/examples/rechnungslegung_2026_05.py b/server/app/mailer/examples/rechnungslegung_2026_05.py new file mode 100644 index 0000000..4b3eb06 --- /dev/null +++ b/server/app/mailer/examples/rechnungslegung_2026_05.py @@ -0,0 +1,73 @@ +"""Python port of the provided Java MultiMailerSettings example. + +This is intentionally safe: credentials and real recipients are placeholders. +Run from server/ with: + python -m app.mailer.examples.rechnungslegung_2026_05 +""" +from pathlib import Path + +from app.mailer.domain.campaign import MailAttachmentConfig, MailCampaign, MailServerSettings +from app.mailer.domain.fields import FieldType +from app.mailer.domain.recipients import Recipient +from app.mailer.services.campaign_executor import build_mail_queue + + +def build_campaign() -> MailCampaign: + mail_settings = MailServerSettings( + server="smtp.example.org", + username="user@example.org", + password="change-me", + ).use_starttls() + + campaign = MailCampaign.with_server_settings(mail_settings) + campaign.set_from(Recipient(address="d5-rechnungslegung@example.org", name="Rechnungslegung D5")) + campaign.allow_individual_to() + campaign.allow_individual_attachments() + campaign.dont_send_without_attachments() + campaign.base_attachment_path = Path("/mnt/FLASH/rele/202606") + + campaign.add_field("monthyear", FieldType.STRING) + campaign.add_field("number", FieldType.STRING) + campaign.add_field("password", FieldType.PASSWORD) + campaign.add_field("anrede", FieldType.STRING) + + campaign.set_field_content_for_name("monthyear", "05 / 2026") + campaign.subject_template.set_template_string( + "Rechnungslegungslisten für ${global::monthyear} und Dienststelle ${local::number}" + ) + campaign.mail_template.set_template_string( + "${local::anrede},\r\n\r\n" + "in der Anlage erhalten Sie die Rechnungslegungslisten für die Dienststelle " + "${local::number} für den Abrechnungsmonat ${global::monthyear} im Excel-Format. " + "Bitte verwenden Sie zum öffnen das dauerhafte Passwort, das Ihnen bereits in der Vergangenheit zugeschickt wurde.\r\n" + "Die Rechnungslegungslisten liefern den Nachweis (inkl. Brutto-/Netto-Darstellung) " + "der auf Ihren dezentral bewirtschafteten Fonds gebuchten Personalkosten. Sie dienen der " + "Überwachung und Kontrolle und ggf. als Nachweis gegenüber Drittmittelgebern.\r\n" + "Die Listen erhalten vertrauliche personenbezogene Daten, daher sind diese nur berechtigten " + "Personen zugänglich zu machen und nur für einen unbedingt notwendigen Zeitraum aufzubewahren.\r\n" + "Falls Sie Rechnungslegungslisten erhalten haben sollten, die nicht zu Ihrer Einrichtung gehören, " + "bitten wir Sie um entsprechende Rückmeldung.\r\n\r\n" + "Mit freundlichen Grüßen\r\n\r\n" + "Rechnungslegungsteam Dezernat 5" + ) + + campaign.add_new_mail_entry() \ + .add_to(Recipient(address="mail@example.com", name="mail@example.com")) \ + .no_combine_to() \ + .combine_attachments_with_global() \ + .add_mail_attachment_config(MailAttachmentConfig(Path("xls/"), "ab????-123456-*.XLSX", False)) \ + .set_field_content_for_name("number", "ab0000") \ + .set_field_content_for_name("password", b"..........") \ + .set_field_content_for_name("anrede", "Sehr geehrte Damen und Herren") + + return campaign + + +if __name__ == "__main__": + mc = build_campaign() + queue = build_mail_queue(mc, zip_attachments=False) + print(f"Built {queue.mail_count} message(s).") + for message in queue: + print("---") + print("To:", message.get("To")) + print("Subject:", message.get("Subject")) diff --git a/server/app/mailer/messages/__init__.py b/server/app/mailer/messages/__init__.py new file mode 100644 index 0000000..dcdc016 --- /dev/null +++ b/server/app/mailer/messages/__init__.py @@ -0,0 +1,12 @@ +"""Message building and review helpers.""" + +from .builder import build_campaign_messages +from .models import CampaignBuildReport, MessageDraft, MessageIssue, MessageValidationStatus + +__all__ = [ + "build_campaign_messages", + "CampaignBuildReport", + "MessageDraft", + "MessageIssue", + "MessageValidationStatus", +] diff --git a/server/app/mailer/messages/builder.py b/server/app/mailer/messages/builder.py new file mode 100644 index 0000000..f2b10ec --- /dev/null +++ b/server/app/mailer/messages/builder.py @@ -0,0 +1,547 @@ +from __future__ import annotations + +import mimetypes +import re +import tempfile +from dataclasses import dataclass +from email.message import EmailMessage +from email.utils import formataddr, make_msgid, formatdate +from pathlib import Path +from typing import Any, Iterable + +from app.mailer.attachments.resolver import ( + AttachmentMatchStatus, + EntryAttachmentResolution, + MessageAttachmentStatus, + ResolvedAttachment, + resolve_entry_attachments, +) +from app.mailer.campaign.entries import load_campaign_entries +from app.mailer.campaign.models import ( + Behavior, + BuildStatus, + CampaignConfig, + EntryConfig, + MissingAddressBehavior, + RecipientConfig, + SendStatus, +) +from app.mailer.services.zip_service import create_encrypted_zip + +from .models import ( + CampaignBuildReport, + ImapStatus, + MessageAddress, + MessageAttachmentSummary, + MessageDraft, + MessageIssue, + MessageValidationStatus, +) + +_FIELD_PATTERN = re.compile(r"(? Path: + campaign_path = Path(campaign_file).resolve() + path = Path(raw_path).expanduser() + if path.is_absolute(): + return path + return (campaign_path.parent / path).resolve() + + +def _read_text(campaign_file: str | Path, raw_path: str | None, encoding: str = "utf-8") -> str | None: + if not raw_path: + return None + path = _resolve(campaign_file, raw_path) + return path.read_text(encoding=encoding) + + +def _render_template(template: str, values: dict[str, Any], *, keep_missing: bool = True) -> str: + def replace(match: re.Match[str]) -> str: + key = match.group(1) + if key in values: + value = values[key] + return "" if value is None else str(value) + return match.group(0) if keep_missing else "" + + rendered = _FIELD_PATTERN.sub(replace, template) + return rendered.replace(r"\${", "${").replace(r"\}", "}") + + +def _find_unresolved_placeholders(text: str | None) -> set[str]: + if not text: + return set() + return set(_FIELD_PATTERN.findall(text)) + + +def _recipient_values(entry: EntryConfig) -> dict[str, str]: + values: dict[str, str] = {} + for list_name in ["to", "cc", "bcc", "reply_to", "bounce_to", "disposition_notification_to"]: + recipients = getattr(entry, list_name) + for index, recipient in enumerate(recipients): + prefix = f"{list_name}.{index}" + values[f"local::{prefix}.email"] = recipient.email + values[f"local::{prefix}.name"] = recipient.name or "" + values[f"local::{prefix}.type"] = recipient.recipient_type.value + if entry.from_: + values["local::from.email"] = entry.from_.email + values["local::from.name"] = entry.from_.name or "" + values["local::from.type"] = entry.from_.recipient_type.value + return values + + +def _template_values(config: CampaignConfig, entry: EntryConfig) -> dict[str, Any]: + values: dict[str, Any] = {} + for key, value in config.global_values.items(): + values[f"global::{key}"] = value + for key, value in entry.fields.items(): + values[f"local::{key}"] = value + if entry.id: + values["local::id"] = entry.id + values["local::active"] = entry.active + values.update(_recipient_values(entry)) + return values + + +def _message_address(recipient: RecipientConfig | None) -> MessageAddress | None: + if recipient is None: + return None + return MessageAddress(email=recipient.email, name=recipient.name) + + +def _message_addresses(recipients: Iterable[RecipientConfig]) -> list[MessageAddress]: + return [MessageAddress(email=recipient.email, name=recipient.name) for recipient in recipients] + + +def _format_recipient(recipient: RecipientConfig) -> str: + return formataddr((recipient.name or recipient.email, recipient.email)) + + +def _format_recipient_header(recipients: Iterable[RecipientConfig]) -> str: + return ", ".join(_format_recipient(recipient) for recipient in recipients) + + +def _effective_sender(config: CampaignConfig, entry: EntryConfig) -> RecipientConfig | None: + if config.recipients.allow_individual_from and entry.from_: + return entry.from_ + return config.recipients.from_ + + +def _combine_recipients( + *, + allow_individual: bool, + combine: bool, + global_recipients: list[RecipientConfig], + entry_recipients: list[RecipientConfig], +) -> list[RecipientConfig]: + recipients: list[RecipientConfig] = [] + if not allow_individual or combine: + recipients.extend(global_recipients) + if allow_individual: + recipients.extend(entry_recipients) + # keep order while avoiding exact duplicate email/type pairs + seen: set[tuple[str, str]] = set() + unique: list[RecipientConfig] = [] + for recipient in recipients: + key = (recipient.email.lower(), recipient.recipient_type.value) + if key in seen: + continue + seen.add(key) + unique.append(recipient) + return unique + + +def _effective_recipients(config: CampaignConfig, entry: EntryConfig) -> dict[str, list[RecipientConfig]]: + return { + "to": _combine_recipients( + allow_individual=config.recipients.allow_individual_to, + combine=entry.combine_to, + global_recipients=config.recipients.to, + entry_recipients=entry.to, + ), + "cc": _combine_recipients( + allow_individual=config.recipients.allow_individual_cc, + combine=entry.combine_cc, + global_recipients=config.recipients.cc, + entry_recipients=entry.cc, + ), + "bcc": _combine_recipients( + allow_individual=config.recipients.allow_individual_bcc, + combine=entry.combine_bcc, + global_recipients=config.recipients.bcc, + entry_recipients=entry.bcc, + ), + "reply_to": _combine_recipients( + allow_individual=config.recipients.allow_individual_reply_to, + combine=entry.combine_reply_to, + global_recipients=config.recipients.reply_to, + entry_recipients=entry.reply_to, + ), + "bounce_to": _combine_recipients( + allow_individual=config.recipients.allow_individual_bounce_to, + combine=entry.combine_bounce_to, + global_recipients=config.recipients.bounce_to, + entry_recipients=entry.bounce_to, + ), + "disposition_notification_to": _combine_recipients( + allow_individual=config.recipients.allow_individual_disposition_notification_to, + combine=entry.combine_disposition_notification_to, + global_recipients=config.recipients.disposition_notification_to, + entry_recipients=entry.disposition_notification_to, + ), + } + + +def _load_template_parts(config: CampaignConfig, campaign_file: str | Path) -> tuple[str, str | None, str | None]: + template = config.template + if template.source: + subject = _read_text(campaign_file, template.source.subject_path, template.source.encoding) + text = _read_text(campaign_file, template.source.text_path, template.source.encoding) + html = _read_text(campaign_file, template.source.html_path, template.source.encoding) + return subject or "", text, html + return template.subject or "", template.text, template.html + + +def _issue_from_behavior(*, code: str, message: str, behavior: str, source: str) -> MessageIssue: + severity = "error" if behavior == "block" else "warning" + return MessageIssue(severity=severity, code=code, message=message, behavior=behavior, source=source) + + +def _apply_behavior(current: MessageValidationStatus, behavior: str) -> MessageValidationStatus: + if behavior == Behavior.BLOCK.value: + return MessageValidationStatus.BLOCKED + if behavior == Behavior.DROP.value: + return MessageValidationStatus.EXCLUDED + if behavior == Behavior.ASK.value: + if current not in {MessageValidationStatus.BLOCKED, MessageValidationStatus.EXCLUDED}: + return MessageValidationStatus.NEEDS_REVIEW + if behavior == Behavior.WARN.value: + if current == MessageValidationStatus.READY: + return MessageValidationStatus.WARNING + # continue leaves status as-is + return current + + +def _validation_status_from_attachment_status(status: MessageAttachmentStatus) -> MessageValidationStatus: + return MessageValidationStatus(status.value) + + +def _attachment_summaries(resolution: EntryAttachmentResolution) -> list[MessageAttachmentSummary]: + return [ + MessageAttachmentSummary( + attachment_id=attachment.attachment_id, + label=attachment.label, + status=attachment.status.value, + behavior=attachment.behavior.value if attachment.behavior else None, + required=attachment.required, + allow_multiple=attachment.allow_multiple, + zip_enabled=attachment.zip_enabled, + file_filter=attachment.file_filter, + directory=attachment.directory, + matches=attachment.matches, + ) + for attachment in resolution.attachments + ] + + +def _message_issues_from_attachment_resolution(resolution: EntryAttachmentResolution) -> list[MessageIssue]: + return [ + MessageIssue( + severity=issue.severity.value, + code=issue.code, + message=issue.message, + behavior=issue.behavior.value if issue.behavior else None, + source="attachments", + ) + for issue in resolution.issues + ] + + +def _safe_filename(value: str | None, fallback: str) -> str: + raw = value or fallback + safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", raw).strip("._") + return safe or fallback + + +def _attachment_bytes(path: Path) -> tuple[bytes, str, str]: + data = path.read_bytes() + mime_type, _ = mimetypes.guess_type(str(path)) + if not mime_type: + return data, "application", "octet-stream" + maintype, subtype = mime_type.split("/", 1) + return data, maintype, subtype + + +def _render_zip_filename( + *, + attachment: ResolvedAttachment, + values: dict[str, Any], + entry: EntryConfig, + default_index: int, +) -> str: + template = attachment.attachment_id or attachment.label or f"attachments-{default_index}" + # The resolver summary does not carry the full ZipConfig, so the build step receives + # filename/password through the resolved attachment's original config by re-resolving + # via a private companion in _zip_config_for_attachment. + rendered = _render_template(template, values, keep_missing=False) + if not rendered.lower().endswith(".zip"): + rendered += ".zip" + return _safe_filename(rendered, f"entry-{entry.id or default_index}.zip") + + +def _iter_attachment_configs_for_resolution(config: CampaignConfig, entry: EntryConfig): + if entry.combine_attachments: + for index, attachment_config in enumerate(config.attachments.global_): + yield "global", index, attachment_config + if config.attachments.allow_individual: + for index, attachment_config in enumerate(entry.attachments): + yield "entry", index, attachment_config + + +def _zip_config_for_attachment(config: CampaignConfig, entry: EntryConfig, resolved: ResolvedAttachment): + for scope, index, attachment_config in _iter_attachment_configs_for_resolution(config, entry): + if scope == resolved.scope.value and index == resolved.index: + return attachment_config.zip + return None + + +def _attach_files( + *, + message: EmailMessage, + config: CampaignConfig, + entry: EntryConfig, + resolution: EntryAttachmentResolution, + values: dict[str, Any], + work_dir: Path, +) -> int: + attached_count = 0 + zip_dir = work_dir / "_zip" + zip_dir.mkdir(parents=True, exist_ok=True) + + for index, attachment in enumerate(resolution.attachments, start=1): + # Missing/ambiguous configs still keep the message draft. They simply do not add files. + if attachment.status != AttachmentMatchStatus.OK: + continue + match_paths = [Path(match) for match in attachment.matches] + if not match_paths: + continue + + zip_config = _zip_config_for_attachment(config, entry, attachment) + if attachment.zip_enabled: + filename_template = zip_config.filename_template if zip_config else None + if filename_template: + filename = _safe_filename(_render_template(filename_template, values, keep_missing=False), f"entry-{entry.entry_id if hasattr(entry, 'entry_id') else index}.zip") + if not filename.lower().endswith(".zip"): + filename += ".zip" + else: + filename = _render_zip_filename(attachment=attachment, values=values, entry=entry, default_index=index) + password = _render_template(zip_config.password_template or "", values, keep_missing=False) if zip_config else "" + zip_path = create_encrypted_zip(zip_dir / filename, match_paths, password) + files_to_attach = [zip_path] + else: + files_to_attach = match_paths + + for path in files_to_attach: + data, maintype, subtype = _attachment_bytes(path) + message.add_attachment(data, maintype=maintype, subtype=subtype, filename=path.name) + attached_count += 1 + return attached_count + + +def _imap_initial_status(config: CampaignConfig) -> ImapStatus: + if config.delivery.imap_append_sent.enabled: + return ImapStatus.PENDING + return ImapStatus.NOT_REQUESTED + + +def _write_eml(message: EmailMessage, output_dir: Path, entry: EntryConfig, entry_index: int) -> tuple[str, int]: + output_dir.mkdir(parents=True, exist_ok=True) + filename = _safe_filename(entry.id, f"entry-{entry_index:04d}") + ".eml" + path = output_dir / filename + path.write_bytes(bytes(message)) + return str(path), path.stat().st_size + + +def build_entry_message( + *, + config: CampaignConfig, + campaign_file: str | Path, + entry: EntryConfig, + entry_index: int, + output_dir: Path | None = None, + write_eml: bool = False, + work_dir: Path | None = None, +) -> BuiltMessage: + resolution = resolve_entry_attachments(config=config, campaign_file=campaign_file, entry=entry, entry_index=entry_index) + recipients = _effective_recipients(config, entry) + sender = _effective_sender(config, entry) + issues = _message_issues_from_attachment_resolution(resolution) + validation_status = _validation_status_from_attachment_status(resolution.status) + + if not entry.active: + draft = MessageDraft( + entry_index=entry_index, + entry_id=entry.id, + active=False, + build_status=BuildStatus.BUILD_FAILED, + validation_status=MessageValidationStatus.INACTIVE, + send_status=SendStatus.DRAFT, + imap_status=ImapStatus.SKIPPED, + from_=_message_address(sender), + to=_message_addresses(recipients["to"]), + cc=_message_addresses(recipients["cc"]), + bcc=_message_addresses(recipients["bcc"]), + reply_to=_message_addresses(recipients["reply_to"]), + bounce_to=_message_addresses(recipients["bounce_to"]), + disposition_notification_to=_message_addresses(recipients["disposition_notification_to"]), + attachments=_attachment_summaries(resolution), + issues=[MessageIssue(severity="info", code="inactive_entry", message="Entry is inactive", behavior=config.validation_policy.inactive_entry.value, source="entry")], + ) + return BuiltMessage(draft=draft, mime=None) + + if not recipients["to"]: + behavior = config.validation_policy.missing_email.value + issues.append(_issue_from_behavior(code="missing_email", message="No effective To recipient is configured", behavior=behavior, source="recipients")) + validation_status = MessageValidationStatus.BLOCKED if behavior == MissingAddressBehavior.BLOCK.value else MessageValidationStatus.EXCLUDED + + subject_template, text_template, html_template = _load_template_parts(config, campaign_file) + values = _template_values(config, entry) + subject = _render_template(subject_template, values) + text_body = _render_template(text_template or "", values) if text_template is not None else None + html_body = _render_template(html_template or "", values) if html_template is not None else None + + unresolved = sorted( + _find_unresolved_placeholders(subject) + | _find_unresolved_placeholders(text_body) + | _find_unresolved_placeholders(html_body) + ) + if unresolved: + behavior = config.validation_policy.template_error.value + issues.append( + _issue_from_behavior( + code="template_error", + message="Unresolved template placeholder(s): " + ", ".join(unresolved), + behavior=behavior, + source="template", + ) + ) + validation_status = MessageValidationStatus.BLOCKED if behavior == MissingAddressBehavior.BLOCK.value else MessageValidationStatus.EXCLUDED + + message = EmailMessage() + try: + message["Date"] = formatdate(localtime=True) + message["Message-ID"] = make_msgid() + if sender: + message["From"] = _format_recipient(sender) + if recipients["to"]: + message["To"] = _format_recipient_header(recipients["to"]) + if recipients["cc"]: + message["Cc"] = _format_recipient_header(recipients["cc"]) + # Bcc deliberately remains envelope-only and is tracked in MessageDraft. + if recipients["reply_to"]: + message["Reply-To"] = _format_recipient_header(recipients["reply_to"]) + if recipients["disposition_notification_to"]: + message["Disposition-Notification-To"] = _format_recipient_header(recipients["disposition_notification_to"]) + # bounce_to is tracked but not emitted as Return-Path. That should be the SMTP envelope sender. + message["Subject"] = subject + + if html_body is not None: + message.set_content(text_body or "") + message.add_alternative(html_body, subtype="html") + else: + message.set_content(text_body or "") + + if work_dir is None: + work_dir = output_dir or Path(tempfile.mkdtemp(prefix="multimailer-build-")) + attachment_count = _attach_files( + message=message, + config=config, + entry=entry, + resolution=resolution, + values=values, + work_dir=work_dir, + ) + build_status = BuildStatus.BUILT + except Exception as exc: + issues.append(MessageIssue(severity="error", code="build_failed", message=str(exc), behavior="block", source="builder")) + validation_status = MessageValidationStatus.BLOCKED + build_status = BuildStatus.BUILD_FAILED + attachment_count = 0 + message = None # type: ignore[assignment] + + eml_path: str | None = None + eml_size: int | None = None + if write_eml and output_dir is not None and message is not None: + eml_path, eml_size = _write_eml(message, output_dir, entry, entry_index) + + draft = MessageDraft( + entry_index=entry_index, + entry_id=entry.id, + active=entry.active, + build_status=build_status, + validation_status=validation_status, + send_status=SendStatus.DRAFT, + imap_status=_imap_initial_status(config) if build_status == BuildStatus.BUILT else ImapStatus.SKIPPED, + subject=subject, + from_=_message_address(sender), + to=_message_addresses(recipients["to"]), + cc=_message_addresses(recipients["cc"]), + bcc=_message_addresses(recipients["bcc"]), + reply_to=_message_addresses(recipients["reply_to"]), + bounce_to=_message_addresses(recipients["bounce_to"]), + disposition_notification_to=_message_addresses(recipients["disposition_notification_to"]), + attachment_count=attachment_count, + attachments=_attachment_summaries(resolution), + issues=issues, + eml_path=eml_path, + eml_size_bytes=eml_size, + ) + return BuiltMessage(draft=draft, mime=message) + + +def build_campaign_messages( + config: CampaignConfig, + *, + campaign_file: str | Path, + output_dir: str | Path | None = None, + write_eml: bool = False, +) -> CampaignBuildResult: + campaign_path = Path(campaign_file).resolve() + entries = load_campaign_entries(config, campaign_file=campaign_path) + output_path = Path(output_dir).resolve() if output_dir is not None else None + + with tempfile.TemporaryDirectory(prefix="multimailer-build-") as tmp: + work_dir = output_path or Path(tmp) + built_messages = [ + build_entry_message( + config=config, + campaign_file=campaign_path, + entry=entry, + entry_index=index, + output_dir=output_path, + write_eml=write_eml, + work_dir=work_dir, + ) + for index, entry in enumerate(entries, start=1) + ] + + report = CampaignBuildReport( + campaign_id=config.campaign.id, + campaign_name=config.campaign.name, + campaign_file=str(campaign_path), + entries_count=len(entries), + messages=[built.draft for built in built_messages], + ) + return CampaignBuildResult(report=report, built_messages=built_messages) diff --git a/server/app/mailer/messages/models.py b/server/app/mailer/messages/models.py new file mode 100644 index 0000000..b879950 --- /dev/null +++ b/server/app/mailer/messages/models.py @@ -0,0 +1,139 @@ +from __future__ import annotations + +from enum import StrEnum +from typing import Literal + +from pydantic import BaseModel, ConfigDict, Field + +from app.mailer.campaign.models import BuildStatus, SendStatus + + +class MessageValidationStatus(StrEnum): + READY = "ready" + WARNING = "warning" + NEEDS_REVIEW = "needs_review" + BLOCKED = "blocked" + EXCLUDED = "excluded" + INACTIVE = "inactive" + + +class ImapStatus(StrEnum): + NOT_REQUESTED = "not_requested" + PENDING = "pending" + APPENDED = "appended" + FAILED = "failed" + SKIPPED = "skipped" + + +class MessageIssue(BaseModel): + model_config = ConfigDict(extra="forbid") + + severity: Literal["info", "warning", "error"] + code: str + message: str + behavior: str | None = None + source: str | None = None + + +class MessageAddress(BaseModel): + model_config = ConfigDict(extra="forbid") + + email: str + name: str | None = None + + +class MessageAttachmentSummary(BaseModel): + model_config = ConfigDict(extra="forbid") + + attachment_id: str | None = None + label: str | None = None + status: str + behavior: str | None = None + required: bool + allow_multiple: bool + zip_enabled: bool + file_filter: str + directory: str + matches: list[str] = Field(default_factory=list) + + +class MessageDraft(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + entry_index: int + entry_id: str | None = None + active: bool + + build_status: BuildStatus + validation_status: MessageValidationStatus + send_status: SendStatus + imap_status: ImapStatus + + subject: str | None = None + from_: MessageAddress | None = Field(default=None, alias="from") + to: list[MessageAddress] = Field(default_factory=list) + cc: list[MessageAddress] = Field(default_factory=list) + bcc: list[MessageAddress] = Field(default_factory=list) + reply_to: list[MessageAddress] = Field(default_factory=list) + bounce_to: list[MessageAddress] = Field(default_factory=list) + disposition_notification_to: list[MessageAddress] = Field(default_factory=list) + + attachment_count: int = 0 + attachments: list[MessageAttachmentSummary] = Field(default_factory=list) + issues: list[MessageIssue] = Field(default_factory=list) + + eml_path: str | None = None + eml_size_bytes: int | None = None + + @property + def is_queueable(self) -> bool: + return self.active and self.build_status == BuildStatus.BUILT and self.validation_status in { + MessageValidationStatus.READY, + MessageValidationStatus.WARNING, + } + + +class CampaignBuildReport(BaseModel): + model_config = ConfigDict(extra="forbid") + + campaign_id: str + campaign_name: str + campaign_file: str + entries_count: int + messages: list[MessageDraft] = Field(default_factory=list) + + @property + def built_count(self) -> int: + return sum(1 for message in self.messages if message.build_status == BuildStatus.BUILT) + + @property + def build_failed_count(self) -> int: + return sum(1 for message in self.messages if message.build_status == BuildStatus.BUILD_FAILED) + + @property + def ready_count(self) -> int: + return sum(1 for message in self.messages if message.validation_status == MessageValidationStatus.READY) + + @property + def warning_count(self) -> int: + return sum(1 for message in self.messages if message.validation_status == MessageValidationStatus.WARNING) + + @property + def needs_review_count(self) -> int: + return sum(1 for message in self.messages if message.validation_status == MessageValidationStatus.NEEDS_REVIEW) + + @property + def blocked_count(self) -> int: + return sum(1 for message in self.messages if message.validation_status == MessageValidationStatus.BLOCKED) + + @property + def excluded_count(self) -> int: + return sum(1 for message in self.messages if message.validation_status == MessageValidationStatus.EXCLUDED) + + @property + def inactive_count(self) -> int: + return sum(1 for message in self.messages if message.validation_status == MessageValidationStatus.INACTIVE) + + @property + def queueable_count(self) -> int: + return sum(1 for message in self.messages if message.is_queueable) diff --git a/server/app/mailer/persistence/__init__.py b/server/app/mailer/persistence/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app/mailer/persistence/campaigns.py b/server/app/mailer/persistence/campaigns.py new file mode 100644 index 0000000..b65291d --- /dev/null +++ b/server/app/mailer/persistence/campaigns.py @@ -0,0 +1,309 @@ +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +import copy + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.db.models import ( + Campaign, + CampaignIssue, + CampaignJob, + CampaignStatus, + CampaignVersion, + JobBuildStatus, + JobImapStatus, + JobQueueStatus, + JobSendStatus, + JobValidationStatus, +) +from app.mailer.campaign.loader import load_campaign_config +from app.mailer.campaign.validation import Severity, validate_campaign_config +from app.mailer.messages.builder import build_campaign_messages +from app.mailer.messages.models import MessageDraft + +RUNTIME_DIR = Path(__file__).resolve().parents[3] / "runtime" +CAMPAIGN_SNAPSHOT_DIR = RUNTIME_DIR / "campaign_snapshots" +BUILD_OUTPUT_DIR = RUNTIME_DIR / "generated_eml" + + +class CampaignPersistenceError(RuntimeError): + pass + + +def _ensure_dirs() -> None: + CAMPAIGN_SNAPSHOT_DIR.mkdir(parents=True, exist_ok=True) + BUILD_OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + + +def _write_campaign_snapshot(version: CampaignVersion) -> Path: + _ensure_dirs() + path = CAMPAIGN_SNAPSHOT_DIR / f"{version.id}.json" + path.write_text(json.dumps(version.raw_json, ensure_ascii=False, indent=2), encoding="utf-8") + return path + + +def _next_version_number(session: Session, campaign_id: str) -> int: + current = session.query(func.max(CampaignVersion.version_number)).filter(CampaignVersion.campaign_id == campaign_id).scalar() + return int(current or 0) + 1 + + +def _resolve_runtime_path(base_path: Path | None, value: str | None) -> str | None: + if not value or base_path is None: + return value + path = Path(value).expanduser() + if path.is_absolute(): + return str(path) + return str((base_path / path).resolve()) + + +def normalize_campaign_paths(raw_json: dict[str, Any], source_base_path: str | Path | None) -> dict[str, Any]: + """Return a DB/runtime-safe campaign JSON snapshot. + + The CLI naturally resolves relative paths against the campaign.json file. + Once the campaign is stored in the database, the JSON snapshot lives in + app/mailer/runtime/campaign_snapshots. To keep existing file-based + campaigns working, relative file paths are normalized to absolute paths at + import time when a source_base_path is known. + """ + base = Path(source_base_path).expanduser().resolve() if source_base_path else None + data = copy.deepcopy(raw_json) + + template_source = data.get("template", {}).get("source") if isinstance(data.get("template"), dict) else None + if isinstance(template_source, dict): + for key in ("subject_path", "text_path", "html_path"): + template_source[key] = _resolve_runtime_path(base, template_source.get(key)) + + entries_source = data.get("entries", {}).get("source") if isinstance(data.get("entries"), dict) else None + if isinstance(entries_source, dict): + entries_source["path"] = _resolve_runtime_path(base, entries_source.get("path")) + + attachments = data.get("attachments") + if isinstance(attachments, dict): + attachments["base_path"] = _resolve_runtime_path(base, attachments.get("base_path")) or "." + + return data + + +def create_campaign_version_from_json( + session: Session, + *, + tenant_id: str, + user_id: str | None, + raw_json: dict[str, Any], + source_filename: str | None = None, + source_base_path: str | None = None, +) -> tuple[Campaign, CampaignVersion]: + if source_base_path is None and source_filename: + source_path = Path(source_filename).expanduser() + source_base_path = str(source_path.parent if source_path.suffix else source_path) + + runtime_json = normalize_campaign_paths(raw_json, source_base_path) + + # load_campaign_config is file-oriented. Use a temporary snapshot for schema/Pydantic validation. + _ensure_dirs() + tmp_path = CAMPAIGN_SNAPSHOT_DIR / "_incoming_campaign.json" + tmp_path.write_text(json.dumps(runtime_json, ensure_ascii=False, indent=2), encoding="utf-8") + config = load_campaign_config(tmp_path) + + campaign = ( + session.query(Campaign) + .filter(Campaign.tenant_id == tenant_id, Campaign.external_id == config.campaign.id) + .one_or_none() + ) + if campaign is None: + campaign = Campaign( + tenant_id=tenant_id, + created_by_user_id=user_id, + external_id=config.campaign.id, + name=config.campaign.name, + description=config.campaign.description, + status=CampaignStatus.DRAFT.value, + ) + session.add(campaign) + session.flush() + else: + campaign.name = config.campaign.name + campaign.description = config.campaign.description + + version = CampaignVersion( + campaign_id=campaign.id, + version_number=_next_version_number(session, campaign.id), + raw_json=runtime_json, + schema_version=raw_json.get("version", "1.0"), + source_filename=source_filename, + source_base_path=source_base_path, + ) + session.add(version) + session.flush() + campaign.current_version_id = version.id + session.add(campaign) + _write_campaign_snapshot(version) + session.commit() + return campaign, version + + +def load_version_config(session: Session, version_id: str): + version = session.get(CampaignVersion, version_id) + if not version: + raise CampaignPersistenceError(f"Campaign version not found: {version_id}") + path = _write_campaign_snapshot(version) + return version, path, load_campaign_config(path) + + +def validate_campaign_version(session: Session, *, tenant_id: str, version_id: str, check_files: bool = False) -> dict[str, Any]: + version, snapshot_path, config = load_version_config(session, version_id) + campaign = session.get(Campaign, version.campaign_id) + if not campaign or campaign.tenant_id != tenant_id: + raise CampaignPersistenceError("Campaign version is not accessible for this tenant") + + report = validate_campaign_config(config, campaign_file=snapshot_path, check_files=check_files) + report_json = report.model_dump(mode="json") + report_json.update({"ok": report.ok, "error_count": report.error_count, "warning_count": report.warning_count}) + version.validation_summary = report_json + + # Replace version-level semantic issues from previous validations. + ( + session.query(CampaignIssue) + .filter(CampaignIssue.campaign_version_id == version.id, CampaignIssue.job_id.is_(None)) + .delete(synchronize_session=False) + ) + for issue in report.issues: + session.add( + CampaignIssue( + tenant_id=tenant_id, + campaign_id=campaign.id, + campaign_version_id=version.id, + severity=issue.severity.value, + code=issue.code, + message=issue.message, + source=issue.path, + ) + ) + + campaign.status = CampaignStatus.VALIDATED.value if report.ok else CampaignStatus.NEEDS_REVIEW.value + if report.ok: + version.workflow_state = "under_review" + version.is_complete = True + session.add(version) + session.add(campaign) + session.commit() + return report_json + + +def _job_validation_status(value: str) -> str: + allowed = {item.value for item in JobValidationStatus} + return value if value in allowed else JobValidationStatus.NEEDS_REVIEW.value + + +def _job_from_message( + *, + tenant_id: str, + campaign_id: str, + version_id: str, + message: MessageDraft, +) -> CampaignJob: + recipient_email = message.to[0].email if message.to else None + return CampaignJob( + tenant_id=tenant_id, + campaign_id=campaign_id, + campaign_version_id=version_id, + entry_index=message.entry_index, + entry_id=message.entry_id, + recipient_email=recipient_email, + subject=message.subject, + eml_local_path=message.eml_path, + eml_size_bytes=message.eml_size_bytes, + build_status=message.build_status.value if hasattr(message.build_status, "value") else str(message.build_status), + validation_status=_job_validation_status(message.validation_status.value), + queue_status=JobQueueStatus.DRAFT.value, + send_status=JobSendStatus.NOT_QUEUED.value, + imap_status=message.imap_status.value if hasattr(message.imap_status, "value") else JobImapStatus.NOT_REQUESTED.value, + resolved_recipients={ + "from": message.from_.model_dump(mode="json") if message.from_ else None, + "to": [item.model_dump(mode="json") for item in message.to], + "cc": [item.model_dump(mode="json") for item in message.cc], + "bcc": [item.model_dump(mode="json") for item in message.bcc], + "reply_to": [item.model_dump(mode="json") for item in message.reply_to], + "bounce_to": [item.model_dump(mode="json") for item in message.bounce_to], + "disposition_notification_to": [item.model_dump(mode="json") for item in message.disposition_notification_to], + }, + resolved_attachments=[item.model_dump(mode="json") for item in message.attachments], + issues_snapshot=[item.model_dump(mode="json") for item in message.issues], + last_error="; ".join(issue.message for issue in message.issues if issue.severity == "error") or None, + ) + + +def build_campaign_version( + session: Session, + *, + tenant_id: str, + version_id: str, + write_eml: bool = True, +) -> dict[str, Any]: + version, snapshot_path, config = load_version_config(session, version_id) + campaign = session.get(Campaign, version.campaign_id) + if not campaign or campaign.tenant_id != tenant_id: + raise CampaignPersistenceError("Campaign version is not accessible for this tenant") + + output_dir = BUILD_OUTPUT_DIR / campaign.id / version.id + result = build_campaign_messages(config, campaign_file=snapshot_path, output_dir=output_dir, write_eml=write_eml) + report_json = result.report.model_dump(mode="json", by_alias=True) + report_json.update({ + "built_count": result.report.built_count, + "build_failed_count": result.report.build_failed_count, + "ready_count": result.report.ready_count, + "warning_count": result.report.warning_count, + "needs_review_count": result.report.needs_review_count, + "blocked_count": result.report.blocked_count, + "excluded_count": result.report.excluded_count, + "inactive_count": result.report.inactive_count, + "queueable_count": result.report.queueable_count, + }) + version.build_summary = report_json + + # Rebuild jobs for the current version. Later, protect sent jobs from destructive rebuilds. + session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id, CampaignIssue.job_id.is_not(None)).delete(synchronize_session=False) + session.query(CampaignJob).filter(CampaignJob.campaign_version_id == version.id).delete(synchronize_session=False) + session.flush() + + for built in result.built_messages: + job = _job_from_message( + tenant_id=tenant_id, + campaign_id=campaign.id, + version_id=version.id, + message=built.draft, + ) + session.add(job) + session.flush() + for issue in built.draft.issues: + session.add( + CampaignIssue( + tenant_id=tenant_id, + campaign_id=campaign.id, + campaign_version_id=version.id, + job_id=job.id, + severity=issue.severity, + code=issue.code, + message=issue.message, + source=issue.source, + behavior=issue.behavior, + ) + ) + + if result.report.needs_review_count or result.report.blocked_count: + campaign.status = CampaignStatus.NEEDS_REVIEW.value + version.workflow_state = "under_review" + elif result.report.queueable_count > 0: + campaign.status = CampaignStatus.READY_TO_QUEUE.value + version.workflow_state = "built" + else: + campaign.status = CampaignStatus.VALIDATED.value + + session.add(version) + session.add(campaign) + session.commit() + return report_json diff --git a/server/app/mailer/persistence/versions.py b/server/app/mailer/persistence/versions.py new file mode 100644 index 0000000..28d5d4c --- /dev/null +++ b/server/app/mailer/persistence/versions.py @@ -0,0 +1,346 @@ +from __future__ import annotations + +import copy +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app.db.models import ( + Campaign, + CampaignIssue, + CampaignStatus, + CampaignVersion, + CampaignVersionFlow, + CampaignVersionWorkflowState, +) +from app.mailer.campaign.loader import load_campaign_config +from app.mailer.persistence.campaigns import ( + CAMPAIGN_SNAPSHOT_DIR, + CampaignPersistenceError, + _ensure_dirs, + _next_version_number, + _write_campaign_snapshot, + normalize_campaign_paths, +) + + +def minimal_campaign_json(*, external_id: str, name: str, description: str | None = None) -> dict[str, Any]: + """Return a WebUI-friendly starter campaign JSON. + + It is intentionally usable as an editable working copy. It contains the + main sections the UI expects, but it may still be incomplete from the + strict send/build perspective until the user configures recipients, + template and sender details. + """ + + return { + "version": "1.0", + "campaign": { + "id": external_id, + "name": name, + "description": description or "", + "mode": "draft", + }, + "fields": [], + "global_values": {}, + "server": { + "smtp": { + "host": "", + "port": 587, + "username": "", + "password": "", + "security": "starttls", + }, + "imap": { + "enabled": False, + "host": "", + "port": 993, + "username": "", + "password": "", + "security": "tls", + "sent_folder": "auto", + }, + }, + "recipients": { + "from": {"name": "", "email": ""}, + "allow_individual_from": False, + "to": [], + "allow_individual_to": True, + "cc": [], + "allow_individual_cc": False, + "bcc": [], + "allow_individual_bcc": False, + "reply_to": [], + "allow_individual_reply_to": False, + "bounce_to": [], + "allow_individual_bounce_to": False, + "disposition_notification_to": [], + "allow_individual_disposition_notification_to": False, + }, + "template": { + "subject": "", + "text": "", + "html": None, + }, + "attachments": { + "base_path": ".", + "allow_individual": True, + "send_without_attachments": False, + "global": [], + "missing_behavior": "ask", + "ambiguous_behavior": "ask", + }, + "entries": { + "inline": [], + "defaults": { + "active": True, + "combine_to": False, + "combine_cc": True, + "combine_bcc": True, + "combine_reply_to": True, + "combine_bounce_to": True, + "combine_disposition_notification_to": True, + "combine_attachments": True, + "attachments": [], + }, + }, + "validation_policy": { + "missing_required_attachment": "ask", + "missing_optional_attachment": "warn", + "ambiguous_attachment_match": "ask", + "missing_email": "block", + "template_error": "block", + }, + "delivery": { + "rate_limit": { + "messages_per_minute": 5, + "concurrency": 1, + }, + "imap_append_sent": { + "enabled": False, + "folder": "auto", + }, + "retry": { + "max_attempts": 3, + "backoff_seconds": [60, 300, 900], + }, + }, + "status_tracking": { + "enabled": True, + }, + } + + +def create_minimal_campaign( + session: Session, + *, + tenant_id: str, + user_id: str | None, + external_id: str, + name: str, + description: str | None = None, + current_flow: str = CampaignVersionFlow.CREATE.value, + current_step: str = "basics", +) -> tuple[Campaign, CampaignVersion]: + existing = session.query(Campaign).filter(Campaign.tenant_id == tenant_id, Campaign.external_id == external_id).one_or_none() + if existing: + raise CampaignPersistenceError(f"Campaign with id '{external_id}' already exists for this tenant") + + campaign = Campaign( + tenant_id=tenant_id, + created_by_user_id=user_id, + external_id=external_id, + name=name, + description=description, + status=CampaignStatus.DRAFT.value, + ) + session.add(campaign) + session.flush() + + version = CampaignVersion( + campaign_id=campaign.id, + version_number=1, + raw_json=minimal_campaign_json(external_id=external_id, name=name, description=description), + schema_version="1.0", + workflow_state=CampaignVersionWorkflowState.EDITING.value, + current_flow=current_flow, + current_step=current_step, + is_complete=False, + editor_state={"created_from": "minimal_campaign"}, + autosaved_at=datetime.now(UTC), + ) + session.add(version) + session.flush() + campaign.current_version_id = version.id + session.add(campaign) + _write_campaign_snapshot(version) + session.commit() + return campaign, version + + +def get_campaign_version_for_tenant( + session: Session, + *, + tenant_id: str, + campaign_id: str, + version_id: str, +) -> CampaignVersion: + campaign = session.get(Campaign, campaign_id) + version = session.get(CampaignVersion, version_id) + if not campaign or campaign.tenant_id != tenant_id or not version or version.campaign_id != campaign.id: + raise CampaignPersistenceError("Campaign version not found") + return version + + +def update_campaign_version( + session: Session, + *, + tenant_id: str, + campaign_id: str, + version_id: str, + raw_json: dict[str, Any] | None = None, + current_flow: str | None = None, + current_step: str | None = None, + workflow_state: str | None = None, + is_complete: bool | None = None, + editor_state: dict[str, Any] | None = None, + source_filename: str | None = None, + source_base_path: str | None = None, + autosave: bool = False, +) -> CampaignVersion: + version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id) + campaign = session.get(Campaign, campaign_id) + assert campaign is not None + + if raw_json is not None: + runtime_json = normalize_campaign_paths(raw_json, source_base_path) if source_base_path else copy.deepcopy(raw_json) + version.raw_json = runtime_json + version.schema_version = str(runtime_json.get("version", version.schema_version or "1.0")) + campaign_meta = runtime_json.get("campaign") if isinstance(runtime_json.get("campaign"), dict) else {} + if campaign_meta: + campaign.name = campaign_meta.get("name") or campaign.name + campaign.description = campaign_meta.get("description", campaign.description) + campaign.external_id = campaign_meta.get("id") or campaign.external_id + + if current_flow is not None: + version.current_flow = current_flow + if current_step is not None: + version.current_step = current_step + if workflow_state is not None: + version.workflow_state = workflow_state + if is_complete is not None: + version.is_complete = is_complete + if editor_state is not None: + version.editor_state = editor_state + if source_filename is not None: + version.source_filename = source_filename + if source_base_path is not None: + version.source_base_path = source_base_path + if autosave: + version.autosaved_at = datetime.now(UTC) + + # Changes invalidate previous build and validation summaries. + if raw_json is not None: + version.validation_summary = None + version.build_summary = None + session.query(CampaignIssue).filter(CampaignIssue.campaign_version_id == version.id).delete(synchronize_session=False) + + session.add(version) + session.add(campaign) + session.flush() + _write_campaign_snapshot(version) + session.commit() + return version + + +def publish_campaign_version( + session: Session, + *, + tenant_id: str, + campaign_id: str, + version_id: str, +) -> CampaignVersion: + version = get_campaign_version_for_tenant(session, tenant_id=tenant_id, campaign_id=campaign_id, version_id=version_id) + campaign = session.get(Campaign, campaign_id) + assert campaign is not None + version.workflow_state = CampaignVersionWorkflowState.APPROVED.value + version.published_at = datetime.now(UTC) + campaign.current_version_id = version.id + campaign.status = CampaignStatus.VALIDATED.value + session.add(version) + session.add(campaign) + session.commit() + return version + + +def validate_campaign_partial(raw_json: dict[str, Any], *, section: str | None = None) -> dict[str, Any]: + """Lightweight UI-facing validation for incomplete campaign working copies. + + This is intentionally less strict than campaign.schema.json validation. It + lets the WebUI autosave and validate one wizard step at a time. + """ + + issues: list[dict[str, Any]] = [] + + def issue(severity: str, sec: str, field: str, code: str, message: str) -> None: + if section is None or section == sec: + issues.append({ + "severity": severity, + "section": sec, + "field": field, + "code": code, + "message": message, + }) + + campaign = raw_json.get("campaign") if isinstance(raw_json.get("campaign"), dict) else {} + if not campaign.get("id"): + issue("error", "basics", "campaign.id", "missing_campaign_id", "Campaign id is required.") + if not campaign.get("name"): + issue("error", "basics", "campaign.name", "missing_campaign_name", "Campaign name is required.") + + recipients = raw_json.get("recipients") if isinstance(raw_json.get("recipients"), dict) else {} + sender = recipients.get("from") if isinstance(recipients.get("from"), dict) else {} + if not sender.get("email"): + issue("warning", "sender", "recipients.from.email", "missing_sender_email", "Sender email is not configured yet.") + + entries = raw_json.get("entries") if isinstance(raw_json.get("entries"), dict) else {} + has_inline = bool(entries.get("inline")) + has_source = isinstance(entries.get("source"), dict) + if not has_inline and not has_source: + issue("warning", "recipients", "entries", "missing_recipients", "No inline recipients or external recipient source configured yet.") + if has_source: + mapping = entries.get("mapping") if isinstance(entries.get("mapping"), dict) else {} + if not any(key in mapping for key in ("to.0.email", "to.email", "email")): + issue("warning", "recipients", "entries.mapping", "missing_email_mapping", "No email field mapping is configured.") + + template = raw_json.get("template") if isinstance(raw_json.get("template"), dict) else {} + if not template.get("subject") and not (isinstance(template.get("source"), dict) and template["source"].get("subject_path")): + issue("warning", "template", "template.subject", "missing_subject", "Template subject is empty.") + if not template.get("text") and not template.get("html") and not isinstance(template.get("source"), dict): + issue("warning", "template", "template", "missing_template_body", "No text, HTML or file-based template body configured yet.") + + attachments = raw_json.get("attachments") if isinstance(raw_json.get("attachments"), dict) else {} + if not attachments.get("base_path"): + issue("info", "attachments", "attachments.base_path", "missing_attachment_base_path", "Attachment base path is not configured yet.") + + delivery = raw_json.get("delivery") if isinstance(raw_json.get("delivery"), dict) else {} + rate_limit = delivery.get("rate_limit") if isinstance(delivery.get("rate_limit"), dict) else {} + messages_per_minute = rate_limit.get("messages_per_minute") + if messages_per_minute is not None: + try: + if int(messages_per_minute) < 1: + issue("error", "send", "delivery.rate_limit.messages_per_minute", "invalid_rate_limit", "Messages per minute must be at least 1.") + except (TypeError, ValueError): + issue("error", "send", "delivery.rate_limit.messages_per_minute", "invalid_rate_limit", "Messages per minute must be a number.") + + return { + "ok": not any(item["severity"] == "error" for item in issues), + "section": section, + "error_count": sum(1 for item in issues if item["severity"] == "error"), + "warning_count": sum(1 for item in issues if item["severity"] == "warning"), + "info_count": sum(1 for item in issues if item["severity"] == "info"), + "issues": issues, + } diff --git a/server/app/mailer/reports/__init__.py b/server/app/mailer/reports/__init__.py new file mode 100644 index 0000000..fe46cb0 --- /dev/null +++ b/server/app/mailer/reports/__init__.py @@ -0,0 +1 @@ +"""Reporting helpers for campaigns and jobs.""" diff --git a/server/app/mailer/reports/campaigns.py b/server/app/mailer/reports/campaigns.py new file mode 100644 index 0000000..16f5da7 --- /dev/null +++ b/server/app/mailer/reports/campaigns.py @@ -0,0 +1,351 @@ +from __future__ import annotations + +import csv +import io +import math +from collections import Counter +from datetime import datetime, timezone +from typing import Any + +from sqlalchemy.orm import Session + +from app.db.models import ( + Campaign, + CampaignIssue, + CampaignJob, + CampaignVersion, + ImapAppendAttempt, + SendAttempt, +) +from app.mailer.campaign.loader import load_campaign_config +from app.mailer.persistence.campaigns import _write_campaign_snapshot + + +class CampaignReportError(RuntimeError): + pass + + +def _utcnow_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _counter(values: list[str | None]) -> dict[str, int]: + return dict(Counter(value or "unknown" for value in values)) + + +def _get_campaign(session: Session, *, tenant_id: str, campaign_id: str) -> Campaign: + campaign = session.query(Campaign).filter(Campaign.tenant_id == tenant_id, Campaign.id == campaign_id).one_or_none() + if not campaign: + raise CampaignReportError(f"Campaign not found or not accessible: {campaign_id}") + return campaign + + +def _current_version(session: Session, campaign: Campaign) -> CampaignVersion | None: + if not campaign.current_version_id: + return None + version = session.get(CampaignVersion, campaign.current_version_id) + if version and version.campaign_id == campaign.id: + return version + return None + + +def _version_info(version: CampaignVersion | None) -> dict[str, Any] | None: + if not version: + return None + return { + "id": version.id, + "version_number": version.version_number, + "schema_version": version.schema_version, + "source_filename": version.source_filename, + "created_at": version.created_at.isoformat() if version.created_at else None, + "validation_summary": version.validation_summary, + "build_summary": version.build_summary, + } + + +def _load_delivery_info(version: CampaignVersion | None, jobs: list[CampaignJob]) -> dict[str, Any]: + """Extract rate-limit and IMAP settings from the version JSON where possible. + + This stays best-effort so reports still work if the schema evolves or a + partial/invalid campaign snapshot exists. + """ + + default = { + "rate_limit": {"messages_per_minute": None, "concurrency": None}, + "imap_append_sent": {"enabled": None, "folder": None}, + "retry": {"max_attempts": None, "backoff_seconds": []}, + "estimated_remaining_send_seconds": None, + "estimated_remaining_send_human": None, + } + if not version: + return default + try: + snapshot_path = _write_campaign_snapshot(version) + config = load_campaign_config(snapshot_path) + except Exception as exc: # pragma: no cover - reporting should not fail hard here + default["load_error"] = str(exc) + return default + + messages_per_minute = config.delivery.rate_limit.messages_per_minute + pending = [job for job in jobs if job.send_status in {"queued", "failed_temporary", "sending"}] + estimated_seconds = None + if messages_per_minute and pending: + estimated_seconds = int(math.ceil((len(pending) / messages_per_minute) * 60)) + + return { + "rate_limit": { + "messages_per_minute": messages_per_minute, + "concurrency": config.delivery.rate_limit.concurrency, + }, + "imap_append_sent": { + "enabled": config.delivery.imap_append_sent.enabled, + "folder": config.delivery.imap_append_sent.folder, + }, + "retry": { + "max_attempts": config.delivery.retry.max_attempts, + "backoff_seconds": config.delivery.retry.backoff_seconds, + }, + "estimated_remaining_send_seconds": estimated_seconds, + "estimated_remaining_send_human": _human_duration(estimated_seconds), + } + + +def _human_duration(seconds: int | None) -> str | None: + if seconds is None: + return None + if seconds < 60: + return f"{seconds}s" + minutes, sec = divmod(seconds, 60) + if minutes < 60: + return f"{minutes}m {sec}s" if sec else f"{minutes}m" + hours, minute = divmod(minutes, 60) + return f"{hours}h {minute}m" if minute else f"{hours}h" + + +def _issue_summary_from_jobs(jobs: list[CampaignJob]) -> dict[str, Any]: + severity_counter: Counter[str] = Counter() + code_counter: Counter[str] = Counter() + behavior_counter: Counter[str] = Counter() + total = 0 + for job in jobs: + for issue in job.issues_snapshot or []: + if not isinstance(issue, dict): + continue + total += 1 + severity_counter[issue.get("severity") or "unknown"] += 1 + code_counter[issue.get("code") or "unknown"] += 1 + if issue.get("behavior"): + behavior_counter[issue["behavior"]] += 1 + return { + "total": total, + "by_severity": dict(severity_counter), + "by_code": dict(code_counter), + "by_behavior": dict(behavior_counter), + } + + +def _attachment_summary(jobs: list[CampaignJob]) -> dict[str, Any]: + status_counter: Counter[str] = Counter() + behavior_counter: Counter[str] = Counter() + total_configs = 0 + total_matched_files = 0 + zip_enabled = 0 + missing = 0 + ambiguous = 0 + for job in jobs: + for attachment in job.resolved_attachments or []: + if not isinstance(attachment, dict): + continue + total_configs += 1 + status = attachment.get("status") or "unknown" + status_counter[status] += 1 + if attachment.get("behavior"): + behavior_counter[attachment["behavior"]] += 1 + matches = attachment.get("matches") or [] + if isinstance(matches, list): + total_matched_files += len(matches) + if attachment.get("zip_enabled"): + zip_enabled += 1 + if status == "missing": + missing += 1 + if status == "ambiguous": + ambiguous += 1 + return { + "total_attachment_configs": total_configs, + "total_matched_files": total_matched_files, + "zip_enabled_configs": zip_enabled, + "missing_configs": missing, + "ambiguous_configs": ambiguous, + "by_status": dict(status_counter), + "by_behavior": dict(behavior_counter), + } + + +def _recent_failures(jobs: list[CampaignJob], *, limit: int = 20) -> list[dict[str, Any]]: + failed = [job for job in jobs if job.last_error or str(job.send_status).startswith("failed") or job.imap_status == "failed"] + failed.sort(key=lambda job: job.updated_at or job.created_at, reverse=True) + return [ + { + "job_id": job.id, + "entry_index": job.entry_index, + "entry_id": job.entry_id, + "recipient_email": job.recipient_email, + "validation_status": job.validation_status, + "send_status": job.send_status, + "imap_status": job.imap_status, + "attempt_count": job.attempt_count, + "last_error": job.last_error, + "updated_at": job.updated_at.isoformat() if job.updated_at else None, + } + for job in failed[:limit] + ] + + +def _job_row(job: CampaignJob) -> dict[str, Any]: + return { + "job_id": job.id, + "entry_index": job.entry_index, + "entry_id": job.entry_id, + "recipient_email": job.recipient_email, + "subject": job.subject, + "build_status": job.build_status, + "validation_status": job.validation_status, + "queue_status": job.queue_status, + "send_status": job.send_status, + "imap_status": job.imap_status, + "attempt_count": job.attempt_count, + "queued_at": job.queued_at.isoformat() if job.queued_at else None, + "sent_at": job.sent_at.isoformat() if job.sent_at else None, + "last_error": job.last_error, + "eml_size_bytes": job.eml_size_bytes, + "issues_count": len(job.issues_snapshot or []), + "attachment_config_count": len(job.resolved_attachments or []), + "matched_file_count": sum(len(item.get("matches") or []) for item in (job.resolved_attachments or []) if isinstance(item, dict)), + } + + +def generate_campaign_report( + session: Session, + *, + tenant_id: str, + campaign_id: str, + include_jobs: bool = False, + include_recent_failures: bool = True, +) -> dict[str, Any]: + """Generate a dashboard/report payload for one campaign. + + The shape is intentionally web-UI friendly: status counters for cards, + issue/attachment summaries for review panels, and optional job rows for + tables/export. + """ + + campaign = _get_campaign(session, tenant_id=tenant_id, campaign_id=campaign_id) + version = _current_version(session, campaign) + jobs = ( + session.query(CampaignJob) + .filter(CampaignJob.tenant_id == tenant_id, CampaignJob.campaign_id == campaign.id) + .order_by(CampaignJob.entry_index.asc()) + .all() + ) + job_ids = [job.id for job in jobs] + send_attempts = session.query(SendAttempt).filter(SendAttempt.job_id.in_(job_ids)).count() if job_ids else 0 + imap_attempts = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id.in_(job_ids)).count() if job_ids else 0 + persisted_issues = session.query(CampaignIssue).filter(CampaignIssue.tenant_id == tenant_id, CampaignIssue.campaign_id == campaign.id).count() + + validation_counts = _counter([job.validation_status for job in jobs]) + queue_counts = _counter([job.queue_status for job in jobs]) + send_counts = _counter([job.send_status for job in jobs]) + imap_counts = _counter([job.imap_status for job in jobs]) + build_counts = _counter([job.build_status for job in jobs]) + + queueable = sum(1 for job in jobs if job.validation_status in {"ready", "warning"} and job.build_status == "built") + needs_attention = sum( + 1 + for job in jobs + if job.validation_status in {"needs_review", "blocked"} + or job.send_status in {"failed_temporary", "failed_permanent"} + or job.imap_status == "failed" + ) + sent = send_counts.get("sent", 0) + failed = send_counts.get("failed_temporary", 0) + send_counts.get("failed_permanent", 0) + + report: dict[str, Any] = { + "generated_at": _utcnow_iso(), + "campaign": { + "id": campaign.id, + "external_id": campaign.external_id, + "name": campaign.name, + "description": campaign.description, + "status": campaign.status, + "created_at": campaign.created_at.isoformat() if campaign.created_at else None, + "updated_at": campaign.updated_at.isoformat() if campaign.updated_at else None, + }, + "current_version": _version_info(version), + "cards": { + "jobs_total": len(jobs), + "queueable": queueable, + "needs_attention": needs_attention, + "sent": sent, + "failed": failed, + "imap_appended": imap_counts.get("appended", 0), + "imap_failed": imap_counts.get("failed", 0), + }, + "status_counts": { + "build": build_counts, + "validation": validation_counts, + "queue": queue_counts, + "send": send_counts, + "imap": imap_counts, + }, + "issues": { + **_issue_summary_from_jobs(jobs), + "persisted_campaign_issue_count": persisted_issues, + }, + "attachments": _attachment_summary(jobs), + "attempts": { + "send_attempts": int(send_attempts), + "imap_append_attempts": int(imap_attempts), + }, + "delivery": _load_delivery_info(version, jobs), + } + if include_recent_failures: + report["recent_failures"] = _recent_failures(jobs) + if include_jobs: + report["jobs"] = [_job_row(job) for job in jobs] + return report + + +def generate_jobs_csv(session: Session, *, tenant_id: str, campaign_id: str) -> str: + campaign = _get_campaign(session, tenant_id=tenant_id, campaign_id=campaign_id) + jobs = ( + session.query(CampaignJob) + .filter(CampaignJob.tenant_id == tenant_id, CampaignJob.campaign_id == campaign.id) + .order_by(CampaignJob.entry_index.asc()) + .all() + ) + rows = [_job_row(job) for job in jobs] + fieldnames = [ + "job_id", + "entry_index", + "entry_id", + "recipient_email", + "subject", + "build_status", + "validation_status", + "queue_status", + "send_status", + "imap_status", + "attempt_count", + "queued_at", + "sent_at", + "last_error", + "eml_size_bytes", + "issues_count", + "attachment_config_count", + "matched_file_count", + ] + buffer = io.StringIO() + writer = csv.DictWriter(buffer, fieldnames=fieldnames) + writer.writeheader() + writer.writerows(rows) + return buffer.getvalue() diff --git a/server/app/mailer/reports/emailing.py b/server/app/mailer/reports/emailing.py new file mode 100644 index 0000000..5bc9ebd --- /dev/null +++ b/server/app/mailer/reports/emailing.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +import json +from dataclasses import dataclass +from email.message import EmailMessage +from email.utils import formataddr +from typing import Any + +from sqlalchemy.orm import Session + +from app.db.models import Campaign, CampaignVersion +from app.mailer.campaign.loader import load_campaign_config +from app.mailer.campaign.models import CampaignConfig, SmtpConfig +from app.mailer.persistence.campaigns import _write_campaign_snapshot +from app.mailer.reports.campaigns import CampaignReportError, generate_campaign_report, generate_jobs_csv +from app.mailer.sending.smtp import SmtpConfigurationError, SmtpSendResult, send_email_message + + +class CampaignReportEmailError(RuntimeError): + pass + + +@dataclass(slots=True) +class CampaignReportEmailResult: + campaign_id: str + to: list[str] + subject: str + dry_run: bool + sent: bool + attached_jobs_csv: bool + attached_report_json: bool + smtp_host: str | None = None + smtp_port: int | None = None + accepted_count: int | None = None + + def as_dict(self) -> dict[str, Any]: + return { + "campaign_id": self.campaign_id, + "to": self.to, + "subject": self.subject, + "dry_run": self.dry_run, + "sent": self.sent, + "attached_jobs_csv": self.attached_jobs_csv, + "attached_report_json": self.attached_report_json, + "smtp_host": self.smtp_host, + "smtp_port": self.smtp_port, + "accepted_count": self.accepted_count, + } + + +def _current_version(session: Session, campaign: Campaign) -> CampaignVersion: + version = session.get(CampaignVersion, campaign.current_version_id) if campaign.current_version_id else None + if version is None: + version = ( + session.query(CampaignVersion) + .filter(CampaignVersion.campaign_id == campaign.id) + .order_by(CampaignVersion.version_number.desc()) + .first() + ) + if version is None: + raise CampaignReportEmailError("Campaign has no version") + return version + + +def _load_config(version: CampaignVersion) -> CampaignConfig: + snapshot_path = _write_campaign_snapshot(version) + return load_campaign_config(snapshot_path) + + +def _effective_from(config: CampaignConfig) -> tuple[str, str | None]: + if config.recipients.from_: + return config.recipients.from_.email, config.recipients.from_.name + if config.server.smtp and config.server.smtp.username and "@" in config.server.smtp.username: + return config.server.smtp.username, None + raise SmtpConfigurationError("Report email requires recipients.from.email or an SMTP username that is an email address") + + +def _text_summary(report: dict[str, Any]) -> str: + campaign = report["campaign"] + cards = report["cards"] + status = report["status_counts"] + delivery = report.get("delivery", {}) + lines = [ + f"Campaign report: {campaign['name']}", + "", + f"Campaign ID: {campaign['id']}", + f"External ID: {campaign['external_id']}", + f"Status: {campaign['status']}", + "", + "Overview", + f"- Jobs total: {cards['jobs_total']}", + f"- Queueable: {cards['queueable']}", + f"- Needs attention: {cards['needs_attention']}", + f"- Sent: {cards['sent']}", + f"- Failed: {cards['failed']}", + f"- IMAP appended: {cards['imap_appended']}", + f"- IMAP failed: {cards['imap_failed']}", + "", + f"Build status: {status.get('build', {})}", + f"Validation status: {status.get('validation', {})}", + f"Queue status: {status.get('queue', {})}", + f"Send status: {status.get('send', {})}", + f"IMAP status: {status.get('imap', {})}", + ] + if delivery.get("estimated_remaining_send_human"): + lines.extend(["", f"Estimated remaining send time: {delivery['estimated_remaining_send_human']}"]) + lines.extend(["", "This report was generated by MultiMailer."]) + return "\n".join(lines) + + +def build_report_message( + *, + campaign: Campaign, + config: CampaignConfig, + report: dict[str, Any], + to: list[str], + jobs_csv: str | None = None, + report_json: dict[str, Any] | None = None, +) -> EmailMessage: + from_email, from_name = _effective_from(config) + subject = f"MultiMailer report: {campaign.name}" + + msg = EmailMessage() + msg["Subject"] = subject + msg["From"] = formataddr((from_name or from_email, from_email)) + msg["To"] = ", ".join(to) + msg["X-MultiMailer-Report"] = "campaign" + msg.set_content(_text_summary(report)) + + if jobs_csv is not None: + filename = f"multimailer-{campaign.external_id}-jobs.csv" + msg.add_attachment(jobs_csv.encode("utf-8"), maintype="text", subtype="csv", filename=filename) + if report_json is not None: + filename = f"multimailer-{campaign.external_id}-report.json" + msg.add_attachment( + json.dumps(report_json, indent=2, ensure_ascii=False, default=str).encode("utf-8"), + maintype="application", + subtype="json", + filename=filename, + ) + return msg + + +def send_campaign_report_email( + session: Session, + *, + tenant_id: str, + campaign_id: str, + to: list[str], + include_jobs: bool = False, + attach_jobs_csv: bool = True, + attach_report_json: bool = False, + dry_run: bool = False, +) -> CampaignReportEmailResult: + campaign = session.get(Campaign, campaign_id) + if not campaign or campaign.tenant_id != tenant_id: + raise CampaignReportError("Campaign not found") + if not to: + raise CampaignReportEmailError("At least one report recipient is required") + + version = _current_version(session, campaign) + config = _load_config(version) + smtp_config: SmtpConfig | None = config.server.smtp + if smtp_config is None: + raise SmtpConfigurationError("Campaign has no SMTP configuration") + + report = generate_campaign_report(session, tenant_id=tenant_id, campaign_id=campaign_id, include_jobs=include_jobs) + jobs_csv = generate_jobs_csv(session, tenant_id=tenant_id, campaign_id=campaign_id) if attach_jobs_csv else None + report_json = report if attach_report_json else None + message = build_report_message( + campaign=campaign, + config=config, + report=report, + to=to, + jobs_csv=jobs_csv, + report_json=report_json, + ) + envelope_from, _ = _effective_from(config) + + if dry_run: + return CampaignReportEmailResult( + campaign_id=campaign.id, + to=to, + subject=str(message["Subject"]), + dry_run=True, + sent=False, + attached_jobs_csv=jobs_csv is not None, + attached_report_json=report_json is not None, + smtp_host=smtp_config.host, + smtp_port=smtp_config.port, + ) + + result: SmtpSendResult = send_email_message( + message, + smtp_config=smtp_config, + envelope_from=envelope_from, + envelope_recipients=to, + ) + return CampaignReportEmailResult( + campaign_id=campaign.id, + to=to, + subject=str(message["Subject"]), + dry_run=False, + sent=True, + attached_jobs_csv=jobs_csv is not None, + attached_report_json=report_json is not None, + smtp_host=result.host, + smtp_port=result.port, + accepted_count=result.accepted_count, + ) diff --git a/server/app/mailer/schema/campaign.schema.json b/server/app/mailer/schema/campaign.schema.json new file mode 100644 index 0000000..b82fa19 --- /dev/null +++ b/server/app/mailer/schema/campaign.schema.json @@ -0,0 +1,804 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://multimailer.local/schema/campaign.schema.json", + "title": "MultiMailer Campaign", + "type": "object", + "required": [ + "version", + "campaign", + "template", + "entries" + ], + "properties": { + "version": { + "type": "string", + "const": "1.0" + }, + "campaign": { + "type": "object", + "required": [ + "id", + "name" + ], + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "draft", + "test", + "send" + ], + "default": "draft" + } + }, + "additionalProperties": false + }, + "fields": { + "type": "array", + "items": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "string", + "integer", + "double", + "date", + "password" + ], + "default": "string" + }, + "label": { + "type": "string" + }, + "required": { + "type": "boolean", + "default": false + } + }, + "additionalProperties": false + }, + "default": [] + }, + "global_values": { + "type": "object", + "additionalProperties": true, + "default": {} + }, + "server": { + "type": "object", + "properties": { + "smtp": { + "type": "object", + "properties": { + "host": { + "type": "string" + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "security": { + "type": "string", + "enum": [ + "plain", + "tls", + "starttls" + ], + "default": "starttls" + }, + "timeout_seconds": { + "type": "integer", + "minimum": 1, + "default": 30 + } + }, + "additionalProperties": false + }, + "imap": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false + }, + "host": { + "type": "string" + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535 + }, + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "security": { + "type": "string", + "enum": [ + "plain", + "tls", + "starttls" + ], + "default": "tls" + }, + "sent_folder": { + "type": "string", + "default": "auto" + }, + "timeout_seconds": { + "type": "integer", + "minimum": 1, + "default": 30 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "recipients": { + "type": "object", + "properties": { + "from": { + "$ref": "#/$defs/recipient" + }, + "allow_individual_from": { + "type": "boolean", + "default": false + }, + "to": { + "type": "array", + "items": { + "$ref": "#/$defs/recipient" + }, + "default": [] + }, + "allow_individual_to": { + "type": "boolean", + "default": false + }, + "cc": { + "type": "array", + "items": { + "$ref": "#/$defs/recipient" + }, + "default": [] + }, + "allow_individual_cc": { + "type": "boolean", + "default": false + }, + "bcc": { + "type": "array", + "items": { + "$ref": "#/$defs/recipient" + }, + "default": [] + }, + "allow_individual_bcc": { + "type": "boolean", + "default": false + }, + "reply_to": { + "type": "array", + "items": { + "$ref": "#/$defs/recipient" + }, + "default": [] + }, + "allow_individual_reply_to": { + "type": "boolean", + "default": false + }, + "bounce_to": { + "type": "array", + "items": { + "$ref": "#/$defs/recipient" + }, + "default": [] + }, + "allow_individual_bounce_to": { + "type": "boolean", + "default": false + }, + "disposition_notification_to": { + "type": "array", + "items": { + "$ref": "#/$defs/recipient" + }, + "default": [] + }, + "allow_individual_disposition_notification_to": { + "type": "boolean", + "default": false + } + }, + "additionalProperties": false, + "default": {} + }, + "template": { + "oneOf": [ + { + "type": "object", + "required": [ + "subject" + ], + "properties": { + "subject": { + "type": "string" + }, + "text": { + "type": "string" + }, + "html": { + "type": "string" + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "source" + ], + "properties": { + "source": { + "type": "object", + "required": [ + "type" + ], + "properties": { + "type": { + "type": "string", + "const": "files" + }, + "subject_path": { + "type": "string" + }, + "text_path": { + "type": "string" + }, + "html_path": { + "type": "string" + }, + "encoding": { + "type": "string", + "default": "utf-8" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + ] + }, + "attachments": { + "type": "object", + "properties": { + "base_path": { + "type": "string", + "default": ".", + "description": "Campaign-level base path. Global and entry attachment base_dir values are resolved relative to this path unless absolute." + }, + "allow_individual": { + "type": "boolean", + "default": false + }, + "send_without_attachments": { + "type": "boolean", + "default": true, + "description": "Legacy compatibility flag. Prefer validation_policy and per-config missing_behavior for new campaigns." + }, + "global": { + "type": "array", + "items": { + "$ref": "#/$defs/attachment_config" + }, + "default": [] + }, + "missing_behavior": { + "type": "string", + "enum": [ + "block", + "ask", + "drop", + "continue", + "warn" + ], + "default": "ask" + }, + "ambiguous_behavior": { + "type": "string", + "enum": [ + "block", + "ask", + "drop", + "continue", + "warn" + ], + "default": "ask" + } + }, + "additionalProperties": false, + "default": { + "base_path": ".", + "global": [] + } + }, + "entries": { + "oneOf": [ + { + "type": "object", + "required": [ + "inline" + ], + "properties": { + "inline": { + "type": "array", + "items": { + "$ref": "#/$defs/entry" + } + } + }, + "additionalProperties": false + }, + { + "type": "object", + "required": [ + "source", + "mapping" + ], + "properties": { + "source": { + "$ref": "#/$defs/source" + }, + "mapping": { + "type": "object", + "description": "Internal campaign path -> source column/key. Examples: to.0.email, fields.number, attachments.0.file_filter.", + "additionalProperties": { + "type": "string" + } + }, + "defaults": { + "$ref": "#/$defs/entry" + } + }, + "additionalProperties": false + } + ] + }, + "validation_policy": { + "type": "object", + "properties": { + "missing_required_attachment": { + "type": "string", + "enum": [ + "block", + "ask", + "drop", + "continue", + "warn" + ], + "default": "ask" + }, + "missing_optional_attachment": { + "type": "string", + "enum": [ + "block", + "ask", + "drop", + "continue", + "warn" + ], + "default": "warn" + }, + "ambiguous_attachment_match": { + "type": "string", + "enum": [ + "block", + "ask", + "drop", + "continue", + "warn" + ], + "default": "ask" + }, + "missing_email": { + "type": "string", + "enum": [ + "block", + "drop" + ], + "default": "block" + }, + "template_error": { + "type": "string", + "enum": [ + "block", + "drop" + ], + "default": "block" + }, + "inactive_entry": { + "type": "string", + "enum": [ + "drop", + "block", + "warn" + ], + "default": "drop" + } + }, + "additionalProperties": false, + "default": {} + }, + "delivery": { + "type": "object", + "properties": { + "rate_limit": { + "type": "object", + "properties": { + "messages_per_minute": { + "type": "integer", + "minimum": 1, + "default": 5 + }, + "concurrency": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + "additionalProperties": false + }, + "imap_append_sent": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false + }, + "folder": { + "type": "string", + "default": "auto" + } + }, + "additionalProperties": false + }, + "retry": { + "type": "object", + "properties": { + "max_attempts": { + "type": "integer", + "minimum": 1, + "default": 3 + }, + "backoff_seconds": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1 + }, + "default": [ + 60, + 300, + 900 + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "default": {} + }, + "status_tracking": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": true + }, + "initial_build_status": { + "type": "string", + "enum": [ + "built", + "build_failed" + ], + "default": "built" + }, + "initial_send_status": { + "type": "string", + "enum": [ + "draft", + "queued" + ], + "default": "draft" + } + }, + "additionalProperties": false, + "default": { + "enabled": true + } + } + }, + "additionalProperties": false, + "$defs": { + "recipient": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string", + "format": "email" + }, + "name": { + "type": "string" + }, + "type": { + "type": "string", + "enum": [ + "to", + "cc", + "bcc", + "reply_to", + "bounce_to", + "disposition_notification_to" + ], + "default": "to" + } + }, + "additionalProperties": false + }, + "attachment_config": { + "type": "object", + "required": [ + "base_dir", + "file_filter" + ], + "properties": { + "id": { + "type": "string", + "description": "Optional stable ID for UI/status references." + }, + "label": { + "type": "string" + }, + "base_dir": { + "type": "string", + "description": "Directory relative to attachments.base_path unless absolute." + }, + "file_filter": { + "type": "string", + "description": "Glob/filter expression, rendered with global/local fields before matching." + }, + "include_subdirs": { + "type": "boolean", + "default": false + }, + "required": { + "type": "boolean", + "default": true + }, + "allow_multiple": { + "type": "boolean", + "default": false + }, + "missing_behavior": { + "type": "string", + "enum": [ + "block", + "ask", + "drop", + "continue", + "warn" + ], + "default": "ask" + }, + "ambiguous_behavior": { + "type": "string", + "enum": [ + "block", + "ask", + "drop", + "continue", + "warn" + ], + "default": "ask" + }, + "zip": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "default": false + }, + "filename_template": { + "type": "string" + }, + "password_template": { + "type": "string" + }, + "method": { + "type": "string", + "enum": [ + "zip_standard", + "aes" + ], + "default": "aes" + } + }, + "additionalProperties": false, + "default": { + "enabled": false + } + } + }, + "additionalProperties": false + }, + "entry": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "active": { + "type": "boolean", + "default": true + }, + "from": { + "$ref": "#/$defs/recipient" + }, + "to": { + "type": "array", + "items": { + "$ref": "#/$defs/recipient" + }, + "default": [] + }, + "combine_to": { + "type": "boolean", + "default": true + }, + "cc": { + "type": "array", + "items": { + "$ref": "#/$defs/recipient" + }, + "default": [] + }, + "combine_cc": { + "type": "boolean", + "default": true + }, + "bcc": { + "type": "array", + "items": { + "$ref": "#/$defs/recipient" + }, + "default": [] + }, + "combine_bcc": { + "type": "boolean", + "default": true + }, + "reply_to": { + "type": "array", + "items": { + "$ref": "#/$defs/recipient" + }, + "default": [] + }, + "combine_reply_to": { + "type": "boolean", + "default": true + }, + "bounce_to": { + "type": "array", + "items": { + "$ref": "#/$defs/recipient" + }, + "default": [] + }, + "combine_bounce_to": { + "type": "boolean", + "default": true + }, + "disposition_notification_to": { + "type": "array", + "items": { + "$ref": "#/$defs/recipient" + }, + "default": [] + }, + "combine_disposition_notification_to": { + "type": "boolean", + "default": true + }, + "attachments": { + "type": "array", + "items": { + "$ref": "#/$defs/attachment_config" + }, + "default": [] + }, + "combine_attachments": { + "type": "boolean", + "default": true + }, + "fields": { + "type": "object", + "additionalProperties": true, + "default": {} + }, + "last_sent": { + "type": "string", + "format": "date-time" + } + }, + "additionalProperties": false + }, + "source": { + "type": "object", + "required": [ + "type", + "path" + ], + "properties": { + "type": { + "type": "string", + "enum": [ + "csv", + "json" + ] + }, + "path": { + "type": "string" + }, + "delimiter": { + "type": "string", + "default": ";" + }, + "encoding": { + "type": "string", + "default": "utf-8" + }, + "has_header": { + "type": "boolean", + "default": true + } + }, + "additionalProperties": false + } + } +} diff --git a/server/app/mailer/sending/__init__.py b/server/app/mailer/sending/__init__.py new file mode 100644 index 0000000..947c797 --- /dev/null +++ b/server/app/mailer/sending/__init__.py @@ -0,0 +1 @@ +"""Sending helpers for MultiMailer.""" diff --git a/server/app/mailer/sending/imap.py b/server/app/mailer/sending/imap.py new file mode 100644 index 0000000..6de25f2 --- /dev/null +++ b/server/app/mailer/sending/imap.py @@ -0,0 +1,195 @@ +from __future__ import annotations + +import imaplib +import re +import socket +import ssl +import time +from dataclasses import dataclass + +from app.mailer.campaign.models import ImapConfig, TransportSecurity + + +class ImapConfigurationError(ValueError): + """Raised when IMAP settings are incomplete or inconsistent.""" + + +class ImapAppendError(RuntimeError): + """Raised when APPENDing to Sent fails. + + temporary=True means retrying later may help. temporary=False means the + configuration or mailbox choice probably needs user/admin attention. + """ + + def __init__(self, message: str, *, temporary: bool | None = None): + super().__init__(message) + self.temporary = temporary + + +@dataclass(frozen=True, slots=True) +class ImapAppendResult: + host: str + port: int + security: str + folder: str + bytes_appended: int + response: str | None = None + + +def _require_imap_config(config: ImapConfig) -> tuple[str, int]: + if not config.enabled: + raise ImapConfigurationError("IMAP is disabled") + if not config.host: + raise ImapConfigurationError("IMAP host is required") + if not config.port: + raise ImapConfigurationError("IMAP port is required") + if bool(config.username) != bool(config.password): + raise ImapConfigurationError("IMAP username and password must be provided together, or both omitted") + return config.host, config.port + + +def _open_imap(config: ImapConfig) -> imaplib.IMAP4: + host, port = _require_imap_config(config) + context = ssl.create_default_context() + + try: + if config.security == TransportSecurity.TLS: + client: imaplib.IMAP4 = imaplib.IMAP4_SSL(host=host, port=port, timeout=config.timeout_seconds, ssl_context=context) + else: + client = imaplib.IMAP4(host=host, port=port, timeout=config.timeout_seconds) + if config.security == TransportSecurity.STARTTLS: + typ, data = client.starttls(ssl_context=context) + if typ != "OK": + raise ImapAppendError(f"IMAP STARTTLS failed: {data!r}", temporary=True) + + if config.username and config.password: + typ, data = client.login(config.username, config.password) + if typ != "OK": + raise ImapAppendError(f"IMAP login failed: {data!r}", temporary=False) + return client + except Exception: + try: + client.logout() # type: ignore[possibly-undefined] + except Exception: + pass + raise + + +def _decode_item(item: bytes | str | None) -> str: + if item is None: + return "" + if isinstance(item, bytes): + return item.decode("utf-8", errors="replace") + return item + + +def _extract_mailbox_name(list_response_line: bytes | str) -> tuple[str, set[str]] | None: + """Best-effort parser for IMAP LIST response lines. + + Example lines: + (\\HasNoChildren \\Sent) "/" "Sent" + (\\HasNoChildren) "/" "Sent Items" + """ + + line = _decode_item(list_response_line).strip() + flags_match = re.match(r"^\((?P[^)]*)\)\s+", line) + flags = set() + if flags_match: + flags = {part.lower() for part in flags_match.group("flags").split()} + + quoted = re.findall(r'"((?:[^"\\]|\\.)*)"', line) + if quoted: + # Usually: delimiter, mailbox. Take the last quoted token. + return quoted[-1].replace(r'\"', '"'), flags + + # Fallback for unquoted final atom. + parts = line.split() + if parts: + return parts[-1], flags + return None + + +def discover_sent_folder(client: imaplib.IMAP4) -> str | None: + typ, data = client.list() + if typ != "OK" or not data: + return None + + parsed: list[tuple[str, set[str]]] = [] + for item in data: + extracted = _extract_mailbox_name(item) + if extracted: + parsed.append(extracted) + + for name, flags in parsed: + if "\\sent" in flags or "\\sentmail" in flags: + return name + + common_names = [ + "Sent", + "Sent Items", + "Sent Messages", + "Gesendet", + "Gesendete Elemente", + "INBOX.Sent", + "INBOX/Sent", + ] + names = {name.lower(): name for name, _ in parsed} + for candidate in common_names: + if candidate.lower() in names: + return names[candidate.lower()] + return None + + +def _effective_sent_folder(*, config: ImapConfig, requested_folder: str | None, client: imaplib.IMAP4) -> str: + if requested_folder and requested_folder != "auto": + return requested_folder + if config.sent_folder and config.sent_folder != "auto": + return config.sent_folder + discovered = discover_sent_folder(client) + if discovered: + return discovered + raise ImapConfigurationError("Could not discover Sent folder; configure delivery.imap_append_sent.folder or server.imap.sent_folder") + + +def append_message_to_sent( + message_bytes: bytes, + *, + imap_config: ImapConfig, + folder: str | None = None, +) -> ImapAppendResult: + """Append a sent MIME message to the configured IMAP Sent folder. + + The SMTP send remains authoritative. APPEND is a separate best-effort step + and should not be used to decide whether an email was sent. + """ + + host, port = _require_imap_config(imap_config) + client: imaplib.IMAP4 | None = None + try: + client = _open_imap(imap_config) + target_folder = _effective_sent_folder(config=imap_config, requested_folder=folder, client=client) + internal_date = imaplib.Time2Internaldate(time.time()) + typ, data = client.append(target_folder, "\\Seen", internal_date, message_bytes) + if typ != "OK": + raise ImapAppendError(f"IMAP APPEND failed for folder {target_folder!r}: {data!r}", temporary=False) + response = "; ".join(_decode_item(item) for item in (data or [])) or None + return ImapAppendResult( + host=host, + port=port, + security=imap_config.security.value, + folder=target_folder, + bytes_appended=len(message_bytes), + response=response, + ) + except ImapAppendError: + raise + except (OSError, socket.timeout, imaplib.IMAP4.abort) as exc: + raise ImapAppendError(f"IMAP append failed: {exc}", temporary=True) from exc + except imaplib.IMAP4.error as exc: + raise ImapAppendError(f"IMAP append failed: {exc}", temporary=False) from exc + finally: + if client is not None: + try: + client.logout() + except Exception: + pass diff --git a/server/app/mailer/sending/jobs.py b/server/app/mailer/sending/jobs.py new file mode 100644 index 0000000..783d0d5 --- /dev/null +++ b/server/app/mailer/sending/jobs.py @@ -0,0 +1,606 @@ +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass +from datetime import datetime, timezone +from email import policy +from email.parser import BytesParser +from pathlib import Path +from typing import Any + +from sqlalchemy.orm import Session + +from app.db.models import ( + Campaign, + CampaignJob, + CampaignStatus, + CampaignVersion, + JobBuildStatus, + JobImapStatus, + JobQueueStatus, + JobSendStatus, + JobValidationStatus, + ImapAppendAttempt, + SendAttempt, +) +from app.mailer.campaign.loader import load_campaign_config +from app.mailer.campaign.models import CampaignConfig +from app.mailer.persistence.campaigns import _write_campaign_snapshot +from app.mailer.sending.rate_limit import wait_for_rate_limit +from app.mailer.sending.smtp import SmtpConfigurationError, SmtpSendError, send_email_message +from app.mailer.sending.imap import ImapAppendError, ImapConfigurationError, append_message_to_sent + + +class QueueingError(RuntimeError): + pass + + +class SendJobError(RuntimeError): + pass + + +@dataclass(frozen=True, slots=True) +class QueueCampaignResult: + campaign_id: str + version_id: str + queued_count: int + skipped_count: int + blocked_count: int + enqueued_count: int + dry_run: bool = False + + def as_dict(self) -> dict[str, Any]: + return { + "campaign_id": self.campaign_id, + "version_id": self.version_id, + "queued_count": self.queued_count, + "skipped_count": self.skipped_count, + "blocked_count": self.blocked_count, + "enqueued_count": self.enqueued_count, + "dry_run": self.dry_run, + } + + +@dataclass(frozen=True, slots=True) +class SendJobResult: + job_id: str + status: str + attempt_number: int + dry_run: bool = False + message: str | None = None + + def as_dict(self) -> dict[str, Any]: + return { + "job_id": self.job_id, + "status": self.status, + "attempt_number": self.attempt_number, + "dry_run": self.dry_run, + "message": self.message, + } + + +@dataclass(frozen=True, slots=True) +class AppendSentResult: + job_id: str + status: str + attempt_number: int + dry_run: bool = False + folder: str | None = None + message: str | None = None + + def as_dict(self) -> dict[str, Any]: + return { + "job_id": self.job_id, + "status": self.status, + "attempt_number": self.attempt_number, + "dry_run": self.dry_run, + "folder": self.folder, + "message": self.message, + } + + +QUEUEABLE_VALIDATION_STATUSES = { + JobValidationStatus.READY.value, + JobValidationStatus.WARNING.value, +} + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def _get_campaign_for_tenant(session: Session, *, campaign_id: str, tenant_id: str) -> Campaign: + campaign = session.query(Campaign).filter(Campaign.id == campaign_id, Campaign.tenant_id == tenant_id).one_or_none() + if not campaign: + raise QueueingError(f"Campaign not found or not accessible: {campaign_id}") + return campaign + + +def _get_current_version(session: Session, campaign: Campaign, version_id: str | None = None) -> CampaignVersion: + wanted = version_id or campaign.current_version_id + if not wanted: + raise QueueingError("Campaign has no current version") + version = session.get(CampaignVersion, wanted) + if not version or version.campaign_id != campaign.id: + raise QueueingError(f"Campaign version not found or not part of campaign: {wanted}") + return version + + +def _load_version_campaign_config(version: CampaignVersion) -> tuple[Path, CampaignConfig]: + snapshot_path = _write_campaign_snapshot(version) + return snapshot_path, load_campaign_config(snapshot_path) + + +def _celery_enqueue_send_job(job_id: str) -> None: + from app.celery_app import celery + + celery.send_task("multimailer.send_email", args=[job_id], queue="send_email") + + +def _celery_enqueue_append_sent_job(job_id: str) -> None: + from app.celery_app import celery + + celery.send_task("multimailer.append_sent", args=[job_id], queue="append_sent") + + +def queue_campaign_jobs( + session: Session, + *, + tenant_id: str, + campaign_id: str, + version_id: str | None = None, + enqueue_celery: bool = True, + include_warnings: bool = True, + dry_run: bool = False, +) -> QueueCampaignResult: + """Move queueable DB jobs to QUEUED and optionally enqueue Celery tasks.""" + + campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id) + version = _get_current_version(session, campaign, version_id=version_id) + + allowed_validation = {JobValidationStatus.READY.value} + if include_warnings: + allowed_validation.add(JobValidationStatus.WARNING.value) + + jobs = ( + session.query(CampaignJob) + .filter(CampaignJob.tenant_id == tenant_id, CampaignJob.campaign_version_id == version.id) + .order_by(CampaignJob.entry_index.asc()) + .all() + ) + if not jobs: + raise QueueingError("Campaign version has no jobs. Build messages before queueing.") + + queued: list[CampaignJob] = [] + skipped_count = 0 + blocked_count = 0 + for job in jobs: + if job.queue_status in {JobQueueStatus.CANCELLED.value, JobQueueStatus.SENDING.value} or job.send_status == JobSendStatus.SENT.value: + skipped_count += 1 + continue + if job.build_status != JobBuildStatus.BUILT.value or job.validation_status not in allowed_validation: + blocked_count += 1 + continue + if not job.eml_local_path and not job.eml_storage_key: + job.last_error = "Job has no generated EML path/storage key. Rebuild with write_eml enabled before queueing." + blocked_count += 1 + continue + + queued.append(job) + if not dry_run: + job.queue_status = JobQueueStatus.QUEUED.value + job.send_status = JobSendStatus.QUEUED.value + job.queued_at = _utcnow() + job.last_error = None + session.add(job) + + if not dry_run: + if queued: + campaign.status = CampaignStatus.QUEUED.value + session.add(campaign) + session.commit() + + enqueued_count = 0 + if enqueue_celery and not dry_run: + for job in queued: + _celery_enqueue_send_job(job.id) + enqueued_count += 1 + + return QueueCampaignResult( + campaign_id=campaign.id, + version_id=version.id, + queued_count=len(queued), + skipped_count=skipped_count, + blocked_count=blocked_count, + enqueued_count=enqueued_count, + dry_run=dry_run, + ) + + +def enqueue_existing_queued_jobs(session: Session, *, tenant_id: str, campaign_id: str) -> int: + campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id) + jobs = ( + session.query(CampaignJob) + .filter( + CampaignJob.tenant_id == tenant_id, + CampaignJob.campaign_id == campaign.id, + CampaignJob.queue_status == JobQueueStatus.QUEUED.value, + CampaignJob.send_status.in_([JobSendStatus.QUEUED.value, JobSendStatus.FAILED_TEMPORARY.value]), + ) + .order_by(CampaignJob.entry_index.asc()) + .all() + ) + for job in jobs: + _celery_enqueue_send_job(job.id) + return len(jobs) + + +def pause_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str) -> dict[str, Any]: + campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id) + changed = ( + session.query(CampaignJob) + .filter( + CampaignJob.tenant_id == tenant_id, + CampaignJob.campaign_id == campaign.id, + CampaignJob.queue_status == JobQueueStatus.QUEUED.value, + ) + .update({CampaignJob.queue_status: JobQueueStatus.PAUSED.value}, synchronize_session=False) + ) + if changed: + campaign.status = CampaignStatus.READY_TO_QUEUE.value + session.add(campaign) + session.commit() + return {"campaign_id": campaign.id, "paused_count": int(changed)} + + +def resume_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str, enqueue_celery: bool = True) -> dict[str, Any]: + campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id) + jobs = ( + session.query(CampaignJob) + .filter( + CampaignJob.tenant_id == tenant_id, + CampaignJob.campaign_id == campaign.id, + CampaignJob.queue_status == JobQueueStatus.PAUSED.value, + ) + .order_by(CampaignJob.entry_index.asc()) + .all() + ) + for job in jobs: + job.queue_status = JobQueueStatus.QUEUED.value + job.send_status = JobSendStatus.QUEUED.value + session.add(job) + if jobs: + campaign.status = CampaignStatus.QUEUED.value + session.add(campaign) + session.commit() + + enqueued_count = 0 + if enqueue_celery: + for job in jobs: + _celery_enqueue_send_job(job.id) + enqueued_count += 1 + return {"campaign_id": campaign.id, "resumed_count": len(jobs), "enqueued_count": enqueued_count} + + +def cancel_campaign_jobs(session: Session, *, tenant_id: str, campaign_id: str) -> dict[str, Any]: + campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id) + jobs = ( + session.query(CampaignJob) + .filter( + CampaignJob.tenant_id == tenant_id, + CampaignJob.campaign_id == campaign.id, + CampaignJob.send_status.notin_([JobSendStatus.SENT.value]), + ) + .all() + ) + for job in jobs: + if job.queue_status != JobQueueStatus.SENDING.value: + job.queue_status = JobQueueStatus.CANCELLED.value + job.send_status = JobSendStatus.CANCELLED.value + session.add(job) + campaign.status = CampaignStatus.CANCELLED.value + session.add(campaign) + session.commit() + return {"campaign_id": campaign.id, "cancelled_count": len(jobs)} + + +def _load_eml_bytes_for_job(job: CampaignJob) -> bytes: + if job.eml_local_path: + path = Path(job.eml_local_path) + if not path.exists(): + raise SendJobError(f"Generated EML file does not exist: {path}") + return path.read_bytes() + raise SendJobError("Only local EML paths are supported for sending in this implementation step") + + +def _load_eml_for_job(job: CampaignJob): + return BytesParser(policy=policy.default).parsebytes(_load_eml_bytes_for_job(job)) + + +def _addresses_from_job(job: CampaignJob, field: str) -> list[str]: + data = job.resolved_recipients or {} + values = data.get(field) or [] + return [item.get("email") for item in values if isinstance(item, dict) and item.get("email")] + + +def _sender_from_job(job: CampaignJob, config: CampaignConfig) -> str: + data = job.resolved_recipients or {} + bounce_to = _addresses_from_job(job, "bounce_to") + if bounce_to: + return bounce_to[0] + from_data = data.get("from") if isinstance(data, dict) else None + if isinstance(from_data, dict) and from_data.get("email"): + return from_data["email"] + if config.server.smtp and config.server.smtp.username: + return config.server.smtp.username + raise SmtpConfigurationError("No envelope sender could be determined") + + +def _recipients_from_job(job: CampaignJob) -> list[str]: + recipients: list[str] = [] + for field in ["to", "cc", "bcc"]: + recipients.extend(_addresses_from_job(job, field)) + # Preserve order while de-duplicating. + return list(dict.fromkeys(recipients)) + + +def _record_attempt_start(session: Session, job: CampaignJob) -> SendAttempt: + attempt = SendAttempt( + job_id=job.id, + attempt_number=job.attempt_count + 1, + started_at=_utcnow(), + ) + job.attempt_count += 1 + job.queue_status = JobQueueStatus.SENDING.value + job.send_status = JobSendStatus.SENDING.value + job.last_error = None + session.add(attempt) + session.add(job) + session.commit() + return attempt + + +def _update_campaign_after_job(session: Session, campaign_id: str) -> None: + session.flush() + campaign = session.get(Campaign, campaign_id) + if not campaign: + return + remaining = ( + session.query(CampaignJob) + .filter( + CampaignJob.campaign_id == campaign_id, + CampaignJob.queue_status.in_([JobQueueStatus.QUEUED.value, JobQueueStatus.SENDING.value, JobQueueStatus.PAUSED.value]), + ) + .count() + ) + failed = ( + session.query(CampaignJob) + .filter( + CampaignJob.campaign_id == campaign_id, + CampaignJob.send_status.in_([JobSendStatus.FAILED_TEMPORARY.value, JobSendStatus.FAILED_PERMANENT.value]), + ) + .count() + ) + sent = session.query(CampaignJob).filter(CampaignJob.campaign_id == campaign_id, CampaignJob.send_status == JobSendStatus.SENT.value).count() + if remaining: + campaign.status = CampaignStatus.QUEUED.value + elif failed: + campaign.status = CampaignStatus.FAILED.value if not sent else CampaignStatus.NEEDS_REVIEW.value + elif sent: + campaign.status = CampaignStatus.SENT.value + session.add(campaign) + + +def send_campaign_job(session: Session, *, job_id: str, dry_run: bool = False, use_rate_limit: bool = True, enqueue_imap_task: bool = False) -> SendJobResult: + job = session.get(CampaignJob, job_id) + if not job: + raise SendJobError(f"Job not found: {job_id}") + if job.queue_status == JobQueueStatus.CANCELLED.value or job.send_status == JobSendStatus.CANCELLED.value: + return SendJobResult(job_id=job_id, status="cancelled", attempt_number=job.attempt_count, dry_run=dry_run) + if job.queue_status == JobQueueStatus.PAUSED.value: + return SendJobResult(job_id=job_id, status="paused", attempt_number=job.attempt_count, dry_run=dry_run) + if job.send_status == JobSendStatus.SENT.value: + return SendJobResult(job_id=job_id, status="already_sent", attempt_number=job.attempt_count, dry_run=dry_run) + if job.queue_status not in {JobQueueStatus.QUEUED.value, JobQueueStatus.SENDING.value}: + raise SendJobError(f"Job is not queued: {job.queue_status}") + + version = session.get(CampaignVersion, job.campaign_version_id) + if not version: + raise SendJobError("Campaign version not found") + _, config = _load_version_campaign_config(version) + if not config.server.smtp: + raise SmtpConfigurationError("Campaign has no SMTP configuration") + + message = _load_eml_for_job(job) + envelope_from = _sender_from_job(job, config) + envelope_recipients = _recipients_from_job(job) + if not envelope_recipients: + raise SmtpConfigurationError("No envelope recipients could be determined") + + if dry_run: + return SendJobResult( + job_id=job.id, + status="dry_run", + attempt_number=job.attempt_count, + dry_run=True, + message=f"Would send to {len(envelope_recipients)} recipient(s) from {envelope_from}", + ) + + attempt = _record_attempt_start(session, job) + try: + wait_for_rate_limit( + key=f"tenant:{job.tenant_id}:campaign:{job.campaign_id}", + messages_per_minute=config.delivery.rate_limit.messages_per_minute, + enabled=use_rate_limit, + ) + + result = send_email_message( + message, + smtp_config=config.server.smtp, + envelope_from=envelope_from, + envelope_recipients=envelope_recipients, + ) + attempt.finished_at = _utcnow() + attempt.smtp_response = json.dumps(asdict(result), default=str) + job.queue_status = JobQueueStatus.DRAFT.value + job.send_status = JobSendStatus.SENT.value + job.sent_at = _utcnow() + if config.delivery.imap_append_sent.enabled: + job.imap_status = JobImapStatus.PENDING.value + else: + job.imap_status = JobImapStatus.NOT_REQUESTED.value + job.last_error = None + session.add(attempt) + session.add(job) + _update_campaign_after_job(session, job.campaign_id) + session.commit() + if enqueue_imap_task and job.imap_status == JobImapStatus.PENDING.value: + _celery_enqueue_append_sent_job(job.id) + return SendJobResult(job_id=job.id, status="sent", attempt_number=attempt.attempt_number) + + except (SmtpConfigurationError, SmtpSendError, SendJobError, OSError) as exc: + attempt.finished_at = _utcnow() + attempt.error_type = exc.__class__.__name__ + attempt.error_message = str(exc) + job.last_error = str(exc) + retry = getattr(exc, "temporary", None) is True + max_attempts = config.delivery.retry.max_attempts + if retry and job.attempt_count < max_attempts: + job.queue_status = JobQueueStatus.QUEUED.value + job.send_status = JobSendStatus.FAILED_TEMPORARY.value + else: + job.queue_status = JobQueueStatus.DRAFT.value + job.send_status = JobSendStatus.FAILED_PERMANENT.value + session.add(attempt) + session.add(job) + _update_campaign_after_job(session, job.campaign_id) + session.commit() + raise + + + + +def _record_imap_attempt_start(session: Session, job: CampaignJob) -> ImapAppendAttempt: + existing_count = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id == job.id).count() + attempt = ImapAppendAttempt( + job_id=job.id, + attempt_number=existing_count + 1, + status="running", + ) + job.imap_status = JobImapStatus.PENDING.value + job.last_error = None + session.add(attempt) + session.add(job) + session.commit() + return attempt + + +def _effective_imap_folder(config: CampaignConfig) -> str: + delivery_folder = config.delivery.imap_append_sent.folder + if delivery_folder and delivery_folder != "auto": + return delivery_folder + if config.server.imap and config.server.imap.sent_folder and config.server.imap.sent_folder != "auto": + return config.server.imap.sent_folder + return "auto" + + +def append_sent_for_job(session: Session, *, job_id: str, dry_run: bool = False) -> AppendSentResult: + """Append one successfully sent job's exact EML to the configured IMAP Sent folder.""" + + job = session.get(CampaignJob, job_id) + if not job: + raise SendJobError(f"Job not found: {job_id}") + if job.send_status != JobSendStatus.SENT.value: + return AppendSentResult(job_id=job_id, status="not_sent", attempt_number=0, dry_run=dry_run, message="Job has not been sent") + if job.imap_status == JobImapStatus.NOT_REQUESTED.value: + return AppendSentResult(job_id=job_id, status="not_requested", attempt_number=0, dry_run=dry_run) + if job.imap_status == JobImapStatus.APPENDED.value: + attempts = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id == job.id).count() + return AppendSentResult(job_id=job_id, status="already_appended", attempt_number=attempts, dry_run=dry_run) + if job.imap_status == JobImapStatus.SKIPPED.value: + attempts = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id == job.id).count() + return AppendSentResult(job_id=job_id, status="skipped", attempt_number=attempts, dry_run=dry_run) + + version = session.get(CampaignVersion, job.campaign_version_id) + if not version: + raise SendJobError("Campaign version not found") + _, config = _load_version_campaign_config(version) + if not config.delivery.imap_append_sent.enabled: + job.imap_status = JobImapStatus.NOT_REQUESTED.value + session.add(job) + session.commit() + return AppendSentResult(job_id=job.id, status="not_requested", attempt_number=0, dry_run=dry_run) + if not config.server.imap or not config.server.imap.enabled: + job.imap_status = JobImapStatus.SKIPPED.value + job.last_error = "IMAP append requested, but server.imap is missing or disabled" + session.add(job) + session.commit() + return AppendSentResult(job_id=job.id, status="skipped", attempt_number=0, dry_run=dry_run, message=job.last_error) + + message_bytes = _load_eml_bytes_for_job(job) + folder = _effective_imap_folder(config) + if dry_run: + attempts = session.query(ImapAppendAttempt).filter(ImapAppendAttempt.job_id == job.id).count() + return AppendSentResult( + job_id=job.id, + status="dry_run", + attempt_number=attempts, + dry_run=True, + folder=folder, + message=f"Would append {len(message_bytes)} bytes to IMAP folder {folder!r}", + ) + + attempt = _record_imap_attempt_start(session, job) + try: + result = append_message_to_sent( + message_bytes, + imap_config=config.server.imap, + folder=None if folder == "auto" else folder, + ) + attempt.status = "appended" + attempt.folder = result.folder + job.imap_status = JobImapStatus.APPENDED.value + job.last_error = None + session.add(attempt) + session.add(job) + session.commit() + return AppendSentResult(job_id=job.id, status="appended", attempt_number=attempt.attempt_number, folder=result.folder) + except (ImapConfigurationError, ImapAppendError, SendJobError, OSError) as exc: + attempt.status = "failed" + attempt.folder = None if folder == "auto" else folder + attempt.error_message = str(exc) + job.imap_status = JobImapStatus.FAILED.value + job.last_error = str(exc) + session.add(attempt) + session.add(job) + session.commit() + raise + + +def enqueue_pending_imap_appends(session: Session, *, tenant_id: str, campaign_id: str, enqueue_celery: bool = True, dry_run: bool = False) -> dict[str, Any]: + campaign = _get_campaign_for_tenant(session, campaign_id=campaign_id, tenant_id=tenant_id) + jobs = ( + session.query(CampaignJob) + .filter( + CampaignJob.tenant_id == tenant_id, + CampaignJob.campaign_id == campaign.id, + CampaignJob.send_status == JobSendStatus.SENT.value, + CampaignJob.imap_status.in_([JobImapStatus.PENDING.value, JobImapStatus.FAILED.value]), + ) + .order_by(CampaignJob.entry_index.asc()) + .all() + ) + if not dry_run and enqueue_celery: + for job in jobs: + _celery_enqueue_append_sent_job(job.id) + return { + "campaign_id": campaign.id, + "pending_count": len(jobs), + "enqueued_count": 0 if dry_run or not enqueue_celery else len(jobs), + "dry_run": dry_run, + } + +def next_retry_delay(config: CampaignConfig, attempt_count: int) -> int: + delays = config.delivery.retry.backoff_seconds or [60] + index = max(0, min(attempt_count - 1, len(delays) - 1)) + return int(delays[index]) diff --git a/server/app/mailer/sending/rate_limit.py b/server/app/mailer/sending/rate_limit.py new file mode 100644 index 0000000..ff1e197 --- /dev/null +++ b/server/app/mailer/sending/rate_limit.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +import time +from dataclasses import dataclass + +from redis import Redis +from redis.exceptions import RedisError + +from app.settings import settings + + +@dataclass(frozen=True, slots=True) +class RateLimitDecision: + key: str + messages_per_minute: int + gap_seconds: float + waited_seconds: float + + +def _redis_client() -> Redis: + return Redis.from_url(settings.redis_url, decode_responses=True) + + +def wait_for_rate_limit(*, key: str, messages_per_minute: int, enabled: bool = True) -> RateLimitDecision: + """Throttle sends across worker processes using Redis when available. + + The implementation stores the next allowed send timestamp per key. A Redis + lock keeps multiple Celery processes from reading/updating the timestamp at + the same time. If Redis is unavailable, it falls back to no distributed wait; + the per-container Celery concurrency still protects local development. + """ + + messages_per_minute = max(1, int(messages_per_minute or 1)) + gap = 60.0 / messages_per_minute + if not enabled: + return RateLimitDecision(key=key, messages_per_minute=messages_per_minute, gap_seconds=gap, waited_seconds=0.0) + + redis_key = f"multimailer:ratelimit:{key}:next_allowed" + lock_key = f"multimailer:ratelimit:{key}:lock" + waited = 0.0 + + try: + client = _redis_client() + with client.lock(lock_key, timeout=30, blocking_timeout=30): + now = time.time() + raw_next = client.get(redis_key) + next_allowed = float(raw_next) if raw_next else now + if next_allowed > now: + waited = next_allowed - now + time.sleep(waited) + now = time.time() + client.set(redis_key, now + gap, ex=max(60, int(gap * 10))) + except (RedisError, TimeoutError, ValueError): + # Development fallback: do not fail sending because Redis is absent. + waited = 0.0 + + return RateLimitDecision(key=key, messages_per_minute=messages_per_minute, gap_seconds=gap, waited_seconds=waited) diff --git a/server/app/mailer/sending/smtp.py b/server/app/mailer/sending/smtp.py new file mode 100644 index 0000000..52f38e6 --- /dev/null +++ b/server/app/mailer/sending/smtp.py @@ -0,0 +1,157 @@ +from __future__ import annotations + +import copy +import smtplib +import ssl +from dataclasses import dataclass +from email.message import EmailMessage +from email.utils import formataddr + +from app.mailer.campaign.models import SmtpConfig, TransportSecurity + + +class SmtpConfigurationError(ValueError): + """Raised when SMTP settings are incomplete or inconsistent.""" + + +class SmtpSendError(RuntimeError): + """Raised when an SMTP send attempt fails.""" + + +@dataclass(frozen=True, slots=True) +class SmtpSendResult: + host: str + port: int + security: str + envelope_from: str + envelope_recipients: list[str] + refused_recipients: dict[str, tuple[int, bytes | str]] + + @property + def accepted_count(self) -> int: + return len(self.envelope_recipients) - len(self.refused_recipients) + + +def _require_smtp_config(config: SmtpConfig) -> tuple[str, int]: + if not config.host: + raise SmtpConfigurationError("SMTP host is required") + if not config.port: + raise SmtpConfigurationError("SMTP port is required") + if bool(config.username) != bool(config.password): + raise SmtpConfigurationError("SMTP username and password must be provided together, or both omitted") + return config.host, config.port + + +def _open_smtp(config: SmtpConfig) -> smtplib.SMTP: + host, port = _require_smtp_config(config) + context = ssl.create_default_context() + + try: + if config.security == TransportSecurity.TLS: + smtp: smtplib.SMTP = smtplib.SMTP_SSL(host=host, port=port, timeout=config.timeout_seconds, context=context) + smtp.ehlo() + else: + smtp = smtplib.SMTP(host=host, port=port, timeout=config.timeout_seconds) + smtp.ehlo() + if config.security == TransportSecurity.STARTTLS: + smtp.starttls(context=context) + smtp.ehlo() + + if config.username and config.password: + smtp.login(config.username, config.password) + return smtp + except Exception: + # If construction/login fails after a socket was created, smtplib usually closes + # on GC, but explicit cleanup is safer when the variable exists. + try: + smtp.quit() # type: ignore[possibly-undefined] + except Exception: + pass + raise + + +def _decode_refused(refused: dict[str, tuple[int, bytes]]) -> dict[str, tuple[int, bytes | str]]: + normalized: dict[str, tuple[int, bytes | str]] = {} + for recipient, (code, response) in refused.items(): + try: + normalized[recipient] = (code, response.decode("utf-8", errors="replace")) + except AttributeError: + normalized[recipient] = (code, response) + return normalized + + +def prepare_test_message( + message: EmailMessage, + *, + test_recipient: str, + test_recipient_name: str | None = None, +) -> EmailMessage: + """Return a safe copy of a generated campaign message for test delivery. + + The original recipient headers are removed so a test send cannot accidentally + leak the real To/Cc list or deliver to the real recipients. The envelope + recipient must also be supplied separately to send_email_message(). + """ + + test_message = copy.deepcopy(message) + + for header in ["To", "Cc", "Bcc"]: + if header in test_message: + del test_message[header] + + # Replace potential previous marker headers if the user test-sends an EML twice. + for header in ["X-MultiMailer-Test-Send"]: + if header in test_message: + del test_message[header] + + test_message["To"] = formataddr((test_recipient_name or test_recipient, test_recipient)) + test_message["X-MultiMailer-Test-Send"] = "true" + return test_message + + +def send_email_message( + message: EmailMessage, + *, + smtp_config: SmtpConfig, + envelope_from: str, + envelope_recipients: list[str], +) -> SmtpSendResult: + """Send an EmailMessage through SMTP. + + This low-level function deliberately receives explicit envelope sender and + recipients. Headers and SMTP envelope are related but not identical; Bcc and + future bounce-address handling depend on keeping them separate. + """ + + host, port = _require_smtp_config(smtp_config) + if not envelope_from: + raise SmtpConfigurationError("SMTP envelope sender is required") + if not envelope_recipients: + raise SmtpConfigurationError("at least one SMTP envelope recipient is required") + + try: + with _open_smtp(smtp_config) as smtp: + refused = smtp.send_message( + message, + from_addr=envelope_from, + to_addrs=envelope_recipients, + ) + except smtplib.SMTPAuthenticationError as exc: + raise SmtpSendError(f"SMTP authentication failed: {exc.smtp_code} {exc.smtp_error!r}") from exc + except smtplib.SMTPRecipientsRefused as exc: + raise SmtpSendError(f"all SMTP recipients were refused: {_decode_refused(exc.recipients)}") from exc + except smtplib.SMTPSenderRefused as exc: + raise SmtpSendError(f"SMTP sender was refused: {exc.smtp_code} {exc.smtp_error!r}") from exc + except smtplib.SMTPResponseException as exc: + raise SmtpSendError(f"SMTP error: {exc.smtp_code} {exc.smtp_error!r}") from exc + except (OSError, smtplib.SMTPException) as exc: + raise SmtpSendError(f"SMTP send failed: {exc}") from exc + + return SmtpSendResult( + host=host, + port=port, + security=smtp_config.security.value, + envelope_from=envelope_from, + envelope_recipients=list(envelope_recipients), + refused_recipients=_decode_refused(refused), + ) diff --git a/server/app/mailer/services/__init__.py b/server/app/mailer/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app/mailer/services/attachment_matching.py b/server/app/mailer/services/attachment_matching.py new file mode 100644 index 0000000..56c01b4 --- /dev/null +++ b/server/app/mailer/services/attachment_matching.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +from pathlib import Path + +from app.mailer.domain.campaign import MailAttachmentConfig + + +def match_files(base_path: Path, config: MailAttachmentConfig) -> list[Path]: + directory = base_path / config.base_dir + if not directory.exists(): + return [] + iterator = directory.rglob(config.file_filter) if config.include_subdirs else directory.glob(config.file_filter) + return sorted(path for path in iterator if path.is_file() and path.stat().st_size >= 0) diff --git a/server/app/mailer/services/campaign_executor.py b/server/app/mailer/services/campaign_executor.py new file mode 100644 index 0000000..ee105c4 --- /dev/null +++ b/server/app/mailer/services/campaign_executor.py @@ -0,0 +1,135 @@ +from __future__ import annotations + +import smtplib +from email.message import EmailMessage +from pathlib import Path +from typing import Iterable + +from app.mailer.domain.campaign import MailCampaign, MailEntry, MailServerSettings, TransportSecurity +from app.mailer.domain.queue import MailQueue +from app.mailer.domain.recipients import Recipient, RecipientType +from app.mailer.services.attachment_matching import match_files +from app.mailer.services.zip_service import create_encrypted_zip + + +def _recipient_header(recipients: Iterable[Recipient], recipient_type: RecipientType) -> str: + return ", ".join(r.formatted() for r in recipients if r.type == recipient_type) + + +def _recipient_values(recipients: list[Recipient]) -> dict[str, str]: + def rows(recipient_type: RecipientType) -> list[Recipient]: + return [r for r in recipients if r.type == recipient_type] + + def joined(recipient_type: RecipientType, mode: str) -> str: + selected = rows(recipient_type) + if mode == "address": + return ", ".join(r.address for r in selected) + if mode == "name": + return ", ".join(r.name or r.address for r in selected) + return ", ".join(r.formatted() for r in selected) + + return { + "mm_recipients": joined(RecipientType.TO, "formatted"), + "mm_recipients_address": joined(RecipientType.TO, "address"), + "mm_recipients_name": joined(RecipientType.TO, "name"), + "mm_cc": joined(RecipientType.CC, "formatted"), + "mm_cc_address": joined(RecipientType.CC, "address"), + "mm_cc_name": joined(RecipientType.CC, "name"), + "mm_bcc": joined(RecipientType.BCC, "formatted"), + "mm_bcc_address": joined(RecipientType.BCC, "address"), + "mm_bcc_name": joined(RecipientType.BCC, "name"), + } + + +def _message_attachment_paths(campaign: MailCampaign, entry: MailEntry) -> list[Path]: + paths: list[Path] = [] + if entry.combine_attachments: + for config in campaign.global_attachment_configs: + paths.extend(match_files(campaign.base_attachment_path, config)) + if campaign.individual_attachments: + for config in entry.attachment_configs: + paths.extend(match_files(campaign.base_attachment_path, config)) + return paths + + +def get_not_sent_files(campaign: MailCampaign) -> set[Path]: + """Return files matching campaign attachment configs that are not referenced by any built entry.""" + all_files: set[Path] = set() + used_files: set[Path] = set() + for config in campaign.global_attachment_configs: + all_files.update(match_files(campaign.base_attachment_path, config)) + for entry in campaign.mail_entries: + files = set(_message_attachment_paths(campaign, entry)) + used_files.update(files) + all_files.update(files) + return all_files - used_files + + +def build_message(campaign: MailCampaign, entry: MailEntry, *, zip_attachments: bool = True) -> EmailMessage | None: + recipients = campaign.all_recipients_for(entry) + attachments = _message_attachment_paths(campaign, entry) + if not attachments and not campaign.send_without_attachments: + return None + + values = {} + values.update(_recipient_values(recipients)) + values.update(campaign.field_contents.as_value_map("global")) + values.update(entry.field_contents.as_value_map("local")) + + message = EmailMessage() + sender = entry.from_recipient if campaign.individual_from and entry.from_recipient else campaign.global_from + if sender: + message["From"] = sender.formatted() + to_header = _recipient_header(recipients, RecipientType.TO) + cc_header = _recipient_header(recipients, RecipientType.CC) + if to_header: + message["To"] = to_header + if cc_header: + message["Cc"] = cc_header + message["Subject"] = campaign.subject_template.apply_values(values) + message.set_content(campaign.mail_template.apply_values(values)) + + attachment_paths = attachments + + if attachments and zip_attachments: + number = entry.get_field_content_from_name("number").as_string() if "number" in entry.field_contents.field_map else "attachments" + password = entry.get_field_content_from_name("password").as_string() if "password" in entry.field_contents.field_map else "" + zip_path = campaign.base_attachment_path / f"{number}.zip" + attachment_paths = [create_encrypted_zip(zip_path, attachments, password)] + + for attachment_path in attachment_paths: + data = attachment_path.read_bytes() + message.add_attachment(data, maintype="application", subtype="octet-stream", filename=attachment_path.name) + return message + + +def build_mail_queue(campaign: MailCampaign, *, zip_attachments: bool = True) -> MailQueue: + queue = MailQueue() + for entry in campaign.mail_entries: + if not entry.is_active: + continue + message = build_message(campaign, entry, zip_attachments=zip_attachments) + if message is not None: + queue.add_mail(message) + return queue + + +def send_mail_queue(settings: MailServerSettings, queue: MailQueue) -> MailQueue: + retry_queue = MailQueue() + if settings.transport_security == TransportSecurity.TLS: + smtp = smtplib.SMTP_SSL(settings.server, settings.resolved_port(), timeout=60) + else: + smtp = smtplib.SMTP(settings.server, settings.resolved_port(), timeout=60) + try: + if settings.transport_security == TransportSecurity.STARTTLS: + smtp.starttls() + if settings.username: + smtp.login(settings.username, settings.password) + for message in queue: + try: + smtp.send_message(message) + except Exception: + retry_queue.add_mail(message) + finally: + smtp.quit() + return retry_queue diff --git a/server/app/mailer/services/zip_service.py b/server/app/mailer/services/zip_service.py new file mode 100644 index 0000000..d573504 --- /dev/null +++ b/server/app/mailer/services/zip_service.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from pathlib import Path + +try: + import pyzipper +except ImportError: # pragma: no cover + pyzipper = None + + +def create_encrypted_zip(output_path: Path, files: list[Path], password: str) -> Path: + output_path.parent.mkdir(parents=True, exist_ok=True) + if pyzipper is None: + raise RuntimeError("pyzipper is required for writing encrypted ZIP files") + with pyzipper.AESZipFile( + output_path, + "w", + compression=pyzipper.ZIP_DEFLATED, + encryption=pyzipper.WZ_AES, + ) as zip_file: + if password: + zip_file.setpassword(password.encode("utf-8")) + for file_path in files: + zip_file.write(file_path, arcname=file_path.name) + return output_path diff --git a/server/app/main.py b/server/app/main.py new file mode 100644 index 0000000..008bf4e --- /dev/null +++ b/server/app/main.py @@ -0,0 +1,47 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.api.v1 import router as api_v1_router +from app.db.bootstrap import bootstrap_dev_data, create_all_tables +from app.db.session import SessionLocal +from app.settings import settings + + +@asynccontextmanager +async def lifespan(app: FastAPI): + if settings.app_env == "dev" and settings.dev_bootstrap_enabled: + create_all_tables() + with SessionLocal() as session: + bootstrap_dev_data(session, api_key_secret=settings.dev_bootstrap_api_key) + yield + + +app = FastAPI(title="MultiMailer Server", version="0.2.0", lifespan=lifespan) + +origins = [item.strip() for item in settings.cors_origins.split(",") if item.strip()] +if origins: + app.add_middleware( + CORSMiddleware, + allow_origins=["*"] if "*" in origins else origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + +app.include_router(api_v1_router) + + +@app.get("/health") +def health(): + return { + "status": "ok", + "env": settings.app_env, + "api": {"version": "v1", "auth": "api-key"}, + "storage": { + "endpoint": settings.s3_endpoint_url, + "bucket": settings.s3_bucket, + "region": settings.s3_region, + }, + } diff --git a/server/app/security/__init__.py b/server/app/security/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/server/app/security/api_keys.py b/server/app/security/api_keys.py new file mode 100644 index 0000000..b1723c5 --- /dev/null +++ b/server/app/security/api_keys.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +import hashlib +import hmac +import secrets +from dataclasses import dataclass +from datetime import datetime, timezone + +from sqlalchemy.orm import Session + +from app.db.models import ApiKey, User + +API_KEY_PREFIX_LENGTH = 12 +API_KEY_RANDOM_BYTES = 32 + + +@dataclass(slots=True) +class CreatedApiKey: + model: ApiKey + secret: str + + +def hash_api_key(secret: str) -> str: + return hashlib.sha256(secret.encode("utf-8")).hexdigest() + + +def verify_api_key(secret: str, expected_hash: str) -> bool: + return hmac.compare_digest(hash_api_key(secret), expected_hash) + + +def generate_api_key_secret() -> str: + return "mm_" + secrets.token_urlsafe(API_KEY_RANDOM_BYTES) + + +def api_key_prefix(secret: str) -> str: + # Prefix is only a lookup helper and must not be enough to authenticate. + return secret[:API_KEY_PREFIX_LENGTH] + + +def create_api_key( + session: Session, + *, + user: User, + name: str, + scopes: list[str], + secret: str | None = None, + expires_at: datetime | None = None, +) -> CreatedApiKey: + secret = secret or generate_api_key_secret() + model = ApiKey( + tenant_id=user.tenant_id, + user_id=user.id, + name=name, + prefix=api_key_prefix(secret), + key_hash=hash_api_key(secret), + scopes=scopes, + expires_at=expires_at, + ) + session.add(model) + session.flush() + return CreatedApiKey(model=model, secret=secret) + + +def authenticate_api_key(session: Session, secret: str) -> ApiKey | None: + prefix = api_key_prefix(secret) + candidates = session.query(ApiKey).filter(ApiKey.prefix == prefix, ApiKey.revoked_at.is_(None)).all() + now = datetime.now(timezone.utc) + for candidate in candidates: + if candidate.expires_at and candidate.expires_at < now: + continue + if verify_api_key(secret, candidate.key_hash): + candidate.last_used_at = now + session.add(candidate) + return candidate + return None + + +def has_scope(api_key: ApiKey, required_scope: str) -> bool: + scopes = set(api_key.scopes or []) + return "*" in scopes or required_scope in scopes diff --git a/server/app/settings.py b/server/app/settings.py new file mode 100644 index 0000000..b754758 --- /dev/null +++ b/server/app/settings.py @@ -0,0 +1,33 @@ +from pydantic import Field +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=None, extra="ignore") + + app_env: str = Field(default="dev", alias="APP_ENV") + + database_url: str = Field( + default="sqlite:///./multimailer-dev.db", + alias="DATABASE_URL", + ) + redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL") + + s3_endpoint_url: str = Field(default="http://garage:3900", alias="S3_ENDPOINT_URL") + s3_region: str = Field(default="garage", alias="S3_REGION") + s3_access_key_id: str = Field(default="GKmultimailerdev0000000000000000", alias="S3_ACCESS_KEY_ID") + s3_secret_access_key: str = Field(default="multimailer-dev-secret-change-me", alias="S3_SECRET_ACCESS_KEY") + s3_bucket: str = Field(default="attachments", alias="S3_BUCKET") + + master_key_b64: str | None = Field(default=None, alias="MASTER_KEY_B64") + celery_queues: str = Field(default="send_email,append_sent,default", alias="CELERY_QUEUES") + + # Development bootstrap only. Do not use this in production. + dev_bootstrap_api_key: str | None = Field(default="dev-multimailer-api-key", alias="DEV_BOOTSTRAP_API_KEY") + dev_bootstrap_enabled: bool = Field(default=True, alias="DEV_BOOTSTRAP_ENABLED") + + # Comma-separated list. Use * only for local development. + cors_origins: str = Field(default="http://localhost:5173,http://127.0.0.1:5173,http://localhost:8080", alias="CORS_ORIGINS") + + +settings = Settings() diff --git a/server/entrypoint.sh b/server/entrypoint.sh new file mode 100644 index 0000000..f04a8c4 --- /dev/null +++ b/server/entrypoint.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env sh +set -e + +ROLE="${APP_ROLE:-api}" + +if [ "$ROLE" = "api" ]; then + exec uvicorn app.main:app \ + --host "${APP_HOST:-0.0.0.0}" \ + --port "${APP_PORT:-8000}" \ + --proxy-headers +elif [ "$ROLE" = "worker" ]; then + exec celery -A app.celery_app.celery worker \ + --loglevel="${CELERY_LOGLEVEL:-INFO}" \ + --queues="${CELERY_QUEUES:-send_email,append_sent,default}" \ + --concurrency="${CELERY_CONCURRENCY:-4}" \ + --prefetch-multiplier="${CELERY_PREFETCH_MULTIPLIER:-1}" \ + --max-tasks-per-child="${CELERY_MAX_TASKS_PER_CHILD:-200}" +else + echo "Unknown APP_ROLE=$ROLE (expected api|worker)" + exit 1 +fi diff --git a/server/multimailer-dev.db b/server/multimailer-dev.db new file mode 100644 index 0000000000000000000000000000000000000000..3a8f888e987d67bb3319b23a0bb4e8b480ceff93 GIT binary patch literal 356352 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 amNbGW!mbPOW@Q7ZWCX$hAl(PzFaQ9={5*aD literal 0 HcmV?d00001 diff --git a/server/runtime/generated_eml/1615e1fb-20c8-409e-86d8-d6999e5d43da/06246903-c0ef-492f-9170-755e8e922bcf/entry-001.eml b/server/runtime/generated_eml/1615e1fb-20c8-409e-86d8-d6999e5d43da/06246903-c0ef-492f-9170-755e8e922bcf/entry-001.eml new file mode 100644 index 0000000..0de3584 --- /dev/null +++ b/server/runtime/generated_eml/1615e1fb-20c8-409e-86d8-d6999e5d43da/06246903-c0ef-492f-9170-755e8e922bcf/entry-001.eml @@ -0,0 +1,33 @@ +Date: Sun, 07 Jun 2026 19:28:59 +0000 +Message-ID: <178086053947.6395.2536174559477997222@2384c9acfa31> +From: Rechnungslegung D5 +To: Example Recipient +Reply-To: Rechnungslegung D5 +Subject: Rechnungslegungslisten =?utf-8?q?f=C3=BCr?= 05 / 2026 und + Dienststelle ab0000 +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="===============6057789063302149784==" + +--===============6057789063302149784== +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 8bit + +Sehr geehrte Damen und Herren, + +beigefügt erhalten Sie die Rechnungslegungslisten für 05 / 2026. + +Mit freundlichen Grüßen + +--===============6057789063302149784== +Content-Type: application/zip +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; filename="Rechnungslegung_ab0000.zip" +MIME-Version: 1.0 + +UEsDBBQAAQBjAMdSx1wFviTFTgAAADIAAAAXAAsAYWIwMDAwLTEyMzQ1Ni1kZW1vLlhMU1gBmQcA +AQBBRQMIAG82lyDnqpMmcAAcJVGArk0eCEXKrnNuShYXX3AfgWjdo4oG3mGsRBteuWa4nRyBIlIO +MeVKdagIxi0XlnF8lWIWdBJdy0rabIG2YhGZiVBLAQIUAxQAAQBjAMdSx1wFviTFTgAAADIAAAAX +AAsAAAAAAAAAAACkgQAAAABhYjAwMDAtMTIzNDU2LWRlbW8uWExTWAGZBwABAEFFAwgAUEsFBgAA +AAABAAEAUAAAAI4AAAAAAA== + +--===============6057789063302149784==-- diff --git a/server/runtime/generated_eml/24be6ff0-ba66-4c56-ba95-525466fc1cd3/05721f39-b5f4-4f8c-9c6f-5f44b06b9459/_zip/Rechnungslegung_ab0000.zip b/server/runtime/generated_eml/24be6ff0-ba66-4c56-ba95-525466fc1cd3/05721f39-b5f4-4f8c-9c6f-5f44b06b9459/_zip/Rechnungslegung_ab0000.zip new file mode 100644 index 0000000000000000000000000000000000000000..f208dba41f483c67d826ab577d84f4f1304a16ea GIT binary patch literal 244 zcmWIWW@Zs#U}Q*UI39F7hIOCHQ9lL-1|uLAXW(W?OfmohT|*;d6H_zYl+@gOy$GM+ z2*#Q0KrN20%p43!?XEUeDo0JvmO8e*ep@7xtWh|}J#(&Zs8#UcmVlpb<(=maZ%GRE z;t{(4uYKCxn%Rk;p9J>ivY-3i-Vh`rYfx!)@Lj0(8%+fc{i9|V0=yZSM3`~A2k0y? aSkefh2)i!8o0Scuk`V|4fOH>-!vFxRJ3)8= literal 0 HcmV?d00001 diff --git a/server/runtime/generated_eml/24be6ff0-ba66-4c56-ba95-525466fc1cd3/05721f39-b5f4-4f8c-9c6f-5f44b06b9459/entry-001.eml b/server/runtime/generated_eml/24be6ff0-ba66-4c56-ba95-525466fc1cd3/05721f39-b5f4-4f8c-9c6f-5f44b06b9459/entry-001.eml new file mode 100644 index 0000000..93fd6c5 --- /dev/null +++ b/server/runtime/generated_eml/24be6ff0-ba66-4c56-ba95-525466fc1cd3/05721f39-b5f4-4f8c-9c6f-5f44b06b9459/entry-001.eml @@ -0,0 +1,33 @@ +Date: Sun, 07 Jun 2026 19:28:50 +0000 +Message-ID: <178086053079.6313.8137512540680822768@2384c9acfa31> +From: Rechnungslegung D5 +To: Example Recipient +Reply-To: Rechnungslegung D5 +Subject: Rechnungslegungslisten =?utf-8?q?f=C3=BCr?= 05 / 2026 und + Dienststelle ab0000 +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="===============6768665778686419714==" + +--===============6768665778686419714== +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 8bit + +Sehr geehrte Damen und Herren, + +beigefügt erhalten Sie die Rechnungslegungslisten für 05 / 2026. + +Mit freundlichen Grüßen + +--===============6768665778686419714== +Content-Type: application/zip +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; filename="Rechnungslegung_ab0000.zip" +MIME-Version: 1.0 + +UEsDBBQAAQBjAMdSx1wFviTFTgAAADIAAAAXAAsAYWIwMDAwLTEyMzQ1Ni1kZW1vLlhMU1gBmQcA +AQBBRQMIAGKHRTx6JMU1zXVBhuvtchRjKHGcR2mdLVU6U8OEUPlGd4nOw7RiVUoMEt/+h5bdfJth +8+RRjW0HzvuHgFIUHTB5MsHuVSvsKSAIL8U20FBLAQIUAxQAAQBjAMdSx1wFviTFTgAAADIAAAAX +AAsAAAAAAAAAAACkgQAAAABhYjAwMDAtMTIzNDU2LWRlbW8uWExTWAGZBwABAEFFAwgAUEsFBgAA +AAABAAEAUAAAAI4AAAAAAA== + +--===============6768665778686419714==-- diff --git a/server/runtime/generated_eml/3f42af9b-8ddd-42a2-bf77-7bd72e46a8ab/29079dd3-e93d-487b-a58b-73329d3c05de/_zip/Rechnungslegung_ab0000.zip b/server/runtime/generated_eml/3f42af9b-8ddd-42a2-bf77-7bd72e46a8ab/29079dd3-e93d-487b-a58b-73329d3c05de/_zip/Rechnungslegung_ab0000.zip new file mode 100644 index 0000000000000000000000000000000000000000..b7428b2f36c775a96c055388adbff9338241c7a8 GIT binary patch literal 244 zcmWIWW@Zs#U}Q*UIG%JohIOCHQ9lL-1|uLAXW(W?OfmohT|*;d6H_zYl+@gOy$GM+ z2*#Q0KrN20%p44xPO%mlFP^`RZM>{WC5fU&(Ue$@~CsMkWzv-0lH7 c3k?1@f+)hS3-D%T1F2*L!T=!M2jVaQ07u+Jl>h($ literal 0 HcmV?d00001 diff --git a/server/runtime/generated_eml/3f42af9b-8ddd-42a2-bf77-7bd72e46a8ab/29079dd3-e93d-487b-a58b-73329d3c05de/entry-001.eml b/server/runtime/generated_eml/3f42af9b-8ddd-42a2-bf77-7bd72e46a8ab/29079dd3-e93d-487b-a58b-73329d3c05de/entry-001.eml new file mode 100644 index 0000000..522aa17 --- /dev/null +++ b/server/runtime/generated_eml/3f42af9b-8ddd-42a2-bf77-7bd72e46a8ab/29079dd3-e93d-487b-a58b-73329d3c05de/entry-001.eml @@ -0,0 +1,33 @@ +Date: Mon, 08 Jun 2026 01:44:31 +0200 +Message-ID: <178087587122.1246.13502800870574615602@zemions-desktop> +From: Rechnungslegung D5 +To: Example Recipient +Reply-To: Rechnungslegung D5 +Subject: Rechnungslegungslisten =?utf-8?q?f=C3=BCr?= 05 / 2026 und + Dienststelle ab0000 +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="===============1872171815029107515==" + +--===============1872171815029107515== +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 8bit + +Sehr geehrte Damen und Herren, + +beigefügt erhalten Sie die Rechnungslegungslisten für 05 / 2026. + +Mit freundlichen Grüßen + +--===============1872171815029107515== +Content-Type: application/zip +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; filename="Rechnungslegung_ab0000.zip" +MIME-Version: 1.0 + +UEsDBBQAAQBjAMdix1wFviTFTgAAADIAAAAXAAsAYWIwMDAwLTEyMzQ1Ni1kZW1vLlhMU1gBmQcA +AQBBRQMIALLKBXIzo5ulJ8qB3Lq6eux7FJhN5rkDc+BOsNPJx6YJcr5GJTKuv6oZEmBdZQ1kjT0o +Fgf6Z38xwybzuFpNqF0Ns9XdNr+Y08epBNDkn1BLAQIUAxQAAQBjAMdix1wFviTFTgAAADIAAAAX +AAsAAAAAAAAAAAD/gQAAAABhYjAwMDAtMTIzNDU2LWRlbW8uWExTWAGZBwABAEFFAwgAUEsFBgAA +AAABAAEAUAAAAI4AAAAAAA== + +--===============1872171815029107515==-- diff --git a/server/runtime/generated_eml/9e239ef8-3ca2-454b-a336-ca697063cc1d/3253bd83-b934-4e5e-ab82-fb73590f6e93/_zip/Rechnungslegung_ab0000.zip b/server/runtime/generated_eml/9e239ef8-3ca2-454b-a336-ca697063cc1d/3253bd83-b934-4e5e-ab82-fb73590f6e93/_zip/Rechnungslegung_ab0000.zip new file mode 100644 index 0000000000000000000000000000000000000000..44a70f11770305a70a118a0149079ad3bc98a15e GIT binary patch literal 244 zcmWIWW@Zs#U}Q*UI39F7hIOCHQ9lL-1|uLAXW(W?OfmohT|*;d6H_zYl+@gOy$GM+ z2*#Q0KrN20%p43?QXd>i%uLb#%@tT)Aia3agYcT;tCQoFyjzo{kbS!DpM_n-qQyKf zcO2tSvRicZ@xC`9$HD~o#e&~W|CsT>QmyjvrVCOp6#d&3nD=I_yBy%n$Rxsy+dV*M bfx(hS5JlK^0p6@^AeD?j7yzXEKpX}Dq`^eu literal 0 HcmV?d00001 diff --git a/server/runtime/generated_eml/9e239ef8-3ca2-454b-a336-ca697063cc1d/3253bd83-b934-4e5e-ab82-fb73590f6e93/entry-001.eml b/server/runtime/generated_eml/9e239ef8-3ca2-454b-a336-ca697063cc1d/3253bd83-b934-4e5e-ab82-fb73590f6e93/entry-001.eml new file mode 100644 index 0000000..8b19c07 --- /dev/null +++ b/server/runtime/generated_eml/9e239ef8-3ca2-454b-a336-ca697063cc1d/3253bd83-b934-4e5e-ab82-fb73590f6e93/entry-001.eml @@ -0,0 +1,33 @@ +Date: Sun, 07 Jun 2026 19:28:39 +0000 +Message-ID: <178086051975.6087.7115616117230684590@2384c9acfa31> +From: Rechnungslegung D5 +To: Example Recipient +Reply-To: Rechnungslegung D5 +Subject: Rechnungslegungslisten =?utf-8?q?f=C3=BCr?= 05 / 2026 und + Dienststelle ab0000 +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="===============3288135729314332323==" + +--===============3288135729314332323== +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 8bit + +Sehr geehrte Damen und Herren, + +beigefügt erhalten Sie die Rechnungslegungslisten für 05 / 2026. + +Mit freundlichen Grüßen + +--===============3288135729314332323== +Content-Type: application/zip +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; filename="Rechnungslegung_ab0000.zip" +MIME-Version: 1.0 + +UEsDBBQAAQBjAMdSx1wFviTFTgAAADIAAAAXAAsAYWIwMDAwLTEyMzQ1Ni1kZW1vLlhMU1gBmQcA +AQBBRQMIANRl4MRhaWQr+wpRd3Abo6zgV3zHq2NepO6saiBry37+OD5YoqMM6bjGD2I+otXjvuxU +xlYQDxZT7pfxaOA5JnnDstAa6CFPhyADvWqu01BLAQIUAxQAAQBjAMdSx1wFviTFTgAAADIAAAAX +AAsAAAAAAAAAAACkgQAAAABhYjAwMDAtMTIzNDU2LWRlbW8uWExTWAGZBwABAEFFAwgAUEsFBgAA +AAABAAEAUAAAAI4AAAAAAA== + +--===============3288135729314332323==-- diff --git a/server/runtime/generated_eml/c87a8bb0-fd10-46d4-95e1-5e1f57d3d3ca/dbc9cd27-54c3-43b1-b152-d9560d27eb79/_zip/Rechnungslegung_ab0000.zip b/server/runtime/generated_eml/c87a8bb0-fd10-46d4-95e1-5e1f57d3d3ca/dbc9cd27-54c3-43b1-b152-d9560d27eb79/_zip/Rechnungslegung_ab0000.zip new file mode 100644 index 0000000000000000000000000000000000000000..fcd298383c0393785b791aa73adb58061a5cd375 GIT binary patch literal 244 zcmWIWW@Zs#U}Q*UI39F7hIOCHQ9lL-1|uLAXW(W?OfmohT|*;d6H_zYl+@gOy$GM+ z2*#Q0KrN20%p44&2em(>F8bzhWmVi$d6(_8*{873>vDF{AzSMDtfqJ# z)BfA|B}~larz~&Q$(5@zTYkkqad~8U)vod4o89{p&rQ!?q`AfVPiBBOBa;X-ZubD4 b1qMqRK@?%v1$eWvfmAXAVE~Zs192DtjbBFB literal 0 HcmV?d00001 diff --git a/server/runtime/generated_eml/c87a8bb0-fd10-46d4-95e1-5e1f57d3d3ca/dbc9cd27-54c3-43b1-b152-d9560d27eb79/entry-001.eml b/server/runtime/generated_eml/c87a8bb0-fd10-46d4-95e1-5e1f57d3d3ca/dbc9cd27-54c3-43b1-b152-d9560d27eb79/entry-001.eml new file mode 100644 index 0000000..c5ac8b7 --- /dev/null +++ b/server/runtime/generated_eml/c87a8bb0-fd10-46d4-95e1-5e1f57d3d3ca/dbc9cd27-54c3-43b1-b152-d9560d27eb79/entry-001.eml @@ -0,0 +1,33 @@ +Date: Sun, 07 Jun 2026 19:29:24 +0000 +Message-ID: <178086056428.6645.7378043332025505962@2384c9acfa31> +From: Rechnungslegung D5 +To: Example Recipient +Reply-To: Rechnungslegung D5 +Subject: Rechnungslegungslisten =?utf-8?q?f=C3=BCr?= 05 / 2026 und + Dienststelle ab0000 +MIME-Version: 1.0 +Content-Type: multipart/mixed; boundary="===============7843579084059210473==" + +--===============7843579084059210473== +Content-Type: text/plain; charset="utf-8" +Content-Transfer-Encoding: 8bit + +Sehr geehrte Damen und Herren, + +beigefügt erhalten Sie die Rechnungslegungslisten für 05 / 2026. + +Mit freundlichen Grüßen + +--===============7843579084059210473== +Content-Type: application/zip +Content-Transfer-Encoding: base64 +Content-Disposition: attachment; filename="Rechnungslegung_ab0000.zip" +MIME-Version: 1.0 + +UEsDBBQAAQBjAMdSx1wFviTFTgAAADIAAAAXAAsAYWIwMDAwLTEyMzQ1Ni1kZW1vLlhMU1gBmQcA +AQBBRQMIABXBK/BlovZA1Kp5vaGEN5a2okfJ3QbzNvJWVdcePltUtH6OOpRJxiv9gfRWFkT5HQ1q +yamqaYT6X+RE4jnVPoHR7Lu/Yc6Xb6IptDv8aVBLAQIUAxQAAQBjAMdSx1wFviTFTgAAADIAAAAX +AAsAAAAAAAAAAACkgQAAAABhYjAwMDAtMTIzNDU2LWRlbW8uWExTWAGZBwABAEFFAwgAUEsFBgAA +AAABAAEAUAAAAI4AAAAAAA== + +--===============7843579084059210473==--