Skip to content

Design: Direct Email Send Integration

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.

#DecisionChosen Option
DQ-001What drives the direct-send vs copy-only toggleAn EmailConfiguration in Operational state whose identity.sendingDomainSlug contains the PROCUREMENT_EMAIL_SLUG_TOKEN constant
DQ-002When the toggle is resolvedAt sign-in and tenant switch; cached in the SPA per tenant (not per request)
DQ-003Source of the required subjectEditable Subject field, prefilled Order for {supplier} — {MMM d, yyyy} (en-US)
DQ-004What the UI “From” field becomesThe message Reply-To (replyToEmail); the actual From is the configuration’s sender
DQ-005How the email body is producedApp-generated email-safe HTML with inlined styles + a plain-text textBody; never the raw class-based DOM
DQ-006Injection / validation handlingEscape all user content on compose; validate ≥1 To and RFC-valid addresses; block the send with an error toast otherwise
DQ-007Tenant + idempotency headersBFF injects X-Tenant-Id from the session; the SPA generates the Idempotency-Key, stable across retries of one Send
DQ-008Non-Operational config states (Draft / Provisioning / *Failed)Treated as restricted (copy-only); provisioning tracked by PDEV-971
DQ-009attachments / bccEmpty for this feature ([])
DQ-010Protecting the body against HTML/script injectionAllow-list sanitize + reject-if-stripped at the shared BFF; backend hardening tracked by PDEV-976
DQ-011Send 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.


PlantUML 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.

  • Location: arda-frontend-app shared constants.
  • Responsibility: the single, easily-updated marker ("procurement") used to recognize the procurement-feature EmailConfiguration by its identity.sendingDomainSlug.
  • Design decision: DQ-001.
  • Responsibility: holds { directSendEnabled, configurationEId, senderAddress } for the active tenant; populated from GET /api/email/config-status at sign-in and on tenant switch; read synchronously by the composer to pick the form shape.
  • Design decision: DQ-001, DQ-002.
  • 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.
  • 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 the EmailJobInput, injects X-Tenant-Id + Idempotency-Key, and calls the backend job endpoint. It allow-list-sanitizes htmlBody and 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.

Toggle resolution — runs once per sign-in / tenant switch; the result is cached and drives every subsequent open of the composer.

PlantUML diagram

Send (happy path) — the SPA validates and composes locally, then the BFF performs the authenticated, idempotent backend call.

PlantUML diagram

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-Key is retained so a user retry of the same Send does not double-send.
  • Network / BFF failure: error toast; retry reuses the same idempotency key.

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); 502 if 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 — htmlBody populated, textBody carried as the alternate) or plain text (textBody only, htmlBody omitted). The dual-format Copy is unchanged.
  • Response 200: { ok: true, jobEId: string }
  • Body protection (DQ-010): allow-list-sanitize htmlBody; reject 400 if 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/422 mapped from the backend; 502 for 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 feature bcc = [] and attachments = [] (DQ-009); there is no from field — the sender is bound to the configuration.
  • Response 200: TypedIdempotencyOutcome (the send is idempotent on the Idempotency-Key).
  • See the Email module API reference.

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.

ThreatMitigation (this design)WhereStatus
HTML / script injection in the bodyAllow-list sanitize htmlBody; reject 400 if anything stripped; SPA escapes user content on composeBFF + SPADQ-010
Malicious links / remote contentSanitizer restricts <a href> to http/https/mailto (no javascript:/data:); remote <img> disallowed (tracking pixels / SSRF)BFFnew
Header / field injection (CR/LF)Reject CR/LF + control chars in To/Cc, subject, and Reply-To before sendBFF (validate)new
Content / PII in logsSend route never logs recipients or body (extends the no-header-logging guardrail, PDEV-478)BFFnew
CSRFRoutes authenticate with the bearer token from the auth store (no ambient cookie session)BFFconfirm
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 blindlybackendPDEV-976
Send abuse / data exfiltration to arbitrary recipientsRecipient policy + per-tenant rate/volume limitsproduct + backendgoal.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).


FilePathPurpose
procurement-email.ts (constant)arda-frontend-app shared configPROCUREMENT_EMAIL_SLUG_TOKEN = "procurement" (DQ-001)
config-status routearda-frontend-app BFF (/api/email/config-status)Resolve + return the per-tenant toggle
useEmailConfigStatus (hook/context)arda-frontend-app SPAFetch at sign-in / tenant switch, cache, expose { directSendEnabled, configurationEId, senderAddress }
compose-email-html.tsarda-frontend-app SPABuild the inline-styled HTML body + plain text from composer state, escaping user content (DQ-005, DQ-006)
validate-email-order.tsarda-frontend-app SPA≥1 To, RFC address checks, non-empty subject (DQ-006)
FileChange 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
  • 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.

TestTargetValidates
Toggle selectionconfig-status resolverOperational + slug-contains-token ⇒ enabled; Draft/Provisioning/Failed ⇒ disabled (DQ-008)
Subject defaultcomposerOrder for {supplier} — {MMM d, yyyy} in en-US, editable thereafter
HTML escapingcompose-email-html<, >, &, quotes in user fields are escaped; no raw markup reaches the body
Address validationvalidate-email-orderrejects empty To, malformed addresses; accepts valid To/Cc/Reply-To
Reply-To mappingcomposerUI From → replyToEmail; empty From ⇒ null
TestSetupValidates
config-status routeBFF with mocked Email Module configuration/queryCorrect toggle + configurationEId returned; query failure ⇒ not-enabled
send-order routeBFF with mocked job endpointEmailJobInput shape, X-Tenant-Id + Idempotency-Key headers present; backend error ⇒ mapped error
Retry idempotencysend-order routeA retried Send reuses the same Idempotency-Key
TestMethodPathExpected
Send job smoke (dev)POST/v1/shop-access/email/job200 TypedIdempotencyOutcome against a dev tenant with an Operational procurement config



Copyright: (c) Arda Systems 2025-2026, All rights reserved