Integrations¶
RBO V2's external surface is deliberately small. Two kinds of outbound: email and data export. No payment gateway, no SMS, no calendars, no webhooks.
Confidence: High.
Email — Resend SMTP¶
Provider locked by keystone ADR-0005 (2026-04-28). RBO consumes the platform-level decision; it does not own the relay choice.
| Aspect | Choice |
|---|---|
| Provider | Resend transactional SMTP relay (smtp.resend.com:587, STARTTLS) |
| Library | aiosmtplib (async-native, drop-in for FastAPI) — Resend exposes a standard SMTP submission endpoint, so no SDK is required |
| Authentication | SMTP AUTH with Resend API key. Credentials injected by Atlas's CI as SMTP_HOST, SMTP_PORT, SMTP_USER (resend), SMTP_PASS (re_<api-key>), SMTP_FROM (per app-env, e.g. noreply@wagen.io) — see keystone email runbook |
| Sender domain | wagen.io (same domain Caddy serves) |
| DKIM | resend._domainkey.wagen.io, signed d=wagen.io — provisioned by Atlas via the Resend dashboard |
| SPF + DMARC | Owned by Atlas at the platform level (combined SPF includes _spf.resend.com; DMARC p=none → quarantine → reject rollout per keystone email doc) |
| Relay cap | 100/day, 3000/month on Resend's free tier |
Volume reality check: at ~50 receipts/year per stringer + a handful of admin moderation pings + V2 onboarding magic-links via gotrue, we are well below the 100/day cap. No rate-pacing logic needed in V2. If V3 notification fan-out approaches 80/day on a sustained basis (keystone's documented upgrade trigger), Atlas moves the platform to Resend's first paid tier — RBO does not change. If V3 burstiness becomes a problem, add a queue (RQ/arq) — V3 decision, not a V2 architecture concern.
Email events emitted by RBO¶
| Event | Trigger | Recipient | Includes attachment |
|---|---|---|---|
| Receipt | Order's strung_at is set, OR manual "resend" |
order.client_profile.person.email (if non-null) |
Yes — PDF receipt |
| Stringer onboarding invite | Admin creates a Stringer | New stringer's email | No — gotrue invite link |
| Catalogue moderation notification | New row enters catalogue_submissions with status = 'pending' |
All admins (Stefan) | No |
| Password reset | Stringer requests reset | Stringer's email | No — gotrue link |
The first three are RBO-emitted. The last two are emitted by gotrue, not RBO — RBO just configures gotrue's SMTP to point at the same Resend relay (same credentials, same d=wagen.io DKIM).
SMTP wrapper design¶
A single EmailSender class owns the connection and the from-address. It exposes send_receipt(order, locale), send_admin_moderation_notice(submission), send_invite(stringer). The wrapper is the only place SMTP credentials are read; handlers never call aiosmtplib directly.
This keeps the four event types' templates colocated with the wrapper and makes the V3 notification fan-out a clean "add a fifth method on the same class" change.
Data export — XLSX + JSON¶
Per Topic 5: per-stringer self-service export, two formats.
XLSX export¶
Mirrors the V1 spreadsheet shape so Stefan (or any stringer) can fall back to Excel-driven analysis. One file per stringer per export.
- Tool:
openpyxl(already in the project's heritage; same library used for the V1 → V2 migration ETL). - Structure: four sheets, mirroring V1's familiar shape:
Orders— all orders for this stringer (unified, with aClient Typecolumn derived fromclient_profile.is_self_for_stringer).Clients— allClientProfilerows belonging to this stringer (de-referenced with the correspondingPerson's display name + email).Rackets— Racket rows visible to this stringer (own private + shared, the visibility filter applied).Strings— same.- Trigger: stringer hits a "Download my data (XLSX)" button. Synchronous request, file streamed in the response. Sub-second at our row counts.
- Tenancy: uses the same SQLAlchemy session hook as every other read — per auth-and-tenancy, the export endpoint sees only the requesting stringer's data. No special privilege; no admin override.
JSON export¶
Per Topic 5: a "complete client-context export" that is self-contained — i.e. dereferenced ClientProfile (+ Person) + racket + string rows are nested inside each order, not just FK ids. This is the "I want a backup of my own data and be able to read it without the app" path.
- Shape: a single JSON document
{ stringer: {...}, client_profiles: [{...}], persons: [{...}], rackets: [{...}], strings: [{...}], orders: [{...with embedded references...}] }. - Tool: Python
json+ Pydantic models for stable schema. Streamed response. - Tenancy: same session-scoped hook. No admin override.
Admin's per-stringer export¶
Admin can trigger either export for a specific stringer (e.g. when offboarding). Implemented via the same endpoint with an ?as_stringer={id} parameter that the admin role can use; this is the second deliberate use of the admin's bypass_tenant privilege from auth-and-tenancy. Logged.
What's NOT integrated¶
| Area | V2 status | V3 status |
|---|---|---|
| Online payments | Out of scope (Topic 4 W1) | Out of scope |
| SMS | Out of scope V2 (Topic 5 — only if free/cheap) | Possible in V3 if Stefan finds a free path |
| Calendars (iCal / Google Calendar) | Not asked for | Not in candidate list |
| Webhooks (incoming) | None | None |
| Webhooks (outgoing) | None | None |
| QR code on receipts | Not asked for in V2 | Hinted at as V3 idea — not blocking |
| Push notifications | Not in scope | Not in scope (PWA is V3-ish per Topic 5) |
The integration surface stays small by design — every external dependency is an ops cost Stefan would carry as the sole operator.
Backup integration (NOT an RBO feature)¶
Whole-database backups are Atlas's responsibility per Atlas §8. RBO does not run its own backup path. The XLSX/JSON exports above are product-level features (user-visible "download my data"), not infrastructural backup.
If Stefan ever wants RBO to push a periodic export to a stringer's own email or cloud storage, that's a V3 nice-to-have — not architecturally interesting, just a cron + reusing the export endpoint.