Decision Log: Direct Email Send Integration
Decision Log: Direct Email Send Integration
Section titled “Decision Log: Direct Email Send Integration”Purpose
Section titled “Purpose”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.
Decision Table
Section titled “Decision Table”| # | Question | Status | Decision | Round |
|---|---|---|---|---|
| DQ-001 | What drives the direct-send vs copy-only toggle | Resolved | Operational config whose slug contains the procurement token | R1 |
| DQ-002 | When the toggle is resolved | Resolved | On composer open via the BFF config-status route; no cache (initial impl) | R1→R6 |
| DQ-003 | Source of the required subject | Resolved | Editable field, default Order for {supplier} — {MMM d, yyyy} | R1 |
| DQ-004 | What the UI “From” field becomes | Resolved | Sent as replyToEmail (Reply-To) | R1 |
| DQ-005 | How the email body is produced | Resolved | App-generated inline-styled HTML + plain text | R1 |
| DQ-006 | Injection / validation handling | Resolved | Escape on compose + validate addresses; block otherwise | R1 |
| DQ-007 | Tenant + idempotency headers | Resolved | BFF injects X-Tenant-Id; SPA generates stable Idempotency-Key | R1 |
| DQ-008 | Non-Operational config states | Resolved | Treated as restricted (copy-only) | R1 |
| DQ-009 | attachments / bcc | Resolved | Empty ([]) for this feature | R1 |
| DQ-010 | Body HTML/script injection protection | Resolved | (Revised R7) Sanitize the BFF-composed body with sanitize-html (defense-in-depth, sanitize-and-send — no reject); backend hardening tracked by PDEV-976 | R2→R7 |
| DQ-011 | Send format (HTML vs plain text) | Resolved | Send split button: default HTML (+ text alternate); menu HTML / plain text. Copy writes both (unchanged) | R3 |
Round 1: Initial Integration Design
Section titled “Round 1: Initial Integration Design”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.
| Option | Description | Trade-offs |
|---|---|---|
| A | Existence of an Operational EmailConfiguration whose identity.sendingDomainSlug contains a procurement marker (a constant) | Uses the as-built contract; marker keeps it easily retargetable |
| B | A dedicated capability flag/endpoint | Cleaner 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).
DQ-002: When the toggle is resolved
Section titled “DQ-002: When the toggle is resolved”Context: The capability changes very rarely. Re-checking on every panel open would be wasteful.
| Option | Description | Trade-offs |
|---|---|---|
| A | Resolve at sign-in / tenant switch, cache in the SPA per tenant | Cheap; matches the slow-moving nature; small staleness window |
| B | Resolve on every Email Order open | Always 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).
DQ-003: Source of the required subject
Section titled “DQ-003: Source of the required subject”Context: EmailJobInput.subject is required, but this send path has no
server-side subject composer.
| Option | Description | Trade-offs |
|---|---|---|
| A | Editable Subject field, prefilled with a sensible default | User control; satisfies the required field; one extra row |
| B | Auto-composed, not shown | Less UI; no user control |
| C | Fixed constant | Simplest; 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”.
| Option | Description | Trade-offs |
|---|---|---|
| A | Map the UI From to the message Reply-To (replyToEmail) | Gives the field real meaning; replies route to the chosen address |
| B | Display-only sender | Honest, but the field becomes inert |
| C | Remove the From row | Simplest; 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).
DQ-005: How the email body is produced
Section titled “DQ-005: How the email body is produced”Context: “Collect the resulting HTML static form” — but the rendered DOM uses CSS-module classes, which mail clients strip.
| Option | Description | Trade-offs |
|---|---|---|
| A | App-generated email-safe HTML with inlined styles (+ plain textBody) from the live composer state | Renders reliably across mail clients; deterministic |
| B | Serialize the live class-based DOM as-is | Literal, 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).
DQ-006: Injection / validation handling
Section titled “DQ-006: Injection / validation handling”Context: User-editable fields feed an HTML email; the front-end must not emit unsafe markup and must ensure a well-formed envelope.
| Option | Description | Trade-offs |
|---|---|---|
| A | Escape user content on compose + validate (≥1 To, RFC addresses); block the send with an error otherwise | Injection structurally impossible; permissive about stray < in prose |
| B | Escape + hard-reject any field containing markup | Stricter; annoys on legitimate </> usage |
| C | Escape + non-blocking warning | Softer; 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).
DQ-007: Tenant and idempotency headers
Section titled “DQ-007: Tenant and idempotency headers”Context: POST /job requires X-Tenant-Id and Idempotency-Key.
| Option | Description | Trade-offs |
|---|---|---|
| A | BFF injects X-Tenant-Id from the session; SPA generates the Idempotency-Key, stable across retries of one Send | Tenant trust stays server-side; retries don’t double-send |
| B | SPA supplies both | Tenant 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.
| Option | Description | Trade-offs |
|---|---|---|
| A | Only Operational enables direct send; all other states ⇒ restricted (copy-only) | Safe; never offers Send when delivery can’t succeed |
| B | Enable for any matching config regardless of state | Risks 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).
DQ-009: attachments / bcc
Section titled “DQ-009: attachments / bcc”Context: Both are present in the contract (attachments required as an
array; bcc required in recipients) but unused by this feature.
| Option | Description | Trade-offs |
|---|---|---|
| A | Send 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).
Round 2: Security review
Section titled “Round 2: Security review”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).
| Option | Description | Trade-offs |
|---|---|---|
| A | Allow-list sanitize htmlBody at the shared BFF route and reject (400) if anything was stripped; SPA escape-on-compose (DQ-006) stays the first layer | Strongest; protects every client of the route; clear signal on injection attempts |
| B | Sanitize only (silently clean) | Never blocks, but silently alters the message and hides attempts |
| C | Reject only, no sanitize | No 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).
Round 3: Format parity
Section titled “Round 3: Format parity”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.
| Option | Description | Trade-offs |
|---|---|---|
| A | Send 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 |
| B | Two separate buttons (Send HTML, Send plain text) | More footer clutter; no clear default |
| C | A format toggle/radio elsewhere in the panel | Detaches 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).
DQ-018: Per-line Notes column (QF#2)
Section titled “DQ-018: Per-line Notes column (QF#2)”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).
Copyright: © Arda Systems 2025-2026, All rights reserved