Skip to content

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=nonequarantinereject 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 a Client Type column derived from client_profile.is_self_for_stringer).
  • Clients — all ClientProfile rows belonging to this stringer (de-referenced with the corresponding Person'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.