Skip to content
All work

01Case study

Outreach Pro

An internal outreach workflow tool for Studio Sajtova

Status
Internal tool
Role
Product & engineering
Stack
TanStack Start, React, TypeScript, Supabase, Resend, Google Places API

Follow one lead through the system

The panes on the right are the system's records and rules. One of them is followed from search result to sent email, and on to what happens when outreach has to stop.

Skip the walkthrough
  1. 0107

    Discover and review

    A search runs against Google Places and returns businesses as lead results: name, address, website, phone and rating. Each one is reviewed, then kept or skipped.

    • lead: new → reviewed | skipped
    One row per lead. Reviewed rows stay; skipped rows step back.
  2. 0207

    Lead becomes contact

    Conversion keeps the lead's Google Places ID, and that ID is unique per user: the same business can never become two contacts, not even when a warning is overridden.

    Email, website, phone and name + city are checked as softer duplicate signals. A lead needs an email address to convert, and Places results don't include one.

    • lead → added_to_contacts
    • contact: new
    The lead with an email becomes a contact; a second lead with the same place ID is refused.
  3. 0307

    Draft, then review

    An AI-generated draft is written from what's known about the business: name, type, city, website (or the lack of one) and notes. It's shown for review and can be edited before it's saved or sent; nothing goes out straight from the model.

    • draft: generated → reviewed → saved
    Generated draft (lighter) → reviewed (dark). Editing is possible, not required.
  4. 0407

    One policy for every send

    Manual and background sends run the same checks in the same order: the recipient (exists, valid address, not suppressed or in a stop status), the sender (set up, on the outreach domain), then the send window: weekdays 09:00–17:00, Belgrade time.

    A failed recipient check cancels the email. A sender problem changes nothing and stops processing until it's fixed. Outside the window, email waits; only a manual send may override it, background processing never does.

    • recipient fails → cancelled
    • sender fails → unchanged, processing stops
    • outside window → stays scheduled
    Checked in order: the email that fails a recipient check leaves the flow.
  5. 0507

    Claim a slot, send, schedule follow-ups

    The daily limit is per user, counted by Belgrade calendar day. A slot is claimed in one locked database step, so two sends at the same moment can't both take the last one; past the limit, email waits for the next day.

    Sending goes through Resend. A failed send is marked failed for a person to look at: never retried automatically, and it uses no slot. After a campaign's first email is sent, its follow-ups are scheduled, each after its own delay.

    • scheduled → sending → sent
    • limit reached → stays scheduled
    • send error → failed
    One send claims the last free slot; a second at the same moment waits for tomorrow. Follow-ups land on later days.
  6. 0607

    Signed feedback

    Delivery events come back from Resend through a signed webhook. The signature is checked against the raw request, events more than five minutes off are rejected, and each event ID is recorded once, so a replay does nothing.

    • valid signature → accepted
    • invalid or stale → 401
    • known event ID → ignored

    Tested live on the migrated preview: a real bounce event was delivered, its signature verified, and the endpoint answered 200 OK.

    Valid → recorded once. Replay → ignored. Unsigned or stale → rejected.
  7. 0707

    Stopping means stopping

    Replies are marked by hand; marking one cancels the follow-ups that have a send time and leaves unscheduled drafts alone.

    A hard stop cancels everything pending for the contact: an unsubscribe, a bounce or complaint reported by Resend, a suppressed address, or a “not interested” or “do not contact” status. It happens in the database, on the status change itself; the send policy checks again before any email goes out.

    Every email carries one-click unsubscribe headers that mail apps can act on; the link opens a confirmation page, and only the confirming request changes anything.

    • replied → timed follow-ups cancelled, drafts kept
    • unsubscribed | bounced | suppressed → all pending cancelled

    The cancellation paths are covered by the database integration and SQL tests.

    Left: a reply cancels the timed follow-ups; the draft stays. Right: a hard stop cancels everything pending.

Problem

Outreach for Studio Sajtova means finding local businesses, turning the promising ones into contacts, writing each email and following up, without ever writing to someone who asked not to be contacted.

Outreach Pro keeps that work in one place, with rules that decide what may be sent and when.

Product & design thinking

Drafting is assisted, never automatic: every AI draft is shown for review and can be edited before it's saved.

Stopping is a first-class state. A reply, an unsubscribe or a bounce changes the contact, and that change is what cancels pending email.

When a search falls back to generated demo data, the results are marked as demo, so they can't be mistaken for real businesses.

Architecture

  1. 01

    App

    TanStack Start, React

    • search and review
    • contacts
    • drafts and schedule
  2. 02

    Server functions

    signed-in user only

    • shared send policy
    • lead conversion
    • AI drafts
  3. 03

    Postgres

    Supabase, RLS on every table

    • slot claim in SQL
    • cancellation triggers
    • suppression
  4. 04

    Resend

    sending and delivery events

    • one-click unsubscribe headers
  5. 05

    Public endpoints

    webhook · unsubscribe · processing

    • signed, replay-safe webhook
    • secret-guarded processing

Every table carries its owner's user ID, and row-level security limits every read and write to that user.

External services are called from the server only: Google Places for search, an AI model for drafts, Resend for sending.

Key decisions & trade-offs

One send policy, shared by manual and background sending, instead of two sets of rules that can drift apart.

Cancellation lives in the database as triggers on the status change, so every path that stops a contact (the app, the webhook, the unsubscribe page) cancels pending email the same way.

The daily limit is claimed in SQL under a row lock rather than counted in application code, so concurrent sends can't exceed it.

Unsubscribe links never change anything on a plain visit, so link scanners that open them can't unsubscribe anyone by accident.

Hardest problem

Making “stop” mean stop everywhere. A stop can come from a person, from the webhook or from the unsubscribe page while email is already scheduled, so cancellation had to happen where all of those paths meet: in the database.

Reliability engineering

A dedicated reliability pass aligned manual and background sending on the shared policy, moved follow-up cancellation into the database and made the daily limit race-safe.

Background processing isolates each email: a failure marks that one email failed and the batch continues. An email stuck in sending is failed for review, never re-sent.

Google Places IDs are kept when a lead is converted, and are unique per user.

Schema changes are additive migrations. The upgrade path was tested from the old schema with data, checking that the data survives.

Testing & migration

71 unit tests and 43 database integration tests pass, plus 3 SQL test files, run against a local database rebuilt from the migrations.

Coverage centres on the send policy, webhook verification, unsubscribe behaviour, the daily-limit claim, the cancellation triggers and the upgrade path.

The backend has moved to a new Supabase project. Both accounts kept their original IDs and password hashes, all 18 migrations applied, the data was copied one-to-one with no orphaned rows, and policies, triggers, indexes and functions were compared.

A preview deployment runs against the new backend. Both accounts sign in and see their own data, the sending domain is verified and a real test email was delivered.

Current state / Next iteration

Current state

In use as Studio Sajtova's internal outreach tool.

In progressThe move to the new infrastructure is verified on preview. The production cutover is still ahead: production settings, scheduled processing, the app.studiosajtova.net domain, the final webhook address and a final smoke test.