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 on composer open via the BFF config-status route (no cache — DQ-002 revised), 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 resolved(Revised — Round 6) On composer open via the BFF config-status route; no caching in the initial implementation (neither SPA nor BFF; the query is quick) — revisit later if needed
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. Default value: the logged-in user’s email (DQ-012)
DQ-005How the email body is producedApp-generated email-safe HTML with inlined styles + a plain-text textBody; never the raw class-based DOM. (Composition runs in the BFF via a shared util — DQ-020)
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 injection(Revised — R7) Sanitize the BFF-composed body with sanitize-html (defense-in-depth, sanitize-and-send — no reject); backend hardening tracked by PDEV-976
DQ-011Send format (HTML vs plain text)(Revised by DQ-013) The active Body preview is the send format; a single Send replaces the earlier split button. Copy writes both formats (unchanged)
DQ-012Default recipientsFrom and Cc seed with the logged-in user’s email (self-CC); To opens empty (supplier prefill = PDEV-977)
DQ-013Body view + send formatBody” section three-state toggle: Compose (editable) / HTML Preview / Plain Text Preview; the active preview is the send format. Send & Copy enabled only in a preview
DQ-014mailto: optionOpen in email in the Send button’s caret menu (a standalone button beside Copy in restricted mode) — a mailto: draft (plain text only, ignores the toggle), available even when direct send is not provisioned
DQ-015Send confirmationA successful Send / Copy / mailto: closes the panel (accept → markItemStale → refresh → close); the toast is secondary
DQ-016Component reusabilityComposer shell (recipient fields, Body toggle, footer/send controls) is reusable across email features; Body content is the feature slot
DQ-017Edit affordanceGrid-style click-to-edit (no pencil badge), matching Items / Vendor / PO-Lite; per-field revert + Revert all / ⌘Z retained
DQ-018Per-line NotesEditable per-line Notes column in the items table (HTML column + plain-text Note: sub-line); the global Note field is retained
DQ-019Session-default formatLast send format (HTML/plain) persists as the session default; the composer opens on the last-used preview
DQ-020Where composition runsThe BFF composes the authoritative HTML+plain body from a structured payload via the shared compose-email-html util; the SPA uses the same util for live preview; the sanitizer (DQ-010) is defense-in-depth

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/arda/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 config-status response fetched on open), owns the editable fields (recipients, Subject, greeting, intro, line quantities/prices, per-line + order notes, sign-off), runs validation, composes the HTML/text bodies for live preview (shared util), and sends the structured payload to the route (the BFF composes the authoritative body — DQ-020).
  • Design decision: DQ-003, DQ-004, DQ-005, DQ-006, DQ-020.
  • GET /api/arda/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/arda/email/send — new route (the /api/email/send-order stub is retired); builds the EmailJobInput, injects X-Tenant-Id + Idempotency-Key, and calls the backend job endpoint. It composes the body (shared util, DQ-020) and runs it through sanitize-html as a defense-in-depth backstop before sending (sanitize-and-send; no reject — DQ-010 revised, R7).
  • Design decision: DQ-007, DQ-010, DQ-020.

Toggle resolution — runs on composer open (no cache — DQ-002 revised); the result drives the full-vs-restricted form for that open.

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/arda/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/arda/email/send

  • Authentication: session cookie; BFF injects X-Tenant-Id.
  • Request (structured — DQ-020): { configurationEId, recipients { to[], cc[] }, replyToEmail, subject, format: 'html' | 'text', body: { greeting, intro, lineItems[{ name, quantity, taxable, sku, unitPrice, note }], orderNote, signOff }, idempotencyKey }. The SPA sends content, not markup — the BFF composes the HTML + plain body from this via the shared compose-email-html util (escaping user content), then sanitizes (backstop) before building EmailJobInput.
  • Format (DQ-013, revising DQ-011): the active Body preview chooses the format — HTML Preview sends htmlBody (with textBody as the alternate), Plain Text Preview sends textBody only (htmlBody omitted). A single Send replaces the earlier split button; Send and Copy are enabled only in a preview. The dual-format Copy is unchanged.
  • mailto: option (DQ-014): Open in email in the Send button’s caret menu (a standalone button beside Copy in restricted mode) builds a mailto: draft (To/Cc/Subject + plain-text body) and hands off to the local client — plain text only, no backend, available even in restricted mode.
  • Confirmation (DQ-015): on success the side-panel closes (accept → markItemStale → refresh → close); the toast is secondary. Copy and mailto: follow the same terminal accept-and-close behavior.
  • Response 200: { ok: true, jobEId: string }
  • Body protection (DQ-010, revised R7): the BFF-composed htmlBody is run through sanitize-html (allow-list tags/attributes; http/https/mailto only; drops <script>/<iframe>/<style>/event handlers/javascript:/data:/ images) and the sanitized result is sent — sanitize-and-send, no 400 reject. Defense-in-depth over composition’s escaping. 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 bodyBFF composes the body (escaping user content) then runs sanitize-html (allow-list) and sends the sanitized result — defense-in-depthBFFDQ-010 (R7) / DQ-020
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/arda/email/config-status)Resolve + return the per-tenant toggle
useEmailConfigStatus (hook)arda-frontend-app SPAFetch on composer open (no cache — DQ-002 revised); 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 stub)Retire this logging-only stub — superseded by the new POST /api/arda/email/send route (composes via the shared util → sanitizes → EmailJobProxy; DQ-007/DQ-020)
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