feat(api): CRM intake and marketing contracts, double opt-in, signed hooks #9

Merged
KofTwentyTwo merged 7 commits from feature/intake-api into develop 2026-10-14 17:48:49 +00:00
KofTwentyTwo commented 2026-10-14 17:46:12 +00:00 (Migrated from github.com)

Summary

P3 (intake and contracts) of the qbit-crm 0.2.0 design:

  • Public package com.kingsrook.qbits.crm.api, the only supported surface:
    • CrmIntakeApi:
      • submitForm: stores the submission, links or creates the contact, sets first- and last-touch UTM attribution, records consent, logs an activity, auto-assigns the owner and sends a crm.lead.received notification
      • subscribe, confirm, unsubscribe and unsubscribeByContact, with double opt-in by default
    • CrmMarketingApi: the contract agreed with qbit-marketing: resolveRecipients, checkEmailEligibility, listFormSubmissions, recordActivity(kind), recordUnsubscribe, buildUnsubscribeUrl and recipientScopeOptions
    • CrmApi: isInstalled and readiness
  • Consent rules:
    • A contact form never grants marketing consent unless the visitor explicitly opts in, and the exact wording is stored as evidence.
    • An explicit re-subscribe clears an earlier global unsubscribe. Bounce and complaint suppression (doNotEmail) is never cleared.
  • Write permissions: intake runs as trusted host work after the host validates the public request, so permission enforcement is skipped and the reason is recorded. The marketing API runs with the caller's permissions.
  • CrmEmailTransport: qbit-email's EmailApi.sendOnce first (an optional dependency allowed by G1), then core SendMessageAction.
  • CrmTokens: HMAC-signed confirm and unsubscribe tokens that expire after 90 days. Only token hashes are stored.
  • Hook routes: /qqq/hooks/crm/confirm/{token} and /qqq/hooks/crm/unsubscribe/{token}. A GET shows a page; only a POST button acts, so mail scanners cannot unsubscribe anyone.
  • Schema:
    • attribution fields on contacts
    • list keys and double opt-in fields
    • consent evidence
    • activity-type codes
    • no raw IP addresses or user agents are stored
  • Alerts: crm.lead.received, crm.sequence.failing and crm.email.unavailable.

Codex implemented this; Claude reviewed it. Review fixes: trusted intake writes; eligibility no longer blocked by another list's status; enum ids instead of magic numbers; re-subscribe; last-touch only when the request carries attribution; auto-assign.

Test plan

  • ./mvnw verify: 412 tests, 0 failures; checkstyle and guardrails pass; JaCoCo 91% instructions
  • Eligibility matrix, attribution, double opt-in end to end, token tampering and expiry, and Javalin route tests

🤖 Generated with Claude Code

## Summary P3 (intake and contracts) of the qbit-crm 0.2.0 design: - **Public package `com.kingsrook.qbits.crm.api`**, the only supported surface: - **`CrmIntakeApi`:** - `submitForm`: stores the submission, links or creates the contact, sets first- and last-touch UTM attribution, records consent, logs an activity, auto-assigns the owner and sends a `crm.lead.received` notification - `subscribe`, `confirm`, `unsubscribe` and `unsubscribeByContact`, with double opt-in by default - **`CrmMarketingApi`:** the contract agreed with qbit-marketing: `resolveRecipients`, `checkEmailEligibility`, `listFormSubmissions`, `recordActivity(kind)`, `recordUnsubscribe`, `buildUnsubscribeUrl` and `recipientScopeOptions` - **`CrmApi`:** `isInstalled` and `readiness` - **Consent rules:** - A contact form never grants marketing consent unless the visitor explicitly opts in, and the exact wording is stored as evidence. - An explicit re-subscribe clears an earlier global unsubscribe. Bounce and complaint suppression (`doNotEmail`) is never cleared. - **Write permissions:** intake runs as trusted host work after the host validates the public request, so permission enforcement is skipped and the reason is recorded. The marketing API runs with the caller's permissions. - **`CrmEmailTransport`:** qbit-email's `EmailApi.sendOnce` first (an optional dependency allowed by G1), then core `SendMessageAction`. - **`CrmTokens`:** HMAC-signed confirm and unsubscribe tokens that expire after 90 days. Only token hashes are stored. - **Hook routes:** `/qqq/hooks/crm/confirm/{token}` and `/qqq/hooks/crm/unsubscribe/{token}`. A GET shows a page; only a POST button acts, so mail scanners cannot unsubscribe anyone. - **Schema:** - attribution fields on contacts - list keys and double opt-in fields - consent evidence - activity-type codes - no raw IP addresses or user agents are stored - **Alerts:** `crm.lead.received`, `crm.sequence.failing` and `crm.email.unavailable`. Codex implemented this; Claude reviewed it. Review fixes: trusted intake writes; eligibility no longer blocked by another list's status; enum ids instead of magic numbers; re-subscribe; last-touch only when the request carries attribution; auto-assign. ## Test plan - [x] `./mvnw verify`: 412 tests, 0 failures; checkstyle and guardrails pass; JaCoCo 91% instructions - [x] Eligibility matrix, attribution, double opt-in end to end, token tampering and expiry, and Javalin route tests 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.