Emails and funnels internals

7 min read

The designs' compile and fill, the auth emails, the funnel grammar, the audience query, the runner, identity and tracking, and chat prompts.

The compile (build/funnels.mjs) runs over the store, so the same code answers from a checkout on a machine and from the rows in an Edge Function — which is what lets a publish make this artefact where the deployment will never build again (cloud mode).

Designs: built in the admin, compiled at build, filled at send

Every build output written beside the functions — the compiled designs, funnels.json, strings.json, the catalog — goes through build/write-if-changed.mjs: the same bytes are never rewritten, so a served edge runtime (which watches that tree) is not restarted for nothing while npm test runs its checks.

Each email is a design, content/emails/<kind>.json — a Templatical template (@templatical/editor: drag-and-drop, JSON templates, MJML out) mounted with the site's colours and fonts as defaults. build/emails.mjs compiles every design (renderer → MJML → html) into supabase/functions/_shared/emails/compiled.json, committed beside the functions; the deploy runs it, the admin's save runs it, and platformkit test refuses a design that will not compile or whose sample leaves a tag unfilled. The subject is a line of the emails strings scope.

At send time _shared/email.ts fills the compiled html and the subject with the real data through emails/fill.js — the handful of Handlebars a design may use ({{name}}, {{#each lines}}, {{#if ships}}, {{else}}, {{#unless}}), plain JavaScript shared by Deno, the build and the admin — and emails/tags.js is the one list of what each email may say: the tags with samples, the pairs a section may wear as its display condition, one sample per email. Sending is nodemailer over SMTP_*; unset, nothing is sent and the action stands — an email never throws into a ledger write. The store's print words ride along in strings.json so a confirmation's line reads as the store's page did.

The auth emails

The confirmation (also the magic-link sign-in), recovery and recipient-bound Social publisher invitation designs are Supabase's own sends: their link is the Go token ({{ .ConfirmationURL }}, {{ .Email }}), which fill.js leaves exactly as written unless the data carries $go (a preview or a test send does; the real path never). At deploy build/auth-config.mjs sets them on the hosted project (mailer_templates_*_content and their subjects) along with the site url and the redirect list; for the local stack emails.mjs writes supabase/templates/<kind>.html and the [auth.email.template.*] block into config.toml between markers, read at supabase start. auth-config refuses a design whose button does not go to the token. Confirmation of the address is the app's own: profiles.email_verified_at is set by mark_email_verified() only for a token that came from that link or a provider (the JWT's amr says so).

The funnel grammar

The policy (content/emails/policy.md, normalizePolicy in _shared/emails/rules.js) carries the counts, the desk's clocks (support-wait, support-presence, support-keep-files) and recordings-keep — the days a coaching call's recording is kept, 0 forever, read by the session-recordings function's nightly retain — and session-reminders, the hours before a native session the person is reminded (a list, largest first; [24, 1] when absent, [] for none), read by the session-reminders function pg_cron calls every five minutes. The four session-* kinds are filled by _shared/session-mail.ts from the booking (name the recipient's first name, with the other side, when in each person's zone, where from _shared/session-mail.ts's placeOf, join, manage, the reminder's in) and sent with an invitation from _shared/ics.js (pure; build/ics.test.mjs pins it) as nodemailer's icalEvent — sendEmail's ical option; a filled subject is unescaped (unescapeText in fill.js), since it is plain text. content/funnels/<name>.md: frontmatter title, published, design, cooldown, audience (the rules), then a step per # heading with after, subject and the email's markdown — or channel: chat, when, an optional teaser and image, and a closing - list of choices (nested by two spaces, up to three deep). content/blasts/<name>.md: title, subject, design, audience, respect-cooldown, the markdown, and what the send wrote back (status: sent, sent_at, sent). content/emails/policy.md: daily, weekly, monthly, blasts-respect-cooldown, support-wait, support-presence, support-keep-files.

build/funnel-io.mjs is the grammar (read and written by the admin); build/funnels.mjs compiles all three into supabase/functions/_shared/emails/funnels.json — each step's and blast's markdown as email html with the styles inline (_shared/emails/markdown.js, under _shared because the providers function turns the terms into a letter with the same styles), each audience as the query's rules, a book's slug widened to its skus by the catalog, the chat steps apart as prompts (and, for the widget, into .velite/support-prompts.json; the policy's two desk clocks into .velite/desk-policy.json, which is how the staff's desk says what a support thread is owed without a second copy of the number; for the admin's funnel analytics, the funnels' shape — names, steps with their ids and delays, goals, audiences, never a body — into .velite/funnels-index.json) — run by the deploy, by every admin save, and by platformkit test, which refuses a step with no subject, an after that is not a span, a rule that is not one of RULE_KEYS, a design that is not compiled, or a goal that is not a path beginning with /.

The audience

One rule set, _shared/emails/rules.js (plain JavaScript shared by the build, the admin and the functions): the rules as written for a person in the markdown, turned by normalizeRules into the jsonb email_audience(rules) takes, which reads what the account knows — the last event or answer, the profile's age, the last paid purchase, the last session attended, the live tier, the areas taken and content read, the level the sheet placed them at (signals, the person's own results summarised by the client since the engine runs in the browser), a statement as answered, what was bought. Every rule must hold; a list is any of; never the opted out. email_audience_count is the admin's count; email_audience_me(rules) — over the predicate factored out as email_audience_match(uid, rules) — is what a signed-in person asks for a chat prompt. A criterion is added in three places: the SQL function, rules.js (parse and describe) and AudienceEditor.vue.

The runner

funnels-run: pg_cron (email-funnels, hourly) calls run_email_funnels(), which posts to the function over pg_net with the url and key in app_settings (the local stack's from the data script; a hosted project's written by the function itself on its first authorised call — the deploy makes one, dry). For every published funnel it takes the audience now, each person's place (funnel_members), the step due, the cooldowns (the policy's counts over email_log, the funnel's own gap) and a person still matching at that moment — sends through _shared/marketing.ts, logs, moves them on; who stops matching stays where they are. email-blast sends once to the audience less the held and those the log shows it went to. email-unsubscribe (?u=<id>&t=<hmac>&l=<log id>, the key derived from the service-role key; l is the send the link was in, added by tracked() since it alone knows the log row) sets profiles.email_opt_out_at and stamps email_log.unsubscribed_at on that send — only when the row is the person's own, so a swapped id marks nothing.

Once, ever — and what came back

Every step and every blast carries an id, a uuid written into its markdown on its first save (keyed; node build/funnels.mjs --key for a hand-written file) and never edited. email_log (user_id, email_id) is unique, and sendMarketing writes the row before it sends (sent_at stamped after; deleted if the relay refused), so a row the index refuses is a person who already had that email, whatever the funnel is now called. version beside the id is a hash of the words as sent.

Every link in the email and its open pixel is a row of email_links under a random ten-character token that is the whole url — EMAIL_LINK_BASE/<token>, …/<token>.gif for the pixel — so nothing in a link names a person, a row or a destination. email-track asks email_hit(token), which counts the hit, records the event on the log row and says which email the link was in, and answers the pixel or a redirect — to the destination with the email's UTM tags added (utm_source the funnel's or blast's name, utm_medium email, utm_campaign the step's or blast's title; tags the author wrote are kept), so the visit is the email's on the site's dashboard and never a referral from the functions' host; an unknown token goes to the site with nothing counted. email_stats serves sent · opened · clicked by email_id for the editors' lines; the funnel analytics page reads admin_funnels(days, goals) (the index: the window against the one before, a series a day, an item per funnel or blast the log or the members know, the prompts by funnel, reachable and opted out) and admin_funnel(kind, name, days, steps, rules, goal, tz) (one funnel or blast: the tiles, the series, the journey over the cohort that entered in the window — per email step sent · opened · clicked · goal · unsubscribed, the conversions credited to its sends, links by destination, versions, opens by hour × weekday at tz, the median time to open, the share on a phone; between steps the members waiting, held or no longer matching rules — email_audience_match per member, an opted-out one counted as left; a chat step's prompt numbers), both is_admin() or 42501, both in 60-emails.sql. The page passes the steps and rules in from .velite/funnels-index.json, since the database holds the log and knows nothing of a funnel's shape. Conversions are last touch by email: a paid purchase, a subscription created or a page view of the goal in the seven days after a send is credited to the last send the person clicked before it, else the last opened, else none — within the funnel on its page, across every funnel and blast (once) on the index. funnels-analytics.test.mjs recomputes the journey and the credit from the seed's rows and checks the unsubscribe link against the served function. Never key anything on a funnel's or blast's file name.

Chat prompts

A step with channel: chat is a popup the browser shows when every trigger on its when line holds — on-site 30s, on-page /store/**, page-time 10s, signed-in (or signed-in false), returning, cart — lib/support/when.js is the whole rule (pure; support.test.mjs pins it): parseWhen, serialiseWhen, holds(when, ctx), describeWhen, splitChoices. lib/support/prompts.js is a one-second ticker reading .velite/support-prompts.json (never on the admin, the preview, the share cards, the prerender, or for the staff); a prompt shown is remembered in the person's store (the support_prompt event carries the prompt's line, so the analytics can name a prompt since taken down), and the support function refuses a second thread on one (409). A funnel with no rules is for everyone; one with rules is for a matching signed-in reader through email_audience_me, never a guest. The step with when: default is the desk's resting prompt. Both the prompt and its launcher use the same mayInterrupt() check as ads. It combines reserved quiet paths with the site's registered feed, spaces and people routes. After resolving a default nudge or awaiting the audience, the ticker confirms the reader is still on the same interruptible route before claiming the corner, so navigation during an asynchronous lookup cannot surface a stale prompt.