Skip to content

Latest commit

 

History

History
113 lines (89 loc) · 8.38 KB

File metadata and controls

113 lines (89 loc) · 8.38 KB

cold-cli

Agent-first CLI cold email sequence engine in Go. See ARCHITECTURE.md for full design.

Quick Reference

  • Language: Go
  • CLI framework: Cobra
  • Database: SQLite by default via modernc.org/sqlite; Postgres supported via COLD_CLI_DATABASE_URL
  • External dependency: gws CLI (subprocess calls for Gmail API)
  • Config/data dir: ~/.cold-cli/

Project Structure

cmd/cold-cli/main.go     — CLI entry, Cobra command definitions
internal/                 — single flat package, all application logic
  db.go                   — schema bootstrap, SQLite migrations, indexes
  store.go                — dialect-aware store open, backend selection, tick locking
  sql_runner.go           — cross-dialect query execution + placeholder rebinding
  models.go               — structs (Account, Lead, Campaign, ScheduledSend, Event)
  tick.go                 — tick engine (dialect-aware lock, poll, send loop)
  scheduler.go            — eager schedule computation, variant assignment, round-robin
  gws.go                  — GWSClient interface + real subprocess implementation
  send.go                 — RFC 2822 message construction, threading headers
  reply.go                — reply/bounce detection, In-Reply-To header matching
  template.go             — {{placeholder}} replacement, alias resolution, unresolved stripping
  csv.go                  — lead CSV import, BOM stripping, field validation
  config.go               — YAML config loading
  campaign.go             — campaign CRUD, preview, rendered preview, daily limit warnings
  campaign_preflight.go   — read-only duplicate/history/suppression/recipient gate
  account.go              — account CRUD, update, domain diagnostics
  lead.go                 — lead pause/resume/blacklist/list, campaign remove-lead
  stats.go                — campaign/step/variant/lead stats, event log
  inbox_needs_reply.go    — provider-verified latest-inbound reply review queue

Key Design Decisions

These are settled — do not revisit without explicit instruction:

  1. Eager scheduling — all sends stored in scheduled_sends; then deterministically rebalanced across active/draft campaigns sharing an account. Do NOT use lazy/rolling next_send_at on campaign_leads.
  2. GWSClient interface — gws interaction goes through an interface (SendEmail, ListMessages). Real impl calls subprocess. Tests use a mock.
  3. Template renderingstrings.ReplaceAll for {{placeholder}} substitution with alias resolution (namefirst_name, etc.). Unresolved variables stripped at send time (not sent literally). No Go text/template. No template engine.
  4. Daily limits and gaps — count from events table (SELECT COUNT(*) ... WHERE type='sent' AND timestamp >= today) and apply limits through shared rebalance logic used by preview, warnings, and tick. The same rebalancer enforces campaign minimum gaps across pending sends sharing an account, including per-lead timezone schedules. No mutable sends_today counter on accounts.
  5. Account rotation — round-robin at schedule time. All steps for one lead use the same account (thread continuity).
  6. Thread management — after step 1 send, backfill thread_id and parent_message_id onto all remaining scheduled_sends for that lead+campaign.
  7. Error isolation — gws send failure marks that one scheduled_sends row as 'failed' and continues. Never crash the whole tick. Emails with empty subject/body after rendering are also marked failed (not sent).
  8. Status semanticsskipped = auto-cancelled (reply/bounce/domain-reply). cancelled = user action (pause/blacklist). These are distinct.
  9. Campaign completion — an active campaign with no non-terminal sends becomes completed; if one or more sends failed, it becomes completed_with_failures. campaign retry reactivates completed_with_failures only when it actually resets at least one failed send.
  10. Tick locking — SQLite mode uses flock/fcntl on ~/.cold-cli/tick.lock; Postgres mode uses an advisory lock on a dedicated connection. Keep the semantics aligned.
  11. Validation at creation — template placeholders validated against lead CSV at campaign creation with alias resolution and Levenshtein "Did you mean?" suggestions. Unresolved vars stripped at send time as a safety net.
  12. Workspace ownershipcold-cli is the source of truth for account/campaign ownership. Accounts and campaigns carry workspace_id, defaulting to default for backward compatibility; there is no separate app-side account mapping to maintain. Use --workspace <id> or COLD_CLI_WORKSPACE_ID when adding inboxes or campaigns for hosted/multi-brand setups; do not rely on email-domain inference as the access boundary. For hosted dashboards or multi-tenant control planes, always pass the intended workspace explicitly so campaigns do not accidentally land in default.
  13. Preflight history defaults globalcampaign preflight checks prior email and company-domain campaign history across every workspace by default. Workspace ownership controls access and sending, but it is not a contact-reuse boundary. Narrowing history scope or allowing a shared domain must be explicit.
  14. Provider state gates reply queues — both inbox needs-reply and inbox followups audit provider history before returning candidates. They fail closed on missing messages and never draft or send.
  15. Evergreen cohorts are explicitly dated and reactivated — after an active campaign's stored start date arrives, campaign add-leads requires a future --start-date. A completed campaign additionally requires --reactivate, which must also appear in the rollback preview. Use --preview-only first to render and schedule the exact cohort without saving changes. This prevents an old date from creating immediately-due sends and prevents pending rows from being stranded under a completed campaign.

Testing

  • Use real SQLite (:memory:) in tests for the main behavioral suite. Do NOT mock the database.
  • Add focused Postgres boundary tests around dialect seams (rebinding, clone/tick/store lock paths) when touching cross-dialect behavior.
  • Mock only the GWSClient interface.
  • Test scheduler, template rendering, reply matching, bounce parsing as pure functions.
  • Every codepath needs: happy path + key error branches.

Build & Run

go build -o cold-cli ./cmd/cold-cli
go test ./...

scheduled_sends Status Values

pending   → waiting to send
sent      → successfully sent via gws
failed    → gws send failed (error stored in error_message column + events table)
skipped   → auto-cancelled (reply/bounce/domain-reply detected)
cancelled → user-cancelled (pause/blacklist)

Data Model

Core tables: accounts, campaigns, campaign_accounts, leads, campaign_leads, scheduled_sends, events, plus support tables such as email_messages and kv. See ARCHITECTURE.md for full schema.

Agents adding inboxes for the hosted product should run account commands with an explicit workspace, for example:

cold-cli --workspace workspace-a account add-smtp sender@workspace-a.example ...

Campaigns can only use active accounts from the same workspace.

Key: scheduled_sends is the core table. Each row is a self-contained send instruction with current send_at, assigned account_id, variant_index, and (after step 1 sends) thread_id + parent_message_id. Pending rows may be rebalanced later to reflect account daily limits or actual sent-time drift. Failed sends store the reason in error_message and insert a 'failed' event.

CSV Import Rules

  • email column is always required
  • All other required columns are driven by {{placeholders}} in the sequence YAML
  • Strip UTF-8 BOM on import
  • Validate all leads have values for all placeholders at campaign creation
  • Reserved column names (subject, body, step, delay, variant) are rejected — they conflict with sequence YAML fields
  • Extra columns beyond built-in fields stored as JSON in leads.custom_fields
  • Reimporting a lead updates all fields from the new CSV (source of truth)

gws Integration

  • Always call via subprocess with 30s timeout
  • Parse stdout for message_id/thread_id after send
  • Capture stderr on failure for error reporting
  • Health check on cold-cli init: verify gws binary exists and can auth
  • Reply polling: use last_poll_at timestamp + after: query filter