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.
- outreach_desk-2.0.0/.dockerignore +9 -0
- outreach_desk-2.0.0/.env.example +22 -0
- outreach_desk-2.0.0/.github/workflows/ci.yml +36 -0
- outreach_desk-2.0.0/.github/workflows/publish-n8n.yml +33 -0
- outreach_desk-2.0.0/.github/workflows/publish.yml +30 -0
- outreach_desk-2.0.0/.gitignore +11 -0
- outreach_desk-2.0.0/Dockerfile +9 -0
- outreach_desk-2.0.0/LICENSE +21 -0
- outreach_desk-2.0.0/PKG-INFO +230 -0
- outreach_desk-2.0.0/README.md +203 -0
- outreach_desk-2.0.0/docker-compose.yml +31 -0
- outreach_desk-2.0.0/outreach_desk/__init__.py +6 -0
- outreach_desk-2.0.0/outreach_desk/__main__.py +44 -0
- outreach_desk-2.0.0/outreach_desk/app.py +579 -0
- outreach_desk-2.0.0/outreach_desk/config.py +71 -0
- outreach_desk-2.0.0/outreach_desk/decisions.py +184 -0
- outreach_desk-2.0.0/outreach_desk/drafting.py +163 -0
- outreach_desk-2.0.0/outreach_desk/engine.py +398 -0
- outreach_desk-2.0.0/outreach_desk/mail.py +149 -0
- outreach_desk-2.0.0/outreach_desk/models.py +142 -0
- outreach_desk-2.0.0/outreach_desk/static/htmx.min.js +1 -0
- outreach_desk-2.0.0/outreach_desk/static/style.css +126 -0
- outreach_desk-2.0.0/outreach_desk/templates/_contact_form.html +12 -0
- outreach_desk-2.0.0/outreach_desk/templates/_draft.html +45 -0
- outreach_desk-2.0.0/outreach_desk/templates/_reply.html +24 -0
- outreach_desk-2.0.0/outreach_desk/templates/analytics.html +53 -0
- outreach_desk-2.0.0/outreach_desk/templates/base.html +25 -0
- outreach_desk-2.0.0/outreach_desk/templates/campaign.html +57 -0
- outreach_desk-2.0.0/outreach_desk/templates/campaigns.html +56 -0
- outreach_desk-2.0.0/outreach_desk/templates/contacts.html +60 -0
- outreach_desk-2.0.0/outreach_desk/templates/pipeline.html +23 -0
- outreach_desk-2.0.0/outreach_desk/templates/queue.html +39 -0
- outreach_desk-2.0.0/outreach_desk/templates/settings.html +51 -0
- outreach_desk-2.0.0/outreach_desk/webhooks.py +54 -0
- outreach_desk-2.0.0/pyproject.toml +46 -0
|
@@ -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,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
|
+
[](https://github.com/owen-alderson/outreach-desk/actions/workflows/ci.yml)
|
|
37
|
+
[](https://pypi.org/project/outreach-desk/)
|
|
38
|
+
[](https://www.npmjs.com/package/n8n-nodes-outreach-desk)
|
|
39
|
+

|
|
40
|
+
|
|
41
|
+

|
|
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
|
+
[](https://github.com/owen-alderson/outreach-desk/actions/workflows/ci.yml)
|
|
10
|
+
[](https://pypi.org/project/outreach-desk/)
|
|
11
|
+
[](https://www.npmjs.com/package/n8n-nodes-outreach-desk)
|
|
12
|
+

|
|
13
|
+
|
|
14
|
+

|
|
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:
|