Build OutFriend

KINDAreplaces $29/mosaves $348/yrback to the verdict

0%0 of 17 items done

Saved on this device only. Tick prerequisites first, then work the phases in order · do not start one until the checks above it pass.

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-daythe files for this build are in the project pack

RuntimeNode 22 with TypeScript, a command line only, SQLite for stateMailThe provider's API with the narrowest scopes that send and read threads, one accountSchedulingA scheduler on a machine that stays awake, with every tick idempotentStop conditionsThread state, not string matching on subjectsProvenanceA source and a contact basis required per lead, enforced by the schema

Before step 1

Everything below is assumed from the first step. Tick each one when you actually have it, not when you plan to.

  1. 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

  2. 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. open ↗

    Verify The consent flow completes and the stored grant lists only the scopes you asked for

  3. 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

  4. 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

  5. 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.

VariableNeededExampleWhere the value comes from
MAIL_CLIENT_IDrequired...apps.googleusercontent.comThe OAuth client for your cloud project.
MAIL_CLIENT_SECRETsecretrequired...redactedPairs with the client id. Never in the repository.
MAIL_TOKEN_PATHsecretrequired~/.config/outreach/token.jsonThe refresh token, outside the repository. Expect to renew it weekly while the project is in testing.
MODEL_API_KEYsecretrequiredsk-...redactedDrafting only. It never sees the mailbox.
DATABASE_PATHrequired./data/outreach.dbLeads, drafts, sends, events and suppression. Back this up; suppression in particular.
SEND_WINDOWrequired09:30-16:30 Europe/LondonOutside this, nothing sends. Not deferred, refused.
DAILY_CAPrequired25A hard stop per day. Lower than you think is usually right.
SEND_GAP_SECONDSrequired45-210A random gap in this range between consecutive sends.

The build, in order

  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.

    1. Every extra scope is more of your mailbox in a tool you wrote at the weekend.

      Files src/auth/scopes.tsREADME.md

    2. A weekly expiry is fine; a silent one is not.

      Files src/auth/refresh.tstests/halt.test.ts

    3. A 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 passes
    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.
  2. No lead without a reason

    Importing a contact requires saying where the address came from and why you may write to them.

    1. Optional means empty within a week.

      Files src/db/migrations/002_leads.sql

    2. The earliest refusal is the kindest one.

      Files src/leads/import.tstests/suppression.test.ts

    3. One person should not become three leads.

      Files src/leads/normalise.ts

    done when · tick each as it passes
    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.
  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.

    1. A style reference you cannot read is a style you cannot fix.

      Files src/voice/build.tsvoice.md

    2. What you read must be what leaves.

      Files src/drafts/approve.tstests/approved-body.test.ts

    3. An approval is of a specific message.

      Files src/drafts/edit.ts

    done when · tick each as it passes
    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.
  4. Sending at a human pace

    Approved drafts leave within a window, under a daily cap, spaced apart, and never twice.

    1. The gap between send and record is where duplicates are born.

      Files src/send/claim.tstests/no-double-send.test.ts

    2. Every stop condition in phase five hangs off it.

      Files src/send/record.ts

    3. A queue that catches up at three in the morning is its own problem.

      Files src/send/window.ts

    done when · tick each as it passes
    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.
what this build does not replace
after v1, if you want it

Need the files? The project pack on the verdict page hands your agent the whole brief · more sales outreach.