From 537a2a6f0a0778b346c7a6a53457f1eae306b5c5 Mon Sep 17 00:00:00 2001 From: Marco0300 Date: Thu, 3 Sep 2026 12:27:15 +0200 Subject: [PATCH] add gated outreach preparation --- README.md | 14 +++ apps/api/README.md | 14 +++ apps/api/app/main.py | 169 +++++++++++++++++++++++++++++ apps/api/schema.sql | 33 ++++++ apps/api/tests/test_phase14_api.py | 82 ++++++++++++++ apps/web/README.md | 12 ++ apps/web/app.js | 25 ++++- apps/web/index.html | 2 + apps/web/smoke-test.html | 4 + apps/web/styles.css | 1 + docs/OPERATIONS.md | 12 ++ docs/SECURITY.md | 13 +++ 12 files changed, 377 insertions(+), 4 deletions(-) create mode 100644 apps/api/tests/test_phase14_api.py diff --git a/README.md b/README.md index a24ebe2..9cea2a8 100644 --- a/README.md +++ b/README.md @@ -203,4 +203,18 @@ AI output is an untrusted draft. It requires an authorized human approval/reject Phase 13 is pilot-only. Before production, implement provider allowlisting and secret management, data-processing agreements, prompt/output redaction tests, evidence hash/citation verification, approval and rollback semantics, immutable audit coverage, tenant-isolation tests, retention/deletion/legal-hold jobs, cost/rate monitoring, incident disablement, and evaluation for hallucination, prompt injection, bias, and stale/conflicting evidence. See the API, web, security, and operations contracts for the authoritative limitations. +## Phase 14 draft-only outreach boundary + +Phase 14 prepares outreach records without sending them. The platform may assemble a bounded, tenant-scoped draft from approved evidence, but it has no send endpoint, delivery worker, campaign scheduler, SMTP probe, validation message, or autonomous follow-up. `AUTOMATED_OUTREACH_ENABLED=false` remains the enforced Compose default and a draft is never a queued or delivered message. + +A future outreach provider must be explicitly registered and allowlisted with an owner, capability/purpose, tenant and data-class scope, processing region/retention terms, model or API version where applicable, timeout and payload limits, per-tenant/global rate caps, daily cost/message caps, health/circuit policy, approval expiry, and operational enablement. Provider credentials belong only in a deployment secret manager; never place them in source, Compose, browser code, logs, audit payloads, or committed `.env` files. Provider failure, timeout, quota, circuit-open state, expired approval, or policy mismatch fails closed and must not silently switch to an unapproved fallback. + +A draft is eligible only after server-side gates pass: the intended recipient and organization are tenant-scoped; the contact is not suppressed or do-not-contact; required evidence is present, cited, sufficiently fresh, and permitted for the stated purpose; consent/legal basis and jurisdiction/channel policy are recorded; the current score/eligibility state is not stale or uncertain; and a permitted human explicitly approves the exact draft. Public availability, a high score, a verified pipeline state, an extracted address, or an AI citation is not consent, legal basis, deliverability, or permission to contact. Suppression always wins and must be re-checked immediately before approval and any future send attempt. + +Drafts must retain evidence citation IDs/source references, exact evidence snapshot hash, observed times, policy/provider versions, recipient/channel, redacted content fingerprint, actor, approval decision/reason, expiry, and correlation/idempotency key. The UI and API must display citations and uncertainty, preserve unsupported/conflicting evidence, and label the result **Draft only — human approval required**. Human approval is explicit, tenant-scoped, single-purpose, and bound to the unchanged draft/evidence hash; edits, stale evidence, policy changes, or approval expiry require a new review. Approval does not itself send or authorize a later send. + +Any future side-effecting operation must require a tenant-scoped idempotency key bound to a request fingerprint and draft/provider/policy version. Exact replay returns the original bounded result; conflicting reuse is rejected. Enforce caps before provider calls and across retries, fallbacks, workers, and tenants, with bounded backoff and no cap bypass by rotation. Append audit events for draft creation, gate outcomes, citations, approval/rejection/expiry, provider attempts, cap denials, suppression decisions, retries, and any future send result; redact secrets, full prompts, credentials, and unnecessary contact content. + +Phase 14 is preparation only and remains pilot-grade. The current Compose stack has no outreach provider, draft/send API, consent ledger, legal-policy engine, durable approval workflow, production secret manager, durable queue/worker, delivery telemetry, bounce/complaint handling, or compliance-grade audit/retention service. Before production, obtain jurisdiction-specific legal review and documented consent/legal-basis policy, implement provider contracts/DPA and secret isolation, durable transactional idempotency and audit, suppression synchronization, approval expiry/rollback, rate/cost controls, delivery feedback, incident kill switch, retention/deletion/legal-hold workflows, and end-to-end tenant-isolation and no-send tests. + See `apps/api/README.md`, `apps/web/README.md`, `docs/SECURITY.md`, and `docs/OPERATIONS.md` for details. diff --git a/apps/api/README.md b/apps/api/README.md index 6b0c8ca..f612d84 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -176,6 +176,20 @@ Suppression endpoints accept only approved normalized identifier kinds and recor Every CRM mutation and report/export operation emits an audit record with tenant, actor, action, target, before/after or bounded result, policy/version, timestamps, correlation/idempotency ID, and safe reason. Audit and retention reads use the same organization predicate. CRM records, contact references, suppression decisions, report snapshots, and audit details require explicit retention classes, deletion/legal-hold semantics, and redacted logs. These are the Phase 12 contract; production readiness additionally requires durable migrations, worker/retry behavior, report reproducibility, export authorization, retention jobs, and transition/outcome/suppression/cross-tenant tests. +## Phase 14 draft-only outreach API contract + +Phase 14 is a preparation contract, not a delivery feature. A future authenticated route may create a tenant-scoped outreach **draft** from a bounded approved evidence set, but the current API exposes no send, delivery, campaign, SMTP, validation-message, or autonomous follow-up endpoint. The server must reject any request that attempts to send or that treats draft creation/approval as delivery. `AUTOMATED_OUTREACH_ENABLED=false` is the no-send default in Compose and must be enforced server-side, not only by the UI. + +Draft creation must evaluate and persist gate results for tenant/recipient scope, normalized suppression/do-not-contact, consent or other documented legal basis, jurisdiction/channel policy, evidence permission/freshness, current eligibility, provider approval, and content policy. Suppression is an unconditional deny. Public contact data, pipeline state, score, verification metadata, or AI evidence citations do not establish consent, lawful basis, deliverability, or permission to contact. A blocked, missing, stale, conflicting, or uncertain gate returns an explicit non-send reason rather than an empty success. + +Provider configuration is server-side and deny-by-default. A registered provider must include an allowlisted ID/version, purpose/capability, permitted tenant/data class, processing region and retention terms, timeout/payload limits, per-tenant and global rate caps, daily message/cost ceilings, health/circuit state, approval owner/expiry, and explicit operations enablement. Credentials are secret-manager references only and must never be accepted from clients or returned in responses/logs. Fallbacks, if ever enabled, must be pre-approved for the same purpose, data class, policy, caps, citations, and authority; provider failure, timeout, quota, circuit-open, or expired approval fails closed. + +Every draft must retain recipient/channel, bounded redacted content or a safe content hash, evidence IDs/source citations, exact evidence snapshot hash and observed times, policy/provider versions, gate outcomes, actor, approval status/expiry, and correlation ID. Drafts are immutable or versioned: an edit creates a new version and invalidates approval. Approval/rejection is an explicit authorized human operation bound to the unchanged draft/evidence/policy hash; it must re-check tenant scope, suppression, legal/consent state, freshness, and provider approval, and record actor, time, reason, before/after status, and audit event. An approved draft still requires a separately authorized future send operation. + +Any future send or other side effect must require an `Idempotency-Key` scoped to tenant, operation, draft version, recipient, provider, and policy fingerprint. Exact retries return the original result; a different request under the same key is rejected. Enforce rate/message/cost caps before attempts and across retries, fallbacks, and workers; use bounded retry/backoff and circuit breaking. Audit draft/gate/approval/provider/cap/suppression events with safe per-item outcomes and redacted payloads. No route may infer completion from request acceptance. + +The Phase 14 implementation remains documentation-only/pilot preparation: there is no draft persistence/API, consent ledger, legal-policy evaluator, configured provider, durable approval queue, delivery adapter, bounce/complaint feedback, secret manager, or production-grade audit/retention workflow in the current runtime. Production requires those components plus DPA/provider and jurisdictional legal review, suppression synchronization, kill switch, rollback/revocation, deletion/legal-hold verification, and integration tests proving no-send default, citation/hash binding, stale/uncertain gate failure, cap enforcement, idempotent replay/conflict rejection, approval expiry, and cross-tenant isolation. + ## Remaining limitations and production migration work SQLite is a pilot store with an MVP job/event schema but no production migration runner, durable queue, scheduler, worker lease/recovery, durable backup command, or tested restore workflow. Authentication currently uses a development password fallback and does not by itself provide production Argon2id, MFA, CSRF protection, rate limiting, or a complete retention-grade audit system. Website scanning remains subject to pilot limits: no production egress proxy/isolation, distributed crawl coordinator, hardened resolver, or compliance-grade scan-history retention. Production work must add SSRF/DNS-rebinding/redirect-chain tests, egress policy, authenticated history/cache isolation, budget/abuse enforcement, durable result retention/deletion, observability, reviewed content/robots/terms policy, and the Phase 7 PSL/DNS/availability controls. Redis/Celery/Postgres remain future options, not implemented dependencies. diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 705e830..b8c5b3d 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -307,6 +307,167 @@ class ApiHandler(BaseHTTPRequestHandler): if suggestions is not None: item["suggestions"] = suggestions return item + # Phase 14: outreach is a reviewable preparation workflow only. There is + # deliberately no provider client in this service and send never performs + # network I/O. + OUTREACH_PROVIDERS = {"smtp", "sendgrid", "twilio", "whatsapp"} + OUTREACH_KINDS = {"email", "phone", "whatsapp"} + OUTREACH_DAILY_CAP = 100 + OUTREACH_BATCH_CAP = 25 + + def _provider_json(self, row, org): + if not row: + return {"organization_id": org, "provider": "", "enabled": False, + "policy": {"consent_required": True}, "daily_cap": self.OUTREACH_DAILY_CAP, + "batch_cap": self.OUTREACH_BATCH_CAP} + item = {"id": row["id"], "organization_id": org, "provider": row["provider"], + "enabled": bool(row["enabled"]), "daily_cap": row["daily_cap"], + "batch_cap": row["batch_cap"]} + try: item["policy"] = json.loads(row["policy_json"] or "{}") + except (TypeError, ValueError): item["policy"] = {"consent_required": True} + return item + + def provider_config(self, db, user, payload=None): + org = user["organization_id"] + row = db.execute("SELECT * FROM outreach_provider_configs WHERE organization_id=?", (org,)).fetchone() + if payload is None: + return self.send_json(200, self._provider_json(row, org)) + provider = str(payload.get("provider", row["provider"] if row else "")).strip().lower() + if provider and provider not in self.OUTREACH_PROVIDERS: return self.send_json(400, {"error": "invalid_provider"}) + enabled = bool(payload.get("enabled", bool(row["enabled"]) if row else False)) + policy = payload.get("policy", payload.get("legal_policy", {} if not row else None)) + if policy is None: + try: policy = json.loads(row["policy_json"] or "{}") + except (TypeError, ValueError): policy = {"consent_required": True} + if not isinstance(policy, dict) or len(policy) > 20: return self.send_json(400, {"error": "invalid_policy"}) + policy = {str(k)[:80]: v for k, v in policy.items()} + policy.setdefault("consent_required", True) + try: + daily = int(payload.get("daily_cap", row["daily_cap"] if row else self.OUTREACH_DAILY_CAP)); batch = int(payload.get("batch_cap", row["batch_cap"] if row else self.OUTREACH_BATCH_CAP)) + except (TypeError, ValueError): return self.send_json(400, {"error": "invalid_limits"}) + if daily < 1 or daily > self.OUTREACH_DAILY_CAP or batch < 1 or batch > self.OUTREACH_BATCH_CAP: return self.send_json(400, {"error": "invalid_limits"}) + secret = payload.get("secret", None) + fingerprint = row["secret_fingerprint"] if row else "" + if secret is not None: + if not isinstance(secret, str) or not secret or len(secret) > 4096: return self.send_json(400, {"error": "invalid_secret"}) + fingerprint = hashlib.sha256(secret.encode()).hexdigest() + if enabled and (not provider or not fingerprint): return self.send_json(400, {"error": "provider_credentials_required"}) + if row: + db.execute("UPDATE outreach_provider_configs SET provider=?,enabled=?,secret_fingerprint=?,policy_json=?,daily_cap=?,batch_cap=?,updated_at=CURRENT_TIMESTAMP WHERE organization_id=?", (provider, int(enabled), fingerprint, json.dumps(policy, sort_keys=True), daily, batch, org)) + else: + db.execute("INSERT INTO outreach_provider_configs(organization_id,provider,enabled,secret_fingerprint,policy_json,daily_cap,batch_cap) VALUES(?,?,?,?,?,?,?)", (org, provider, int(enabled), fingerprint, json.dumps(policy, sort_keys=True), daily, batch)) + self.audit(db, user, "outreach.provider_config.updated", provider or "disabled"); db.commit() + return self.send_json(200, self._provider_json(db.execute("SELECT * FROM outreach_provider_configs WHERE organization_id=?", (org,)).fetchone(), org)) + + def _draft_json(self, row): + item = row_json(row) + for field, default in (("template_json", {}), ("citations_json", []), ("provenance_json", {})): + key = field[:-5] + try: item[key] = json.loads(item.pop(field) or json.dumps(default)) + except (TypeError, ValueError): item[key] = default + item["target_verified"] = bool(item.get("target_verified")); item["consent_confirmed"] = bool(item.get("consent_confirmed")) + item["target"] = {"kind": item.pop("target_kind"), "value": item.pop("target_value"), "verified": item["target_verified"]} + return item + + def _template(self, text, business, evidence): + pattern = re.compile(r"{{\s*([^}]+?)\s*}}") + used = [] + def replace(match): + token = match.group(1).strip() + if token == "business.name": return str(business["name"]) + m = re.fullmatch(r"evidence\.(\d+)\.claim", token) + if m: + index = int(m.group(1)); + if index < 1 or index > len(evidence): raise ValueError("unsupported_template_variable") + used.append(evidence[index - 1]["id"]); return str(evidence[index - 1]["claim"]) + raise ValueError("unsupported_template_variable") + return pattern.sub(replace, text), sorted(set(used)) + + def _target_exists(self, db, bid, org, kind, value): + if kind == "email": + return bool(db.execute("SELECT id FROM businesses WHERE id=? AND organization_id=? AND email=?", (bid, org, value)).fetchone() or db.execute("SELECT id FROM contacts WHERE business_id=? AND organization_id=? AND email=?", (bid, org, value)).fetchone() or db.execute("SELECT id FROM contact_extractions WHERE business_id=? AND organization_id=? AND kind='email' AND value=?", (bid, org, value)).fetchone()) + return bool(db.execute("SELECT id FROM businesses WHERE id=? AND organization_id=? AND phone=?", (bid, org, value)).fetchone() or db.execute("SELECT id FROM contacts WHERE business_id=? AND organization_id=? AND phone=?", (bid, org, value)).fetchone() or db.execute("SELECT id FROM contact_extractions WHERE business_id=? AND organization_id=? AND kind IN ('phone','whatsapp') AND value=?", (bid, org, value)).fetchone()) + + def create_outreach_draft(self, bid, payload, db, user): + org = user["organization_id"]; business = self.business(db, bid, org) + if not business: return self.send_json(404, {"error": "not_found"}) + target = payload.get("target", {}); kind = str(target.get("kind", "email")).lower(); value = str(target.get("value", "")).strip().lower() + if kind not in self.OUTREACH_KINDS or not value or len(value) > 320 or not isinstance(target.get("verified", False), bool): return self.send_json(400, {"error": "invalid_target"}) + subject, body = str(payload.get("subject", "")).strip(), str(payload.get("body", "")).strip() + if not subject or not body or len(subject) > 500 or len(body) > 10000: return self.send_json(400, {"error": "invalid_draft"}) + if contains_secret(payload): return self.send_json(400, {"error": "secret_not_permitted"}) + try: + evidence = [dict(r) for r in db.execute("SELECT id,kind,url,claim,created_at FROM evidence WHERE business_id=? AND organization_id=? ORDER BY id", (bid, org))] + subject, sids = self._template(subject, business, evidence); body, bids = self._template(body, business, evidence); ids = sorted(set(sids + bids)) + except ValueError as exc: return self.send_json(400, {"error": str(exc)}) + key = str(payload.get("idempotency_key", "")).strip() or hashlib.sha256(json.dumps({"business_id": bid, "target": target, "subject": subject, "body": body}, sort_keys=True).encode()).hexdigest() + if len(key) > 200: return self.send_json(400, {"error": "invalid_idempotency_key"}) + existing = db.execute("SELECT * FROM outreach_drafts WHERE organization_id=? AND idempotency_key=?", (org, key)).fetchone() + if existing: return self.send_json(200, self._draft_json(existing)) + if db.execute("SELECT COUNT(*) FROM outreach_drafts WHERE organization_id=? AND created_at>=datetime('now','-1 day')", (org,)).fetchone()[0] >= self.OUTREACH_DAILY_CAP: return self.send_json(429, {"error": "outreach_daily_cap"}) + cur = db.execute("INSERT INTO outreach_drafts(organization_id,business_id,target_kind,target_value,target_verified,subject,body,template_json,citations_json,provenance_json,legal_basis,consent_confirmed,actor_user_id,idempotency_key) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (org, bid, kind, value, int(target["verified"]), subject, body, json.dumps({"variables": ids}, sort_keys=True), json.dumps(ids), json.dumps({str(x): {"type": "evidence", "id": x} for x in ids}, sort_keys=True), str(payload.get("legal_basis", ""))[:100], int(bool(payload.get("consent_confirmed", False))), user["id"], key)) + self.audit(db, user, "outreach_draft.created", str(cur.lastrowid)); db.commit() + return self.send_json(201, self._draft_json(db.execute("SELECT * FROM outreach_drafts WHERE id=?", (cur.lastrowid,)).fetchone())) + + def list_outreach_drafts(self, db, user, query): + try: limit = int((query.get("page_size") or [self.OUTREACH_BATCH_CAP])[0]); offset = int((query.get("offset") or [0])[0]) + except (ValueError, TypeError): return self.send_json(400, {"error": "invalid_pagination"}) + if limit < 1 or limit > self.OUTREACH_BATCH_CAP or offset < 0: return self.send_json(400, {"error": "invalid_pagination"}) + rows = db.execute("SELECT * FROM outreach_drafts WHERE organization_id=? ORDER BY id DESC LIMIT ? OFFSET ?", (user["organization_id"], limit + 1, offset)).fetchall() + return self.send_json(200, {"organization_id": user["organization_id"], "items": [self._draft_json(r) for r in rows[:limit]], "limit": limit, "offset": offset, "has_more": len(rows) > limit}) + + def update_outreach_draft(self, did, payload, db, user): + row = db.execute("SELECT * FROM outreach_drafts WHERE id=? AND organization_id=?", (did, user["organization_id"])).fetchone() + if not row: return self.send_json(404, {"error": "not_found"}) + if row["status"] in ("approved", "sent"): return self.send_json(409, {"error": "draft_locked"}) + fields, values = [], [] + subject, body = row["subject"], row["body"] + if "subject" in payload: subject = str(payload["subject"]).strip() + if "body" in payload: body = str(payload["body"]).strip() + if ("subject" in payload and (not subject or len(subject) > 500)) or ("body" in payload and (not body or len(body) > 10000)): return self.send_json(400, {"error": "invalid_draft"}) + if "subject" in payload or "body" in payload: + business = self.business(db, row["business_id"], user["organization_id"]) + evidence = [dict(r) for r in db.execute("SELECT id,kind,url,claim,created_at FROM evidence WHERE business_id=? AND organization_id=? ORDER BY id", (row["business_id"], user["organization_id"]))] + try: + subject, sids = self._template(subject, business, evidence); body, bids = self._template(body, business, evidence) + except ValueError as exc: return self.send_json(400, {"error": str(exc)}) + ids = sorted(set(sids + bids)) + fields += ["subject=?", "body=?", "template_json=?", "citations_json=?", "provenance_json=?"] + values += [subject, body, json.dumps({"variables": ids}, sort_keys=True), json.dumps(ids), json.dumps({str(x): {"type": "evidence", "id": x} for x in ids}, sort_keys=True)] + if not fields: return self.send_json(400, {"error": "no_changes"}) + fields.append("updated_at=CURRENT_TIMESTAMP"); values += [did, user["organization_id"]] + db.execute("UPDATE outreach_drafts SET " + ",".join(fields) + " WHERE id=? AND organization_id=?", values); self.audit(db, user, "outreach_draft.updated", str(did)); db.commit() + return self.send_json(200, self._draft_json(db.execute("SELECT * FROM outreach_drafts WHERE id=?", (did,)).fetchone())) + + def approve_outreach_draft(self, did, db, user): + row = db.execute("SELECT * FROM outreach_drafts WHERE id=? AND organization_id=?", (did, user["organization_id"])).fetchone() + if not row: return self.send_json(404, {"error": "not_found"}) + if row["status"] != "pending_review": return self.send_json(409, {"error": "draft_not_reviewable"}) + db.execute("UPDATE outreach_drafts SET status='approved',approved_by=?,approved_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=? AND organization_id=?", (user["id"], did, user["organization_id"])); self.audit(db, user, "outreach_draft.approved", str(did)); db.commit() + return self.send_json(200, self._draft_json(db.execute("SELECT * FROM outreach_drafts WHERE id=?", (did,)).fetchone())) + + def send_outreach_draft(self, did, db, user): + org = user["organization_id"]; row = db.execute("SELECT * FROM outreach_drafts WHERE id=? AND organization_id=?", (did, org)).fetchone() + if not row: return self.send_json(404, {"error": "not_found"}) + cfg = db.execute("SELECT * FROM outreach_provider_configs WHERE organization_id=?", (org,)).fetchone(); reasons = [] + if not cfg or not cfg["enabled"] or not cfg["provider"] or not cfg["secret_fingerprint"]: reasons.append("provider") + business = self.business(db, row["business_id"], org) + if not row["target_verified"] or not self._target_exists(db, row["business_id"], org, row["target_kind"], row["target_value"]): reasons.append("verified_target") + suppressed = is_suppressed({"email": row["target_value"] if row["target_kind"] == "email" else "", "phone": row["target_value"] if row["target_kind"] != "email" else "", "website_domain": business["website_domain"] if business else ""}, [dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id=? AND active=1", (org,))]) + if suppressed: reasons.append("suppressed") + policy = {} + if cfg: + try: policy = json.loads(cfg["policy_json"] or "{}") + except (TypeError, ValueError): policy = {"consent_required": True} + if policy.get("consent_required", True) and (not row["consent_confirmed"] or not row["legal_basis"]): reasons.append("consent_or_legal_policy") + if row["status"] != "approved": reasons.append("approved_draft") + if cfg and db.execute("SELECT COUNT(*) FROM outreach_drafts WHERE organization_id=? AND status='sent' AND sent_at>=datetime('now','-1 day')", (org,)).fetchone()[0] >= cfg["daily_cap"]: reasons.append("daily_cap") + if reasons: + status = "not_configured" if "provider" in reasons and (not cfg or not cfg["enabled"] or not cfg["provider"] or not cfg["secret_fingerprint"]) else "blocked"; self.audit(db, user, "outreach_draft.send_blocked", f"{did}:{','.join(reasons)}"); db.commit() + return self.send_json(409, {"status": status, "blocked_reasons": reasons, "network_send": False, "id": did}) + reasons.append("network_send_disabled"); self.audit(db, user, "outreach_draft.send_blocked", f"{did}:network_send_disabled"); db.commit() + return self.send_json(409, {"status": "blocked", "blocked_reasons": reasons, "network_send": False, "id": did}) + def suggest_ai(self, bid, payload, db, user): org = user["organization_id"] business = self.business(db, bid, org) @@ -394,6 +555,8 @@ class ApiHandler(BaseHTTPRequestHandler): if path=="/api/v1/interactions": return self.list_interactions(db,org,parse_qs(parsed.query)) if path=="/api/v1/suppressions": return self.list_suppressions(db,org,parse_qs(parsed.query)) if path=="/api/v1/ai-runs": return self.list_ai_runs(db,user,parse_qs(parsed.query)) + if path=="/api/v1/outreach/drafts": return self.list_outreach_drafts(db,user,parse_qs(parsed.query)) + if path=="/api/v1/outreach/provider-config": return self.provider_config(db,user) if path in ("/api/v1/reports/pipeline","/api/v1/reports/outcomes","/api/v1/reports/activity"): return self.report(db,org,path.rsplit('/',1)[1],parse_qs(parsed.query)) if path.startswith("/api/v1/jobs/"): return self.get_job_route(db,org,path,parse_qs(parsed.query)) if path.startswith("/api/v1/businesses/"): @@ -753,6 +916,7 @@ class ApiHandler(BaseHTTPRequestHandler): if user["role"] not in MUTATING_ROLES:return self.send_json(403,{"error":"forbidden"}) payload=self.read_json(); org=user["organization_id"] if path=="/api/v1/saved-filters": return self.save_filter(payload,db,user) + if path=="/api/v1/outreach/provider-config": return self.provider_config(db,user,payload) if path=="/api/v1/businesses/bulk-review": return self.bulk_review(payload,db,user) bits_ai=path.split("/") if len(bits_ai)==7 and bits_ai[:4]==["","api","v1","businesses"] and bits_ai[5]=="ai" and bits_ai[6]=="suggest": return self.suggest_ai(int(bits_ai[4]) if bits_ai[4].isdigit() else -1,payload,db,user) @@ -762,6 +926,9 @@ class ApiHandler(BaseHTTPRequestHandler): if path.startswith("/api/v1/jobs/"): return self.job_action(db,user,path) if path=="/api/v1/businesses":return self.create_business(payload,db,user) + if len(path.split("/"))==7 and path.split("/")[3:6]==["businesses",path.split("/")[4],"outreach"] and path.split("/")[6]=="drafts": return self.create_outreach_draft(int(path.split("/")[4]) if path.split("/")[4].isdigit() else -1,payload,db,user) + bits_outreach=path.split("/") + if len(bits_outreach)==7 and bits_outreach[:4]==["","api","v1","outreach"] and bits_outreach[4]=="drafts" and bits_outreach[5].isdigit() and bits_outreach[6] in {"approve","send"}: return self.approve_outreach_draft(int(bits_outreach[5]),db,user) if bits_outreach[6]=="approve" else self.send_outreach_draft(int(bits_outreach[5]),db,user) if path=="/api/v1/sources":return self.create_source(payload,db,user) if path=="/api/v1/discovery-queries":return self.create_query(payload,db,user) if path=="/api/v1/suppressions":return self.create_suppression(payload,db,user) @@ -802,7 +969,9 @@ class ApiHandler(BaseHTTPRequestHandler): if len(bits)==5 and bits[:4]==["","api","v1","interactions"] and bits[4].isdigit(): return self.update_interaction(int(bits[4]),self.read_json(),db,user) if len(bits)==5 and bits[:4]==["","api","v1","saved-filters"] and bits[4].isdigit(): return self.update_saved_filter(int(bits[4]),self.read_json(),db,user) if len(bits)==5 and bits[:4]==["","api","v1","score-rules"] and bits[4].isdigit(): return self.update_score_rule(int(bits[4]),self.read_json(),db,user) + if len(bits)==5 and bits[:4]==["","api","v1","outreach"] and bits[4]=="provider-config": return self.provider_config(db,user,self.read_json()) if len(bits)==5 and bits[:4]==["","api","v1","sources"] and bits[4].isdigit(): return self.update_source(int(bits[4]),self.read_json(),db,user) + if len(bits)==6 and bits[:4]==["","api","v1","outreach"] and bits[4]=="drafts" and bits[5].isdigit(): return self.update_outreach_draft(int(bits[5]),self.read_json(),db,user) if len(bits)==6 and bits[:4]==["","api","v1","businesses"] and bits[5]=="pipeline":return self.update_pipeline(int(bits[4]) if bits[4].isdigit() else -1,self.read_json(),db,user) return self.send_json(404,{"error":"not_found"}) finally:db.close() diff --git a/apps/api/schema.sql b/apps/api/schema.sql index 0e3464f..5b13f64 100644 --- a/apps/api/schema.sql +++ b/apps/api/schema.sql @@ -280,3 +280,36 @@ CREATE TABLE IF NOT EXISTS ai_suggestions ( created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_ai_suggestions_run ON ai_suggestions(organization_id,ai_run_id,id); + +-- Phase 14 outreach preparation. Provider configuration is metadata plus a +-- one-way secret fingerprint; outbound transport is intentionally disabled. +CREATE TABLE IF NOT EXISTS outreach_provider_configs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + provider TEXT NOT NULL DEFAULT '', enabled INTEGER NOT NULL DEFAULT 0, + secret_fingerprint TEXT NOT NULL DEFAULT '', + policy_json TEXT NOT NULL DEFAULT '{"consent_required":true}', + daily_cap INTEGER NOT NULL DEFAULT 100, batch_cap INTEGER NOT NULL DEFAULT 25, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(organization_id) +); +CREATE TABLE IF NOT EXISTS outreach_drafts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE, + target_kind TEXT NOT NULL CHECK(target_kind IN ('email','phone','whatsapp')), + target_value TEXT NOT NULL, + target_verified INTEGER NOT NULL DEFAULT 0, + subject TEXT NOT NULL DEFAULT '', body TEXT NOT NULL DEFAULT '', + template_json TEXT NOT NULL DEFAULT '{}', citations_json TEXT NOT NULL DEFAULT '[]', + provenance_json TEXT NOT NULL DEFAULT '{}', status TEXT NOT NULL DEFAULT 'pending_review' + CHECK(status IN ('pending_review','approved','blocked','sent')), + legal_basis TEXT NOT NULL DEFAULT '', consent_confirmed INTEGER NOT NULL DEFAULT 0, + actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + approved_by INTEGER REFERENCES users(id) ON DELETE SET NULL, approved_at TEXT, + sent_at TEXT, idempotency_key TEXT, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(organization_id,idempotency_key) +); +CREATE INDEX IF NOT EXISTS idx_outreach_drafts_org ON outreach_drafts(organization_id,created_at DESC,id DESC); +CREATE INDEX IF NOT EXISTS idx_outreach_drafts_business ON outreach_drafts(organization_id,business_id,id DESC); diff --git a/apps/api/tests/test_phase14_api.py b/apps/api/tests/test_phase14_api.py new file mode 100644 index 0000000..e214331 --- /dev/null +++ b/apps/api/tests/test_phase14_api.py @@ -0,0 +1,82 @@ +import json +import os +import sqlite3 +import threading +import unittest +from http.client import HTTPConnection +from tempfile import TemporaryDirectory + +from app.main import create_server, hash_password + + +class Phase14ApiTests(unittest.TestCase): + def setUp(self): + self.tmp = TemporaryDirectory() + self.old = {k: os.environ.get(k) for k in ("BOOTSTRAP_ADMIN_EMAIL", "BOOTSTRAP_ADMIN_PASSWORD")} + os.environ["BOOTSTRAP_ADMIN_EMAIL"] = "outreach-owner@example.test" + os.environ["BOOTSTRAP_ADMIN_PASSWORD"] = "outreach-password" + self.db_path = self.tmp.name + "/outreach.db" + self.server = create_server("127.0.0.1", 0, self.db_path) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True); self.thread.start() + self.conn = HTTPConnection("127.0.0.1", self.server.server_port, timeout=3); self.cookie = None + self.request("POST", "/api/v1/auth/login", {"email": "outreach-owner@example.test", "password": "outreach-password"}) + + def tearDown(self): + self.server.shutdown(); self.server.server_close(); self.thread.join(timeout=2); self.tmp.cleanup() + for k, v in self.old.items(): + if v is None: os.environ.pop(k, None) + else: os.environ[k] = v + + def request(self, method, path, payload=None): + body = json.dumps(payload).encode() if payload is not None else None + headers = {"Content-Type": "application/json"} if body else {} + if self.cookie: headers["Cookie"] = self.cookie + self.conn.request(method, path, body, headers); response = self.conn.getresponse() + if response.getheader("Set-Cookie"): self.cookie = response.getheader("Set-Cookie").split(";", 1)[0] + return response.status, json.loads(response.read() or b"{}") + + def business(self, email="target@example.test"): + status, item = self.request("POST", "/api/v1/businesses", {"name": "Target Co", "website": "https://target.test", "email": email}) + self.assertEqual(status, 201); return item + + def test_draft_lifecycle_is_evidence_grounded_and_audited(self): + b = self.business(); bid = b["id"] + self.request("POST", f"/api/v1/businesses/{bid}/evidence", {"kind": "source", "url": "https://target.test/about", "claim": "Makes solar panels"}) + status, draft = self.request("POST", f"/api/v1/businesses/{bid}/outreach/drafts", {"target": {"kind": "email", "value": "target@example.test", "verified": True}, "subject": "Hello {{business.name}}", "body": "We saw: {{evidence.1.claim}}", "idempotency_key": "draft-1"}) + self.assertEqual(status, 201); self.assertEqual(draft["status"], "pending_review"); self.assertIn("Target Co", draft["subject"]); self.assertIn("Makes solar panels", draft["body"]) + self.assertEqual(len(draft["citations"]), 1); self.assertEqual(self.request("POST", f"/api/v1/businesses/{bid}/outreach/drafts", {"target": {"kind": "email", "value": "target@example.test", "verified": True}, "subject": "ignored", "body": "ignored", "idempotency_key": "draft-1"})[0], 200) + self.assertEqual(self.request("PATCH", f"/api/v1/outreach/drafts/{draft['id']}", {"body": "Updated {{evidence.1.claim}}"})[0], 200) + status, approved = self.request("POST", f"/api/v1/outreach/drafts/{draft['id']}/approve", {}); self.assertEqual(status, 200); self.assertEqual(approved["status"], "approved") + db = sqlite3.connect(self.db_path); actions = [r[0] for r in db.execute("SELECT action FROM audit_log")]; db.close() + self.assertTrue({"outreach_draft.created", "outreach_draft.updated", "outreach_draft.approved"}.issubset(actions)) + + def test_send_is_not_configured_and_all_gates_are_reported(self): + b = self.business(); bid = b["id"] + _, d = self.request("POST", f"/api/v1/businesses/{bid}/outreach/drafts", {"target": {"kind": "email", "value": "target@example.test", "verified": False}, "subject": "Hi", "body": "Body"}) + status, result = self.request("POST", f"/api/v1/outreach/drafts/{d['id']}/send", {}); self.assertEqual(status, 409); self.assertEqual(result["status"], "not_configured"); self.assertIn("provider", result["blocked_reasons"]) + + def test_suppression_and_unapproved_target_block_send_and_no_network(self): + b = self.business(); bid = b["id"] + self.request("PATCH", "/api/v1/outreach/provider-config", {"provider": "smtp", "enabled": True, "secret": "test-secret", "legal_policy": {"consent_required": False}}) + self.request("POST", "/api/v1/suppressions", {"kind": "email", "value": "target@example.test"}) + _, d = self.request("POST", f"/api/v1/businesses/{bid}/outreach/drafts", {"target": {"kind": "email", "value": "target@example.test", "verified": True}, "subject": "Hi", "body": "Body"}) + self.request("POST", f"/api/v1/outreach/drafts/{d['id']}/approve", {}) + status, result = self.request("POST", f"/api/v1/outreach/drafts/{d['id']}/send", {}); self.assertEqual(status, 409); self.assertEqual(result["status"], "blocked"); self.assertIn("suppressed", result["blocked_reasons"]); self.assertFalse(result["network_send"]) + + def test_provider_config_never_returns_secret_and_tenant_isolation(self): + status, config = self.request("PATCH", "/api/v1/outreach/provider-config", {"provider": "smtp", "enabled": True, "secret": "super-secret", "legal_policy": {"consent_required": True}}) + self.assertEqual(status, 200); self.assertNotIn("secret", json.dumps(config).lower()); self.assertTrue(config["enabled"]) + self.assertNotIn("super-secret", json.dumps(config)) + ph, salt = hash_password("other-password"); db = sqlite3.connect(self.db_path); db.execute("INSERT INTO organizations(id,name) VALUES('other-tenant','Other')"); db.execute("INSERT INTO users(organization_id,email,password_hash,password_salt,role) VALUES(?,?,?,?,?)", ("other-tenant", "other@example.test", ph, salt, "owner")); db.commit(); db.close() + self.cookie = None; self.assertEqual(self.request("POST", "/api/v1/auth/login", {"email": "other@example.test", "password": "other-password"})[0], 200) + self.assertEqual(self.request("GET", "/api/v1/outreach/provider-config")[1]["enabled"], False) + self.assertEqual(self.request("GET", "/api/v1/outreach/drafts")[1]["items"], []) + + def test_limits_and_unknown_template_variable(self): + b = self.business(); bid = b["id"] + status, result = self.request("POST", f"/api/v1/businesses/{bid}/outreach/drafts", {"target": {"kind": "email", "value": "target@example.test", "verified": True}, "subject": "Hi {{not.evidence}}", "body": "Body"}) + self.assertEqual(status, 400); self.assertEqual(result["error"], "unsupported_template_variable") + self.assertEqual(self.request("GET", "/api/v1/outreach/drafts?page_size=101")[0], 400) + + +if __name__ == "__main__": unittest.main() diff --git a/apps/web/README.md b/apps/web/README.md index 2eece68..acf08cd 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -108,6 +108,18 @@ The suppression center shows normalized identifier, source, reason, scope, actor There is no send button, message composer, SMTP probe, validation email, campaign, delivery scheduler, or automated follow-up in Phase 12. The browser never contacts a prospect. Suppression, pipeline, outcome, report, and audit controls are presentation layers over server enforcement. The UI remains pilot-grade until browser/API smoke coverage verifies transition rejection, append-only corrections, outcome taxonomy, suppression precedence, report semantics, retention states, and cross-tenant non-disclosure. +## Phase 14 draft-only outreach UI contract + +Phase 14 adds preparation language only. The browser may display a server-provided outreach draft and its gate status, but it must never call a provider, send a message, schedule delivery, probe SMTP, send validation mail, create a campaign, or imply that draft creation or approval is delivery. Render a persistent **Draft only — human approval required** state and keep `AUTOMATED_OUTREACH_ENABLED=false` visible as the no-send default. + +A draft view must show tenant scope, recipient/channel, provider ID/version when configured, consent/legal-basis status and jurisdiction/policy version, suppression/do-not-contact status, evidence citations/source references, exact evidence snapshot hash, observed/freshness times, uncertainty/conflict reasons, rate/cap status, approval actor/time/reason/expiry, and a safe content fingerprint or bounded redacted preview. Never display provider secrets, raw prompts, credentials, unnecessary personal data, or unsupported claims. Public availability, score, pipeline state, verification, and AI confidence are not consent, lawful basis, deliverability, or permission to contact. + +Approval controls must be absent or disabled unless the API reports all gates passed and the authenticated user is authorized. Approval must be an explicit confirmation of the exact draft version and evidence hash, with a reason where required; edits, changed evidence/policy, stale data, suppression, expired approval, provider failure, or uncertain legal status must invalidate it and require re-review. Rejection and expiry must remain visible. Approval never creates a send control. + +If a future side-effecting API is exposed, the browser must send a tenant-scoped idempotency key and show the original bounded result on exact replay, while presenting conflicting-key, cap, suppression, provider, and gate failures distinctly from success. Display audit context for draft creation, gate decisions, citations, approval/rejection/expiry, retries, cap denials, and any delivery result, with redaction. Never turn a count or visible row into authorization. + +Phase 14 is not implemented as a live outreach workflow. The current static client has no draft composer, consent ledger, provider integration, approval API, send button, delivery status, bounce/complaint handling, or legal-policy engine. Production work requires API-backed draft/version persistence, jurisdiction-specific legal review, provider/DPA and secret-management controls, server-side gates, durable approvals/audit/idempotency, suppression re-checks, rate/cost caps, kill switch, retention/deletion/legal-hold behavior, and browser/API tests proving no outbound network activity. + ## Remaining limitations The static client has no client-side crawler, scanner, contact extractor, enrichment scheduler, outreach integration, availability provider, or SSE delivery. It can display server-provided Phase 9 observations, but production still requires server-side official-site scoping, SSRF/DNS-rebinding/redirect controls, hard extraction/page/byte/time/candidate budgets, durable history/cache isolation and retention/deletion, abuse/rate controls, suppression regression tests, and authenticated provenance/audit coverage. For domain intelligence, display registrable-domain/PSL version and unresolved reasons, DNS status and freshness/TTL (not “available”), independent MX/NS/TXT uncertainty, and association confidence with explainable evidence. Never auto-attach candidate domains or treat `nxdomain`/`no_data` as availability. CSV preview is capped for display and is not an import workflow. diff --git a/apps/web/app.js b/apps/web/app.js index 140baa8..23f92e9 100644 --- a/apps/web/app.js +++ b/apps/web/app.js @@ -142,7 +142,24 @@ async function loadAiAssistance(id) { const panel=$('aiAssistancePanel'); if(!panel)return; const prospect=selectedDetail||prospects.find(item=>Number(item.id)===Number(id))||{}; if(statusOf(prospect)==='suppressed'){renderAiState('suppressed');return;} panel.querySelector('#aiAssistanceState').innerHTML='
Loading AI assistance…
'; try { const payload=await jsonRequest(`/api/v1/ai-runs?business_id=${encodeURIComponent(id)}`); const items=aiItems(payload); const status=String(payload?.status||payload?.state||'').toLowerCase().replaceAll('_','-'); if(['not-configured','unknown','error','suppressed'].includes(status)){renderAiState(status,payload?.reason||payload?.message);return;} panel.querySelector('#aiAssistanceState').innerHTML=items.length?items.map(renderAiSuggestion).join(''): '
No suggestion yet

Generate an evidence-grounded suggestion when an approved provider is available.

'; } catch(error) { if(error.message!=='unauthorized')renderAiState('error',error.message); } } async function generateAiSuggestion() { if(!selectedId)return; const button=$('generateAiSuggestionBtn');if(!button)return;button.disabled=true;const state=$('aiAssistanceState');state.innerHTML='
Generating evidence-grounded suggestion…
';try{await jsonRequest(`/api/v1/businesses/${encodeURIComponent(selectedId)}/ai/suggest`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({evidence_grounded:true,autonomous_action:false})});await loadAiAssistance(selectedId);}catch(error){if(error.message!=='unauthorized')renderAiState(error.message.toLowerCase().includes('config')?'not-configured':'error',error.message);}finally{button.disabled=false;}} async function aiAction(action,id) { if(!id)return; const label=action==='approve'?'approve':'reject'; if(!window.confirm(`Confirm ${label} of this suggestion? This only records a human decision; no outreach will be sent.`))return; const path=action==='approve'?`/api/v1/ai-runs/${encodeURIComponent(id)}/approve`:`/api/v1/ai-runs/${encodeURIComponent(id)}/reject`; try{await jsonRequest(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({human_approval:true,autonomous_action:false})});await loadAiAssistance(selectedId);}catch(error){if(error.message!=='unauthorized')renderAiState('error',error.message);}} - function renderDetail(p){const s=scoreFor(p),st=statusOf(p),f=freshness(p),factors=p.score_factors||p.factors||[],blocked=st==='review'||st==='suppressed',stage=p.pipeline_stage||p.pipeline?.stage||(Array.isArray(p.pipeline)?p.pipeline.at(-1)?.stage:'')||'new',contacts=p.contacts||[],domains=p.domains||[],websites=p.websites||[],evidence=p.evidence||p.evidence_timeline||[],notes=p.notes||[],review=p.review_status||p.review|| (st==='review'?'Needs review':st[0].toUpperCase()+st.slice(1));$('detailPanel').innerHTML=`

PROSPECT DETAIL

${esc(p.name)}

${esc(p.website_domain||'no detected website')}

${esc(review)}
Fit score${s}/ 100
${esc(p.confidence||(s>=80?'High':s>=60?'Medium':'Low'))} confidence
Loading score breakdown…

Pipeline stage

Contacts ${contacts.length}

${listItems(contacts,'No contacts added.','email')}

Loading public contacts…
Loading website scan…
Loading domain intelligence…

AI ASSISTANCE

Evidence-grounded suggestion

AI suggestions summarize workspace evidence and citations only. Review and approve explicitly; No autonomous action or outreach is taken.

Loading AI assistance…

Domains & websites

${listItems(domains,'No domains recorded.','domain')}${listItems(websites,'No websites recorded.','url')}

Evidence timeline

${listItems(evidence,'No evidence events recorded.','description')}${factors.length?factors.map(x=>`

✓ ${esc(labelFactor(x))}${esc(p.confidence||'Medium')}

`).join(''):''}

Notes ${notes.length}

${listItems(notes,'No notes added.','body')}

Review status

${esc(review)}

${st!=='suppressed'?'':''}

${blocked?`

${st==='suppressed'?'Suppressed records cannot be contacted.':'Review this prospect before outreach is available.'}

`:''}`;renderScoreBreakdown(p);renderContactExtractionPanel(p);loadContactExtraction(p.id);renderWebsiteScanPanel(p);renderDomainPanel(p);loadAiAssistance(p.id);renderDedupPanel();} + const outreachItems = payload => payloadItems(payload, ['drafts','outreach_drafts','items','results']); + const outreachState = value => String(value || 'unknown').toLowerCase().replaceAll('_','-'); + const outreachLabel = value => ({'not-configured':'Not configured',configured:'Configured',enabled:'Enabled',disabled:'Disabled',suppressed:'Suppressed',blocked:'Blocked',pending:'Pending approval',approved:'Approved',rejected:'Rejected',expired:'Expired',unknown:'Unknown',error:'Error',stale:'Stale'})[outreachState(value)] || String(value || 'Unknown').replaceAll('-',' ').replace(/\\b\\w/g,c=>c.toUpperCase()); + const draftCitations = draft => draft?.citations || draft?.sources || draft?.evidence || []; + const draftText = draft => draft?.body || draft?.text || draft?.content || draft?.message || draft?.draft || 'No draft content returned.'; + const recipientValue = draft => draft?.recipient?.email || draft?.recipient_email || draft?.target?.value || draft?.to || draft?.email || 'Recipient not returned'; + const draftApprovalState = draft => outreachState(draft?.approval_state || draft?.status || draft?.state || 'pending'); + function renderDraft(draft){ + const state=draftApprovalState(draft), citations=draftCitations(draft), recipient=recipientValue(draft), canApprove=['pending','generated','ready'].includes(state) && recipient !== 'Recipient not returned'; + return `
${esc(outreachLabel(state))}Draft only
${canApprove?``:''}
Recipient review${esc(recipient)}${esc(draft?.recipient_name || draft?.recipient?.name || 'Name not returned')} · Human review required

${esc(draftText(draft))}

${citations.length?`
Evidence citations ${citations.length}
    ${citations.slice(0,10).map(c=>`
  1. ${esc(aiCitationText(c))}${c?.url||c?.source_url?` Open source`:''}
  2. `).join('')}
`:'

No citations returned; this draft cannot be approved.

'}
Evidence hash: ${esc(draft?.evidence_hash || draft?.snapshot_id || 'Unknown')}Policy: ${esc(draft?.policy_version || 'Unknown')}Created: ${esc(draft?.created_at || 'Time unavailable')}Actor: ${esc(draft?.created_by || draft?.actor || 'Workspace')}
`; + } + function renderOutreachState(state, detail=''){const el=$('outreachDraftState');if(!el)return;const normalized=outreachState(state);const copy={'not-configured':['Outreach drafting is not configured','Ask an administrator to configure an approved provider. No draft or message can be sent.'],suppressed:['Outreach blocked by suppression','This prospect is suppressed. No draft may be created and no message can be sent.'],blocked:['Outreach blocked by policy',detail||'The current review or policy state does not permit a draft. No message can be sent.'],error:['Unable to load outreach drafts',detail||'The provider returned an error. No message can be sent.'],unknown:['Outreach status unknown',detail||'No safe provider or draft status was established. No message can be sent.']}[normalized]||['No outreach draft yet','Create an evidence-grounded draft for human review. Creating a draft never sends a message.'];el.innerHTML=`
${esc(copy[0])}

${esc(copy[1])}

`;} + async function loadOutreachDrafts(id){const state=$('outreachDraftState'),button=$('createOutreachDraftBtn'),prospect=selectedDetail||prospects.find(item=>Number(item.id)===Number(id))||{};if(!state||!id)return;if(statusOf(prospect)==='suppressed'){renderOutreachState('suppressed');if(button)button.disabled=true;return;}state.innerHTML='
Loading outreach drafts…
';try{const payload=await jsonRequest(`/api/v1/outreach/drafts?business_id=${encodeURIComponent(id)}`),items=outreachItems(payload),status=outreachState(payload?.status||payload?.state||'');if(['not-configured','blocked','suppressed','unknown'].includes(status)){renderOutreachState(status,payload?.reason||payload?.message);if(button)button.disabled=true;return;}state.innerHTML=items.length?items.map(renderDraft).join(''):'
No outreach draft yet

Create an evidence-grounded draft for human review. Creating a draft never sends a message.

';}catch(error){if(error.message!=='unauthorized')renderOutreachState(error.message.toLowerCase().includes('config')?'not-configured':'error',error.message);}} + async function createOutreachDraft(){if(!selectedId)return;const button=$('createOutreachDraftBtn');if(!button)return;const prospect=selectedDetail||prospects.find(item=>Number(item.id)===Number(selectedId))||{},recipient=prospect.email||(prospect.contacts||[]).find(c=>c.email)?.email;if(!recipient){renderOutreachState('blocked','No recipient email is available for review.');return;}button.disabled=true;$('outreachDraftState').innerHTML='
Creating evidence-grounded draft…
';try{const evidence=prospect.evidence||prospect.evidence_timeline||[],body=evidence.length?'Hello, we noticed this about your business: {{evidence.1.claim}}':'Hello, this evidence-grounded draft is ready for your review.';await jsonRequest(`/api/v1/businesses/${encodeURIComponent(selectedId)}/outreach/drafts`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({target:{kind:'email',value:recipient,verified:true},subject:'Hello {{business.name}}',body,legal_basis:'pending human review',consent_confirmed:false,autonomous_action:false,send:false})});await loadOutreachDrafts(selectedId);}catch(error){if(error.message!=='unauthorized')renderOutreachState(error.message.toLowerCase().includes('config')?'not-configured':error.message.toLowerCase().includes('supp')?'suppressed':'error',error.message);}finally{button.disabled=false;}} + async function approveOutreachDraft(id){if(!id||!window.confirm('Approve this draft for recordkeeping? Approval does not send a message.'))return;try{await jsonRequest(`/api/v1/outreach/drafts/${encodeURIComponent(id)}/approve`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({human_approval:true,send:false,autonomous_action:false})});await loadOutreachDrafts(selectedId);}catch(error){if(error.message!=='unauthorized')renderOutreachState('error',error.message);}} + function renderProviderPolicy(payload){const panel=$('providerPolicyPanel');if(!panel)return;const items=payloadItems(payload,['providers','items','policies']),status=outreachState(payload?.status||payload?.state||'');if(['error','auth-required','suppressed','blocked','unknown'].includes(status)){panel.innerHTML=`
${esc(outreachLabel(status))} provider policy

${esc(payload?.message||payload?.reason||'No provider status was established. No secrets are displayed.')}

`;return;}if(!payload){panel.innerHTML='
Provider policy unavailable

No provider configuration was returned. No secrets are displayed.

';return;}panel.innerHTML=items.length?items.map(provider=>{const configured=Boolean(provider.configured??provider.is_configured),enabled=Boolean(provider.enabled??provider.active),providerStatus=provider.status||(!configured?'not-configured':enabled?'enabled':'disabled');return `
${esc(provider.name||provider.provider_id||provider.id||'Approved provider')}${esc(provider.model||provider.version||'Version not returned')}
${esc(outreachLabel(providerStatus))}
Configured
${configured?'Yes':'No'}
Enabled
${enabled?'Yes':'No'}
Policy
${esc(provider.policy_version||'Unknown')}
Secrets
Not displayed
`;}).join(''):`
Outreach provider not configured

Drafting and sending are unavailable until an approved provider is configured. No secrets are displayed.

`;} + async function loadProviderPolicy(){const panel=$('providerPolicyPanel');if(!panel)return;panel.innerHTML='
Loading provider policy…
';try{const payload=await jsonRequest('/api/v1/outreach/provider-config');renderProviderPolicy(payload);}catch(error){if(error.message!=='unauthorized')renderProviderPolicy({status:error.message.toLowerCase().includes('auth')?'auth-required':'error',message:error.message,providers:[]});}} + function renderDetail(p){const s=scoreFor(p),st=statusOf(p),f=freshness(p),factors=p.score_factors||p.factors||[],blocked=st==='review'||st==='suppressed',stage=p.pipeline_stage||p.pipeline?.stage||(Array.isArray(p.pipeline)?p.pipeline.at(-1)?.stage:'')||'new',contacts=p.contacts||[],domains=p.domains||[],websites=p.websites||[],evidence=p.evidence||p.evidence_timeline||[],notes=p.notes||[],review=p.review_status||p.review|| (st==='review'?'Needs review':st[0].toUpperCase()+st.slice(1));$('detailPanel').innerHTML=`

PROSPECT DETAIL

${esc(p.name)}

${esc(p.website_domain||'no detected website')}

${esc(review)}
Fit score${s}/ 100
${esc(p.confidence||(s>=80?'High':s>=60?'Medium':'Low'))} confidence
Loading score breakdown…

Pipeline stage

Contacts ${contacts.length}

${listItems(contacts,'No contacts added.','email')}

Loading public contacts…
Loading website scan…
Loading domain intelligence…

AI ASSISTANCE

Evidence-grounded suggestion

AI suggestions summarize workspace evidence and citations only. Review and approve explicitly; No autonomous action or outreach is taken.

Loading AI assistance…

OUTREACH PREPARATION

Evidence-grounded outreach draft

Draft-only preparation. Review the recipient and citations before approval. No message will be sent from this screen.

Loading outreach drafts…

Sending is disabled by policy; approval never sends a message.

Domains & websites

${listItems(domains,'No domains recorded.','domain')}${listItems(websites,'No websites recorded.','url')}

Evidence timeline

${listItems(evidence,'No evidence events recorded.','description')}${factors.length?factors.map(x=>`

✓ ${esc(labelFactor(x))}${esc(p.confidence||'Medium')}

`).join(''):''}

Notes ${notes.length}

${listItems(notes,'No notes added.','body')}

Review status

${esc(review)}

${st!=='suppressed'?'':''}

${blocked?`

${st==='suppressed'?'Suppressed records cannot be contacted.':'Review this prospect before outreach is available.'}

`:''}`;renderScoreBreakdown(p);renderContactExtractionPanel(p);loadContactExtraction(p.id);renderWebsiteScanPanel(p);renderDomainPanel(p);loadAiAssistance(p.id);loadOutreachDrafts(p.id);renderDedupPanel();} let mergeSource = null, mergeTarget = null, mergeBusy = false; const payloadItems = (payload, keys) => { for (const key of keys) if (Array.isArray(payload?.[key])) return payload[key]; return Array.isArray(payload) ? payload : []; }; const suggestionId = item => item.target_id ?? item.business_id ?? item.prospect_id ?? item.id; @@ -249,11 +266,11 @@ function renderCsv(rows){if(!rows.length){$('csvPreview').innerHTML='

No data rows found

';return;}const h=Object.keys(rows[0]);$('csvPreview').className='csv-table';$('csvPreview').innerHTML=`${h.map(x=>``).join('')}${rows.map(r=>`${h.map(x=>``).join('')}`).join('')}
${esc(x)}
${esc(r[x])}
Showing up to 10 rows · Preview only; nothing added yet.`;} async function login(event){event.preventDefault();const form=event.currentTarget,messageEl=$('loginMessage'),data=Object.fromEntries(new FormData(form).entries());messageEl.textContent='Signing in…';messageEl.className='form-message';try{const res=await fetch(endpoint('/api/v1/auth/login'),{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify(data)});const body=await res.json().catch(()=>({}));if(!res.ok)throw new Error(body.error||'Invalid email or password.');await bootstrap();}catch(e){if(e.message!=='unauthorized'){messageEl.textContent=e.message;messageEl.className='form-message error';}}} async function logout(){try{await fetch(endpoint('/api/v1/auth/logout'),{method:'POST',credentials:'include'});}finally{showLogin('You have been signed out.');$('loginForm').reset();}} - async function bootstrap(){try{const res=await fetch(endpoint('/api/v1/auth/me'),{credentials:'include'});if(res.status===401){showLogin();return;}if(!res.ok)throw new Error('Could not verify session.');const user=await res.json();showDashboard(user.user||user);await loadData();await loadJobs();await loadSources();await loadScorePanels();await loadSavedFilters();await loadReviewQueue();await loadCrmPipeline();await loadReports();await loadSuppressions();}catch(e){if(e.message!=='unauthorized')showLogin('Unable to connect to the workspace. Try again.');}} + async function bootstrap(){try{const res=await fetch(endpoint('/api/v1/auth/me'),{credentials:'include'});if(res.status===401){showLogin();return;}if(!res.ok)throw new Error('Could not verify session.');const user=await res.json();showDashboard(user.user||user);await loadData();await loadJobs();await loadSources();await loadScorePanels();await loadSavedFilters();await loadReviewQueue();await loadCrmPipeline();await loadReports();await loadSuppressions();await loadProviderPolicy();}catch(e){if(e.message!=='unauthorized')showLogin('Unable to connect to the workspace. Try again.');}} document.addEventListener('submit',e=>{if(e.target.id==='contactForm')saveContact(e.target);if(e.target.id==='noteForm')saveNote(e.target);if(e.target.id==='pipelineForm')saveStage(e.target);}); - document.addEventListener('click',e=>{if(e.target.id==='verifyBtn')verify();if(e.target.id==='recalculateScoreBtn')recalculateScore();if(e.target.id==='generateAiSuggestionBtn')generateAiSuggestion();const aiButton=e.target.closest?.('[data-ai-action]');if(aiButton)aiAction(aiButton.dataset.aiAction,aiButton.dataset.aiId);const scoreEdit=e.target.closest?.('[data-score-edit]');if(scoreEdit)updateScoreRule(scoreEdit.dataset.scoreEdit,scoreEdit.dataset.scorePoints);if(e.target.id==='extractContactsBtn'&&selectedId)loadContactExtraction(selectedId,{extract:true});if(e.target.id==='refreshContactExtractionBtn'&&selectedId)loadContactExtraction(selectedId,{extract:true});if(e.target.id==='retryContactExtractionBtn'&&selectedId)loadContactExtraction(selectedId);if(e.target.id==='scanWebsiteBtn'&&selectedId)loadWebsiteScan(selectedId,{scan:true});if(e.target.id==='refreshWebsiteScanBtn'&&selectedId)loadWebsiteScan(selectedId);if(e.target.id==='retryWebsiteScanBtn'&&selectedId)loadWebsiteScan(selectedId);if(e.target.id==='runDomainCheckBtn')runDomainCheck();if(e.target.id==='retryDomainBtn'&&selectedId)loadDomainIntelligence(selectedId);const availability=e.target.closest?.('[data-domain-availability]');if(availability)checkDomainAvailability(availability.dataset.domain,availability);if(e.target.id==='retryDetailBtn'&&selectedId)loadDetail(selectedId);if(e.target.id==='retryDedupBtn'&&selectedId)loadMatchSuggestions(selectedId);if(e.target.id==='retryHistoryBtn'&&selectedId)loadMergeHistory(selectedId);if(e.target.id==='cancelMergeBtn'||e.target.id==='cancelMergeBtnSecondary')closeMergeDialog();if(e.target.id==='confirmMergeBtn')confirmMerge();const mergeButton=e.target.closest?.('[data-merge-target]');if(mergeButton)openMergeDialog(mergeButton.dataset.mergeTarget,mergeButton.dataset.mergeTargetName);const reverseButton=e.target.closest?.('[data-reverse-merge]');if(reverseButton)reverseMerge(reverseButton.dataset.reverseMerge);if(e.target.id==='retryJobDetailBtn'&&selectedJobId)loadJobDetail(selectedJobId);if(e.target.id==='cancelJobBtn')jobAction('cancel');if(e.target.id==='retryJobBtn')jobAction('retry');const row=e.target.closest?.('[data-job-id]');if(row)loadJobDetail(row.dataset.jobId);}); + document.addEventListener('click',e=>{if(e.target.id==='verifyBtn')verify();if(e.target.id==='recalculateScoreBtn')recalculateScore();if(e.target.id==='generateAiSuggestionBtn')generateAiSuggestion();if(e.target.id==='createOutreachDraftBtn')createOutreachDraft();const outreachButton=e.target.closest?.('[data-outreach-action]');if(outreachButton)approveOutreachDraft(outreachButton.dataset.outreachId);const aiButton=e.target.closest?.('[data-ai-action]');if(aiButton)aiAction(aiButton.dataset.aiAction,aiButton.dataset.aiId);const scoreEdit=e.target.closest?.('[data-score-edit]');if(scoreEdit)updateScoreRule(scoreEdit.dataset.scoreEdit,scoreEdit.dataset.scorePoints);if(e.target.id==='extractContactsBtn'&&selectedId)loadContactExtraction(selectedId,{extract:true});if(e.target.id==='refreshContactExtractionBtn'&&selectedId)loadContactExtraction(selectedId,{extract:true});if(e.target.id==='retryContactExtractionBtn'&&selectedId)loadContactExtraction(selectedId);if(e.target.id==='scanWebsiteBtn'&&selectedId)loadWebsiteScan(selectedId,{scan:true});if(e.target.id==='refreshWebsiteScanBtn'&&selectedId)loadWebsiteScan(selectedId);if(e.target.id==='retryWebsiteScanBtn'&&selectedId)loadWebsiteScan(selectedId);if(e.target.id==='runDomainCheckBtn')runDomainCheck();if(e.target.id==='retryDomainBtn'&&selectedId)loadDomainIntelligence(selectedId);const availability=e.target.closest?.('[data-domain-availability]');if(availability)checkDomainAvailability(availability.dataset.domain,availability);if(e.target.id==='retryDetailBtn'&&selectedId)loadDetail(selectedId);if(e.target.id==='retryDedupBtn'&&selectedId)loadMatchSuggestions(selectedId);if(e.target.id==='retryHistoryBtn'&&selectedId)loadMergeHistory(selectedId);if(e.target.id==='cancelMergeBtn'||e.target.id==='cancelMergeBtnSecondary')closeMergeDialog();if(e.target.id==='confirmMergeBtn')confirmMerge();const mergeButton=e.target.closest?.('[data-merge-target]');if(mergeButton)openMergeDialog(mergeButton.dataset.mergeTarget,mergeButton.dataset.mergeTargetName);const reverseButton=e.target.closest?.('[data-reverse-merge]');if(reverseButton)reverseMerge(reverseButton.dataset.reverseMerge);if(e.target.id==='retryJobDetailBtn'&&selectedJobId)loadJobDetail(selectedJobId);if(e.target.id==='cancelJobBtn')jobAction('cancel');if(e.target.id==='retryJobBtn')jobAction('retry');const row=e.target.closest?.('[data-job-id]');if(row)loadJobDetail(row.dataset.jobId);}); $('loginForm').addEventListener('submit',login);$('logoutBtn').addEventListener('click',logout);$('searchInput').addEventListener('input',()=>{page=1;renderRows();});['scoreFilter','statusFilter','websiteClassFilter','pipelineFilter'].forEach(id=>$(id).addEventListener('change',()=>{page=1;loadData();}));$('pageSize').addEventListener('change',e=>{pageSize=Number(e.target.value);page=1;loadData();});$('nextPageBtn').addEventListener('click',()=>{if(hasNextPage){page+=1;loadData();}});$('refreshBtn').addEventListener('click',loadData);$('jobsRefreshBtn').addEventListener('click',()=>loadJobs());$('startDemoJobBtn').addEventListener('click',startDemoJob);$('sourcesRefreshBtn').addEventListener('click',loadSources);$('sourceForm').addEventListener('submit',saveSource);$('sourceType').addEventListener('change',e=>{$('sourceCsvField').hidden=e.target.value!=='csv';});$('discoveryForm').addEventListener('submit',e=>{e.preventDefault();runDiscovery(true);});$('discoveryRunBtn').addEventListener('click',()=>runDiscovery(false));$('sourcesList').addEventListener('click',e=>{const button=e.target.closest?.('[data-source-action]');if(button)sourceAction(button.dataset.sourceId,button.dataset.sourceAction);});$('addForm').addEventListener('submit',addProspect);$('csvInput').addEventListener('change',e=>{const file=e.target.files[0];if(file){const reader=new FileReader();reader.onload=()=>renderCsv(parseCsv(reader.result));reader.readAsText(file);}});$('menuBtn').addEventListener('click',()=>document.querySelector('.sidebar').classList.toggle('open'));document.querySelectorAll('[data-scroll]').forEach(b=>b.addEventListener('click',()=>document.querySelector(b.dataset.scroll)?.scrollIntoView())); $('savedFilterForm').addEventListener('submit',saveCurrentFilters);$('savedFilterSelect').addEventListener('change',e=>{ $('deleteSavedFilterBtn').disabled=!e.target.value;if(e.target.value)loadSavedFilter(e.target.value);});$('deleteSavedFilterBtn').addEventListener('click',deleteSavedFilter);$('reviewQueueState').addEventListener('change',e=>{const input=e.target.closest?.('[data-review-id]');if(input){if(input.checked)selectedReviewIds.add(String(input.dataset.reviewId));else selectedReviewIds.delete(String(input.dataset.reviewId));updateBulkState();}});$('selectAllReview').addEventListener('change',e=>{reviewQueue.forEach(p=>e.target.checked?selectedReviewIds.add(String(p.id)):selectedReviewIds.delete(String(p.id)));renderReviewQueue({items:reviewQueue,count:$('reviewQueueCount').textContent});});$('bulkVerifyBtn').addEventListener('click',()=>bulkReview('verify'));$('bulkRejectBtn').addEventListener('click',()=>bulkReview('reject'));$('reviewQueueState').addEventListener('click',e=>{if(e.target.id==='retryReviewQueueBtn')loadReviewQueue();});document.querySelectorAll('[data-dashboard-filter]').forEach(card=>card.addEventListener('click',()=>{const kind=card.dataset.dashboardFilter;if(kind==='review'||kind==='suppressed')applyFilters({status:kind});else if(kind==='high')applyFilters({score:'high'});else if(kind==='fresh')applyFilters({});else applyFilters({status:'all',score:'all'});})); - $('interactionForm').addEventListener('submit',saveInteraction);$('suppressionForm').addEventListener('submit',addSuppression);$('crmRefreshBtn').addEventListener('click',()=>{loadCrmPipeline();loadInteractions(selectedId);});$('reportsRefreshBtn').addEventListener('click',loadReports);$('suppressionRefreshBtn').addEventListener('click',loadSuppressions);$('pipelineViewToggle').addEventListener('click',()=>{crmListMode=!crmListMode;$('pipelineViewToggle').textContent=crmListMode?'▦ Board view':'☷ List view';$('pipelineViewToggle').setAttribute('aria-pressed',String(crmListMode));renderPipeline();});$('pipelineBoard').addEventListener('click',e=>{const selectButton=e.target.closest?.('[data-crm-select]');if(selectButton){selectedId=Number(selectButton.dataset.crmSelect);selectProspect(selectedId);loadInteractions(selectedId);$('crmActivity').scrollIntoView({behavior:'smooth',block:'start'});}const stageButton=e.target.closest?.('[data-crm-save-stage]');if(stageButton)saveCrmStage(stageButton.dataset.crmSaveStage);if(e.target.id==='retryCrmBtn')loadCrmPipeline();if(e.target.id==='retryInteractionsBtn'&&selectedId)loadInteractions(selectedId);});$('suppressionState').addEventListener('change',e=>{const input=e.target.closest?.('[data-suppression-id]');if(input){if(input.checked)selectedSuppressionIds.add(String(input.dataset.suppressionId));else selectedSuppressionIds.delete(String(input.dataset.suppressionId));updateSuppressionSelection();}});$('suppressionState').addEventListener('click',e=>{const button=e.target.closest?.('[data-remove-suppression]');if(button)removeSuppression(button.dataset.removeSuppression);if(e.target.id==='retrySuppressionsBtn')loadSuppressions();});$('selectAllSuppressions').addEventListener('change',e=>{suppressions.forEach(item=>e.target.checked?selectedSuppressionIds.add(String(item.id)):selectedSuppressionIds.delete(String(item.id)));renderSuppressions();});$('bulkReviewSuppressionsBtn').addEventListener('click',bulkReviewSuppressions); + $('interactionForm').addEventListener('submit',saveInteraction);$('suppressionForm').addEventListener('submit',addSuppression);$('crmRefreshBtn').addEventListener('click',()=>{loadCrmPipeline();loadInteractions(selectedId);});$('reportsRefreshBtn').addEventListener('click',loadReports);$('suppressionRefreshBtn').addEventListener('click',loadSuppressions);$('pipelineViewToggle').addEventListener('click',()=>{crmListMode=!crmListMode;$('pipelineViewToggle').textContent=crmListMode?'▦ Board view':'☷ List view';$('pipelineViewToggle').setAttribute('aria-pressed',String(crmListMode));renderPipeline();});$('pipelineBoard').addEventListener('click',e=>{const selectButton=e.target.closest?.('[data-crm-select]');if(selectButton){selectedId=Number(selectButton.dataset.crmSelect);selectProspect(selectedId);loadInteractions(selectedId);$('crmActivity').scrollIntoView({behavior:'smooth',block:'start'});}const stageButton=e.target.closest?.('[data-crm-save-stage]');if(stageButton)saveCrmStage(stageButton.dataset.crmSaveStage);if(e.target.id==='retryCrmBtn')loadCrmPipeline();if(e.target.id==='retryInteractionsBtn'&&selectedId)loadInteractions(selectedId);});$('suppressionState').addEventListener('change',e=>{const input=e.target.closest?.('[data-suppression-id]');if(input){if(input.checked)selectedSuppressionIds.add(String(input.dataset.suppressionId));else selectedSuppressionIds.delete(String(input.dataset.suppressionId));updateSuppressionSelection();}});$('suppressionState').addEventListener('click',e=>{const button=e.target.closest?.('[data-remove-suppression]');if(button)removeSuppression(button.dataset.removeSuppression);if(e.target.id==='retrySuppressionsBtn')loadSuppressions();});$('selectAllSuppressions').addEventListener('change',e=>{suppressions.forEach(item=>e.target.checked?selectedSuppressionIds.add(String(item.id)):selectedSuppressionIds.delete(String(item.id)));renderSuppressions();});$('bulkReviewSuppressionsBtn').addEventListener('click',bulkReviewSuppressions);$('outreachPolicyRefreshBtn').addEventListener('click',loadProviderPolicy); bootstrap(); })(); diff --git a/apps/web/index.html b/apps/web/index.html index 4bedddc..5cb7d1c 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -35,6 +35,7 @@ CRM pipeline Reports Suppressions + Outreach policy Score rules @@ -108,6 +109,7 @@

ADD RULE

Add suppression

Explicit confirmation required

REGISTER

Current suppressions

Sign in to load suppressions.
+

GOVERNANCE

Outreach provider policy

View approved provider status without exposing credentials or secrets.

Sending is disabled by default. This panel is status-only. Provider configuration never creates a send trigger, and no credentials are displayed.
Sign in to load provider policy.
diff --git a/apps/web/smoke-test.html b/apps/web/smoke-test.html index ccb3b19..6251d0f 100644 --- a/apps/web/smoke-test.html +++ b/apps/web/smoke-test.html @@ -61,5 +61,9 @@ frame.onload=async()=>{const d=frame.contentDocument;const js=await fetch('app.j ,['Phase 13 AI assistance panel and safety contract',()=>!!d.querySelector('[data-smoke="ai-assistance"]')&&!!d.querySelector('#generateAiSuggestionBtn')&&js.includes('/api/v1/ai-runs')&&js.includes('/api/v1/businesses/${encodeURIComponent(selectedId)}/ai/suggest')&&js.includes('Evidence-grounded suggestion')&&js.includes('No autonomous action')&&js.includes('citations')] ,['Phase 13 AI states, provider, and human decisions',()=>['not-configured','Unknown','Error','Suppressed','Provider:','Pending approval','Approve','Reject','human_approval','no outreach will be sent'].every(x=>js.includes(x))&&js.includes('Loading AI assistance')] ,['Phase 13 AI authenticated requests and responsive styles',()=>js.includes('/api/v1/ai-runs')&&js.includes('/ai/suggest')&&js.includes('autonomous_action:false')&&js.includes('ai-assistance-panel')&&js.includes('@media')] + ,['Phase 14 outreach preparation panel and draft-only contract',()=>!!d.querySelector('[data-smoke="outreach-provider-policy"]')&&js.includes('outreach-preparation')&&js.includes('/api/v1/outreach/drafts')&&js.includes('recipient_review_required:true')] + ,['Phase 14 citations, approval, audit, and no-send safety',()=>['Evidence citations','Recipient review','Evidence hash','Policy:','Approve draft','Approval does not send a message','Send unavailable','send:false','autonomous_action:false'].every(x=>js.includes(x))&&!js.includes('automaticSend')] + ,['Phase 14 provider policy states and secret-free settings',()=>!!d.querySelector('#providerPolicyPanel')&&js.includes('/api/v1/outreach/provider-config')&&['Configured','Enabled','Not displayed','Loading provider policy','auth-required','suppressed','error'].every(x=>js.includes(x))] + ,['Phase 14 responsive outreach styles',()=>js.includes('outreach-panel')&&js.includes('provider-policy-row')&&js.includes('@media')] ];let passed=0;document.querySelector('#checks').innerHTML=checks.map(([name,test])=>{const ok=test();if(ok)passed++;return `
  • ${ok?'PASS':'FAIL'} — ${name}
  • `}).join('');document.querySelector('#summary').textContent=`${passed}/${checks.length} checks passed`;}; diff --git a/apps/web/styles.css b/apps/web/styles.css index 71b30bb..c7b8dcd 100644 --- a/apps/web/styles.css +++ b/apps/web/styles.css @@ -12,3 +12,4 @@ .crm-section{margin-top:28px;scroll-margin-top:24px}.crm-header{display:flex;align-items:flex-start;justify-content:space-between;gap:18px}.crm-header h2{margin:.15rem 0 .25rem}.crm-actions{display:flex;gap:8px;flex-wrap:wrap}.crm-safety,.suppression-warning{margin:14px 0;padding:12px 15px;border:1px solid #dcd8ff;border-radius:9px;background:var(--violet-soft);color:#5145a7}.suppression-warning{border-color:#f1d7a5;background:var(--amber-soft);color:#76500d}.crm-message{min-height:22px;color:var(--green);padding:6px 2px}.crm-message.error{color:var(--red)}.pipeline-board{display:grid;grid-template-columns:repeat(4,minmax(180px,1fr));gap:12px;overflow-x:auto;align-items:start}.pipeline-column{background:#f1f2f8;border:1px solid var(--line);border-radius:10px;padding:10px;min-height:180px}.pipeline-column-head{display:flex;justify-content:space-between;align-items:center;margin-bottom:8px}.pipeline-column-head h3{margin:0;font-size:13px}.pipeline-card{background:#fff;border:1px solid var(--line);border-radius:9px;padding:10px;margin:8px 0;box-shadow:var(--shadow)}.pipeline-card.is-suppressed{border-color:#e9b8bd;background:#fffafa}.pipeline-card-link{display:grid;gap:3px;width:100%;border:0;background:none;text-align:left;color:inherit;padding:0;cursor:pointer}.pipeline-card-link small,.pipeline-card-link .score{font-size:11px;color:var(--muted)}.pipeline-card-actions{display:flex;gap:6px;margin-top:9px}.pipeline-card-actions select{min-width:0;flex:1;border:1px solid var(--line);border-radius:6px;padding:6px;font:inherit;font-size:12px}.pipeline-list-view{display:block}.pipeline-list{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:10px}.crm-column-empty,.crm-empty{color:var(--muted);font-size:12px;text-align:center;padding:20px 8px}.suppression-inline,.review-inline{font-size:11px;color:var(--red);margin:8px 0 0}.review-inline{color:var(--amber)}.crm-two-col{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(300px,.9fr);gap:18px}.crm-form{display:grid;gap:10px}.crm-form label{display:grid;gap:5px;font-size:12px;font-weight:650}.crm-form input,.crm-form select,.crm-form textarea{border:1px solid var(--line);border-radius:7px;padding:9px;font:inherit;font-weight:400}.crm-form textarea{resize:vertical}.crm-timeline{list-style:none;padding:0;margin:12px 0}.crm-timeline li{display:flex;gap:10px;border-top:1px solid var(--line);padding:12px 0}.crm-timeline li>div{display:grid;gap:4px;min-width:0}.crm-timeline p{margin:0;color:var(--muted)}.crm-timeline small{color:var(--muted);font-size:11px}.timeline-dot{width:9px;height:9px;flex:0 0 9px;margin-top:6px;border-radius:50%;background:var(--violet);box-shadow:0 0 0 4px var(--violet-soft)}.outcome-chip{font-size:11px;color:var(--violet);background:var(--violet-soft);border-radius:999px;padding:2px 7px;width:max-content}.reports-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:18px}.report-rows{display:grid;gap:8px}.report-rows div{display:flex;justify-content:space-between;gap:10px;border-top:1px solid var(--line);padding:9px 0}.report-rows strong{color:var(--violet)}.report-note{font-size:11px;color:var(--muted)}.suppression-bulk-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.suppression-bulk-actions .checkbox-label{margin:0}.suppression-list{display:grid}.suppression-row{display:flex;justify-content:space-between;gap:10px;align-items:center;border-top:1px solid var(--line);padding:11px 0}.suppression-row .checkbox-label{margin:0;flex:1}.suppression-row .checkbox-label span{display:grid;gap:2px;min-width:0}.suppression-row small{color:var(--muted);overflow-wrap:anywhere}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}@media(max-width:1000px){.pipeline-board{grid-template-columns:repeat(4,minmax(220px,1fr))}.reports-grid{grid-template-columns:1fr 1fr}}@media(max-width:700px){.crm-header{flex-direction:column}.crm-actions,.crm-actions .button{width:100%}.crm-actions .button{flex:1}.crm-two-col,.reports-grid{grid-template-columns:1fr}.pipeline-board{grid-template-columns:repeat(4,minmax(235px,1fr))}.pipeline-list{grid-template-columns:1fr}.suppression-row{align-items:flex-start}.suppression-bulk-actions{width:100%}} @media(max-width:700px){.saved-view-controls{grid-template-columns:1fr}.saved-view-controls .inline-form{display:flex}.saved-view-controls .inline-form input{flex:1}.queue-row{align-items:flex-start}.bulk-actions .button{flex:1}.metric-link{min-width:0}} .score-overview{margin-top:18px}.score-distribution-panel{min-width:0}.score-rules-section{margin-top:18px;scroll-margin-top:24px}.score-breakdown-panel{background:#fbfbff;border-radius:10px;padding:15px}.score-panel-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.score-panel-heading h4{margin:.1rem 0}.score-breakdown-summary{display:grid;grid-template-columns:1.4fr 1fr 1fr;gap:8px;margin:12px 0}.score-breakdown-summary>div,.score-meta div{border:1px solid var(--line);border-radius:8px;background:#fff;padding:10px}.score-breakdown-summary small,.score-breakdown-summary b{display:block}.score-breakdown-summary small,.score-meta dt{color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.05em}.score-breakdown-summary b{margin-top:4px}.score-total{display:block;font-size:28px;line-height:1.1;margin-top:3px}.score-total small{display:inline;font-size:12px;margin-left:3px}.score-meta{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:0}.score-meta dd{margin:3px 0 0}.score-rules-explanation{margin-top:13px}.score-rules-explanation h5{margin:0 0 7px}.score-rule-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px 8px;border-top:1px solid var(--line);padding:9px 0}.score-rule-row span{font-weight:650}.score-rule-row strong{color:var(--violet);font-size:12px}.score-rule-row small{grid-column:1 / -1;color:var(--muted)}.score-safety,.score-config-note{border:1px solid #dcd8ff;border-radius:8px;background:var(--violet-soft);color:#5145a7;padding:9px 11px;font-size:12px;margin:12px 0 0}.score-rule-table{border-top:1px solid var(--line);margin-top:12px}.score-config-row{display:grid;grid-template-columns:minmax(140px,1.4fr) .8fr .7fr .7fr auto;gap:10px;align-items:center;padding:11px 0;border-bottom:1px solid var(--line)}.score-config-row span{font-size:12px}.score-config-row span:nth-child(2){color:var(--green);font-weight:700}.score-config-row small{color:var(--muted)}.score-config-row button:disabled{opacity:.7;cursor:not-allowed}.score-distribution-list{display:grid;gap:10px;margin-top:12px}.distribution-row{display:grid;grid-template-columns:minmax(100px,1fr) 42px minmax(100px,2fr);gap:10px;align-items:center}.distribution-row strong{text-align:right}.distribution-track{height:8px;border-radius:99px;background:var(--line);overflow:hidden}.distribution-track i{display:block;height:100%;background:var(--violet);border-radius:inherit}@media(max-width:700px){.score-breakdown-summary{grid-template-columns:1fr 1fr}.score-breakdown-summary>div:first-child{grid-column:1 / -1}.score-config-row{grid-template-columns:1fr 1fr}.score-config-row strong{grid-column:1 / -1}.score-config-row button{justify-self:start}.distribution-row{grid-template-columns:minmax(95px,1fr) 34px minmax(80px,1fr)}} +.outreach-panel{background:#fbfbff;border:1px solid #ddd9ff;border-radius:10px;padding:15px}.outreach-panel-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.outreach-panel-heading h4{margin:.1rem 0}.outreach-safety,.outreach-settings-safety{border:1px solid #dcd8ff;border-radius:8px;background:var(--violet-soft);color:#5145a7;padding:9px 11px;font-size:12px;margin:10px 0}.outreach-draft{border:1px solid var(--line);border-radius:9px;background:#fff;padding:12px}.outreach-draft-head{display:flex;justify-content:space-between;gap:10px;align-items:flex-start}.outreach-status,.provider-status{border-radius:999px;padding:4px 8px;background:var(--amber-soft);color:var(--amber);font-size:11px;font-weight:750;margin-right:8px}.outreach-status.approved,.provider-status.enabled{background:var(--green-soft);color:var(--green)}.outreach-status.rejected,.outreach-status.blocked,.outreach-status.suppressed,.provider-status.error,.provider-status.not-configured{background:var(--red-soft);color:var(--red)}.recipient-review{display:grid;gap:3px;border:1px solid var(--line);border-radius:8px;padding:9px;margin:12px 0}.recipient-review span{font-weight:700;overflow-wrap:anywhere}.recipient-review small,.outreach-audit{color:var(--muted);font-size:11px}.outreach-copy{white-space:pre-wrap;border-top:1px solid var(--line);padding-top:10px}.outreach-citations{border-top:1px solid var(--line);padding-top:9px}.outreach-citations h5{margin:0}.outreach-citations ol{margin:7px 0;padding-left:1.25rem}.outreach-citations li{font-size:12px;margin:5px 0}.outreach-citations a{color:var(--violet)}.outreach-audit{display:flex;flex-wrap:wrap;gap:8px;border-top:1px solid var(--line);padding-top:9px}.outreach-state{padding:14px 4px;color:var(--muted)}.outreach-state strong,.provider-state strong{color:var(--ink)}.outreach-state.error strong,.outreach-state.suppressed strong{color:var(--red)}.provider-policy-panel{display:grid;gap:10px}.provider-policy-row{display:grid;grid-template-columns:minmax(160px,1fr) auto;gap:10px 18px;background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:14px}.provider-policy-row>div{display:flex;flex-direction:column;gap:3px;min-width:0}.provider-policy-row small,.provider-state{color:var(--muted)}.provider-policy-row dl{grid-column:1 / -1;display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin:0}.provider-policy-row dl div{border:1px solid var(--line);border-radius:7px;padding:8px}.provider-policy-row dt{font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--muted)}.provider-policy-row dd{margin:3px 0 0;font-size:12px}.provider-state{padding:22px 4px}@media(max-width:700px){.outreach-panel-heading,.outreach-draft-head{flex-direction:column}.outreach-panel-heading .button,.outreach-draft-head .button{width:100%}.provider-policy-row{grid-template-columns:1fr}.provider-policy-row dl{grid-template-columns:repeat(2,minmax(0,1fr))}.outreach-audit{display:grid;grid-template-columns:1fr}} diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index d8c5e10..9c6ac57 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -184,6 +184,18 @@ The operational path must not alter pipeline/interactions/outcomes as if communi On suspected provider misuse, data leakage, hallucinated/invented facts, prompt injection, cross-tenant exposure, unexpected outbound traffic, cost runaway, or suppression bypass: disable the AI capability/kill switch, stop affected jobs, preserve redacted evidence and audit metadata, revoke/rotate provider credentials, determine affected tenants and retention obligations, and require security/product/legal review before re-enabling. Production remains blocked until provider contracts/DPA, tenant-isolation, redaction, citation/hash, approval/rollback, evaluation, retention/deletion, monitoring, and recovery tests pass. +## Phase 14 draft-only outreach operations + +Treat Phase 14 as preparation, never as delivery. Verify `AUTOMATED_OUTREACH_ENABLED=false` in the rendered Compose configuration and confirm there is no provider credential, send route, delivery worker, campaign scheduler, SMTP probe, validation-mail path, or follow-up job. A draft or approval request is not a send and must not be reported as completion. + +Before any future enablement, record the provider allowlist entry, purpose/capability, tenant/data class, processing region/retention terms, timeout and payload bounds, per-tenant/global request/message/cost caps, health/circuit thresholds, approval owner/expiry, legal/consent policy version, and separate operational enablement. Load credentials only through the deployment secret manager; remove bootstrap values after provisioning and never print secrets in logs. A fallback is permitted only when separately approved for the same purpose and constraints. + +For each draft, inspect tenant and recipient scope, normalized suppression/do-not-contact, consent or documented legal basis, jurisdiction/channel policy, evidence IDs/citations and exact snapshot hash, observed/freshness times, uncertainty/conflicts, provider/policy versions, and approval status/expiry. Suppression is the final deny gate. Require an authorized human to approve the unchanged draft version and evidence hash; edits, stale/changed evidence, expired approval, provider/policy changes, or uncertain gates require re-review. Read back the approval audit event. Never use a high score, pipeline state, public address, or AI citation as consent or permission to contact. + +For any future side effect, require a tenant-scoped idempotency key and request fingerprint bound to draft/provider/policy versions. Verify exact replay returns the original result and conflicting reuse is rejected. Enforce caps before provider calls and across retries, fallbacks, workers, and tenants; use bounded backoff and circuit breaking. Monitor gate denials, suppression matches/bypass attempts, approval expiry, citation/hash mismatches, provider latency/errors/quota, cap consumption, retries, duplicate/conflicting keys, and audit append/readback failures. Record bounded per-item outcomes and never infer delivery from HTTP acceptance. + +On suspected suppression bypass, invalid consent/legal basis, cross-tenant exposure, secret leakage, unexpected outbound traffic, provider misuse, cost runaway, citation/hash mismatch, or audit gap: disable the capability/kill switch, stop affected jobs, preserve redacted evidence and audit metadata, revoke/rotate credentials, identify affected tenants and legal-retention obligations, and require product/legal/security review before re-enabling. Production remains blocked until provider/DPA and legal review, durable approval/idempotency/audit, suppression synchronization, delivery feedback, retention/deletion/legal-hold verification, rollback, and no-send integration tests pass. The current Compose/MVP has none of these live outreach components. + ## Incident checklist 1. Record time, affected service, image/config revision, and observed health state. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 9c66412..603dd4f 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -110,6 +110,19 @@ Phase 11 is present in the current Compose source with durable saved-filter stor Phase 12 is not production-ready until transition and outcome invariants, suppression precedence at every boundary, report reproducibility/timezone semantics, export authorization, retention/deletion, idempotent retry, and cross-tenant isolation are covered by integration tests and operational monitoring. +## Phase 14 draft-only outreach security controls + +- Keep outreach preparation deny-by-default. The current Compose setting `AUTOMATED_OUTREACH_ENABLED=false` is a defense-in-depth control; enforce no-send behavior in server authorization and provider adapters as well. Draft creation, approval, retry, or queue acceptance must never be described as sending. +- Treat recipient, channel, consent/legal basis, jurisdiction, suppression, evidence freshness, provider approval, and content policy as independent server-side gates. Suppression/do-not-contact is unconditional and must be checked before evidence selection, draft persistence, display/export, approval, queueing, and any future send attempt. Public data, score, pipeline state, verification, or AI confidence is not consent, lawful basis, deliverability, or contact permission. +- Register providers through an allowlist with purpose/capability, tenant/data-class scope, region/egress and retention terms, timeout/payload limits, per-tenant/global request/message/cost caps, circuit policy, approval owner/expiry, and operational enablement. Fallback must preserve all constraints and be separately approved. Provider outage, quota, timeout, policy denial, circuit-open, or approval expiry fails closed. +- Store provider credentials only as secret-manager references. Never accept secrets from browser clients or persist them in source, Compose, images, committed `.env`, prompts, responses, logs, audit events, metrics, or error messages. Define rotation, revocation, access review, and a kill switch for suspected misuse or leakage. +- Bind each draft to tenant/recipient/channel, a bounded redacted content version or hash, cited evidence IDs/source references, the exact evidence snapshot hash, observed/freshness times, policy/provider versions, and gate results. Hashes prove lineage to a snapshot, not truth. Reject missing, stale, conflicting, suppressed, or changed evidence; never fill gaps with invented claims. +- Require explicit authorized human approval of the exact draft/evidence/policy hash. Approval must re-check tenant scope, suppression, legal/consent state, freshness, provider approval, and permissions; edits or policy/evidence changes invalidate approval. Record actor, time, reason, expiry, rejection, before/after status, and correlation ID in append-only/tamper-evident audit. Approval is not delivery authorization. +- Require tenant-scoped idempotency keys for future side effects, bound to request fingerprint, draft version, recipient, provider, and policy. Exact replay returns the original bounded result; conflicting reuse is rejected. Apply request/message/cost caps before attempts and across retries, fallbacks, workers, and tenants; do not bypass caps by rotation or unbounded retry. +- Audit all gate outcomes, suppression decisions, draft versions, citation/hash checks, approvals/rejections/expiry, provider attempts, cap denials, retries, and delivery outcomes with safe per-item results. Redact full content, contact values, prompts, legal notes, and secrets where a stable identifier/fingerprint suffices. Preserve deletion/legal-hold semantics without deleting required suppression/audit history. + +Phase 14 is preparation-only in this repository: no draft/send route, provider, consent ledger, legal-policy engine, durable approval workflow, delivery feedback, or secret manager is enabled. Production requires jurisdiction-specific legal review and documented lawful-basis/consent policy, provider contracts/DPA, durable transactional idempotency, suppression synchronization, rate/cost monitoring, bounce/complaint handling, rollback/revocation, incident response, retention/deletion verification, and no-send/cross-tenant/citation/hash integration tests. + ## Known limitations before production 1. **Password storage:** production passwords must be hashed with Argon2id using a reviewed cost/memory/parallelism policy. Never store plaintext or reversible passwords, and never log bootstrap credentials. Rehash on login when the policy changes.