Task — engineering-spec@1

"EMAIL Missive-style team UX — shared inbox, thread assignment, internal comments, Genie actions panel, keyboard shortcuts"

draftTASK-EMAIL-003
module email · class product · priority p0 · created 2026-05-17 · shipped null
depends on TASK-EMAIL-001, TASK-EMAIL-009 · blocks none

§1 — Description (BCP-14 normative)

The EMAIL service + portal-web frontend MUST ship Missive-style UX — shared inbox, thread state + assignment, internal comments, Genie panel, keyboard shortcuts, 5 memory audit kinds.

  1. MUST define thread_state per DEC-1610 + DEC-1613 — open | assigned | snoozed | closed | archived. Validated against closed enum (cardinality 5).
  1. MUST expose thread-state APIs: ``text POST /v1/email/threads/{id}/assign (body: {user_id}) POST /v1/email/threads/{id}/snooze (body: {wake_at}) POST /v1/email/threads/{id}/close POST /v1/email/threads/{id}/reopen ``
  1. MUST support internal comments at threads/internal_comments.rs:
  1. MUST define table extension at migration 0012: ```sql ALTER TABLE threads ADD COLUMN state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open','assigned','snoozed','closed','archived')); ALTER TABLE threads ADD COLUMN assigned_to UUID; ALTER TABLE threads ADD COLUMN snoozed_until TIMESTAMPTZ; ALTER TABLE threads ADD COLUMN closed_at TIMESTAMPTZ; ALTER TABLE threads ADD COLUMN closed_by UUID; CREATE INDEX threads_state_assigned_idx ON threads(tenant_id, state, assigned_to) WHERE state IN ('open','assigned'); GRANT UPDATE (state, assigned_to, snoozed_until, closed_at, closed_by, updated_at) ON threads TO cyberos_app;

CREATE TABLE thread_comments ( comment_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, thread_id UUID NOT NULL, author_id UUID NOT NULL, body TEXT NOT NULL, mentions UUID[] NOT NULL DEFAULT '{}', created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); ALTER TABLE thread_comments ENABLE ROW LEVEL SECURITY; CREATE POLICY thread_comments_rls ON thread_comments USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON thread_comments FROM cyberos_app; ```

  1. MUST wake snoozed threads at snoozed_until via TASK-MCP-007 cron — flip state to open, notify assignee via TASK-CHAT-005.
  1. MUST render frontend at services/portal-web/src/email/:
  1. MUST wire keyboard shortcuts per DEC-1615 at keyboard_shortcuts.ts:
  1. MUST emit 5 memory audit kinds per DEC-1616. PII per TASK-MEMORY-111: comment body SHA-256 hashed; mentions (uuids) ok.
  1. MUST thread trace_id from UI action → backend mutation → audit.
  1. MUST NOT include internal comments in email reply quote per DEC-1612 — Reply composer pulls thread.messages only, not thread.comments.
  1. MUST NOT send email notification on assignment per DEC-1611 — in-app + TASK-CHAT-005 mention only.
  1. MUST NOT show closed/archived threads in default inbox view — separate filter chip.

§2 — Why this design

Why shared inbox (DEC-1610)? Single-user email apps (Apple Mail) can't support team handoff; Missive's channel model is industry-validated.

Why one assignee (DEC-1611)? Multiple assignees → diffusion of responsibility; Missive's single-assignee model proves better SLA.

Why no email notif on assignment (DEC-1611)? Customer doesn't need to see "Stephen reassigned to Lisa" emails; internal-only via TASK-CHAT-005.

Why never include internal comments in reply (DEC-1612)? Single most catastrophic bug class in collab email tools; hard contract.

Why keyboard shortcuts (DEC-1615)? Power users 3-5x faster than mouse; Missive's shortcut grammar is well-known.

Why Genie panel right-side (DEC-1614)? Active context without disrupting reading flow; consistent with TASK-PORTAL-005 chat layout.


§3 — API contract (see §1.2 + §1.3)

Sample thread state response:

{
  "thread_id": "uuid",
  "state": "assigned",
  "assigned_to": "uuid",
  "assigned_to_name": "Lisa Nguyen",
  "snoozed_until": null,
  "message_count": 5,
  "internal_comment_count": 2,
  "last_message_at": "2026-05-17T10:00:00Z"
}

Sample comment add:

POST /v1/email/threads/{id}/comments
{
  "body": "@Lisa can you handle this? Customer is asking about pricing",
  "mentions": ["uuid-lisa"]
}

§4 — Acceptance criteria

  1. 5 thread states + cardinality test. 2. One assignee at a time. 3. Assignment no customer-facing email. 4. Internal comments visible in thread view. 5. Internal comments NEVER in Reply quote. 6. Snooze wakes at wake_at via cron. 7. Snoozed thread invisible in default inbox. 8. 5 memory audit kinds emitted. 9. PII scrubbed (comment body SHA256). 10. RLS denies cross-tenant. 11. Mentions trigger TASK-CHAT-005 notification. 12. Trace_id preserved. 13. Keyboard shortcuts work (j/k/r/f/a/z/e/g). 14. Shortcuts disabled in text inputs. 15. Genie panel streams TASK-EMAIL-008 proposals. 16. Channel selector lists tenant inboxes. 17. Reply composer pulls thread.messages only (no comments). 18. Append-only thread_comments table. 19. Closed/archived hidden by default. 20. Wake from snooze sends TASK-CHAT-005 ping to assignee.

§5 — Verification

#[tokio::test]
async fn assign_no_customer_email() {
    let ctx = TestContext::with_thread().await;
    ctx.assign_thread(ctx.thread_id, ctx.user_b).await;
    let sent_emails = ctx.outbound_send_count().await;
    assert_eq!(sent_emails, 0);
}

#[tokio::test]
async fn internal_comment_not_in_reply() {
    let ctx = TestContext::with_thread_and_comments().await;
    let reply_quote = ctx.compose_reply_quote(ctx.thread_id).await;
    assert!(!reply_quote.contains("INTERNAL_FLAG_XYZ"));
}

#[tokio::test]
async fn snooze_wakes_at_target() {
    let ctx = TestContext::with_thread().await;
    let wake = Utc::now() + Duration::seconds(2);
    ctx.snooze_thread(ctx.thread_id, wake).await;
    tokio::time::sleep(Duration::from_secs(3)).await;
    ctx.run_snooze_cron().await;
    let t: Thread = ctx.fetch_thread(ctx.thread_id).await;
    assert_eq!(t.state, "open");
}

// 5.4..5.10
test('keyboard shortcut r opens reply composer', async ({page}) => {
  await page.goto('/email/inbox');
  await page.keyboard.press('j');  // focus first thread
  await page.keyboard.press('r');
  await expect(page.locator('[data-testid=reply-composer]')).toBeVisible();
});

§7 — Dependencies

Upstream: TASK-EMAIL-001, TASK-EMAIL-009. Cross-module: TASK-EMAIL-008 (Genie panel), TASK-EMAIL-006 (CRM contact display), TASK-EMAIL-007 (convert button), TASK-CHAT-005 (mention notif), TASK-CUO-101 (panel embedding), TASK-MCP-007 (snooze cron), TASK-MEMORY-111 (PII).

§8 — Sample payloads (see §3)

§9 — Open questions

None blocking — Missive is the gold-standard reference.

§10 — Failure modes

FailureDetectionOutcomeRecovery
Concurrent assignmentoptimistic lock on versionsecond 409UI refresh + retry
Snooze cron missed runlast_run checkwake on next bootinherent
Mention user doesn't existvalidatefilter + sev-3 auditdata fix
Internal comment >10k charsvalidate400use TASK-DOC-001 for long
Reply quote source includes comment (bug)test guardhard CI blocktests catch
TASK-CHAT-005 unreachablemention notif retryinherentinherent
Genie panel stream disconnectsreconnectretryinherent
Snoozed past 1 yearwarn at createUI alertmanual confirm
Channel ACL mismatchRLS403request access
Cross-tenant URL guessRLS404inherent

§11 — Implementation notes


End of TASK-EMAIL-003 spec.