openoutsend 0.1.17__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.
- openoutsend-0.1.17/.flake8 +3 -0
- openoutsend-0.1.17/.github/PULL_REQUEST_TEMPLATE.md +16 -0
- openoutsend-0.1.17/.github/workflows/deploy.yml +70 -0
- openoutsend-0.1.17/.gitignore +22 -0
- openoutsend-0.1.17/.pre-commit-config.yaml +19 -0
- openoutsend-0.1.17/LICENSE +9 -0
- openoutsend-0.1.17/PKG-INFO +163 -0
- openoutsend-0.1.17/README.md +126 -0
- openoutsend-0.1.17/cold_outreach/README.md +71 -0
- openoutsend-0.1.17/cold_outreach/__init__.py +0 -0
- openoutsend-0.1.17/cold_outreach/__main__.py +172 -0
- openoutsend-0.1.17/cold_outreach/core/__init__.py +0 -0
- openoutsend-0.1.17/cold_outreach/core/agents/__init__.py +0 -0
- openoutsend-0.1.17/cold_outreach/core/agents/outreach.py +190 -0
- openoutsend-0.1.17/cold_outreach/core/agents/prompt.py +57 -0
- openoutsend-0.1.17/cold_outreach/core/business_time.py +47 -0
- openoutsend-0.1.17/cold_outreach/core/conf.py +135 -0
- openoutsend-0.1.17/cold_outreach/core/llm.py +235 -0
- openoutsend-0.1.17/cold_outreach/core/operator.py +68 -0
- openoutsend-0.1.17/cold_outreach/core/sending_window.py +63 -0
- openoutsend-0.1.17/cold_outreach/core/templates/prompts/outreach_agent.j2 +98 -0
- openoutsend-0.1.17/cold_outreach/docs/outreach_agent.md +59 -0
- openoutsend-0.1.17/cold_outreach/docs/template-variables.md +18 -0
- openoutsend-0.1.17/cold_outreach/docs/templating.md +12 -0
- openoutsend-0.1.17/cold_outreach/emails/__init__.py +0 -0
- openoutsend-0.1.17/cold_outreach/emails/admin.py +63 -0
- openoutsend-0.1.17/cold_outreach/emails/apps.py +8 -0
- openoutsend-0.1.17/cold_outreach/emails/classify.py +192 -0
- openoutsend-0.1.17/cold_outreach/emails/delivery_policy.py +315 -0
- openoutsend-0.1.17/cold_outreach/emails/mail_pass.py +33 -0
- openoutsend-0.1.17/cold_outreach/emails/migrations/0001_initial.py +37 -0
- openoutsend-0.1.17/cold_outreach/emails/migrations/0002_mailbox_signature.py +23 -0
- openoutsend-0.1.17/cold_outreach/emails/migrations/0003_alter_mailbox_daily_limit.py +36 -0
- openoutsend-0.1.17/cold_outreach/emails/migrations/0004_alter_mailbox_daily_limit_sendverdict.py +66 -0
- openoutsend-0.1.17/cold_outreach/emails/migrations/0005_mailbox_unsub_scan_uid_and_more.py +23 -0
- openoutsend-0.1.17/cold_outreach/emails/migrations/0006_mailbox_next_send_at.py +18 -0
- openoutsend-0.1.17/cold_outreach/emails/migrations/0007_deliveryevent_foldercoverage_message_thread_and_more.py +283 -0
- openoutsend-0.1.17/cold_outreach/emails/migrations/__init__.py +0 -0
- openoutsend-0.1.17/cold_outreach/emails/models/__init__.py +30 -0
- openoutsend-0.1.17/cold_outreach/emails/models/mailbox.py +223 -0
- openoutsend-0.1.17/cold_outreach/emails/models/maillog.py +263 -0
- openoutsend-0.1.17/cold_outreach/emails/parsing.py +178 -0
- openoutsend-0.1.17/cold_outreach/emails/project.py +161 -0
- openoutsend-0.1.17/cold_outreach/emails/report.py +39 -0
- openoutsend-0.1.17/cold_outreach/emails/sender.py +296 -0
- openoutsend-0.1.17/cold_outreach/emails/smtp.py +47 -0
- openoutsend-0.1.17/cold_outreach/emails/steps/__init__.py +0 -0
- openoutsend-0.1.17/cold_outreach/emails/steps/reply.py +162 -0
- openoutsend-0.1.17/cold_outreach/emails/steps/send.py +92 -0
- openoutsend-0.1.17/cold_outreach/emails/sync.py +267 -0
- openoutsend-0.1.17/cold_outreach/emails/threads.py +92 -0
- openoutsend-0.1.17/cold_outreach/emails/warmth.py +255 -0
- openoutsend-0.1.17/cold_outreach/errors.py +15 -0
- openoutsend-0.1.17/cold_outreach/first_run.py +239 -0
- openoutsend-0.1.17/cold_outreach/leads/__init__.py +11 -0
- openoutsend-0.1.17/cold_outreach/leads/apps.py +7 -0
- openoutsend-0.1.17/cold_outreach/leads/campaigns.py +87 -0
- openoutsend-0.1.17/cold_outreach/leads/ingest.py +194 -0
- openoutsend-0.1.17/cold_outreach/leads/migrations/0001_initial.py +134 -0
- openoutsend-0.1.17/cold_outreach/leads/migrations/0002_alter_deal_state.py +28 -0
- openoutsend-0.1.17/cold_outreach/leads/migrations/__init__.py +0 -0
- openoutsend-0.1.17/cold_outreach/leads/models.py +236 -0
- openoutsend-0.1.17/cold_outreach/leads/pools.py +76 -0
- openoutsend-0.1.17/cold_outreach/leads/summaries.py +175 -0
- openoutsend-0.1.17/cold_outreach/leads/suppression.py +86 -0
- openoutsend-0.1.17/cold_outreach/send_pass.py +148 -0
- openoutsend-0.1.17/cold_outreach/settings.py +74 -0
- openoutsend-0.1.17/cold_outreach/tests/__init__.py +0 -0
- openoutsend-0.1.17/cold_outreach/tests/agents/__init__.py +0 -0
- openoutsend-0.1.17/cold_outreach/tests/agents/test_outreach.py +185 -0
- openoutsend-0.1.17/cold_outreach/tests/conftest.py +22 -0
- openoutsend-0.1.17/cold_outreach/tests/emails/__init__.py +0 -0
- openoutsend-0.1.17/cold_outreach/tests/emails/fake_imap.py +135 -0
- openoutsend-0.1.17/cold_outreach/tests/emails/maillog.py +95 -0
- openoutsend-0.1.17/cold_outreach/tests/emails/test_classify.py +142 -0
- openoutsend-0.1.17/cold_outreach/tests/emails/test_delivery_policy.py +196 -0
- openoutsend-0.1.17/cold_outreach/tests/emails/test_mail_pass.py +159 -0
- openoutsend-0.1.17/cold_outreach/tests/emails/test_mailbox.py +52 -0
- openoutsend-0.1.17/cold_outreach/tests/emails/test_project.py +204 -0
- openoutsend-0.1.17/cold_outreach/tests/emails/test_reply.py +201 -0
- openoutsend-0.1.17/cold_outreach/tests/emails/test_send.py +388 -0
- openoutsend-0.1.17/cold_outreach/tests/emails/test_smtp.py +45 -0
- openoutsend-0.1.17/cold_outreach/tests/emails/test_sync.py +280 -0
- openoutsend-0.1.17/cold_outreach/tests/emails/test_unsubscribe.py +283 -0
- openoutsend-0.1.17/cold_outreach/tests/emails/test_warmth.py +181 -0
- openoutsend-0.1.17/cold_outreach/tests/factories.py +49 -0
- openoutsend-0.1.17/cold_outreach/tests/leads/__init__.py +0 -0
- openoutsend-0.1.17/cold_outreach/tests/leads/test_campaigns.py +78 -0
- openoutsend-0.1.17/cold_outreach/tests/leads/test_ingest.py +186 -0
- openoutsend-0.1.17/cold_outreach/tests/leads/test_pools.py +116 -0
- openoutsend-0.1.17/cold_outreach/tests/leads/test_summaries.py +107 -0
- openoutsend-0.1.17/cold_outreach/tests/leads/test_suppression.py +66 -0
- openoutsend-0.1.17/cold_outreach/tests/test_cli.py +111 -0
- openoutsend-0.1.17/cold_outreach/tests/test_first_run.py +257 -0
- openoutsend-0.1.17/cold_outreach/tests/test_send_pass.py +220 -0
- openoutsend-0.1.17/cold_outreach/tests/test_sending_window.py +91 -0
- openoutsend-0.1.17/conftest.py +11 -0
- openoutsend-0.1.17/pyproject.toml +94 -0
- openoutsend-0.1.17/pytest.ini +4 -0
- openoutsend-0.1.17/roadmap/README.md +58 -0
- openoutsend-0.1.17/roadmap/history/2026-08-27-p1-e2-outsend-ingest-and-packaging.md +256 -0
- openoutsend-0.1.17/roadmap/p1-e2-email-bounce-detection-suppression.md +298 -0
- openoutsend-0.1.17/roadmap/p1-e2-inbound-mail-silent-skip.md +331 -0
- openoutsend-0.1.17/roadmap/p1-e2-sender-message-generation.md +248 -0
- openoutsend-0.1.17/roadmap/p2-e2-followup-identity-backoff-sentiment.md +90 -0
- openoutsend-0.1.17/roadmap/p2-e2-message-carries-a-face.md +119 -0
- openoutsend-0.1.17/roadmap/p2-e3-inbound-agentic-email.md +230 -0
- openoutsend-0.1.17/roadmap/p3-e1-onboarding-daily-send-cap-prompt.md +47 -0
- openoutsend-0.1.17/roadmap/p3-e2-mailbox-oauth-authentication.md +207 -0
- openoutsend-0.1.17/roadmap/p3-e2-resend-opt-in-send-transport.md +74 -0
- openoutsend-0.1.17/setup.cfg +31 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
## Description
|
|
2
|
+
<!--- Describe your changes in detail -->
|
|
3
|
+
|
|
4
|
+
## Closes issue(s)
|
|
5
|
+
<!--- e.g. closes #34 -->
|
|
6
|
+
|
|
7
|
+
## Screenshots (if appropriate)
|
|
8
|
+
|
|
9
|
+
## Changes include
|
|
10
|
+
<!--- What types of changes does your code introduce? Put an `x` in all the boxes that apply: -->
|
|
11
|
+
- [ ] Bugfix (non-breaking change that solves an issue, bump the third digit of the version)
|
|
12
|
+
- [ ] New feature (non-breaking change that adds functionality, bump the second digit of the version)
|
|
13
|
+
- [ ] Breaking change (change that is not backwards-compatible and/or changes current functionality, bump the first digit of the version)
|
|
14
|
+
- [ ] Documentation update (Don't bump the version of the project)
|
|
15
|
+
|
|
16
|
+
## Other comments
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
name: Deploy
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
|
|
7
|
+
jobs:
|
|
8
|
+
test:
|
|
9
|
+
runs-on: ubuntu-latest
|
|
10
|
+
steps:
|
|
11
|
+
- uses: actions/checkout@v4
|
|
12
|
+
|
|
13
|
+
- uses: actions/setup-python@v5
|
|
14
|
+
with:
|
|
15
|
+
python-version: "3.12"
|
|
16
|
+
|
|
17
|
+
- uses: astral-sh/setup-uv@v5
|
|
18
|
+
|
|
19
|
+
- name: Install
|
|
20
|
+
run: uv pip install --system -e ".[dev]"
|
|
21
|
+
|
|
22
|
+
- name: Run tests
|
|
23
|
+
run: pytest
|
|
24
|
+
|
|
25
|
+
# The supported install. **Every green push to `main` is a release** — the version is
|
|
26
|
+
# derived here, not committed, because PyPI numbers are single-use and permanent.
|
|
27
|
+
#
|
|
28
|
+
# This is the finder's rule, and it is here for the reason it went there: `0.1.0` was
|
|
29
|
+
# tagged by hand on that side, twelve commits landed behind it, and `uvx openoutreach`
|
|
30
|
+
# kept serving the version from before all of them. A release nobody has to remember
|
|
31
|
+
# cannot drift. The cost is accepted: every commit that passes CI is public and
|
|
32
|
+
# permanent, so `main` is the release branch and `needs: test` is the whole gate.
|
|
33
|
+
publish-pypi:
|
|
34
|
+
if: github.ref == 'refs/heads/main'
|
|
35
|
+
needs: test
|
|
36
|
+
runs-on: ubuntu-latest
|
|
37
|
+
# Required: the PyPI trusted publisher is registered against this workflow filename
|
|
38
|
+
# *and* this environment name. Do not add a required reviewer to it — that would put
|
|
39
|
+
# every push behind a manual approval, which is the thing this job exists to remove.
|
|
40
|
+
environment: pypi
|
|
41
|
+
permissions:
|
|
42
|
+
id-token: write # PyPI trusted publishing
|
|
43
|
+
steps:
|
|
44
|
+
- uses: actions/checkout@v4
|
|
45
|
+
with:
|
|
46
|
+
fetch-depth: 0 # the version is a commit count; a shallow clone cannot count
|
|
47
|
+
|
|
48
|
+
# `0.1.0` in pyproject.toml is the *base*: major and minor are declared there and
|
|
49
|
+
# bumped by hand when the change deserves it; the patch is how many commits this
|
|
50
|
+
# repo has. Monotonic across a base bump (0.1.20 → 0.2.21), unique per commit, and
|
|
51
|
+
# nothing has to be committed for it. The finder counts from its `v0.1.0` tag; this
|
|
52
|
+
# repo has no tags, so the count starts at the first commit.
|
|
53
|
+
- name: Derive the version from the commit count
|
|
54
|
+
id: version
|
|
55
|
+
run: |
|
|
56
|
+
set -euo pipefail
|
|
57
|
+
base=$(grep -m1 '^version = ' pyproject.toml | cut -d'"' -f2 | cut -d. -f1,2)
|
|
58
|
+
patch=$(git rev-list --count HEAD)
|
|
59
|
+
version="${base}.${patch}"
|
|
60
|
+
echo "Releasing ${version}"
|
|
61
|
+
sed -i "0,/^version = .*/s//version = \"${version}\"/" pyproject.toml
|
|
62
|
+
echo "value=${version}" >> "$GITHUB_OUTPUT"
|
|
63
|
+
|
|
64
|
+
- name: Build sdist + wheel
|
|
65
|
+
run: pipx run build
|
|
66
|
+
|
|
67
|
+
- name: Publish to PyPI
|
|
68
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
69
|
+
with:
|
|
70
|
+
skip-existing: true # a re-run of an already-published commit is a no-op
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
exclude: 'docs|node_modules|migrations|.git|.tox'
|
|
2
|
+
default_stages: [pre-commit]
|
|
3
|
+
fail_fast: true
|
|
4
|
+
default_language_version:
|
|
5
|
+
python: python3.13
|
|
6
|
+
|
|
7
|
+
repos:
|
|
8
|
+
- repo: https://github.com/pre-commit/pre-commit-hooks
|
|
9
|
+
rev: v6.0.0
|
|
10
|
+
hooks:
|
|
11
|
+
- id: trailing-whitespace
|
|
12
|
+
- id: end-of-file-fixer
|
|
13
|
+
- id: check-yaml
|
|
14
|
+
|
|
15
|
+
- repo: https://github.com/astral-sh/ruff-pre-commit
|
|
16
|
+
rev: v0.15.7
|
|
17
|
+
hooks:
|
|
18
|
+
- id: ruff
|
|
19
|
+
- id: ruff-format
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
Copyright (c) 2026 eracle
|
|
2
|
+
Copyright (c) 2020 Rootstrap
|
|
3
|
+
Copyright (c) 2012 Zapier Inc.
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: openoutsend
|
|
3
|
+
Version: 0.1.17
|
|
4
|
+
Summary: The sending half of OpenOutreach: reads qualified leads on stdin and mails them from your own box.
|
|
5
|
+
Project-URL: Homepage, https://github.com/eracle/OpenOutSend
|
|
6
|
+
Project-URL: Finder, https://github.com/eracle/OpenOutreach
|
|
7
|
+
Author-email: OpenOutreach <hello@openoutreach.app>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: b2b,cold-email,imap,open-source,outreach,sales-automation,self-hosted,smtp
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Framework :: Django
|
|
14
|
+
Classifier: Intended Audience :: Developers
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Office/Business
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Requires-Dist: django<7.0,>=5.2
|
|
22
|
+
Requires-Dist: griffe<2,>=1
|
|
23
|
+
Requires-Dist: imapclient<4,>=3
|
|
24
|
+
Requires-Dist: jinja2<4,>=3.1
|
|
25
|
+
Requires-Dist: pydantic-ai-slim[anthropic,bedrock,cohere,google,groq,mistral,openai]<3,>=2
|
|
26
|
+
Requires-Dist: pydantic<3,>=2.9
|
|
27
|
+
Requires-Dist: pytz>=2024.1
|
|
28
|
+
Requires-Dist: tenacity<10,>=8
|
|
29
|
+
Requires-Dist: termcolor<4,>=2.4
|
|
30
|
+
Provides-Extra: dev
|
|
31
|
+
Requires-Dist: factory-boy>=3.3; extra == 'dev'
|
|
32
|
+
Requires-Dist: pytest-cov>=5; extra == 'dev'
|
|
33
|
+
Requires-Dist: pytest-django>=4.8; extra == 'dev'
|
|
34
|
+
Requires-Dist: pytest-mock>=3.14; extra == 'dev'
|
|
35
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
[](https://opensource.org/licenses/MIT)
|
|
39
|
+
|
|
40
|
+
# OpenOutSend
|
|
41
|
+
|
|
42
|
+
The **sending half** of [OpenOutreach](https://github.com/eracle/OpenOutreach). OpenOutreach finds and
|
|
43
|
+
qualifies B2B leads and prints them; it does not send email. This is what sends them.
|
|
44
|
+
|
|
45
|
+
The boundary between the two is a pipe, and nothing else crosses it:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
openoutreach find 50 --json | outsend
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`find` writes qualified leads as JSON Lines on stdout. `outsend` reads them, stores them in its own
|
|
52
|
+
database, and exits — it transmits nothing at that moment. Delivery, pacing and whatever step
|
|
53
|
+
structure it grows are its own business, on its own clock.
|
|
54
|
+
|
|
55
|
+
The pipe is **one-way by design**. Every consumer sees the same bytes, so a file dropped into Instantly
|
|
56
|
+
or Smartlead gets exactly what this receiver gets, and "our own sender has no privileged path" is held
|
|
57
|
+
by construction rather than by discipline.
|
|
58
|
+
|
|
59
|
+
## Status: it runs end to end — install, pipe, connect a box, send
|
|
60
|
+
|
|
61
|
+
**`outsend` is a command and the store is its own.** Install it, pipe leads in, and they land as rows;
|
|
62
|
+
a second, separate invocation is what mails them:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
pip install -e .
|
|
66
|
+
outsend init --campaign devtools # once: what you sell, who you are, a box
|
|
67
|
+
openoutreach find 50 --json | outsend --campaign devtools # store
|
|
68
|
+
outsend send --campaign devtools # read, answer, open
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The two invocations are separate on purpose: a pipe's right-hand side must not block on the network
|
|
72
|
+
while a producer is still writing, and the cadences differ — leads arrive when `find` runs, mail moves
|
|
73
|
+
on the mailbox's clock. So the cron line is two entries, not one command doing both.
|
|
74
|
+
|
|
75
|
+
It reads JSON Lines on stdin, upserts on `(lead_id, campaign)`, checks every address against the
|
|
76
|
+
suppression list at the door, skips and counts a malformed line, prints the campaign it resolved and
|
|
77
|
+
the counts to **stderr**, and exits 0 when every line became a row. Its database is
|
|
78
|
+
`~/.openoutsend/data/db.sqlite3` (`OUTSEND_HOME` / `OUTSEND_DB` override it) and it migrates itself on
|
|
79
|
+
first run, so a fresh install is an ingest that works rather than a traceback.
|
|
80
|
+
|
|
81
|
+
**`outsend send` is one bounded pass, not a daemon.** It reads the mail, answers every thread the lead
|
|
82
|
+
has replied in, opens as many first emails as the guards allow, and exits — cadence is a timer's job.
|
|
83
|
+
Reading first is what makes the other two honest: an opt-out that arrived overnight suppresses the
|
|
84
|
+
person before anything is written to them. Openers are the only cold volume, so they are the only
|
|
85
|
+
thing under a daily cap, a spacing clock and a sending window; a reply obeys none of the three.
|
|
86
|
+
**`outsend init` collects what a first run needs** — what the campaign sells and to whom, the name
|
|
87
|
+
that signs the mail, and a mailbox to send it from — and it runs implicitly on the first send, so a
|
|
88
|
+
setup step is never something a timer discovers. The environment first, prompts second and **only on a
|
|
89
|
+
terminal**; headless, whatever is still missing is one error naming every variable that would have
|
|
90
|
+
answered it. The mailbox is stored only once its credentials pass an SMTP login, because the provider
|
|
91
|
+
has no health API and that login is the only gate there is.
|
|
92
|
+
|
|
93
|
+
**Releases are every green push to `main`** (`.github/workflows/deploy.yml`): tests, then a build and a
|
|
94
|
+
PyPI upload over trusted publishing, with the version derived from the commit count rather than
|
|
95
|
+
committed — the finder's rule, for the reason it went there, since a release nobody has to remember
|
|
96
|
+
cannot drift. No token is stored anywhere; the publisher is registered against the workflow filename
|
|
97
|
+
and the `pypi` environment, so neither may be renamed.
|
|
98
|
+
|
|
99
|
+
Still open: arming that (a PyPI pending publisher and the `pypi` environment are two browser steps),
|
|
100
|
+
and then `pip install openoutreach[send]`, which can only be declared once this distribution is on
|
|
101
|
+
PyPI.
|
|
102
|
+
|
|
103
|
+
## Tests
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
pytest
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
pytest-django against a throwaway state dir (`conftest.py` redirects `OUTSEND_HOME` before Django
|
|
110
|
+
loads, so a test run never touches `~/.openoutsend`). Nothing is skipped or ignored: the files that
|
|
111
|
+
came across with the transport now assert against this side's own models.
|
|
112
|
+
|
|
113
|
+
## Layout
|
|
114
|
+
|
|
115
|
+
| Path | What it is |
|
|
116
|
+
| --- | --- |
|
|
117
|
+
| `cold_outreach/leads/` | what comes through the pipe — the models, ingest, suppression, the facts extraction |
|
|
118
|
+
| `cold_outreach/emails/` | the transport — SMTP, IMAP sync, the mail pass, threads, delivery policy, warmth |
|
|
119
|
+
| `cold_outreach/core/` | the outreach agent, its templates, and the sending window |
|
|
120
|
+
| `cold_outreach/docs/` | how the agent and its templating work |
|
|
121
|
+
| `cold_outreach/settings.py` | this repo's own Django settings and the state dir |
|
|
122
|
+
| `cold_outreach/send_pass.py` | one pass — read, answer, open — and the line saying what held it |
|
|
123
|
+
| `cold_outreach/first_run.py` | what `init` collects — the campaign's fields, the operator, the mailbox |
|
|
124
|
+
| `cold_outreach/__main__.py` | the `outsend` console script |
|
|
125
|
+
| `roadmap/` | open work, mostly inherited from OpenOutreach along with the code it describes |
|
|
126
|
+
|
|
127
|
+
## Configuration
|
|
128
|
+
|
|
129
|
+
The environment is the operator seam — the only way in a timer has:
|
|
130
|
+
|
|
131
|
+
| | |
|
|
132
|
+
| --- | --- |
|
|
133
|
+
| `OUTSEND_OPERATOR_COUNTRY` | ISO 3166 alpha-2; resolves the local clock the sending window is measured in |
|
|
134
|
+
| `OUTSEND_AI_MODEL` | a pydantic-ai `provider:model` id, e.g. `anthropic:claude-sonnet-4-5-20250929` |
|
|
135
|
+
| `OUTSEND_LLM_API_KEY` / `OUTSEND_LLM_API_BASE` | credentials for it |
|
|
136
|
+
| `OUTSEND_PRODUCT_DOCS` / `OUTSEND_CAMPAIGN_TARGET` / `OUTSEND_BOOKING_LINK` | what a campaign writes from; `outsend init` also asks for these on a terminal |
|
|
137
|
+
| `OUTSEND_OPERATOR_NAME` / `OUTSEND_OPERATOR_EMAIL` | who signs the mail, and the address every send is blind-copied to (blank for none) |
|
|
138
|
+
| `OUTSEND_MAILBOX_ADDRESS` / `OUTSEND_MAILBOX_PASSWORD` | the box to send from, and its **app password** — a Google box rejects the login password |
|
|
139
|
+
| `OUTSEND_SMTP_HOST` / `OUTSEND_SMTP_PORT` / `OUTSEND_IMAP_HOST` / `OUTSEND_IMAP_PORT` | only for a box that is not on Google Workspace; those four default to Gmail's and are never prompted for |
|
|
140
|
+
| `OUTSEND_SIGNATURE` | the sign-off appended to every send from that box; empty declines one for good |
|
|
141
|
+
| `OUTSEND_HOME` / `OUTSEND_DB` | where the store lives |
|
|
142
|
+
|
|
143
|
+
## The contract it has to implement
|
|
144
|
+
|
|
145
|
+
The full design — what crosses, the record's field set, exit-code meaning, and why ingest is
|
|
146
|
+
idempotent — lives in
|
|
147
|
+
[`roadmap/p1-e2-find-send-boundary-contract.md`](https://github.com/eracle/openoutreach-docs/blob/main/roadmap/p1-e2-find-send-boundary-contract.md)
|
|
148
|
+
in the `openoutreach-docs` repo. The parts this side owes:
|
|
149
|
+
|
|
150
|
+
- **Ingest is idempotent**, keyed on `(lead_id, campaign)`, so the pipe is allowed to be lossy and
|
|
151
|
+
recovery is running it again.
|
|
152
|
+
- **Suppression is checked at the door and is terminal** — the legal duty came here with `emails/`, and
|
|
153
|
+
a re-ingest must never resurrect somebody who opted out. An address that *changed* is re-checked,
|
|
154
|
+
because ingest is lead-keyed while suppression is address-keyed.
|
|
155
|
+
- **Conflicts resolve latest-wins**, field by field: a re-ingest is a correction, not a duplicate.
|
|
156
|
+
- **A malformed line is skipped and counted**, named on stderr, with a non-zero exit — `find` spent
|
|
157
|
+
real money on the rows behind it, so aborting the batch throws away paid work.
|
|
158
|
+
- **A blank `email` is stored, not rejected.** An exportable row is not a mailable one; the address is
|
|
159
|
+
an enrichment that a later run fills in for free.
|
|
160
|
+
|
|
161
|
+
## License
|
|
162
|
+
|
|
163
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
[](https://opensource.org/licenses/MIT)
|
|
2
|
+
|
|
3
|
+
# OpenOutSend
|
|
4
|
+
|
|
5
|
+
The **sending half** of [OpenOutreach](https://github.com/eracle/OpenOutreach). OpenOutreach finds and
|
|
6
|
+
qualifies B2B leads and prints them; it does not send email. This is what sends them.
|
|
7
|
+
|
|
8
|
+
The boundary between the two is a pipe, and nothing else crosses it:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
openoutreach find 50 --json | outsend
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
`find` writes qualified leads as JSON Lines on stdout. `outsend` reads them, stores them in its own
|
|
15
|
+
database, and exits — it transmits nothing at that moment. Delivery, pacing and whatever step
|
|
16
|
+
structure it grows are its own business, on its own clock.
|
|
17
|
+
|
|
18
|
+
The pipe is **one-way by design**. Every consumer sees the same bytes, so a file dropped into Instantly
|
|
19
|
+
or Smartlead gets exactly what this receiver gets, and "our own sender has no privileged path" is held
|
|
20
|
+
by construction rather than by discipline.
|
|
21
|
+
|
|
22
|
+
## Status: it runs end to end — install, pipe, connect a box, send
|
|
23
|
+
|
|
24
|
+
**`outsend` is a command and the store is its own.** Install it, pipe leads in, and they land as rows;
|
|
25
|
+
a second, separate invocation is what mails them:
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
pip install -e .
|
|
29
|
+
outsend init --campaign devtools # once: what you sell, who you are, a box
|
|
30
|
+
openoutreach find 50 --json | outsend --campaign devtools # store
|
|
31
|
+
outsend send --campaign devtools # read, answer, open
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The two invocations are separate on purpose: a pipe's right-hand side must not block on the network
|
|
35
|
+
while a producer is still writing, and the cadences differ — leads arrive when `find` runs, mail moves
|
|
36
|
+
on the mailbox's clock. So the cron line is two entries, not one command doing both.
|
|
37
|
+
|
|
38
|
+
It reads JSON Lines on stdin, upserts on `(lead_id, campaign)`, checks every address against the
|
|
39
|
+
suppression list at the door, skips and counts a malformed line, prints the campaign it resolved and
|
|
40
|
+
the counts to **stderr**, and exits 0 when every line became a row. Its database is
|
|
41
|
+
`~/.openoutsend/data/db.sqlite3` (`OUTSEND_HOME` / `OUTSEND_DB` override it) and it migrates itself on
|
|
42
|
+
first run, so a fresh install is an ingest that works rather than a traceback.
|
|
43
|
+
|
|
44
|
+
**`outsend send` is one bounded pass, not a daemon.** It reads the mail, answers every thread the lead
|
|
45
|
+
has replied in, opens as many first emails as the guards allow, and exits — cadence is a timer's job.
|
|
46
|
+
Reading first is what makes the other two honest: an opt-out that arrived overnight suppresses the
|
|
47
|
+
person before anything is written to them. Openers are the only cold volume, so they are the only
|
|
48
|
+
thing under a daily cap, a spacing clock and a sending window; a reply obeys none of the three.
|
|
49
|
+
**`outsend init` collects what a first run needs** — what the campaign sells and to whom, the name
|
|
50
|
+
that signs the mail, and a mailbox to send it from — and it runs implicitly on the first send, so a
|
|
51
|
+
setup step is never something a timer discovers. The environment first, prompts second and **only on a
|
|
52
|
+
terminal**; headless, whatever is still missing is one error naming every variable that would have
|
|
53
|
+
answered it. The mailbox is stored only once its credentials pass an SMTP login, because the provider
|
|
54
|
+
has no health API and that login is the only gate there is.
|
|
55
|
+
|
|
56
|
+
**Releases are every green push to `main`** (`.github/workflows/deploy.yml`): tests, then a build and a
|
|
57
|
+
PyPI upload over trusted publishing, with the version derived from the commit count rather than
|
|
58
|
+
committed — the finder's rule, for the reason it went there, since a release nobody has to remember
|
|
59
|
+
cannot drift. No token is stored anywhere; the publisher is registered against the workflow filename
|
|
60
|
+
and the `pypi` environment, so neither may be renamed.
|
|
61
|
+
|
|
62
|
+
Still open: arming that (a PyPI pending publisher and the `pypi` environment are two browser steps),
|
|
63
|
+
and then `pip install openoutreach[send]`, which can only be declared once this distribution is on
|
|
64
|
+
PyPI.
|
|
65
|
+
|
|
66
|
+
## Tests
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
pytest
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
pytest-django against a throwaway state dir (`conftest.py` redirects `OUTSEND_HOME` before Django
|
|
73
|
+
loads, so a test run never touches `~/.openoutsend`). Nothing is skipped or ignored: the files that
|
|
74
|
+
came across with the transport now assert against this side's own models.
|
|
75
|
+
|
|
76
|
+
## Layout
|
|
77
|
+
|
|
78
|
+
| Path | What it is |
|
|
79
|
+
| --- | --- |
|
|
80
|
+
| `cold_outreach/leads/` | what comes through the pipe — the models, ingest, suppression, the facts extraction |
|
|
81
|
+
| `cold_outreach/emails/` | the transport — SMTP, IMAP sync, the mail pass, threads, delivery policy, warmth |
|
|
82
|
+
| `cold_outreach/core/` | the outreach agent, its templates, and the sending window |
|
|
83
|
+
| `cold_outreach/docs/` | how the agent and its templating work |
|
|
84
|
+
| `cold_outreach/settings.py` | this repo's own Django settings and the state dir |
|
|
85
|
+
| `cold_outreach/send_pass.py` | one pass — read, answer, open — and the line saying what held it |
|
|
86
|
+
| `cold_outreach/first_run.py` | what `init` collects — the campaign's fields, the operator, the mailbox |
|
|
87
|
+
| `cold_outreach/__main__.py` | the `outsend` console script |
|
|
88
|
+
| `roadmap/` | open work, mostly inherited from OpenOutreach along with the code it describes |
|
|
89
|
+
|
|
90
|
+
## Configuration
|
|
91
|
+
|
|
92
|
+
The environment is the operator seam — the only way in a timer has:
|
|
93
|
+
|
|
94
|
+
| | |
|
|
95
|
+
| --- | --- |
|
|
96
|
+
| `OUTSEND_OPERATOR_COUNTRY` | ISO 3166 alpha-2; resolves the local clock the sending window is measured in |
|
|
97
|
+
| `OUTSEND_AI_MODEL` | a pydantic-ai `provider:model` id, e.g. `anthropic:claude-sonnet-4-5-20250929` |
|
|
98
|
+
| `OUTSEND_LLM_API_KEY` / `OUTSEND_LLM_API_BASE` | credentials for it |
|
|
99
|
+
| `OUTSEND_PRODUCT_DOCS` / `OUTSEND_CAMPAIGN_TARGET` / `OUTSEND_BOOKING_LINK` | what a campaign writes from; `outsend init` also asks for these on a terminal |
|
|
100
|
+
| `OUTSEND_OPERATOR_NAME` / `OUTSEND_OPERATOR_EMAIL` | who signs the mail, and the address every send is blind-copied to (blank for none) |
|
|
101
|
+
| `OUTSEND_MAILBOX_ADDRESS` / `OUTSEND_MAILBOX_PASSWORD` | the box to send from, and its **app password** — a Google box rejects the login password |
|
|
102
|
+
| `OUTSEND_SMTP_HOST` / `OUTSEND_SMTP_PORT` / `OUTSEND_IMAP_HOST` / `OUTSEND_IMAP_PORT` | only for a box that is not on Google Workspace; those four default to Gmail's and are never prompted for |
|
|
103
|
+
| `OUTSEND_SIGNATURE` | the sign-off appended to every send from that box; empty declines one for good |
|
|
104
|
+
| `OUTSEND_HOME` / `OUTSEND_DB` | where the store lives |
|
|
105
|
+
|
|
106
|
+
## The contract it has to implement
|
|
107
|
+
|
|
108
|
+
The full design — what crosses, the record's field set, exit-code meaning, and why ingest is
|
|
109
|
+
idempotent — lives in
|
|
110
|
+
[`roadmap/p1-e2-find-send-boundary-contract.md`](https://github.com/eracle/openoutreach-docs/blob/main/roadmap/p1-e2-find-send-boundary-contract.md)
|
|
111
|
+
in the `openoutreach-docs` repo. The parts this side owes:
|
|
112
|
+
|
|
113
|
+
- **Ingest is idempotent**, keyed on `(lead_id, campaign)`, so the pipe is allowed to be lossy and
|
|
114
|
+
recovery is running it again.
|
|
115
|
+
- **Suppression is checked at the door and is terminal** — the legal duty came here with `emails/`, and
|
|
116
|
+
a re-ingest must never resurrect somebody who opted out. An address that *changed* is re-checked,
|
|
117
|
+
because ingest is lead-keyed while suppression is address-keyed.
|
|
118
|
+
- **Conflicts resolve latest-wins**, field by field: a re-ingest is a correction, not a duplicate.
|
|
119
|
+
- **A malformed line is skipped and counted**, named on stderr, with a non-zero exit — `find` spent
|
|
120
|
+
real money on the rows behind it, so aborting the batch throws away paid work.
|
|
121
|
+
- **A blank `email` is stored, not rejected.** An exportable row is not a mailable one; the address is
|
|
122
|
+
an enrichment that a later run fills in for free.
|
|
123
|
+
|
|
124
|
+
## License
|
|
125
|
+
|
|
126
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# cold_outreach — ported from OpenOutreach, not yet wired up
|
|
2
|
+
|
|
3
|
+
This directory is **parked code**, and it is now the whole of this repo. Nothing imports it,
|
|
4
|
+
nothing runs it, and it is not on `INSTALLED_APPS`. It is the cold-outreach half of
|
|
5
|
+
[OpenOutreach](https://github.com/eracle/OpenOutreach), moved here whole so it can be integrated
|
|
6
|
+
deliberately rather than rewritten from memory.
|
|
7
|
+
|
|
8
|
+
## Why it moved
|
|
9
|
+
|
|
10
|
+
OpenOutreach became a **lead finder**: product description in, qualified leads out, with the reason
|
|
11
|
+
each one was chosen, handed over as CSV. Sending is a specialism it was not good at, and a good lead
|
|
12
|
+
list ruined by a bad opener reads to the buyer as a bad lead list. So the sending half comes here,
|
|
13
|
+
where the whole job is the message.
|
|
14
|
+
|
|
15
|
+
The boundary between the two is **one-way and public**: OpenOutreach exports leads, and nothing flows
|
|
16
|
+
back. Whatever this code becomes, it must read that same export — no shared model, no direct import,
|
|
17
|
+
no DB join. OpenOutSend gets no privileged path that Instantly or Smartlead would not get.
|
|
18
|
+
|
|
19
|
+
The design decisions behind that split live in `roadmap/p1-e3-leadfinder-sequencer-boundary.md` in the
|
|
20
|
+
`openoutreach-docs` repo. Read it before integrating; several decisions were reversed after the fact
|
|
21
|
+
and the reasoning is recorded there rather than in either repo's git history.
|
|
22
|
+
|
|
23
|
+
## What is here
|
|
24
|
+
|
|
25
|
+
Layout mirrors OpenOutreach's, so the original import paths still read correctly.
|
|
26
|
+
|
|
27
|
+
| path | what it is |
|
|
28
|
+
|---|---|
|
|
29
|
+
| `emails/models/mailbox.py` | `Mailbox` — one SMTP sending inbox, plus the pool-level pacing manager (`free_for_first_email`, `remaining_today`) and the measured daily cap. |
|
|
30
|
+
| `emails/models/maillog.py` | `Message` / `Thread` / `DeliveryEvent` / `FolderCoverage` — the mail log. The record and its interpretation are separate objects; `processed_at IS NULL` is the third state. |
|
|
31
|
+
| `emails/sync.py`, `classify.py`, `project.py`, `mail_pass.py` | The mail pass, in its three ordered jobs: sync (IMAP → rows, the only network step), classify (pure, versioned, over stored bytes), project (bounces → `DeliveryEvent`, opt-outs → suppression). |
|
|
32
|
+
| `emails/threads.py` | Union-find over Message-IDs, so a reply carrying only `In-Reply-To` still reaches its deal. |
|
|
33
|
+
| `emails/parsing.py` | Message parsing helpers. |
|
|
34
|
+
| `emails/sender.py`, `smtp.py` | The send path: body → signature → opt-out line → attribution, `List-Unsubscribe`, SMTP auth. |
|
|
35
|
+
| `emails/warmth.py`, `delivery_policy.py`, `report.py` | The three send guards — hours, rate, volume — and the receiver feedback that moves them. The daily cap is **measured** from the box's own Sent folder, not configured. |
|
|
36
|
+
| `emails/steps/send.py`, `steps/reply.py` | The two pipeline steps: send one opener, answer one reply. |
|
|
37
|
+
| `emails/admin.py`, `apps.py`, `migrations/` | Django wiring and the full migration history of the `emails` app. |
|
|
38
|
+
| `core/agents/outreach.py`, `prompt.py` | The single outreach agent that owns the whole thread — cold open and every reply. Its job is **Mom Test research, not selling**: learn how the lead works today, never pitch unprompted. |
|
|
39
|
+
| `core/templates/prompts/outreach_agent.j2` | That agent's prompt. |
|
|
40
|
+
| `core/sending_window.py` | Mon–Fri, 08:00–20:00 in the *operator's* timezone, derived from their onboarding country. Openers wait for it; replies never come through it. |
|
|
41
|
+
| `tests/` | The suite for all of the above, moved unchanged. |
|
|
42
|
+
|
|
43
|
+
## What it still expects, and does not find here
|
|
44
|
+
|
|
45
|
+
Every one of these is an OpenOutreach import that did **not** come along. They are the integration
|
|
46
|
+
work, and most of them are the boundary in disguise:
|
|
47
|
+
|
|
48
|
+
| import | what it was | what it has to become |
|
|
49
|
+
|---|---|---|
|
|
50
|
+
| `openoutreach.crm.models` (`Deal`, `Lead`, `DealState`) | the lead, the campaign-scoped deal, the state machine | **the port's main question.** A sequencer's unit is a recipient in a sequence, not a `Deal` in a finder's FSM. Do not recreate the finder's states here — take the exported row (`email, first_name, last_name, company, title, website, linkedin_url, reason, lead_id`) as the input and model the rest yourself. `lead_id` is the finder's join key; carry it, don't resolve it. |
|
|
51
|
+
| `openoutreach.core.db.leads` (`suppress_email`) | permanent, account-level opt-out on `Lead.disqualified` | **the opt-out duty moves with this code.** A finder that never contacts anyone is not the sender under CAN-SPAM / GDPR / CASL, so the `List-Unsubscribe` header, the `+unsub` alias, the mailbox scan and the agent's `suppress` action are all this side's obligation now. Suppression needs a home here. |
|
|
52
|
+
| `openoutreach.core.db.deals`, `db.summaries` | deal creation and conversation summaries | conversation state, so it belongs on this side; re-model against whatever replaces `Deal`. |
|
|
53
|
+
| `openoutreach.core.llm` | the pydantic-ai model factory (`SiteConfig.ai_model` + key) | needs an equivalent, or accept an injected agent. |
|
|
54
|
+
| `openoutreach.core.models.SiteConfig` | the config singleton — `country_code` drives the sending window | `sending_window.py` reads it directly; give it a seam rather than a singleton. |
|
|
55
|
+
| `openoutreach.core.conf` | `WARM_*`, `MIN_SEND_INTERVAL_SECONDS`, `SEND_WINDOW_*` | copy the constants across; `WARM_CEILING_SENDS` is **derived** from the window and the pacing, so keep the arithmetic rather than re-declaring a number. |
|
|
56
|
+
| `openoutreach.core.business_time` | Mon–Fri, used by the sending window and by thread age | small and self-contained; copy it. |
|
|
57
|
+
| `openoutreach.core.operator` | the operator `User` — the BCC target on their own campaigns | an account concept this app already has in some form. |
|
|
58
|
+
| `openoutreach.core.cycle` | the daemon loop that called these steps | **do not port.** The loop is the finder's, and it deleted its own daemon in favour of one bounded verb behind a timer. Sending here wants a cadence, not a resident process. |
|
|
59
|
+
| `openoutreach.core.logblock` | log formatting | cosmetic; drop or replace. |
|
|
60
|
+
|
|
61
|
+
## Two things not to lose
|
|
62
|
+
|
|
63
|
+
**The freemium promotional campaign does not come with this code.** OpenOutreach shipped a campaign
|
|
64
|
+
advertising itself, sent from the operator's own mailbox. It has been reclassified from growth asset
|
|
65
|
+
to install tax and dies with the sender — it should not reappear here. `LEGAL_NOTICE.md` §4 in
|
|
66
|
+
OpenOutreach is the disclosure that describes it.
|
|
67
|
+
|
|
68
|
+
**The measured send cap is the good part.** `warmth.py` reads the box's own Sent folder and derives a
|
|
69
|
+
daily ceiling from what that mailbox already does, rather than taking a configured number. It replaced
|
|
70
|
+
a declared 50/day that nobody had measured. Whatever this becomes, keep the principle: the receiver
|
|
71
|
+
counts every message the box emits, so the ledger has to count the same things.
|
|
File without changes
|