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 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.
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 | (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-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. Default value: the logged-in user’s email (DQ-012) |
| 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. (Composition runs in the BFF via a shared util — DQ-020) |
| 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 | (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-011 | Send 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-012 | Default recipients | From and Cc seed with the logged-in user’s email (self-CC); To opens empty (supplier prefill = PDEV-977) |
| DQ-013 | Body view + send format | ”Body” 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-014 | mailto: option | Open 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-015 | Send confirmation | A successful Send / Copy / mailto: closes the panel (accept → markItemStale → refresh → close); the toast is secondary |
| DQ-016 | Component reusability | Composer shell (recipient fields, Body toggle, footer/send controls) is reusable across email features; Body content is the feature slot |
| DQ-017 | Edit affordance | Grid-style click-to-edit (no pencil badge), matching Items / Vendor / PO-Lite; per-field revert + Revert all / ⌘Z retained |
| DQ-018 | Per-line Notes | Editable per-line Notes column in the items table (HTML column + plain-text Note: sub-line); the global Note field is retained |
| DQ-019 | Session-default format | Last send format (HTML/plain) persists as the session default; the composer opens on the last-used preview |
| DQ-020 | Where composition runs | The 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.
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/arda/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
config-statusresponse 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.
BFF routes (Next.js)
Section titled “BFF routes (Next.js)”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-orderstub is retired); builds theEmailJobInput, injectsX-Tenant-Id+Idempotency-Key, and calls the backendjobendpoint. It composes the body (shared util, DQ-020) and runs it throughsanitize-htmlas a defense-in-depth backstop before sending (sanitize-and-send; no reject — DQ-010 revised, R7).- Design decision: DQ-007, DQ-010, DQ-020.
Behavioral Design
Section titled “Behavioral Design”Sequence Diagrams
Section titled “Sequence Diagrams”Toggle resolution — runs on composer open (no cache — DQ-002 revised); the result drives the full-vs-restricted form for that open.
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/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);502if 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 sharedcompose-email-htmlutil (escaping user content), then sanitizes (backstop) before buildingEmailJobInput. - Format (DQ-013, revising DQ-011): the active Body preview chooses the
format — HTML Preview sends
htmlBody(withtextBodyas the alternate), Plain Text Preview sendstextBodyonly (htmlBodyomitted). 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 amailto: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 andmailto:follow the same terminal accept-and-close behavior. - Response
200:{ ok: true, jobEId: string } - Body protection (DQ-010, revised R7): the BFF-composed
htmlBodyis run throughsanitize-html(allow-list tags/attributes;http/https/mailtoonly; drops<script>/<iframe>/<style>/event handlers/javascript:/data:/ images) and the sanitized result is sent — sanitize-and-send, no400reject. 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/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 | BFF composes the body (escaping user content) then runs sanitize-html (allow-list) and sends the sanitized result — defense-in-depth | BFF | DQ-010 (R7) / DQ-020 |
| 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/arda/email/config-status) | Resolve + return the per-tenant toggle |
useEmailConfigStatus (hook) | arda-frontend-app SPA | Fetch on composer open (no cache — DQ-002 revised); 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 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 |
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