Cold Email
Plain-text outbound sequences with hard caps, pre-flight policy, and a queue-drain scheduler that protects your sender reputation.
Cold Email
Cold Email is HeyCMO's outbound system for customers who want to send personal-style cold sequences from their own Gmail or Outlook inbox. It is deliberately conservative β the customer's deliverability and account standing are at stake, so the policy layer refuses sends rather than expose them to damage they can't undo.
Configuration
| Property | Value |
|---|---|
| Phase | 2.1 |
| Schema | ColdEmailCampaign, ColdEmailSend, ColdEmailSuppression |
| Policy module | apps/api/agent/lib/cold-email-policy.ts |
| Workflow | apps/api/agent/workflows/cold-email-sequence.ts |
| Tool | apps/api/agent/tools/cold-email-sender.ts |
| UI | /outreach/cold-email |
| Sender rail | Composio (GMAIL_SEND_EMAIL) |
Schema
Three tables, all customer-scoped.
ColdEmailCampaign
model ColdEmailCampaign {
id String @id @default(uuid()) @db.Uuid
customerId String @map("customer_id") @db.Uuid
name String
fromEmail String @map("from_email")
status String @default("draft") // draft | scheduled | paused | cancelled
dailyCap Int @default(25) @map("daily_cap")
/// JSONB array: [{ day_offset, subject, body }]
sequenceSteps Json @map("sequence_steps")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}ColdEmailSend
One row per (campaign Γ recipient Γ step). The @@unique([campaignId, toEmail, stepDay]) constraint is what makes the scheduler idempotent β re-running the workflow can never duplicate a send.
model ColdEmailSend {
id String @id @default(uuid()) @db.Uuid
campaignId String @db.Uuid
customerId String @db.Uuid
stepDay Int
toEmail String
toName String?
subject String
body String
status String @default("queued") // queued | sending | sent | skipped_capped | skipped_suppressed | failed
scheduledFor DateTime
sentAt DateTime?
replyReceivedAt DateTime?
composioMessageId String?
errorMessage String?
@@unique([campaignId, toEmail, stepDay])
}ColdEmailSuppression
Composite-PK on (customerId, email). Hard-bounced, opted-out, or replied recipients are inserted here and never receive another send for that customer.
model ColdEmailSuppression {
customerId String
email String
reason String // 'replied' | 'opted_out' | 'bounced' | 'manual'
notes String?
@@id([customerId, email])
}Hard limits
These are not advisory. The pre-flight rejects campaigns that violate any of them.
| Limit | Value | Why |
|---|---|---|
HARD_DAILY_CAP | 25 sends/day per inbox | The real Gmail/Outlook 2026 cold ceiling. Anything above this elevates spam-folder risk significantly. The customer can lower this; they cannot raise it above 25. |
MAX_SEQUENCE_STEPS | 5 | Day 0/3/7/14/21 cadence. 6+ is over-emailing and tanks reply rate. |
MAX_SUBJECT_LENGTH | 100 chars | Cold subject lines should be 3β7 words. |
MAX_BODY_LENGTH | 4000 chars (~700 words) | Above this, the email reads as a wall of text. |
MAX_BODY_API (request) | enforced by bodyLimit | Defense in depth on the HTTP layer. |
Pre-flight policy
Every transition from draft β scheduled runs through preflightCampaign(input). The function returns { ok, reasons[] } and the API refuses the transition if ok === false.
The checks, in order:
fromEmailis a valid address β bare regex; nothing fancy.fromEmailis not a generic alias βinfo@,noreply@,support@,hello@,admin@,contact@are blocked. Cold delivery from these aliases is much worse than from a personal address.dailyCapis 1 β€ N β€ 25.- Sequence has 1 β€ N β€ 5 steps, with strictly increasing
dayOffset, no duplicates, and step 0 hasdayOffset === 0. - Per-step content checks:
- non-empty subject + body
- subject β€ 100 chars
- body β€ 4000 chars
- subject is spam-trigger free (see below)
- body contains no HTML tags β refuses
<html>,<div>,<p>,<a>, etc. - body contains no tracking pixels β refuses any
<img>orsrc=
Spam triggers
detectSpamTriggers(subject) matches against a static list:
FREE, GUARANTEE, GUARANTEED, ACT NOW, LIMITED TIME, $$$, !!!,
WINNER, CASH BONUS, CONGRATULATIONS, CLICK HERE, BUY NOW,
AS SEEN ON, WHILE SUPPLIES LASTAlphabetic triggers use word-boundary matching (so FREE doesn't false-positive on freedom). Symbolic triggers ($$$, !!!) use plain substring match β they're noise wherever they appear.
The drain workflow
cold-email-sequence.ts is a Mastra workflow scheduled every 5 minutes. The cadence is deliberate: 25/day cap = ~1/hour, so 5-min ticks comfortably absorb the natural send rate without bursts.
FOR UPDATE SKIP LOCKED
Concurrent Fly machines run the same cron. To avoid double-sending, the workflow claims due rows inside a single transaction:
SELECT id, campaign_id, customer_id, step_day, to_email, to_name, subject, body
FROM cold_email_sends
WHERE customer_id = $1::uuid
AND status = 'queued'
AND scheduled_for <= now()
ORDER BY scheduled_for ASC
LIMIT $2
FOR UPDATE SKIP LOCKEDInside the same transaction, the rows flip to status = 'sending' so peers see them as invisible. If the worker crashes after the lock but before completion, the row stays in 'sending' and a future tick can re-claim it (Postgres releases the lock on connection drop).
Per-row state machine
queued ββclaimβββΆ sending ββsendβββΆ sent
β
ββββΆ skipped_capped (back to queued, scheduledFor=tomorrow morning)
ββββΆ skipped_suppressed (terminal)
ββββΆ failed (with errorMessage)canSendNow({ customerId, fromEmail, toEmail, dailyCap }) runs per row, in order:
- Suppression check β looked up first because it's the cheapest and most important. A suppressed recipient is never sent to, even if there's capacity.
- Recipient sanity β must look like an email.
- Daily cap β counts
coldEmailSend.countwherestatus='sent',sentAt >= now()-24h,campaign.fromEmail === input.fromEmail. Compared againstmin(campaign.dailyCap, HARD_DAILY_CAP).
Auto-suppression on hard bounces
If the Composio send raises a mailbox not found, does not exist, user unknown, or 550 5.1.1, the recipient is automatically inserted into cold_email_suppression with reason='bounced'. Future steps in the sequence (day 3, 7, 14, 21) will be skipped.
API endpoints
All endpoints are tenant-scoped under /api/cold-email/:customerId/... and require API-key auth.
| Method | Path | Purpose |
|---|---|---|
GET | /api/cold-email/:customerId/campaigns | List campaigns + per-campaign roll-up stats (queued/sent/failed/skipped/replied). |
POST | /api/cold-email/:customerId/campaigns | Create a draft campaign. Validates with the same Zod schema below; runs preflightCampaign before insert. |
POST | /api/cold-email/:customerId/campaigns/:id/recipients | Bulk-add recipients. One ColdEmailSend row per (recipient Γ step). Skips suppressed addresses up-front. |
POST | /api/cold-email/:customerId/campaigns/:id/pause | Flips status to paused. The drain workflow puts in-flight rows back to queued when it sees a paused campaign. |
POST | /api/cold-email/:customerId/campaigns/:id/resume | Flips status back to scheduled. |
Campaign create payload
{
name: string, // 1β120 chars
fromEmail: string, // valid email, β€254 chars
dailyCap: number, // 1β25, default 25
sequenceSteps: Array<{
dayOffset: number, // 0β120
subject: string, // 1β120 chars
body: string, // 1β8000 chars
}> // 1β5 entries
}UI at /outreach/cold-email
The page lists campaigns with their roll-up stats, lets the customer:
- Create a new campaign through a multi-step form (preview rendered alongside)
- Add recipients (CSV paste or single-row entry)
- Pause / resume / cancel
- See the per-row send log, with reasons for skipped rows
The form runs the same validation client-side as the server pre-flight, so the customer gets immediate feedback on cap violations, generic aliases, HTML bodies, and spam triggers before they hit submit.
Where it fits in the stack
Cold sequences are typically created from the approval queue β the Phase 1.2 lead-outreach drain workflow drafts an outreach piece per high-confidence lead, the customer approves it in Approve.tsx, and approval inserts the ColdEmailSend rows that this workflow consumes. The two halves are decoupled by the queue, so you can also create a campaign manually from the UI without going through Mia.
Source files
apps/api/agent/lib/cold-email-policy.tsβ caps, spam triggers, pre-flight,canSendNow, suppression helpersapps/api/agent/workflows/cold-email-sequence.tsβ the 5-minute drain workflowapps/api/agent/tools/cold-email-sender.tsβ the agent-callable tool wrapperapps/api/infra/server.tsβ endpoints (search for/api/cold-email/)apps/web/src/pages/ColdEmailCampaigns.tsxβ customer UI
Free Interactive Tools
25 free, no-signup marketing tools at /tools/<slug> β top-of-funnel SEO + GEO bait that doubles as in-product utilities.
LinkedIn Outreach
BYO Chrome extension companion that runs connection requests, DMs, and comments from the customer's own browser β heycmo never holds the LinkedIn cookie.