![OpenOutSend Logo](docs/logo.png) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) # OpenOutSend The **sending half** of OpenOutreach. [OpenOutFind](https://github.com/eracle/OpenOutFind) finds and qualifies B2B leads and prints them; this is what sends them. Want both in one install, with one wizard and one command? That is [OpenOutreach](https://github.com/eracle/OpenOutreach), which requires both packages and hosts them in one process — this repo stays a program of its own underneath it. Want the leads without running anything? Ask for ten at [openoutreach.app](https://openoutreach.app/?utm_source=github&utm_medium=readme&utm_campaign=openoutsend), free. The boundary between the two halves is a pipe, and nothing else crosses it: ```bash outfind 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. ## Legal notice Every message this sends ends with one added line, `Sent with OpenOutreach`, appended after your signature and opt-out (`openoutsend/emails/sender.py`). **It is always on — there is no setting to remove it.** It carries no link, no pixel, and no per-install identifier; nothing reports back whether a send happened or whether the line survived. This is how the project sustains itself with no subscription fee, no promo campaign, and no markup on anything you buy through it. Everything else about a send — the address you write from, what you write, and your compliance with anti-spam law (CAN-SPAM, GDPR/ePrivacy, CASL, and others) — is yours: this tool provides the mechanism, not legal cover. ## 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: ```bash uv tool install openoutsend # or pip install -e . from a checkout outsend check # confirm the environment can send from a box outfind find 50 --json | outsend # store outsend send # one pass: read, answer, later steps, follow up, open outsend send 5 # or: keep going until 5 are open outsend send all # or: until nobody is left to email ``` 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` within the sequence (cold, unless `--sequence` names another; see *Sequences* below), checks every address against the suppression list at the door, skips and counts a malformed line, prints 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 `OUTSEND_DATABASE_URL` puts it in Postgres) and it migrates itself on first run, so a fresh install is an ingest that works rather than a traceback. **`outsend send` is one pass**: 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 *right now*, and exits. Reading first is what makes the rest honest — an opt-out that arrived overnight suppresses the person before anything is written to them. That is the form a timer wants, and it is what the cron line fires. **`outsend send 5` is a goal, and it waits.** Five means five new conversations, and the run keeps passing until they are open — sleeping out the spacing clock between sends, the daily ceiling, and the sending window, including overnight and across a weekend if that is what the guards say. Nothing polls: the planner is asked *when* it could next open one (`planner.next_first_email_at`) and the run sleeps until then, so the external cycler that used to check whether now was a good time has nothing left to do. The wait is spent working — every five minutes it wakes for a full pass, so replies are still answered on a mailbox's cadence while the openers are on hold. Only openers count toward the goal; a busy inbox cannot satisfy it. It stops short, and says so, when the lead pool is drained (waiting cannot refill it — ingest is a separate invocation), when no mailbox is connected, or when two passes in a row fail a send and open nothing, which is a receiver's answer rather than a clock. Ctrl-C hands back the conversations already opened. **`outsend send all` makes the pool itself the goal** — everybody who has an address gets one, and the run ends when nobody is left. It is the same run with one verdict inverted: the drained pool that *fails* `send 5` at three of five is exactly what `all` asked for, so it exits 0 saying how many went. Prefer it to typing a count you had to look up, since that number is stale by the time you type it. The other endings stay failures — no mailbox, and a receiver refusing twice in a row. **`outsend send --agent-draft` opts the opener and every later sequence step out of `AI_MODEL`**, for a caller that is itself an LLM (Claude Code driving the CLI) and would otherwise pay for a second, redundant model call to write what it could write itself. The pass stops at the deal whose opener or next step has been due longest and exits non-zero with `error: draft_pending: ` (`{"error": {"type": "draft_pending", ...}}` under `--json`), carrying the deal's own `profile_text`/`company`/`title`, the step's file name as `step`, its rendered step file as `prompt` — what `AI_MODEL` would have been asked — and, for a later step, the thread's `subject`. Write what the prompt asks, then re-run with the answer: ```bash outsend send --agent-draft --subject "Quick one" --body "Saw you're hiring for platform roles..." outsend send --agent-draft --body "One more thought on the list..." # a later step: the thread's subject ``` This resumes the same deal — no id to track, since at most one is ever pending — and sends it the moment a mailbox is free (an answer already given is kept, not thrown away, if the spacing clock or the sending window is holding every box right now). No count yet — it is one pass at a time, like a bare `outsend send`. A deal stopped or replied to while its draft waited is dropped, not written to. Don't pass `--agent-draft` unless you intend to answer every `draft_pending` it raises; without it, `AI_MODEL` writes every opener and step as before. **Replies come first.** A lead who wrote back is handed over before any step, with `step: "reply"`, the line they were piped with as `fields`, and the whole conversation followed by the reply prompt as `prompt`. Answer with `--body` alone: it goes in their thread at once, whatever the send clocks say. Ending the thread or honouring a worded "stop" is not an answer `--agent-draft` takes. That stays with the model or the operator. `OUTSEND_REPLIES=hold` keeps an unattended pass from answering at all: it reads the reply, stops that deal's steps, sends everyone else's, and leaves the reply for an `--agent-draft` session. **`outsend reply` answers replies and does nothing else**: it reads the mail and hands over the oldest reply owed the same way, `outsend reply --body "…"` sends the answer in its thread, and no opener, step or follow-up goes out, so a session can run it beside a timer's passes on the same store. A draft held for an opener or a step stops it until `send --agent-draft` answers that. **`outsend file --message ID --lead ID` places a reply the headers could not**: an answer to mail this install did not send names nothing the store holds and is read as `unrelated`. Whoever sent that mail knows whose answer it is, and filing puts it, with its own thread, into that lead's newest conversation, where `reply` hands it over. Only `unrelated` mail is filed, and only into a lead that has a thread. Follow-ups still go to `AI_MODEL`, so set `OUTSEND_FOLLOW_UPS=off` with it unless a model is configured. **`--agent-draft` also drops the model out of `outsend check`'s requirements for that run** — `OUTSEND_AI_MODEL`/`OUTSEND_LLM_API_KEY` are never checked or pinged, since a key that will never be spent is not a reason to stop. So `find N emails --json | outsend && outsend send N` needs no cron entry at all: the second command returns when the conversations are open, whether that takes four minutes or spans a weekend. A timer firing a bare `outsend send` remains the other way to run it, and the two do not conflict — the pass is the same either way, and nothing here is state a lost process would strand. **Every email is written from the whole history with that person.** The model reads every earlier prompt, every email sent, every reply and how long each silence lasted, verbatim, and then the prompt for the email at hand. The first email's prompt is a file, `openoutsend/sequences/cold/E0-opener.prompt.j2`; a copy of the `cold/` folder at `~/.openoutsend/sequences/cold/` replaces it whole. **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`. `OUTSEND_FOLLOW_UPS=off` turns those two off for the install. A reply at any point ends the follow-ups 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. ### Sequences Cold outreach is one sequence, built in. Any other is a folder of step files, one per email, kept wherever its owner keeps it and named on the command line: ``` after-a-demo/ E0-1d-thanks-and-one-question.prompt.j2 E1-3d-the-thing-they-asked-about.prompt.j2 E2-5d-last-note.prompt.j2 ``` ```bash some-export --json | outsend --sequence after-a-demo # enrol or update, exit outsend send # mail what is due, in every sequence outsend send --sequence after-a-demo # or in this one alone ``` - **The filename is the schedule**: `E[-d]-..j2`. `E0` goes its days after the lead's `start_at` (the ingest time when the line has none); every later step goes its days after the previous step, in business days, on the same thread and box. A bad name, a duplicate or missing number, a `0d` after `E0` or a Jinja syntax error fails the command that named the folder. - **The kind says whether a model writes it.** A `.prompt.j2` is instructions: the model reads it with the whole history and writes the email. An `.email.j2` is the email itself, sent as written with no model call — for a step with nothing about the reader to write from. An email that opens the thread starts with `Subject: …` and a blank line; a later one goes under the opener's subject and has none. One sequence can mix both, and `--agent-draft` never hands back an email step, because it is written. - **The body is rendered by Jinja** with every key of the piped line that is not a lead column: `{{ first_name }}`, `{{ booking_url }}`, whatever the export carries. A field the line lacks is an error for that lead, never a blank. - **One deal per lead per sequence.** The same `lead_id` piped into cold and into an event sequence is two deals. Re-piping into the same sequence is a correction, latest wins key by key, and a line with `"stop": true` takes the lead out of every remaining send. - **A pass sends every sequence**, cold among them, in one queue, oldest due first, because they all spend the same boxes' cap, spacing and window, and **at most one message per address**. `--sequence F` narrows a pass to `F`. Reading the mail and answering replies are never narrowed, and a reply stops the remaining steps: the agent answers it and takes the thread over. - **Follow-ups are per install** (`OUTSEND_FOLLOW_UPS`); an install mailing event sequences usually turns them off. With them on, each lands only if it fits before the next step is due. - **Every send records** its sequence, its step, the step file's path, the prompt as rendered and, when one was drawn, the prompt line's id. [`examples/sequences/after-a-demo/`](examples/sequences/after-a-demo/) is a whole sequence to copy: three emails after a product demo, with a piped date, `{{ field }}` and the `stop` field, and the four commands that run it. ### Sending through Resend `OUTSEND_TRANSPORT` picks one of two setups, and an install never mixes them: | | `mailbox` (default) | `resend` | | --- | --- | --- | | Sends through | your box's SMTP | Resend's API | | Connected by | `OUTSEND_MAILBOX_ADDRESS` + app password | `OUTSEND_MAILBOX_ADDRESS` + `OUTSEND_RESEND_API_KEY`, on a domain verified in Resend | | Replies | read over IMAP and answered | with `OUTSEND_REPLY_TO`, polled from Resend Inbound and answered; without it, not read — they go wherever the domain forwards them | | Bounces, complaints | read from the inbox | polled from Resend's email list every pass | | Opt-out | *Reply "stop"*, and a `mailto:` header | the line's `unsubscribe_url`, in the footer and a one-click `List-Unsubscribe` | | Daily cap | measured from the Sent folder, ramping from 5 | declared: `OUTSEND_DAILY_CAP`, 50 by default | Spacing and the sending window apply to both. - **The key must be full-access.** A sending-only key can post an email and read nothing back, so a resend install would never learn of a bounce. `outsend check` asks Resend for the domain list, which only a full-access key can read, and refuses the other kind by name. - **Every line needs an `unsubscribe_url`.** With no mailbox to read, a link that takes a one-click POST is the only opt-out there is, so ingest skips a line without one (unless it says `"stop": true`). The link's owner records the opt-out, and its next export sends that line with `"stop": true`. - **Replies need a domain Resend receives on.** Point a subdomain's MX at Resend Inbound and set `OUTSEND_REPLY_TO` to an address on it: every send carries it as `Reply-To`, and every pass stores what Resend received on that domain, whole, before classifying it like mail read over IMAP. Nobody reads that domain but the install, so everything that lands there is kept. - **Threading rides on `References`.** Resend replaces the Message-ID it is given, so the opener names its own minted id in `References`, and every later step carries the chain that starts with it. - **A bounce or a complaint suppresses the address**, as one read from an inbox would. A complaint ends the deal as `unsubscribed`. The poll looks back a week; Resend only reports each email's latest event. - **One store is one setup.** A store whose box was connected the other way stops the run; point `OUTSEND_HOME` at a second store to run both. **`outsend check` verifies what a run needs** — what this install sells and to whom, the name that signs the mail, and a mailbox to send it from — and it runs implicitly on every send, so a setup step is never something a timer discovers. **It comes from the environment and nothing is asked**, on a terminal as much as headless: this is the right-hand side of a pipe, where a question has nobody to answer it. Whatever is missing is one error naming every variable that would have satisfied it. The mailbox is connected 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. The package is on PyPI as **`openoutsend`** — `uv tool install openoutsend`, or `uvx --from openoutsend outsend check` to try it without installing anything. `openoutreach` pins it exactly and installs it for you. ### 🤖 Use it from Claude Code This repo ships a **Claude Code plugin**, so you can send without leaving your agent session: ``` /plugin marketplace add eracle/OpenOutSend /plugin install openoutsend@openoutsend ``` The skill (`skills/send-mail/SKILL.md`) teaches Claude when `send` is safe to run and when it isn't (never unasked — it puts mail in strangers' inboxes), how `--agent-draft` lets it write the openers and steps itself instead of spending a second LLM key, and how to read the narration on stderr. Prefer skills to plugins? Copy `skills/send-mail/` into `~/.claude/skills/` instead. ## Tests ```bash 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 | | --- | --- | | `openoutsend/leads/` | app `outsend_leads` — what comes through the pipe: the models, ingest, suppression, the facts extraction | | `openoutsend/emails/` | app `outsend_emails` — the transport: SMTP, IMAP sync, the mail pass, threads, delivery policy, warmth | | `openoutsend/core/` | app `outsend_core` — the outreach agent, its templates, the sending window, and the stored site configuration | | `openoutsend/docs/` | how the agent and its templating work | | `openoutsend/sequences/` | the built-in cold sequence, one prompt file per step | | `openoutsend/planner.py` | what is due, from which box, and when the next send can go | | `openoutsend/settings.py` | this repo's own Django settings — one host of the apps, not the only one | | `openoutsend/defaults.py` | what any host must splat: `APPS`, `state_dir()`, `app_settings()` | | `openoutsend/send_pass.py` | one pass — read, answer, open — and the line saying what held it | | `openoutsend/send_job.py` | `send N` — passes until the goal is open, waiting out the send clocks | | `openoutsend/first_run.py` | what `check` verifies — the message fields, the model, the operator, the mailbox | | `openoutsend/core/config.py` | the `OUTSEND_*` a message is written from, read fresh on every run | | `openoutsend/__main__.py` | the `outsend` console script | This repo carries no `roadmap/` of its own — open work for it lives in the `openoutreach-docs` repo, under [`roadmap/OpenOutSend/`](https://github.com/eracle/openoutreach-docs/tree/main/roadmap/OpenOutSend). The app labels are namespaced `outsend_*` because these apps are hosted twice: by this repo's `settings.py`, and by OpenOutreach, which installs them in one registry beside OpenOutFind's — whose own engine app is also called `core`, and two apps cannot share a label. So write cross-app model references label-first (`"outsend_emails.Mailbox"`), and never write a table name as a literal — ask the model (`Mailbox._meta.db_table`). ## 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; pinged once per run before any mail moves | | `OUTSEND_PRODUCT_DOCS` / `OUTSEND_CAMPAIGN_TARGET` / `OUTSEND_BOOKING_LINK` | what a message is written from; the first two are required | | `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_FOLLOW_UPS` | `off` stops the two follow-ups to a lead who never answered; on by default | | `OUTSEND_REPLIES` | `model` (the default) answers replies with `AI_MODEL`; `hold` leaves them for an `outsend reply` or `send --agent-draft` session | | `OUTSEND_TRANSPORT` | `mailbox` (the default) or `resend` — see *Sending through Resend* | | `OUTSEND_RESEND_API_KEY` | a resend install's **full-access** key, in place of `OUTSEND_MAILBOX_PASSWORD` | | `OUTSEND_DAILY_CAP` | a resend install's first contacts per day; 50 by default | | `OUTSEND_REPLY_TO` | a resend install's reply address, on a domain whose MX is Resend Inbound; unset, replies are not read | | `OUTSEND_HOME` / `OUTSEND_DB` | where the store lives | | `OUTSEND_DATABASE_URL` | `postgres://user:pass@host:5432/name` puts the store in Postgres instead of the SQLite file; install `openoutsend[postgres]` | **These are read on every run, and none of them is remembered.** Remembering what somebody typed is a convenience for somebody who types; this program is an agent's, and an agent supplies its environment every time. So `outsend check` — which `outsend send` runs implicitly before any mail moves — verifies them and stops with one error naming every variable that is missing. Two are verified against the provider rather than merely read: the mailbox by its SMTP login, the model by one ping, neither of which has a health API. Changing a variable changes the next run, with nothing stale to clear out first. **Two things are still rows, because neither is an answer somebody gave.** The operator is a Django `User`, written once — identity, not configuration. And the mailbox keeps what it *measured*: its spacing clock and the daily capacity it learned from the provider. Its credentials are seeded from the environment on the run that connects it, because a box is connected by an accepted SMTP login rather than by a variable. The store therefore still holds the mailbox's app password. `~/.openoutsend/data/db.sqlite3` is a file to keep: back it up, and do not copy it around. ## 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`](https://github.com/eracle/openoutreach-docs/blob/main/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` and the sequence, 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 — `find` spent real money on the rows behind it, so aborting the batch throws away paid work. - **A blank `email` is 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](LICENSE).