Skip to content

ByteStreams-AI/dialtone_outreach

Repository files navigation

DialTone Outreach

Cold email sequencer for restaurant owner outreach. Built on AWS SES + Supabase.

Imports owner contacts from Apollo and runs a 5-email sequence with automatic timing logic — pausing the moment anyone replies. Includes a local web UI for non-technical reviewers to monitor outreach and update contact statuses.


Stack

Layer Tool
Email sending AWS SES
Database Supabase (PostgreSQL)
CLI Click + Rich
Web UI (local) FastAPI + Jinja2 + HTMX
Contact source Apollo (restaurant owner contacts)

Project Structure

dialtone-outreach/
├── cli.py                      # Entry point — all commands live here
├── schema.sql                  # Run once in Supabase SQL editor
├── pyproject.toml              # Project metadata (uv-managed)
├── requirements.txt            # Pip-compatible dependency pin
├── .env.example                # Copy to .env and fill in credentials
├── docs/
│   ├── project-status.md       # Milestone tracker (source of truth)
│   └── apollo-contacts-export.csv
├── outreach/
│   ├── config.py               # All settings loaded from .env
│   ├── db.py                   # Supabase client and query functions
│   ├── email_client.py         # AWS SES wrapper
│   ├── sequence.py             # Timing logic — who gets what email today
│   ├── templates.py            # All 5 email templates (Jinja2) + CAN-SPAM helpers
│   └── runner.py               # Orchestration loop + terminal dashboard
├── scripts/
│   ├── import_contacts.py      # Apollo CSV importer
│   └── preview_templates.py    # Render all 5 templates against real Apollo rows
├── developer/
│   ├── developer-journal.md    # Running engineering log
│   ├── cohorts/                # Locked cohorts (gitignored, recipient PII)
│   └── template-previews/      # Generated previews (gitignored — regenerate with preview_templates.py)
└── web/                        # Local FastAPI UI for non-technical reviewers (planned, milestone 4)
    ├── app.py                  # FastAPI application + routes
    ├── templates/              # Jinja2 page templates (HTMX-driven)
    └── static/                 # CSS + minimal JS

Setup

1. Clone and install

git clone https://github.com/ByteStreams-AI/dialtone_outreach.git
cd dialtone_outreach

With uv (preferred — matches the checked-in uv.lock):

uv venv
source .venv/bin/activate       # Windows: .venv\Scripts\activate
uv pip install -r requirements.txt

Or with stock venv + pip:

python -m venv .venv
source .venv/bin/activate       # Windows: .venv\Scripts\activate
pip install -r requirements.txt

2. Configure environment

cp .env.example .env

Edit .env with your credentials:

SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_KEY=your-service-role-key
AWS_ACCESS_KEY_ID=your-access-key
AWS_SECRET_ACCESS_KEY=your-secret-key
AWS_REGION=us-east-1
FROM_EMAIL=steve@dialtone.menu
CALENDLY_URL=https://calendly.com/your-link
BUSINESS_ADDRESS="100 Powell Place #1473\nNashville, TN 37204\nUnited States"
COMPANY_LEGAL_NAME=ByteStreams LLC
UNSUBSCRIBE_EMAIL=unsubscribe@dialtone.menu

CAN-SPAM: BUSINESS_ADDRESS must be a real physical postal address. outreach/templates.py will refuse to render an email if it is unset.

3. Create the database

Open your Supabase SQL editor and run the contents of schema.sql.

This creates:

  • contacts table
  • email_log table
  • contacts_due_for_outreach view
  • contact_status_counts view
  • Auto-update trigger for updated_at

4. Verify SES sender identity

Your FROM_EMAIL must be verified in AWS SES before sending.

python -c "from outreach.email_client import verify_sender_identity; verify_sender_identity('steve@dialtone.menu')"

Check your inbox and click the verification link.

Note: New AWS SES accounts are in sandbox mode (200 emails/day, verified recipients only). Request production access in the SES console before running live outreach.


Workflow

Step 1 — Build your contact list

Export from Apollo (owner contact finder):

  • Search: Title=Owner, Industry=Restaurants, Location=Nashville TN, Company size=1-50
  • Save as apollo_export.csv

Step 2 — Import

python cli.py import --source apollo --file apollo_export.csv

# Preview without writing
python cli.py import --source apollo --file apollo_export.csv --dry-run

Step 3 — Preview today's run

Always dry-run first:

python cli.py run --dry-run

Output shows exactly which contacts would receive which email, with subject lines.

Step 4 — Send

python cli.py run

Sends up to DAILY_SEND_LIMIT emails (default: 20). Ordered by lead_score descending — your hottest leads always go first.

Step 5 — Monitor

# Status breakdown table
python cli.py status

# Conversion rates
python cli.py stats

# Look up a specific contact
python cli.py contact --email owner@rossieskitchen.com
python cli.py contact --domain rossieskitchen.com

First Live Cohort (Milestone 2)

Once the M1 templates pass review, the M2 path is operator-driven but backed by repo tooling. Runbook lives in docs/runbook-first-cohort.md. Quick reference:

# Set the warmup start date in .env, then:
python cli.py preflight                         # env / Supabase / SES / DNS
python cli.py cohort lock --name batch-1 --limit 5
python cli.py cohort show  --name batch-1       # review with stakeholders
python cli.py run --dry-run --cohort batch-1
python cli.py run          --cohort batch-1     # LIVE — only after approval
python cli.py metrics --cohort batch-1          # bounce / complaint / replies
python cli.py metrics --since 7d                # rolling window

The runner picks up the warmup limit automatically when WARMUP_START_DATE is set; manual overrides via --limit still work for one-off runs.


Email Sequence

# Timing Subject Purpose
1 Day 0 Friday nights at [Restaurant] The opener — short, one question
2 Day 3 The math on missed calls Value add — the $87,500 stat
3 Day 7 What other owners are saying Social proof
4 Day 14 Closing the loop Breakup email — low pressure
5 Day 60 Checking back in Re-engage (openers only)

The sequence stops immediately if:

  • The contact replies (any email)
  • The contact's status is set to demo_booked, customer, pilot, not_interested, or invalid

Contact Statuses

Status Meaning
new Imported, not yet emailed
emailed_1 Email #1 sent
emailed_2 Email #2 sent
emailed_3 Email #3 sent
breakup_sent Breakup email sent
re_engage Re-engage email sent
replied Responded to any email — STOP sequence
demo_booked Demo scheduled — STOP sequence
pilot In pilot program — STOP sequence
customer Paying customer — STOP sequence
not_interested Explicitly opted out — STOP sequence
invalid Bad email / not a real contact — STOP sequence

Update status from the local web UI (see below) or directly in Supabase when contacts reply, book a demo, etc.


Lead Scoring

Score contacts 1–5 to control outreach priority:

Score Meaning
5 Hot — demo booked or very engaged
4 Warm — replied, interested
3 Good fit, not yet contacted
2 Has contact info, lower fit
1 Minimal info, low priority

Higher-scored contacts always send first when the daily limit is reached.


Local Web UI

A lightweight FastAPI + Jinja2 + HTMX app for non-technical reviewers to monitor outreach and update contact statuses without touching the CLI or Supabase. Runs locally only — no auth, no public exposure.

Start the server

uvicorn web.app:app --reload --port 8000
# then open http://localhost:8000

Screens

Screen Purpose
Dashboard Status counts, conversion funnel (Email → Demo → Pilot → Customer), emails sent today
Contacts Searchable, filterable list (by status, lead score, city); click a row to open detail
Contact detail Owner info, full email history (subject, sent, opened, replied), status edit buttons (replied, demo_booked, pilot, customer, not_interested, invalid), notes editor
Run preview Read-only view of cli.py run --dry-run output — shows exactly which contacts would receive which email today

Scope

  • Read + status edits only. Sending emails stays in the CLI (python cli.py run) so non-technical users can't accidentally trigger a live send.
  • Reuses outreach/db.py and outreach/templates.py — no duplication of business logic.
  • Server-rendered Jinja2 pages with HTMX for interactive bits (status buttons, search). No JavaScript build step.

Scheduling (Optional)

To run automatically every morning at 8am CT, add a cron job:

# crontab -e
0 8 * * 1-5 cd /path/to/dialtone_outreach && source .venv/bin/activate && python cli.py run >> logs/outreach.log 2>&1

Or deploy as an AWS Lambda function triggered by EventBridge (same scheduler already in your DialTone stack).


Customising Templates

All email templates are in outreach/templates.py. Each template is a Jinja2 string with these tokens:

Token Value
{{ first_name }} Owner first name (falls back to "there")
{{ restaurant_name }} Restaurant name (cleaned of LLC, Inc., surrounding quotes; falls back to "your restaurant")
{{ city }} Restaurant city; opener uses a {% if city %} hook so missing values don't break the sentence
{{ calendly_url }} Your booking link
{{ from_name }} Your name

Edit the template strings directly — no restart needed, changes apply on next run.

Previewing rendered templates

Before unfreezing send (milestone 1, step 6), render all 5 templates against 5 real Apollo rows and review the output:

python scripts/preview_templates.py

Outputs land in developer/template-previews/<contact>-seq<n>.{txt,html}. Open the HTML files in Gmail / Apple Mail for visual review. The script exits non-zero if any rendered file still contains a {{ … }} artifact, so it doubles as a smoke test against the milestone 1 brace-leak bug.

Verified-inbox test send

python cli.py send-test --to verified@inbox.example

Renders email #1 against a sample contact and sends through SES after a confirmation prompt. Use to satisfy the milestone 1 acceptance criterion "test send to a verified inbox renders correctly in Gmail and Apple Mail."

CAN-SPAM Compliance

Every email rendered by outreach/templates.py includes:

  • The legal entity name (COMPANY_LEGAL_NAME)
  • The physical postal address (BUSINESS_ADDRESS) — required by law
  • A working unsubscribe link
  • Sender identity in the signature and footer

Until milestone 3 ships automated reply detection, unsubscribes are handled manually:

  1. Recipients click the mailto: link in the footer (or send unsubscribe as a reply).
  2. The mailbox owner (UNSUBSCRIBE_EMAIL, default unsubscribe@dialtone.menu) marks the contact as not_interested in Supabase, which removes them from the active sequence within minutes.

Set UNSUBSCRIBE_URL if you later wire up a Cloudflare Worker / endpoint that handles unsubscribes automatically — the templates will use that URL in place of the mailto link.


Reply Detection (Milestone 3)

Replies from known contacts are detected automatically via IMAP polling. The check-replies command connects to the reply mailbox, scans unread messages, matches senders against contacts.owner_email, and flips matched contacts to replied so the sequence engine stops emailing them.

Setup

  1. Set the REPLY_CHECK_* env vars in .env (see .env.example). For Gmail, enable IMAP in Settings and create an App Password.
  2. Test manually:
python cli.py check-replies --dry-run   # preview without writing
python cli.py check-replies              # scan + mark matched contacts
  1. Audit for status mismatches (safety net for partial failures):
python cli.py check-replies --audit        # report mismatches
python cli.py check-replies --audit --fix  # auto-correct them

Scheduling

During the M2–M3 pilot phase, run check-replies manually before each cohort send. Automated scheduling (cron, EventBridge + Lambda, or a persistent host) is deferred to Milestone 6 (Production Operations) so the solution doesn't depend on a development machine.

For local testing with cron (not recommended for production):

# crontab -e
*/5 * * * * cd /path/to/dialtone_outreach && source .venv/bin/activate && python cli.py check-replies >> logs/reply-check.log 2>&1

Environment Variables Reference

Variable Required Default Description
SUPABASE_URL Your Supabase project URL
SUPABASE_SERVICE_KEY Service role key (bypasses RLS)
AWS_ACCESS_KEY_ID AWS IAM key with SES send permissions
AWS_SECRET_ACCESS_KEY AWS IAM secret
AWS_REGION us-east-1 AWS region for SES
FROM_EMAIL Verified SES sender address
FROM_NAME Steve Cotton Display name for sender
DAILY_SEND_LIMIT 20 Max emails per run
CALENDLY_URL placeholder Booking link appended to email CTAs
BUSINESS_ADDRESS Physical postal address shown in every email footer (CAN-SPAM)
COMPANY_LEGAL_NAME ByteStreams LLC Legal entity name shown in the footer
UNSUBSCRIBE_EMAIL unsubscribe@dialtone.menu Mailbox used in the mailto: unsubscribe link
UNSUBSCRIBE_URL (mailto fallback) Optional URL-based unsubscribe endpoint
WARMUP_START_DATE (none) ISO date (YYYY-MM-DD) for day 1 of the warmup ramp. Empty disables warmup.
WARMUP_DAY_LIMITS 5,5,5,10,10,10,20 Comma-separated per-day caps used while warmup is active.
REPLY_CHECK_IMAP_HOST imap.gmail.com IMAP server for reply-check mailbox
REPLY_CHECK_IMAP_PORT 993 IMAP SSL port
REPLY_CHECK_EMAIL Email address of the reply-check mailbox
REPLY_CHECK_PASSWORD IMAP password (Gmail App Password recommended)

License

Private — ByteStreams LLC. Not for redistribution.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors