Skip to main content

Email Funnel Metrics

Tracks the full email outreach funnel from send to meeting booked. All metrics are computed at query time via SQL aggregations over synced SmartLead data.


Data Sources

TablePurpose
jeff_sl_lead_messagesEmail events (SENT, REPLY) with timestamps
jeff_sl_leadsLead records with status and category
jeff_sl_campaignsCampaign metadata (series, sub_series, client)
jeff_sl_categoriesSmartLead category labels (Positive Reply, Meeting Booked, etc.)
jeff_sl_email_accountsSender email account info
jeff_meetingsMeeting CRM records with stage tracking and agency_id (credit owner)
orange_schema.agenciesInternal agency table; bridges Smartlead client_id to CRM credit via smartlead_client_id
jeff_posthog_eventsAudit page view and click events
jeff_job_rowsLinks audits to jobs and emails
jeff_jobsLinks jobs to SmartLead campaigns

Date Modes

The date mode controls how events are bucketed into time periods and which leads are included in the date range. Everything else (metric columns, joins, filters) stays the same.

Activity Mode (default)

Each event is bucketed by its own timestamp. The date range filters on m.time (the event timestamp).

Use when: "What happened in this time window?" — operational dashboards, daily monitoring.

date_trunc('{granularity}', m.time)::date AS date

WHERE m.time >= :date_from
AND m.time < :date_to + INTERVAL '1 day'

Cohort Mode

ALL events for a lead are bucketed by that lead's email_1 send date. The date range filters on COALESCE(sent_e1.time, l.created_at) — so it selects leads whose first email was sent in the date range, then pulls in all their events regardless of when those events occurred.

Use when: "For leads we emailed in this window, what were the outcomes?" — campaign effectiveness, conversion analysis.

-- Extra self-join to look up each lead's email_1 send time
LEFT JOIN jeff_sl_lead_messages sent_e1
ON sent_e1.lead_id = m.lead_id
AND sent_e1.campaign_id = m.campaign_id
AND sent_e1.sequence_number = 1
AND sent_e1.type = 'SENT'

date_trunc('{granularity}', COALESCE(sent_e1.time, l.created_at))::date AS date

WHERE COALESCE(sent_e1.time, l.created_at) >= :date_from
AND COALESCE(sent_e1.time, l.created_at) < :date_to + INTERVAL '1 day'

What changes between the two modes

ComponentActivityCohort
Date expression (SELECT + GROUP BY)m.timeCOALESCE(sent_e1.time, l.created_at)
WHERE date filterFilters on event timestampFilters on email_1 send date
Extra JOINNoneSelf-join to jeff_sl_lead_messages to find email_1 timestamp
Metric columnsSameSame
Other JOINsSameSame
FiltersSameSame

Example

A lead receives email_1 on Jan 3, then replies on Jan 15, then books a meeting on Jan 22. Querying January with weekly granularity:

EventActivity Mode BucketCohort Mode Bucket
email_1 sentWeek of Jan 1Week of Jan 1
replyWeek of Jan 15Week of Jan 1
meeting bookedWeek of Jan 22Week of Jan 1

In activity mode, the events spread across 3 weeks. In cohort mode, all 3 events collapse into the week the lead was first emailed.


Dimensions (group by)

DimensionSourceDescription
seriescampaigns.seriesCampaign series number
sub_seriescampaigns.sub_seriesCampaign sub-series string
client_idcampaigns.client_idSmartLead client
agency_idagencies.idAgency that owns the credit. SL-side metrics resolve via agencies.smartlead_client_id = campaigns.client_id; CRM metrics read jeff_meetings.agency_id directly. Agencies with NULL smartlead_client_id show zero SL-side counts and real CRM counts.
domainemail_accounts.domain_nameSending email domain
provideremail_accounts.typeSMTP, GMAIL, or OUTLOOK
sequence_numberlead_messages.sequence_numberPosition in email sequence
agency_nameagencies.name (via PostHog join)Agency name (PostHog-only queries)
marketplacePostHog event propertiesMarketplace code, e.g., US

CRM metrics (crm_positive_replies, crm_meetings_booked) only decompose along client_id and agency_id. When grouped by series, sub_series, domain, provider, or sequence_number, the CRM columns are NULL — those dimensions describe the source message, which is not the credit unit.


Filters

FilterDescription
client_idRestrict to specific Smartlead client IDs. For CRM metrics, resolves via agencies.smartlead_client_id — the count includes meetings credited to any agency linked to the requested client(s).
campaign_idRestrict to specific campaign IDs
seriesRestrict to specific series numbers
sub_seriesRestrict to specific sub-series strings
agency_idRestrict to specific agency IDs. For SL-side metrics, joins agencies.smartlead_client_id = campaigns.client_id. For CRM metrics, filters jeff_meetings.agency_id directly.
agency_nameAgency name filter (PostHog-only queries)

Count Metrics (21)

All SQL snippets below show the metric column logic. The date bucketing (activity vs cohort) and WHERE clause are determined by the date mode described above.

Delivery Metrics

MetricSQL LogicDescription
prospects_reachedCOUNT(DISTINCT campaign_lead_map_id) WHERE type='SENT' AND sequence_number=1Lead-campaign participations that received email 1. Same lead in two campaigns counts twice. Denominator for all rate metrics.
unique_prospects_reachedCOUNT(DISTINCT lead_id) WHERE type='SENT' AND sequence_number=1Distinct humans that received email 1, deduped on lead_id only. Same lead in two campaigns counts once. Non-additive across group_by dimensions.
total_emails_sentCOUNT(*) WHERE type='SENT'Total emails across all sequence steps
email_1_sentCOUNT(*) WHERE type='SENT' AND sequence_number=1First email sends
email_2_sentCOUNT(*) WHERE type='SENT' AND sequence_number=2Second email sends
email_3_sentCOUNT(*) WHERE type='SENT' AND sequence_number=3Third email sends

Reply Metrics

MetricSQL LogicDescription
total_repliesCOUNT(DISTINCT lead_id) WHERE type='REPLY'Leads who replied (any type)
replies_email_1COUNT(DISTINCT lead_id) WHERE type='REPLY' AND sequence_number=1Replies to email 1
replies_email_2COUNT(DISTINCT lead_id) WHERE type='REPLY' AND sequence_number=2Replies to email 2
replies_email_3COUNT(DISTINCT lead_id) WHERE type='REPLY' AND sequence_number=3Replies to email 3

SmartLead Category Metrics (rep-validated)

These come from SmartLead's lead categorization system. A sales rep manually assigns these categories after reviewing lead responses.

MetricCategory MatchDescription
sl_positive_repliescategory.name = 'Positive Reply'Rep-confirmed genuine interest
sl_meetings_bookedcategory.name = 'Meeting Booked'Prospect confirmed a meeting slot
total_bouncescategory.name = 'Sender Originated Bounce'Delivery failures
ooo_repliescategory.name = 'Out Of Office'Auto-replies, not real engagement

CRM Metrics (from Meeting CRM)

CRM metrics attribute meetings by agency ownership (jeff_meetings.agency_id), not by the source campaign's Smartlead client. When the rep publishes a meeting via POST /meetings/{id}/publish, the chosen agency owns the credit — even if it differs from the agency that owns the campaign that produced the lead. Meetings with NULL agency_id (unpublished) are excluded.

When filtered by client_id, the lookup resolves agencies.smartlead_client_id → mtg.agency_id. This makes the count match what the CRM UI shows for that client/week (whereas the older campaign-source attribution would undercount whenever a meeting was credited to a different agency than the campaign owner).

Bucketing in both activity and cohort date modes is by the meeting event date (creation date for crm_positive_replies, first BOOKED+ event date for crm_meetings_booked) — the source email's cohort window does not apply because the credited agency may have nothing to do with that source campaign.

MetricSQL LogicDescription
crm_positive_repliesCOUNT(DISTINCT mtg.id) joined jeff_meetings → orange_schema.agencies ON agencies.id = mtg.agency_idMeetings credited to an agency in the date window. Bucketed by mtg.created_at.
crm_meetings_bookedCOUNT(DISTINCT mtg.id) over the same join, restricted to meetings with a stage event reaching BOOKED+ (BOOKED, NO_SHOW, PITCHED, DID_NOT_PITCH, DID_NOT_CLOSE, CLOSED)Meetings that have ever reached BOOKED or a later stage. Monotonic — backward transitions don't drop the count. Bucketed by the first such event's date.

Other

MetricSQL LogicDescription
blocked_leadsCOUNT(DISTINCT lead_id) WHERE lead.status = 'BLOCKED'SmartLead prevented sending

PostHog Engagement Metrics

Track audit page interactions. Joined via: jeff_posthog_eventsjeff_job_rows (audit_id) → jeff_jobs (job_id) → jeff_sl_leads (campaign_id + email).

MetricEvent FilterDescription
audit_viewsevent = 'audit_viewed'Distinct leads who viewed audit page
unique_audit_visitorsDISTINCT distinct_id WHERE event = 'audit_viewed'Unique browser sessions on audit pages
cta_clicksevent = '$autocapture' AND el_text ILIKE '%get started%' OR '%başlayın%'CTA button clicks on audit page
amazon_clicksevent = '$autocapture' AND el_text ILIKE '%view on amazon%'"View on Amazon" link clicks

Rate Metrics (10)

All rates use prospects_reached as denominator unless noted otherwise. Rates are computed in Python after the SQL query returns (not in SQL).

Caller note: rate metrics depend on the underlying counts being selected. When requesting a rate via smartlead_query_funnel, also pass its numerator and denominator count metrics — missing counts silently produce 0.0.

MetricFormulaDescription
reply_ratetotal_replies / prospects_reachedRaw reply rate (includes OOO, negative)
sl_positive_reply_ratesl_positive_replies / prospects_reachedRep-confirmed positive reply rate
sl_booking_ratesl_meetings_booked / prospects_reachedEnd-to-end conversion from email to meeting
sl_slot_to_booking_ratesl_meetings_booked / sl_positive_repliesConversion from positive reply to booked meeting
reply_to_crm_positive_ratecrm_positive_replies / total_repliesReply-quality rate — fraction of replies that become CRM-positive. Caveat: numerator is agency-attributed, denominator is campaign-attributed (see paragraph below).
bounce_ratetotal_bounces / prospects_reachedDelivery failure rate
blocked_rateblocked_leads / prospects_reachedRate SmartLead refused to send
audit_view_rateaudit_views / prospects_reachedEmail-to-page conversion
cta_click_ratecta_clicks / prospects_reachedEnd-to-end email to CTA click
audit_cta_ratecta_clicks / audit_viewsOn-page CTA conversion rate

crm_positive_reply_rate and crm_booking_rate were removed in EQU-290. CRM counts attribute by agency credit while prospects_reached attributes by campaign source — a meeting credited to agency X may have come from agency Y's campaign, so X's denominator doesn't include the email that produced it. The ratio is per-agency-meaningless under the new attribution. Request crm_positive_replies / crm_meetings_booked and prospects_reached separately if you need to compute a ratio with that caveat in mind.

reply_to_crm_positive_rate (kept) inherits the same numerator-vs-denominator attribution split: numerator is agency-attributed CRM positives, denominator is replies on the source client's campaigns. Treat it as a directional signal rather than a precise per-client conversion.

Rate computation code
def safe_div(num, den):
return round(num / den, 4) if den else 0.0

reached = row["prospects_reached"]

row["reply_rate"] = safe_div(row["total_replies"], reached)
row["sl_positive_reply_rate"] = safe_div(row["sl_positive_replies"], reached)
row["sl_booking_rate"] = safe_div(row["sl_meetings_booked"], reached)
row["sl_slot_to_booking_rate"] = safe_div(row["sl_meetings_booked"], row["sl_positive_replies"])
row["reply_to_crm_positive_rate"] = safe_div(row["crm_positive_replies"], row["total_replies"])
row["bounce_rate"] = safe_div(row["total_bounces"], reached)
row["blocked_rate"] = safe_div(row["blocked_leads"], reached)
row["audit_view_rate"] = safe_div(row["audit_views"], reached)
row["cta_click_rate"] = safe_div(row["cta_clicks"], reached)
row["audit_cta_rate"] = safe_div(row["cta_clicks"], row["audit_views"])

Full SQL Query

The complete query with all metric columns. The date expression and WHERE clause shown here are for activity mode — see Date Modes above for how these change in cohort mode.

Full funnel query (activity mode)
SELECT
date_trunc('{granularity}', m.time)::date AS date,

-- Delivery
COUNT(DISTINCT CASE WHEN m.type = 'SENT' AND m.sequence_number = 1
THEN l.id END) AS prospects_reached,
COUNT(*) FILTER (WHERE m.type = 'SENT') AS total_emails_sent,
COUNT(*) FILTER (WHERE m.type = 'SENT' AND m.sequence_number = 1) AS email_1_sent,
COUNT(*) FILTER (WHERE m.type = 'SENT' AND m.sequence_number = 2) AS email_2_sent,
COUNT(*) FILTER (WHERE m.type = 'SENT' AND m.sequence_number = 3) AS email_3_sent,

-- Replies
COUNT(DISTINCT CASE WHEN m.type = 'REPLY' THEN l.id END) AS total_replies,
COUNT(DISTINCT CASE WHEN m.type = 'REPLY' AND m.sequence_number = 1
THEN l.id END) AS replies_email_1,
COUNT(DISTINCT CASE WHEN m.type = 'REPLY' AND m.sequence_number = 2
THEN l.id END) AS replies_email_2,
COUNT(DISTINCT CASE WHEN m.type = 'REPLY' AND m.sequence_number = 3
THEN l.id END) AS replies_email_3,

-- SmartLead categories (rep-validated)
COUNT(DISTINCT CASE WHEN LOWER(cat.name) = LOWER('Positive Reply')
THEN l.id END) AS sl_positive_replies,
COUNT(DISTINCT CASE WHEN LOWER(cat.name) = LOWER('Meeting Booked')
THEN l.id END) AS sl_meetings_booked,
COUNT(DISTINCT CASE WHEN LOWER(cat.name) = LOWER('Sender Originated Bounce')
THEN l.id END) AS total_bounces,
COUNT(DISTINCT CASE WHEN LOWER(cat.name) = LOWER('Out Of Office')
THEN l.id END) AS ooo_replies,

-- (CRM metrics are computed in a separate query — see "CRM CTE" below)

-- Blocked
COUNT(DISTINCT CASE WHEN l.status = 'BLOCKED' THEN l.id END) AS blocked_leads,

-- PostHog engagement
COUNT(DISTINCT CASE WHEN ph.event = 'audit_viewed' THEN l.id END) AS audit_views,
COUNT(DISTINCT CASE WHEN ph.event = 'audit_viewed'
THEN ph.distinct_id END) AS unique_audit_visitors,
COUNT(DISTINCT CASE WHEN ph.event = '$autocapture' AND (
ph.properties->>'$el_text' ILIKE '%get started%' OR
ph.properties->>'$el_text' ILIKE '%başlayın%')
THEN l.id END) AS cta_clicks,
COUNT(DISTINCT CASE WHEN ph.event = '$autocapture' AND
ph.properties->>'$el_text' ILIKE '%view on amazon%'
THEN l.id END) AS amazon_clicks

FROM {schema}.jeff_sl_lead_messages m
JOIN {schema}.jeff_sl_leads l
ON l.id = m.lead_id AND l.campaign_id = m.campaign_id
JOIN {schema}.jeff_sl_campaigns c
ON c.id = m.campaign_id
LEFT JOIN {schema}.jeff_sl_categories cat
ON cat.id = l.lead_category_id
LEFT JOIN {schema}.jeff_job_rows ph_jr
ON ph_jr.prospect_email = l.email
LEFT JOIN {schema}.jeff_jobs ph_j
ON ph_j.id = ph_jr.job_id AND ph_j.sl_campaign_id = c.id
LEFT JOIN {schema}.jeff_posthog_events ph
ON ph.audit_id = ph_jr.audit_id AND ph_j.id IS NOT NULL
WHERE m.time >= :date_from
AND m.time < :date_to + INTERVAL '1 day'
AND c.parent_campaign_id IS NULL -- exclude sub-campaigns
GROUP BY 1
ORDER BY 1
CRM CTE (agency-attributed, runs in a separate SQL pass)

CRM metrics are computed in their own queries because (a) attribution is by jeff_meetings.agency_id, not by the source campaign's client_id, and (b) bucketing is by the meeting event date in both activity and cohort modes. Results are merged into the main funnel rows by (date, client_id|agency_id) keys after both queries return.

-- crm_positive_replies (bucketed by mtg.created_at)
SELECT
date_trunc('{granularity}', mtg.created_at)::date AS date,
a.smartlead_client_id AS client_id, -- present when group_by includes client_id
a.id AS agency_id, -- present when group_by includes agency_id
COUNT(DISTINCT mtg.id) AS crm_positive_replies
FROM {schema}.jeff_meetings mtg
JOIN {schema}.agencies a ON a.id = mtg.agency_id -- excludes NULL agency_id
WHERE mtg.created_at >= :date_from
AND mtg.created_at < :date_to + INTERVAL '1 day'
-- client_id filter resolves through the agency mapping:
AND a.smartlead_client_id = ANY(:filter_client_ids) -- when client_id filter set
AND a.id = ANY(:filter_agency_ids) -- when agency_id filter set
GROUP BY 1, 2, 3;

-- crm_meetings_booked (bucketed by first stage event reaching BOOKED+)
WITH first_booked AS (
SELECT DISTINCT ON (meeting_id)
meeting_id, created_at AS booked_at
FROM {schema}.jeff_meeting_stage_events
WHERE new_stage IN ('BOOKED','NO_SHOW','PITCHED','DID_NOT_PITCH','DID_NOT_CLOSE','CLOSED')
ORDER BY meeting_id, created_at
)
SELECT
date_trunc('{granularity}', fb.booked_at)::date AS date,
a.smartlead_client_id AS client_id,
a.id AS agency_id,
COUNT(DISTINCT mtg.id) AS crm_meetings_booked
FROM first_booked fb
JOIN {schema}.jeff_meetings mtg ON mtg.id = fb.meeting_id
JOIN {schema}.agencies a ON a.id = mtg.agency_id
WHERE fb.booked_at >= :date_from
AND fb.booked_at < :date_to + INTERVAL '1 day'
GROUP BY 1, 2, 3;

If group_by contains a CRM-incompatible dimension (series, sub_series, domain, provider, sequence_number), the CRM CTE is skipped entirely and the CRM columns remain NULL on every row.

PostHog-only query (no SmartLead join)

When only PostHog metrics are requested (audit_views, unique_audit_visitors, cta_clicks, amazon_clicks, and their rates), a simpler query runs directly against PostHog events without the SmartLead join chain:

SELECT
date_trunc('{granularity}', pe.timestamp)::date AS date,
COUNT(*) FILTER (WHERE pe.event = 'audit_viewed') AS audit_views,
COUNT(DISTINCT pe.distinct_id) FILTER (WHERE pe.event = 'audit_viewed')
AS unique_audit_visitors,
COUNT(*) FILTER (WHERE pe.event = '$autocapture' AND (
pe.properties->>'$el_text' ILIKE '%get started%' OR
pe.properties->>'$el_text' ILIKE '%başlayın%'))
AS cta_clicks,
COUNT(*) FILTER (WHERE pe.event = '$autocapture' AND
pe.properties->>'$el_text' ILIKE '%view on amazon%')
AS amazon_clicks
FROM {schema}.jeff_posthog_events pe
JOIN {schema}.jeff_audits ja ON ja.id = pe.audit_id
WHERE pe.timestamp >= :date_from
AND pe.timestamp < :date_to + INTERVAL '1 day'
GROUP BY 1
ORDER BY 1

This shows ALL audit page activity regardless of campaign linkage.