Skip to content

Specification: Direct Email Send (Email Orders)

This is the driving document for implementing direct email sending in arda-frontend-app (PDEV-969). It turns the order-queue EmailPanel from a copy-only composer into a real sender, behind a per-tenant capability toggle.

Read alongside:

Project type: Modification (single repo, arda-frontend-app). The complex design/decision phases are complete; this is the project-planning output.

In scope: the capability toggle; the redesigned composer (recipients, subject, editable body, note); HTML/plain-text split-button Send; dual-format Copy; client validation; shared-BFF body sanitization; the config-status and send BFF routes + a reusable backend proxy; MSW handlers; unit + E2E tests.

Out of scope: recipient pre-population (separate ticket — “Integrate Email Orders with Supplier Data”); send authorization/permission gating (handled separately); backend body hardening (PDEV-976); PO-Line emails (PDEV-970); config provisioning (PDEV-971); attachments/BCC.

Per ui-implementation-analysis.md §3.0/§4: the reuse-before-build procedure; ux-prototype component rules; no raw/inline styles (semantic tokens / cn(); new styles escalate to Sebrand Warren @nail60); SPA / BFF / shared tier separation (eslint-enforced); co-location of feature-specific code; canary-first (Button/IconButton/SplitButton confirmed present); ≤ ~500-line files; strict TypeScript; coverage 84/84/81/72; no content/PII logging in BFF routes.

Phases map to reviewable PRs (see PR and Delivery Strategy). All work lands on the feature branch and is reviewed via PR before merge — no per-file approval gate is needed to edit existing files. Each phase is a coherent, independently mergeable unit.

Phase 1 — Foundation (contracts + mocks)

Section titled “Phase 1 — Foundation (contracts + mocks)”
  • lib/email/email-constants.ts <<shared>>PROCUREMENT_EMAIL_SLUG_TOKEN, subject/greeting/sign-off default builders.
  • types/email.ts <<shared>>EmailJobInput, EmailRecipients, EmailConfigStatus, configuration-query result.
  • MSW handlers for config-status and send (extend mocks/handlers/email.ts).

Acceptance: typecheck + lint clean; MSW returns the new shapes in mock mode.

Self-contained, independently tested; consumers (BFF, UI) come later.

  • lib/email/validate-email-order.ts <<shared>> — ≥1 To, RFC addresses, non-empty subject, no CR/LF / control chars.
  • lib/email/sanitize-email-html.ts <<BFF>> — thin sanitize-html wrapper (server-only): allow-list tags/attributes; http/https/mailto links only; drops scripts/styles/iframes/images/event handlers. Returns the sanitized HTML (sanitize-and-send; no reject — DQ-010 revised R7).
  • lib/email/compose-email-html.ts <<shared>> — inline-styled htmlBody + plain textBody from structured content; escapes user content. Shared tier (DQ-020): imported by the BFF (authoritative compose on send) and the SPA (live preview). Includes the per-line Notes rendering (REQ-ES-062).

Acceptance: REQ-ES-040/041/042 unit tests green.

  • One explicit proxy class per backend email endpoint (<<BFF>>), each in its own file under lib/arda/email/, owning that endpoint’s URL, typed request/response, and the Authorization/X-Tenant-Id/X-Author/ X-oidc-subject/Idempotency-Key headers + cache:'no-store':
    • EmailConfigurationProxyPOST /v1/shop-access/email/configuration/query.
    • EmailJobProxyPOST /v1/shop-access/email/job.
  • app/api/arda/email/config-status/route.ts <<BFF>> — via EmailConfigurationProxy; applies the Operational + slug rule. Called on composer open; no caching in the initial implementation (DQ-002, revised) — so no emailConfigSlice/thunks/selectors and no sign-in/tenant-switch dispatch.
  • app/api/arda/email/send/route.ts <<BFF>> — accepts the structured payload, composes the body via compose-email-html (DQ-020) → sanitize-email-html (sanitize-and-send backstop) → EmailJobProxy.

Acceptance: BFF unit tests (REQ-ES-002/022/023/041/042/043) — incl. the route composing from structured input and config-status resolving on open.

  • Create components/email-order-panel/ (orchestrator + email-body-preview + co-located use-email-composer). Run the §3.0 reuse search for recipient-chips / editable-text (general-use candidates → src/components/, added to the promotion ticket; else feature-local), designing the shell as a reusable seam (recipient fields / toggle / footer reusable; Body content is the feature slot — REQ-ES-060). Apply semantic tokens + canary Button/IconButton/SplitButton.
  • Editable fields use the grid inline-edit pattern (click-to-edit, no pencil badge — REQ-ES-061); the items table includes the editable per-line Notes column alongside the retained global Note (REQ-ES-062).

Acceptance: the new panel renders full vs restricted; REQ-ES-010/011/012/014/015/060/061/062.

  • use-email-send (format → body; accept-after-send); wire the toggle; mount the new panel from order-queue; retire the /api/email/send-order stub.
  • Persist the last-used send format as the session default (REQ-ES-063): the composer opens on the last preview after the first send (Redux/sessionStorage).

Acceptance: REQ-ES-020/021/024/050/063; restricted mode = today’s copy-only.

  • e2e/pages/email-order-panel.page.ts + the three scenarios in verification.md.

Acceptance: E2E green in mock mode with Istanbul coverage.

  • Remove Spanish comments and the dead deliveryAddress wiring; confirm file sizes ≤ target; compile the ux-prototype promotion ticket (assigned sebrand@arda.cards) listing promotion candidates; run make ci-replicate.

Acceptance: make ci-replicate green; coverage thresholds met.

Recommendation: a stack of 5 small, ordered PRs — each independently mergeable to main without leaving an unstable state, and collapsible into one delivery PR.

Why incremental merges to main are safe. The feature is dark-launched behind the capability toggle (REQ-ES-001/002): the direct-send form appears only for a tenant with an Operational procurement EmailConfiguration, and provisioning those configs is a separate effort (PDEV-971). Until then every tenant sees the restricted = copy-only form, which is byte-for-byte today’s behavior. So PR 1–4 are purely additive (new, not-yet-consumed files) and PR 5 (the cut-over) preserves current behavior for every tenant. Nothing destabilizes main.

PRPhasesContentsSafe because
PR11constants, types, MSW handlersadditive; no runtime path
PR22validate / sanitize / compose utilities + testspure functions, unconsumed
PR33per-endpoint proxies, config-status + send routes, toggle slice/thunknew routes unconsumed by UI; toggle only caches a flag
PR44email-order-panel/ + subcomponents + composer + testsnew components, not yet mounted
PR55–7use-email-send, cut-over (mount panel, retire stub), E2E, cleanuprestricted/copy-only == today; toggle off in prod (PDEV-971 pending)

Ordering: utilities (PR2) precede their consumers (PR3 BFF, PR4 UI); the cut-over (PR5) is last. Each PR targets the previous branch in the stack (or all target a shared integration branch).

Flexibility (as required):

  • Merge in order: land PR1 → main, retarget PR2 → main, etc. (gh-stack rebase). Each is green and non-disruptive on its own.
  • Single delivery PR: point the stack at the integration branch (jmpicnic/email-order-sending-fe) and open one PR from it to main.

Coupling note: the cut-over (PR5) should be functionally complete before any tenant is provisioned via PDEV-971, so the toggle never turns on against a half-built UI. Provisioning is gated and separate, so this is easily sequenced.

If fewer, larger reviews are preferred, PR1–PR3 (all additive backend/utilities) can be combined into one, giving a 3-PR shape (backend+utils / UI / cut-over) with the same safety properties.

Implementation within a PR follows an incremental-commit cadence — a bounded look-ahead pipeline that keeps the agent close to the reviewer. Increments are not pre-listed: each is the smallest self-contained unit that stays green (compiles, lints, touched tests pass) and is therefore always safe to commit and push.

Per-increment gate: tsc --noEmit + eslint + Jest for the touched files (fast gate). The full make ci-replicate runs on the PR / CI, not per increment.

Cadence:

  1. Implement increment 1 → fast gate → commit, push, open the PR; run the touched-area tests in the background while CI runs.
  2. In parallel, while the reviewer reviews the PR:
    • implement the next increment and commit locally (do not push);
    • if no go-signal yet, implement the following increment and stop before committing (held in the working tree);
    • buffer ceiling = 2 (one committed-unpushed + one uncommitted-held); when full with no signal, stop and report state.
  3. The reviewer leaves comments (in the PR and/or directly) and sends a direct go-signal — the cue for the agent to read the PR comments and proceed.
  4. On the go-signal: address review feedback first (fix-forward — pushed history is never rewritten; the unpushed buffer is rebased/amended to fold feedback in), then push the oldest buffered commit (FIFO, one per signal), promote the held increment to a local commit, and begin the next increment uncommitted.
  5. Repeat 2–4 until the PR is complete.

Push discipline: the agent pushes only (a) the PR-creating increment and (b) one increment per go-signal — never otherwise.

State reporting: every turn, report the pipeline state — pushed HEAD · N local commits ahead · uncommitted-held (yes/no).

Why it’s safe: each increment is independently green and the PR stays mergeable throughout (the feature is dark behind the capability toggle), so a pushed mid-PR increment never destabilizes main. The workflow assumes a single implementing agent and a single intra-PR reviewer coordinating closely; it is per-PR and restarts when the next PR in the stack opens.

#QuestionOptionsRecommendationDecision
D1Recipient pre-populationempty / prefill from supplierempty for V1Resolved — To/Cc empty with placeholder cues; prefill = separate ticket
D2Authorization to sendexisting access / new permissionDeferred — handled separately; not in this plan
D3Reply-To policyfree-form / constrainedfree-form for V1Resolved — free-form (editable From → Reply-To)
D4Rate / volume limitsFE / backendbackendResolved — backend, PDEV-976
OQ-2BFF route namespace/api/email/* / /api/arda/email/*proxy conventionResolved — /api/arda/email/*; retire old stub
OQ-8Toggle cache durabilitysession / redux-persist / nonenoneResolved — no cache in the initial implementation; resolved on composer open (DQ-002 revised)
OQ-10File namingkebab / Pascalkebab per ux-prototype rulesResolved — kebab-case for new email-order-panel/ files
OQ-11Validation placementSPA-only / sharedsharedResolved — shared; BFF re-validates

No open blockers remain. Full rationale: decision-log.md.

Single agent, single repo, sequential writes — no parallel worktrees needed. Implementation happens in the existing arda-frontend-app worktree (projects/email-integration-worktrees/phase-6/arda-frontend-app) on branch jmpicnic/email-order-sending-fe; documentation changes in the paired documentation worktree. One PR per repo.

  • Toggle drives full vs restricted form, resolved on composer open with no cache (REQ-ES-001–003).
  • To/Cc empty with placeholders; From→Reply-To; editable Subject default (REQ-ES-011/012/013).
  • Editable body + note; Revert all (dirty-gated); ⌘Z undo (REQ-ES-010/014/015).
  • Send split button: default HTML, menu HTML/plain text; ⌘↵ default (REQ-ES-020/021).
  • Send builds EmailJobInput with X-Tenant-Id + stable Idempotency-Key; accept-after-send on success only (REQ-ES-022/023/024).
  • Copy writes both text/html and text/plain (REQ-ES-030).
  • Validation (≥1 To, RFC, non-empty subject, no CR/LF) blocks send (REQ-ES-040).
  • BFF sanitizes the composed body with sanitize-html (sanitize-and-send); no content logging (REQ-ES-041/042/043).
  • Outcomes via toast; backend errors surfaced (REQ-ES-050).
  • No raw styles; canary components; tier separation; ≤500-line files; coverage 84/84/81/72.
  • Unit + E2E per verification.md; make ci-replicate green.