outreach-desk 2.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. outreach_desk-2.0.0/.dockerignore +9 -0
  2. outreach_desk-2.0.0/.env.example +22 -0
  3. outreach_desk-2.0.0/.github/workflows/ci.yml +36 -0
  4. outreach_desk-2.0.0/.github/workflows/publish-n8n.yml +33 -0
  5. outreach_desk-2.0.0/.github/workflows/publish.yml +30 -0
  6. outreach_desk-2.0.0/.gitignore +11 -0
  7. outreach_desk-2.0.0/Dockerfile +9 -0
  8. outreach_desk-2.0.0/LICENSE +21 -0
  9. outreach_desk-2.0.0/PKG-INFO +230 -0
  10. outreach_desk-2.0.0/README.md +203 -0
  11. outreach_desk-2.0.0/docker-compose.yml +31 -0
  12. outreach_desk-2.0.0/outreach_desk/__init__.py +6 -0
  13. outreach_desk-2.0.0/outreach_desk/__main__.py +44 -0
  14. outreach_desk-2.0.0/outreach_desk/app.py +579 -0
  15. outreach_desk-2.0.0/outreach_desk/config.py +71 -0
  16. outreach_desk-2.0.0/outreach_desk/decisions.py +184 -0
  17. outreach_desk-2.0.0/outreach_desk/drafting.py +163 -0
  18. outreach_desk-2.0.0/outreach_desk/engine.py +398 -0
  19. outreach_desk-2.0.0/outreach_desk/mail.py +149 -0
  20. outreach_desk-2.0.0/outreach_desk/models.py +142 -0
  21. outreach_desk-2.0.0/outreach_desk/static/htmx.min.js +1 -0
  22. outreach_desk-2.0.0/outreach_desk/static/style.css +126 -0
  23. outreach_desk-2.0.0/outreach_desk/templates/_contact_form.html +12 -0
  24. outreach_desk-2.0.0/outreach_desk/templates/_draft.html +45 -0
  25. outreach_desk-2.0.0/outreach_desk/templates/_reply.html +24 -0
  26. outreach_desk-2.0.0/outreach_desk/templates/analytics.html +53 -0
  27. outreach_desk-2.0.0/outreach_desk/templates/base.html +25 -0
  28. outreach_desk-2.0.0/outreach_desk/templates/campaign.html +57 -0
  29. outreach_desk-2.0.0/outreach_desk/templates/campaigns.html +56 -0
  30. outreach_desk-2.0.0/outreach_desk/templates/contacts.html +60 -0
  31. outreach_desk-2.0.0/outreach_desk/templates/pipeline.html +23 -0
  32. outreach_desk-2.0.0/outreach_desk/templates/queue.html +39 -0
  33. outreach_desk-2.0.0/outreach_desk/templates/settings.html +51 -0
  34. outreach_desk-2.0.0/outreach_desk/webhooks.py +54 -0
  35. outreach_desk-2.0.0/pyproject.toml +46 -0
@@ -0,0 +1,9 @@
1
+ .git
2
+ .venv
3
+ **/__pycache__
4
+ n8n-node
5
+ n8n
6
+ tests
7
+ docs
8
+ *.db
9
+ .env
@@ -0,0 +1,22 @@
1
+ # Copy to ~/.outreach-desk/.env (or ./.env) and fill in.
2
+
3
+ # Claude writes the drafts. Required.
4
+ ANTHROPIC_API_KEY=
5
+ # OUTREACH_DESK_MODEL=claude-opus-5-5
6
+
7
+ # Jev makes the decisions. Optional: without it, Claude Haiku answers the same questions.
8
+ TYPESAFE_API_KEY=
9
+
10
+ # Sending (SMTP) and replies (IMAP). Use an app password, ideally on a secondary address.
11
+ SMTP_HOST=smtp.gmail.com
12
+ SMTP_PORT=587
13
+ SMTP_USER=you@example.com
14
+ SMTP_PASSWORD=
15
+ IMAP_HOST=imap.gmail.com
16
+ FROM_NAME=Your Name
17
+
18
+ # Guard rails
19
+ DAILY_CAP=30
20
+ SEND_WINDOW=8-19
21
+ # Required for sales mode (CAN-SPAM): your physical postal address.
22
+ POSTAL_ADDRESS=
@@ -0,0 +1,36 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ python:
10
+ runs-on: ubuntu-latest
11
+ strategy:
12
+ matrix:
13
+ python-version: ["3.10", "3.12", "3.13"]
14
+ steps:
15
+ - uses: actions/checkout@v5
16
+ - uses: actions/setup-python@v6
17
+ with:
18
+ python-version: ${{ matrix.python-version }}
19
+ - run: pip install -e '.[dev]'
20
+ - run: pytest -q
21
+
22
+ n8n-node:
23
+ runs-on: ubuntu-latest
24
+ defaults:
25
+ run:
26
+ working-directory: n8n-node
27
+ steps:
28
+ - uses: actions/checkout@v5
29
+ - uses: actions/setup-node@v5
30
+ with:
31
+ node-version: lts/*
32
+ cache: npm
33
+ cache-dependency-path: n8n-node/package-lock.json
34
+ - run: npm ci
35
+ - run: npm run lint
36
+ - run: npm run build
@@ -0,0 +1,33 @@
1
+ # Push a tag like n8n-v1.0.1 to lint, build and publish n8n-nodes-outreach-desk to npm with a
2
+ # provenance statement (required by n8n for community nodes since May 1 2026).
3
+ # One-time setup: on npmjs.com add this repo + publish-n8n.yml as a Trusted Publisher,
4
+ # or store an automation token as the NPM_TOKEN repository secret.
5
+ name: Publish n8n node to npm
6
+
7
+ on:
8
+ push:
9
+ tags: ["n8n-v*"]
10
+
11
+ jobs:
12
+ publish:
13
+ runs-on: ubuntu-latest
14
+ permissions:
15
+ id-token: write # npm provenance + trusted publishing
16
+ contents: read
17
+ defaults:
18
+ run:
19
+ working-directory: n8n-node
20
+ steps:
21
+ - uses: actions/checkout@v5
22
+ - uses: actions/setup-node@v5
23
+ with:
24
+ node-version: lts/*
25
+ registry-url: https://registry.npmjs.org
26
+ cache: npm
27
+ cache-dependency-path: n8n-node/package-lock.json
28
+ - name: Tag matches package.json version
29
+ run: test "${GITHUB_REF_NAME#n8n-v}" = "$(node -p "require('./package.json').version")"
30
+ - run: npm ci
31
+ - run: npm run release # lint, build, npm publish with provenance
32
+ env:
33
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -0,0 +1,30 @@
1
+ # Push a version tag (e.g. v2.0.1) to test, build and publish to PyPI via Trusted Publishing (no tokens).
2
+ # The run finishes green only once pip can install the new version.
3
+ name: Publish to PyPI
4
+
5
+ on:
6
+ push:
7
+ tags: ["v*"]
8
+
9
+ jobs:
10
+ publish:
11
+ runs-on: ubuntu-latest
12
+ environment: pypi
13
+ permissions:
14
+ id-token: write # lets PyPI verify this workflow instead of a stored token
15
+ steps:
16
+ - uses: actions/checkout@v5
17
+ - uses: actions/setup-python@v6
18
+ with:
19
+ python-version: "3.12"
20
+ - run: pip install -e '.[dev]' build
21
+ - run: pytest -q
22
+ - run: python -m build
23
+ - uses: pypa/gh-action-pypi-publish@release/v1
24
+ - name: Wait until pip can install it # PyPI's CDN can serve the old index for a few minutes after upload
25
+ run: |
26
+ for i in $(seq 30); do
27
+ pip download --no-deps --no-cache-dir -q -d /tmp/check "outreach-desk==${GITHUB_REF_NAME#v}" && exit 0
28
+ sleep 10
29
+ done
30
+ exit 1
@@ -0,0 +1,11 @@
1
+ .env
2
+ __pycache__/
3
+ *.pyc
4
+ .DS_Store
5
+ venv/
6
+ .venv/
7
+ .pytest_cache/
8
+ dist/
9
+ *.db
10
+ n8n-node/node_modules/
11
+ n8n-node/dist/
@@ -0,0 +1,9 @@
1
+ FROM python:3.12-slim
2
+ WORKDIR /app
3
+ COPY pyproject.toml README.md LICENSE ./
4
+ COPY outreach_desk ./outreach_desk
5
+ RUN pip install --no-cache-dir .
6
+ ENV OUTREACH_DESK_HOME=/data
7
+ VOLUME /data
8
+ EXPOSE 8000
9
+ CMD ["outreach-desk", "--host", "0.0.0.0", "--no-browser"]
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Owen Alderson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,230 @@
1
+ Metadata-Version: 2.5
2
+ Name: outreach-desk
3
+ Version: 2.0.0
4
+ Summary: A self-hosted outreach desk: Claude writes the emails, Jev makes the calls, you approve every send. With an n8n node.
5
+ Project-URL: Homepage, https://github.com/owen-alderson/outreach-desk
6
+ Author: Owen Alderson
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: claude,cold-email,crm,fastapi,htmx,jev,n8n,outreach,typesafe
10
+ Classifier: Framework :: FastAPI
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Topic :: Communications :: Email
13
+ Classifier: Topic :: Office/Business
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: anthropic>=1.11
16
+ Requires-Dist: fastapi>=0.115
17
+ Requires-Dist: httpx>=0.27
18
+ Requires-Dist: jinja2>=3.1
19
+ Requires-Dist: python-multipart>=0.0.9
20
+ Requires-Dist: sqlmodel>=0.0.22
21
+ Requires-Dist: system-one-adapter[anthropic]>=0.2
22
+ Requires-Dist: typesafe-sdk>=0.7
23
+ Requires-Dist: uvicorn>=0.30
24
+ Provides-Extra: dev
25
+ Requires-Dist: pytest>=8; extra == 'dev'
26
+ Description-Content-Type: text/markdown
27
+
28
+ # outreach-desk
29
+
30
+ **A self-hosted outreach desk. Claude writes the emails, Jev makes the calls, you approve every send.**
31
+
32
+ Contacts, campaigns, follow-up sequences, reply triage, a pipeline board and analytics, in one
33
+ `pipx install`. It plugs into n8n, so leads can flow in from a Google Sheet and hot replies can
34
+ ping you on Slack.
35
+
36
+ [![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)
37
+ [![PyPI](https://img.shields.io/pypi/v/outreach-desk)](https://pypi.org/project/outreach-desk/)
38
+ [![npm](https://img.shields.io/npm/v/n8n-nodes-outreach-desk?label=n8n%20node)](https://www.npmjs.com/package/n8n-nodes-outreach-desk)
39
+ ![License: MIT](https://img.shields.io/badge/license-MIT-blue)
40
+
41
+ ![outreach-desk demo: review queue, guardrail flags, approval, pipeline, analytics](docs/demo.gif)
42
+
43
+ <sub>Screenshots use fictional demo data.</sub>
44
+
45
+ ## Why
46
+
47
+ Most cold-email tools either write generic text and blast it, or leave you doing everything by
48
+ hand. outreach-desk splits the work three ways:
49
+
50
+ | | Who | What |
51
+ |---|---|---|
52
+ | **Writes** | Claude (`claude-opus-5-5`) | First emails and follow-ups, personalised **only** from facts you gave it about the recipient |
53
+ | **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 |
54
+ | **Approves** | You | Nothing is ever sent until you approve that exact draft |
55
+
56
+ When Jev isn't confident, the decision goes to you instead of guessing.
57
+
58
+ ## What it does
59
+
60
+ - **Contacts:** add by hand, import a CSV, or paste a LinkedIn bio and let Claude fill the form.
61
+ Each contact has a *facts* field, and drafts may only personalise from those facts.
62
+ - **Campaigns:** two modes, **networking** (students and job-seekers writing to senior people)
63
+ and **sales** (B2B), with a sequence like `0, 4, 7` (first email, then follow-ups 4 and 7 days later).
64
+ - **Review queue:** every draft arrives pre-checked by Jev. Edit, approve, regenerate or discard.
65
+ - **Sending:** through your own mailbox over SMTP, threaded properly (`In-Reply-To`/`References`),
66
+ with a daily cap and a send window.
67
+ - **Replies:** IMAP sync. **Any reply stops the sequence immediately.** Jev labels each reply
68
+ (`interested`, `meeting_request`, `referral`, `not_now`, `not_interested`, `out_of_office`,
69
+ `unsubscribe`, `bounce`) and moves the contact along the pipeline. Unsubscribes and bounces go
70
+ on a do-not-contact list. Out-of-office replies don't stop the sequence.
71
+ - **Pipeline board:** `new → contacted → replied → meeting → won / lost`.
72
+ - **Analytics:** reply rate and positive rate per campaign and per step, reply labels, and every
73
+ decision logged with which backend answered and how long it took.
74
+ - **API + webhooks:** a bearer-key JSON API and HMAC-signed webhooks, used by the n8n node.
75
+
76
+ ## Quickstart
77
+
78
+ ```bash
79
+ pipx install outreach-desk
80
+ outreach-desk # opens http://127.0.0.1:8000
81
+ ```
82
+
83
+ Put your settings in `~/.outreach-desk/.env` (or a `.env` in the folder you run it from):
84
+
85
+ ```bash
86
+ ANTHROPIC_API_KEY=sk-ant-... # required: Claude writes the drafts
87
+ TYPESAFE_API_KEY= # optional: Jev makes the decisions (see below)
88
+
89
+ SMTP_HOST=smtp.gmail.com # optional until you want to send
90
+ SMTP_PORT=587
91
+ SMTP_USER=you@example.com
92
+ SMTP_PASSWORD=your-app-password # an app password, not your login password
93
+ IMAP_HOST=imap.gmail.com # optional: reply sync
94
+ FROM_NAME=Your Name
95
+
96
+ DAILY_CAP=30
97
+ SEND_WINDOW=8-19 # local hours
98
+ POSTAL_ADDRESS= # required for sales mode (CAN-SPAM footer)
99
+ ```
100
+
101
+ Without SMTP settings you can still build contacts, campaigns and drafts; nothing will send.
102
+ See [.env.example](.env.example) for every option.
103
+
104
+ <details>
105
+ <summary>Docker / Docker Compose (with n8n)</summary>
106
+
107
+ ```bash
108
+ docker compose up -d # outreach-desk on :8000, n8n on :5678 (both localhost-only)
109
+ ```
110
+
111
+ The compose file reads `.env` if present and keeps data in named volumes.
112
+ </details>
113
+
114
+ ## How Jev is used
115
+
116
+ Jev is a *System One* model: you describe a decision as typed questions, and it answers each one
117
+ with a probability or a label plus its confidence (TypeSafe quotes 70–500 ms per call). outreach-desk asks three
118
+ sets of questions ([`decisions.py`](outreach_desk/decisions.py)):
119
+
120
+ | Decision | Questions | What happens |
121
+ |---|---|---|
122
+ | **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. |
123
+ | **Lead fit** | `Score` against the campaign's target · `Choice` seniority | Contacts sorted by fit |
124
+ | **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 |
125
+
126
+ **No Jev key? It still works.** Without `TYPESAFE_API_KEY`, the same questions go to TypeSafe's
127
+ open-source [`system-one-adapter`](https://github.com/typesafe-ai/system-one-adapter-python),
128
+ which answers them with Claude Haiku 4.5 through the same interface. Expect it to be slower
129
+ (seconds, not milliseconds); the Analytics page shows which backend answered each decision and
130
+ how long it took, so you can compare.
131
+
132
+ ## n8n
133
+
134
+ [`n8n-nodes-outreach-desk`](n8n-node/) adds two nodes to n8n:
135
+
136
+ - **Outreach Desk:** create, update or list contacts; enrol contacts in a campaign; list,
137
+ approve (with edits) or reject drafts.
138
+ - **Outreach Desk Trigger:** starts a workflow on `draft.ready`, `email.sent`,
139
+ `reply.classified` (optionally only for certain labels) or `contact.stage_changed`. It
140
+ registers its own webhook when the workflow is activated, removes it when deactivated, and
141
+ rejects any request without a valid signature.
142
+
143
+ Install it from **Settings → Community Nodes** in n8n. Ready-made workflows in
144
+ [`n8n/workflows/`](n8n/workflows/):
145
+
146
+ | Template | Flow |
147
+ |---|---|
148
+ | [sheet-to-campaign](n8n/workflows/sheet-to-campaign.json) | New Google Sheet row → contact → campaign |
149
+ | [hot-reply-to-slack](n8n/workflows/hot-reply-to-slack.json) | Interested / meeting / referral reply → Slack message |
150
+ | [won-to-hubspot](n8n/workflows/won-to-hubspot.json) | Contact moved to *won* → HubSpot contact |
151
+
152
+ ## API
153
+
154
+ Every endpoint needs `Authorization: Bearer <key>` (the key is on the Settings page) except
155
+ `/api/health`.
156
+
157
+ | Method | Path | |
158
+ |---|---|---|
159
+ | `GET` | `/api/contacts?stage=` | List contacts |
160
+ | `POST` | `/api/contacts` | `{name, email, role?, company?, facts?}` |
161
+ | `PATCH` | `/api/contacts/{id}` | Any field, or `stage` |
162
+ | `GET` | `/api/campaigns` | List campaigns |
163
+ | `POST` | `/api/campaigns/{id}/enroll` | `{contact_ids?: [...], emails?: [...]}` |
164
+ | `GET` | `/api/drafts` | Drafts waiting for review |
165
+ | `POST` | `/api/drafts/{id}/approve` | `{subject?, body?}` |
166
+ | `POST` | `/api/drafts/{id}/reject` | Discards it and stops that contact's sequence |
167
+ | `POST` / `DELETE` | `/api/webhooks[/{id}]` | Subscribe `{url, events}`; the response includes the signing secret |
168
+
169
+ Webhook bodies are `{"event", "data", "sent_at"}` with
170
+ `X-Outreach-Signature: sha256=HMAC_SHA256(secret, raw_body)`.
171
+
172
+ ## Safety rules
173
+
174
+ These are enforced in code and covered by tests:
175
+
176
+ - **Nothing sends without approval**, and an approved email that hasn't gone out yet is cancelled
177
+ if the person replies first.
178
+ - **Daily cap and send window** for every mailbox.
179
+ - **Do-not-contact list:** unsubscribes, bounces and contacts you delete are never emailed again.
180
+ - **Sales mode** adds a postal address, an unsubscribe line and a `List-Unsubscribe` header.
181
+ - **Delete a contact** removes their data (right to erasure) and keeps only their address on the
182
+ do-not-contact list.
183
+ - **The app listens on localhost** with no login, and refuses form posts coming from other
184
+ websites. Don't expose it to the internet without putting authentication in front of it.
185
+
186
+ Deliverability tip: send from a secondary address or domain, warm it up, and keep the daily cap low.
187
+
188
+ ## Architecture
189
+
190
+ ```mermaid
191
+ flowchart LR
192
+ subgraph desk[outreach-desk · FastAPI + SQLite + HTMX]
193
+ UI[Pages] --> Engine
194
+ API[JSON API] --> Engine
195
+ Scheduler[[every 60 s]] --> Engine
196
+ Engine --> Drafting[drafting.py]
197
+ Engine --> Decisions[decisions.py]
198
+ Engine --> Mail[mail.py]
199
+ Engine --> Hooks[webhooks.py]
200
+ end
201
+ Drafting -- writes --> Claude[(Claude API)]
202
+ Decisions -- decides --> Jev[(Jev / adapter)]
203
+ Mail -- SMTP / IMAP --> Mailbox[(Your mailbox)]
204
+ Hooks -- signed events --> n8n[(n8n)]
205
+ n8n -- API --> API
206
+ ```
207
+
208
+ `engine.py` holds the rules (enrol, draft what's due, send what's approved, route replies). Every
209
+ function takes a session and a clock, so it is fully testable without a network.
210
+
211
+ ## Development
212
+
213
+ ```bash
214
+ git clone https://github.com/owen-alderson/outreach-desk && cd outreach-desk
215
+ python -m venv .venv && . .venv/bin/activate
216
+ pip install -e '.[dev]'
217
+ pytest # 178 tests, no network: Claude, Jev, SMTP and IMAP are faked
218
+
219
+ cd n8n-node && npm install && npm run lint && npm run build
220
+ ```
221
+
222
+ Releases: push `vX.Y.Z` to publish to PyPI, `n8n-vX.Y.Z` to publish the n8n node to npm (both
223
+ from GitHub Actions; the npm package carries a provenance statement).
224
+
225
+ The original single-email Streamlit version is kept at tag
226
+ [`v1-streamlit`](https://github.com/owen-alderson/outreach-desk/tree/v1-streamlit).
227
+
228
+ ## License
229
+
230
+ MIT
@@ -0,0 +1,203 @@
1
+ # outreach-desk
2
+
3
+ **A self-hosted outreach desk. Claude writes the emails, Jev makes the calls, you approve every send.**
4
+
5
+ Contacts, campaigns, follow-up sequences, reply triage, a pipeline board and analytics, in one
6
+ `pipx install`. It plugs into n8n, so leads can flow in from a Google Sheet and hot replies can
7
+ ping you on Slack.
8
+
9
+ [![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)
10
+ [![PyPI](https://img.shields.io/pypi/v/outreach-desk)](https://pypi.org/project/outreach-desk/)
11
+ [![npm](https://img.shields.io/npm/v/n8n-nodes-outreach-desk?label=n8n%20node)](https://www.npmjs.com/package/n8n-nodes-outreach-desk)
12
+ ![License: MIT](https://img.shields.io/badge/license-MIT-blue)
13
+
14
+ ![outreach-desk demo: review queue, guardrail flags, approval, pipeline, analytics](docs/demo.gif)
15
+
16
+ <sub>Screenshots use fictional demo data.</sub>
17
+
18
+ ## Why
19
+
20
+ Most cold-email tools either write generic text and blast it, or leave you doing everything by
21
+ hand. outreach-desk splits the work three ways:
22
+
23
+ | | Who | What |
24
+ |---|---|---|
25
+ | **Writes** | Claude (`claude-opus-5-5`) | First emails and follow-ups, personalised **only** from facts you gave it about the recipient |
26
+ | **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 |
27
+ | **Approves** | You | Nothing is ever sent until you approve that exact draft |
28
+
29
+ When Jev isn't confident, the decision goes to you instead of guessing.
30
+
31
+ ## What it does
32
+
33
+ - **Contacts:** add by hand, import a CSV, or paste a LinkedIn bio and let Claude fill the form.
34
+ Each contact has a *facts* field, and drafts may only personalise from those facts.
35
+ - **Campaigns:** two modes, **networking** (students and job-seekers writing to senior people)
36
+ and **sales** (B2B), with a sequence like `0, 4, 7` (first email, then follow-ups 4 and 7 days later).
37
+ - **Review queue:** every draft arrives pre-checked by Jev. Edit, approve, regenerate or discard.
38
+ - **Sending:** through your own mailbox over SMTP, threaded properly (`In-Reply-To`/`References`),
39
+ with a daily cap and a send window.
40
+ - **Replies:** IMAP sync. **Any reply stops the sequence immediately.** Jev labels each reply
41
+ (`interested`, `meeting_request`, `referral`, `not_now`, `not_interested`, `out_of_office`,
42
+ `unsubscribe`, `bounce`) and moves the contact along the pipeline. Unsubscribes and bounces go
43
+ on a do-not-contact list. Out-of-office replies don't stop the sequence.
44
+ - **Pipeline board:** `new → contacted → replied → meeting → won / lost`.
45
+ - **Analytics:** reply rate and positive rate per campaign and per step, reply labels, and every
46
+ decision logged with which backend answered and how long it took.
47
+ - **API + webhooks:** a bearer-key JSON API and HMAC-signed webhooks, used by the n8n node.
48
+
49
+ ## Quickstart
50
+
51
+ ```bash
52
+ pipx install outreach-desk
53
+ outreach-desk # opens http://127.0.0.1:8000
54
+ ```
55
+
56
+ Put your settings in `~/.outreach-desk/.env` (or a `.env` in the folder you run it from):
57
+
58
+ ```bash
59
+ ANTHROPIC_API_KEY=sk-ant-... # required: Claude writes the drafts
60
+ TYPESAFE_API_KEY= # optional: Jev makes the decisions (see below)
61
+
62
+ SMTP_HOST=smtp.gmail.com # optional until you want to send
63
+ SMTP_PORT=587
64
+ SMTP_USER=you@example.com
65
+ SMTP_PASSWORD=your-app-password # an app password, not your login password
66
+ IMAP_HOST=imap.gmail.com # optional: reply sync
67
+ FROM_NAME=Your Name
68
+
69
+ DAILY_CAP=30
70
+ SEND_WINDOW=8-19 # local hours
71
+ POSTAL_ADDRESS= # required for sales mode (CAN-SPAM footer)
72
+ ```
73
+
74
+ Without SMTP settings you can still build contacts, campaigns and drafts; nothing will send.
75
+ See [.env.example](.env.example) for every option.
76
+
77
+ <details>
78
+ <summary>Docker / Docker Compose (with n8n)</summary>
79
+
80
+ ```bash
81
+ docker compose up -d # outreach-desk on :8000, n8n on :5678 (both localhost-only)
82
+ ```
83
+
84
+ The compose file reads `.env` if present and keeps data in named volumes.
85
+ </details>
86
+
87
+ ## How Jev is used
88
+
89
+ Jev is a *System One* model: you describe a decision as typed questions, and it answers each one
90
+ with a probability or a label plus its confidence (TypeSafe quotes 70–500 ms per call). outreach-desk asks three
91
+ sets of questions ([`decisions.py`](outreach_desk/decisions.py)):
92
+
93
+ | Decision | Questions | What happens |
94
+ |---|---|---|
95
+ | **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. |
96
+ | **Lead fit** | `Score` against the campaign's target · `Choice` seniority | Contacts sorted by fit |
97
+ | **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 |
98
+
99
+ **No Jev key? It still works.** Without `TYPESAFE_API_KEY`, the same questions go to TypeSafe's
100
+ open-source [`system-one-adapter`](https://github.com/typesafe-ai/system-one-adapter-python),
101
+ which answers them with Claude Haiku 4.5 through the same interface. Expect it to be slower
102
+ (seconds, not milliseconds); the Analytics page shows which backend answered each decision and
103
+ how long it took, so you can compare.
104
+
105
+ ## n8n
106
+
107
+ [`n8n-nodes-outreach-desk`](n8n-node/) adds two nodes to n8n:
108
+
109
+ - **Outreach Desk:** create, update or list contacts; enrol contacts in a campaign; list,
110
+ approve (with edits) or reject drafts.
111
+ - **Outreach Desk Trigger:** starts a workflow on `draft.ready`, `email.sent`,
112
+ `reply.classified` (optionally only for certain labels) or `contact.stage_changed`. It
113
+ registers its own webhook when the workflow is activated, removes it when deactivated, and
114
+ rejects any request without a valid signature.
115
+
116
+ Install it from **Settings → Community Nodes** in n8n. Ready-made workflows in
117
+ [`n8n/workflows/`](n8n/workflows/):
118
+
119
+ | Template | Flow |
120
+ |---|---|
121
+ | [sheet-to-campaign](n8n/workflows/sheet-to-campaign.json) | New Google Sheet row → contact → campaign |
122
+ | [hot-reply-to-slack](n8n/workflows/hot-reply-to-slack.json) | Interested / meeting / referral reply → Slack message |
123
+ | [won-to-hubspot](n8n/workflows/won-to-hubspot.json) | Contact moved to *won* → HubSpot contact |
124
+
125
+ ## API
126
+
127
+ Every endpoint needs `Authorization: Bearer <key>` (the key is on the Settings page) except
128
+ `/api/health`.
129
+
130
+ | Method | Path | |
131
+ |---|---|---|
132
+ | `GET` | `/api/contacts?stage=` | List contacts |
133
+ | `POST` | `/api/contacts` | `{name, email, role?, company?, facts?}` |
134
+ | `PATCH` | `/api/contacts/{id}` | Any field, or `stage` |
135
+ | `GET` | `/api/campaigns` | List campaigns |
136
+ | `POST` | `/api/campaigns/{id}/enroll` | `{contact_ids?: [...], emails?: [...]}` |
137
+ | `GET` | `/api/drafts` | Drafts waiting for review |
138
+ | `POST` | `/api/drafts/{id}/approve` | `{subject?, body?}` |
139
+ | `POST` | `/api/drafts/{id}/reject` | Discards it and stops that contact's sequence |
140
+ | `POST` / `DELETE` | `/api/webhooks[/{id}]` | Subscribe `{url, events}`; the response includes the signing secret |
141
+
142
+ Webhook bodies are `{"event", "data", "sent_at"}` with
143
+ `X-Outreach-Signature: sha256=HMAC_SHA256(secret, raw_body)`.
144
+
145
+ ## Safety rules
146
+
147
+ These are enforced in code and covered by tests:
148
+
149
+ - **Nothing sends without approval**, and an approved email that hasn't gone out yet is cancelled
150
+ if the person replies first.
151
+ - **Daily cap and send window** for every mailbox.
152
+ - **Do-not-contact list:** unsubscribes, bounces and contacts you delete are never emailed again.
153
+ - **Sales mode** adds a postal address, an unsubscribe line and a `List-Unsubscribe` header.
154
+ - **Delete a contact** removes their data (right to erasure) and keeps only their address on the
155
+ do-not-contact list.
156
+ - **The app listens on localhost** with no login, and refuses form posts coming from other
157
+ websites. Don't expose it to the internet without putting authentication in front of it.
158
+
159
+ Deliverability tip: send from a secondary address or domain, warm it up, and keep the daily cap low.
160
+
161
+ ## Architecture
162
+
163
+ ```mermaid
164
+ flowchart LR
165
+ subgraph desk[outreach-desk · FastAPI + SQLite + HTMX]
166
+ UI[Pages] --> Engine
167
+ API[JSON API] --> Engine
168
+ Scheduler[[every 60 s]] --> Engine
169
+ Engine --> Drafting[drafting.py]
170
+ Engine --> Decisions[decisions.py]
171
+ Engine --> Mail[mail.py]
172
+ Engine --> Hooks[webhooks.py]
173
+ end
174
+ Drafting -- writes --> Claude[(Claude API)]
175
+ Decisions -- decides --> Jev[(Jev / adapter)]
176
+ Mail -- SMTP / IMAP --> Mailbox[(Your mailbox)]
177
+ Hooks -- signed events --> n8n[(n8n)]
178
+ n8n -- API --> API
179
+ ```
180
+
181
+ `engine.py` holds the rules (enrol, draft what's due, send what's approved, route replies). Every
182
+ function takes a session and a clock, so it is fully testable without a network.
183
+
184
+ ## Development
185
+
186
+ ```bash
187
+ git clone https://github.com/owen-alderson/outreach-desk && cd outreach-desk
188
+ python -m venv .venv && . .venv/bin/activate
189
+ pip install -e '.[dev]'
190
+ pytest # 178 tests, no network: Claude, Jev, SMTP and IMAP are faked
191
+
192
+ cd n8n-node && npm install && npm run lint && npm run build
193
+ ```
194
+
195
+ Releases: push `vX.Y.Z` to publish to PyPI, `n8n-vX.Y.Z` to publish the n8n node to npm (both
196
+ from GitHub Actions; the npm package carries a provenance statement).
197
+
198
+ The original single-email Streamlit version is kept at tag
199
+ [`v1-streamlit`](https://github.com/owen-alderson/outreach-desk/tree/v1-streamlit).
200
+
201
+ ## License
202
+
203
+ MIT
@@ -0,0 +1,31 @@
1
+ # outreach-desk + n8n, wired together.
2
+ # docker compose up -d
3
+ # outreach-desk: http://localhost:8000 n8n: http://localhost:5678
4
+ # Then in n8n: Settings → Community Nodes → install "n8n-nodes-outreach-desk", and create an
5
+ # "Outreach Desk API" credential with base URL http://outreach-desk:8000 and the API key from
6
+ # outreach-desk's Settings page. Workflow templates are in n8n/workflows/.
7
+ services:
8
+ outreach-desk:
9
+ build: .
10
+ env_file:
11
+ - path: .env
12
+ required: false
13
+ ports:
14
+ - "127.0.0.1:8000:8000" # localhost only: the pages have no login
15
+ volumes:
16
+ - desk-data:/data
17
+
18
+ n8n:
19
+ image: n8nio/n8n:latest
20
+ environment:
21
+ # Trigger webhooks are registered with this base URL, which the outreach-desk container can reach.
22
+ - WEBHOOK_URL=http://n8n:5678/
23
+ - N8N_SECURE_COOKIE=false
24
+ ports:
25
+ - "127.0.0.1:5678:5678"
26
+ volumes:
27
+ - n8n-data:/home/node/.n8n
28
+
29
+ volumes:
30
+ desk-data:
31
+ n8n-data:
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("outreach-desk")
5
+ except PackageNotFoundError: # running from a source checkout
6
+ __version__ = "0.0.0"