Skip to content
Merged
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
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,12 @@ HUNTER_API_KEY=
# tier; set this to your plan's allowance if you pay for Hunter.
HUNTER_MONTHLY_SEARCHES=25

# Precleared — per-recipient cold-email compliance check [OPTIONAL]
# Only used when compliance.precleared.enabled is true in harvey.yaml.
# Without a key: 10 checks a day. Free key: 25 a day. Paid packs beyond that.
# Where to get it: https://precleared.dev/get-key
PRECLEARED_API_KEY=

# ------------------------------------------------------------
# DISCOVERY — where Harvey finds businesses. [ALL OPTIONAL]
# The default source (OpenStreetMap) needs no account at all:
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,21 @@ 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

- **Optional per-recipient compliance check (Precleared).** Set
`compliance.precleared.enabled: true` with `sender_country: GB` or `US` to
have each new recipient checked before the first cold email against UK
PECR, US CAN-SPAM, Canada's CASL, Germany's UWG and France's rules. Any
verdict but `ok_to_email` fails that outbox item with the rule and the next
step. If the check can't be made (daily allowance used up, network down, a
malformed response), cold emails wait for a later cycle while replies still
go out. One check per address, cached for 30 days; changing the sender
country, address source or product description triggers a fresh check. The
receipt id is logged with each send. Native providers only. Off by default.

## [0.3.0] — 2026-10-13

### Added
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,8 @@ 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.

Optionally, the same step can ask [Precleared](https://precleared.dev) whether cold email to each new address is allowed before the first one goes out. See [Legal and deliverability](#legal-and-deliverability).

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

---
Expand Down Expand Up @@ -459,6 +461,8 @@ Harvey automates outreach, but **you are the sender.** Cold email is legal in mo

**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.

**Per-recipient check (optional).** Those rules turn on facts about each recipient: a UK sole trader needs consent where a limited company doesn't, Germany wants prior consent even for business email, and Canada's CASL depends on whether the address was published. With `compliance.precleared.enabled: true`, Harvey asks [Precleared](https://precleared.dev) about each new recipient before the first cold email. Anything other than `ok_to_email` holds that email back, with the rule and the next step shown in the Outbox, and the signed receipt id is logged with every send. One check per address, cached for 30 days (changing the sender country, address source or product description triggers a fresh check); replies are never checked; native providers (Gmail, SMTP) only. Precleared receives the recipient's address, your sender country, the address source and your product description. Without a key it allows 10 checks a day, and a free key (`PRECLEARED_API_KEY`) allows 25. Put the setting in `harvey.yaml`, or in `harvey.local.yaml` if you trained Harvey, since that file wins.

**Bot disclosure.** Some jurisdictions require disclosing automation. Harvey is instructed to answer truthfully, always, if a prospect asks whether they're talking to an AI. Never configure it otherwise.

**LinkedIn.** Browser automation violates LinkedIn's ToS and can get the account restricted. Off by default. If you turn it on, use an account you can afford to lose.
Expand Down
12 changes: 12 additions & 0 deletions harvey.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,15 @@ usage:
start: "22:00"
end: "07:00"
timezone: "America/New_York"

# Optional: check each new recipient with Precleared (precleared.dev) before
# the first cold email: may cold marketing email go to this address under UK
# PECR, US CAN-SPAM, Canada's CASL, Germany's UWG and France's rules? Any other
# answer holds the email back, with the reason in the Outbox. One check per
# address; 10 a day free without a key, 25 with a free key (PRECLEARED_API_KEY
# in .env). Applies to the native providers (gmail, smtp).
compliance:
precleared:
enabled: false
sender_country: "" # GB or US: where your business is based
address_source: "unknown" # published | bought | scraped | given | unknown
60 changes: 56 additions & 4 deletions harvey/agents/sender.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from harvey.gate import pre_send_check
from harvey.integrations.instantly import InstantlyClient
from harvey.integrations.mail_provider import NATIVE_PROVIDERS, get_mail_provider
from harvey.integrations.precleared import PreclearedClient
from harvey.state import StateManager

logger = logging.getLogger("harvey.sender")
Expand Down Expand Up @@ -62,6 +63,8 @@ def __init__(
self.config = config
self.instantly = InstantlyClient(env.instantly_api_key)
self.provider = get_mail_provider(config, env)
# Optional per-recipient compliance check; None unless enabled.
self.precleared = PreclearedClient.from_config(config, env)
# Disabled in tests to skip inter-send sleeps.
self.send_pacing = True

Expand Down Expand Up @@ -437,14 +440,36 @@ async def _drain_due(self):
due = await self.state.get_outbox(
status="approved", due_before=now, limit=budget * 3
)
if self.precleared is not None:
# Cold emails held by an unavailable compliance check stay
# approved at the front of the queue and could fill the page
# above, so fetch replies (and any other unchecked kind) on their
# own too. Bounded: one extra query of the same size.
seen = {item["id"] for item in due}
unchecked = await self.state.get_outbox(
status="approved", due_before=now, limit=budget * 3,
exclude_kind="sequence",
)
extra = [item for item in unchecked if item["id"] not in seen]
if extra:
due = sorted(
due + extra,
key=lambda i: (i.get("send_at") or "", i.get("created_at") or ""),
)
if not due:
return

allow_risky = getattr(self.config.channels.email, "send_to_risky", False)
sent = 0
# Set when Precleared can't be asked (allowance used up, network):
# cold emails wait for a later cycle, replies still go out.
compliance_paused = False
for item in due:
if sent >= budget:
break
needs_check = self.precleared is not None and item["kind"] == "sequence"
if needs_check and compliance_paused:
continue

prospect = await self.state.get_prospect(item["prospect_id"])
if prospect is None:
Expand Down Expand Up @@ -476,6 +501,30 @@ async def _drain_due(self):
)
continue

# Optional compliance check (compliance.precleared): one per
# address, before the first cold email. Replies are never checked.
receipt_id = ""
if needs_check:
clearance = await self.precleared.clear(item["to_email"], self.state)
if clearance.held:
compliance_paused = True
logger.warning(
f"Sender: compliance check unavailable, holding cold "
f"email until a later cycle: {clearance.reason}"
)
continue
if not clearance.allowed:
await self.state.update_outbox_item(
item["id"], status="failed",
error=f"precleared: {clearance.verdict}: {clearance.reason}"[:300],
)
logger.warning(
f"Sender: Precleared held back email to {item['to_email']}: "
f"{clearance.verdict} ({clearance.reason})"
)
continue
receipt_id = clearance.receipt_id

result = await self.provider.send_email(
item["to_email"], item["subject"], item["body"],
thread_ref=item.get("thread_ref", ""),
Expand Down Expand Up @@ -510,13 +559,16 @@ async def _drain_due(self):
)
except Exception as e:
logger.debug(f"Sender: convo append failed: {e}")
details = {
"to": item["to_email"], "step": item["step"],
"kind": item["kind"], "provider": self.provider.name,
}
if receipt_id:
details["precleared_receipt"] = receipt_id
await self.state.log_action(
action_type="email_sent",
agent="sender",
details={
"to": item["to_email"], "step": item["step"],
"kind": item["kind"], "provider": self.provider.name,
},
details=details,
)
logger.info(
f"Sender: sent step {item['step']} to {item['to_email']} "
Expand Down
57 changes: 56 additions & 1 deletion harvey/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import yaml
from dotenv import load_dotenv
from pydantic import BaseModel, ValidationError, field_validator
from pydantic import BaseModel, ValidationError, field_validator, model_validator

from harvey.paths import PROJECT_ROOT

Expand Down Expand Up @@ -105,6 +105,57 @@ class ChannelsConfig(BaseModel):
linkedin: LinkedInChannelConfig = LinkedInChannelConfig()


PRECLEARED_ADDRESS_SOURCES = ("published", "bought", "scraped", "given", "unknown")


class PreclearedConfig(BaseModel):
"""Optional per-recipient cold-email check through Precleared.

Off by default. When on, each new recipient is checked once, before the
first cold email, against UK PECR, US CAN-SPAM, Canada's CASL, Germany's
UWG and France's rules. Any verdict other than ok_to_email holds the
email back with the reason. See harvey/integrations/precleared.py.
"""

enabled: bool = False
# Where your business is based: GB or US. Required when enabled.
sender_country: str = ""
# Where prospect addresses come from, in Precleared's terms. Harvey
# builds most addresses from a learned pattern, so the honest default
# is "unknown"; use "published" only if yours are copied from the
# businesses' own websites.
address_source: str = "unknown"

@field_validator("sender_country", mode="before")
@classmethod
def _upper_country(cls, v) -> str:
return str(v or "").strip().upper()

@field_validator("address_source", mode="before")
@classmethod
def _valid_source(cls, v) -> str:
v = str(v or "unknown").strip().lower()
if v not in PRECLEARED_ADDRESS_SOURCES:
raise ValueError(
f"'{v}' is not a valid address_source. "
f"Use one of: {', '.join(PRECLEARED_ADDRESS_SOURCES)}."
)
return v

@model_validator(mode="after")
def _country_when_enabled(self):
if self.enabled and self.sender_country not in ("GB", "US"):
raise ValueError(
"compliance.precleared.sender_country must be GB or US "
"when the check is enabled."
)
return self


class ComplianceConfig(BaseModel):
precleared: PreclearedConfig = PreclearedConfig()


class QuietHoursConfig(BaseModel):
start: str = "22:00"
end: str = "07:00"
Expand Down Expand Up @@ -212,6 +263,7 @@ class HarveyConfig(BaseModel):
channels: ChannelsConfig = ChannelsConfig()
usage: UsageConfig = UsageConfig()
brain: BrainConfig = BrainConfig()
compliance: ComplianceConfig = ComplianceConfig()


class EnvConfig(BaseModel):
Expand All @@ -237,6 +289,8 @@ class EnvConfig(BaseModel):
imap_port: int = 993
imap_username: str = ""
imap_password: str = ""
# Optional: only used when compliance.precleared.enabled is true.
precleared_api_key: str = ""


def _format_validation_error(e: ValidationError) -> str:
Expand Down Expand Up @@ -347,6 +401,7 @@ def load_env() -> EnvConfig:
imap_port=int(os.getenv("IMAP_PORT", "993").strip() or 993),
imap_username=os.getenv("IMAP_USERNAME", "").strip(),
imap_password=os.getenv("IMAP_PASSWORD", "").strip(),
precleared_api_key=os.getenv("PRECLEARED_API_KEY", "").strip(),
)
return env

Expand Down
Loading
Loading