IMAP poll detects replies and tags funnel_replied. Humans move the lead through booked, demo, won, or lost. Every event links back to the email_prompt_id that drafted the message — the input the self-teaching loop runs on.
email_message.messageId — RFC id for thread matchworkspace.imap[] — per-brand inboxes polled (default ~120s cadence)imap.replyDetectRegex — ID + In-Reply-To header checklead.activityLog — opens, clicks, replies, manual notesworkspace.userId — operator transitioning the leadfunnel_replied — auto, on IMAP detectfunnel_call_booked — manual transitionfunnel_demo — manual transitionfunnel_closed_won, funnel_closed_lostfunnel_event.email_prompt_id — back-link for attributionfunnel_event.lagDays — days since sendfunnel_event.notes — operator-added context
Reply detection is automatic. The IMAP service polls each brand's inbox on a short cadence and matches incoming messages by RFC In-Reply-To header against the workspace's outgoing message ids. A match fires funnel_replied on the lead, attaches the reply body to the activity log, and surfaces the lead at the top of the operator's queue. The system does not classify reply sentiment — that's an operator judgment, made cleanly with the message text in front of them.
From funnel_replied onwards the operator drives the lead through the funnel. funnel_call_booked when a meeting lands. funnel_demo when the call happens. funnel_closed_won or funnel_closed_lost at the end, with a structured loss-reason on lost deals. Each transition timestamps and stores the operator id, so the activity log per lead reads as a clean timeline a sales manager can audit.
The attribution is what closes the self-teaching loop. Every funnel event carries the email_prompt_id of the message that earned it. After enough volume, the prompt registry can answer questions like "which email_draft_v19 variant has the highest reply-to-booked rate on the marketing pack" from a single SQL query — not a slide deck. That's the input that makes next quarter's prompts measurably better than this quarter's.
--- imap poll --- every 120s, per workspace.brands[]: inbox = imap.connect(brand.imap) for msg in inbox.unread(): inReply = msg.header("In-Reply-To") refs = msg.header("References") candidate = inReply || pickFrom(refs) if (!candidate) continue; sent = email_messages.findOne({ messageId: candidate }); if (!sent) continue; # not ours, skip db.funnel_events.insert({ lead_id: sent.lead_id, kind: "funnel_replied", detected_at: now(), email_prompt_id: sent.email_prompt_id, # self-teaching link lag_days: daysBetween(sent.sent_at, now()), reply_body: msg.text }); activity_log.append(lead_id: sent.lead_id, event: "replied", body: msg.text); --- manual transitions --- PUT /leads/:id/funnel { "kind": "call_booked"|"demo"|"closed_won"|"closed_lost", "notes": string, "lossReason": string|null # required on closed_lost } --- attribution rollup --- SELECT email_prompt_id, COUNT(*) AS replied FROM funnel_events WHERE kind = 'funnel_replied' AND lead.pack = 'fintech' GROUP BY email_prompt_id ORDER BY replied DESC; # <!-- PLACEHOLDER — exact poll cadence per workspace plan -->
Out-of-office bounce-back tags the lead as replied, pollutes the funnel.
Auto-response detection (Auto-Submitted, X-Auto-Response-Suppress, common OOO patterns) tags the event as replied_auto, separated from human replies in attribution.
Funnel event lacks the prompt id that drafted it; loop is broken.
Funnel write requires email_prompt_id via foreign key. Manual transitions inherit it from the lead's most recent sent message.
Lead replies, no manual follow-up, sits in the pipeline forever.
Stale-replied alert per assignee. After a configurable window, the lead surfaces in the operator's daily queue with a nudge.
Send a thread. Reply from another inbox. Watch the funnel event land with the prompt id attached.