Skip to content

UI Implementation Analysis: Direct Email Send (arda-frontend-app)

This document analyzes the current arda-frontend-app structure around the Email Order composer and proposes how to update it to the redesigned direct-send experience (prototyped in email-order-ui.md, specified in design.md), holding the work to the repository’s own conventions and the Arda front-end / design skills: component design, styling/design-system, TypeScript, unit + e2e testing, file-size limits, and DRY.

It complements design.md (the cross-system design): where that maps the feature onto the backend contract, this maps it onto the real front-end codebase — concrete files, the gap from today’s code, the target module decomposition, and the test plan.

Findings below cite the phase-6 worktree (projects/email-integration-worktrees/phase-6/arda-frontend-app) at file:line. They reflect the code as read on 2026-06-26.


The composer is src/components/EmailPanel.tsx (458 lines). It is opened from three places in the Order Queue, always with orderMethod === 'Email' items:

  • OrderQueueGroupedView.tsx:475–478 — per-card “Email” action (single item).
  • orderQueueHandlers.ts:138–142 — bulk “Order All / Complete All” on an Email group.
  • OrderQueuePanels.tsx:403–405 — the “Missing information” modal path.

It is mounted in OrderQueuePanels.tsx:296–309 with:

<EmailPanel
isOpen={isEmailPanelOpen}
onClose={}
items={selectedItemsForEmail} // OrderItem[]
onSendEmail={handleSendEmail} // exists in handlers, NOT wired to a button
onCopyToClipboard={handleCopyToClipboard}
userContext={userContext || undefined} // from JWTProvider
/>

Data available at the call site (OrderItem, orderQueueSlice.ts:9–50): id (kanban card eId), name, quantity, orderMethod, status, supplier (a name, not an email), taxable?, sku?, unitPrice?, unitCurrency?, notes? / orderingNotes?, itemEntityId?.

What EmailPanel.tsx does today (read in full):

  • Builds an ASCII plain-text table (buildPlainTextWithTable, lines 48–103) and renders the same content as HTML with inline style={{…}} for the items table (lines 256–394).
  • Copy writes both text/html (from bodyTextRef.current.innerHTML, line 124) and text/plain to the clipboard; on success calls onCopyToClipboard.
  • Footer is Cancel + Copy to clipboard only — there is no Send button, even though onSendEmail is a prop and handleSendEmail exists.

The accept-after-action flow (the part we must preserve):

  • handleCopyToClipboard (orderQueueHandlers.ts:327–372): on copy, POSTs /api/arda/kanban/kanban-card/{id}/event/accept per item, then markItemStale
    • refreshKanbanData + closes the panel.
  • handleSendEmail (orderQueueHandlers.ts:193–323): POSTs the send-order route, and on success runs the same accept/close flow. It is implemented but unreachable because no Send control invokes it.
  • BFF stub: src/app/api/email/send-order/route.ts verifies the JWT (processJWTForArda), composes subject + HTML server-side ("{tenantCompanyName} Order -- {date}"), logs to console (TODO: integrate email service), and returns { ok, data: { subject, htmlContent, recipientEmail } }. Its test (route.test.ts, 106 ln) covers 401 / success / subject / delivery / greeting / 500 — but never a real send.
  • Canonical proxy pattern (src/app/api/arda/items/route.ts:1–61, business-affiliate/route.ts): processJWTForArda(request)extractUserContext → forward to ${env.BASE_URL}/… with Authorization: Bearer ${env.ARDA_API_KEY}, X-Request-ID (generateRequestId()), X-Author, X-Tenant-Id, X-oidc-subject, cache: 'no-store', then parseUpstreamResponse + forwardAsNextResponse; wrapped in withCors. Helpers live in src/lib/api-route-utils.ts. Guardrail: BFF routes must never log header objects (eslint no-restricted-syntax, PDEV-478, eslint.config.mjs:36–53).
  • Client layer (src/lib/ardaClient.ts): functions call /api/… via fetch with getBffAuthHeaders() (src/lib/auth-headers.ts:25–98, which returns both Authorization and X-ID-Token and proactively refreshes within 5 min of expiry), then normalize via handleApiResponse (401-with-”jwt” ⇒ handleAuthError). Never call the ARDA backend directly.
  • Caching precedent: itemsSlice + itemThunks cache per-tenant data in Redux Toolkit (+ redux-persist). A capability flag fits the same shape.
  • MSW: src/mocks/handlers/email.ts already stubs POST /api/email/send-order and a config-status GET; registered in src/mocks/handlers/index.ts.
DimensionRuleSource
TypeScriptstrict: true; no-explicit-any (error; only table/* exempt for AG Grid); no unused vars; @/ path aliastsconfig.json, eslint.config.mjs:6–9
StylingSemantic tokens (--base-primary #fc5a29, bg-primary, text-foreground, border-border); cn() (src/lib/utils.ts:4–6); no hardcoded hex, no raw inline style={{}}; a genuinely new style is escalated to Sebrand Warren (@nail60), never hardcodedglobals.css:61–193
Design systemImport from @arda-cards/design-system/canary (v6) — Button, IconButton, DropdownMenu, TypeaheadInput, ReadOnlyField; don’t hand-roll buttonsgrep; jest.config.js:62–63
Component designfollow the ux-prototype docs component rules (kebab-case files; lifecycle-phased prop interfaces Static/Init/Runtime; controlled/uncontrolled; useId + aria-*)ui-component skill; ux-prototype docs/
Code tiersevery file is exactly one of SPA (browser), BFF (server), or shared (both); shared code must be import-safe for both (no window, no server env); enforce with eslint import boundariesthis doc §3.0
File sizeTarget 300–500 ln production; co-locate feature-specific hooks/utils with their component; decompose earlyconventions; oversized examples (ItemFormPanel 2578)
Unit testsJest + RTL + user-event; renderWithAll() (src/test-utils/render-with-providers.tsx); mock-factories.ts; MSW; scenario mocks for the 8 branch categories; no render-only tests, no conditional guards, getBy* over queryBy*mocking-patterns skill; jest.config.js:34–40
Coveragelines 84 / statements 84 / functions 81 / branches 72jest.config.js:34–40
E2EPlaywright page objects (e2e/pages/*.page.ts); mock mode default; auth.setup.ts storageState; Istanbul coverage fixture (e2e/fixtures/base.ts); multi-browserplaywright.config.ts
StateuseAuth() from src/store/hooks/useAuth.ts for new code (not legacy AuthContext)FE CLAUDE.md
Pre-pushnpm run lint, tsc --noEmit, npm test, make ci-replicate; PR-body changelogFE CLAUDE.md

2. Gap Analysis — current EmailPanel.tsx vs the new design + best practices

Section titled “2. Gap Analysis — current EmailPanel.tsx vs the new design + best practices”
#GapTodayRequired
G1Monolithone 458-line file, all concerns inlinedecompose to an email-order-panel/ module + co-located hooks/utils (§3)
G2Hardcoded / raw styles#fc5a29, #e5e5e5, #e2e8f0, inline style={{}} tablesemantic tokens + cn() + canary Button/IconButton
G3No design-system usageraw <button> elementscanary components
G4No Send actionCopy-only footer; onSendEmail unusedSend (primary, ⌘↵) gated by the toggle; Copy secondary
G5No capability togglealways the same formfull vs restricted form from cached config-status (DQ-001/002)
G6Static, non-editable bodyread-only rendereditable recipients / Subject / greeting / qty / price / note / sign-off
G7Recipient gaponly supplier nameTo/Cc recipients; no contact email at call site (see OQ-1)
G8Subjectcomposed server-side in the stubeditable field, default Order for {supplier} — {MMM d, yyyy} (DQ-003)
G9Body HTML provenancebodyTextRef.innerHTML (class-based, brittle for email)deterministic inline-styled HTML + plain text (DQ-005)
G10No validationnone≥1 To, RFC addresses, non-empty subject, escape user content (DQ-006)
G11No idempotency / tenant header from sessionn/a (stub)BFF injects X-Tenant-Id + Idempotency-Key (DQ-007)
G12i18n / hygieneSpanish comments (lines 22, 255)en-US comments; remove dead deliveryAddress wiring or wire it (OQ-5)
G13No server-side body protectionbackend forwards htmlBody verbatim; Postmark doesn’t sanitizeshared BFF allow-list-sanitizes + rejects if stripped (DQ-010); backend hardening = PDEV-976

These rules govern every artifact this feature adds or refactors. They derive from the Arda front-end / design skills and the repository conventions in §1.3.

Grow the panel into a module. EmailPanel.tsx will gain complexity, so it becomes a directory src/components/email-order-panel/ holding the main component, its subcomponents, and co-located feature-specific helpers/hooks/types.

Reuse before building. For every new element — component, hook, type, effect, util — follow this decision flow:

PlantUML diagram

  1. Search ux-prototype/canary for a reusable element; if found, import it.
  2. Search arda-frontend-app; reuse, extending/modifying if close. If the result is worth generalizing, refactor it into a reusable and add it to the promotion ticket (below).
  3. If nothing fits, decide general-use vs feature-specific:
    • General-use → create as a standalone in src/components/<name>/, import it into the panel, and add it to the promotion ticket.
    • Feature-specific → create inside email-order-panel/ — a single file for simple elements, or a subdirectory (component + styles + tests) for complex ones.
  4. The same procedure applies to hooks, types, effects, and utilities, not just visual components.

Follow the ux-prototype component rules (docs/ in that repo, mirrored by the ui-component skill): naming, lifecycle-phased configurability, file structure, styling, and testing.

No raw styles. Never use inline style={{}} or hardcoded hex. Use semantic tokens or Tailwind classes via cn(). A genuinely new style is escalated to Sebrand Warren (@nail60) for the design system — not invented inline.

Three-tier separation. Every file is exactly one of:

  • <<SPA>> — runs in the browser (React components, hooks, the Redux slice, the ardaClient wrappers).
  • <<BFF>> — runs on the server (the api/.../route.ts handlers and the backend-access proxy that holds ARDA_API_KEY).
  • <<shared>> — imported by both (pure constants, types, validation). Shared code must be import-safe for both runtimes (no window/DOM, no server-only env). Recommend enforcing the boundaries with eslint import rules so an <<SPA>> module can never import a <<BFF>> one and vice versa.

Co-locate by cohesion. React imposes no restriction on where hooks, effects, utilities, state, or types live — they are ordinary modules. So feature-specific auxiliary code lives next to the component that needs it inside email-order-panel/; only genuinely shared or general-use code is hoisted to src/lib/, src/types/, or src/components/.

Promotion ticket. When implementation surfaces components (or other elements) worth promoting to ux-prototype, record them in a single Linear ticket that enumerates each candidate with its intent, design, and implementation notes, and assign it to sebrand@arda.cards. Do not promote piecemeal.

The feature decomposes into modules grouped by execution tier. Each unit stays well under the 500-line target and is independently testable.

PlantUML diagram

3.2 SPA — src/components/email-order-panel/ and friends

Section titled “3.2 SPA — src/components/email-order-panel/ and friends”
  • email-order-panel.tsx <<SPA>> — orchestrator: slide-over chrome, the full-vs-restricted layout switch (from the cached toggle), and the footer (Cancel / Revert all (only when dirty) / Copy to clipboard / Send) built from canary Button / IconButton / SplitButton. Send is a canary SplitButton (like the Items page “Add Item”): default action sends HTML, the caret menu offers “Send as HTML” / “Send as plain text” (DQ-011) — it forwards loading/disabled/tooltip per the arda-design SplitButton rule. Copy writes both text/html and text/plain (unchanged). Restricted mode hides the addresses + Send and makes Copy primary (mirrors the prototype’s directSend prop).
  • email-body-preview.tsx <<SPA>> (feature-specific) — the on-screen body card (greeting → items table → note → sign-off → “Powered by Arda”), styled with Tailwind tokens. The email HTML payload is generated separately (§3.3), not scraped from the DOM.
  • use-email-composer.ts <<SPA>> (co-located) — composer state (recipients to/cc/from, subject, greeting, intro, quantities, prices, note, sign-off), isDirty, revertAll, single-step address undo.
  • use-email-send.ts <<SPA>> (co-located) — orchestrates Send for the chosen format (HTML ⇒ htmlBody + textBody alternate; plain text ⇒ textBody only): validatecomposeardaClient.sendEmailOrder(...) → on success run the existing accept-after-send flow (event/accept per card, markItemStale, refreshKanbanData, close) → toasts. One Idempotency-Key per Send attempt, reused on retry.
  • compose-email-html.ts <<SPA>> (co-located) — builds the inline-styled htmlBody + plain textBody from composer state, HTML-escaping every user field (DQ-005/006). Replaces the brittle innerHTML capture.
  • recipient-chips/ and editable-text/ — Gmail-style chip field and the click/✎-to-edit text element. No canary equivalent today (per §3.0 step 1–2), so they are general-use candidates: created standalone in src/components/, imported by the panel, and added to the promotion ticket (OQ-4). If review judges them feature-specific instead, they move inside email-order-panel/.
  • store/emailConfigSlice + thunk <<SPA>>fetchEmailConfigStatus(tenantId) dispatched after sign-in (in authThunks) and on tenant switch; caches { directSendEnabled, configurationEId, senderAddress }; selectors feed the panel. Session-scoped unless persistence is desired (OQ-8).
  • email-constants.ts <<shared>>PROCUREMENT_EMAIL_SLUG_TOKEN = 'procurement' and the subject/greeting/sign-off default builders. Shared because the config-status route (BFF) and the composer (SPA) both consume the token.
  • validate-email-order.ts <<shared>>≥1 To, RFC-valid To/Cc/Reply-To, non-empty subject, and no CR/LF or control characters in addresses, subject, or Reply-To (header/field-injection defense); structured errors for a blocking toast. Shared so the BFF can re-validate as defense-in-depth (OQ-11).
  • types/email.ts <<shared>>EmailJobInput, EmailRecipients, configuration-query result, EmailConfigStatus; mapped to lean UI types via ardaMappers.ts. (Pure types — no runtime imports.)

3.4 BFF — src/app/api/arda/email/ + the backend proxy

Section titled “3.4 BFF — src/app/api/arda/email/ + the backend proxy”

Per §3.0 (L269 guideline), backend access is isolated in a reusable proxy so the route handlers stay thin and DRY:

  • lib/arda/email-proxy.ts <<BFF>> — typed functions queryEmailConfigurations(ctx) and submitEmailJob(ctx, input) that own the ${env.BASE_URL} URLs, the Authorization/X-Tenant-Id/X-Author/ X-oidc-subject/Idempotency-Key headers, cache: 'no-store', and response parsing. Holds ARDA_API_KEY; never imported by SPA code.
  • config-status/route.ts (GET) <<BFF>> — JWT-verify, call queryEmailConfigurations, select a config that is Operational with identity.sendingDomainSlug containing the token, return { directSendEnabled, configurationEId, senderAddress }. (The backend exposes no dedicated config-status endpoint — see OQ-3.)
  • send/route.ts (POST) <<BFF>> — JWT-verify, allow-list-sanitize htmlBody (via sanitize-email-html.ts) and reject 400 if anything was stripped (DQ-010), then build EmailJobInput (configurationEId, recipients {to, cc, bcc: []}, subject, htmlBody, textBody, replyToEmail, attachments: []), call submitEmailJob, map the outcome, and never log recipients or body (extends the no-header-logging guardrail, PDEV-478). Replaces the /api/email/send-order stub (OQ-2).
  • sanitize-email-html.ts <<BFF>> — server-side allow-list sanitizer (safe formatting tags only; no script/iframe/style/event handlers), e.g. via sanitize-html. Restrict <a href> to http/https/mailto (block javascript:/data:); disallow remote <img> (tracking pixels / SSRF). Shared by every client of the route (incl. the upcoming PO direct send); the backend equivalent (source-of-truth) is tracked by PDEV-976.
  • src/mocks/handlers/email.ts: extend the existing handlers to the two routes, returning the new shapes for mock-mode dev + e2e.

Hard rules for this work (reinforced by review):

  • DRY — one source of truth for the procurement token, the defaults, the compose/validate logic (Copy and Send both consume compose-email-html.ts), and backend access (both routes go through email-proxy.ts).
  • No raw styles — no inline style={{}}, no hardcoded hex; only semantic tokens / Tailwind via cn(); new styles escalate to Sebrand Warren (@nail60).
  • Canary first — use @arda-cards/design-system/canary components wherever one fits; build locally only after the §3.0 reuse search fails.
  • Placement — feature-specific elements live in email-order-panel/; general-use candidates live in src/components/ (and go on the promotion ticket).
  • Tier separation — SPA / BFF / shared kept distinct, enforced with eslint import boundaries; shared modules import-safe for both runtimes.
  • Co-location — auxiliary code (hooks, utils, types) sits next to the component that needs it when feature-specific.
  • TypeScriptstrict, no any, lifecycle-phased prop interfaces per the ui-component skill.
  • File size — every file ≤ ~500 ln; decompose early.
  • AccessibilityuseId for field ids, aria-invalid / aria-describedby on invalid fields, ≥44px touch targets, focus management on the slide-over.
  • Cross-Universe rule — recipients/config referenced by id/value only; no shared FKs or transactions across services.
  • Body injection — the shared BFF send route allow-list-sanitizes htmlBody and rejects (400) if anything is stripped (DQ-010); the SPA still escapes user content at compose time. Backend hardening is tracked by PDEV-976.
  • Other security controls — link-scheme allow-listing + no remote images in the sanitizer, CR/LF & control-char validation of addresses/subject/Reply-To, no recipient/body logging, and bearer-token (non-cookie) auth. See design.md §Security; the product/abuse decisions are in goal.md.

5.1 Unit (Jest + RTL + user-event, renderWithAll, MSW, mock-factories)

Section titled “5.1 Unit (Jest + RTL + user-event, renderWithAll, MSW, mock-factories)”

Per mocking-patterns: scenario-specific mocks across the 8 branch categories; no render-only tests; no conditional guards; getBy* over queryBy*; meet 84/84/81/72 coverage. Tests are co-located with their target.

TargetKey scenarios
validate-email-orderempty To (error), malformed To/Cc/Reply-To, empty subject, CR/LF or control chars in any field (error), valid envelope
compose-email-htmlescapes < > & " in every user field; note included only when non-empty; html + text parity
use-email-composeredit → isDirty; revertAll; address undo
emailConfig thunkOperational + slug ⇒ enabled; Draft/Provisioning/Failed ⇒ disabled; query failure ⇒ disabled
email-order-panelfull vs restricted layout; Send hidden when restricted; Copy primary when restricted; Subject default
use-email-sendhappy path (send, then accept per card, toast, close); backend error ⇒ error toast, no accept; retry reuses key
sanitize-email-htmlstrips <script>/<iframe>/<style>/onerror=/javascript:/data: hrefs and remote <img>; preserves table/p/a/strong/em/br/ul/li unchanged
email-proxy + send/routebuilds EmailJobInput; headers present (X-Tenant-Id, Idempotency-Key); rejects 400 when body sanitization strips content (DQ-010); 401 / upstream-error mapping; no header logging
config-status/routemaps query result to toggle; query failure ⇒ not-enabled

5.2 E2E (Playwright, mock mode, page object)

Section titled “5.2 E2E (Playwright, mock mode, page object)”

Add e2e/pages/email-order-panel.page.ts and a spec:

  • Happy path — open the panel on an Email group → (toggle enabled via MSW) → edit a field + Subject → Send → success toast → panel closes → card moves to accepted/requested.
  • Restricted — MSW returns directSendEnabled:false → no Send; Copy works and accepts.
  • Validation — clear To → Send → blocking error toast; no network call.

Reuse auth.setup.ts storageState and the Istanbul coverage fixture.


  1. Foundationemail-constants.ts + types/email.ts (shared); MSW handlers for both routes.
  2. BFF + toggleemail-proxy.ts; config-status + send routes; emailConfigSlice/thunk + dispatch on sign-in/tenant-switch + selectors.
  3. Pure utils (TDD)compose-email-html.ts + validate-email-order.ts with unit tests first.
  4. Decompose UIemail-order-panel/ (orchestrator + email-body-preview), run the §3.0 reuse search for recipient-chips / editable-text, apply semantic tokens + canary, add the co-located use-email-composer.
  5. Send pathuse-email-send; wire the toggle to full/restricted; retire the /api/email/send-order stub.
  6. E2E — page object + specs.
  7. Cleanup & handoff — remove Spanish comments and dead deliveryAddress wiring (or wire it); confirm file sizes; compile the ux-prototype promotion ticket (assigned sebrand@arda.cards) listing any promotion candidates; run make ci-replicate.

Each increment is independently shippable behind the toggle (restricted mode is the current copy-only behavior, so partial rollout is safe).


These are decisions made to keep moving; please confirm or revise.

  • OQ-1 — Recipient email source. No supplier contact email exists at the EmailPanel call site today (supplier is a name). handleSendEmail reads richer originalApiData[...].payload.itemDetails.primarySupply, so the supplier Business Affiliate contact email is plausibly reachable there or via the reference-data/business-affiliate module. Assumption: the To field starts empty / user-entered for v1; prefilling is a follow-up once that data is wired. Is empty-To-then-type acceptable for the first release?
  • OQ-2 — BFF route namespace. Assumption: add the new proxy routes under src/app/api/arda/email/{config-status,send} (house proxy convention) and retire the non-proxy src/app/api/email/send-order stub. OK to relocate, or keep the /api/email/* path?
  • OQ-3 — config-status backend call. The backend exposes no dedicated config-status endpoint; the route will POST /v1/shop-access/email/configuration/query and apply the Operational + slug rule (matches design.md). Confirm this is the intended detection mechanism (vs. a future dedicated endpoint).
  • OQ-4 — New shared components. recipient-chips and editable-text have no canary equivalent. Assumption: build them as general-use candidates in src/components/ now and list them on the promotion ticket. Or build them directly in ux-prototype first (the ui-component skill’s preference)?
  • OQ-5 — Note vs Deliver-to. Assumption: the editable Note (from the prototype) is in scope; the existing-but-unused deliveryAddress prop is left out of scope unless wired. Keep deliver-to out for v1?
  • OQ-6 — Subject default. Assumption: Order for {supplier} — {MMM d, yyyy} (en-US), editable. The current stub uses {tenantCompanyName} Order — {date}. Should the default include the tenant company name instead of / in addition to the supplier?
  • OQ-7 — From → Reply-To. Per the prototype decision, the editable From maps to replyToEmail; the actual From is the config sender, and the field label stays “From”. Confirm this carries into production.
  • OQ-8 — Toggle cache durability. Assumption: cache the toggle in Redux session-scoped (re-fetched on sign-in / tenant switch), not redux-persist. Persist across sessions like items?
  • OQ-9 — Accept-after-send semantics. Assumption: reuse the existing handleSendEmail accept flow unchanged (accept each card on success). Confirm send failures must leave cards un-accepted (no partial state).
  • OQ-10 — File naming convention. The ux-prototype rule is kebab-case, but arda-frontend-app’s existing components are PascalCase (EmailPanel.tsx). Assumption: use kebab-case for the new email-order-panel/ module and the promotion-candidate components (per the ux-prototype rules), accepting a local mismatch. Confirm, or match the local PascalCase for files that stay in arda-frontend-app?
  • OQ-11 — Where validation runs. Assumption: validate-email-order is shared so the BFF re-validates as defense-in-depth. Or keep it SPA-only (co-located) and rely on backend validation server-side?



Copyright: (c) Arda Systems 2025-2026, All rights reserved