🧠 HeyCMO
Features

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

PropertyValue
Phase2.1
SchemaColdEmailCampaign, ColdEmailSend, ColdEmailSuppression
Policy moduleapps/api/agent/lib/cold-email-policy.ts
Workflowapps/api/agent/workflows/cold-email-sequence.ts
Toolapps/api/agent/tools/cold-email-sender.ts
UI/outreach/cold-email
Sender railComposio (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.

LimitValueWhy
HARD_DAILY_CAP25 sends/day per inboxThe 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_STEPS5Day 0/3/7/14/21 cadence. 6+ is over-emailing and tanks reply rate.
MAX_SUBJECT_LENGTH100 charsCold subject lines should be 3–7 words.
MAX_BODY_LENGTH4000 chars (~700 words)Above this, the email reads as a wall of text.
MAX_BODY_API (request)enforced by bodyLimitDefense 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:

  1. fromEmail is a valid address β€” bare regex; nothing fancy.
  2. fromEmail is 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.
  3. dailyCap is 1 ≀ N ≀ 25.
  4. Sequence has 1 ≀ N ≀ 5 steps, with strictly increasing dayOffset, no duplicates, and step 0 has dayOffset === 0.
  5. 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> or src=

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 LAST

Alphabetic 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 LOCKED

Inside 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:

  1. 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.
  2. Recipient sanity β€” must look like an email.
  3. Daily cap β€” counts coldEmailSend.count where status='sent', sentAt >= now()-24h, campaign.fromEmail === input.fromEmail. Compared against min(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.

MethodPathPurpose
GET/api/cold-email/:customerId/campaignsList campaigns + per-campaign roll-up stats (queued/sent/failed/skipped/replied).
POST/api/cold-email/:customerId/campaignsCreate a draft campaign. Validates with the same Zod schema below; runs preflightCampaign before insert.
POST/api/cold-email/:customerId/campaigns/:id/recipientsBulk-add recipients. One ColdEmailSend row per (recipient Γ— step). Skips suppressed addresses up-front.
POST/api/cold-email/:customerId/campaigns/:id/pauseFlips 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/resumeFlips 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 helpers
  • apps/api/agent/workflows/cold-email-sequence.ts β€” the 5-minute drain workflow
  • apps/api/agent/tools/cold-email-sender.ts β€” the agent-callable tool wrapper
  • apps/api/infra/server.ts β€” endpoints (search for /api/cold-email/)
  • apps/web/src/pages/ColdEmailCampaigns.tsx β€” customer UI

On this page