Build OutFriend
KINDAreplaces $29/mosaves $348/yrback to the verdict
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.
Before step 1
Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.
- installfree
Why Runs the command line tool and the scheduler.
Get it nodejs.org, or your version manager. open ↗
Verify
node --version prints v22 or higher - accountfree
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. open ↗
Verify
The consent flow completes and the stored grant lists only the scopes you asked for - API keypennies 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 - have readyfree 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 - decidefree
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
Data model
Create these before the first phase that stores anything. Changing a table later is the expensive kind of change.
- 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.
Environment variables
These go in a .env file the app reads at startup. The pack's .env.example is this table as a file · copy it, never commit the filled-in version.
| Variable | Needed | Example | Where the value comes from |
|---|---|---|---|
MAIL_CLIENT_ID | required | ...apps.googleusercontent.com | The OAuth client for your cloud project. |
MAIL_CLIENT_SECRETsecret | required | ...redacted | Pairs with the client id. Never in the repository. |
MAIL_TOKEN_PATHsecret | required | ~/.config/outreach/token.json | The refresh token, outside the repository. Expect to renew it weekly while the project is in testing. |
MODEL_API_KEYsecret | required | sk-...redacted | Drafting only. It never sees the mailbox. |
DATABASE_PATH | required | ./data/outreach.db | Leads, drafts, sends, events and suppression. Back this up; suppression in particular. |
SEND_WINDOW | required | 09:30-16:30 Europe/London | Outside this, nothing sends. Not deferred, refused. |
DAILY_CAP | required | 25 | A hard stop per day. Lower than you think is usually right. |
SEND_GAP_SECONDS | required | 45-210 | A random gap in this range between consecutive sends. |
The build, in order
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.
Every extra scope is more of your mailbox in a tool you wrote at the weekend.
Files
src/auth/scopes.tsREADME.mdA weekly expiry is fine; a silent one is not.
Files
src/auth/refresh.tstests/halt.test.tsA token in a log file is a token in a backup.
Files
src/auth/store.tstests/no-token-leak.test.ts
done when · tick each as it passeswatch 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.
No lead without a reason
Importing a contact requires saying where the address came from and why you may write to them.
Optional means empty within a week.
Files
src/db/migrations/002_leads.sqlThe earliest refusal is the kindest one.
Files
src/leads/import.tstests/suppression.test.tsOne person should not become three leads.
Files
src/leads/normalise.ts
done when · tick each as it passeswatch 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.
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.
A style reference you cannot read is a style you cannot fix.
Files
src/voice/build.tsvoice.mdWhat you read must be what leaves.
Files
src/drafts/approve.tstests/approved-body.test.tsAn approval is of a specific message.
Files
src/drafts/edit.ts
done when · tick each as it passeswatch 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.
Sending at a human pace
Approved drafts leave within a window, under a daily cap, spaced apart, and never twice.
The gap between send and record is where duplicates are born.
Files
src/send/claim.tstests/no-double-send.test.tsEvery stop condition in phase five hangs off it.
Files
src/send/record.tsA queue that catches up at three in the morning is its own problem.
Files
src/send/window.ts
done when · tick each as it passeswatch 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.
Stopping, which is the whole productproduct builder
Any reply, bounce or opt-out ends the sequence immediately, and an automatic reply is never read as interest.
Replies come from assistants, colleagues and forwards.
Files
src/watch/threads.tstests/stop-conditions.test.tsAn out of office is not a warm lead.
Files
src/watch/autoreply.tsSomeone asked you to stop. That is not a per-campaign setting.
Files
src/suppression/permanent.tssrc/cli/export.ts
done when · tick each as it passeswatch 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.
That is the whole plan for OutFriend. What it deliberately does not cover is below · check the gaps before you call it a replacement.
- 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
- 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
Need the files? The project pack on the verdict page hands your agent the whole brief · more sales outreach.