Vibecode OutFriend
track this build4 phases, 12 steps, beginner friendly0%This one is closer to buildable than most outreach tools, because OutFriend sends through your own Gmail rather than its own infrastructure. That removes the moat that usually saves this category: no warmup pools, no IP reputation, no deliverability engineering you cannot replicate. What is left is a Gmail OAuth integration, a voice sample you pass to a model, a scheduler with send windows and daily caps, and the one piece that is genuinely fiddly, watching threads so a sequence stops the instant someone replies. That is a weekend if you have written against the Gmail API before, and a long weekend if you have not.
You are building a lean indie version of OutFriend. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # OutFriend · indie build A single-mailbox outreach tool whose hard problems are stop conditions that are actually correct, a grant that expires weekly without anyone noticing, and a provenance record per recipient that makes you decide whether you should be writing to them at all. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with TypeScript, a command line only, SQLite for state | a tool with your mailbox credentials does not need a web interface as well | | Mail | The provider's API with the narrowest scopes that send and read threads, one account | the scope list is the security review you are trying not to need | | Scheduling | A scheduler on a machine that stays awake, with every tick idempotent | a laptop that sleeps mid-campaign is the normal case | | Stop conditions | Thread state, not string matching on subjects | a reply from a colleague's address on the same thread must still stop the sequence | | Provenance | A source and a contact basis required per lead, enforced by the schema | if you cannot say why you may write to someone, that is the answer | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the command line tool and the scheduler. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **A cloud project with the mail API enabled and a consent screen configured** · free - Why: This is where the scope list and the publishing status live, and both of them decide how this tool behaves week to week. - Get it: Create the project, enable the mail API, configure the consent screen and note its publishing status. - Verify: The consent flow completes and the stored grant lists only the scopes you asked for - [ ] **An API key for a text model** · pennies per draft - Why: Drafts one message per lead from your voice sample and their context. - Get it: Create a key and set a spend limit in the provider's console. - Verify: One lead produces a draft that reads like you wrote it - [ ] **A machine that stays awake** · free to a few pounds a month - Why: Thread polling and the send window both need something running when your laptop is shut, and a missed poll is a follow-up sent under somebody's reply. - Get it: A small always-on box, or a cheap virtual machine you already have. - Verify: A poll runs on schedule with your laptop closed - [ ] **Your written note on which marketing mail rules apply to your recipients** · free - Why: The rules differ by jurisdiction and between personal and business addresses, and deciding this after the first campaign is deciding it too late. - Get it: Read the applicable rules where your recipients are, and write down what you concluded and what you will record per lead. - Verify: The note exists, names the jurisdictions you checked and the date, and matches the contact_basis values you actually use ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Lead sourcing, scraping or address guessing. - Open and click tracking. - Sending from anybody else's mailbox. - Inbox warmup and reply exchanges. - reply detection that is thread-based rather than naive, so out-of-office replies do not count as interest and a reply from a different address still stops the sequence - verification and suppression, meaning unverified, bounced and opted-out addresses filtered before anything queues - a review surface where every draft is readable and editable before it goes, instead of trusting a script with your inbox - lead generation, which the paid tiers include and your CSV does not - the Google OAuth verification process, which you will meet the moment you want this on more than your own account If one of those is essential to you, that is the reason to keep paying for OutFriend, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · OutFriend The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a personal outreach tool that drafts in your voice, sends from your own mailbox, and stops the instant anybody replies to replace OutFriend. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Three things are settled before any code, and two of them are settled by the mail provider rather than by you. This connects one mailbox and it is yours. Detecting a reply means reading a thread, and Google's API Services User Data Policy puts apps using sensitive and restricted scopes into a process where they must pass an annual security assessment and obtain a letter of assessment from a Google-designated third party. That is a recurring cost and a review, not a checkbox. Build this for your own account and stop there; the moment you want it sending for somebody else you are in that process. The grant expires under you. Google's OAuth documentation states that a Cloud project with an external consent screen whose publishing status is Testing is issued a refresh token expiring in seven days, unless the only scopes requested are a subset of name, email address and user profile. So a tool built this way stops being able to send roughly every week. That is survivable and it is not survivable silently: detect the failed refresh, halt the queue, and put it at the top of the screen. A campaign that quietly stopped on Wednesday is worse than one that never started. The legal position is not the same everywhere and it is not optional. Before the first send, read what applies where your recipients are: several jurisdictions require prior consent for marketing mail to individuals and treat a bought list as no consent at all, and the rules for business addresses often differ from the rules for personal ones. Record, per recipient, where the address came from and why you believe you may write to them. If you cannot write that sentence, do not queue the address. And nothing leaves your mailbox that you have not read, which is why the approval gate is a phase rather than a setting. ### Stack (fixed, do not substitute) - Runtime: Node 22 with TypeScript, a command line only, SQLite for state · a tool with your mailbox credentials does not need a web interface as well - Mail: The provider's API with the narrowest scopes that send and read threads, one account · the scope list is the security review you are trying not to need - Scheduling: A scheduler on a machine that stays awake, with every tick idempotent · a laptop that sleeps mid-campaign is the normal case - Stop conditions: Thread state, not string matching on subjects · a reply from a colleague's address on the same thread must still stop the sequence - Provenance: A source and a contact basis required per lead, enforced by the schema · if you cannot say why you may write to someone, that is the answer ### Data model (create this before Phase 1) - account(email, token_ref, granted_at, scopes, publishing_status, last_refresh_ok_at, state) · state goes to halted the moment a refresh fails - leads(id, email_hash, email, first_name, company, context, source, contact_basis, added_at, state) · source and contact_basis are not nullable and there is no import path that fills them in for you - drafts(id, lead_id, step, subject, body, model_id, generated_at, approved_at, approved_body) · approved_body is what sends, so an edit at review is what goes out - sends(id, draft_id, thread_id, provider_message_id, sent_at, window_id) - events(id, lead_id, thread_id, kind, detected_at, raw_ref) · kind is reply, auto_reply, bounce or opt_out, and they are recorded separately because they mean different things - suppression(email_hash, reason, added_at) · permanent, checked before drafting as well as before sending, and never emptied by a bulk operation - A sequence stops because the lead's state changed, not because a check ran at send time. The check at send time is the second line, not the first. ### Phase 1 · One mailbox, and a grant that tells you when it dies Build: An auth command runs the consent flow once and stores the refresh token outside the repository. Request only the scopes needed to send and to read thread state. Refresh before every run; on failure, set the account state to halted, stop the queue and print the reason at the top of every subsequent command until it is fixed. Trap: Treating a failed token refresh as a transient error and retrying on the next tick means the scheduler keeps running, keeps marking rows as attempted and keeps reporting a healthy campaign while nothing has left the mailbox for days, which is precisely the failure that makes you look unreliable to the people who did reply. Halt on the first refresh failure, refuse to run anything that sends, and make the message unmissable. Done when: The scopes requested are the minimum that send and read thread state, listed in the README and asserted against the stored grant. A failed refresh sets the account to halted and every send-related command refuses with the reason, tested by invalidating the token. Re-running the auth command clears the halt and records a new grant time. The refresh token is never written to the repository or to a log, verified by a test that scans both. Do not build yet: No leads, no drafting, no sending and no thread watching yet. ### Phase 2 · No lead without a reason Build: A CSV import that refuses any row missing a source and a contact basis, both free text and both stored. Check every address against the suppression list at import as well as at send. Normalise addresses before hashing so two spellings of the same person do not become two leads. Trap: Making provenance an optional column means it is empty for every row within a week, and the first time somebody asks where you got their address the honest answer is that you do not know, which is both an unpleasant conversation and, in several places, the wrong answer to give a regulator. Make both fields required at the schema level so there is no import path that leaves them blank. Done when: A CSV row missing a source or a contact basis is rejected with the line number, tested. An address already in suppression is refused at import rather than silently dropped later, tested. Address normalisation is case-insensitive and trims whitespace and the suppression check uses the normalised form, tested with variants. There is no command anywhere that adds a lead without both provenance fields, asserted by review. Do not build yet: No drafting, no review, no sending and no watching yet. ### Phase 3 · A draft in your voice, and a gate you cannot skip Build: A voice command pulls a sample of your own sent messages, strips quoted history and signatures, and saves a short style reference to a file you can edit. Drafting uses that file plus the lead's context column. A review command shows drafts one at a time with approve, edit and skip, and stores the approved body as the thing that will send. Trap: Storing only an approval flag and regenerating the body at send time means the message that goes out is not the message you read, because the model was called again and produced something slightly different, and you find out from the reply. Store the exact approved text and send that, and treat any change to the draft after approval as requiring a fresh approval. Done when: The approved body is stored verbatim and is byte-identical to what is sent, asserted in the suite. No send path exists for a draft without an approval timestamp, verified by review and a test. Editing a draft after approval clears the approval, tested. The voice sample file is plain text, editable, and its contents are visible in the review output so you can see what the draft was built from. Do not build yet: No sending, no follow-ups and no thread watching yet. ### Phase 4 · Sending at a human pace Build: A send command that runs only inside a configured window, stops at a daily cap, and leaves a random gap between messages. Claim each draft with a conditional update before calling the provider and record the returned message and thread identifiers immediately. A message sent outside a window or over the cap is not sent at all rather than queued for later. Trap: Marking a draft as sent after the provider call returns means a process killed mid-send leaves a message delivered and a row that still says pending, and the next run sends the same message again to the same person, which is the single most embarrassing bug this category has. Claim the row first, write the provider identifiers the moment you have them, and treat a claimed row with no identifiers as something to check by hand rather than to retry. Done when: Killing the process during a send never results in a second delivery on the next run, tested by interrupting between claim and record. Sends outside the window or over the daily cap do not happen, tested at both boundaries. Every send stores the provider's thread identifier, which is what every stop condition later depends on. The gap between consecutive sends is randomised within a configured range, verified across a run. Do not build yet: No follow-ups, no reply detection and no export yet. ### Phase 5 · Stopping, which is the whole product Build: Poll thread state on a schedule. Any inbound message on a thread you started sets the lead to replied and cancels its remaining steps, regardless of which address it came from. Classify automatic replies as their own event kind that stops the sequence without counting as interest. Parse delivery failure notices to suppression. Honour an opt-out request permanently and at once. Trap: Detecting replies by matching the sender address against the lead means a message from a colleague, an assistant or a forwarded internal address on the same thread does not count, and your follow-up lands underneath a human answer as if nobody had spoken, which is exactly the carelessness this tool is meant to avoid. Key the stop condition on the thread, not on the address, and treat any inbound message on a thread you started as a reply. Done when: An inbound message from an address other than the lead's, on a thread you started, stops the sequence, tested with a fixture. An automatic reply stops the sequence and is recorded as its own event kind, never as interest, tested. A delivery failure notice adds the address to suppression permanently and suppression survives a database rebuild, tested. An opt-out request is honoured before any further send and cannot be undone by a re-import, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Lead sourcing by scraping, address guessing or pattern-based email generation, all of which produce addresses you cannot write a provenance line for and bounces that damage your own mailbox. - Open and click tracking pixels, which in several jurisdictions need consent in their own right and which tell you less about interest than a reply does. - Sending for anybody but yourself, because a second mailbox is what puts you into the annual security assessment the policy describes. - Inbox warmup schemes and reply exchanges between accounts, which are attempts to game a provider's own filtering and are exactly the behaviour that gets a domain penalised. ### README must contain - That this connects one mailbox, yours, and why sending for anybody else means an annual third-party security assessment under the provider's user data policy - That a project left in testing status is issued a refresh token expiring in seven days, so the grant needs renewing weekly, with the source and the date you checked it - That every lead requires a recorded source and a recorded reason you may contact them, and that there is no import path around it - That you must read the marketing mail rules that apply where your recipients are before the first send, and that a bought list is treated as no consent in several jurisdictions - That stop conditions key on the thread rather than the sender, and that an automatic reply stops a sequence without being treated as interest ===== AGENTS.md ===== # Agent instructions · OutFriend indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with TypeScript, a command line only, SQLite for state, The provider's API with the narrowest scopes that send and read threads, one account, A scheduler on a machine that stays awake, with every tick idempotent, Thread state, not string matching on subjects, A source and a contact basis required per lead, enforced by the schema. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Treating a failed token refresh as a transient error and retrying on the next tick means the scheduler keeps running, keeps marking rows as attempted and keeps reporting a healthy campaign while nothing has left the mailbox for days, which is precisely the failure that makes you look unreliable to the people who did reply. Halt on the first refresh failure, refuse to run anything that sends, and make the message unmissable. - Making provenance an optional column means it is empty for every row within a week, and the first time somebody asks where you got their address the honest answer is that you do not know, which is both an unpleasant conversation and, in several places, the wrong answer to give a regulator. Make both fields required at the schema level so there is no import path that leaves them blank. - Storing only an approval flag and regenerating the body at send time means the message that goes out is not the message you read, because the model was called again and produced something slightly different, and you find out from the reply. Store the exact approved text and send that, and treat any change to the draft after approval as requiring a fresh approval. - Marking a draft as sent after the provider call returns means a process killed mid-send leaves a message delivered and a row that still says pending, and the next run sends the same message again to the same person, which is the single most embarrassing bug this category has. Claim the row first, write the provider identifiers the moment you have them, and treat a claimed row with no identifiers as something to check by hand rather than to retry. ===== BUILD_PLAN.md ===== # Build plan · OutFriend A single-mailbox outreach tool whose hard problems are stop conditions that are actually correct, a grant that expires weekly without anyone noticing, and a provenance record per recipient that makes you decide whether you should be writing to them at all. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · One mailbox, and a grant that tells you when it dies Authorisation happens once, the narrowest scopes are used, and a failed refresh halts everything loudly. ### Steps 1. Request the narrowest scopes that do the job Every extra scope is more of your mailbox in a tool you wrote at the weekend. Files: `src/auth/scopes.ts`, `README.md` 2. Halt on the first refresh failure A weekly expiry is fine; a silent one is not. Files: `src/auth/refresh.ts`, `tests/halt.test.ts` 3. Keep the token out of the repository and out of logs A token in a log file is a token in a backup. Files: `src/auth/store.ts`, `tests/no-token-leak.test.ts` ### Done when - [ ] Only the minimum scopes are requested - [ ] A failed refresh halts every sending command - [ ] Re-authorising clears the halt - [ ] The token never reaches the repository or a log ### Watch out - Treating a failed token refresh as a transient error and retrying on the next tick means the scheduler keeps running, keeps marking rows as attempted and keeps reporting a healthy campaign while nothing has left the mailbox for days, which is precisely the failure that makes you look unreliable to the people who did reply. Halt on the first refresh failure, refuse to run anything that sends, and make the message unmissable. ## Phase 2 · No lead without a reason Importing a contact requires saying where the address came from and why you may write to them. ### Steps 1. Make both provenance fields non-nullable Optional means empty within a week. Files: `src/db/migrations/002_leads.sql` 2. Check suppression at import, not only at send The earliest refusal is the kindest one. Files: `src/leads/import.ts`, `tests/suppression.test.ts` 3. Normalise before hashing One person should not become three leads. Files: `src/leads/normalise.ts` ### Done when - [ ] Rows without provenance are rejected with a line number - [ ] Suppressed addresses are refused at import - [ ] Normalisation is case and whitespace safe - [ ] No path creates a lead without provenance ### Watch out - Making provenance an optional column means it is empty for every row within a week, and the first time somebody asks where you got their address the honest answer is that you do not know, which is both an unpleasant conversation and, in several places, the wrong answer to give a regulator. Make both fields required at the schema level so there is no import path that leaves them blank. ## Phase 3 · A draft in your voice, and a gate you cannot skip Each lead gets one draft built from your own sent mail, and nothing sends without you reading it. ### Steps 1. Build the voice reference into a file you can edit A style reference you cannot read is a style you cannot fix. Files: `src/voice/build.ts`, `voice.md` 2. Store the approved text, not an approval flag What you read must be what leaves. Files: `src/drafts/approve.ts`, `tests/approved-body.test.ts` 3. Clear approval on any edit An approval is of a specific message. Files: `src/drafts/edit.ts` ### Done when - [ ] The sent body is byte-identical to the approved body - [ ] No draft sends without an approval timestamp - [ ] Editing after approval clears it - [ ] The voice reference is readable and editable ### Watch out - Storing only an approval flag and regenerating the body at send time means the message that goes out is not the message you read, because the model was called again and produced something slightly different, and you find out from the reply. Store the exact approved text and send that, and treat any change to the draft after approval as requiring a fresh approval. ## Phase 4 · Sending at a human pace Approved drafts leave within a window, under a daily cap, spaced apart, and never twice. ### Steps 1. Claim the row before calling the provider The gap between send and record is where duplicates are born. Files: `src/send/claim.ts`, `tests/no-double-send.test.ts` 2. Store the thread identifier immediately Every stop condition in phase five hangs off it. Files: `src/send/record.ts` 3. Refuse rather than defer outside the window A queue that catches up at three in the morning is its own problem. Files: `src/send/window.ts` ### Done when - [ ] An interrupted send never delivers twice - [ ] The window and daily cap are both enforced - [ ] Thread identifiers are stored on every send - [ ] Gaps between sends are randomised ### Watch out - Marking a draft as sent after the provider call returns means a process killed mid-send leaves a message delivered and a row that still says pending, and the next run sends the same message again to the same person, which is the single most embarrassing bug this category has. Claim the row first, write the provider identifiers the moment you have them, and treat a claimed row with no identifiers as something to check by hand rather than to retry. ## Not in this build - Lead sourcing, scraping or address guessing. - Open and click tracking. - Sending from anybody else's mailbox. - Inbox warmup and reply exchanges. ## After v1, if you want it - A per-lead note field written after a reply, which is the only useful thing a tool like this can remember about a person - A weekly report of replies against sends by contact basis, which tells you which sources are worth keeping - A dry run mode that renders the whole campaign to files without touching the mailbox, useful before every first send ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. The OAuth client for your cloud project. MAIL_CLIENT_ID=...apps.googleusercontent.com # Required · secret. Pairs with the client id. Never in the repository. MAIL_CLIENT_SECRET=...redacted # Required · secret. The refresh token, outside the repository. Expect to renew it weekly while the project is in testing. MAIL_TOKEN_PATH=~/.config/outreach/token.json # Required · secret. Drafting only. It never sees the mailbox. MODEL_API_KEY=sk-...redacted # Required. Leads, drafts, sends, events and suppression. Back this up; suppression in particular. DATABASE_PATH=./data/outreach.db # Required. Outside this, nothing sends. Not deferred, refused. SEND_WINDOW=09:30-16:30 Europe/London # Required. A hard stop per day. Lower than you think is usually right. DAILY_CAP=25 # Required. A random gap in this range between consecutive sends. SEND_GAP_SECONDS=45-210
You are building a lean indie version of OutFriend. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== README.md ===== # OutFriend · indie build A single-mailbox outreach tool whose hard problems are stop conditions that are actually correct, a grant that expires weekly without anyone noticing, and a provenance record per recipient that makes you decide whether you should be writing to them at all. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with TypeScript, a command line only, SQLite for state | a tool with your mailbox credentials does not need a web interface as well | | Mail | The provider's API with the narrowest scopes that send and read threads, one account | the scope list is the security review you are trying not to need | | Scheduling | A scheduler on a machine that stays awake, with every tick idempotent | a laptop that sleeps mid-campaign is the normal case | | Stop conditions | Thread state, not string matching on subjects | a reply from a colleague's address on the same thread must still stop the sequence | | Provenance | A source and a contact basis required per lead, enforced by the schema | if you cannot say why you may write to someone, that is the answer | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the command line tool and the scheduler. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **A cloud project with the mail API enabled and a consent screen configured** · free - Why: This is where the scope list and the publishing status live, and both of them decide how this tool behaves week to week. - Get it: Create the project, enable the mail API, configure the consent screen and note its publishing status. - Verify: The consent flow completes and the stored grant lists only the scopes you asked for - [ ] **An API key for a text model** · pennies per draft - Why: Drafts one message per lead from your voice sample and their context. - Get it: Create a key and set a spend limit in the provider's console. - Verify: One lead produces a draft that reads like you wrote it - [ ] **A machine that stays awake** · free to a few pounds a month - Why: Thread polling and the send window both need something running when your laptop is shut, and a missed poll is a follow-up sent under somebody's reply. - Get it: A small always-on box, or a cheap virtual machine you already have. - Verify: A poll runs on schedule with your laptop closed - [ ] **Your written note on which marketing mail rules apply to your recipients** · free - Why: The rules differ by jurisdiction and between personal and business addresses, and deciding this after the first campaign is deciding it too late. - Get it: Read the applicable rules where your recipients are, and write down what you concluded and what you will record per lead. - Verify: The note exists, names the jurisdictions you checked and the date, and matches the contact_basis values you actually use ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Lead sourcing, scraping or address guessing. - Open and click tracking. - Sending from anybody else's mailbox. - Inbox warmup and reply exchanges. - reply detection that is thread-based rather than naive, so out-of-office replies do not count as interest and a reply from a different address still stops the sequence - verification and suppression, meaning unverified, bounced and opted-out addresses filtered before anything queues - a review surface where every draft is readable and editable before it goes, instead of trusting a script with your inbox - lead generation, which the paid tiers include and your CSV does not - the Google OAuth verification process, which you will meet the moment you want this on more than your own account If one of those is essential to you, that is the reason to keep paying for OutFriend, and the README should say so rather than pretend. ===== BRIEF.md ===== # Build brief · OutFriend The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a personal outreach tool that drafts in your voice, sends from your own mailbox, and stops the instant anybody replies to replace OutFriend. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Three things are settled before any code, and two of them are settled by the mail provider rather than by you. This connects one mailbox and it is yours. Detecting a reply means reading a thread, and Google's API Services User Data Policy puts apps using sensitive and restricted scopes into a process where they must pass an annual security assessment and obtain a letter of assessment from a Google-designated third party. That is a recurring cost and a review, not a checkbox. Build this for your own account and stop there; the moment you want it sending for somebody else you are in that process. The grant expires under you. Google's OAuth documentation states that a Cloud project with an external consent screen whose publishing status is Testing is issued a refresh token expiring in seven days, unless the only scopes requested are a subset of name, email address and user profile. So a tool built this way stops being able to send roughly every week. That is survivable and it is not survivable silently: detect the failed refresh, halt the queue, and put it at the top of the screen. A campaign that quietly stopped on Wednesday is worse than one that never started. The legal position is not the same everywhere and it is not optional. Before the first send, read what applies where your recipients are: several jurisdictions require prior consent for marketing mail to individuals and treat a bought list as no consent at all, and the rules for business addresses often differ from the rules for personal ones. Record, per recipient, where the address came from and why you believe you may write to them. If you cannot write that sentence, do not queue the address. And nothing leaves your mailbox that you have not read, which is why the approval gate is a phase rather than a setting. ### Stack (fixed, do not substitute) - Runtime: Node 22 with TypeScript, a command line only, SQLite for state · a tool with your mailbox credentials does not need a web interface as well - Mail: The provider's API with the narrowest scopes that send and read threads, one account · the scope list is the security review you are trying not to need - Scheduling: A scheduler on a machine that stays awake, with every tick idempotent · a laptop that sleeps mid-campaign is the normal case - Stop conditions: Thread state, not string matching on subjects · a reply from a colleague's address on the same thread must still stop the sequence - Provenance: A source and a contact basis required per lead, enforced by the schema · if you cannot say why you may write to someone, that is the answer ### Data model (create this before Phase 1) - account(email, token_ref, granted_at, scopes, publishing_status, last_refresh_ok_at, state) · state goes to halted the moment a refresh fails - leads(id, email_hash, email, first_name, company, context, source, contact_basis, added_at, state) · source and contact_basis are not nullable and there is no import path that fills them in for you - drafts(id, lead_id, step, subject, body, model_id, generated_at, approved_at, approved_body) · approved_body is what sends, so an edit at review is what goes out - sends(id, draft_id, thread_id, provider_message_id, sent_at, window_id) - events(id, lead_id, thread_id, kind, detected_at, raw_ref) · kind is reply, auto_reply, bounce or opt_out, and they are recorded separately because they mean different things - suppression(email_hash, reason, added_at) · permanent, checked before drafting as well as before sending, and never emptied by a bulk operation - A sequence stops because the lead's state changed, not because a check ran at send time. The check at send time is the second line, not the first. ### Phase 1 · One mailbox, and a grant that tells you when it dies Build: An auth command runs the consent flow once and stores the refresh token outside the repository. Request only the scopes needed to send and to read thread state. Refresh before every run; on failure, set the account state to halted, stop the queue and print the reason at the top of every subsequent command until it is fixed. Trap: Treating a failed token refresh as a transient error and retrying on the next tick means the scheduler keeps running, keeps marking rows as attempted and keeps reporting a healthy campaign while nothing has left the mailbox for days, which is precisely the failure that makes you look unreliable to the people who did reply. Halt on the first refresh failure, refuse to run anything that sends, and make the message unmissable. Done when: The scopes requested are the minimum that send and read thread state, listed in the README and asserted against the stored grant. A failed refresh sets the account to halted and every send-related command refuses with the reason, tested by invalidating the token. Re-running the auth command clears the halt and records a new grant time. The refresh token is never written to the repository or to a log, verified by a test that scans both. Do not build yet: No leads, no drafting, no sending and no thread watching yet. ### Phase 2 · No lead without a reason Build: A CSV import that refuses any row missing a source and a contact basis, both free text and both stored. Check every address against the suppression list at import as well as at send. Normalise addresses before hashing so two spellings of the same person do not become two leads. Trap: Making provenance an optional column means it is empty for every row within a week, and the first time somebody asks where you got their address the honest answer is that you do not know, which is both an unpleasant conversation and, in several places, the wrong answer to give a regulator. Make both fields required at the schema level so there is no import path that leaves them blank. Done when: A CSV row missing a source or a contact basis is rejected with the line number, tested. An address already in suppression is refused at import rather than silently dropped later, tested. Address normalisation is case-insensitive and trims whitespace and the suppression check uses the normalised form, tested with variants. There is no command anywhere that adds a lead without both provenance fields, asserted by review. Do not build yet: No drafting, no review, no sending and no watching yet. ### Phase 3 · A draft in your voice, and a gate you cannot skip Build: A voice command pulls a sample of your own sent messages, strips quoted history and signatures, and saves a short style reference to a file you can edit. Drafting uses that file plus the lead's context column. A review command shows drafts one at a time with approve, edit and skip, and stores the approved body as the thing that will send. Trap: Storing only an approval flag and regenerating the body at send time means the message that goes out is not the message you read, because the model was called again and produced something slightly different, and you find out from the reply. Store the exact approved text and send that, and treat any change to the draft after approval as requiring a fresh approval. Done when: The approved body is stored verbatim and is byte-identical to what is sent, asserted in the suite. No send path exists for a draft without an approval timestamp, verified by review and a test. Editing a draft after approval clears the approval, tested. The voice sample file is plain text, editable, and its contents are visible in the review output so you can see what the draft was built from. Do not build yet: No sending, no follow-ups and no thread watching yet. ### Phase 4 · Sending at a human pace Build: A send command that runs only inside a configured window, stops at a daily cap, and leaves a random gap between messages. Claim each draft with a conditional update before calling the provider and record the returned message and thread identifiers immediately. A message sent outside a window or over the cap is not sent at all rather than queued for later. Trap: Marking a draft as sent after the provider call returns means a process killed mid-send leaves a message delivered and a row that still says pending, and the next run sends the same message again to the same person, which is the single most embarrassing bug this category has. Claim the row first, write the provider identifiers the moment you have them, and treat a claimed row with no identifiers as something to check by hand rather than to retry. Done when: Killing the process during a send never results in a second delivery on the next run, tested by interrupting between claim and record. Sends outside the window or over the daily cap do not happen, tested at both boundaries. Every send stores the provider's thread identifier, which is what every stop condition later depends on. The gap between consecutive sends is randomised within a configured range, verified across a run. Do not build yet: No follow-ups, no reply detection and no export yet. ### Phase 5 · Stopping, which is the whole product Build: Poll thread state on a schedule. Any inbound message on a thread you started sets the lead to replied and cancels its remaining steps, regardless of which address it came from. Classify automatic replies as their own event kind that stops the sequence without counting as interest. Parse delivery failure notices to suppression. Honour an opt-out request permanently and at once. Trap: Detecting replies by matching the sender address against the lead means a message from a colleague, an assistant or a forwarded internal address on the same thread does not count, and your follow-up lands underneath a human answer as if nobody had spoken, which is exactly the carelessness this tool is meant to avoid. Key the stop condition on the thread, not on the address, and treat any inbound message on a thread you started as a reply. Done when: An inbound message from an address other than the lead's, on a thread you started, stops the sequence, tested with a fixture. An automatic reply stops the sequence and is recorded as its own event kind, never as interest, tested. A delivery failure notice adds the address to suppression permanently and suppression survives a database rebuild, tested. An opt-out request is honoured before any further send and cannot be undone by a re-import, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Lead sourcing by scraping, address guessing or pattern-based email generation, all of which produce addresses you cannot write a provenance line for and bounces that damage your own mailbox. - Open and click tracking pixels, which in several jurisdictions need consent in their own right and which tell you less about interest than a reply does. - Sending for anybody but yourself, because a second mailbox is what puts you into the annual security assessment the policy describes. - Inbox warmup schemes and reply exchanges between accounts, which are attempts to game a provider's own filtering and are exactly the behaviour that gets a domain penalised. ### README must contain - That this connects one mailbox, yours, and why sending for anybody else means an annual third-party security assessment under the provider's user data policy - That a project left in testing status is issued a refresh token expiring in seven days, so the grant needs renewing weekly, with the source and the date you checked it - That every lead requires a recorded source and a recorded reason you may contact them, and that there is no import path around it - That you must read the marketing mail rules that apply where your recipients are before the first send, and that a bought list is treated as no consent in several jurisdictions - That stop conditions key on the thread rather than the sender, and that an automatic reply stops a sequence without being treated as interest ===== AGENTS.md ===== # Agent instructions · OutFriend indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with TypeScript, a command line only, SQLite for state, The provider's API with the narrowest scopes that send and read threads, one account, A scheduler on a machine that stays awake, with every tick idempotent, Thread state, not string matching on subjects, A source and a contact basis required per lead, enforced by the schema. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Treating a failed token refresh as a transient error and retrying on the next tick means the scheduler keeps running, keeps marking rows as attempted and keeps reporting a healthy campaign while nothing has left the mailbox for days, which is precisely the failure that makes you look unreliable to the people who did reply. Halt on the first refresh failure, refuse to run anything that sends, and make the message unmissable. - Making provenance an optional column means it is empty for every row within a week, and the first time somebody asks where you got their address the honest answer is that you do not know, which is both an unpleasant conversation and, in several places, the wrong answer to give a regulator. Make both fields required at the schema level so there is no import path that leaves them blank. - Storing only an approval flag and regenerating the body at send time means the message that goes out is not the message you read, because the model was called again and produced something slightly different, and you find out from the reply. Store the exact approved text and send that, and treat any change to the draft after approval as requiring a fresh approval. - Marking a draft as sent after the provider call returns means a process killed mid-send leaves a message delivered and a row that still says pending, and the next run sends the same message again to the same person, which is the single most embarrassing bug this category has. Claim the row first, write the provider identifiers the moment you have them, and treat a claimed row with no identifiers as something to check by hand rather than to retry. ===== BUILD_PLAN.md ===== # Build plan · OutFriend A single-mailbox outreach tool whose hard problems are stop conditions that are actually correct, a grant that expires weekly without anyone noticing, and a provenance record per recipient that makes you decide whether you should be writing to them at all. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · One mailbox, and a grant that tells you when it dies Authorisation happens once, the narrowest scopes are used, and a failed refresh halts everything loudly. ### Steps 1. Request the narrowest scopes that do the job Every extra scope is more of your mailbox in a tool you wrote at the weekend. Files: `src/auth/scopes.ts`, `README.md` 2. Halt on the first refresh failure A weekly expiry is fine; a silent one is not. Files: `src/auth/refresh.ts`, `tests/halt.test.ts` 3. Keep the token out of the repository and out of logs A token in a log file is a token in a backup. Files: `src/auth/store.ts`, `tests/no-token-leak.test.ts` ### Done when - [ ] Only the minimum scopes are requested - [ ] A failed refresh halts every sending command - [ ] Re-authorising clears the halt - [ ] The token never reaches the repository or a log ### Watch out - Treating a failed token refresh as a transient error and retrying on the next tick means the scheduler keeps running, keeps marking rows as attempted and keeps reporting a healthy campaign while nothing has left the mailbox for days, which is precisely the failure that makes you look unreliable to the people who did reply. Halt on the first refresh failure, refuse to run anything that sends, and make the message unmissable. ## Phase 2 · No lead without a reason Importing a contact requires saying where the address came from and why you may write to them. ### Steps 1. Make both provenance fields non-nullable Optional means empty within a week. Files: `src/db/migrations/002_leads.sql` 2. Check suppression at import, not only at send The earliest refusal is the kindest one. Files: `src/leads/import.ts`, `tests/suppression.test.ts` 3. Normalise before hashing One person should not become three leads. Files: `src/leads/normalise.ts` ### Done when - [ ] Rows without provenance are rejected with a line number - [ ] Suppressed addresses are refused at import - [ ] Normalisation is case and whitespace safe - [ ] No path creates a lead without provenance ### Watch out - Making provenance an optional column means it is empty for every row within a week, and the first time somebody asks where you got their address the honest answer is that you do not know, which is both an unpleasant conversation and, in several places, the wrong answer to give a regulator. Make both fields required at the schema level so there is no import path that leaves them blank. ## Phase 3 · A draft in your voice, and a gate you cannot skip Each lead gets one draft built from your own sent mail, and nothing sends without you reading it. ### Steps 1. Build the voice reference into a file you can edit A style reference you cannot read is a style you cannot fix. Files: `src/voice/build.ts`, `voice.md` 2. Store the approved text, not an approval flag What you read must be what leaves. Files: `src/drafts/approve.ts`, `tests/approved-body.test.ts` 3. Clear approval on any edit An approval is of a specific message. Files: `src/drafts/edit.ts` ### Done when - [ ] The sent body is byte-identical to the approved body - [ ] No draft sends without an approval timestamp - [ ] Editing after approval clears it - [ ] The voice reference is readable and editable ### Watch out - Storing only an approval flag and regenerating the body at send time means the message that goes out is not the message you read, because the model was called again and produced something slightly different, and you find out from the reply. Store the exact approved text and send that, and treat any change to the draft after approval as requiring a fresh approval. ## Phase 4 · Sending at a human pace Approved drafts leave within a window, under a daily cap, spaced apart, and never twice. ### Steps 1. Claim the row before calling the provider The gap between send and record is where duplicates are born. Files: `src/send/claim.ts`, `tests/no-double-send.test.ts` 2. Store the thread identifier immediately Every stop condition in phase five hangs off it. Files: `src/send/record.ts` 3. Refuse rather than defer outside the window A queue that catches up at three in the morning is its own problem. Files: `src/send/window.ts` ### Done when - [ ] An interrupted send never delivers twice - [ ] The window and daily cap are both enforced - [ ] Thread identifiers are stored on every send - [ ] Gaps between sends are randomised ### Watch out - Marking a draft as sent after the provider call returns means a process killed mid-send leaves a message delivered and a row that still says pending, and the next run sends the same message again to the same person, which is the single most embarrassing bug this category has. Claim the row first, write the provider identifiers the moment you have them, and treat a claimed row with no identifiers as something to check by hand rather than to retry. ## Not in this build - Lead sourcing, scraping or address guessing. - Open and click tracking. - Sending from anybody else's mailbox. - Inbox warmup and reply exchanges. ## After v1, if you want it - A per-lead note field written after a reply, which is the only useful thing a tool like this can remember about a person - A weekly report of replies against sends by contact basis, which tells you which sources are worth keeping - A dry run mode that renders the whole campaign to files without touching the mailbox, useful before every first send ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. The OAuth client for your cloud project. MAIL_CLIENT_ID=...apps.googleusercontent.com # Required · secret. Pairs with the client id. Never in the repository. MAIL_CLIENT_SECRET=...redacted # Required · secret. The refresh token, outside the repository. Expect to renew it weekly while the project is in testing. MAIL_TOKEN_PATH=~/.config/outreach/token.json # Required · secret. Drafting only. It never sees the mailbox. MODEL_API_KEY=sk-...redacted # Required. Leads, drafts, sends, events and suppression. Back this up; suppression in particular. DATABASE_PATH=./data/outreach.db # Required. Outside this, nothing sends. Not deferred, refused. SEND_WINDOW=09:30-16:30 Europe/London # Required. A hard stop per day. Lower than you think is usually right. DAILY_CAP=25 # Required. A random gap in this range between consecutive sends. SEND_GAP_SECONDS=45-210
You are building a production product version of OutFriend. Create the following project files first, then implement the application by following them. Keep the files updated as decisions change. Do not collapse this into a single README or prompt. ===== PRODUCT.md ===== # OutFriend · product brief ## Problem This one is closer to buildable than most outreach tools, because OutFriend sends through your own Gmail rather than its own infrastructure. That removes the moat that usually saves this category: no warmup pools, no IP reputation, no deliverability engineering you cannot replicate. What is left is a Gmail OAuth integration, a voice sample you pass to a model, a scheduler with send windows and daily caps, and the one piece that is genuinely fiddly, watching threads so a sequence stops the instant someone replies. That is a weekend if you have written against the Gmail API before, and a long weekend if you have not. ## Product outcome An outreach tool that halts loudly when its grant expires, refuses a lead you cannot account for, sends only what you read, and stops the sequence on any inbound message on the thread rather than only on one from the address you expected. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - a Google Cloud project with Gmail API access and OAuth consent configured - an OpenAI or Anthropic API key - a leads CSV you sourced yourself - a scheduler that survives your laptop sleeping - your own read of CAN-SPAM and GDPR before you send anything ## Explicit non-goals for v1 - Lead sourcing, scraping or address guessing. - Open and click tracking. - Sending from anybody else's mailbox. - Inbox warmup and reply exchanges. - reply detection that is thread-based rather than naive, so out-of-office replies do not count as interest and a reply from a different address still stops the sequence - verification and suppression, meaning unverified, bounced and opted-out addresses filtered before anything queues - a review surface where every draft is readable and editable before it goes, instead of trusting a script with your inbox - lead generation, which the paid tiers include and your CSV does not - the Google OAuth verification process, which you will meet the moment you want this on more than your own account ## Success criteria - A failed token refresh is confirmed to halt every sending command - A lead without provenance is confirmed impossible to create - The sent body is confirmed byte-identical to the approved body - An interrupted send is confirmed never to deliver twice - Any inbound message on the thread is confirmed to stop the sequence ===== BRIEF.md ===== # Build brief · OutFriend The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a personal outreach tool that drafts in your voice, sends from your own mailbox, and stops the instant anybody replies to replace OutFriend. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Three things are settled before any code, and two of them are settled by the mail provider rather than by you. This connects one mailbox and it is yours. Detecting a reply means reading a thread, and Google's API Services User Data Policy puts apps using sensitive and restricted scopes into a process where they must pass an annual security assessment and obtain a letter of assessment from a Google-designated third party. That is a recurring cost and a review, not a checkbox. Build this for your own account and stop there; the moment you want it sending for somebody else you are in that process. The grant expires under you. Google's OAuth documentation states that a Cloud project with an external consent screen whose publishing status is Testing is issued a refresh token expiring in seven days, unless the only scopes requested are a subset of name, email address and user profile. So a tool built this way stops being able to send roughly every week. That is survivable and it is not survivable silently: detect the failed refresh, halt the queue, and put it at the top of the screen. A campaign that quietly stopped on Wednesday is worse than one that never started. The legal position is not the same everywhere and it is not optional. Before the first send, read what applies where your recipients are: several jurisdictions require prior consent for marketing mail to individuals and treat a bought list as no consent at all, and the rules for business addresses often differ from the rules for personal ones. Record, per recipient, where the address came from and why you believe you may write to them. If you cannot write that sentence, do not queue the address. And nothing leaves your mailbox that you have not read, which is why the approval gate is a phase rather than a setting. ### Stack (fixed, do not substitute) - Runtime: Node 22 with TypeScript, a command line only, SQLite for state · a tool with your mailbox credentials does not need a web interface as well - Mail: The provider's API with the narrowest scopes that send and read threads, one account · the scope list is the security review you are trying not to need - Scheduling: A scheduler on a machine that stays awake, with every tick idempotent · a laptop that sleeps mid-campaign is the normal case - Stop conditions: Thread state, not string matching on subjects · a reply from a colleague's address on the same thread must still stop the sequence - Provenance: A source and a contact basis required per lead, enforced by the schema · if you cannot say why you may write to someone, that is the answer ### Data model (create this before Phase 1) - account(email, token_ref, granted_at, scopes, publishing_status, last_refresh_ok_at, state) · state goes to halted the moment a refresh fails - leads(id, email_hash, email, first_name, company, context, source, contact_basis, added_at, state) · source and contact_basis are not nullable and there is no import path that fills them in for you - drafts(id, lead_id, step, subject, body, model_id, generated_at, approved_at, approved_body) · approved_body is what sends, so an edit at review is what goes out - sends(id, draft_id, thread_id, provider_message_id, sent_at, window_id) - events(id, lead_id, thread_id, kind, detected_at, raw_ref) · kind is reply, auto_reply, bounce or opt_out, and they are recorded separately because they mean different things - suppression(email_hash, reason, added_at) · permanent, checked before drafting as well as before sending, and never emptied by a bulk operation - A sequence stops because the lead's state changed, not because a check ran at send time. The check at send time is the second line, not the first. ### Phase 1 · One mailbox, and a grant that tells you when it dies Build: An auth command runs the consent flow once and stores the refresh token outside the repository. Request only the scopes needed to send and to read thread state. Refresh before every run; on failure, set the account state to halted, stop the queue and print the reason at the top of every subsequent command until it is fixed. Trap: Treating a failed token refresh as a transient error and retrying on the next tick means the scheduler keeps running, keeps marking rows as attempted and keeps reporting a healthy campaign while nothing has left the mailbox for days, which is precisely the failure that makes you look unreliable to the people who did reply. Halt on the first refresh failure, refuse to run anything that sends, and make the message unmissable. Done when: The scopes requested are the minimum that send and read thread state, listed in the README and asserted against the stored grant. A failed refresh sets the account to halted and every send-related command refuses with the reason, tested by invalidating the token. Re-running the auth command clears the halt and records a new grant time. The refresh token is never written to the repository or to a log, verified by a test that scans both. Do not build yet: No leads, no drafting, no sending and no thread watching yet. ### Phase 2 · No lead without a reason Build: A CSV import that refuses any row missing a source and a contact basis, both free text and both stored. Check every address against the suppression list at import as well as at send. Normalise addresses before hashing so two spellings of the same person do not become two leads. Trap: Making provenance an optional column means it is empty for every row within a week, and the first time somebody asks where you got their address the honest answer is that you do not know, which is both an unpleasant conversation and, in several places, the wrong answer to give a regulator. Make both fields required at the schema level so there is no import path that leaves them blank. Done when: A CSV row missing a source or a contact basis is rejected with the line number, tested. An address already in suppression is refused at import rather than silently dropped later, tested. Address normalisation is case-insensitive and trims whitespace and the suppression check uses the normalised form, tested with variants. There is no command anywhere that adds a lead without both provenance fields, asserted by review. Do not build yet: No drafting, no review, no sending and no watching yet. ### Phase 3 · A draft in your voice, and a gate you cannot skip Build: A voice command pulls a sample of your own sent messages, strips quoted history and signatures, and saves a short style reference to a file you can edit. Drafting uses that file plus the lead's context column. A review command shows drafts one at a time with approve, edit and skip, and stores the approved body as the thing that will send. Trap: Storing only an approval flag and regenerating the body at send time means the message that goes out is not the message you read, because the model was called again and produced something slightly different, and you find out from the reply. Store the exact approved text and send that, and treat any change to the draft after approval as requiring a fresh approval. Done when: The approved body is stored verbatim and is byte-identical to what is sent, asserted in the suite. No send path exists for a draft without an approval timestamp, verified by review and a test. Editing a draft after approval clears the approval, tested. The voice sample file is plain text, editable, and its contents are visible in the review output so you can see what the draft was built from. Do not build yet: No sending, no follow-ups and no thread watching yet. ### Phase 4 · Sending at a human pace Build: A send command that runs only inside a configured window, stops at a daily cap, and leaves a random gap between messages. Claim each draft with a conditional update before calling the provider and record the returned message and thread identifiers immediately. A message sent outside a window or over the cap is not sent at all rather than queued for later. Trap: Marking a draft as sent after the provider call returns means a process killed mid-send leaves a message delivered and a row that still says pending, and the next run sends the same message again to the same person, which is the single most embarrassing bug this category has. Claim the row first, write the provider identifiers the moment you have them, and treat a claimed row with no identifiers as something to check by hand rather than to retry. Done when: Killing the process during a send never results in a second delivery on the next run, tested by interrupting between claim and record. Sends outside the window or over the daily cap do not happen, tested at both boundaries. Every send stores the provider's thread identifier, which is what every stop condition later depends on. The gap between consecutive sends is randomised within a configured range, verified across a run. Do not build yet: No follow-ups, no reply detection and no export yet. ### Phase 5 · Stopping, which is the whole product Build: Poll thread state on a schedule. Any inbound message on a thread you started sets the lead to replied and cancels its remaining steps, regardless of which address it came from. Classify automatic replies as their own event kind that stops the sequence without counting as interest. Parse delivery failure notices to suppression. Honour an opt-out request permanently and at once. Trap: Detecting replies by matching the sender address against the lead means a message from a colleague, an assistant or a forwarded internal address on the same thread does not count, and your follow-up lands underneath a human answer as if nobody had spoken, which is exactly the carelessness this tool is meant to avoid. Key the stop condition on the thread, not on the address, and treat any inbound message on a thread you started as a reply. Done when: An inbound message from an address other than the lead's, on a thread you started, stops the sequence, tested with a fixture. An automatic reply stops the sequence and is recorded as its own event kind, never as interest, tested. A delivery failure notice adds the address to suppression permanently and suppression survives a database rebuild, tested. An opt-out request is honoured before any further send and cannot be undone by a re-import, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Lead sourcing by scraping, address guessing or pattern-based email generation, all of which produce addresses you cannot write a provenance line for and bounces that damage your own mailbox. - Open and click tracking pixels, which in several jurisdictions need consent in their own right and which tell you less about interest than a reply does. - Sending for anybody but yourself, because a second mailbox is what puts you into the annual security assessment the policy describes. - Inbox warmup schemes and reply exchanges between accounts, which are attempts to game a provider's own filtering and are exactly the behaviour that gets a domain penalised. ### README must contain - That this connects one mailbox, yours, and why sending for anybody else means an annual third-party security assessment under the provider's user data policy - That a project left in testing status is issued a refresh token expiring in seven days, so the grant needs renewing weekly, with the source and the date you checked it - That every lead requires a recorded source and a recorded reason you may contact them, and that there is no import path around it - That you must read the marketing mail rules that apply where your recipients are before the first send, and that a bought list is treated as no consent in several jurisdictions - That stop conditions key on the thread rather than the sender, and that an automatic reply stops a sequence without being treated as interest ===== ARCHITECTURE.md ===== # Architecture · OutFriend ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with TypeScript, a command line only, SQLite for state | a tool with your mailbox credentials does not need a web interface as well | | Mail | The provider's API with the narrowest scopes that send and read threads, one account | the scope list is the security review you are trying not to need | | Scheduling | A scheduler on a machine that stays awake, with every tick idempotent | a laptop that sleeps mid-campaign is the normal case | | Stop conditions | Thread state, not string matching on subjects | a reply from a colleague's address on the same thread must still stop the sequence | | Provenance | A source and a contact basis required per lead, enforced by the schema | if you cannot say why you may write to someone, that is the answer | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Auth | Scopes, the grant and the halt state. | Any provider, provided a refresh failure halts sending rather than retrying quietly. | | Leads | Provenance, normalisation and suppression checks. | Any import, provided source and contact basis stay required. | | Drafting | The voice reference and the approved body. | Any model, provided the approved text is what sends. | | Sending | Claims, windows, caps and thread identifiers. | Any transport, provided the claim precedes the call. | | Stopping | Thread events, automatic replies, bounces and opt-outs. | Not swappable for sender-address matching, which is the bug this exists to prevent. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `MAIL_CLIENT_ID` · required · The OAuth client for your cloud project. - `MAIL_CLIENT_SECRET` · required, secret · Pairs with the client id. Never in the repository. - `MAIL_TOKEN_PATH` · required, secret · The refresh token, outside the repository. Expect to renew it weekly while the project is in testing. - `MODEL_API_KEY` · required, secret · Drafting only. It never sees the mailbox. - `DATABASE_PATH` · required · Leads, drafts, sends, events and suppression. Back this up; suppression in particular. - `SEND_WINDOW` · required · Outside this, nothing sends. Not deferred, refused. - `DAILY_CAP` · required · A hard stop per day. Lower than you think is usually right. - `SEND_GAP_SECONDS` · required · A random gap in this range between consecutive sends. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it. ===== AGENTS.md ===== # Agent instructions · OutFriend product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with TypeScript, a command line only, SQLite for state, The provider's API with the narrowest scopes that send and read threads, one account, A scheduler on a machine that stays awake, with every tick idempotent, Thread state, not string matching on subjects, A source and a contact basis required per lead, enforced by the schema. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Treating a failed token refresh as a transient error and retrying on the next tick means the scheduler keeps running, keeps marking rows as attempted and keeps reporting a healthy campaign while nothing has left the mailbox for days, which is precisely the failure that makes you look unreliable to the people who did reply. Halt on the first refresh failure, refuse to run anything that sends, and make the message unmissable. - Making provenance an optional column means it is empty for every row within a week, and the first time somebody asks where you got their address the honest answer is that you do not know, which is both an unpleasant conversation and, in several places, the wrong answer to give a regulator. Make both fields required at the schema level so there is no import path that leaves them blank. - Storing only an approval flag and regenerating the body at send time means the message that goes out is not the message you read, because the model was called again and produced something slightly different, and you find out from the reply. Store the exact approved text and send that, and treat any change to the draft after approval as requiring a fresh approval. - Marking a draft as sent after the provider call returns means a process killed mid-send leaves a message delivered and a row that still says pending, and the next run sends the same message again to the same person, which is the single most embarrassing bug this category has. Claim the row first, write the provider identifiers the moment you have them, and treat a claimed row with no identifiers as something to check by hand rather than to retry. - Detecting replies by matching the sender address against the lead means a message from a colleague, an assistant or a forwarded internal address on the same thread does not count, and your follow-up lands underneath a human answer as if nobody had spoken, which is exactly the carelessness this tool is meant to avoid. Key the stop condition on the thread, not on the address, and treat any inbound message on a thread you started as a reply. ===== MILESTONES.md ===== # Delivery milestones · OutFriend Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · One mailbox, and a grant that tells you when it dies Authorisation happens once, the narrowest scopes are used, and a failed refresh halts everything loudly. ### Steps 1. Request the narrowest scopes that do the job Every extra scope is more of your mailbox in a tool you wrote at the weekend. Files: `src/auth/scopes.ts`, `README.md` 2. Halt on the first refresh failure A weekly expiry is fine; a silent one is not. Files: `src/auth/refresh.ts`, `tests/halt.test.ts` 3. Keep the token out of the repository and out of logs A token in a log file is a token in a backup. Files: `src/auth/store.ts`, `tests/no-token-leak.test.ts` ### Done when - [ ] Only the minimum scopes are requested - [ ] A failed refresh halts every sending command - [ ] Re-authorising clears the halt - [ ] The token never reaches the repository or a log ### Watch out - Treating a failed token refresh as a transient error and retrying on the next tick means the scheduler keeps running, keeps marking rows as attempted and keeps reporting a healthy campaign while nothing has left the mailbox for days, which is precisely the failure that makes you look unreliable to the people who did reply. Halt on the first refresh failure, refuse to run anything that sends, and make the message unmissable. ## M2 · No lead without a reason Importing a contact requires saying where the address came from and why you may write to them. ### Steps 1. Make both provenance fields non-nullable Optional means empty within a week. Files: `src/db/migrations/002_leads.sql` 2. Check suppression at import, not only at send The earliest refusal is the kindest one. Files: `src/leads/import.ts`, `tests/suppression.test.ts` 3. Normalise before hashing One person should not become three leads. Files: `src/leads/normalise.ts` ### Done when - [ ] Rows without provenance are rejected with a line number - [ ] Suppressed addresses are refused at import - [ ] Normalisation is case and whitespace safe - [ ] No path creates a lead without provenance ### Watch out - Making provenance an optional column means it is empty for every row within a week, and the first time somebody asks where you got their address the honest answer is that you do not know, which is both an unpleasant conversation and, in several places, the wrong answer to give a regulator. Make both fields required at the schema level so there is no import path that leaves them blank. ## M3 · A draft in your voice, and a gate you cannot skip Each lead gets one draft built from your own sent mail, and nothing sends without you reading it. ### Steps 1. Build the voice reference into a file you can edit A style reference you cannot read is a style you cannot fix. Files: `src/voice/build.ts`, `voice.md` 2. Store the approved text, not an approval flag What you read must be what leaves. Files: `src/drafts/approve.ts`, `tests/approved-body.test.ts` 3. Clear approval on any edit An approval is of a specific message. Files: `src/drafts/edit.ts` ### Done when - [ ] The sent body is byte-identical to the approved body - [ ] No draft sends without an approval timestamp - [ ] Editing after approval clears it - [ ] The voice reference is readable and editable ### Watch out - Storing only an approval flag and regenerating the body at send time means the message that goes out is not the message you read, because the model was called again and produced something slightly different, and you find out from the reply. Store the exact approved text and send that, and treat any change to the draft after approval as requiring a fresh approval. ## M4 · Sending at a human pace Approved drafts leave within a window, under a daily cap, spaced apart, and never twice. ### Steps 1. Claim the row before calling the provider The gap between send and record is where duplicates are born. Files: `src/send/claim.ts`, `tests/no-double-send.test.ts` 2. Store the thread identifier immediately Every stop condition in phase five hangs off it. Files: `src/send/record.ts` 3. Refuse rather than defer outside the window A queue that catches up at three in the morning is its own problem. Files: `src/send/window.ts` ### Done when - [ ] An interrupted send never delivers twice - [ ] The window and daily cap are both enforced - [ ] Thread identifiers are stored on every send - [ ] Gaps between sends are randomised ### Watch out - Marking a draft as sent after the provider call returns means a process killed mid-send leaves a message delivered and a row that still says pending, and the next run sends the same message again to the same person, which is the single most embarrassing bug this category has. Claim the row first, write the provider identifiers the moment you have them, and treat a claimed row with no identifiers as something to check by hand rather than to retry. ## M5 · Stopping, which is the whole product (production only) Any reply, bounce or opt-out ends the sequence immediately, and an automatic reply is never read as interest. ### Steps 1. Key stop conditions on the thread, not the address Replies come from assistants, colleagues and forwards. Files: `src/watch/threads.ts`, `tests/stop-conditions.test.ts` 2. Classify automatic replies separately and stop anyway An out of office is not a warm lead. Files: `src/watch/autoreply.ts` 3. Make suppression permanent and re-import proof Someone asked you to stop. That is not a per-campaign setting. Files: `src/suppression/permanent.ts`, `src/cli/export.ts` ### Done when - [ ] Any inbound message on the thread stops the sequence - [ ] Automatic replies stop without counting as interest - [ ] Bounces add to permanent suppression - [ ] Opt-outs survive a re-import ### Watch out - Detecting replies by matching the sender address against the lead means a message from a colleague, an assistant or a forwarded internal address on the same thread does not count, and your follow-up lands underneath a human answer as if nobody had spoken, which is exactly the carelessness this tool is meant to avoid. Key the stop condition on the thread, not on the address, and treat any inbound message on a thread you started as a reply. ===== OPERATIONS.md ===== # Operations · OutFriend ## Backup The database, daily, and understand that the suppression table is the part you cannot afford to lose. Everything else is reconstructible from a CSV and a few model calls; a lost suppression list means writing again to people who asked you not to. ## Restore Restore, then check three things before enabling sending: the account state, the suppression row count against your last known figure, and that no draft is approved with a body that differs from what is in sends. Re-authorise rather than trusting a restored token. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Days since the last successful token refresh, the count of leads in replied state against sent, and any send row claimed without a provider identifier. The third is the one that means a message may have gone out twice. ## Incident checklist The incident in this category is a follow-up that landed under somebody's reply, or a message to a person on the suppression list. Both are recoverable only as apologies, which is why the events table records the kind and time of every inbound message and why suppression is checked at import as well as at send. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A failed token refresh is confirmed to halt every sending command - [ ] A lead without provenance is confirmed impossible to create - [ ] The sent body is confirmed byte-identical to the approved body - [ ] An interrupted send is confirmed never to deliver twice - [ ] Any inbound message on the thread is confirmed to stop the sequence ## Launch constraint Do not market omitted OutFriend capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered. ===== .env.example ===== # Copy to .env and fill in. Never commit .env; this file documents it. # Required. The OAuth client for your cloud project. MAIL_CLIENT_ID=...apps.googleusercontent.com # Required · secret. Pairs with the client id. Never in the repository. MAIL_CLIENT_SECRET=...redacted # Required · secret. The refresh token, outside the repository. Expect to renew it weekly while the project is in testing. MAIL_TOKEN_PATH=~/.config/outreach/token.json # Required · secret. Drafting only. It never sees the mailbox. MODEL_API_KEY=sk-...redacted # Required. Leads, drafts, sends, events and suppression. Back this up; suppression in particular. DATABASE_PATH=./data/outreach.db # Required. Outside this, nothing sends. Not deferred, refused. SEND_WINDOW=09:30-16:30 Europe/London # Required. A hard stop per day. Lower than you think is usually right. DAILY_CAP=25 # Required. A random gap in this range between consecutive sends. SEND_GAP_SECONDS=45-210
# OutFriend · indie build A single-mailbox outreach tool whose hard problems are stop conditions that are actually correct, a grant that expires weekly without anyone noticing, and a provenance record per recipient that makes you decide whether you should be writing to them at all. Estimated effort: **multi-day**. Work `BUILD_PLAN.md` top to bottom · every phase ends in a check that has to pass before the next one starts. ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with TypeScript, a command line only, SQLite for state | a tool with your mailbox credentials does not need a web interface as well | | Mail | The provider's API with the narrowest scopes that send and read threads, one account | the scope list is the security review you are trying not to need | | Scheduling | A scheduler on a machine that stays awake, with every tick idempotent | a laptop that sleeps mid-campaign is the normal case | | Stop conditions | Thread state, not string matching on subjects | a reply from a colleague's address on the same thread must still stop the sequence | | Provenance | A source and a contact basis required per lead, enforced by the schema | if you cannot say why you may write to someone, that is the answer | ## Before you start Have every one of these ready. The plan assumes them from step one. - [ ] **Node.js 22 or newer** · free - Why: Runs the command line tool and the scheduler. - Get it: nodejs.org, or your version manager. - Verify: node --version prints v22 or higher - [ ] **A cloud project with the mail API enabled and a consent screen configured** · free - Why: This is where the scope list and the publishing status live, and both of them decide how this tool behaves week to week. - Get it: Create the project, enable the mail API, configure the consent screen and note its publishing status. - Verify: The consent flow completes and the stored grant lists only the scopes you asked for - [ ] **An API key for a text model** · pennies per draft - Why: Drafts one message per lead from your voice sample and their context. - Get it: Create a key and set a spend limit in the provider's console. - Verify: One lead produces a draft that reads like you wrote it - [ ] **A machine that stays awake** · free to a few pounds a month - Why: Thread polling and the send window both need something running when your laptop is shut, and a missed poll is a follow-up sent under somebody's reply. - Get it: A small always-on box, or a cheap virtual machine you already have. - Verify: A poll runs on schedule with your laptop closed - [ ] **Your written note on which marketing mail rules apply to your recipients** · free - Why: The rules differ by jurisdiction and between personal and business addresses, and deciding this after the first campaign is deciding it too late. - Get it: Read the applicable rules where your recipients are, and write down what you concluded and what you will record per lead. - Verify: The note exists, names the jurisdictions you checked and the date, and matches the contact_basis values you actually use ## Quick start Follow Phase 1 in `BUILD_PLAN.md`; it creates the project. Then copy `.env.example` to `.env` and fill in the values it documents. ## Honest limits This build deliberately does not replace: - Lead sourcing, scraping or address guessing. - Open and click tracking. - Sending from anybody else's mailbox. - Inbox warmup and reply exchanges. - reply detection that is thread-based rather than naive, so out-of-office replies do not count as interest and a reply from a different address still stops the sequence - verification and suppression, meaning unverified, bounced and opted-out addresses filtered before anything queues - a review surface where every draft is readable and editable before it goes, instead of trusting a script with your inbox - lead generation, which the paid tiers include and your CSV does not - the Google OAuth verification process, which you will meet the moment you want this on more than your own account If one of those is essential to you, that is the reason to keep paying for OutFriend, and the README should say so rather than pretend.
# Build brief · OutFriend The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a personal outreach tool that drafts in your voice, sends from your own mailbox, and stops the instant anybody replies to replace OutFriend. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Three things are settled before any code, and two of them are settled by the mail provider rather than by you. This connects one mailbox and it is yours. Detecting a reply means reading a thread, and Google's API Services User Data Policy puts apps using sensitive and restricted scopes into a process where they must pass an annual security assessment and obtain a letter of assessment from a Google-designated third party. That is a recurring cost and a review, not a checkbox. Build this for your own account and stop there; the moment you want it sending for somebody else you are in that process. The grant expires under you. Google's OAuth documentation states that a Cloud project with an external consent screen whose publishing status is Testing is issued a refresh token expiring in seven days, unless the only scopes requested are a subset of name, email address and user profile. So a tool built this way stops being able to send roughly every week. That is survivable and it is not survivable silently: detect the failed refresh, halt the queue, and put it at the top of the screen. A campaign that quietly stopped on Wednesday is worse than one that never started. The legal position is not the same everywhere and it is not optional. Before the first send, read what applies where your recipients are: several jurisdictions require prior consent for marketing mail to individuals and treat a bought list as no consent at all, and the rules for business addresses often differ from the rules for personal ones. Record, per recipient, where the address came from and why you believe you may write to them. If you cannot write that sentence, do not queue the address. And nothing leaves your mailbox that you have not read, which is why the approval gate is a phase rather than a setting. ### Stack (fixed, do not substitute) - Runtime: Node 22 with TypeScript, a command line only, SQLite for state · a tool with your mailbox credentials does not need a web interface as well - Mail: The provider's API with the narrowest scopes that send and read threads, one account · the scope list is the security review you are trying not to need - Scheduling: A scheduler on a machine that stays awake, with every tick idempotent · a laptop that sleeps mid-campaign is the normal case - Stop conditions: Thread state, not string matching on subjects · a reply from a colleague's address on the same thread must still stop the sequence - Provenance: A source and a contact basis required per lead, enforced by the schema · if you cannot say why you may write to someone, that is the answer ### Data model (create this before Phase 1) - account(email, token_ref, granted_at, scopes, publishing_status, last_refresh_ok_at, state) · state goes to halted the moment a refresh fails - leads(id, email_hash, email, first_name, company, context, source, contact_basis, added_at, state) · source and contact_basis are not nullable and there is no import path that fills them in for you - drafts(id, lead_id, step, subject, body, model_id, generated_at, approved_at, approved_body) · approved_body is what sends, so an edit at review is what goes out - sends(id, draft_id, thread_id, provider_message_id, sent_at, window_id) - events(id, lead_id, thread_id, kind, detected_at, raw_ref) · kind is reply, auto_reply, bounce or opt_out, and they are recorded separately because they mean different things - suppression(email_hash, reason, added_at) · permanent, checked before drafting as well as before sending, and never emptied by a bulk operation - A sequence stops because the lead's state changed, not because a check ran at send time. The check at send time is the second line, not the first. ### Phase 1 · One mailbox, and a grant that tells you when it dies Build: An auth command runs the consent flow once and stores the refresh token outside the repository. Request only the scopes needed to send and to read thread state. Refresh before every run; on failure, set the account state to halted, stop the queue and print the reason at the top of every subsequent command until it is fixed. Trap: Treating a failed token refresh as a transient error and retrying on the next tick means the scheduler keeps running, keeps marking rows as attempted and keeps reporting a healthy campaign while nothing has left the mailbox for days, which is precisely the failure that makes you look unreliable to the people who did reply. Halt on the first refresh failure, refuse to run anything that sends, and make the message unmissable. Done when: The scopes requested are the minimum that send and read thread state, listed in the README and asserted against the stored grant. A failed refresh sets the account to halted and every send-related command refuses with the reason, tested by invalidating the token. Re-running the auth command clears the halt and records a new grant time. The refresh token is never written to the repository or to a log, verified by a test that scans both. Do not build yet: No leads, no drafting, no sending and no thread watching yet. ### Phase 2 · No lead without a reason Build: A CSV import that refuses any row missing a source and a contact basis, both free text and both stored. Check every address against the suppression list at import as well as at send. Normalise addresses before hashing so two spellings of the same person do not become two leads. Trap: Making provenance an optional column means it is empty for every row within a week, and the first time somebody asks where you got their address the honest answer is that you do not know, which is both an unpleasant conversation and, in several places, the wrong answer to give a regulator. Make both fields required at the schema level so there is no import path that leaves them blank. Done when: A CSV row missing a source or a contact basis is rejected with the line number, tested. An address already in suppression is refused at import rather than silently dropped later, tested. Address normalisation is case-insensitive and trims whitespace and the suppression check uses the normalised form, tested with variants. There is no command anywhere that adds a lead without both provenance fields, asserted by review. Do not build yet: No drafting, no review, no sending and no watching yet. ### Phase 3 · A draft in your voice, and a gate you cannot skip Build: A voice command pulls a sample of your own sent messages, strips quoted history and signatures, and saves a short style reference to a file you can edit. Drafting uses that file plus the lead's context column. A review command shows drafts one at a time with approve, edit and skip, and stores the approved body as the thing that will send. Trap: Storing only an approval flag and regenerating the body at send time means the message that goes out is not the message you read, because the model was called again and produced something slightly different, and you find out from the reply. Store the exact approved text and send that, and treat any change to the draft after approval as requiring a fresh approval. Done when: The approved body is stored verbatim and is byte-identical to what is sent, asserted in the suite. No send path exists for a draft without an approval timestamp, verified by review and a test. Editing a draft after approval clears the approval, tested. The voice sample file is plain text, editable, and its contents are visible in the review output so you can see what the draft was built from. Do not build yet: No sending, no follow-ups and no thread watching yet. ### Phase 4 · Sending at a human pace Build: A send command that runs only inside a configured window, stops at a daily cap, and leaves a random gap between messages. Claim each draft with a conditional update before calling the provider and record the returned message and thread identifiers immediately. A message sent outside a window or over the cap is not sent at all rather than queued for later. Trap: Marking a draft as sent after the provider call returns means a process killed mid-send leaves a message delivered and a row that still says pending, and the next run sends the same message again to the same person, which is the single most embarrassing bug this category has. Claim the row first, write the provider identifiers the moment you have them, and treat a claimed row with no identifiers as something to check by hand rather than to retry. Done when: Killing the process during a send never results in a second delivery on the next run, tested by interrupting between claim and record. Sends outside the window or over the daily cap do not happen, tested at both boundaries. Every send stores the provider's thread identifier, which is what every stop condition later depends on. The gap between consecutive sends is randomised within a configured range, verified across a run. Do not build yet: No follow-ups, no reply detection and no export yet. ### Phase 5 · Stopping, which is the whole product Build: Poll thread state on a schedule. Any inbound message on a thread you started sets the lead to replied and cancels its remaining steps, regardless of which address it came from. Classify automatic replies as their own event kind that stops the sequence without counting as interest. Parse delivery failure notices to suppression. Honour an opt-out request permanently and at once. Trap: Detecting replies by matching the sender address against the lead means a message from a colleague, an assistant or a forwarded internal address on the same thread does not count, and your follow-up lands underneath a human answer as if nobody had spoken, which is exactly the carelessness this tool is meant to avoid. Key the stop condition on the thread, not on the address, and treat any inbound message on a thread you started as a reply. Done when: An inbound message from an address other than the lead's, on a thread you started, stops the sequence, tested with a fixture. An automatic reply stops the sequence and is recorded as its own event kind, never as interest, tested. A delivery failure notice adds the address to suppression permanently and suppression survives a database rebuild, tested. An opt-out request is honoured before any further send and cannot be undone by a re-import, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Lead sourcing by scraping, address guessing or pattern-based email generation, all of which produce addresses you cannot write a provenance line for and bounces that damage your own mailbox. - Open and click tracking pixels, which in several jurisdictions need consent in their own right and which tell you less about interest than a reply does. - Sending for anybody but yourself, because a second mailbox is what puts you into the annual security assessment the policy describes. - Inbox warmup schemes and reply exchanges between accounts, which are attempts to game a provider's own filtering and are exactly the behaviour that gets a domain penalised. ### README must contain - That this connects one mailbox, yours, and why sending for anybody else means an annual third-party security assessment under the provider's user data policy - That a project left in testing status is issued a refresh token expiring in seven days, so the grant needs renewing weekly, with the source and the date you checked it - That every lead requires a recorded source and a recorded reason you may contact them, and that there is no import path around it - That you must read the marketing mail rules that apply where your recipients are before the first send, and that a bought list is treated as no consent in several jurisdictions - That stop conditions key on the thread rather than the sender, and that an automatic reply stops a sequence without being treated as interest
# Agent instructions · OutFriend indie build - Read `README.md` and `BUILD_PLAN.md` before writing code. The stack is fixed: Node 22 with TypeScript, a command line only, SQLite for state, The provider's API with the narrowest scopes that send and read threads, one account, A scheduler on a machine that stays awake, with every tick idempotent, Thread state, not string matching on subjects, A source and a contact basis required per lead, enforced by the schema. Do not substitute. - Work one phase at a time, in order. Do not start a phase until every "Done when" item of the previous one passes. - Prefer the fewest moving parts that satisfy the step. No frameworks, services or dependencies the plan does not name. - Secrets live in `.env`, never in source or logs. Keep `.env.example` current when a variable is introduced. - Do not invent cryptography, security guarantees, APIs or compliance claims. - Add a focused test for every destructive, security-sensitive or data-loss path the plan names. - Run the project checks before declaring a phase complete, and record any deliberate shortcut in the README under "Tradeoffs". ## Known traps - Treating a failed token refresh as a transient error and retrying on the next tick means the scheduler keeps running, keeps marking rows as attempted and keeps reporting a healthy campaign while nothing has left the mailbox for days, which is precisely the failure that makes you look unreliable to the people who did reply. Halt on the first refresh failure, refuse to run anything that sends, and make the message unmissable. - Making provenance an optional column means it is empty for every row within a week, and the first time somebody asks where you got their address the honest answer is that you do not know, which is both an unpleasant conversation and, in several places, the wrong answer to give a regulator. Make both fields required at the schema level so there is no import path that leaves them blank. - Storing only an approval flag and regenerating the body at send time means the message that goes out is not the message you read, because the model was called again and produced something slightly different, and you find out from the reply. Store the exact approved text and send that, and treat any change to the draft after approval as requiring a fresh approval. - Marking a draft as sent after the provider call returns means a process killed mid-send leaves a message delivered and a row that still says pending, and the next run sends the same message again to the same person, which is the single most embarrassing bug this category has. Claim the row first, write the provider identifiers the moment you have them, and treat a claimed row with no identifiers as something to check by hand rather than to retry.
# Build plan · OutFriend A single-mailbox outreach tool whose hard problems are stop conditions that are actually correct, a grant that expires weekly without anyone noticing, and a provenance record per recipient that makes you decide whether you should be writing to them at all. Phases are in dependency order. Each ends in a "Done when" list; treat an unticked item as a blocker, not a note. ## Phase 1 · One mailbox, and a grant that tells you when it dies Authorisation happens once, the narrowest scopes are used, and a failed refresh halts everything loudly. ### Steps 1. Request the narrowest scopes that do the job Every extra scope is more of your mailbox in a tool you wrote at the weekend. Files: `src/auth/scopes.ts`, `README.md` 2. Halt on the first refresh failure A weekly expiry is fine; a silent one is not. Files: `src/auth/refresh.ts`, `tests/halt.test.ts` 3. Keep the token out of the repository and out of logs A token in a log file is a token in a backup. Files: `src/auth/store.ts`, `tests/no-token-leak.test.ts` ### Done when - [ ] Only the minimum scopes are requested - [ ] A failed refresh halts every sending command - [ ] Re-authorising clears the halt - [ ] The token never reaches the repository or a log ### Watch out - Treating a failed token refresh as a transient error and retrying on the next tick means the scheduler keeps running, keeps marking rows as attempted and keeps reporting a healthy campaign while nothing has left the mailbox for days, which is precisely the failure that makes you look unreliable to the people who did reply. Halt on the first refresh failure, refuse to run anything that sends, and make the message unmissable. ## Phase 2 · No lead without a reason Importing a contact requires saying where the address came from and why you may write to them. ### Steps 1. Make both provenance fields non-nullable Optional means empty within a week. Files: `src/db/migrations/002_leads.sql` 2. Check suppression at import, not only at send The earliest refusal is the kindest one. Files: `src/leads/import.ts`, `tests/suppression.test.ts` 3. Normalise before hashing One person should not become three leads. Files: `src/leads/normalise.ts` ### Done when - [ ] Rows without provenance are rejected with a line number - [ ] Suppressed addresses are refused at import - [ ] Normalisation is case and whitespace safe - [ ] No path creates a lead without provenance ### Watch out - Making provenance an optional column means it is empty for every row within a week, and the first time somebody asks where you got their address the honest answer is that you do not know, which is both an unpleasant conversation and, in several places, the wrong answer to give a regulator. Make both fields required at the schema level so there is no import path that leaves them blank. ## Phase 3 · A draft in your voice, and a gate you cannot skip Each lead gets one draft built from your own sent mail, and nothing sends without you reading it. ### Steps 1. Build the voice reference into a file you can edit A style reference you cannot read is a style you cannot fix. Files: `src/voice/build.ts`, `voice.md` 2. Store the approved text, not an approval flag What you read must be what leaves. Files: `src/drafts/approve.ts`, `tests/approved-body.test.ts` 3. Clear approval on any edit An approval is of a specific message. Files: `src/drafts/edit.ts` ### Done when - [ ] The sent body is byte-identical to the approved body - [ ] No draft sends without an approval timestamp - [ ] Editing after approval clears it - [ ] The voice reference is readable and editable ### Watch out - Storing only an approval flag and regenerating the body at send time means the message that goes out is not the message you read, because the model was called again and produced something slightly different, and you find out from the reply. Store the exact approved text and send that, and treat any change to the draft after approval as requiring a fresh approval. ## Phase 4 · Sending at a human pace Approved drafts leave within a window, under a daily cap, spaced apart, and never twice. ### Steps 1. Claim the row before calling the provider The gap between send and record is where duplicates are born. Files: `src/send/claim.ts`, `tests/no-double-send.test.ts` 2. Store the thread identifier immediately Every stop condition in phase five hangs off it. Files: `src/send/record.ts` 3. Refuse rather than defer outside the window A queue that catches up at three in the morning is its own problem. Files: `src/send/window.ts` ### Done when - [ ] An interrupted send never delivers twice - [ ] The window and daily cap are both enforced - [ ] Thread identifiers are stored on every send - [ ] Gaps between sends are randomised ### Watch out - Marking a draft as sent after the provider call returns means a process killed mid-send leaves a message delivered and a row that still says pending, and the next run sends the same message again to the same person, which is the single most embarrassing bug this category has. Claim the row first, write the provider identifiers the moment you have them, and treat a claimed row with no identifiers as something to check by hand rather than to retry. ## Not in this build - Lead sourcing, scraping or address guessing. - Open and click tracking. - Sending from anybody else's mailbox. - Inbox warmup and reply exchanges. ## After v1, if you want it - A per-lead note field written after a reply, which is the only useful thing a tool like this can remember about a person - A weekly report of replies against sends by contact basis, which tells you which sources are worth keeping - A dry run mode that renders the whole campaign to files without touching the mailbox, useful before every first send
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. The OAuth client for your cloud project. MAIL_CLIENT_ID=...apps.googleusercontent.com # Required · secret. Pairs with the client id. Never in the repository. MAIL_CLIENT_SECRET=...redacted # Required · secret. The refresh token, outside the repository. Expect to renew it weekly while the project is in testing. MAIL_TOKEN_PATH=~/.config/outreach/token.json # Required · secret. Drafting only. It never sees the mailbox. MODEL_API_KEY=sk-...redacted # Required. Leads, drafts, sends, events and suppression. Back this up; suppression in particular. DATABASE_PATH=./data/outreach.db # Required. Outside this, nothing sends. Not deferred, refused. SEND_WINDOW=09:30-16:30 Europe/London # Required. A hard stop per day. Lower than you think is usually right. DAILY_CAP=25 # Required. A random gap in this range between consecutive sends. SEND_GAP_SECONDS=45-210
# OutFriend · product brief ## Problem This one is closer to buildable than most outreach tools, because OutFriend sends through your own Gmail rather than its own infrastructure. That removes the moat that usually saves this category: no warmup pools, no IP reputation, no deliverability engineering you cannot replicate. What is left is a Gmail OAuth integration, a voice sample you pass to a model, a scheduler with send windows and daily caps, and the one piece that is genuinely fiddly, watching threads so a sequence stops the instant someone replies. That is a weekend if you have written against the Gmail API before, and a long weekend if you have not. ## Product outcome An outreach tool that halts loudly when its grant expires, refuses a lead you cannot account for, sends only what you read, and stops the sequence on any inbound message on the thread rather than only on one from the address you expected. ## Target user A builder who needs a maintainable product foundation, not a one-off demo. ## Required capabilities - a Google Cloud project with Gmail API access and OAuth consent configured - an OpenAI or Anthropic API key - a leads CSV you sourced yourself - a scheduler that survives your laptop sleeping - your own read of CAN-SPAM and GDPR before you send anything ## Explicit non-goals for v1 - Lead sourcing, scraping or address guessing. - Open and click tracking. - Sending from anybody else's mailbox. - Inbox warmup and reply exchanges. - reply detection that is thread-based rather than naive, so out-of-office replies do not count as interest and a reply from a different address still stops the sequence - verification and suppression, meaning unverified, bounced and opted-out addresses filtered before anything queues - a review surface where every draft is readable and editable before it goes, instead of trusting a script with your inbox - lead generation, which the paid tiers include and your CSV does not - the Google OAuth verification process, which you will meet the moment you want this on more than your own account ## Success criteria - A failed token refresh is confirmed to halt every sending command - A lead without provenance is confirmed impossible to create - The sent body is confirmed byte-identical to the approved body - An interrupted send is confirmed never to deliver twice - Any inbound message on the thread is confirmed to stop the sequence
# Build brief · OutFriend The one-shot brief this plan expands. `BUILD_PLAN.md` (or `MILESTONES.md`) is the same sequence broken into steps and checks; where the two disagree, the plan wins. Build me a personal outreach tool that drafts in your voice, sends from your own mailbox, and stops the instant anybody replies to replace OutFriend. Build it in phases, in the order below. Do not write the whole thing in one pass. Finish a phase, run its "Done when" check, fix what fails, and only then start the next phase. ### Rule zero Three things are settled before any code, and two of them are settled by the mail provider rather than by you. This connects one mailbox and it is yours. Detecting a reply means reading a thread, and Google's API Services User Data Policy puts apps using sensitive and restricted scopes into a process where they must pass an annual security assessment and obtain a letter of assessment from a Google-designated third party. That is a recurring cost and a review, not a checkbox. Build this for your own account and stop there; the moment you want it sending for somebody else you are in that process. The grant expires under you. Google's OAuth documentation states that a Cloud project with an external consent screen whose publishing status is Testing is issued a refresh token expiring in seven days, unless the only scopes requested are a subset of name, email address and user profile. So a tool built this way stops being able to send roughly every week. That is survivable and it is not survivable silently: detect the failed refresh, halt the queue, and put it at the top of the screen. A campaign that quietly stopped on Wednesday is worse than one that never started. The legal position is not the same everywhere and it is not optional. Before the first send, read what applies where your recipients are: several jurisdictions require prior consent for marketing mail to individuals and treat a bought list as no consent at all, and the rules for business addresses often differ from the rules for personal ones. Record, per recipient, where the address came from and why you believe you may write to them. If you cannot write that sentence, do not queue the address. And nothing leaves your mailbox that you have not read, which is why the approval gate is a phase rather than a setting. ### Stack (fixed, do not substitute) - Runtime: Node 22 with TypeScript, a command line only, SQLite for state · a tool with your mailbox credentials does not need a web interface as well - Mail: The provider's API with the narrowest scopes that send and read threads, one account · the scope list is the security review you are trying not to need - Scheduling: A scheduler on a machine that stays awake, with every tick idempotent · a laptop that sleeps mid-campaign is the normal case - Stop conditions: Thread state, not string matching on subjects · a reply from a colleague's address on the same thread must still stop the sequence - Provenance: A source and a contact basis required per lead, enforced by the schema · if you cannot say why you may write to someone, that is the answer ### Data model (create this before Phase 1) - account(email, token_ref, granted_at, scopes, publishing_status, last_refresh_ok_at, state) · state goes to halted the moment a refresh fails - leads(id, email_hash, email, first_name, company, context, source, contact_basis, added_at, state) · source and contact_basis are not nullable and there is no import path that fills them in for you - drafts(id, lead_id, step, subject, body, model_id, generated_at, approved_at, approved_body) · approved_body is what sends, so an edit at review is what goes out - sends(id, draft_id, thread_id, provider_message_id, sent_at, window_id) - events(id, lead_id, thread_id, kind, detected_at, raw_ref) · kind is reply, auto_reply, bounce or opt_out, and they are recorded separately because they mean different things - suppression(email_hash, reason, added_at) · permanent, checked before drafting as well as before sending, and never emptied by a bulk operation - A sequence stops because the lead's state changed, not because a check ran at send time. The check at send time is the second line, not the first. ### Phase 1 · One mailbox, and a grant that tells you when it dies Build: An auth command runs the consent flow once and stores the refresh token outside the repository. Request only the scopes needed to send and to read thread state. Refresh before every run; on failure, set the account state to halted, stop the queue and print the reason at the top of every subsequent command until it is fixed. Trap: Treating a failed token refresh as a transient error and retrying on the next tick means the scheduler keeps running, keeps marking rows as attempted and keeps reporting a healthy campaign while nothing has left the mailbox for days, which is precisely the failure that makes you look unreliable to the people who did reply. Halt on the first refresh failure, refuse to run anything that sends, and make the message unmissable. Done when: The scopes requested are the minimum that send and read thread state, listed in the README and asserted against the stored grant. A failed refresh sets the account to halted and every send-related command refuses with the reason, tested by invalidating the token. Re-running the auth command clears the halt and records a new grant time. The refresh token is never written to the repository or to a log, verified by a test that scans both. Do not build yet: No leads, no drafting, no sending and no thread watching yet. ### Phase 2 · No lead without a reason Build: A CSV import that refuses any row missing a source and a contact basis, both free text and both stored. Check every address against the suppression list at import as well as at send. Normalise addresses before hashing so two spellings of the same person do not become two leads. Trap: Making provenance an optional column means it is empty for every row within a week, and the first time somebody asks where you got their address the honest answer is that you do not know, which is both an unpleasant conversation and, in several places, the wrong answer to give a regulator. Make both fields required at the schema level so there is no import path that leaves them blank. Done when: A CSV row missing a source or a contact basis is rejected with the line number, tested. An address already in suppression is refused at import rather than silently dropped later, tested. Address normalisation is case-insensitive and trims whitespace and the suppression check uses the normalised form, tested with variants. There is no command anywhere that adds a lead without both provenance fields, asserted by review. Do not build yet: No drafting, no review, no sending and no watching yet. ### Phase 3 · A draft in your voice, and a gate you cannot skip Build: A voice command pulls a sample of your own sent messages, strips quoted history and signatures, and saves a short style reference to a file you can edit. Drafting uses that file plus the lead's context column. A review command shows drafts one at a time with approve, edit and skip, and stores the approved body as the thing that will send. Trap: Storing only an approval flag and regenerating the body at send time means the message that goes out is not the message you read, because the model was called again and produced something slightly different, and you find out from the reply. Store the exact approved text and send that, and treat any change to the draft after approval as requiring a fresh approval. Done when: The approved body is stored verbatim and is byte-identical to what is sent, asserted in the suite. No send path exists for a draft without an approval timestamp, verified by review and a test. Editing a draft after approval clears the approval, tested. The voice sample file is plain text, editable, and its contents are visible in the review output so you can see what the draft was built from. Do not build yet: No sending, no follow-ups and no thread watching yet. ### Phase 4 · Sending at a human pace Build: A send command that runs only inside a configured window, stops at a daily cap, and leaves a random gap between messages. Claim each draft with a conditional update before calling the provider and record the returned message and thread identifiers immediately. A message sent outside a window or over the cap is not sent at all rather than queued for later. Trap: Marking a draft as sent after the provider call returns means a process killed mid-send leaves a message delivered and a row that still says pending, and the next run sends the same message again to the same person, which is the single most embarrassing bug this category has. Claim the row first, write the provider identifiers the moment you have them, and treat a claimed row with no identifiers as something to check by hand rather than to retry. Done when: Killing the process during a send never results in a second delivery on the next run, tested by interrupting between claim and record. Sends outside the window or over the daily cap do not happen, tested at both boundaries. Every send stores the provider's thread identifier, which is what every stop condition later depends on. The gap between consecutive sends is randomised within a configured range, verified across a run. Do not build yet: No follow-ups, no reply detection and no export yet. ### Phase 5 · Stopping, which is the whole product Build: Poll thread state on a schedule. Any inbound message on a thread you started sets the lead to replied and cancels its remaining steps, regardless of which address it came from. Classify automatic replies as their own event kind that stops the sequence without counting as interest. Parse delivery failure notices to suppression. Honour an opt-out request permanently and at once. Trap: Detecting replies by matching the sender address against the lead means a message from a colleague, an assistant or a forwarded internal address on the same thread does not count, and your follow-up lands underneath a human answer as if nobody had spoken, which is exactly the carelessness this tool is meant to avoid. Key the stop condition on the thread, not on the address, and treat any inbound message on a thread you started as a reply. Done when: An inbound message from an address other than the lead's, on a thread you started, stops the sequence, tested with a fixture. An automatic reply stops the sequence and is recorded as its own event kind, never as interest, tested. A delivery failure notice adds the address to suppression permanently and suppression survives a database rebuild, tested. An opt-out request is honoured before any further send and cannot be undone by a re-import, tested. Do not build yet: Nothing after this. ### Out of scope (and why) - Lead sourcing by scraping, address guessing or pattern-based email generation, all of which produce addresses you cannot write a provenance line for and bounces that damage your own mailbox. - Open and click tracking pixels, which in several jurisdictions need consent in their own right and which tell you less about interest than a reply does. - Sending for anybody but yourself, because a second mailbox is what puts you into the annual security assessment the policy describes. - Inbox warmup schemes and reply exchanges between accounts, which are attempts to game a provider's own filtering and are exactly the behaviour that gets a domain penalised. ### README must contain - That this connects one mailbox, yours, and why sending for anybody else means an annual third-party security assessment under the provider's user data policy - That a project left in testing status is issued a refresh token expiring in seven days, so the grant needs renewing weekly, with the source and the date you checked it - That every lead requires a recorded source and a recorded reason you may contact them, and that there is no import path around it - That you must read the marketing mail rules that apply where your recipients are before the first send, and that a bought list is treated as no consent in several jurisdictions - That stop conditions key on the thread rather than the sender, and that an automatic reply stops a sequence without being treated as interest
# Architecture · OutFriend ## Stack | Part | Choice | Why | | --- | --- | --- | | Runtime | Node 22 with TypeScript, a command line only, SQLite for state | a tool with your mailbox credentials does not need a web interface as well | | Mail | The provider's API with the narrowest scopes that send and read threads, one account | the scope list is the security review you are trying not to need | | Scheduling | A scheduler on a machine that stays awake, with every tick idempotent | a laptop that sleeps mid-campaign is the normal case | | Stop conditions | Thread state, not string matching on subjects | a reply from a colleague's address on the same thread must still stop the sequence | | Provenance | A source and a contact basis required per lead, enforced by the schema | if you cannot say why you may write to someone, that is the answer | ## Modules Each module has one owner concern and a documented way to replace it. | Module | Owns | How to replace it | | --- | --- | --- | | Auth | Scopes, the grant and the halt state. | Any provider, provided a refresh failure halts sending rather than retrying quietly. | | Leads | Provenance, normalisation and suppression checks. | Any import, provided source and contact basis stay required. | | Drafting | The voice reference and the approved body. | Any model, provided the approved text is what sends. | | Sending | Claims, windows, caps and thread identifiers. | Any transport, provided the claim precedes the call. | | Stopping | Thread events, automatic replies, bounces and opt-outs. | Not swappable for sender-address matching, which is the bug this exists to prevent. | ## Configuration Every runtime setting is an environment variable documented in `.env.example`, validated at startup, with a safe local default wherever one exists. - `MAIL_CLIENT_ID` · required · The OAuth client for your cloud project. - `MAIL_CLIENT_SECRET` · required, secret · Pairs with the client id. Never in the repository. - `MAIL_TOKEN_PATH` · required, secret · The refresh token, outside the repository. Expect to renew it weekly while the project is in testing. - `MODEL_API_KEY` · required, secret · Drafting only. It never sees the mailbox. - `DATABASE_PATH` · required · Leads, drafts, sends, events and suppression. Back this up; suppression in particular. - `SEND_WINDOW` · required · Outside this, nothing sends. Not deferred, refused. - `DAILY_CAP` · required · A hard stop per day. Lower than you think is usually right. - `SEND_GAP_SECONDS` · required · A random gap in this range between consecutive sends. ## Production baseline - Security: least privilege, input validation at every boundary, secret redaction in logs, rate limits on abuse-prone paths, no invented security primitives. - Data: explicit schema and migrations, transactional writes where integrity matters, backup and restore procedures that have been exercised. - Integrations: adapters around third-party providers, idempotent webhook or job processing, bounded retries, timeouts. - Observability: structured logs with request or operation ids, an error-tracking hook, and health and readiness checks where a server exists. - Quality: unit tests for domain rules, integration tests at module boundaries, one end-to-end test of the critical path. ## Decision records For each dependency in the stack table, keep a short note: why it was chosen, its failure mode, and how it is replaced. Do not add infrastructure until a requirement in `PRODUCT.md` justifies it.
# Agent instructions · OutFriend product build - Read `PRODUCT.md` and `ARCHITECTURE.md` before changing code. The stack is fixed: Node 22 with TypeScript, a command line only, SQLite for state, The provider's API with the narrowest scopes that send and read threads, one account, A scheduler on a machine that stays awake, with every tick idempotent, Thread state, not string matching on subjects, A source and a contact basis required per lead, enforced by the schema. - Implement milestone by milestone from `MILESTONES.md`; keep each change reviewable and leave the application runnable at every commit. - Treat authentication, payments, encryption, imports, webhooks and destructive actions as high-risk boundaries when present. - Never invent cryptography or silently weaken a requirement to make a check pass. - Put every external service behind an interface with a deterministic fake for tests. - Add migrations and rollback or recovery notes for every persistent data change. - Log useful operational context without credentials, tokens, passwords or personal data. - Update documentation and run every check before completing a milestone. ## Known traps - Treating a failed token refresh as a transient error and retrying on the next tick means the scheduler keeps running, keeps marking rows as attempted and keeps reporting a healthy campaign while nothing has left the mailbox for days, which is precisely the failure that makes you look unreliable to the people who did reply. Halt on the first refresh failure, refuse to run anything that sends, and make the message unmissable. - Making provenance an optional column means it is empty for every row within a week, and the first time somebody asks where you got their address the honest answer is that you do not know, which is both an unpleasant conversation and, in several places, the wrong answer to give a regulator. Make both fields required at the schema level so there is no import path that leaves them blank. - Storing only an approval flag and regenerating the body at send time means the message that goes out is not the message you read, because the model was called again and produced something slightly different, and you find out from the reply. Store the exact approved text and send that, and treat any change to the draft after approval as requiring a fresh approval. - Marking a draft as sent after the provider call returns means a process killed mid-send leaves a message delivered and a row that still says pending, and the next run sends the same message again to the same person, which is the single most embarrassing bug this category has. Claim the row first, write the provider identifiers the moment you have them, and treat a claimed row with no identifiers as something to check by hand rather than to retry. - Detecting replies by matching the sender address against the lead means a message from a colleague, an assistant or a forwarded internal address on the same thread does not count, and your follow-up lands underneath a human answer as if nobody had spoken, which is exactly the carelessness this tool is meant to avoid. Key the stop condition on the thread, not on the address, and treat any inbound message on a thread you started as a reply.
# Delivery milestones · OutFriend Estimated effort: **multi-day** for the indie phases; the production-only milestones add the trust and operability layer. ## M1 · One mailbox, and a grant that tells you when it dies Authorisation happens once, the narrowest scopes are used, and a failed refresh halts everything loudly. ### Steps 1. Request the narrowest scopes that do the job Every extra scope is more of your mailbox in a tool you wrote at the weekend. Files: `src/auth/scopes.ts`, `README.md` 2. Halt on the first refresh failure A weekly expiry is fine; a silent one is not. Files: `src/auth/refresh.ts`, `tests/halt.test.ts` 3. Keep the token out of the repository and out of logs A token in a log file is a token in a backup. Files: `src/auth/store.ts`, `tests/no-token-leak.test.ts` ### Done when - [ ] Only the minimum scopes are requested - [ ] A failed refresh halts every sending command - [ ] Re-authorising clears the halt - [ ] The token never reaches the repository or a log ### Watch out - Treating a failed token refresh as a transient error and retrying on the next tick means the scheduler keeps running, keeps marking rows as attempted and keeps reporting a healthy campaign while nothing has left the mailbox for days, which is precisely the failure that makes you look unreliable to the people who did reply. Halt on the first refresh failure, refuse to run anything that sends, and make the message unmissable. ## M2 · No lead without a reason Importing a contact requires saying where the address came from and why you may write to them. ### Steps 1. Make both provenance fields non-nullable Optional means empty within a week. Files: `src/db/migrations/002_leads.sql` 2. Check suppression at import, not only at send The earliest refusal is the kindest one. Files: `src/leads/import.ts`, `tests/suppression.test.ts` 3. Normalise before hashing One person should not become three leads. Files: `src/leads/normalise.ts` ### Done when - [ ] Rows without provenance are rejected with a line number - [ ] Suppressed addresses are refused at import - [ ] Normalisation is case and whitespace safe - [ ] No path creates a lead without provenance ### Watch out - Making provenance an optional column means it is empty for every row within a week, and the first time somebody asks where you got their address the honest answer is that you do not know, which is both an unpleasant conversation and, in several places, the wrong answer to give a regulator. Make both fields required at the schema level so there is no import path that leaves them blank. ## M3 · A draft in your voice, and a gate you cannot skip Each lead gets one draft built from your own sent mail, and nothing sends without you reading it. ### Steps 1. Build the voice reference into a file you can edit A style reference you cannot read is a style you cannot fix. Files: `src/voice/build.ts`, `voice.md` 2. Store the approved text, not an approval flag What you read must be what leaves. Files: `src/drafts/approve.ts`, `tests/approved-body.test.ts` 3. Clear approval on any edit An approval is of a specific message. Files: `src/drafts/edit.ts` ### Done when - [ ] The sent body is byte-identical to the approved body - [ ] No draft sends without an approval timestamp - [ ] Editing after approval clears it - [ ] The voice reference is readable and editable ### Watch out - Storing only an approval flag and regenerating the body at send time means the message that goes out is not the message you read, because the model was called again and produced something slightly different, and you find out from the reply. Store the exact approved text and send that, and treat any change to the draft after approval as requiring a fresh approval. ## M4 · Sending at a human pace Approved drafts leave within a window, under a daily cap, spaced apart, and never twice. ### Steps 1. Claim the row before calling the provider The gap between send and record is where duplicates are born. Files: `src/send/claim.ts`, `tests/no-double-send.test.ts` 2. Store the thread identifier immediately Every stop condition in phase five hangs off it. Files: `src/send/record.ts` 3. Refuse rather than defer outside the window A queue that catches up at three in the morning is its own problem. Files: `src/send/window.ts` ### Done when - [ ] An interrupted send never delivers twice - [ ] The window and daily cap are both enforced - [ ] Thread identifiers are stored on every send - [ ] Gaps between sends are randomised ### Watch out - Marking a draft as sent after the provider call returns means a process killed mid-send leaves a message delivered and a row that still says pending, and the next run sends the same message again to the same person, which is the single most embarrassing bug this category has. Claim the row first, write the provider identifiers the moment you have them, and treat a claimed row with no identifiers as something to check by hand rather than to retry. ## M5 · Stopping, which is the whole product (production only) Any reply, bounce or opt-out ends the sequence immediately, and an automatic reply is never read as interest. ### Steps 1. Key stop conditions on the thread, not the address Replies come from assistants, colleagues and forwards. Files: `src/watch/threads.ts`, `tests/stop-conditions.test.ts` 2. Classify automatic replies separately and stop anyway An out of office is not a warm lead. Files: `src/watch/autoreply.ts` 3. Make suppression permanent and re-import proof Someone asked you to stop. That is not a per-campaign setting. Files: `src/suppression/permanent.ts`, `src/cli/export.ts` ### Done when - [ ] Any inbound message on the thread stops the sequence - [ ] Automatic replies stop without counting as interest - [ ] Bounces add to permanent suppression - [ ] Opt-outs survive a re-import ### Watch out - Detecting replies by matching the sender address against the lead means a message from a colleague, an assistant or a forwarded internal address on the same thread does not count, and your follow-up lands underneath a human answer as if nobody had spoken, which is exactly the carelessness this tool is meant to avoid. Key the stop condition on the thread, not on the address, and treat any inbound message on a thread you started as a reply.
# Operations · OutFriend ## Backup The database, daily, and understand that the suppression table is the part you cannot afford to lose. Everything else is reconstructible from a CSV and a few model calls; a lost suppression list means writing again to people who asked you not to. ## Restore Restore, then check three things before enabling sending: the account state, the suppression row count against your last known figure, and that no draft is approved with a body that differs from what is in sends. Re-authorise rather than trusting a restored token. Do a restore drill before the first real user, and write the date here when it passes. ## Monitoring Days since the last successful token refresh, the count of leads in replied state against sent, and any send row claimed without a provider identifier. The third is the one that means a message may have gone out twice. ## Incident checklist The incident in this category is a follow-up that landed under somebody's reply, or a message to a person on the suppression list. Both are recoverable only as apologies, which is why the events table records the kind and time of every inbound message and why suppression is checked at import as well as at send. 1. Contain the issue without destroying evidence or user data. 2. Record the timeline and affected scope. 3. Rotate exposed secrets and revoke compromised sessions or credentials. 4. Restore from a verified backup when needed. 5. Document the root cause, the remediation and the regression test. ## Release gate - [ ] A failed token refresh is confirmed to halt every sending command - [ ] A lead without provenance is confirmed impossible to create - [ ] The sent body is confirmed byte-identical to the approved body - [ ] An interrupted send is confirmed never to deliver twice - [ ] Any inbound message on the thread is confirmed to stop the sequence ## Launch constraint Do not market omitted OutFriend capabilities as implemented. The non-goals in `PRODUCT.md` remain user-visible limitations until they are deliberately delivered.
# Copy to .env and fill in. Never commit .env; this file documents it. # Required. The OAuth client for your cloud project. MAIL_CLIENT_ID=...apps.googleusercontent.com # Required · secret. Pairs with the client id. Never in the repository. MAIL_CLIENT_SECRET=...redacted # Required · secret. The refresh token, outside the repository. Expect to renew it weekly while the project is in testing. MAIL_TOKEN_PATH=~/.config/outreach/token.json # Required · secret. Drafting only. It never sees the mailbox. MODEL_API_KEY=sk-...redacted # Required. Leads, drafts, sends, events and suppression. Back this up; suppression in particular. DATABASE_PATH=./data/outreach.db # Required. Outside this, nothing sends. Not deferred, refused. SEND_WINDOW=09:30-16:30 Europe/London # Required. A hard stop per day. Lower than you think is usually right. DAILY_CAP=25 # Required. A random gap in this range between consecutive sends. SEND_GAP_SECONDS=45-210
$ choose a build depth, inspect the files, then open the complete pack in your agent
Because the failure modes are expensive and quiet. A homemade sender that misses a reply and fires the follow-up anyway does not throw an error, it just makes you look careless to the exact person who answered. Same for a bounced address you never suppressed, or an out-of-office you read as engagement. OutFriend is mostly selling the correctness of the stop conditions and a place to read every draft before it leaves. Sending from your own Gmail is the honest part of the pitch, and also the reason a personal build gets closer here than in the rest of this category.
xreply detection that is thread-based rather than naive, so out-of-office replies do not count as interest and a reply from a different address still stops the sequence
xverification and suppression, meaning unverified, bounced and opted-out addresses filtered before anything queues
xa review surface where every draft is readable and editable before it goes, instead of trusting a script with your inbox
xlead generation, which the paid tiers include and your CSV does not
xthe Google OAuth verification process, which you will meet the moment you want this on more than your own account
Don't feel like building it? These folks already made it free.
no votes, no pay-to-list · just what's real
OutFriend pricing
pro$29/mo · monthly per user · $348/yr
free tierA permanent free plan with no card and no trial clock: 1 campaign, CSV upload, emails generated in your voice, intro emails only.
verified 2026-08-10 · source ↗
Is OutFriend free?
A permanent free plan with no card and no trial clock: 1 campaign, CSV upload, emails generated in your voice, intro emails only. Paid is Pro at $29/mo (checked 2026-08-10).
Vibecode OutFriend
Kinda. The core of OutFriend is buildable in a weekend with the prompt on this page, but there are real gaps: reply detection that is thread-based rather than naive, so out-of-office replies do not count as interest and a reply from a different address still stops the sequence, verification and suppression, meaning unverified, bounced and opted-out addresses filtered before anything queues. Read the honest list above before committing.
How much does OutFriend cost?
OutFriend costs about $29/month (Pro, checked 2026-08-10), which is $348 per year.
What do I lose by replacing OutFriend?
Honestly: reply detection that is thread-based rather than naive, so out-of-office replies do not count as interest and a reply from a different address still stops the sequence; verification and suppression, meaning unverified, bounced and opted-out addresses filtered before anything queues; a review surface where every draft is readable and editable before it goes, instead of trusting a script with your inbox; lead generation, which the paid tiers include and your CSV does not; the Google OAuth verification process, which you will meet the moment you want this on more than your own account. If any of those are load-bearing for you, keep paying.
Is there an open-source alternative to OutFriend?
Yes: Mautic (Runs the campaign, the throttling and the stop-on-reply. Writing in your voice is not in the box, and neither is a light afternoon of setup.) The prompt is for when you want it exactly your way.