Design: Direct Email Send Integration
Overview
Section titled “Overview”This design covers the front-end ↔ back-end integration that turns the Email
Order composer (the EmailPanel, prototyped in
email-order-ui.md) into a real sender, delivering the
order email through the shipped ShopAccess/Email module instead of the
copy-to-clipboard workaround. It is the implementation design behind
goal.md for PDEV-969.
Two behaviors define the feature. First, a slow-moving capability toggle:
whether a tenant can send directly is decided by the presence of an
Operational EmailConfiguration whose sending-domain slug carries a
procurement marker. That fact is resolved once per sign-in / tenant switch and
cached in the SPA, where it selects between the full direct-send form and the
restricted copy-only form. Second, the send action: on Send the SPA
validates the recipients, composes an email-safe HTML body (plus a plain-text
alternate) from the live composer state, and posts it to a Backend-for-Frontend
(BFF) route that calls POST /v1/shop-access/email/job, then reports the
outcome via a toast.
Key design choices: the From address is server-owned (the configuration’s sender identity), so the UI’s editable “From” maps to the message’s Reply-To (DQ-004); the email body is app-generated with inlined styles rather than scraped from the live DOM (DQ-005); and user-entered content is escaped at compose time so injection is structurally impossible, with address validation gating the send (DQ-006). Full rationale in the Decision Log.
Decision Summary
Section titled “Decision Summary”| # | Decision | Chosen Option |
|---|---|---|
| DQ-001 | What drives the direct-send vs copy-only toggle | An EmailConfiguration in Operational state whose identity.sendingDomainSlug contains the PROCUREMENT_EMAIL_SLUG_TOKEN constant |
| DQ-002 | When the toggle is resolved | At sign-in and tenant switch; cached in the SPA per tenant (not per request) |
| DQ-003 | Source of the required subject | Editable Subject field, prefilled Order for {supplier} — {MMM d, yyyy} (en-US) |
| DQ-004 | What the UI “From” field becomes | The message Reply-To (replyToEmail); the actual From is the configuration’s sender |
| DQ-005 | How the email body is produced | App-generated email-safe HTML with inlined styles + a plain-text textBody; never the raw class-based DOM |
| DQ-006 | Injection / validation handling | Escape all user content on compose; validate ≥1 To and RFC-valid addresses; block the send with an error toast otherwise |
| DQ-007 | Tenant + idempotency headers | BFF injects X-Tenant-Id from the session; the SPA generates the Idempotency-Key, stable across retries of one Send |
| DQ-008 | Non-Operational config states (Draft / Provisioning / *Failed) | Treated as restricted (copy-only); provisioning tracked by PDEV-971 |
| DQ-009 | attachments / bcc | Empty for this feature ([]) |
| DQ-010 | Protecting the body against HTML/script injection | Allow-list sanitize + reject-if-stripped at the shared BFF; backend hardening tracked by PDEV-976 |
| DQ-011 | Send format (HTML vs plain text) | Send is a split button — default HTML (with a plain-text alternate); menu offers HTML / plain text. Copy writes both formats (unchanged) |
Full rationale in Decision Log.
Structural Design
Section titled “Structural Design”Component / Block Diagram
Section titled “Component / Block Diagram”The SPA never talks to the Operations monolith directly: both the toggle lookup
and the send go through BFF route handlers, which hold the API credentials and
inject tenant context. The Email Module owns both the configuration query
(used to resolve the toggle) and the job endpoint (used to send). The
KanbanCard and Item modules are compose-time sources — they supply the
order lines, item details, supplier, SKU, quantity and unit price that the panel
renders — and are not on the send path itself (the SPA already holds the composed
content when the user clicks Send). The actual From address and delivery to
Postmark are entirely server-side, downstream of the Email Module.
Key Elements
Section titled “Key Elements”PROCUREMENT_EMAIL_SLUG_TOKEN
Section titled “PROCUREMENT_EMAIL_SLUG_TOKEN”- Location:
arda-frontend-appshared constants. - Responsibility: the single, easily-updated marker (
"procurement") used to recognize the procurement-featureEmailConfigurationby itsidentity.sendingDomainSlug. - Design decision: DQ-001.
Email-config cache / context (SPA)
Section titled “Email-config cache / context (SPA)”- Responsibility: holds
{ directSendEnabled, configurationEId, senderAddress }for the active tenant; populated fromGET /api/email/config-statusat sign-in and on tenant switch; read synchronously by the composer to pick the form shape. - Design decision: DQ-001, DQ-002.
EmailPanel composer (SPA)
Section titled “EmailPanel composer (SPA)”- Responsibility: renders the form (full vs restricted per the cache), owns the editable fields (recipients, Subject, greeting, intro, line quantities/prices, note, sign-off), runs validation, composes the HTML/text bodies, and invokes the send route.
- Design decision: DQ-003, DQ-004, DQ-005, DQ-006.
BFF routes (Next.js)
Section titled “BFF routes (Next.js)”GET /api/email/config-status— resolves and returns the toggle for the session tenant (queries the Email Module, applies the slug + Operational rule). Replaces nothing; new.POST /api/email/send-order— replaces today’s logging-only stub; builds theEmailJobInput, injectsX-Tenant-Id+Idempotency-Key, and calls the backendjobendpoint. It allow-list-sanitizeshtmlBodyand rejects the request (400) if sanitizing strips anything, so the protection is shared by every client of the route (DQ-010).- Design decision: DQ-007, DQ-010.
Behavioral Design
Section titled “Behavioral Design”Sequence Diagrams
Section titled “Sequence Diagrams”Toggle resolution — runs once per sign-in / tenant switch; the result is cached and drives every subsequent open of the composer.
Send (happy path) — the SPA validates and composes locally, then the BFF performs the authenticated, idempotent backend call.
Error and edge paths (narrative — kept out of the happy-path diagrams):
- Validation fails (no To, malformed address, empty Subject): the SPA never calls the BFF; it shows a blocking error toast naming the problem. User content is escaped regardless, so a “bad” body cannot reach the wire.
- Toggle disabled / not Operational (DQ-008): the composer is in its restricted (copy-only) shape — there is no Send button to reach this flow. If the cached toggle is stale and the backend rejects with a not-sendable / configuration error, the SPA surfaces a clear message and falls back to Copy.
- Backend rejects the job (e.g. suppression, not-sendable, 4xx/5xx): the BFF
maps the response to a user-facing error toast; the
Idempotency-Keyis retained so a user retry of the same Send does not double-send. - Network / BFF failure: error toast; retry reuses the same idempotency key.
API Contract
Section titled “API Contract”BFF — GET /api/email/config-status
- Authentication: session cookie (BFF resolves tenant from the session).
- Response
200:{ directSendEnabled: boolean, configurationEId: string | null, senderAddress: string | null } - Errors:
401(no session);502if the Email Module query fails (SPA treats a failed lookup as not enabled).
BFF — POST /api/email/send-order
- Authentication: session cookie; BFF injects
X-Tenant-Id. - Request:
{ configurationEId: string, to: string[], cc: string[], replyTo: string | null, subject: string, htmlBody: string, textBody: string, idempotencyKey: string } - Format (DQ-011): the Send split button chooses HTML (default —
htmlBodypopulated,textBodycarried as the alternate) or plain text (textBodyonly,htmlBodyomitted). The dual-format Copy is unchanged. - Response
200:{ ok: true, jobEId: string } - Body protection (DQ-010): allow-list-sanitize
htmlBody; reject400if sanitizing removed disallowed/dangerous markup (<script>/<iframe>/<style>/ event handlers /javascript:URLs). Shared by all clients; complements the SPA escape-on-compose (DQ-006). - Errors:
400(validation / sanitization — defense in depth over the SPA checks);409/422mapped from the backend;502for upstream failures.
Backend — POST /v1/shop-access/email/job (consumed by the BFF)
- Headers:
X-Tenant-Id(required),Idempotency-Key(required),X-Request-ID(optional). - Request (
EmailJobInput):configurationEId(uuid),recipients{ to[], cc[], bcc[] },subject,htmlBody,textBody,replyToEmail,attachments[]. For this featurebcc = []andattachments = [](DQ-009); there is nofromfield — the sender is bound to the configuration. - Response
200:TypedIdempotencyOutcome(the send is idempotent on theIdempotency-Key). - See the Email module API reference.
Security
Section titled “Security”This feature lets an authenticated user send email from the tenant’s verified
domain with editable recipients and body — an elevated-risk capability, so the
defenses are layered (SPA → shared BFF → backend). The front-end / BFF controls
are specified here; the backend (source-of-truth) controls are tracked in
PDEV-976; the product / abuse
decisions are in goal.md.
| Threat | Mitigation (this design) | Where | Status |
|---|---|---|---|
| HTML / script injection in the body | Allow-list sanitize htmlBody; reject 400 if anything stripped; SPA escapes user content on compose | BFF + SPA | DQ-010 |
| Malicious links / remote content | Sanitizer restricts <a href> to http/https/mailto (no javascript:/data:); remote <img> disallowed (tracking pixels / SSRF) | BFF | new |
| Header / field injection (CR/LF) | Reject CR/LF + control chars in To/Cc, subject, and Reply-To before send | BFF (validate) | new |
| Content / PII in logs | Send route never logs recipients or body (extends the no-header-logging guardrail, PDEV-478) | BFF | new |
| CSRF | Routes authenticate with the bearer token from the auth store (no ambient cookie session) | BFF | confirm |
Cross-tenant send via configurationEId (IDOR) | Backend verifies the config belongs to the caller’s tenant; the BFF does not trust a client-supplied id blindly | backend | PDEV-976 |
| Send abuse / data exfiltration to arbitrary recipients | Recipient policy + per-tenant rate/volume limits | product + backend | goal.md / PDEV-976 |
The SPA escape-on-compose (DQ-006) is the first layer; the shared BFF is the client-wide backstop so the upcoming PO direct send inherits the same protection; the backend is the source-of-truth control (PDEV-976).
Implementation Scope
Section titled “Implementation Scope”Files to Create
Section titled “Files to Create”| File | Path | Purpose |
|---|---|---|
procurement-email.ts (constant) | arda-frontend-app shared config | PROCUREMENT_EMAIL_SLUG_TOKEN = "procurement" (DQ-001) |
config-status route | arda-frontend-app BFF (/api/email/config-status) | Resolve + return the per-tenant toggle |
useEmailConfigStatus (hook/context) | arda-frontend-app SPA | Fetch at sign-in / tenant switch, cache, expose { directSendEnabled, configurationEId, senderAddress } |
compose-email-html.ts | arda-frontend-app SPA | Build the inline-styled HTML body + plain text from composer state, escaping user content (DQ-005, DQ-006) |
validate-email-order.ts | arda-frontend-app SPA | ≥1 To, RFC address checks, non-empty subject (DQ-006) |
Files to Modify
Section titled “Files to Modify”| File | Change Description |
|---|---|
POST /api/email/send-order (BFF) | Replace the logging-only stub with a handler that builds EmailJobInput, injects X-Tenant-Id + Idempotency-Key, calls POST /job, and maps outcomes (DQ-007) |
EmailPanel (SPA) | Wire the cached toggle to the form shape; add the editable Subject (default per DQ-003); map the From chip to replyToEmail (DQ-004); on Send: validate → compose → call the send route → toast |
Out of Scope
Section titled “Out of Scope”- PO-Line order emails — PDEV-970.
- Provisioning tenant email configurations — PDEV-971 (this design only reads configuration state).
- Attachments and BCC (
[]for this feature). - Per-send From override / multi-From — deferred to PDEV-903 at the backend.
- Any change to the Email Module contract — consumed as-is.
- Backend-side body sanitization (the source-of-truth control) — tracked by PDEV-976; this design adds the shared BFF sanitize/reject as the interim, client-wide protection.
Testing Strategy
Section titled “Testing Strategy”Unit Tests
Section titled “Unit Tests”| Test | Target | Validates |
|---|---|---|
| Toggle selection | config-status resolver | Operational + slug-contains-token ⇒ enabled; Draft/Provisioning/Failed ⇒ disabled (DQ-008) |
| Subject default | composer | Order for {supplier} — {MMM d, yyyy} in en-US, editable thereafter |
| HTML escaping | compose-email-html | <, >, &, quotes in user fields are escaped; no raw markup reaches the body |
| Address validation | validate-email-order | rejects empty To, malformed addresses; accepts valid To/Cc/Reply-To |
| Reply-To mapping | composer | UI From → replyToEmail; empty From ⇒ null |
Integration Tests
Section titled “Integration Tests”| Test | Setup | Validates |
|---|---|---|
| config-status route | BFF with mocked Email Module configuration/query | Correct toggle + configurationEId returned; query failure ⇒ not-enabled |
| send-order route | BFF with mocked job endpoint | EmailJobInput shape, X-Tenant-Id + Idempotency-Key headers present; backend error ⇒ mapped error |
| Retry idempotency | send-order route | A retried Send reuses the same Idempotency-Key |
API Tests
Section titled “API Tests”| Test | Method | Path | Expected |
|---|---|---|---|
| Send job smoke (dev) | POST | /v1/shop-access/email/job | 200 TypedIdempotencyOutcome against a dev tenant with an Operational procurement config |
References
Section titled “References”- Goal: Direct Email Sending for Email Orders
- Email Order UI — Direct Send Design
- Decision Log
- Email module API reference
- KanbanCard module
- Item module
- Order Queue
Copyright: (c) Arda Systems 2025-2026, All rights reserved
Copyright: © Arda Systems 2025-2026, All rights reserved