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 | At sign-in / tenant switch; cached in the SPA per tenant | R1 |
| 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 | Allow-list sanitize + reject-if-stripped at the shared BFF; backend hardening tracked by PDEV-976 | R2 |
| 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).
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).
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.
Applied to: design.md (DQ-011, Decision Summary + API contract), email-order-ui.md (capabilities + live mock), ui-implementation-analysis.md (§3.2).
Copyright: © Arda Systems 2025-2026, All rights reserved