Metadata-Version: 2.5
Name: outreach-desk
Version: 2.0.1
Summary: A self-hosted outreach desk: Claude writes the emails, Jev makes the calls, you approve every send. With an n8n node.
Project-URL: Homepage, https://github.com/owen-alderson/outreach-desk
Author: Owen Alderson
License-Expression: MIT
License-File: LICENSE
Keywords: claude,cold-email,crm,fastapi,htmx,jev,n8n,outreach,typesafe
Classifier: Framework :: FastAPI
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Communications :: Email
Classifier: Topic :: Office/Business
Requires-Python: >=3.10
Requires-Dist: anthropic>=1.11
Requires-Dist: fastapi>=0.115
Requires-Dist: httpx>=0.27
Requires-Dist: jinja2>=3.1
Requires-Dist: python-multipart>=0.0.9
Requires-Dist: sqlmodel>=0.0.22
Requires-Dist: system-one-adapter[anthropic]>=0.2
Requires-Dist: typesafe-sdk>=0.7
Requires-Dist: uvicorn>=0.30
Provides-Extra: dev
Requires-Dist: pytest>=8; extra == 'dev'
Description-Content-Type: text/markdown

# outreach-desk

**A self-hosted outreach desk. Claude writes the emails, Jev makes the calls, you approve every send.**

Contacts, campaigns, follow-up sequences, reply triage, a pipeline board and analytics, in one
`pipx install`. It plugs into n8n, so leads can flow in from a Google Sheet and hot replies can
ping you on Slack.

[![CI](https://github.com/owen-alderson/outreach-desk/actions/workflows/ci.yml/badge.svg)](https://github.com/owen-alderson/outreach-desk/actions/workflows/ci.yml)
[![PyPI](https://img.shields.io/pypi/v/outreach-desk)](https://pypi.org/project/outreach-desk/)
[![npm](https://img.shields.io/npm/v/n8n-nodes-outreach-desk?label=n8n%20node)](https://www.npmjs.com/package/n8n-nodes-outreach-desk)
![License: MIT](https://img.shields.io/badge/license-MIT-blue)

![outreach-desk demo: review queue, guardrail flags, approval, pipeline, analytics](docs/demo.gif)

<sub>Screenshots use fictional demo data.</sub>

## Why

Most cold-email tools either write generic text and blast it, or leave you doing everything by
hand. outreach-desk splits the work three ways:

| | Who | What |
|---|---|---|
| **Writes** | Claude (`claude-opus-5-5`) | First emails and follow-ups, personalised **only** from facts you gave it about the recipient |
| **Decides** | [Jev](https://typesafe.ai/blog/introducing-system-one-models-and-jev) (TypeSafe's System One model) | Typed, fast decisions with calibrated confidence: is this draft safe to send, how good a fit is this lead, what kind of reply is this |
| **Approves** | You | Nothing is ever sent until you approve that exact draft |

When Jev isn't confident, the decision goes to you instead of guessing.

## What it does

- **Contacts:** add by hand, import a CSV, or paste a LinkedIn bio and let Claude fill the form.
  Each contact has a *facts* field, and drafts may only personalise from those facts.
- **Campaigns:** two modes, **networking** (students and job-seekers writing to senior people)
  and **sales** (B2B), with a sequence like `0, 4, 7` (first email, then follow-ups 4 and 7 days later).
- **Review queue:** every draft arrives pre-checked by Jev. Edit, approve, regenerate or discard.
- **Sending:** through your own mailbox over SMTP, threaded properly (`In-Reply-To`/`References`),
  with a daily cap and a send window.
- **Replies:** IMAP sync. **Any reply stops the sequence immediately.** Jev labels each reply
  (`interested`, `meeting_request`, `referral`, `not_now`, `not_interested`, `out_of_office`,
  `unsubscribe`, `bounce`) and moves the contact along the pipeline. Unsubscribes and bounces go
  on a do-not-contact list. Out-of-office replies don't stop the sequence.
- **Pipeline board:** `new → contacted → replied → meeting → won / lost`.
- **Analytics:** reply rate and positive rate per campaign and per step, reply labels, and every
  decision logged with which backend answered and how long it took.
- **API + webhooks:** a bearer-key JSON API and HMAC-signed webhooks, used by the n8n node.

## Quickstart

```bash
pipx install outreach-desk
outreach-desk            # opens http://127.0.0.1:8000
```

Put your settings in `~/.outreach-desk/.env` (or a `.env` in the folder you run it from):

```bash
ANTHROPIC_API_KEY=sk-ant-...        # required: Claude writes the drafts
TYPESAFE_API_KEY=                   # optional: Jev makes the decisions (see below)

SMTP_HOST=smtp.gmail.com            # optional until you want to send
SMTP_PORT=587
SMTP_USER=you@example.com
SMTP_PASSWORD=your-app-password     # an app password, not your login password
IMAP_HOST=imap.gmail.com            # optional: reply sync
FROM_NAME=Your Name

DAILY_CAP=30
SEND_WINDOW=8-19                    # local hours
POSTAL_ADDRESS=                     # required for sales mode (CAN-SPAM footer)
```

Without SMTP settings you can still build contacts, campaigns and drafts; nothing will send.
See [.env.example](.env.example) for every option.

<details>
<summary>Docker / Docker Compose (with n8n)</summary>

```bash
docker compose up -d     # outreach-desk on :8000, n8n on :5678 (both localhost-only)
```

The compose file reads `.env` if present and keeps data in named volumes.
</details>

## How Jev is used

Jev is a *System One* model: you describe a decision as typed questions, and it answers each one
with a probability or a label plus its confidence (TypeSafe quotes 70–500 ms per call). outreach-desk asks three
sets of questions ([`decisions.py`](outreach_desk/decisions.py)):

| Decision | Questions | What happens |
|---|---|---|
| **Draft guardrail** | `Noul`: uses a cliché? claims something about the recipient that isn't in their facts? unclear ask? · `Score`: tone fit | Flags shown on the draft with their probability. Advice only: you still decide. A plain banned-phrase check runs too, so exact matches are never missed. |
| **Lead fit** | `Score` against the campaign's target · `Choice` seniority | Contacts sorted by fit |
| **Reply triage** | `Choice` over the 8 reply labels | ≥ 70% confidence: routed automatically. Below that: it waits in the queue for you, though the sequence still stops |

**No Jev key? It still works.** Without `TYPESAFE_API_KEY`, the same questions go to TypeSafe's
open-source [`system-one-adapter`](https://github.com/typesafe-ai/system-one-adapter-python),
which answers them with Claude Haiku 4.5 through the same interface. Expect it to be slower
(seconds, not milliseconds); the Analytics page shows which backend answered each decision and
how long it took, so you can compare.

## n8n

[`n8n-nodes-outreach-desk`](n8n-node/) adds two nodes to n8n:

- **Outreach Desk:** create, update or list contacts; enrol contacts in a campaign; list,
  approve (with edits) or reject drafts.
- **Outreach Desk Trigger:** starts a workflow on `draft.ready`, `email.sent`,
  `reply.classified` (optionally only for certain labels) or `contact.stage_changed`. It
  registers its own webhook when the workflow is activated, removes it when deactivated, and
  rejects any request without a valid signature.

Install it from **Settings → Community Nodes** in n8n. Ready-made workflows in
[`n8n/workflows/`](n8n/workflows/):

| Template | Flow |
|---|---|
| [sheet-to-campaign](n8n/workflows/sheet-to-campaign.json) | New Google Sheet row → contact → campaign |
| [hot-reply-to-slack](n8n/workflows/hot-reply-to-slack.json) | Interested / meeting / referral reply → Slack message |
| [won-to-hubspot](n8n/workflows/won-to-hubspot.json) | Contact moved to *won* → HubSpot contact |

## API

Every endpoint needs `Authorization: Bearer <key>` (the key is on the Settings page) except
`/api/health`.

| Method | Path | |
|---|---|---|
| `GET` | `/api/contacts?stage=` | List contacts |
| `POST` | `/api/contacts` | `{name, email, role?, company?, facts?}` |
| `PATCH` | `/api/contacts/{id}` | Any field, or `stage` |
| `GET` | `/api/campaigns` | List campaigns |
| `POST` | `/api/campaigns/{id}/enroll` | `{contact_ids?: [...], emails?: [...]}` |
| `GET` | `/api/drafts` | Drafts waiting for review |
| `POST` | `/api/drafts/{id}/approve` | `{subject?, body?}` |
| `POST` | `/api/drafts/{id}/reject` | Discards it and stops that contact's sequence |
| `POST` / `DELETE` | `/api/webhooks[/{id}]` | Subscribe `{url, events}`; the response includes the signing secret |

Webhook bodies are `{"event", "data", "sent_at"}` with
`X-Outreach-Signature: sha256=HMAC_SHA256(secret, raw_body)`.

## Safety rules

These are enforced in code and covered by tests:

- **Nothing sends without approval**, and an approved email that hasn't gone out yet is cancelled
  if the person replies first.
- **Daily cap and send window** for every mailbox.
- **Do-not-contact list:** unsubscribes, bounces and contacts you delete are never emailed again.
- **Sales mode** adds a postal address, an unsubscribe line and a `List-Unsubscribe` header, and
  won't send until `POSTAL_ADDRESS` is set.
- **Delete a contact** removes their data (right to erasure) and keeps only their address on the
  do-not-contact list.
- **The app listens on localhost** with no login, and refuses form posts coming from other
  websites. Don't expose it to the internet without putting authentication in front of it.

You are responsible for who you email: anti-spam and privacy law (CAN-SPAM in the US, GDPR and
PECR in the UK and EU) applies to you, not to this tool.

Deliverability tip: send from a secondary address or domain, warm it up, and keep the daily cap low.

## Architecture

```mermaid
flowchart LR
  subgraph desk[outreach-desk · FastAPI + SQLite + HTMX]
    UI[Pages] --> Engine
    API[JSON API] --> Engine
    Scheduler[[every 60 s]] --> Engine
    Engine --> Drafting[drafting.py]
    Engine --> Decisions[decisions.py]
    Engine --> Mail[mail.py]
    Engine --> Hooks[webhooks.py]
  end
  Drafting -- writes --> Claude[(Claude API)]
  Decisions -- decides --> Jev[(Jev / adapter)]
  Mail -- SMTP / IMAP --> Mailbox[(Your mailbox)]
  Hooks -- signed events --> n8n[(n8n)]
  n8n -- API --> API
```

`engine.py` holds the rules (enrol, draft what's due, send what's approved, route replies). Every
function takes a session and a clock, so it is fully testable without a network.

## Development

```bash
git clone https://github.com/owen-alderson/outreach-desk && cd outreach-desk
python -m venv .venv && . .venv/bin/activate
pip install -e '.[dev]'
pytest                    # 178 tests, no network: Claude, Jev, SMTP and IMAP are faked

cd n8n-node && npm install && npm run lint && npm run build
```

Releases: push `vX.Y.Z` to publish to PyPI, `n8n-vX.Y.Z` to publish the n8n node to npm (both
from GitHub Actions; the npm package carries a provenance statement).

The original single-email Streamlit version is kept at tag
[`v1-streamlit`](https://github.com/owen-alderson/outreach-desk/tree/v1-streamlit).

## License

MIT
