The public webinar system lives at /webinars and /webinars/[slug]. It covers the full
lifecycle: upcoming registration, live join, and — for past webinars — an email-gated
on-demand recording hub with a premium Mux-first player.
MongoDB (webinars collection)
│ edited via apps/workspace /webinars module
▼
lib/services/webinar.service.ts
│ public projections strip server-only secrets
▼
app/webinars/ → directory listing
app/webinars/[slug]/ → public detail page
app/webinars/[slug]/join → live join hub (token-gated, status = live/upcoming)
app/webinars/[slug]/watch → recording hub (token-gated, status = past)
Two separate gated hubs exist — one for joining a live event, one for watching a recording — both following the same token-validation pattern.
Webinar type — types/webinar.ts#Key fields relevant to the recording flow:
| Field | Type | Notes |
|---|---|---|
mux_playback_id | string | null | Mux video ID — drives the premium player |
youtube_url | string | null | Public YouTube embed URL |
twitch_url | string | null | Twitch channel/video URL |
x_url | string | null | X (Twitter) broadcast URL |
linkedin_live_url | string | null | LinkedIn event page — rendered as "Also on" link, not embedded |
live_webinar_link | string | null | Server-only. Zoom join URL for live events. Never serialized to public components. |
recording_download_url | string | null | Server-only. Zoom HD direct-download link. Never serialized to public components. Served only through the tokenized proxy endpoint. |
status | WebinarStatus | Computed: draft / upcoming / live / past / cancelled |
registration_source | "join" | "recording" | null | Attribution on each webinar_registrations row |
PublicWebinar — the client-safe projection#PublicWebinar is a strict Omit of Webinar that drops every server-only secret before
the type ever reaches a component or API response:
// types/webinar.ts
export type PublicWebinar = Omit<
Webinar,
"status" | "created_by" | "live_webinar_link" | "recording_download_url"
> & { status: WebinarStatus };
recording_download_url is intentionally co-omitted with live_webinar_link. The raw Zoom
URL must never appear in page HTML, client props, or any public API response.
lib/schemas/webinar.ts#recording_download_url: z
.string()
.url("Must be a valid URL")
.nullable()
.optional()
.or(z.literal("")),
Mirrors the live_webinar_link validator exactly. Empty string is normalized to null in
the admin form's pre-save step.
lib/services/webinar.service.ts#| Method | Returns secrets? | Used by |
|---|---|---|
getPublishedWebinars() | No | Directory listing, nav |
getWebinarBySlug(slug) | No | Public detail page |
getWebinarBySlugWithPrivate(slug) | Yes | Recording request route, watch hub server component, download proxy |
Public queries exclude recording_download_url (and live_webinar_link) at the projection
level — never filtered client-side.
A webinar's start is stored as three independent fields, never as an absolute instant:
| Field | Example | Meaning |
|---|---|---|
date | 2026-07-29 | Calendar date, YYYY-MM-DD |
time | 20:00 | 24h wall clock, HH:MM |
timezone | Asia/Kolkata | IANA zone the wall clock is in |
Together these describe a wall-clock instant. Every consumer — the hero countdown, the
live/past status badge, ICS invites, "Add to Calendar" links, agenda status pills and join
gating — needs that collapsed into one absolute UTC timestamp. That conversion has exactly one
implementation: resolveWallClockMs in lib/utils/webinar-time.ts.
1. Read "<date>T<time>:00Z" as though the wall clock were already UTC → wallClockMs
(not the answer — just the reference point offsets are measured against)
2. Ask the zone what full date-time it shows at a candidate instant, re-encode
those parts via Date.UTC, and subtract: offset = wallClockAsUtc − candidate
3. resolved = wallClockMs − offset(wallClockMs) ← first pass
resolved = wallClockMs − offset(resolved) ← second pass, DST refinement
Two invariants make this correct, and both are load-bearing:
1 : 00 : 48 : 49 instead of 0 : 00 : 48 : 49,
and shipped ICS invites dated a day late. Every IST time at or past 18:30 was affected
(18:30 IST = 13:00 UTC, the point where the naive reading crosses local midnight); zones
behind UTC broke symmetrically for early-morning times.hourCycle: "h23" on the formatter. With only hour12: false, some ICU builds report
midnight as "24", which Date.UTC rolls into the next day — silently injecting another
24-hour error.resolveWallClockMs degrades to a UTC reading when the zone string can't be resolved, so
countdowns and status checks stay renderable rather than rendering NaN. Callers that must not
emit a silently-shifted time gate on isSupportedTimeZone first and omit the value instead:
| Caller | On unresolvable zone |
|---|---|
webinarStartMs / isWebinarLive / isWebinarPast | UTC reading (stays renderable) |
buildCalendarParams (ICS + calendar links) | returns null — row omitted |
parseWallClockInZone (join page schedule) | returns null — renders - |
parseAgendaMs (agenda status pills) | returns null — reads upcoming |
Note that ICU resolves legacy abbreviations, so "IST" is accepted and maps to Asia/Calcutta
(+05:30). "EST", however, maps to America/Panama — a fixed −05:00 with no DST — so prefer
full IANA identifiers when authoring.
| Export | Use for |
|---|---|
resolveWallClockMs | wall clock → absolute UTC ms (countdowns, comparisons, ICS) |
formatWallClock | any user-facing date/time label, in the event's own zone |
formatWallClockZoneLabel | the short zone token — "GMT+5:30", "EDT" |
compareWallClock | sorting webinars chronologically across mixed zones |
isSupportedTimeZone | gate for callers that must omit rather than emit a shifted time |
webinarStartMs / webinarEndMs / isWebinarLive / isWebinarPast | webinar-specific wrappers |
formatWallClock resolves the instant first, then formats with an explicit timeZone. Two
consequences worth knowing: the label reads the same for every visitor (it describes the event,
not the reader's clock), and the zone token reflects daylight time at the event, so a
January session correctly reads "EST" where a label taken at Date.now() would say "EDT".
new Date(`${date}T${time}`) — that reads the wall clock in whatever zone
the host happens to be in (the server's zone on RSC, the visitor's browser zone on the
client), ignoring webinar.timezone entirely.lib/utils/webinar-time.ts.These are not honour-system rules. Three layers enforce them:
no-restricted-syntax in eslint.config.mjs rejects both
new Date(`…T…`) and Date.parse(`…T…`) repo-wide, failing pre-commit, pre-push and
CI. lib/utils/webinar-time.ts is the single exempted file, because it is the conversion.
The selectors live in a shared WALL_CLOCK_RESTRICTIONS const and are spread into every
block that sets no-restricted-syntax — ESLint resolves that rule last-wins by name, so a
block redefining it would otherwise silently drop the guard.jest.config.ts pins Australia/Adelaide (+09:30,
with DST). Zone bugs are invisible under UTC, which is precisely what most CI boxes run: a
zone-less parse is correct under UTC, so a UTC suite goes green while the site ships a
day-shifted countdown. The half-hour offset also breaks anything assuming whole hours.
Override deliberately with TEST_TZ=<zone>; tests/lib/host-timezone.test.ts fails loudly
if the pin is ever dropped.tests/lib/webinar-time.test.ts pins the crossover boundary
(18:29 vs 18:30 IST), ±14/−11 extreme offsets, DST transition days, cross-zone ordering, and
the exact countdown breakdown from the original bug.For past webinars the detail page (/webinars/[slug]) shows a teaser — a thumbnail with
play affordance, or the YouTube embed if present (retained for SEO value). The full recording,
HD download, and premium player are only accessible from the gated watch hub.
/webinars/[slug] ← public (teaser + "Get the recording" form)
/webinars/[slug]/watch ← gated (full recording hub, requires token)
Visitors who have not requested the recording see the teaser and the form. There is no way
to reach WatchHubView without a valid token issued via the request flow.
A visitor submits the "Get the recording" form on the detail page. This is the full path from form submission to the gated hub.
Visitor on /webinars/[slug] (status === "past")
│ submits "Get the recording" form
│ fields: full_name, email, company (+ optional phone, country_code, tracking)
▼
POST /api/webinars/[slug]/recording/request
│
│ 1. Validate webinar exists AND computed status === "past" → 400 if not past
│ 2. create-or-reuse webinar_registrations row:
│ • getRegistrationByWebinarAndEmail(webinar_id, email)
│ → if found: reuse existing token (no duplicate row, no duplicate email)
│ → if not: createRegistration({ ..., registration_source: "recording" })
│ 3. silent subscription upsert:
│ upsertConfirmedSubscription({
│ email, full_name, company,
│ source: "webinar_recording", source_slug: slug
│ })
│ → "already subscribed" is swallowed silently, never surfaced to user
│ 4. send "Your recording is ready" email (buildRecordingReadyEmail)
│ → primary CTA: "Watch the recording →" (watchUrl)
│ → secondary: "View webinar details →" (webinarUrl)
│ → includes unsubscribe footer
│ 5. return { success: true, mode: "new" | "returning", watchUrl }
▼
Email delivers tokenized link → /webinars/[slug]/watch?t=<unsubscribe_token>
▼
Gated watch hub (see below)
Key behaviors:
mode: "returning" in the response signals this.upsertConfirmedSubscription
never fail the request.watchUrl so the client can offer "Open your recording now"
immediately; the emailed link remains the canonical, durable path."past", the endpoint returns 400 — the live registration
route (/api/webinars/register) continues to handle upcoming/live events and already
correctly rejects past webinars.lib/email/webinar-emails.ts#buildRecordingReadyEmail({ name, webinarTitle, watchUrl, webinarUrl, unsubscribeUrl })
Renders through the shared Email Design System (@matters/email):
the flyer button links to watchUrl, the flyer text link points to webinarUrl, and the
compliance footer (unsubscribe, privacy policy, postal address) is emitted on every send
together with a real text part.
/webinars/[slug]/watch?t=<token>#A force-dynamic server component that mirrors the live join hub pattern
(app/webinars/[slug]/join/page.tsx).
Every page load runs the following checks in order. Any failure redirects to the recovery gate.
1. token present in query string?
No → recovery gate (missing_token)
2. getRegistrationByToken(token)
null → recovery gate (invalid_token)
3. registration.webinar_slug === slug?
No → recovery gate (wrong_webinar)
4. getWebinarBySlugWithPrivate(slug)
null → recovery gate (webinar_not_found)
5. computed status === "past"?
draft / upcoming / live → redirect to /webinars/[slug] (recording not yet available)
cancelled → recovery gate (cancelled)
past → proceed ✓
6. render WatchHubView with:
• webinar (PublicWebinar fields only — no recording_download_url)
• registrant name + email
• hasDownload: boolean (true if recording_download_url is non-empty)
• downloadUrl: "/api/webinars/[slug]/recording/download?t=<token>"
• token (for client-side re-use)
The raw recording_download_url is never passed to the client. The server component
computes a boolean hasDownload and constructs the proxy URL; the client never sees the
Zoom link.
/webinars/[slug]/watch/invalid#Mirrors join/invalid. Renders a friendly message and offers a re-request form that posts
to POST /api/webinars/[slug]/recording/request. The page never reveals the recording
source or the registrant's identity to an unverified visitor.
WatchHubView layout#┌──────────────────────────────────────────────────────────┐
│ Matters.AI logo Watching as <name> · verified │
├──────────────────────────────────────────────────────────┤
│ ╭┄┄ ambient glow (Mux/direct only) ┄┄╮ │
│ ┌────────────────────────────────────────────────┐ │
│ │ HERO PLAYER (Mux | iframe) │ │
│ │ ⏯ ──────●────── 00:12 / 47:30 [cinema][PiP][⛶] │ │
│ └────────────────────────────────────────────────┘ │
│ Source: ● Mux (HD) ○ YouTube ○ Twitch ○ X │
│ [ ⬇ Download HD ] · Also on: LinkedIn event ↗ │
├──────────────────────────────────────────────────────────┤
│ <title> · why-attend · what-you'll-learn · agenda · speakers │
└──────────────────────────────────────────────────────────┘
The available tab list is built from whichever fields are present on the webinar record:
| Priority | Field | Label | Player type |
|---|---|---|---|
| 1 (default) | mux_playback_id | Mux (HD) | MuxPlayerOfficial / VideoPlayerWithAutoPiP |
| 2 | youtube_url | YouTube | Responsive iframe |
| 3 | twitch_url | Twitch | Responsive iframe |
| 4 | x_url | X | Responsive iframe |
| — | linkedin_live_url | "Also on: LinkedIn event ↗" | External link only (not a tab) |
The default active tab is always the first available, preferring Mux. If only YouTube is present, YouTube is the hero and ambient light is automatically disabled (iframe tabs block cross-origin pixel reads).
Reuses VideoPlayerWithAutoPiP / MuxPlayerOfficial:
mux_playback_id| Mode | Description |
|---|---|
| In-page (default) | Player sits in the normal page layout |
| Cinema | Page dims to near-black; player widens to max width; ambient glow intensifies. Esc or toggle exits. |
| PiP | Native Picture-in-Picture. Auto-triggers when player scrolls out of view. Desktop only. |
| Fullscreen | Native browser fullscreen |
Cinema and PiP are only available on the Mux tab. Iframe tabs (YouTube, Twitch, X) support native fullscreen only.
A <canvas> element sits behind the player. On each animation frame (throttled), it samples
the playing <video> element's pixels (downscaled), blurs and scales the result to produce a
color bloom behind the player frame.
requestAnimationFrame, paused when the video is paused or off-screenprefers-reduced-motion<video> element is accessed via the player ref / mediaElement
property from @mux/mux-player-react. Degrades gracefully if unavailable (ambient light
silently off; everything else works normally)GET /api/webinars/[slug]/recording/download?t=<token>#The "Download HD" button on the watch hub hits this endpoint. It re-validates the token on every request and 307-redirects to the Zoom URL. The raw URL is never in the page HTML.
1. getRegistrationByToken(t)
null, or registration.webinar_slug !== slug → 403
2. getWebinarBySlugWithPrivate(slug)
null, or status !== "past" → 403
3. recording_download_url empty / null → 403
4. success → 307 redirect to recording_download_url
(Zoom serves the bytes directly)
All failure paths return 403 — never 404. This prevents existence leaks: a caller with a
bad token cannot determine whether a download URL exists.
The 307 redirect is the default. There is a clear extension point to switch to a full
streaming proxy (server-side fetch of the Zoom file, piped through the response) if hiding
the Zoom domain becomes a requirement — the interface is identical to callers.
app/webinars/[slug]/client-page.tsx#For past webinars the page no longer renders the full recording ungated. Instead it shows:
youtube_url is set (YouTube embed is kept for SEO value even in gated mode).SubscribeForm past variant, now posting to
POST /api/webinars/[slug]/recording/request instead of the old register route.The old live registration route (app/api/webinars/register/route.ts) is unchanged and
continues to reject past webinars with 400. The recording path is a separate endpoint with
different semantics.
Every webinar email — registration confirmation, reminder, calendar invite, workspace-admin
custom send, bulk invite — draws from the same small set of building blocks in
lib/email/webinar-notify.ts, lib/email/webinar-emails.ts and lib/email/webinar-ics.ts.
This section covers the parts that are easy to get wrong, and the reasoning a future change
must preserve.
Every join-oriented email (registration confirmation, reminder) renders two calls to
action on the flyer, built once by the shared joinCtas helper in lib/email/webinar-emails.ts:
/webinars/[slug]/join?t=<token>. This is JoinTargets.joinPageUrl from
resolveJoinTargets (lib/email/webinar-notify.ts).JoinTargets.directUrl), rendered as the flyer's underlined text link directly under the
button, not a hidden footnote. It is omitted entirely — not shown as a disabled or
empty link — when the webinar has no meeting link configured.The Zoom button is a peer rather than hidden because the two audiences are not interchangeable: the website join page is richer (lists every platform, survives a Zoom outage, carries analytics) but depends on the site being reachable and the visitor tolerating an extra hop. Someone on a flaky connection, inside a locked-down corporate network that blocks the marketing domain, or simply in a hurry needs a way in that does not depend on the website at all. Demoting the Zoom link to a footnote would strand exactly that person at the worst possible moment — two minutes before the session starts.
resolveJoinTargets replaced an older resolveJoinLink that returned a single "best" URL and,
as a result, gave the two real audiences (self-registrants and admin-invited subscribers)
inverted experiences: registrants got the gated join page with no direct escape hatch,
while invited subscribers got the raw Zoom link as their only option and no join page at all.
Every recipient now holds a join token (see below), so every recipient gets both links.
The plaintext part of every email is generated by @matters/email from the same content model
as the HTML (renderEmailText), so it mirrors both links plus the unsubscribe URL by
construction. This is not a formality — a thin plaintext part next to a heavy HTML part
raises spam-filter scoring, and a text-only reader with no unsubscribe link has no way out
except reporting the message as spam, which is worse for deliverability than an unsubscribe.
The webinar's iCalendar LOCATION field and the Google/Outlook/Yahoo "Add to Calendar" links
always carry the direct meeting link (falling back to the public webinar page only when no
meeting link exists) — someone opening a calendar event 30 seconds before the session wants to
join, not read a landing page.
registration_source: "workspace-invite"#ensureInviteRegistration (lib/services/webinar.service.ts) mints a join token for a
subscriber an admin is inviting to a webinar they never registered for. It is a sibling of
createRegistration, not a flag on it: createRegistration throws when registration is closed
or the seat limit is reached, which is correct for a public signup and wrong for an admin
directly inviting someone. The row it creates is stamped registration_source: "workspace-invite".
It deliberately does not increment registrations_count. An invite is not a signup:
registrations_count as social proof ("312 people
registered"). Counting invites would misreport that number for people who never opted in.seats_limit decrement remaining capacity from the same counter. Letting
invites consume seats would let an admin lock real registrants out of a session by inviting
more people than there is room for.The call is idempotent via an upsert on the { webinar_id, email } unique index, so re-sending
an invite (or two concurrent invite sends) never creates a duplicate row or a lost write.
REQUEST vs PUBLISH — the iCalendar METHOD#buildWebinarIcs (lib/email/webinar-ics.ts) emits one of two RFC 5545 METHOD values,
chosen per recipient by deriveInvitationMode (lib/services/webinar.service.ts):
registration_source | METHOD | ATTENDEE line | Recipient experience |
|---|---|---|---|
"join", "recording", or a missing/null value | REQUEST | Present, RSVP-able | Calendar client offers Accept / Decline / Tentative |
"workspace-invite" | PUBLISH | Absent | Calendar client offers a one-tap "add to calendar"; nothing is booked automatically |
This is the single largest reputation decision in the whole email system, and the one most likely to get "simplified" back to a single method by a future contributor. The two methods are not interchangeable:
METHOD:REQUEST tells the recipient's calendar client "someone is asking you to attend this
meeting — here is an RSVP." Sending it to someone who never registered auto-adds an event to
their calendar without them taking any action, and books them as NEEDS-ACTION on a
meeting they never asked for. Google and Microsoft treat unsolicited REQUEST invites as the
textbook calendar-spam pattern and penalise the sending domain's reputation for it.METHOD:PUBLISH is informational: the .ics still attaches, and clients still show a one-tap
"add to calendar" affordance, but nothing is booked on the recipient's behalf and no RSVP is
demanded of someone who did not sign up.deriveInvitationMode is intentionally conservative about who gets PUBLISH: only a row whose
registration_source is explicitly "workspace-invite" gets it. Everything else — "join",
"recording", and a missing/null source (a legacy row predating this field, or any future
source value not yet accounted for) — is treated as a real registration and gets the RSVP-able
REQUEST. Defaulting an unrecognized source to PUBLISH would silently downgrade a genuine
registrant's calendar experience with no signal anything went wrong; defaulting to REQUEST
fails toward the behavior that shipped before invite grants existed.
The .ics also carries a caller-supplied SEQUENCE (so a corrected time or link can supersede
an already-accepted event without creating a duplicate), a -15min VALARM, and
X-MICROSOFT-CDO-BUSYSTATUS / X-MICROSOFT-DISALLOW-COUNTER for Outlook compatibility.
webinar_suppressions (lib/services/webinar-suppression.service.ts) is the list of addresses
that must never be emailed again. It is fed exclusively by the SES SNS feedback loop at
POST /api/webhooks/ses-notifications (app/api/webhooks/ses-notifications/route.ts), which
verifies the SNS message signature per-message (no shared secret) before acting on it. See
lib/email/SES_SETUP.md in the root of the repo for the full SNS wiring runbook.
How an address lands on the list:
| Event | Behavior |
|---|---|
| Permanent (hard) bounce | Suppressed immediately — the mailbox does not exist |
| Complaint (any) | Suppressed immediately — the recipient marked the message as spam |
| Transient (soft) bounce | Counted; suppressed only on the third transient bounce for the same address |
The transient-vs-permanent split exists so a full mailbox or a momentary provider outage does not cost a subscriber permanently — one soft failure is noise, three is a pattern. Permanent bounces and complaints get no such grace, because both are unambiguous signals that continuing to send is actively harmful to sender reputation.
resolveRecipients (lib/email/webinar-notify.ts) filters every send against this list —
suppressed addresses are dropped unconditionally before a send ever happens, and this filtering
is not skippable by any caller. It is a reputation guard, not a per-recipient preference.
Bulk webinar emails (buildBulkSendOptions, lib/email/webinar-notify.ts) emit:
List-Unsubscribe: <https://matters.ai/api/webinars/unsubscribe?token=...> (HTTPS only —
deliberately no mailto:, since advertising a mailto: address nobody processes is a worse
compliance position than advertising none)List-Unsubscribe-Post: List-Unsubscribe=One-ClickList-Id: Matters AI Webinars <webinars.matters.ai>Reply-To: webinars@mail.matters.aiEvery path that mails many people spreads ...buildBulkSendOptions(token) — the three
subscriber send routes, sendCustomBatch (immediate and scheduled), the bulk-invite blast,
and the draft → published auto-invite in
apps/workspace/app/api/workspace/webinars/[id]/route.ts. The last two were missing it: they
called sendEmail({ to, subject, html }) bare, so they carried no List-Unsubscribe, no
List-Id, no Reply-To, no plaintext part, and — because stream defaults to
"transactional" — landed on the transactional configuration set, diluting the exact metric
the stream split exists to isolate. The auto-invite additionally built its unsubscribe URL as
?email=<addr> while the route only ever reads token, so every one of those links was dead.
Both now also run through resolveRecipients, which is what makes suppression filtering and a
resolvable token apply.
The auto-invite is also sequential and paced by SEND_DELAY_MS. It previously ran
Promise.allSettled over chunks of 50 — 50 concurrent unpooled SMTP connections firing as fast
as the network allowed, which is precisely the burst pattern that gets a sending domain
throttled. Wall-clock duration is explicitly not a goal anywhere in this system.
buildBulkInviteEmail and buildAutoInviteEmail now return a real text part too. Without one,
sendEmail substitutes the single line "This email requires an HTML-enabled client." against a
heavy HTML body — the text/HTML ratio spam filters score against.
POST /api/webinars/unsubscribe (app/api/webinars/unsubscribe/route.ts) is the one-click
target named by List-Unsubscribe-Post. It intentionally has no same-origin check, CSRF
token, or session requirement — Gmail's and Yahoo's mail servers call this endpoint directly,
server-to-server, on behalf of a user who clicked "Unsubscribe" inside their own mail client.
There is no browser, no cookie, and no origin header a same-origin check could validate; the
token in the request body is the entire authentication. The route is listed in EXEMPT_PATHS
(lib/security/rate-limit.config.ts) for the same reason — a single send can produce a burst of
one-click POSTs from a provider's own infrastructure that legitimately share an IP/UA, and a 429
on this endpoint is read by providers as "this List-Unsubscribe-Post target is broken."
The route content-negotiates on Accept: a request that prefers text/html (a real person
following the styled confirmation page's "Confirm unsubscribe" button) gets a 303 redirect
back to that page; anything else (Gmail's and Yahoo's machine callers, which do not send
Accept: text/html) gets a plain 200 text/plain body. 303 rather than 302/307 so the
browser's follow-up request is a GET — the visitor is never left sitting on a page a refresh
could resubmit.
GET no longer mutates. It used to unsubscribe on load — the token in the query string was
enough to opt someone out with no confirmation step. That broke as soon as anything besides the
intended human clicked the link: Outlook Safe Links, Proofpoint, and Barracuda all pre-fetch
every URL in an email to scan it for phishing before delivery, and Gmail's own link-prefetcher
does the same for image/link previews. Every one of those scanners was silently unsubscribing
real subscribers who never opened, let alone clicked, the email. GET now only renders a
confirmation page (/webinars/unsubscribe?status=confirm) with a button that POSTs — the
mutation happens only on an explicit action a scanner cannot perform.
The token is resolved against BOTH collections, and the opt-out is written to both.
handleUnsubscribe (lib/services/webinar.service.ts) looks the token up in
webinar_registrations and webinar_subscribers, then sets incremental_opt_in: false on
every row in either collection matching the resolved email. This is not defensive coding — it is
required for the header to work at all. getAggregatedSubscribers gives the
webinar_subscribers token absolute priority when it builds a recipient, so for anyone who
opted in via the subscribe form (the bulk audience) the token in the footer link and the
List-Unsubscribe header exists only in webinar_subscribers. A registrations-only lookup
missed all of them: Gmail POSTed, the route answered 200 "Already unsubscribed", Gmail reported
success, and nothing changed in the database — so the next send reached them and they pressed
"Report spam". An advertised List-Unsubscribe-Post that reports success and does nothing is
worse than no header, because it converts unsubscribes into complaints, the signal with a 0.1%
SES account-review threshold.
The function is idempotent: neither the token lookup nor the email filter depends on the
current flag value, so a repeat call still matches rows and still returns true. That keeps the
route's "Unsubscribed" vs "Already unsubscribed" answer honest instead of reporting a
no-op as a success.
INLINE_SEND_LIMIT = 300#All four inline send routes (custom send, reminder, calendar invite, bulk invite) refuse with a
400 naming the resolved recipient count whenever a selection resolves to more than
INLINE_SEND_LIMIT (lib/email/webinar-notify.ts), currently 300. The response is built by
the shared buildOverLimitResponse helper so the message and shape can't drift between routes.
The limit exists because inline sends run sequentially, inside a single request, against a
maxDuration = 300 (seconds) ceiling. It is a timeout constraint, not a policy choice, which is
why routes reject rather than truncate: resolveRecipients itself no longer caps its result
(it used to silently cut a selection down to 500 and report { total: 500, failed: 0 } — a
clean-looking success for a send that dropped 100–200 people). A loud, actionable 400 naming
the real count replaces that silent data loss.
The honest arithmetic is tighter than 300 × 500ms. Each iteration pays an
ensureInviteRegistration Mongo upsert, one sendMail, and SEND_DELAY_MS — realistically
~350ms same-region and up to ~1050ms cross-region, i.e. 105–315s at 300 recipients against a
300s budget. Two things close that gap:
pool: true, maxConnections: 1 in lib/email/index.ts).
Unpooled, every sendMail paid a fresh TCP + TLS + EHLO + AUTH handshake — 6–8 round trips,
most of the per-recipient cost. Pooling also helps deliverability: SES treats one long-lived
authenticated connection as more normal than 300 consecutive handshakes. maxConnections is 1
on purpose — sends are sequential and paced by design, so a second connection buys nothing.INLINE_SEND_BUDGET_MS (240s, mirroring the scheduled-email
cron's own budget) and reports the untouched remainder as unsent. This matters because
auditMutation and recordEmailHistory both run after the loop: a hard maxDuration kill
at recipient 250 would deliver 250 emails and record nothing — invisible partial delivery,
the same failure class as the original { total: 500, failed: 0 }. A partial send that is
recorded is recoverable; one that times out is not.Larger sends must be split into batches under 300 recipients until a resumable send-job queue lands — that capability is planned but not part of this system as documented here.
POST /api/workspace/webinars/subscribers/scheduled caps emails at INLINE_SEND_LIMIT
(imported, never duplicated), and the drain cron
(app/api/cron/webinar-scheduled-emails/route.ts) refuses any job that still resolves above it —
failing the row once with finalizeScheduledEmail({ status: "failed" }) rather than retrying.
Both halves are needed. The cron drains a claimed job by calling sendCustomBatch inline, so
an oversized job is not merely slow, it is undeliverable: maxDuration kills the function
mid-loop, neither finalizeScheduledEmail nor the catch's requeueScheduledEmail runs,
locked_at goes stale, and claimDueScheduledEmail re-claims the job on a later tick and
restarts from recipient 0. With MAX_ATTEMPTS = 3 the first few hundred addresses receive the
same message up to three times — and duplicate identical bulk mail drives complaints directly,
which is strictly worse for reputation than refusing the schedule.
sendEmail (lib/email/index.ts) takes a stream: "bulk" | "transactional" option (default
"transactional"). The stream selects which SES configuration set the message is attributed to:
| Env var | Stream | Used for |
|---|---|---|
AWS_SES_CONFIGURATION_SET_BULK | "bulk" | Webinar blasts, invites, reminders |
AWS_SES_CONFIGURATION_SET_TRANSACTIONAL | "transactional" | Registration confirmations, join links |
Both fall back to AWS_SES_CONFIGURATION_SET when unset. Keeping the streams separate matters
because sharing one configuration set means a bad blast and the confirmation emails people are
actively waiting for land in the same bounce/complaint metrics — you lose the ability to tell
which stream caused a reputation dip, and any future automated guard reading those metrics
would measure a number diluted by transactional volume. See lib/email/SES_SETUP.md in the root
of the repo for the full SES/SNS runbook, including why this header must be set explicitly on
the SMTP send path.
| Concern | Mitigation |
|---|---|
| Raw Zoom URL leaking | recording_download_url omitted from PublicWebinar; never passed to client components; served only through the tokenized proxy |
| Token guessability | crypto.randomUUID() — same unguessable token as the live join flow |
| Gated content caching | Watch hub route is force-dynamic; no ISR or CDN caching of personalized content |
| Download abuse | Token re-validated on every download request; 403 on any failure |
| Existence leaks | Download proxy always returns 403 (not 404) for any failure condition |
| Unverified access to registrant identity | Recovery gate never reveals recording source or registrant name to unverified visitors |
| Logging | logger throughout (no console.log); custom exceptions (ValidationError, etc.) from @/lib/errors/exceptions |
| Bot / abuse on the request endpoint | Risk-based step-up CAPTCHA: invisible reCAPTCHA v3 runs on every submit; on a low score the endpoint returns { challengeRequired: true } (before any DB/email side-effect) and the form reveals a manual v2 checkbox. Verified via lib/security/recaptcha.ts (verifyRecaptchaToken → verifyRecaptchaV2Token). Gracefully degrades to invisible-v3-only when RECAPTCHA_V2_SECRET_KEY / NEXT_PUBLIC_RECAPTCHA_V2_SITE_KEY are unset. |
After a webinar has ended, the recording becomes available in two steps in the workspace webinar form:
mux_playback_id or youtube_url)#In the workspace webinar edit page, under Video / Playback:
abcXYZ123). Drives the
premium player with ambient light, cinema mode, PiP, and scrub storyboard.https://www.youtube.com/watch?v=... or embed URL. Used as
fallback if no Mux ID is set, and retained on the public detail page for SEO.You can set both — Mux will be the default tab in the watch hub, YouTube will be the second.
recording_download_url)#In the workspace webinar edit page, under Publish Settings (grouped with the existing video/link fields), fill in Recording download (HD):
null on save.Save the form. The recording hub is now live. Any visitor who submits the "Get the recording"
form on the detail page will receive an email with a tokenized link to
/webinars/[slug]/watch?t=<token>.
| File | Purpose |
|---|---|
app/webinars/page.tsx | Webinar directory listing |
app/webinars/[slug]/page.tsx | Public detail page (server) |
app/webinars/[slug]/client-page.tsx | Public detail page (client) — teaser + "Get the recording" form for past webinars |
app/webinars/[slug]/components/SubscribeForm.tsx | Registration / recording-request form |
app/webinars/[slug]/join/page.tsx | Live join hub (pattern reference for watch hub) |
app/webinars/[slug]/join/invalid/page.tsx | Live join recovery gate (pattern reference) |
app/webinars/[slug]/watch/page.tsx | Gated watch hub (server component, force-dynamic) |
app/webinars/[slug]/watch/invalid/page.tsx | Recording recovery gate |
app/webinars/[slug]/watch/WatchHubView.tsx | Client component — source tabs, hero player, view modes, ambient light |
app/api/webinars/[slug]/recording/request/route.ts | POST — recording request, token creation, email |
app/api/webinars/[slug]/recording/download/route.ts | GET — token-validated, proxied 307 redirect to Zoom |
app/api/webinars/register/route.ts | Existing live registration route (unchanged; still rejects past webinars) |
app/api/webinars/unsubscribe/route.ts | POST — one-click unsubscribe target; GET — non-mutating redirect to confirmation page |
app/webinars/unsubscribe/page.tsx | Styled unsubscribe confirmation page (?status=confirm/success/not_found/error) |
app/api/webhooks/ses-notifications/route.ts | SNS webhook — signature-verified bounce/complaint/delivery events → suppression list |
| File | Purpose |
|---|---|
types/webinar.ts | Webinar, PublicWebinar, WebinarRegistration types |
lib/schemas/webinar.ts | Zod schemas including recording_download_url |
lib/services/webinar.service.ts | getWebinarBySlugWithPrivate, getRegistrationByToken, getRegistrationByWebinarAndEmail, createRegistration, ensureInviteRegistration, deriveInvitationMode |
lib/services/webinar-suppression.service.ts | Suppression list: recordSuppression, recordTransientBounce, getSuppressedEmails, isSuppressed |
lib/utils/webinar-time.ts | Single source for wall-clock → UTC: resolveWallClockMs, isSupportedTimeZone, webinarStartMs, webinarEndMs, isWebinarLive, isWebinarPast |
lib/email/webinar-emails.ts | buildRecordingReadyEmail + dual-CTA join/reminder builders + plaintext parts |
lib/email/webinar-notify.ts | resolveRecipients, resolveJoinTargets, buildBulkSendOptions, INLINE_SEND_LIMIT, buildOverLimitResponse |
lib/email/webinar-ics.ts | buildWebinarIcs — RFC 5545 VCALENDAR generator, REQUEST/PUBLISH method selection |
lib/email/index.ts | sendEmail, resolveConfigurationSet, EmailStream ("bulk" | "transactional") |
components/ui/VideoPlayerWithAutoPiP.tsx | Reused in watch hub for Mux tab |
components/ui/mux-player-official.tsx | Mux player primitive |
| File | Purpose |
|---|---|
apps/workspace/app/(workspace)/webinars/components/WebinarFormPage.tsx | Webinar edit form — includes the recording_download_url field under Publish Settings |
| Need | What is reused |
|---|---|
| Token validation | getRegistrationByToken, getRegistrationByWebinarAndEmail, getWebinarBySlugWithPrivate, createRegistration |
| Start-time resolution | resolveWallClockMs / isSupportedTimeZone (lib/utils/webinar-time.ts) — countdown, status, ICS, agenda, join |
| Page/flow pattern | join/page.tsx, join/invalid recovery gate |
| Players | VideoPlayerWithAutoPiP, MuxPlayerOfficial |
sendEmail, buildRegistrationConfirmationEmail (template pattern) | |
| Subscription | upsertConfirmedSubscription (silent upsert) |
| Detail content sections | Existing components in app/webinars/[slug]/ (why-attend, agenda, speakers) |
| Form | SubscribeForm past variant |
| Join-link resolution | resolveJoinTargets (lib/email/webinar-notify.ts) — shared by registration confirmation, reminder, calendar-invite and custom-send emails |
| Suppression filtering | resolveRecipients (lib/email/webinar-notify.ts) filters against getSuppressedEmails on every send |
/videos gallery (different surface; Mux playback pattern reference)