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.
| Layer | Tool |
|---|---|
| Email sending | AWS SES |
| Database | Supabase (PostgreSQL) |
| CLI | Click + Rich |
| Web UI (local) | FastAPI + Jinja2 + HTMX |
| Contact source | Apollo (restaurant owner contacts) |
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
git clone https://github.com/ByteStreams-AI/dialtone_outreach.git
cd dialtone_outreachWith uv (preferred — matches the checked-in uv.lock):
uv venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
uv pip install -r requirements.txtOr with stock venv + pip:
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txtcp .env.example .envEdit .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.menuCAN-SPAM:
BUSINESS_ADDRESSmust be a real physical postal address.outreach/templates.pywill refuse to render an email if it is unset.
Open your Supabase SQL editor and run the contents of schema.sql.
This creates:
contactstableemail_logtablecontacts_due_for_outreachviewcontact_status_countsview- Auto-update trigger for
updated_at
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.
Export from Apollo (owner contact finder):
- Search: Title=Owner, Industry=Restaurants, Location=Nashville TN, Company size=1-50
- Save as
apollo_export.csv
python cli.py import --source apollo --file apollo_export.csv
# Preview without writing
python cli.py import --source apollo --file apollo_export.csv --dry-runAlways dry-run first:
python cli.py run --dry-runOutput shows exactly which contacts would receive which email, with subject lines.
python cli.py runSends up to DAILY_SEND_LIMIT emails (default: 20). Ordered by lead_score descending — your hottest leads always go first.
# 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.comOnce 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 windowThe runner picks up the warmup limit automatically when WARMUP_START_DATE
is set; manual overrides via --limit still work for one-off runs.
| # | 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, orinvalid
| 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.
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.
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.
uvicorn web.app:app --reload --port 8000
# then open http://localhost:8000| 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 |
- 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.
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>&1Or deploy as an AWS Lambda function triggered by EventBridge (same scheduler already in your DialTone stack).
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.
Before unfreezing send (milestone 1, step 6), render all 5 templates against 5 real Apollo rows and review the output:
python scripts/preview_templates.pyOutputs 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.
python cli.py send-test --to verified@inbox.exampleRenders 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."
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:
- Recipients click the
mailto:link in the footer (or sendunsubscribeas a reply). - The mailbox owner (
UNSUBSCRIBE_EMAIL, defaultunsubscribe@dialtone.menu) marks the contact asnot_interestedin 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.
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.
- Set the
REPLY_CHECK_*env vars in.env(see.env.example). For Gmail, enable IMAP in Settings and create an App Password. - Test manually:
python cli.py check-replies --dry-run # preview without writing
python cli.py check-replies # scan + mark matched contacts- 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 themDuring 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| 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) |
Private — ByteStreams LLC. Not for redistribution.