"EMAIL tracked-domain → CRM auto-link — inbound message from tenant-tracked domain auto-creates/links CRM contact + thread association"
§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.
- MUST hook into
services/email/src/inbound_processor.rsafter message stored: callcrm_link::process(message).
- MUST check sender domain against
tracked_domainsper DEC-1572. Match → continue; non-match → skip (no link).
- MUST check contact existence by sender email: if exists → link thread to existing contact_id; else create per DEC-1574.
- MUST auto-create contact via
auto_contact_creator.rs::create(tenant_id, from_header):
- name: from
From:display name ("Acme Corp <john@acme.com>"→"Acme Corp") - email: from
From:address - company: TASK-AI-003 lookup on domain (24h cached); fallback domain text
- link_origin:
auto_tracked_domainper DEC-1573
- MUST validate
link_originagainst closed enum per DEC-1573.
- MUST define
tracked_domainstable at migration0008: ``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``
- 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)``
- MUST add
link_origincolumn 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;``
- MUST emit 3 memory audit kinds per DEC-1575. PII per TASK-MEMORY-111: contact email/name SHA-256 hashed; domain (already public) ok.
- MUST thread trace_id from inbound processor → matcher → CRM upsert → audit.
- MUST NOT link untracked domains per DEC-1572.
- 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
- 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
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| TASK-AI-003 lookup timeout | retry once | use domain text as company | manual fix |
| From: header malformed | regex fail | skip + sev-3 audit | data fix |
| Tracked domain DB unreachable | sql error | skip link + sev-2 audit | retry on next inbound |
| Duplicate contact race | UNIQUE on (tenant,email) | second insert ON CONFLICT | inherent |
| Domain match case-mismatch | lowercase in matcher | match | inherent |
| Subdomain (e.g. sub.acme.com vs acme.com) | configurable | per-tenant flag | CRO config |
| Catch-all routing (multiple recipients) | per-message | linked once per thread | inherent |
| Internal sender (own domain) | skip | not linked | by design |
| Auto-create disabled (CRO toggle) | config check | match-only, skip create | configurable |
| TASK-AI-003 quota | downgrade | use domain only | inherent |
§11 — Implementation notes
- §11.1 Domain extraction:
email.split('@').last().lowercase(). - §11.2 Display-name parse via
mailparsecrate. - §11.3 TASK-AI-003 prompt: "What company owns the domain {domain}? Reply with name only, no commentary."
- §11.4 Cache key:
company_for_domain:{domain}, TTL 86400s. - §11.5 memory audit: domain, link_origin, contact_created flag; email/name SHA256.
End of TASK-EMAIL-006 spec.