Task — engineering-spec@1

"EMAIL convert-to-issue — one-click create task-PROJ issue from message with thread backlink + attachment carry-over + AI summary"

draftTASK-EMAIL-007
module email · class product · priority p1 · created 2026-05-17 · shipped null
depends on TASK-EMAIL-001, TASK-PROJ-001 · blocks none

§1 — Description (BCP-14 normative)

The EMAIL service MUST ship convert-to-issue at services/email/src/convert/ creating task-PROJ issue from message/thread, attachment refs, AI summary, bi-directional backlink, 3 memory audit kinds.

  1. MUST expose POST /v1/email/messages/{id}/convert-to-issue body { project_id, convert_source, title_override?, priority_override? }.
  1. MUST validate convert_source against closed enum per DEC-1582 — email_single_message (this msg), email_full_thread (whole thread), email_inline_quote (quoted-text section).
  1. MUST call AI summarizer per DEC-1581: ai_summarizer.rs::summarize(message, thread, source){title, description, priority}.
  1. MUST build issue via issue_builder.rs::build calling TASK-PROJ-001 create endpoint with:
  1. MUST carry attachments per DEC-1583 — query message attachments → create TASK-DOC-001 doc_links rows (same S3 key, new linked_to=issue_id). No file copy.
  1. MUST define link table at migration 0009: ``sql ALTER TABLE messages ADD COLUMN linked_issue_id UUID; ALTER TABLE messages ADD COLUMN converted_at TIMESTAMPTZ; ALTER TABLE messages ADD COLUMN converted_by UUID; CREATE INDEX messages_linked_issue_idx ON messages(tenant_id, linked_issue_id) WHERE linked_issue_id IS NOT NULL; GRANT UPDATE (linked_issue_id, converted_at, converted_by) ON messages TO cyberos_app; ``
  1. MUST set bi-directional backlink per DEC-1580:
  1. MUST allow project selection per DEC-1584; default = user.last_used_project_id (TASK-AUTH-101 user prefs).
  1. MUST emit 3 memory audit kinds per DEC-1585. PII per TASK-MEMORY-111: message body/subject SHA-256 hashed; ids ok.
  1. MUST thread trace_id from convert action → AI → issue creation → audit.
  1. MUST NOT copy attachment bytes per DEC-1583 — reference S3 key.
  1. MUST NOT convert without project_id per DEC-1584.

§2 — Why this design

Why bi-directional backlink (DEC-1580)? Engineers need to reply to original sender; PMs need to see issue origin.

Why AI summary (DEC-1581)? Raw email subject is rarely a good issue title; AI extracts action.

Why 3 source modes (DEC-1582)? Single (one message), full thread (context), inline quote (cherry-picked text); covers UX patterns.

Why attachment references (DEC-1583)? S3 duplication wastes cost + risks divergence; references are canonical.


§3 — API contract

POST   /v1/email/messages/{id}/convert-to-issue
GET    /v1/email/messages/{id}/converted-issues   (list — message may have been converted multiple times)

Sample request:

{
  "project_id": "uuid",
  "convert_source": "email_full_thread",
  "title_override": null,
  "priority_override": "high"
}

Sample response:

{
  "issue_id": "uuid",
  "issue_url": "/proj/issues/abc-123",
  "ai_summary": {
    "title": "Investigate API timeout for Acme webhook endpoint",
    "description": "Customer reports webhook calls timing out after 30s...",
    "priority": "high"
  },
  "attachments_linked": 3,
  "backlink_created": true
}

§4 — Acceptance criteria

  1. POST creates issue + backlinks message. 2. 3 source modes work distinct. 3. Closed enum + cardinality test. 4. AI summary returns title+desc+priority. 5. Override fields respected. 6. Attachments referenced (not copied). 7. doc_links rows created for each attachment. 8. project_id required (400 if missing). 9. Default project = user last_used. 10. 3 memory audit kinds emitted. 11. PII scrubbed (body/subject SHA256). 12. RLS denies cross-tenant. 13. Trace_id preserved. 14. Bi-directional backlink (msg.linked_issue_id + issue.source_thread_id). 15. Multiple conversions allowed (same msg → multiple issues). 16. GET endpoint lists all conversions. 17. AI failure → fallback to subject as title + sev-2 audit. 18. converted_by audit-traceable. 19. Append-only (REVOKE UPDATE except link cols). 20. Issue created in user's chosen project (RLS-respected).

§5 — Verification

#[tokio::test]
async fn convert_single_message_creates_issue() {
    let ctx = TestContext::with_message_and_project().await;
    let resp = ctx.convert(ctx.message_id, ctx.project_id, "email_single_message").await;
    assert!(resp.issue_id.is_some());
    let m: Message = ctx.fetch_message(ctx.message_id).await;
    assert_eq!(m.linked_issue_id, Some(resp.issue_id.unwrap()));
}

#[tokio::test]
async fn full_thread_includes_all_messages_in_description() {
    let ctx = TestContext::with_thread_of_5().await;
    let resp = ctx.convert(ctx.last_msg, ctx.project_id, "email_full_thread").await;
    let issue: Issue = ctx.fetch_issue(resp.issue_id.unwrap()).await;
    for sender in &["alice", "bob", "carol", "dave", "eve"] {
        assert!(issue.description.contains(sender));
    }
}

#[tokio::test]
async fn attachments_referenced_not_copied() {
    let ctx = TestContext::with_message_with_3_attachments().await;
    let resp = ctx.convert(ctx.message_id, ctx.project_id, "email_single_message").await;
    let links = ctx.fetch_doc_links(resp.issue_id.unwrap()).await;
    assert_eq!(links.len(), 3);
    let orig_keys = ctx.message_attachment_s3_keys(ctx.message_id).await;
    let new_keys: Vec<_> = links.iter().map(|l| &l.s3_key).collect();
    assert_eq!(orig_keys, new_keys);  // same keys, no copy
}

// 5.4..5.10

§7 — Dependencies

Upstream: TASK-EMAIL-001, TASK-PROJ-001. Cross-module: TASK-AI-003 (summary), TASK-DOC-001 (attachment refs), TASK-AUTH-101 (user prefs), TASK-MEMORY-111 (PII).

§8 — Sample payloads (see §3)

§9 — Open questions

None blocking.

§10 — Failure modes

FailureDetectionOutcomeRecovery
TASK-AI-003 timeoutretry 1xfallback subject + sev-2 auditinherent
project_id missing/invalidvalidate400 / 404provide valid
message already converted to issuecheck200 with existing or 409 (configurable)inherent
Attachment S3 missingper-attachmentlink still created (broken-link note)data fix
TASK-PROJ-001 create failsdownstream errorrollback message updateinherent
User no project accessRLS403request access
AI returns invalid priorityenum matchdefault to "med"inherent
Thread has >100 messagestruncate desclast 100 included + sev-3 noteinherent
Concurrent convert same msgUNIQUEsecond wins or both create depending on configinherent
Inline quote selection failsparser errfallback single-messageinherent

§11 — Implementation notes


End of TASK-EMAIL-007 spec.