Segments
ts
import type { SegmentDefinition, SegmentFilter } from 'mailery'Used by broadcasts and (future) segment-entry flow triggers.
SegmentDefinition
ts
interface SegmentDefinition {
filters: SegmentFilter[] // AND-ed together at the top level
}SegmentFilter
ts
type SegmentFilter =
// Host-side (evaluated via the adapter)
| { kind: 'fieldEquals'; field: string; value: unknown }
| { kind: 'fieldIn'; field: string; values: unknown[] }
| { kind: 'fieldExists'; field: string }
| { kind: 'hasTag'; tag: string }
| { kind: 'notHasTag'; tag: string }
// Mailer-side (evaluated against mailer collections)
| { kind: 'subscriptionStatus'; equals: 'subscribed' | 'unsubscribed' | 'pending_doi' | 'bounced' | 'complained' }
| { kind: 'firedEvent'; eventName: string; withinDays?: number }
| { kind: 'notFiredEvent'; eventName: string; withinDays?: number }
| { kind: 'subscribedAfter'; date: Date }
| { kind: 'subscribedBefore'; date: Date }
| { kind: 'opened'; templateSlug?: string; withinDays?: number }
| { kind: 'notOpened'; templateSlug?: string; withinDays?: number }
// Composition
| { kind: 'any'; filters: SegmentFilter[] } // OR
| { kind: 'not'; filter: SegmentFilter }Evaluation order
Two-pass:
- Stage A — host filter: mailery calls
adapter.query()with at most one condition per adapter slot, taken from the top level (the firsthasTag, one condition per field). The adapter returns a cursor. - Stage B — mailer post-filter: every other filter runs over each streamed page with one batched lookup per distinct filter: mailer-side kinds (
subscriptionStatus,firedEvent,opened,subscribedAfter, …), extra host-side filters, and everything nested inany/not. Host-side kinds evaluated here read theContactprojection (tags,fieldsby dotted path). - Suppression check: recipients suppressed for the template's kind are skipped at dispatch, and every send is re-checked at send time.
POST /broadcasts/:slug/count on the agent API reports the count after each stage, and the admin composer's count is the final one.
Segments are validated when saved and strictly when scheduled: values are primitives, field names may not start with $, and an unknown kind is refused — never evaluated as "matches everyone".
Example
"Subscribed Pro users who haven't fired the 'Cancelled' event in the last 90 days, excluding anyone with the 'do-not-email' tag":
ts
const segment: SegmentDefinition = {
filters: [
{ kind: 'subscriptionStatus', equals: 'subscribed' },
{ kind: 'fieldEquals', field: 'tier', value: 'Pro' },
{ kind: 'notHasTag', tag: 'do-not-email' },
{ kind: 'notFiredEvent', eventName: 'Cancelled', withinDays: 90 },
],
}OR / NOT composition
filters: [...] at the top level is AND. For OR within a slot, use kind: 'any':
ts
{
filters: [
{ kind: 'subscriptionStatus', equals: 'subscribed' },
{
kind: 'any',
filters: [
{ kind: 'hasTag', tag: 'beta' },
{ kind: 'fieldEquals', field: 'tier', value: 'Pro' },
],
},
],
}For NOT, wrap a sub-filter:
ts
{ filters: [{ kind: 'not', filter: { kind: 'hasTag', tag: 'banned' } }] }Performance
- Host-side filters first: design the adapter to narrow aggressively at Stage A. A
fieldEqualson an indexed Mongo field is cheap; afiredEventpost-filter on 100K contacts is not. withinDaysfor event filters: bound the lookup window. Without it, mailery scans all ofmailer_eventsfor that contact.opened/notOpenedare heavy: they hitmailer_sendsper contact. Use sparingly; prefer real product events for engagement.
Where segments are used
- Broadcasts:
mailer_broadcasts.segmentDefinitionis evaluated at dispatch time. - Live count in admin UI: the broadcast composer calls
POST /admin/mailer/api/broadcasts/:slug/segment/countwith the in-progress definition; mailery returns 3-stage counts (host, mailer-post-filter, after-suppression). - Future: segment-entry flow triggers (
trigger.type: 'segment_enter') — periodic re-evaluation to enter newly-matching contacts.