OpenOutSend
The sending half of OpenOutreach. OpenOutreach finds and qualifies B2B leads and prints them; it does not send email. This is what sends them.
The boundary between the two is a pipe, and nothing else crosses it:
openoutreach find 50 --json | outsend
find writes qualified leads as JSON Lines on stdout. outsend reads them, stores them in its own
database, and exits — it transmits nothing at that moment. Delivery, pacing and whatever step
structure it grows are its own business, on its own clock.
The pipe is one-way by design. Every consumer sees the same bytes, so a file dropped into Instantly or Smartlead gets exactly what this receiver gets, and "our own sender has no privileged path" is held by construction rather than by discipline.
Status: it runs end to end — install, pipe, connect a box, send
outsend is a command and the store is its own. Install it, pipe leads in, and they land as rows;
a second, separate invocation is what mails them:
pip install -e .
outsend init --campaign devtools # once: what you sell, who you are, a box
openoutreach find 50 --json | outsend --campaign devtools # store
outsend send --campaign devtools # read, answer, follow up, open
The two invocations are separate on purpose: a pipe's right-hand side must not block on the network
while a producer is still writing, and the cadences differ — leads arrive when find runs, mail moves
on the mailbox's clock. So the cron line is two entries, not one command doing both.
It reads JSON Lines on stdin, upserts on (lead_id, campaign), checks every address against the
suppression list at the door, skips and counts a malformed line, prints the campaign it resolved and
the counts to stderr, and exits 0 when every line became a row. Its database is
~/.openoutsend/data/db.sqlite3 (OUTSEND_HOME / OUTSEND_DB override it) and it migrates itself on
first run, so a fresh install is an ingest that works rather than a traceback.
outsend send is one bounded pass, not a daemon. It reads the mail, answers every thread the lead
has replied in, writes again to the ones who went quiet, opens as many first emails as the guards
allow, and exits — cadence is a timer's job. Reading first is what makes the rest honest: an opt-out
that arrived overnight suppresses the person before anything is written to them.
outsend send N opens up to N first emails in this one call, instead of the roughly-one-per-box
a bare outsend send stops at. It sleeps through each box's own ~3.5–4.5 minute spacing clock to keep
going — the same class of short, bounded wait the finder already sleeps through on a provider retry —
but stops the moment the wall holding it up is the sending window or the day's headroom, since those
are hours-to-a-day, not minutes, and sleeping through that inside one process is exactly the
"residency" the daemon this project replaced was deleted for. So find N emails --json | outsend && outsend send N needs no cron at all for a goal inside one day's headroom; past that, whatever
re-invokes it next (a timer, or you) picks up where it stopped — nothing here is state a lost process
would strand.
A lead who never answers gets two more emails, then the pursuit ends — after three working days,
then five, and the deal closes as unresponsive. A reply at any point ends the sequence and the
conversation takes over. Follow-ups are cold volume and are treated as such: they share one daily
cap, one spacing clock and one sending window with the openers, rather than claiming ahead of them out
of a budget of their own. A reply obeys none of the three, because answering someone who wrote to you
is not cold volume and holding the answer until Monday is worse than sending it at 21:00.
outsend init collects what a first run needs — what the campaign sells and to whom, the name
that signs the mail, and a mailbox to send it from — and it runs implicitly on the first send, so a
setup step is never something a timer discovers. The environment first, prompts second and only on a
terminal; headless, whatever is still missing is one error naming every variable that would have
answered it. The mailbox is stored only once its credentials pass an SMTP login, because the provider
has no health API and that login is the only gate there is.
Releases are every green push to main (.github/workflows/deploy.yml): tests, then a build and a
PyPI upload over trusted publishing, with the version derived from the commit count rather than
committed — the finder's rule, for the reason it went there, since a release nobody has to remember
cannot drift. No token is stored anywhere; the publisher is registered against the workflow filename
and the pypi environment, so neither may be renamed.
Still open: arming that (a PyPI pending publisher and the pypi environment are two browser steps),
and then pip install openoutreach[send], which can only be declared once this distribution is on
PyPI.
Tests
pytest
pytest-django against a throwaway state dir (conftest.py redirects OUTSEND_HOME before Django
loads, so a test run never touches ~/.openoutsend). Nothing is skipped or ignored: the files that
came across with the transport now assert against this side's own models.
Layout
| Path | What it is |
|---|---|
cold_outreach/leads/ |
what comes through the pipe — the models, ingest, suppression, the facts extraction |
cold_outreach/emails/ |
the transport — SMTP, IMAP sync, the mail pass, threads, delivery policy, warmth |
cold_outreach/core/ |
the outreach agent, its templates, and the sending window |
cold_outreach/docs/ |
how the agent and its templating work |
cold_outreach/settings.py |
this repo's own Django settings and the state dir |
cold_outreach/send_pass.py |
one pass — read, answer, open — and the line saying what held it |
cold_outreach/first_run.py |
what init collects — the campaign's fields, the operator, the mailbox |
cold_outreach/__main__.py |
the outsend console script |
roadmap/ |
open work, mostly inherited from OpenOutreach along with the code it describes |
Configuration
The environment is the operator seam — the only way in a timer has:
OUTSEND_OPERATOR_COUNTRY |
ISO 3166 alpha-2; resolves the local clock the sending window is measured in |
OUTSEND_AI_MODEL |
a pydantic-ai provider:model id, e.g. anthropic:claude-sonnet-4-5-20250929 |
OUTSEND_LLM_API_KEY / OUTSEND_LLM_API_BASE |
credentials for it |
OUTSEND_PRODUCT_DOCS / OUTSEND_CAMPAIGN_TARGET / OUTSEND_BOOKING_LINK |
what a campaign writes from; outsend init also asks for these on a terminal |
OUTSEND_OPERATOR_NAME / OUTSEND_OPERATOR_EMAIL |
who signs the mail, and the address every send is blind-copied to (blank for none) |
OUTSEND_MAILBOX_ADDRESS / OUTSEND_MAILBOX_PASSWORD |
the box to send from, and its app password — a Google box rejects the login password |
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 |
OUTSEND_SIGNATURE |
the sign-off appended to every send from that box; empty declines one for good |
OUTSEND_HOME / OUTSEND_DB |
where the store lives |
The contract it has to implement
The full design — what crosses, the record's field set, exit-code meaning, and why ingest is
idempotent — lives in
roadmap/p1-e2-find-send-boundary-contract.md
in the openoutreach-docs repo. The parts this side owes:
- Ingest is idempotent, keyed on
(lead_id, campaign), so the pipe is allowed to be lossy and recovery is running it again. - Suppression is checked at the door and is terminal — the legal duty came here with
emails/, and a re-ingest must never resurrect somebody who opted out. An address that changed is re-checked, because ingest is lead-keyed while suppression is address-keyed. - Conflicts resolve latest-wins, field by field: a re-ingest is a correction, not a duplicate.
- A malformed line is skipped and counted, named on stderr, with a non-zero exit —
findspent real money on the rows behind it, so aborting the batch throws away paid work. - A blank
emailis stored, not rejected. An exportable row is not a mailable one; the address is an enrichment that a later run fills in for free.
License
MIT — see LICENSE.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file openoutsend-0.1.21.tar.gz.
File metadata
- Download URL: openoutsend-0.1.21.tar.gz
- Upload date:
- Size: 199.2 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b299ef6f8d2b2747922ab5c10e1bafb8be03c7ce30e9270e5641b5fe2096bce2
|
|
| MD5 |
454d9255d1780597129975611c913838
|
|
| BLAKE2b-256 |
48be22cde4ccd4276d60881992f1851f3e619bb42f6a84ce296429cbb2768556
|
Provenance
The following attestation bundles were made for openoutsend-0.1.21.tar.gz:
Publisher:
deploy.yml on eracle/OpenOutSend
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
openoutsend-0.1.21.tar.gz -
Subject digest:
b299ef6f8d2b2747922ab5c10e1bafb8be03c7ce30e9270e5641b5fe2096bce2 - Sigstore transparency entry: 2620413030
- Sigstore integration time:
-
Permalink:
eracle/OpenOutSend@23950f037bfd7c2cac6b7915a7f147ab6b33bd0a -
Branch / Tag:
refs/heads/main - Owner: https://github.com/eracle
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
deploy.yml@23950f037bfd7c2cac6b7915a7f147ab6b33bd0a -
Trigger Event:
push
-
Statement type:
File details
Details for the file openoutsend-0.1.21-py3-none-any.whl.
File metadata
- Download URL: openoutsend-0.1.21-py3-none-any.whl
- Upload date:
- Size: 140.6 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
59a06598d05f4ac12a82a0d183dac81d66b475e225bf3d8b6272fa2aa2db5c71
|
|
| MD5 |
b4d054c1f7714c46c92c2e9544027aff
|
|
| BLAKE2b-256 |
fc2384ab3ca66c6471714c59d870f3b25aece4edccf2d8d0152bcc902ae7f4a5
|
Provenance
The following attestation bundles were made for openoutsend-0.1.21-py3-none-any.whl:
Publisher:
deploy.yml on eracle/OpenOutSend
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
openoutsend-0.1.21-py3-none-any.whl -
Subject digest:
59a06598d05f4ac12a82a0d183dac81d66b475e225bf3d8b6272fa2aa2db5c71 - Sigstore transparency entry: 2620413035
- Sigstore integration time:
-
Permalink:
eracle/OpenOutSend@23950f037bfd7c2cac6b7915a7f147ab6b33bd0a -
Branch / Tag:
refs/heads/main - Owner: https://github.com/eracle
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
deploy.yml@23950f037bfd7c2cac6b7915a7f147ab6b33bd0a -
Trigger Event:
push
-
Statement type: