Task — engineering-spec@1

"EMAIL tracked-domain → CRM auto-link — inbound message from tenant-tracked domain auto-creates/links CRM contact + thread association"

draftTASK-EMAIL-006
module email · class product · priority p1 · created 2026-05-17 · shipped null
depends on TASK-EMAIL-001, TASK-CRM-001 · blocks TASK-CRM-002

§1 — Description (BCP-14 normative)

The EMAIL service MUST ship CRM auto-link at services/email/src/crm_link/ triggered on inbound, matched against tenant's tracked_domains, contact created/matched, thread linked, 3 memory audit kinds.

  1. MUST hook into services/email/src/inbound_processor.rs after message stored: call crm_link::process(message).
  1. MUST check sender domain against tracked_domains per DEC-1572. Match → continue; non-match → skip (no link).
  1. MUST check contact existence by sender email: if exists → link thread to existing contact_id; else create per DEC-1574.
  1. MUST auto-create contact via auto_contact_creator.rs::create(tenant_id, from_header):
  1. MUST validate link_origin against closed enum per DEC-1573.
  1. MUST define tracked_domains table at migration 0008: ``sql CREATE TABLE tracked_domains ( tenant_id UUID NOT NULL, domain TEXT NOT NULL, added_by UUID NOT NULL, added_at TIMESTAMPTZ NOT NULL DEFAULT now(), notes TEXT, PRIMARY KEY (tenant_id, domain) ); CREATE INDEX tracked_domains_tenant_idx ON tracked_domains(tenant_id); ALTER TABLE tracked_domains ENABLE ROW LEVEL SECURITY; CREATE POLICY tracked_domains_rls ON tracked_domains USING (tenant_id = current_setting('auth.tenant_id')::uuid) WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid); REVOKE UPDATE, DELETE ON tracked_domains FROM cyberos_app; GRANT DELETE ON tracked_domains TO cyberos_app; -- CRO can untrack ``
  1. MUST expose admin endpoints for tracked-domain management: ``text POST /v1/email/tracked-domains (CRO-only) DELETE /v1/email/tracked-domains/{d} (CRO-only) GET /v1/email/tracked-domains (list) ``
  1. MUST add link_origin column to TASK-EMAIL-001 messages table: ``sql ALTER TABLE messages ADD COLUMN crm_contact_id UUID; ALTER TABLE messages ADD COLUMN link_origin TEXT CHECK (link_origin IS NULL OR link_origin IN ('auto_tracked_domain','manual','send_intent','crm_jit')); CREATE INDEX messages_crm_contact_idx ON messages(tenant_id, crm_contact_id) WHERE crm_contact_id IS NOT NULL; GRANT UPDATE (crm_contact_id, link_origin) ON messages TO cyberos_app; ``
  1. MUST emit 3 memory audit kinds per DEC-1575. PII per TASK-MEMORY-111: contact email/name SHA-256 hashed; domain (already public) ok.
  1. MUST thread trace_id from inbound processor → matcher → CRM upsert → audit.
  1. MUST NOT link untracked domains per DEC-1572.
  1. MUST NOT link outbound (handled by TASK-EMAIL-009 at send-intent) per DEC-1570.

§2 — Why this design

Why tracked-domain allowlist (DEC-1572)? Random inbound (newsletters, vendors, spam) shouldn't pollute CRM. Allowlist gives CRO control.

Why auto-create vs match-only (DEC-1574)? Tracked domains imply business interest; manual entry blocks workflow.

Why link_origin enum (DEC-1573)? Distinguishes auto-discovery from manual curation; CRO can audit auto-links separately.


§3 — API contract (see §1.7)

Sample tracked-domain add:

POST /v1/email/tracked-domains
{ "domain": "acme.com", "notes": "Strategic account" }

Sample message link result (after inbound process):

{
  "message_id": "uuid",
  "thread_id": "uuid",
  "crm_contact_id": "uuid",
  "link_origin": "auto_tracked_domain",
  "contact_created": true
}

§4 — Acceptance criteria

  1. Inbound from tracked domain → linked. 2. Inbound from untracked domain → skipped. 3. Existing contact reused (no duplicate). 4. New contact auto-created if missing. 5. Display name parsed from From: header. 6. Company inferred via TASK-AI-003 lookup. 7. AI company lookup cached 24h. 8. link_origin enum 4 values + cardinality test. 9. 3 memory audit kinds emitted. 10. PII scrubbed (email/name SHA256). 11. RLS denies cross-tenant. 12. CRO-only tracked-domain mgmt. 13. Trace_id preserved. 14. Outbound NOT touched. 15. Domain match case-insensitive. 16. Subdomain match optional (configurable per domain). 17. REVOKE UPDATE on tracked_domains (immutable add, can DELETE). 18. Multiple messages same contact → reuse single contact_id. 19. From: header malformed → skip with sev-3 audit. 20. CRM contact created → CRM-side TASK-CRM-001 audit also fires.

§5 — Verification

#[tokio::test]
async fn tracked_domain_creates_and_links() {
    let ctx = TestContext::with_tracked_domain("acme.com").await;
    let msg = ctx.receive_inbound("john@acme.com").await;
    let linked: Message = ctx.fetch_message(msg.id).await;
    assert_eq!(linked.link_origin.as_deref(), Some("auto_tracked_domain"));
    assert!(linked.crm_contact_id.is_some());
    let contact = ctx.fetch_contact(linked.crm_contact_id.unwrap()).await;
    assert_eq!(contact.email, "john@acme.com");
}

#[tokio::test]
async fn untracked_domain_skipped() {
    let ctx = TestContext::new_tenant_no_tracked().await;
    let msg = ctx.receive_inbound("random@spam.com").await;
    let m: Message = ctx.fetch_message(msg.id).await;
    assert!(m.crm_contact_id.is_none());
    assert!(m.link_origin.is_none());
}

#[tokio::test]
async fn existing_contact_reused() {
    let ctx = TestContext::with_tracked_domain("acme.com").await;
    let c1 = ctx.create_contact("jane@acme.com").await;
    let msg = ctx.receive_inbound("jane@acme.com").await;
    let m: Message = ctx.fetch_message(msg.id).await;
    assert_eq!(m.crm_contact_id, Some(c1));
}

// 5.4..5.10

§7 — Dependencies

Upstream: TASK-EMAIL-001, TASK-CRM-001. Cross-module: TASK-AI-003 (company lookup), TASK-AUTH-101 (CRO role), TASK-MEMORY-111 (PII).

§10 — Failure modes

FailureDetectionOutcomeRecovery
TASK-AI-003 lookup timeoutretry onceuse domain text as companymanual fix
From: header malformedregex failskip + sev-3 auditdata fix
Tracked domain DB unreachablesql errorskip link + sev-2 auditretry on next inbound
Duplicate contact raceUNIQUE on (tenant,email)second insert ON CONFLICTinherent
Domain match case-mismatchlowercase in matchermatchinherent
Subdomain (e.g. sub.acme.com vs acme.com)configurableper-tenant flagCRO config
Catch-all routing (multiple recipients)per-messagelinked once per threadinherent
Internal sender (own domain)skipnot linkedby design
Auto-create disabled (CRO toggle)config checkmatch-only, skip createconfigurable
TASK-AI-003 quotadowngradeuse domain onlyinherent

§11 — Implementation notes


End of TASK-EMAIL-006 spec.