Skip to content

Decision Log: Direct Email Send Integration

Decision Log: Direct Email Send Integration

Section titled “Decision Log: Direct Email Send Integration”

Tracks the design decisions for wiring the Email Order composer to the ShopAccess/Email backend (PDEV-969): how the direct-send capability is detected and cached, how the composed message maps onto the EmailJobInput contract, and how the front-end validates and protects the send. Decisions here are mirrored in the Design Document Decision Summary.

#QuestionStatusDecisionRound
DQ-001What drives the direct-send vs copy-only toggleResolvedOperational config whose slug contains the procurement tokenR1
DQ-002When the toggle is resolvedResolvedOn composer open via the BFF config-status route; no cache (initial impl)R1→R6
DQ-003Source of the required subjectResolvedEditable field, default Order for {supplier} — {MMM d, yyyy}R1
DQ-004What the UI “From” field becomesResolvedSent as replyToEmail (Reply-To)R1
DQ-005How the email body is producedResolvedApp-generated inline-styled HTML + plain textR1
DQ-006Injection / validation handlingResolvedEscape on compose + validate addresses; block otherwiseR1
DQ-007Tenant + idempotency headersResolvedBFF injects X-Tenant-Id; SPA generates stable Idempotency-KeyR1
DQ-008Non-Operational config statesResolvedTreated as restricted (copy-only)R1
DQ-009attachments / bccResolvedEmpty ([]) for this featureR1
DQ-010Body HTML/script injection protectionResolved(Revised R7) Sanitize the BFF-composed body with sanitize-html (defense-in-depth, sanitize-and-send — no reject); backend hardening tracked by PDEV-976R2→R7
DQ-011Send format (HTML vs plain text)ResolvedSend split button: default HTML (+ text alternate); menu HTML / plain text. Copy writes both (unchanged)R3

DQ-001: What drives the direct-send vs copy-only toggle

Section titled “DQ-001: What drives the direct-send vs copy-only toggle”

Context: Some tenants can send directly and some cannot. The signal must be derivable from the Email Module without a new endpoint.

OptionDescriptionTrade-offs
AExistence of an Operational EmailConfiguration whose identity.sendingDomainSlug contains a procurement marker (a constant)Uses the as-built contract; marker keeps it easily retargetable
BA dedicated capability flag/endpointCleaner semantically, but no such endpoint exists; new backend work

Recommendation: Option A — fits the shipped contract; the marker is a single constant.

Decision: Option A. Marker is PROCUREMENT_EMAIL_SLUG_TOKEN = "procurement", matched as a substring of sendingDomainSlug; the config must be in Operational state (see DQ-008).

Applied to: design.md (Overview, Structural Design, DQ-001).


Context: The capability changes very rarely. Re-checking on every panel open would be wasteful.

OptionDescriptionTrade-offs
AResolve at sign-in / tenant switch, cache in the SPA per tenantCheap; matches the slow-moving nature; small staleness window
BResolve on every Email Order openAlways fresh; needless repeated backend calls

Recommendation: Option A.

Decision: Option A. A stale cache is tolerated; a backend not-sendable response on Send is the backstop (DQ-008 narrative in design.md).

Superseded by the Round 6 revision — the initial implementation does no caching (neither SPA nor BFF); the toggle is resolved on composer open. See “DQ-002 (revised)” under Round 6.

Applied to: design.md (Overview, toggle-resolution sequence).


Context: EmailJobInput.subject is required, but this send path has no server-side subject composer.

OptionDescriptionTrade-offs
AEditable Subject field, prefilled with a sensible defaultUser control; satisfies the required field; one extra row
BAuto-composed, not shownLess UI; no user control
CFixed constantSimplest; least informative

Recommendation: Option A.

Decision: Option A. Default text Order for {supplier} — {MMM d, yyyy} (en-US date, e.g. “Order for Stark Industries — Jun 26, 2026”); editable thereafter. Non-empty subject is a validation rule (DQ-006).

Applied to: design.md (DQ-003); email-order-ui.md mock (Subject field).


DQ-004: What the UI “From” field becomes

Section titled “DQ-004: What the UI “From” field becomes”

Context: The backend From is fixed by the configuration’s sender identity and is not part of EmailJobInput. The composer nonetheless shows an editable “From”.

OptionDescriptionTrade-offs
AMap the UI From to the message Reply-To (replyToEmail)Gives the field real meaning; replies route to the chosen address
BDisplay-only senderHonest, but the field becomes inert
CRemove the From rowSimplest; loses the reply-routing affordance

Recommendation: Option A.

Decision: Option A. The UI From value is sent as replyToEmail; an empty From yields null. The actual From remains the configuration’s sender.

Applied to: design.md (Overview, DQ-004, API Contract).


Context: “Collect the resulting HTML static form” — but the rendered DOM uses CSS-module classes, which mail clients strip.

OptionDescriptionTrade-offs
AApp-generated email-safe HTML with inlined styles (+ plain textBody) from the live composer stateRenders reliably across mail clients; deterministic
BSerialize the live class-based DOM as-isLiteral, but renders poorly once classes/<style> are stripped

Recommendation: Option A.

Decision: Option A. Compose both htmlBody (inline-styled) and textBody (plain) from the same live state used by the copy/print path.

Applied to: design.md (Overview, DQ-005, Implementation Scope).


Context: User-editable fields feed an HTML email; the front-end must not emit unsafe markup and must ensure a well-formed envelope.

OptionDescriptionTrade-offs
AEscape user content on compose + validate (≥1 To, RFC addresses); block the send with an error otherwiseInjection structurally impossible; permissive about stray < in prose
BEscape + hard-reject any field containing markupStricter; annoys on legitimate </> usage
CEscape + non-blocking warningSofter; relies on the user heeding the warning

Recommendation: Option A.

Decision: Option A. Escape every user field when composing the HTML; validate ≥1 To, RFC-valid To/Cc/Reply-To, and non-empty Subject; a failure shows a blocking error toast and the send is not attempted.

Applied to: design.md (Overview, DQ-006, Testing Strategy).


Context: POST /job requires X-Tenant-Id and Idempotency-Key.

OptionDescriptionTrade-offs
ABFF injects X-Tenant-Id from the session; SPA generates the Idempotency-Key, stable across retries of one SendTenant trust stays server-side; retries don’t double-send
BSPA supplies bothTenant id on the client is less trustworthy

Recommendation: Option A.

Decision: Option A. The SPA mints one Idempotency-Key per Send attempt and reuses it on retry; the BFF derives X-Tenant-Id from the authenticated session.

Applied to: design.md (DQ-007, send sequence, API Contract).


DQ-008: Non-Operational configuration states

Section titled “DQ-008: Non-Operational configuration states”

Context: A matching config may be in Draft, Provisioning, AwaitingVerification, or a *Failed state rather than Operational.

OptionDescriptionTrade-offs
AOnly Operational enables direct send; all other states ⇒ restricted (copy-only)Safe; never offers Send when delivery can’t succeed
BEnable for any matching config regardless of stateRisks offering Send that the backend will reject

Recommendation: Option A.

Decision: Option A. Non-Operational ⇒ restricted form; provisioning is PDEV-971’s responsibility.

Applied to: design.md (DQ-008, error/edge narrative).


Context: Both are present in the contract (attachments required as an array; bcc required in recipients) but unused by this feature.

OptionDescriptionTrade-offs
ASend attachments: [] and bcc: []Satisfies the contract; no extra UI

Recommendation: Option A.

Decision: Option A. Attachments and BCC are out of scope for PDEV-969.

Applied to: design.md (DQ-009, Implementation Scope → Out of Scope).


DQ-010: Protecting the body against HTML / script injection

Section titled “DQ-010: Protecting the body against HTML / script injection”

Context: Users edit text that becomes the email htmlBody, opening an HTML/script-injection surface. The body is also shown in the app’s own preview and may be stored and re-rendered — an XSS surface independent of the recipient’s mail client. An assessment of operations @ origin/main (880790f6) found the Email module does not sanitize or validate the body (it forwards htmlBody verbatim to Postmark), and Postmark does not sanitize either (it relays as-is).

OptionDescriptionTrade-offs
AAllow-list sanitize htmlBody at the shared BFF route and reject (400) if anything was stripped; SPA escape-on-compose (DQ-006) stays the first layerStrongest; protects every client of the route; clear signal on injection attempts
BSanitize only (silently clean)Never blocks, but silently alters the message and hides attempts
CReject only, no sanitizeNo mutation, but brittle — must enumerate every dangerous pattern

Recommendation: Option A.

Decision: Option A. The shared BFF send route allow-list-sanitizes htmlBody (safe formatting tags only; no script / iframe / style / event handlers / javascript: URLs) and rejects the request if sanitizing removed anything. The backend should protect itself at the source of truth — tracked by PDEV-976 (High, Cycle 12).

Applied to: design.md (DQ-010, BFF routes + API contract + Out of Scope), goal.md (constraint 7), ui-implementation-analysis.md (§2 G13, §3.4, §4, §5).

Revised by Round 7 (DQ-010 revised) — the hand-rolled allow-list sanitizer is replaced by sanitize-html, and reject-if-stripped is dropped in favor of sanitize-and-send (defense-in-depth over the BFF-composed body).


DQ-011: How the user chooses HTML vs plain text for direct send

Section titled “DQ-011: How the user chooses HTML vs plain text for direct send”

Context: Today’s EmailPanel Copy puts both a rich-HTML and a plain-text version on the clipboard. Direct send should mirror that — letting the user send either format — without cluttering the footer.

OptionDescriptionTrade-offs
ASend is a split button (like the Items page “Add Item”): default action sends HTML; a caret menu offers “Send as HTML” / “Send as plain text”Familiar pattern; one prominent default; both formats one click away
BTwo separate buttons (Send HTML, Send plain text)More footer clutter; no clear default
CA format toggle/radio elsewhere in the panelDetaches the choice from the action

Recommendation: Option A.

Decision: Option A. Default action sends HTML (htmlBody + a plain-text alternate in textBody); the menu’s “Send as plain text” sends textBody only. Built with the canary SplitButton. Copy is unchanged and writes both text/html and text/plain to the clipboard.

Superseded by DQ-013 — the send format now follows the active Body preview toggle (HTML / Plain Text Preview), so the split-button menu no longer carries format items; it offers Direct Send and Open in email (DQ-014).

Applied to: design.md (DQ-011, Decision Summary + API contract), email-order-ui.md (capabilities + live mock), ui-implementation-analysis.md (§3.2).

Round 4: Stakeholder UX review (2026-06-29)

Section titled “Round 4: Stakeholder UX review (2026-06-29)”

Comments on PDEV-969 (the “UX/Feature Design → Must Have” set, folding in Nice-to-Have #3 and Quick-Follow-up #4). These revise DQ-004’s default and DQ-011’s control shape.

DQ-012: Default recipients — self-CC and the “From” default

Section titled “DQ-012: Default recipients — self-CC and the “From” default”

Context: V1 opened with empty To/Cc (placeholder cues). Reviewers asked that the composer seed the sender’s identity so the buyer keeps a copy and replies route back to them.

Decision: On composer open, From defaults to the logged-in user’s email (it maps to replyToEmail, DQ-004) and Cc is pre-seeded with the same logged-in user’s email (self-CC). To still opens empty with a placeholder (supplier prefill is PDEV-977). Both seeded chips are editable/removable; the self-CC is the login email and does not track later edits to From. This is Cc (not Bcc). Amends the prior “To/Cc empty” default.

Applied to: goal.md (recipients), design.md (DQ-004 note + Decision Summary), email-order-ui.mdx (capabilities + mock), mock (from/cc seed + self-CC hint).

DQ-013: Body view toggle — Compose / HTML Preview / Plain Text Preview

Section titled “DQ-013: Body view toggle — Compose / HTML Preview / Plain Text Preview”

Context: Format choice lived in the Send split-button menu (DQ-011), and the body was a single editable preview. Reviewers asked for a visible Plain/HTML toggle (Nice-to-Have #3) and a Compose → Preview → Send flow (Quick-Follow-up #4).

Decision: The body section (“Body”) carries a three-state toggle — Compose (the editable, inline-edited HTML canvas; “edit is HTML”), HTML Preview (read-only rendered HTML), Plain Text Preview (read-only plain text). The active preview is the send format, so the DQ-011 split button is removed in favor of a single Send that sends the previewed format. Send and Copy are enabled only in a preview mode (forcing a review step); Compose disables them. The toggle resets to Compose on open (last-format-as- session-default — Nice-to-Have #4 — is deferred, out of scope here).

Revises: DQ-011 (split-button format menu → toggle-driven single Send).

Applied to: design.md (DQ-011 revised + Decision Summary), email-order-ui.mdx, mock (body-mode toggle, read-only previews, preview-gated actions).

DQ-014: mailto: option — open a draft in the local client

Section titled “DQ-014: mailto: option — open a draft in the local client”

Context: Reviewers asked for an option to send via the user’s own mail client.

Decision: The send control offers an “Open in email” item in the Send button’s caret menu (and, in restricted mode where there is no Send button, as the default standalone button beside Copy) that opens a mailto: draft (To/Cc/Subject + the plain-text body). mailto: carries plain text only (no HTML, no attachments, URL-length-limited), so it ignores the view toggle and always emits plain text. It is available even when Direct Send is not provisioned (restricted mode = Copy + Open in email), since it needs no backend. Behaviorally it mirrors Copy (DQ-015).

Applied to: design.md (API/behavior + Out-of-Scope nuance), email-order-ui.mdx, mock (Open-in-email anchor in both modes).

DQ-015: Send confirmation — the panel closes (terminal actions accept the order)

Section titled “DQ-015: Send confirmation — the panel closes (terminal actions accept the order)”

Context: A toast alone is too subtle; users were unsure the email had sent.

Decision: A successful Send closes the side-panel (the existing accept → markItemStale → refresh → close flow), with the toast as a secondary confirmation; on failure the panel stays open with an actionable error. Copy and mailto: follow the same terminal behavior — both already accept the selected order cards and close the panel in production (verified in orderQueueHandlers.handleCopyToClipboard). In the mock, the panel switches to a confirmation state with a Reset button (it has no real parent to close).

Applied to: design.md (behavioral outcomes), email-order-ui.mdx, mock (closed/Reset state for Send/Copy/mailto).

Round 5: Nice-to-have / follow-up triage (2026-06-29)

Section titled “Round 5: Nice-to-have / follow-up triage (2026-06-29)”

Triage of the remaining “Nice to Have” / “Quick Follow up” comments. Folded in: NTH#1 (reusability), NTH#2 (edit pattern), QF#2 (per-line notes, prototyped), NTH#4 (session-default format, planned for implementation). Deferred (dependency or scope): QF#1 (harmonize with PO-Lite — blocked on PDEV-970), QF#3 (supplier prefill — PDEV-977), Future#1 (per-tenant supplier templates).

DQ-016: Component reusability seam (NTH#1)

Section titled “DQ-016: Component reusability seam (NTH#1)”

Decision: The composer is built so the shell is reusable across email features (e.g. invitations): the recipient fields, panel shell, Body view toggle, and send/format/Open-in-email controls are reusable; the Body content (greeting / items table / notes / sign-off) is the feature-specific slot. Realized in implementation via the ui-implementation-analysis.md §3 reuse search and the ux-prototype promotion ticket (sebrand@arda.cards); no new runtime behavior — a structural/decomposition commitment.

Applied to: ui-implementation-analysis.md (§3 reusable-seam note), design.md (Decision Summary), plan/requirements.md (REQ-ES-060).

DQ-017: Edit affordance harmonized with grid inline-edit (NTH#2)

Section titled “DQ-017: Edit affordance harmonized with grid inline-edit (NTH#2)”

Context: The ✎-pencil-badge + double-click edit was inconsistent with the AG-grid inline edits used elsewhere (Items, Vendor table, PO-Lite) and the pencils read as visual clutter (reviewer comments).

Decision: Drop the ✎ badges. Editable values follow the grid pattern — the value itself is the click target (single click → edit) with a hover highlight as the only affordance; Enter commits, Esc cancels. The per-field revert (↺) is retained and a global Revert all + ⌘Z stay. No double-click, no pencil.

Applied to: mock (EditableText), email-order-ui.mdx (capabilities), design.md (Decision Summary), plan/requirements.md (REQ-ES-061).

Context: Buyers need per-item specs for the vendor (reviewer: “we need to add order notes … specs to the vendor”); PO-Lite has a per-line notes column.

Decision: Add an editable Notes column to the items table (one note per line), rendered into both the HTML body (a table column) and the plain-text body (an indented Note: sub-line under the item). The existing global Note field is retained — per-line notes carry item-specific specs; the global note covers order-wide remarks. Prototyped in the mock for validation; harmonizing the exact shape with PO-Lite (QF#1) remains a follow-up once PO-Lite is email-enabled.

Applied to: mock (Notes column + render), email-order-ui.mdx (capabilities + Data Inserts), design.md (Decision Summary), plan/requirements.md (REQ-ES-062).

DQ-019: Last send format becomes the session default (NTH#4)

Section titled “DQ-019: Last send format becomes the session default (NTH#4)”

Decision: The chosen send format (HTML vs plain text, expressed via the Body preview toggle) persists as the session default: after the first send, the composer opens directly on the last-used preview rather than resetting to Compose. Client-only session state (Redux / sessionStorage); no backend. Not meaningfully demonstrable in the single-instance mock (the toggle still resets there) — this is a plan/implementation item.

Applied to: plan/requirements.md (REQ-ES-063), plan/specification.md + implementation-changes.md (Phase 4/5 wiring), design.md (Decision Summary).

Round 6: Technical design review (2026-06-29)

Section titled “Round 6: Technical design review (2026-06-29)”

Technical-design comments on PDEV-969. Folded in: Tech-Must-1 (toggle caching) and Tech-NTH-1 (composition in the BFF). Tech-NTH-2 (JWT auth) → separate cross-cutting ticket, out of this project’s scope.

DQ-002 (revised): Toggle resolution — no SPA cache

Section titled “DQ-002 (revised): Toggle resolution — no SPA cache”

Revises DQ-002. The configuration/query behind config-status is a single, quick backend call. For this initial implementation there is no caching at all — neither SPA nor BFF. The composer calls the BFF config-status route on open (brief loading state) and renders full vs restricted from the response; a failed lookup → restricted. (Caching may be revisited later if latency warrants; it would live in the BFF, never the SPA.) This removes the planned emailConfigSlice / emailConfigThunks / selectors and the sign-in / tenant-switch dispatch — a net simplification.

Applied to: design.md (DQ-002 row, config-status element), goal.md (capability detection), plan (requirements REQ-ES-002, specification Phase 3, implementation-changes).

DQ-020: Composition runs in the BFF (Tech-NTH-1)

Section titled “DQ-020: Composition runs in the BFF (Tech-NTH-1)”

Context: “Composition & Sanitization should be in the BFF.” Sanitization already is (DQ-010); this moves composition server-side.

Decision: The BFF send route composes the authoritative HTML + plain-text body from a structured payload (recipients, subject, format, and the editable body fields — greeting, intro, per-line item values + per-line notes, order note, sign-off), escaping user content. compose-email-html becomes a shared lib/email/ pure util imported by both the BFF (authoritative send) and the SPA (live HTML / Plain previews) — one source of truth for the email format. The SPA no longer sends a pre-composed htmlBody/textBody; the allow-list sanitizer (DQ-010) stays as defense-in-depth over the BFF-composed output. Strengthens DQ-006 (the sent body is trusted-by-construction).

Revises: DQ-005 (composition relocated SPA → shared, executed in the BFF on send).

Applied to: design.md (DQ-005 note, DQ-020 row, API Contract send route, BFF + composer elements), goal.md (Backend Contract / field mapping), plan (requirements REQ-ES-041, specification Phase 2/3, implementation-changes — compose-email-html → shared, structured send payload).

Round 7: Library adoption — sanitization & escaping (2026-06-29)

Section titled “Round 7: Library adoption — sanitization & escaping (2026-06-29)”

PR #936 review (and two automated findings) flagged the hand-rolled HTML code. Decision: prefer maintained libraries over ad-hoc HTML manipulation/sanitization.

DQ-010 (revised): Sanitize with sanitize-html, sanitize-and-send (no reject)

Section titled “DQ-010 (revised): Sanitize with sanitize-html, sanitize-and-send (no reject)”

Revises DQ-010. The hand-rolled allow-list sanitizer had a real bypass — attribute quote-smuggling re-serialized into a live event handler while reporting modified === false. Replace it with a thin sanitize-html wrapper (server-only; it parses HTML, so it is not fooled by quote-smuggling). Because composition now runs in the BFF from a structured model (DQ-020) — user content escaped, only allow-listed markup emitted — the sanitizer is defense-in-depth: the send route sanitizes-and-sends the result. The reject-if-stripped (400) step is dropped (it was built for the hand-rolled detector + SPA-composed HTML; with a library it would also false-positive on benign re-serialization). Backend hardening stays PDEV-976.

DQ-021: Escape composed content with escape-html

Section titled “DQ-021: Escape composed content with escape-html”

compose-email-html escapes user content via the maintained escape-html library instead of a bespoke .replace() chain. The deterministic table layout stays hand-written (no lightweight library fits without bloat such as mjml).

DQ-022: Validate control characters on the raw address value

Section titled “DQ-022: Validate control characters on the raw address value”

validate-email-order checks control characters on the raw (un-trimmed) address value, so a trailing \r\n cannot be hidden by trim() and slip past the header-injection guard (Codex P2 review). The same applies to Cc and Reply-To.

Applied to: code (PR #936 — sanitize-email-html, compose-email-html, validate-email-order + tests; deps sanitize-html, escape-html); design.md (DQ-010 row, Security, BFF routes, API contract); goal.md (constraint 7); plan (requirements REQ-ES-040/042, specification Phase 2/3, implementation-changes, verification); ui-implementation-analysis.md (§2 G13, §3.4, §4, §5).