Agent-first CLI cold email sequence engine in Go. See ARCHITECTURE.md for full design.
- Language: Go
- CLI framework: Cobra
- Database: SQLite by default via
modernc.org/sqlite; Postgres supported viaCOLD_CLI_DATABASE_URL - External dependency: gws CLI (subprocess calls for Gmail API)
- Config/data dir:
~/.cold-cli/
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
These are settled — do not revisit without explicit instruction:
- Eager scheduling — all sends stored in
scheduled_sends; then deterministically rebalanced across active/draft campaigns sharing an account. Do NOT use lazy/rollingnext_send_aton campaign_leads. - GWSClient interface — gws interaction goes through an interface (
SendEmail,ListMessages). Real impl calls subprocess. Tests use a mock. - Template rendering —
strings.ReplaceAllfor{{placeholder}}substitution with alias resolution (name→first_name, etc.). Unresolved variables stripped at send time (not sent literally). No Gotext/template. No template engine. - 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 mutablesends_todaycounter on accounts. - Account rotation — round-robin at schedule time. All steps for one lead use the same account (thread continuity).
- Thread management — after step 1 send, backfill
thread_idandparent_message_idonto all remainingscheduled_sendsfor that lead+campaign. - Error isolation — gws send failure marks that one
scheduled_sendsrow as'failed'and continues. Never crash the whole tick. Emails with empty subject/body after rendering are also markedfailed(not sent). - Status semantics —
skipped= auto-cancelled (reply/bounce/domain-reply).cancelled= user action (pause/blacklist). These are distinct. - Campaign completion — an active campaign with no non-terminal sends becomes
completed; if one or more sends failed, it becomescompleted_with_failures.campaign retryreactivatescompleted_with_failuresonly when it actually resets at least one failed send. - 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. - 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.
- Workspace ownership —
cold-cliis the source of truth for account/campaign ownership. Accounts and campaigns carryworkspace_id, defaulting todefaultfor backward compatibility; there is no separate app-side account mapping to maintain. Use--workspace <id>orCOLD_CLI_WORKSPACE_IDwhen 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 indefault. - Preflight history defaults global —
campaign preflightchecks 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. - Provider state gates reply queues — both
inbox needs-replyandinbox followupsaudit provider history before returning candidates. They fail closed on missing messages and never draft or send. - Evergreen cohorts are explicitly dated and reactivated — after an active campaign's stored start date arrives,
campaign add-leadsrequires a future--start-date. A completed campaign additionally requires--reactivate, which must also appear in the rollback preview. Use--preview-onlyfirst 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.
- 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
GWSClientinterface. - Test scheduler, template rendering, reply matching, bounce parsing as pure functions.
- Every codepath needs: happy path + key error branches.
go build -o cold-cli ./cmd/cold-cli
go test ./...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)
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.
emailcolumn 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)
- 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_attimestamp +after:query filter