Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ IMAP_PASSWORD=
# API access requires Instantly's Growth plan or higher.
INSTANTLY_API_KEY=

# ------------------------------------------------------------
# Tellwing — newsletters for people who said yes [OPTIONAL]
# Harvey never sends cold email through Tellwing. With tellwing.enabled in
# harvey.yaml, it adds a prospect to tellwing.list_id only after they reply
# yes to the permission question, sending their exact words as consent_text.
# Where to get it: https://app.tellwing.com/settings/api → a key with ONLY
# the subscribers:write scope (API access needs Tellwing's Growth plan).
TELLWING_API_KEY=

# ------------------------------------------------------------
# LinkedIn — browser-automation prospecting [OPTIONAL]
# These are the credentials of the LinkedIn account Harvey will log into
Expand Down
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,35 @@ Notable changes to Harvey. Dates are release dates; the format follows
follow [semver](https://semver.org/), with the caveat that Harvey is pre-1.0 and
minor versions can still change behaviour.

## [Unreleased]

### Added

- **Compliance footer on cold email.** `compliance.postal_address` and
`compliance.opt_out_line` are appended to every sequence email at send time
(CAN-SPAM). The pre-send gate checks the address is there.
- **Optional Tellwing handoff.** With `tellwing.enabled`, an interested reply
gets one permission question ("mind if I send you a short case study now and
then?"). Only a verified address whose owner explicitly says yes, to a
question that was actually sent, is added to a Tellwing list, with their
verbatim words as `consent_text`. It is idempotent per person and never
resubscribes. `tellwing.landing_url` becomes the only link a cold email may
carry. See [docs/tellwing.md](docs/tellwing.md).

### Changed

- **Cold email is held until `compliance.postal_address` is set.** It waits in
the Outbox; it isn't failed. Replies are unaffected.
- "no thanks" / "no thank you" replies are opt-outs, matching the footer's
instruction.

### Fixed

- **Reply intent is read from the prospect's own words only.** The quoted
original (`On … wrote:`, Outlook's `From:/Sent:` block, `>` lines) is cut
before classification, so text quoted from Harvey's email can't trigger an
opt-out or an escalation.

## [0.3.0] — 2026-10-13

### Added
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,9 @@ harvey outbox --approve-all
harvey sending pause # kill switch, stops everything mid-flight
```

Before anything leaves, a **deterministic pre-send gate** — no model involved — rejects: unrendered merge tags, banned phrases, emails over the length cap, too many links, HTML bodies, non-deliverable addresses, and any mismatch between the recipient and the database record.
Before anything leaves, a **deterministic pre-send gate** — no model involved — rejects: unrendered merge tags, banned phrases, emails over the length cap, too many links, HTML bodies, non-deliverable addresses, any mismatch between the recipient and the database record, and a cold email without your postal address in its footer.

**Optional: hand the yeses to a newsletter tool.** When a prospect replies with interest, Harvey can ask once whether they'd like an occasional case study, and add only the ones who say yes to a [Tellwing](https://tellwing.com) list, with their exact words recorded as consent. Cold email never goes through it. See [docs/tellwing.md](docs/tellwing.md).

When you trust it, set `channels.email.require_approval: false` for full autopilot.

Expand Down Expand Up @@ -455,7 +457,7 @@ What to know:

Harvey automates outreach, but **you are the sender.** Cold email is legal in most places when done right and expensive when done wrong — CAN-SPAM penalties run to $53,088 per email. Harvey ships with compliant defaults. Keep them.

**CAN-SPAM (US).** Truthful subject line and accurate from-address — Harvey's copywriting rules forbid fake "re:" threads and impersonation. A working opt-out, honored fast: Harvey treats any opt-out wording as immediate and permanent. Your physical mailing address in the footer — configure this before your first campaign.
**CAN-SPAM (US).** Truthful subject line and accurate from-address — Harvey's copywriting rules forbid fake "re:" threads and impersonation. A working opt-out, honored fast: Harvey treats any opt-out wording as immediate and permanent. Your physical mailing address in the footer: set `compliance.postal_address`, and Harvey appends it with an opt-out line to every cold email. Until it's set, cold email is held in the Outbox.

**GDPR / PECR (EU & UK).** B2B cold email needs a defensible legitimate interest: the pitch must be genuinely relevant to that person's role, you must know where the data came from, and objecting must be effortless. If you can't say why a specific person would care, Harvey shouldn't email them — and its qualification rules say so.

Expand Down
120 changes: 120 additions & 0 deletions docs/tellwing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
# Cold email footer, and handing yes-replies to Tellwing

Two related features:

1. **The compliance footer.** Every cold (sequence) email carries a postal
address and an opt-out line.
2. **The Tellwing handoff.** A prospect who explicitly says yes to occasional
email is added to a [Tellwing](https://tellwing.com) list, with their own
words recorded as consent.

## The footer (CAN-SPAM)

```yaml
compliance:
postal_address: "123 Main St, Austin, TX 78701"
opt_out_line: 'Not interested? Reply "no thanks" and I won''t email again.'
```

- **When it's added.** The footer is appended at send time to sequence
emails only. Replies to someone who wrote to you don't get it.
- **Without an address, nothing goes out.** If `postal_address` is empty,
cold email is **held** in the Outbox, not failed. It goes out once the
address is set.
- **The gate enforces it.** The deterministic pre-send gate rejects a
sequence email whose final body doesn't contain the address.
- **What counts as an opt-out.** A reply of "no thanks" or "no thank you"
is an immediate, permanent opt-out, alongside "unsubscribe", "remove me"
and the rest.

### Quoted text never decides intent

Before a reply is classified, Harvey cuts the quoted original out of it
(`On … wrote:`, Outlook's `From:/Sent:` block, `>` lines). Otherwise every
reply would quote the footer's "no thanks" and read as an opt-out.

## The Tellwing handoff

Tellwing never sends cold email. Its terms forbid scraped, guessed or bought
addresses, and its sending infrastructure is shared. Cold email goes out from
your own mailbox. Tellwing only gets people who said yes.

```yaml
tellwing:
enabled: true
list_id: 12 # numeric Tellwing list id
landing_url: "https://tellwing.site/you/offer" # optional
permission_question: "Separately, mind if I send you a short case study now and then? A simple yes or no is fine."
```

`.env`: `TELLWING_API_KEY=tw_live_…`. Give the key **only** the
`subscribers:write` scope. The API needs Tellwing's Growth plan.

### The flow

1. **Landing page.** If `landing_url` is set, it's the only link a cold
email may carry. The writer types `{{landing_url}}` and Harvey fills it
in. The gate blocks any other URL. Sign-ups there are Tellwing's own
`website_form` consent.
2. **The question is asked once.** When a reply is classified as
*interested*, the permission question is appended to Harvey's answer.
Only once per person, and only to a verified address.
3. **The answer only counts if the question was sent.** A later reply is
checked for a yes only if the outbox item that asked it was actually
**sent**, with the question still in its body. If you rejected or edited
it in the Outbox, there's nothing to answer.
4. **A yes needs their own words.** The model must return the exact sentence
where they agree. If that sentence isn't in the reply verbatim, Harvey
stores the reply itself instead. Invented words are never stored.
5. **Sync.** Each cycle, granted consents are sent to:

```
POST https://api.tellwing.com/v1/lists/{list_id}/subscribers
```

- The body has `consent_source: "api_other"`, the consent timestamp, and
`consent_text` with the question and their verbatim answer.
- Each person keeps one `Idempotency-Key`, so retries never duplicate.
- `resubscribe` is never sent, so anyone who unsubscribed from Tellwing
stays unsubscribed.

### Who is never added

- people who didn't reply
- people who said no to the question, or weren't interested
- people who opted out, at any point (an opt-out withdraws a pending yes)
- addresses that aren't `verified` or `risky` (never a guess)

If someone opts out **after** joining the Tellwing list, Harvey doesn't
delete them from Tellwing. It logs an escalation so you can unsubscribe
them there.

### Consent states

Stored in the `tellwing_consent` table (migration v12), one row per prospect:

| Status | Meaning |
|---|---|
| `asked` | the question was queued |
| `granted` | they said yes; waiting to sync |
| `subscribed` | added to Tellwing (or already there, left as it was) |
| `declined` | said no, not interested, or opted out. Final. |
| `blocked` | Tellwing refused (`recipient_unsubscribed` / `recipient_suppressed`), or the address isn't deliverable |

How sync failures are handled:

- **Network errors, 429 and 5xx** keep the row `granted` and retry next
cycle (`attempts`, `last_error`).
- **Setup errors** (`unauthorized`, `forbidden_scope`, `plan_required`,
`not_found`) are logged loudly and retried once fixed.

## Files

| File | What it is |
|---|---|
| `harvey/integrations/tellwing.py` | the one-endpoint Tellwing client |
| `harvey/agents/handler.py` | asking, reading the answer, sync |
| `harvey/agents/sender.py` | footer and `{{landing_url}}` at send time |
| `harvey/gate.py` | postal address and landing-link checks |
| `harvey/integrations/mail_provider.py` | `strip_quoted_reply` |
| `tests/test_tellwing.py` | the whole flow, Tellwing faked with `httpx.MockTransport` |
17 changes: 17 additions & 0 deletions harvey.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,23 @@ channels:
max_daily_connections: 20
max_daily_messages: 10

# Every cold email gets these two lines underneath (CAN-SPAM). Cold email is
# held until postal_address is set: a street address or a PO box / virtual
# mailbox you can receive mail at.
compliance:
postal_address: ""
opt_out_line: 'Not interested? Reply "no thanks" and I won''t email again.'

# Tellwing never sends cold email. Its landing page is the only link cold
# email may carry (written as {{landing_url}}), and someone is added to the
# Tellwing list (TELLWING_API_KEY in .env) only after replying yes to
# permission_question, which Harvey asks once, of interested replies.
tellwing:
enabled: false
list_id: 0 # the Tellwing list's numeric id
landing_url: "" # e.g. https://tellwing.site/you/offer
permission_question: "Separately, mind if I send you a short case study now and then? A simple yes or no is fine."

usage:
max_daily_claude_percent: 80
heartbeat_interval_minutes: 15
Expand Down
Loading