"EMAIL Missive-style team UX — shared inbox, thread assignment, internal comments, Genie actions panel, keyboard shortcuts"
§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.
- MUST define
thread_stateper DEC-1610 + DEC-1613 —open | assigned | snoozed | closed | archived. Validated against closed enum (cardinality 5).
- 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``
- MUST support internal comments at
threads/internal_comments.rs:
POST /v1/email/threads/{id}/commentsbody{ body, mentions[] }- Renders in thread view inline (visual distinction)
- NEVER included in email reply quote per DEC-1612
- 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; ```
- MUST wake snoozed threads at
snoozed_untilvia TASK-MCP-007 cron — flipstatetoopen, notify assignee via TASK-CHAT-005.
- MUST render frontend at
services/portal-web/src/email/:
InboxView.tsx: channel selector (left), thread list (middle)ThreadView.tsx: thread rendering + reply composer (right or full)AssignmentPicker.tsx: avatar grid + searchSnoozePicker.tsx: chips (1h, 4h, tomorrow, next week, custom)InternalCommentEditor.tsx: separate composer, visual distinctionGenieActionsPanel.tsx: streams from TASK-EMAIL-008 + quick-actions
- MUST wire keyboard shortcuts per DEC-1615 at
keyboard_shortcuts.ts:
j/knavigaterreply,Rreply-allfforwardaassign focuszsnooze focusearchive/closegGenie panel focusEscapeclears focus- All disabled when text input focused
- MUST emit 5 memory audit kinds per DEC-1616. PII per TASK-MEMORY-111: comment body SHA-256 hashed; mentions (uuids) ok.
- MUST thread trace_id from UI action → backend mutation → audit.
- MUST NOT include internal comments in email reply quote per DEC-1612 —
Replycomposer pulls thread.messages only, not thread.comments.
- MUST NOT send email notification on assignment per DEC-1611 — in-app + TASK-CHAT-005 mention only.
- 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
- 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_atvia 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
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| Concurrent assignment | optimistic lock on version | second 409 | UI refresh + retry |
| Snooze cron missed run | last_run check | wake on next boot | inherent |
| Mention user doesn't exist | validate | filter + sev-3 audit | data fix |
| Internal comment >10k chars | validate | 400 | use TASK-DOC-001 for long |
| Reply quote source includes comment (bug) | test guard | hard CI block | tests catch |
| TASK-CHAT-005 unreachable | mention notif retry | inherent | inherent |
| Genie panel stream disconnects | reconnect | retry | inherent |
| Snoozed past 1 year | warn at create | UI alert | manual confirm |
| Channel ACL mismatch | RLS | 403 | request access |
| Cross-tenant URL guess | RLS | 404 | inherent |
§11 — Implementation notes
- §11.1 Thread state machine: open ↔ assigned, any → snoozed → wake → open/assigned, open/assigned → closed → reopen ↔ archived.
- §11.2 Internal comment markdown rendering via
marked+ DOMPurify sanitize. - §11.3 Keyboard shortcuts: use
mousetrapor custom dispatcher; respect<input>/<textarea>focus. - §11.4 Genie panel uses SSE/WebSocket for live TASK-EMAIL-008 stream.
- §11.5 memory audit body: thread_id, state, assignee uuid; comment body SHA256.
- §11.6 Snooze cron via TASK-MCP-007
kind: 'email.snooze_wake', runs every 5min.
End of TASK-EMAIL-003 spec.