Skip to content

Add a compliance footer and an opt-in Tellwing handoff for yes-replies - #13

Open
cvmilocrz wants to merge 1 commit into
ethanplusai:mainfrom
cvmilocrz:tellwing-handoff
Open

cvmilocrz wants to merge 1 commit into
ethanplusai:mainfrom
cvmilocrz:tellwing-handoff

Conversation

@cvmilocrz

@cvmilocrz cvmilocrz commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

Disclosure: I build Tellwing, the email platform this integrates with. It is off by default, and the compliance and quoted-text parts of this PR stand on their own without it.

Why

Three gaps showed up while setting Harvey up for real outreach:

  1. No postal address or opt-out line in cold email. The README says to add a postal address, but nothing adds or checks one. CAN-SPAM requires both.
  2. Replies are classified with the quoted original still in them. Anything Harvey wrote can trigger an opt-out or an escalation from inside a quote. With a footer that says reply "no thanks", every reply would read as an opt-out.
  3. A "yes" has nowhere to go. When a prospect says yes to hearing more, Harvey has no way to move them, with their consent, into something built for ongoing email.

What

Compliance footer

  • The footer. compliance.postal_address and compliance.opt_out_line are appended to every sequence email at send time. Replies don't get it.
  • Held, not failed. Until the address is set, cold email waits in the Outbox. It goes out once the address is configured.
  • The gate checks it. The pre-send gate verifies the address is in the final body.
  • Opt-out wording. "no thanks" / "no thank you" now count as opt-outs, matching the footer.

Quoted-text stripping

strip_quoted_reply cuts the quote before intent is decided: On … wrote: (Gmail/Apple, wrapped too), Outlook's From:/Sent: block, -----Original Message-----, underscore separators and > lines.

Optional Tellwing handoff (tellwing.enabled: false by default)

Cold email never goes through Tellwing. Tellwing only gets people who said yes:

  • One question, asked once. An interested reply gets one permission question appended (configurable), once per person, and only to a verified or catch-all address.
  • A yes counts only if the question went out. The outbox item carrying the question must actually have been sent, with the question still in its body. If you rejected or edited it in the Outbox, there's nothing to answer.
  • Their own words. The model must return the prospect's exact sentence. If that sentence isn't in the reply verbatim, the reply itself is stored instead.
  • Sync. Granted consents go to POST /v1/lists/{id}/subscribers with consent_source: api_other, the verbatim consent_text and one Idempotency-Key per person. resubscribe is never sent, so anyone who unsubscribed stays unsubscribed.
  • Opt-outs win. An opt-out withdraws a pending yes. An opt-out after subscribing is escalated to a human rather than deleted automatically.
  • State. Consent lives in a new tellwing_consent table (migration v12).
  • The landing link. tellwing.landing_url becomes the only link a cold email may carry: the writer emits {{landing_url}} and the gate blocks any other URL.

Details: docs/tellwing.md.

Live example: tellwing.site/cvmilo/websites is the landing page a cold email links to through {{landing_url}} in a real setup (websites for home-service contractors). Sign-ups there go through Tellwing's own form, which records its own consent. Harvey only adds the people who said yes by email.

Behaviour change to note

Cold email is held until compliance.postal_address is set. Existing installs will see their sequence emails wait in the Outbox after upgrading, with a log line saying why. That seemed better than silently sending without the address CAN-SPAM requires. Happy to make it a warning instead if you prefer.

Testing

  • tests/test_tellwing.py: 15 tests covering:
    • quote stripping, and the quoted footer not opting anyone out
    • asking once, and never asking a guessed address
    • an unsent question not being answerable
    • "no" being final
    • an invented quote falling back to the real reply
    • opt-out withdrawing a pending yes
    • Tellwing refusals and retries
    • the gate's address and landing-link checks
    • the exact sent body, and holding without an address
  • Updated test_outbox_native.py / test_codex_e2e.py configs with a postal address.
  • Full suite: 441 passed. Two failures:
    • test_existing_usage_rows_become_claude fails the same way on main.
    • test_paused_brain_is_visible_on_the_dashboard passes on its own; it failed only while another suite ran in parallel.

🤖 Generated with Claude Code

Compliance footer. compliance.postal_address and compliance.opt_out_line
are appended to every sequence email at send time (CAN-SPAM requires a
postal address and a working opt-out). Until the address is set, cold
email is held in the Outbox rather than failed. The pre-send gate checks
the address is in the final body. "no thanks" now counts as an opt-out,
matching the footer.

Quoted text. Replies were classified with the quoted original still in
them. With a footer saying 'reply "no thanks"', every reply would have
read as an opt-out. strip_quoted_reply cuts the quote (On … wrote:,
Outlook's From:/Sent: block, ">" lines) before intent is decided.

Tellwing handoff (off by default). Cold email never goes through
Tellwing. An interested reply gets one permission question appended.
A later yes counts only if:

- the outbox item carrying the question was actually sent,
- the address is verified or risky, never a guess,
- the quote is the prospect's own words, found verbatim in their reply
  (otherwise the reply itself is stored).

Granted consents sync each cycle to POST /v1/lists/{id}/subscribers
with consent_source api_other, the verbatim consent_text, and one
Idempotency-Key per person; resubscribe is never sent. An opt-out
withdraws a pending yes. Consent state lives in a new tellwing_consent
table (migration v12). tellwing.landing_url becomes the only link a cold
email may carry ({{landing_url}}, enforced by the gate).

Documented in docs/tellwing.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant