Wednesday, April 29, 2026

Comms Arc 1: Unified Sender + Branded Template Shell

Volleyball Elite Academy development update
Volleyball Elite Academy
Comms Arc 1: Unified Sender + Branded Template Shell

Comms Arc 1: Unified Sender + Branded Template Shell

Volleyball Elite Academy — Development Update • April 28, 2026

Comms Arc 1: Unified Sender + Branded Template Shell

What & Why

Today the app sends from two different addresses ( for ~18 templates and for one), every template has its own hand-rolled HTML header/footer/colors, and the brand string varies between "Canadian Elite Volleyball Academy" and other phrasings. Footers tell families "Please do not reply." That blocks every part of the bigger plan: replies can't be threaded if there's no canonical sender, families won't reply if we tell them not to, and the templates can't be made to "look the same" without a shared layout. This task collapses all of that into one consistent, branded outbound surface: every email goes from with a matching , every email is wrapped in one shared layout component, and every footer invites a reply because the inbox work in the next arc will route those replies into the app.

Done looks like

  • Every email the app sends — every transactional, every reminder, every digest, every admin-bulk send, the SOF feedback flow, the new pipeline test, all 19+ templates — uses one canonical sender: with .
  • A single shared layout helper renders every email's header (logo + brand bar), body slot, and footer (academy name, contact line, "Reply to this email — we read every one", unsubscribe link where the email type warrants one). Templates supply only their unique body content; nothing template-side controls header/footer/branding/colors.
  • Visual brand is consistent across templates: same color palette, same typography, same spacing rhythm, same call-to-action button style. Pulled from the existing app palette so emails match the in-app look.
  • Brand display name in templates standardized to "Volleyball Elite Academy" (matches local usage and the domain). The legacy sender is retired from code defaults; any code path that previously fell back to that string now falls back to the new canonical sender.
  • "This is an automated message. Please do not reply" footer copy is removed everywhere; replaced with "Reply to this email and a member of our team will get back to you" (or the unsubscribe-applicable variant for marketing-class emails).
  • The Communication Hub's "from address" field for admin bulk sends defaults to the new canonical sender; the field is still editable but the placeholder/help text reflects the new default.
  • A short rendering test renders each major template through the shared layout and asserts the header brand string, sender display name, reply-to, and footer copy are all the new canonical values — so a future hand-edit to a template can't silently bypass the layout.

Out of scope

  • Setting up inbound email or building the Conversations view (Arc 2).
  • AI-drafted replies (Arc 3).
  • Migrating any existing audience off — there's no subscriber list tied to that address; it was only a sender identity.
  • Building an unsubscribe management UI. Existing unsubscribe links continue to work; no new flow.
  • Changing scheduler timing, recipient logic, or the email-log schema.
  • Touching the domain DNS (currently points at Corsizio per the user). The retirement is purely in code defaults.

Pre-flight (admin action, not code)

  • The user verifies in the Resend dashboard with the DNS records Resend provides (SPF, DKIM x3, return-path MX, recommended DMARC). Code in this task ships independently of that verification — the From change is safe to merge before DNS is green; sends will still succeed but with weaker authentication until DNS verification completes.

Steps

1. Brand + sender constants — Introduce a single source-of-truth module exporting the canonical from-address, reply-to, brand display name, brand short name, contact email, and brand color tokens. All future code reads these constants; no string literals. 2. Shared email layout helper — A server-side function that takes a shape and returns a complete HTML document with the canonical header (logo, brand bar), body slot, and footer (academy address line, "reply to this email" invitation, unsubscribe slot when ). Use inline styles (email-client compatible) keyed off the brand color tokens. Provide a parallel plain-text helper that produces the matching plain-text version so multipart emails stay aligned. 3. Refactor every template through the layout — Walk every inlined HTML block in (and any other file that builds an outbound HTML body), extract just the unique body content, and route it through the shared layout. Strip the now-duplicate header/footer/wrapper HTML from each template. Same pass for plain-text bodies via the plain-text helper. 4. Sender + reply-to sweep — Replace every literal that defaults to the legacy sender with the new canonical sender constant. Add (the canonical reply-to constant) to every Resend send call site. The admin bulk-send field's default now reads the constant. 5. Footer copy sweep — Remove every "this is an automated message, please do not reply" string. Replace with the appropriate footer variant from the layout helper. The SOF feedback flow's existing reply-button URL keeps working but the surrounding "do not reply" copy goes. 6. Brand string sweep — Standardize the visible brand name in template body content to "Volleyball Elite Academy". Display name in From headers and layout header uses the brand-name constant so a future rename is a one-line change. 7. Tests — A rendering test that, for each of the 19 templates, asserts: layout wraps the body, From string equals the canonical sender, Reply-To equals the canonical reply-to, footer contains the new "reply to this email" line and not the old "do not reply" line, body still contains the template's unique signal content. Plus a guard test that grep-asserts no source file under contains the legacy sender literal or the legacy footer copy outside of allowed locations (e.g. comments documenting the migration). 8. Docs — Update with a "Email branding" subsection: where the constants live, where the layout helper lives, the rule that templates must go through the layout, and a note that DNS verification of happens in the Resend dashboard.

Architectural constraints:

  • The layout helper is the only producer of full email HTML/text. Templates produce body fragments only. Enforce this with the grep guard test.
  • Inline styles only. No blocks. No external CSS. Email clients (Gmail, Outlook, iOS Mail) don't reliably honor either.
  • Do not introduce a new email-rendering library or templating engine. Keep it plain TypeScript template literals routed through the layout helper — matches the existing codebase, zero new dependencies.
  • Do not change the email-log schema, recipient resolution, scheduling, or webhook handling. This task is presentation + sender identity only.

Relevant files

- - - - - -

Volleyball Elite Academy

Reply to this email — we read every reply.

You received this because you have an account with Volleyball Elite Academy.

elitevolleyball.training

Fix Corsizio API Import

Volleyball Elite Academy development update
Volleyball Elite Academy
Fix Corsizio API Import

Fix Corsizio API Import

Volleyball Elite Academy — Development Update • April 28, 2026

Fix Corsizio API Import

What & Why

The Corsizio import tool shows "No events found" because the code uses incorrect field names for the Corsizio API v1 response. The API returns events under but the code checks and , so it always gets an empty array. Additionally, several event field names are wrong ( vs , vs , vs , etc.).

Done looks like

  • Clicking "Import from Corsizio" in Events Management shows the actual list of Corsizio events from the academy's account
  • Selecting an event shows its details and registrants correctly
  • Importing an event creates the academy event with correct data (dates, prices, descriptions)

Out of scope

  • New Corsizio features or UI changes beyond fixing the field mapping

Relevant files

- - -

Volleyball Elite Academy

Reply to this email — we read every reply.

You received this because you have an account with Volleyball Elite Academy.

elitevolleyball.training

Comms Arc 1: Unified Sender + Branded Template Shell

Volleyball Elite Academy development update
Volleyball Elite Academy
Comms Arc 1: Unified Sender + Branded Template Shell

Comms Arc 1: Unified Sender + Branded Template Shell

Volleyball Elite Academy — Development Update • April 28, 2026

Comms Arc 1: Unified Sender + Branded Template Shell

What & Why

Today the app sends from two different addresses ( for ~18 templates and for one), every template has its own hand-rolled HTML header/footer/colors, and the brand string varies between "Canadian Elite Volleyball Academy" and other phrasings. Footers tell families "Please do not reply." That blocks every part of the bigger plan: replies can't be threaded if there's no canonical sender, families won't reply if we tell them not to, and the templates can't be made to "look the same" without a shared layout. This task collapses all of that into one consistent, branded outbound surface: every email goes from with a matching , every email is wrapped in one shared layout component, and every footer invites a reply because the inbox work in the next arc will route those replies into the app.

Done looks like

  • Every email the app sends — every transactional, every reminder, every digest, every admin-bulk send, the SOF feedback flow, the new pipeline test, all 19+ templates — uses one canonical sender: with .
  • A single shared layout helper renders every email's header (logo + brand bar), body slot, and footer (academy name, contact line, "Reply to this email — we read every one", unsubscribe link where the email type warrants one). Templates supply only their unique body content; nothing template-side controls header/footer/branding/colors.
  • Visual brand is consistent across templates: same color palette, same typography, same spacing rhythm, same call-to-action button style. Pulled from the existing app palette so emails match the in-app look.
  • Brand display name in templates standardized to "Volleyball Elite Academy" (matches local usage and the domain). The legacy sender is retired from code defaults; any code path that previously fell back to that string now falls back to the new canonical sender.
  • "This is an automated message. Please do not reply" footer copy is removed everywhere; replaced with "Reply to this email and a member of our team will get back to you" (or the unsubscribe-applicable variant for marketing-class emails).
  • The Communication Hub's "from address" field for admin bulk sends defaults to the new canonical sender; the field is still editable but the placeholder/help text reflects the new default.
  • A short rendering test renders each major template through the shared layout and asserts the header brand string, sender display name, reply-to, and footer copy are all the new canonical values — so a future hand-edit to a template can't silently bypass the layout.

Out of scope

  • Setting up inbound email or building the Conversations view (Arc 2).
  • AI-drafted replies (Arc 3).
  • Migrating any existing audience off — there's no subscriber list tied to that address; it was only a sender identity.
  • Building an unsubscribe management UI. Existing unsubscribe links continue to work; no new flow.
  • Changing scheduler timing, recipient logic, or the email-log schema.
  • Touching the domain DNS (currently points at Corsizio per the user). The retirement is purely in code defaults.

Pre-flight (admin action, not code)

  • The user verifies in the Resend dashboard with the DNS records Resend provides (SPF, DKIM x3, return-path MX, recommended DMARC). Code in this task ships independently of that verification — the From change is safe to merge before DNS is green; sends will still succeed but with weaker authentication until DNS verification completes.

Steps

1. Brand + sender constants — Introduce a single source-of-truth module exporting the canonical from-address, reply-to, brand display name, brand short name, contact email, and brand color tokens. All future code reads these constants; no string literals. 2. Shared email layout helper — A server-side function that takes a shape and returns a complete HTML document with the canonical header (logo, brand bar), body slot, and footer (academy address line, "reply to this email" invitation, unsubscribe slot when ). Use inline styles (email-client compatible) keyed off the brand color tokens. Provide a parallel plain-text helper that produces the matching plain-text version so multipart emails stay aligned. 3. Refactor every template through the layout — Walk every inlined HTML block in (and any other file that builds an outbound HTML body), extract just the unique body content, and route it through the shared layout. Strip the now-duplicate header/footer/wrapper HTML from each template. Same pass for plain-text bodies via the plain-text helper. 4. Sender + reply-to sweep — Replace every literal that defaults to the legacy sender with the new canonical sender constant. Add (the canonical reply-to constant) to every Resend send call site. The admin bulk-send field's default now reads the constant. 5. Footer copy sweep — Remove every "this is an automated message, please do not reply" string. Replace with the appropriate footer variant from the layout helper. The SOF feedback flow's existing reply-button URL keeps working but the surrounding "do not reply" copy goes. 6. Brand string sweep — Standardize the visible brand name in template body content to "Volleyball Elite Academy". Display name in From headers and layout header uses the brand-name constant so a future rename is a one-line change. 7. Tests — A rendering test that, for each of the 19 templates, asserts: layout wraps the body, From string equals the canonical sender, Reply-To equals the canonical reply-to, footer contains the new "reply to this email" line and not the old "do not reply" line, body still contains the template's unique signal content. Plus a guard test that grep-asserts no source file under contains the legacy sender literal or the legacy footer copy outside of allowed locations (e.g. comments documenting the migration). 8. Docs — Update with a "Email branding" subsection: where the constants live, where the layout helper lives, the rule that templates must go through the layout, and a note that DNS verification of happens in the Resend dashboard.

Architectural constraints:

  • The layout helper is the only producer of full email HTML/text. Templates produce body fragments only. Enforce this with the grep guard test.
  • Inline styles only. No blocks. No external CSS. Email clients (Gmail, Outlook, iOS Mail) don't reliably honor either.
  • Do not introduce a new email-rendering library or templating engine. Keep it plain TypeScript template literals routed through the layout helper — matches the existing codebase, zero new dependencies.
  • Do not change the email-log schema, recipient resolution, scheduling, or webhook handling. This task is presentation + sender identity only.

Relevant files

- - - - - -

Volleyball Elite Academy

Reply to this email — we read every reply.

You received this because you have an account with Volleyball Elite Academy.

elitevolleyball.training

Comms Arc 3: AI-Drafted Replies for SuperAdmin Review

Volleyball Elite Academy development update
Volleyball Elite Academy
Comms Arc 3: AI-Drafted Replies for SuperAdmin Review

Comms Arc 3: AI-Drafted Replies for SuperAdmin Review

Volleyball Elite Academy — Development Update • April 28, 2026

Comms Arc 3: AI-Drafted Replies for SuperAdmin Review

What & Why

With Arc 2's Conversations inbox live, the SuperAdmin reads every reply in the app — but typing each response by hand is the bulk of the time. This task adds an AI assistant that, for any inbound message, generates a draft reply pre-loaded into the composer using the sender's context (their family/athletes/teams/events), the recent outbound history we sent them, and a curated knowledge base of operational answers admins can edit. The SuperAdmin always reviews and clicks Send — no auto-send, no scheduled AI replies. Per the user's direction, the SuperAdmin remains the single approval point for every outbound reply, and the AI's job is to make that approval fast.

Done looks like

  • Inside any conversation thread (from Arc 2), a "Draft a reply" button sits next to the composer. Clicking it calls the AI, which streams a draft reply into the composer field within ~5 seconds.
  • The draft is pre-filled — the SuperAdmin can edit any character, can discard and start over, can ignore the AI entirely and type from scratch. Nothing is sent without an explicit Send click.
  • Alongside the draft, a small "Sources used" panel shows the knowledge-base entries the AI drew from (with titles linked to the entry, so the admin can verify or edit the underlying entry if the answer was wrong).
  • A "Knowledge base" tab is added to the Communication Hub. SuperAdmin can create/edit/delete FAQ-style entries: title, body (markdown), tags, and an optional "applies to" filter (e.g. "registration", "billing", "schedule"). These entries are the AI's primary reference material.
  • The AI is given context for each draft: the inbound message text, the sender's linked person record (family + registered athletes + current teams + upcoming events) when recognized, the last 3 outbound messages we sent them (subject + body excerpts pulled from ), and the top-K most relevant knowledge-base entries (selected by simple keyword match in v1, no embeddings).
  • The AI is constrained by a system prompt to: stay in the academy's voice, never invent facts (especially names/dates/dollar amounts/policies), explicitly say "I don't have that information — let me check and get back to you" when the knowledge base doesn't cover the question, never promise refunds or schedule changes, and always sign off as the SuperAdmin or the academy by name.
  • A simple usage telemetry counter on the Knowledge Base tab shows: drafts generated this week, drafts sent unmodified, drafts edited then sent, drafts discarded. Lets the SuperAdmin see whether the assistant is helping and which knowledge entries are getting used.
  • Per-SuperAdmin rate limit (~30 draft generations per hour) prevents accidental cost runaways. Limit overage shows a clear toast.

Out of scope

  • Auto-sending any reply. Every send remains a manual click. (Per user.)
  • Embedding-based retrieval over the knowledge base. v1 uses keyword/tag match — fast, debuggable, free. Embeddings are a follow-up if recall becomes the bottleneck.
  • Multi-language drafts. v1 is English. The model can handle other languages if the inbound is in another language, but no UI surface for forced translation.
  • Letting non-SuperAdmin roles see drafts or use the Knowledge Base editor.
  • Bulk-replying to multiple threads with one click.
  • Auto-classifying threads by topic. The composer always offers a draft regardless; the admin decides whether to use it.

Steps

1. Schema — Add a table (title, body, tags array, applies-to optional, created/updated timestamps, author user id) and an table (one row per draft generation: thread id, draft text, sources cited as ids, generated-at, terminal-state which is one of ///, sent-email-log-id when sent). Use existing Drizzle conventions. No primary-key type changes. 2. Knowledge base CRUD — SuperAdmin-only POST/GET/PATCH/DELETE for entries. List endpoint supports tag filtering. UI is a simple table with inline edit modal; no fancy editor — markdown textarea with a live preview is sufficient. 3. Context builder — A server-side helper that, given a thread id, returns the structured context the AI needs: the inbound message, sender person record + relations (family, athletes, teams, events), last 3 outbound messages, top 5 knowledge-base entries by keyword match against the inbound subject + body. Capped at a generous but bounded total token estimate so a single chatty thread can't blow the prompt size. 4. AI draft endpoint — SuperAdmin-only POST that takes a thread id, builds the context via the helper, calls the configured AI provider with a versioned system prompt that encodes the constraints listed in "Done looks like", returns the draft text plus the list of source ids cited. Records an row in state. Uses the existing rate-limit pattern (advisory lock + count) for the 30/hour cap. 5. Composer integration — The Conversations thread view gets a "Draft a reply" button. Clicking it streams the response into the composer (or a non-streamed swap if streaming adds disproportionate complexity for v1; both acceptable). The "Sources used" panel renders alongside. 6. Outcome tracking — When the SuperAdmin clicks Send from a draft-prefilled composer, the existing reply-send endpoint marks the latest pending draft for that thread as if the body matches the original draft byte-for-byte, otherwise, and links the sent id. Discarding (clearing the composer or generating a new draft over an old one) marks the prior draft as . 7. Telemetry view — A small panel on the Knowledge Base tab shows the four counts for the trailing 7 days, plus a top-5 list of most-cited knowledge entries. 8. Tests — Vitest for: rate limit, context builder picks the right knowledge entries by tag and keyword, system prompt is included verbatim in the AI call, outcome tracking correctly classifies vs , knowledge base CRUD enforces SuperAdmin. RTL test for the composer integration showing draft text appearing, sources rendering, and Send marking the draft outcome correctly. 9. Docs — Update with an "AI-drafted replies" subsection: where the system prompt lives, how to add or edit knowledge base entries, the constraint that drafts always require human Send, and a note on the rate limit + which env var holds the AI API key.

Architectural constraints:

  • The AI never sends anything. The endpoint returns text only; the existing reply-send endpoint from Arc 2 is the only outbound path. This is enforced by the endpoint having no Resend client access at all.
  • The system prompt lives in a versioned constant in the server code so it's reviewable in source. Do not load it from a database row that an admin could edit without engineering review.
  • Use the existing integration that's already installed; do not introduce a second AI SDK or provider.
  • Knowledge base entries are markdown rendered server-side into a sanitized form when shown to admins. No raw HTML execution path.
  • This task depends on Arc 2 being merged — there's no thread to draft a reply for until the Conversations inbox exists.

Relevant files

- - - - -

Volleyball Elite Academy

Reply to this email — we read every reply.

You received this because you have an account with Volleyball Elite Academy.

elitevolleyball.training

Fix Event Edit Rental Time Error

Volleyball Elite Academy development update
Volleyball Elite Academy
Fix Event Edit Rental Time Error

Fix Event Edit Rental Time Error

Volleyball Elite Academy — Development Update • April 28, 2026

--- title: Fix 'Not enough time remaining' error blocking event edits ---

Fix Event Edit Rental Time Error

What & Why

When a SuperAdmin tries to save edits to an existing event in the Events Management page, the save can fail with a 409 error like "Not enough time remaining. Rental has 60 min available, but this event date needs 120 min." — even when the event itself is unchanged or the rental was already linked to that event before.

The root cause is in the update flow: every event edit deletes and recreates the event's date rows, then re-assigns each new date to its gym rental one-by-one. The rental-capacity validator only excludes the single date row currently being assigned from "already used" minutes, so when an event has two dates that share the same rental (or when validation runs while only some dates have been re-linked), the second call sees the first re-linked date as "other usage" and rejects the save. Because the destructive delete already happened, the event is left in a broken half-saved state.

We need to (a) make the rental-capacity check aware of the full set of dates being assigned to a rental for the same event, and (b) stop performing the destructive delete-and-recreate before we know the new layout will pass validation.

Done looks like

  • A SuperAdmin can save edits to an existing event without seeing the false 409 "Not enough time remaining" error when the event's own dates collectively still fit inside the linked rental(s).
  • If the event genuinely overflows a rental's capacity (e.g., the new total duration really is longer than the rental's window), the save is rejected before any date rows or rental links are deleted, and the user sees a clear toast naming which date and rental are over capacity.
  • After a failed save the event keeps its previous dates and rental links intact (no half-saved state), and the user can adjust and retry.
  • A pure metadata edit (e.g., changing only the description, banner, or eligibility) does not get blocked by rental-capacity issues at all.
  • Existing single-date and multi-date edits that already work continue to work.

Out of scope

  • Changing how rentals are originally booked, parsed from PDFs, or how the available-rentals list is computed.
  • The PER_GROUP registration model's groups/dates path (only PER_DATE and WHOLE_EVENT date flows are affected by this bug).
  • Redesigning the rental picker UI or its "[This event]" / "[Used in another day]" labelling beyond what's needed for the new error messages.
  • Changing the cleanup of staff/attendance/summary references.

Steps

1. Add a server-side pre-flight validator for an event's full date+rental layout. Introduce a single endpoint (or shared helper invoked from the dates POST) that accepts the full proposed list of new dates with their choices for a given event, and returns OK or a structured per-date error. The validator must compute, per rental, the other events' already-linked minutes (i.e., exclude every date that belongs to the event being edited, not just one date id), then sum the proposed new dates' durations against the rental's window.

2. Make the existing capacity check use the same "exclude this whole event" rule so it stays consistent if called directly, and so multi-date events that share a rental aren't double-counted across the per-date assign loop.

3. Reorder the client update flow in so validation happens before destruction. Before calling , call the new pre-flight validator with the proposed dates+rentals. Only proceed with DELETE → POST dates → per-date when validation passes. If validation fails, surface a clear toast that names the offending date and rental and abort the mutation with the event left untouched.

4. Skip the destructive dates rewrite when nothing about the dates or rental links actually changed. Compare the submitted (date, start time, end time, location, gymRentalId, per-date maxSpots where applicable) against the originally loaded values for the event being edited; if they match, do the PATCH only and skip the DELETE/POST/assign loop entirely. This protects pure metadata edits from rental-capacity regressions.

5. Tighten the per-date loop's failure handling. Even with pre-flight validation in place, if a later call still fails, surface the specific date and rental in the toast (rather than the raw 409 body) so the admin knows what to fix. Pre-flight should make this rare, but the message should still be friendly.

Relevant files

- - - - - - - - -

Volleyball Elite Academy

Reply to this email — we read every reply.

You received this because you have an account with Volleyball Elite Academy.

elitevolleyball.training

Tuesday, April 28, 2026

Fix Athlete Profile Crash for Parents

Volleyball Elite Academy development update
Volleyball Elite Academy
Fix Athlete Profile Crash for Parents

Fix Athlete Profile Crash for Parents

Volleyball Elite Academy — Development Update • April 28, 2026

Fix Athlete Profile Crash for Parents

What & Why

Parents get "Something went wrong" every time they click "View Profile" for their child. The athlete-summary page crashes during React rendering because the coach connections API response sends different field names (, , no ) than what the frontend code tries to read (, , ). Calling on the undefined field throws a TypeError, which the error boundary catches and shows the crash screen.

Done looks like

  • Parents can open their child's Athlete Profile page without seeing the "Something went wrong" crash.
  • Coach Connections section displays the context label and active/inactive status correctly (or is hidden when the coach name is unavailable).
  • If no coach connections exist, the page renders normally (no change needed).

Out of scope

  • Adding or editing coach connections from the parent view
  • Changing how coach connections are stored in the database

Relevant files

- -

Volleyball Elite Academy

Reply to this email — we read every reply.

You received this because you have an account with Volleyball Elite Academy.

elitevolleyball.training

Comms Arc 3: AI-Drafted Replies for SuperAdmin Review

Volleyball Elite Academy development update
Volleyball Elite Academy
Comms Arc 3: AI-Drafted Replies for SuperAdmin Review

Comms Arc 3: AI-Drafted Replies for SuperAdmin Review

Volleyball Elite Academy — Development Update • April 28, 2026

Comms Arc 3: AI-Drafted Replies for SuperAdmin Review

What & Why

With Arc 2's Conversations inbox live, the SuperAdmin reads every reply in the app — but typing each response by hand is the bulk of the time. This task adds an AI assistant that, for any inbound message, generates a draft reply pre-loaded into the composer using the sender's context (their family/athletes/teams/events), the recent outbound history we sent them, and a curated knowledge base of operational answers admins can edit. The SuperAdmin always reviews and clicks Send — no auto-send, no scheduled AI replies. Per the user's direction, the SuperAdmin remains the single approval point for every outbound reply, and the AI's job is to make that approval fast.

Done looks like

  • Inside any conversation thread (from Arc 2), a "Draft a reply" button sits next to the composer. Clicking it calls the AI, which streams a draft reply into the composer field within ~5 seconds.
  • The draft is pre-filled — the SuperAdmin can edit any character, can discard and start over, can ignore the AI entirely and type from scratch. Nothing is sent without an explicit Send click.
  • Alongside the draft, a small "Sources used" panel shows the knowledge-base entries the AI drew from (with titles linked to the entry, so the admin can verify or edit the underlying entry if the answer was wrong).
  • A "Knowledge base" tab is added to the Communication Hub. SuperAdmin can create/edit/delete FAQ-style entries: title, body (markdown), tags, and an optional "applies to" filter (e.g. "registration", "billing", "schedule"). These entries are the AI's primary reference material.
  • The AI is given context for each draft: the inbound message text, the sender's linked person record (family + registered athletes + current teams + upcoming events) when recognized, the last 3 outbound messages we sent them (subject + body excerpts pulled from ), and the top-K most relevant knowledge-base entries (selected by simple keyword match in v1, no embeddings).
  • The AI is constrained by a system prompt to: stay in the academy's voice, never invent facts (especially names/dates/dollar amounts/policies), explicitly say "I don't have that information — let me check and get back to you" when the knowledge base doesn't cover the question, never promise refunds or schedule changes, and always sign off as the SuperAdmin or the academy by name.
  • A simple usage telemetry counter on the Knowledge Base tab shows: drafts generated this week, drafts sent unmodified, drafts edited then sent, drafts discarded. Lets the SuperAdmin see whether the assistant is helping and which knowledge entries are getting used.
  • Per-SuperAdmin rate limit (~30 draft generations per hour) prevents accidental cost runaways. Limit overage shows a clear toast.

Out of scope

  • Auto-sending any reply. Every send remains a manual click. (Per user.)
  • Embedding-based retrieval over the knowledge base. v1 uses keyword/tag match — fast, debuggable, free. Embeddings are a follow-up if recall becomes the bottleneck.
  • Multi-language drafts. v1 is English. The model can handle other languages if the inbound is in another language, but no UI surface for forced translation.
  • Letting non-SuperAdmin roles see drafts or use the Knowledge Base editor.
  • Bulk-replying to multiple threads with one click.
  • Auto-classifying threads by topic. The composer always offers a draft regardless; the admin decides whether to use it.

Steps

1. Schema — Add a table (title, body, tags array, applies-to optional, created/updated timestamps, author user id) and an table (one row per draft generation: thread id, draft text, sources cited as ids, generated-at, terminal-state which is one of ///, sent-email-log-id when sent). Use existing Drizzle conventions. No primary-key type changes. 2. Knowledge base CRUD — SuperAdmin-only POST/GET/PATCH/DELETE for entries. List endpoint supports tag filtering. UI is a simple table with inline edit modal; no fancy editor — markdown textarea with a live preview is sufficient. 3. Context builder — A server-side helper that, given a thread id, returns the structured context the AI needs: the inbound message, sender person record + relations (family, athletes, teams, events), last 3 outbound messages, top 5 knowledge-base entries by keyword match against the inbound subject + body. Capped at a generous but bounded total token estimate so a single chatty thread can't blow the prompt size. 4. AI draft endpoint — SuperAdmin-only POST that takes a thread id, builds the context via the helper, calls the configured AI provider with a versioned system prompt that encodes the constraints listed in "Done looks like", returns the draft text plus the list of source ids cited. Records an row in state. Uses the existing rate-limit pattern (advisory lock + count) for the 30/hour cap. 5. Composer integration — The Conversations thread view gets a "Draft a reply" button. Clicking it streams the response into the composer (or a non-streamed swap if streaming adds disproportionate complexity for v1; both acceptable). The "Sources used" panel renders alongside. 6. Outcome tracking — When the SuperAdmin clicks Send from a draft-prefilled composer, the existing reply-send endpoint marks the latest pending draft for that thread as if the body matches the original draft byte-for-byte, otherwise, and links the sent id. Discarding (clearing the composer or generating a new draft over an old one) marks the prior draft as . 7. Telemetry view — A small panel on the Knowledge Base tab shows the four counts for the trailing 7 days, plus a top-5 list of most-cited knowledge entries. 8. Tests — Vitest for: rate limit, context builder picks the right knowledge entries by tag and keyword, system prompt is included verbatim in the AI call, outcome tracking correctly classifies vs , knowledge base CRUD enforces SuperAdmin. RTL test for the composer integration showing draft text appearing, sources rendering, and Send marking the draft outcome correctly. 9. Docs — Update with an "AI-drafted replies" subsection: where the system prompt lives, how to add or edit knowledge base entries, the constraint that drafts always require human Send, and a note on the rate limit + which env var holds the AI API key.

Architectural constraints:

  • The AI never sends anything. The endpoint returns text only; the existing reply-send endpoint from Arc 2 is the only outbound path. This is enforced by the endpoint having no Resend client access at all.
  • The system prompt lives in a versioned constant in the server code so it's reviewable in source. Do not load it from a database row that an admin could edit without engineering review.
  • Use the existing integration that's already installed; do not introduce a second AI SDK or provider.
  • Knowledge base entries are markdown rendered server-side into a sanitized form when shown to admins. No raw HTML execution path.
  • This task depends on Arc 2 being merged — there's no thread to draft a reply for until the Conversations inbox exists.

Relevant files

- - - - -

Volleyball Elite Academy

Reply to this email — we read every reply.

You received this because you have an account with Volleyball Elite Academy.

elitevolleyball.training

Comms Arc 2: Inbound Email + In-App Conversations Inbox

Volleyball Elite Academy development update
Volleyball Elite Academy
Comms Arc 2: Inbound Email + In-App Conversations Inbox

Comms Arc 2: Inbound Email + In-App Conversations Inbox

Volleyball Elite Academy — Development Update • April 28, 2026

Comms Arc 2: Inbound Email + In-App Conversations Inbox

What & Why

With Arc 1 done, every outbound email has one canonical sender () and invites replies. But today there's nowhere for those replies to go — they'd just sit in a mailbox no one watches. This task captures inbound email at that address, threads each reply against the original outbound that prompted it, links the sender to the family/athlete/staff record when we recognize the email address, and exposes everything as a "Conversations" view inside the Communication Hub. SuperAdmins read and reply directly in the app. Per the user's direction, v1 is app-only (no Gmail/Workspace mailbox in the loop) — part-time staff won't check a separate inbox, so the app is the single source of truth for replies. A future task can layer a Workspace mailbox on top once staff size and reply volume warrant it.

Done looks like

  • Replies sent to arrive in a new "Conversations" tab inside the Communication Hub within seconds of being sent. No external mailbox to log into.
  • Each conversation is a thread: the original outbound from the app (pulled from ) is the first message, followed by all replies in chronological order. Subsequent replies on the same thread (whether from the original recipient or a CC'd person) attach to the same thread automatically via standard email headers (, , ).
  • The thread list shows: sender (with linked family/athlete/staff name when recognized, raw email when not), subject, snippet of the latest message, last-activity timestamp, and an unread badge. Default sort is most-recent-activity-first. A filter toggles "Unread only" / "All".
  • Opening a thread shows the full message history in chronological order — outbound messages from the app rendered with the same branded layout the recipient saw, inbound replies rendered as the sender wrote them (HTML with sanitization, plain-text fallback).
  • A reply composer sits at the bottom of each thread. Sending posts through the existing Resend pipeline using the canonical sender, sets the right and headers so the recipient's mail client threads it correctly, and the new outbound is logged into both (so it shows up in the existing audit) and the conversation thread.
  • When an inbound arrives that we can match to a person (sender email matches a user, family contact, or staff record), the thread shows that person's name and a small link to their profile.
  • When we cannot match the sender, the thread still appears, labeled with just the email address; an admin can manually link it to a person in one click.
  • A small "Unread conversations" count appears on the Communication Hub nav entry so SuperAdmin sees new replies at a glance from anywhere in the app.
  • Attachments on inbound emails are detected and listed by filename + size, but not stored in v1 — the message body shows "[Attachment: filename.pdf — not stored, contact sender for a copy]". This keeps the schema simple and avoids object-storage surface area until we have a real need.
  • Inbound that fails to match any prior outbound (no header or no match found) starts a new thread rather than being dropped. Cold-start emails to are valid customer-initiated conversations.
  • An audit-trail preserved: every inbound webhook payload is stored raw (truncated to a sane size) so a future bug or a misclassified threading decision can be re-derived.

Out of scope

  • AI-drafted replies (Arc 3).
  • Storing or rendering inbound attachment bodies. v1 records the metadata only.
  • Multi-mailbox routing (e.g. vs going to different queues). One mailbox, one queue.
  • Forwarding inbound to a Workspace/Gmail mailbox in parallel. Deferred to a future task per user's preference for v1.
  • A mobile-optimized Conversations UI. Standard responsive treatment is fine; no dedicated mobile flow.
  • Search across thread bodies. v1 sorts and filters; full-text search is a follow-up.
  • Marking threads as resolved/archived/snoozed. v1 has unread/all only.
  • Notifying the SuperAdmin via email or push when a new thread arrives. v1 is the in-app badge only.

Pre-flight (admin action, not code)

  • The user configures Resend Inbound for : DNS MX records pointing inbound to Resend, and a webhook endpoint registered in the Resend dashboard pointing at the new ingest route this task adds.

Steps

1. Schema — Add an table (raw payload, parsed headers, from-email-lower, subject, text-body, html-body, message-id, in-reply-to, references, received-at, thread-id FK) and an table (subject, last-activity-at, linked-person-type/id, linked-person-display-name-cached, unread-count-for-superadmin). The first outbound message of a thread links to its row by id; subsequent inbound and outbound entries reference the same thread id. Use existing Drizzle conventions. No primary-key type changes. 2. Inbound webhook ingest — A new SuperAdmin-internal POST route that accepts Resend's inbound webhook payload, validates the signature with the inbound webhook secret env var, parses headers and bodies, threads the message (find existing thread by Message-ID/In-Reply-To match against either prior inbound message-ids or our outbound ; if no match and the inbound looks like a reply by subject prefix , fall back to subject-based matching scoped to sender; otherwise create a new thread), runs person-matching against users/family-contacts/staff, persists the inbound row, updates thread last-activity-at and unread-count, and ACKs. 3. Outbound thread linking — When the existing wrapper sends an email, it now also writes (or finds) a thread row keyed by the recipient + subject normalization, links the outbound row to that thread, and marks the thread as having app-originated activity. Replies threading back to that outbound find the same thread automatically. This also keeps the existing audit-log behavior intact — nothing about the email_log table changes. 4. Reply send — A new SuperAdmin-only POST that takes a thread id and a body, sends through the existing Resend pipeline with the canonical sender, sets to the most recent message in the thread and to the chain, logs the outbound into , and appends to the thread. 5. Conversations API — SuperAdmin-only GETs for thread list (paginated, filterable by unread, sortable by activity) and thread detail (full message history with both inbound and outbound rendered). A POST to mark a thread as read. 6. Conversations UI — New tab in the Communication Hub. Thread list on the left, thread detail on the right (or stacked on small screens). Inbound rendered through a sanitized HTML viewer (use existing sanitization utilities if present; otherwise a minimal allowlist sanitizer); outbound rendered through Arc 1's shared layout helper for visual continuity. Reply composer with send button and clear "sent as " footer. 7. Person-link UI — When a thread's sender is unmatched, show a small "Link to person" affordance that opens a search/picker tied to existing user/family/staff data. Linking persists the choice on the thread row so future inbounds from the same address auto-link. 8. Unread badge — A small numeric badge on the Communication Hub nav item, sourced from the same SuperAdmin-only endpoint that powers the thread list's unread count. 9. Tests — Vitest for: webhook signature validation rejects bad signatures, threading correctly attaches a reply to its prior outbound via Message-ID, threading falls back to subject-based matching only when scoped to the same sender, person-matching prefers exact email match in the right priority order (user > family contact > staff), reply send sets In-Reply-To and References correctly. RTL test for the thread list and thread detail rendering, inbound HTML sanitization, and reply submission. 10. Docs — Update with an "Inbound email" subsection covering the threading rules, the in-app-only architecture, and a note that the inbound webhook secret env var must be set in production via the deployment Secrets UI (never in chat).

Architectural constraints:

  • Reuse the existing pipeline for all outbound — including replies. No parallel send path. Replies must show up in exactly like any other outbound, just with the thread linkage added.
  • Webhook secret must be a separate env var from (which is for outbound delivery events) — Resend issues a different signing secret per webhook endpoint, and conflating them would silently break one of them.
  • Inbound HTML rendering must sanitize. Never render raw inbound HTML directly into the DOM. A reply that contains or styled tracking pixels must not execute or render in the admin's browser.
  • This task does not touch the existing table or the outbound webhook handler. Those keep working as-is.
  • This task depends on Arc 1 being merged so the canonical sender, reply-to, and branded layout are already in place — threading and rendering both rely on those.

Relevant files

- - - - - -

Volleyball Elite Academy

Reply to this email — we read every reply.

You received this because you have an account with Volleyball Elite Academy.

elitevolleyball.training

Comms Arc 1: Unified Sender + Branded Template Shell

Volleyball Elite Academy development update
Volleyball Elite Academy
Comms Arc 1: Unified Sender + Branded Template Shell

Comms Arc 1: Unified Sender + Branded Template Shell

Volleyball Elite Academy — Development Update • April 28, 2026

Comms Arc 1: Unified Sender + Branded Template Shell

What & Why

Today the app sends from two different addresses ( for ~18 templates and for one), every template has its own hand-rolled HTML header/footer/colors, and the brand string varies between "Canadian Elite Volleyball Academy" and other phrasings. Footers tell families "Please do not reply." That blocks every part of the bigger plan: replies can't be threaded if there's no canonical sender, families won't reply if we tell them not to, and the templates can't be made to "look the same" without a shared layout. This task collapses all of that into one consistent, branded outbound surface: every email goes from with a matching , every email is wrapped in one shared layout component, and every footer invites a reply because the inbox work in the next arc will route those replies into the app.

Done looks like

  • Every email the app sends — every transactional, every reminder, every digest, every admin-bulk send, the SOF feedback flow, the new pipeline test, all 19+ templates — uses one canonical sender: with .
  • A single shared layout helper renders every email's header (logo + brand bar), body slot, and footer (academy name, contact line, "Reply to this email — we read every one", unsubscribe link where the email type warrants one). Templates supply only their unique body content; nothing template-side controls header/footer/branding/colors.
  • Visual brand is consistent across templates: same color palette, same typography, same spacing rhythm, same call-to-action button style. Pulled from the existing app palette so emails match the in-app look.
  • Brand display name in templates standardized to "Volleyball Elite Academy" (matches local usage and the domain). The legacy sender is retired from code defaults; any code path that previously fell back to that string now falls back to the new canonical sender.
  • "This is an automated message. Please do not reply" footer copy is removed everywhere; replaced with "Reply to this email and a member of our team will get back to you" (or the unsubscribe-applicable variant for marketing-class emails).
  • The Communication Hub's "from address" field for admin bulk sends defaults to the new canonical sender; the field is still editable but the placeholder/help text reflects the new default.
  • A short rendering test renders each major template through the shared layout and asserts the header brand string, sender display name, reply-to, and footer copy are all the new canonical values — so a future hand-edit to a template can't silently bypass the layout.

Out of scope

  • Setting up inbound email or building the Conversations view (Arc 2).
  • AI-drafted replies (Arc 3).
  • Migrating any existing audience off — there's no subscriber list tied to that address; it was only a sender identity.
  • Building an unsubscribe management UI. Existing unsubscribe links continue to work; no new flow.
  • Changing scheduler timing, recipient logic, or the email-log schema.
  • Touching the domain DNS (currently points at Corsizio per the user). The retirement is purely in code defaults.

Pre-flight (admin action, not code)

  • The user verifies in the Resend dashboard with the DNS records Resend provides (SPF, DKIM x3, return-path MX, recommended DMARC). Code in this task ships independently of that verification — the From change is safe to merge before DNS is green; sends will still succeed but with weaker authentication until DNS verification completes.

Steps

1. Brand + sender constants — Introduce a single source-of-truth module exporting the canonical from-address, reply-to, brand display name, brand short name, contact email, and brand color tokens. All future code reads these constants; no string literals. 2. Shared email layout helper — A server-side function that takes a shape and returns a complete HTML document with the canonical header (logo, brand bar), body slot, and footer (academy address line, "reply to this email" invitation, unsubscribe slot when ). Use inline styles (email-client compatible) keyed off the brand color tokens. Provide a parallel plain-text helper that produces the matching plain-text version so multipart emails stay aligned. 3. Refactor every template through the layout — Walk every inlined HTML block in (and any other file that builds an outbound HTML body), extract just the unique body content, and route it through the shared layout. Strip the now-duplicate header/footer/wrapper HTML from each template. Same pass for plain-text bodies via the plain-text helper. 4. Sender + reply-to sweep — Replace every literal that defaults to the legacy sender with the new canonical sender constant. Add (the canonical reply-to constant) to every Resend send call site. The admin bulk-send field's default now reads the constant. 5. Footer copy sweep — Remove every "this is an automated message, please do not reply" string. Replace with the appropriate footer variant from the layout helper. The SOF feedback flow's existing reply-button URL keeps working but the surrounding "do not reply" copy goes. 6. Brand string sweep — Standardize the visible brand name in template body content to "Volleyball Elite Academy". Display name in From headers and layout header uses the brand-name constant so a future rename is a one-line change. 7. Tests — A rendering test that, for each of the 19 templates, asserts: layout wraps the body, From string equals the canonical sender, Reply-To equals the canonical reply-to, footer contains the new "reply to this email" line and not the old "do not reply" line, body still contains the template's unique signal content. Plus a guard test that grep-asserts no source file under contains the legacy sender literal or the legacy footer copy outside of allowed locations (e.g. comments documenting the migration). 8. Docs — Update with a "Email branding" subsection: where the constants live, where the layout helper lives, the rule that templates must go through the layout, and a note that DNS verification of happens in the Resend dashboard.

Architectural constraints:

  • The layout helper is the only producer of full email HTML/text. Templates produce body fragments only. Enforce this with the grep guard test.
  • Inline styles only. No blocks. No external CSS. Email clients (Gmail, Outlook, iOS Mail) don't reliably honor either.
  • Do not introduce a new email-rendering library or templating engine. Keep it plain TypeScript template literals routed through the layout helper — matches the existing codebase, zero new dependencies.
  • Do not change the email-log schema, recipient resolution, scheduling, or webhook handling. This task is presentation + sender identity only.

Relevant files

- - - - - -

Volleyball Elite Academy

Reply to this email — we read every reply.

You received this because you have an account with Volleyball Elite Academy.

elitevolleyball.training

Comms Arc 3: AI-Drafted Replies for SuperAdmin Review

Volleyball Elite Academy development update
Volleyball Elite Academy
Comms Arc 3: AI-Drafted Replies for SuperAdmin Review

Comms Arc 3: AI-Drafted Replies for SuperAdmin Review

Volleyball Elite Academy — Development Update • April 28, 2026

Comms Arc 3: AI-Drafted Replies for SuperAdmin Review

What & Why

With Arc 2's Conversations inbox live, the SuperAdmin reads every reply in the app — but typing each response by hand is the bulk of the time. This task adds an AI assistant that, for any inbound message, generates a draft reply pre-loaded into the composer using the sender's context (their family/athletes/teams/events), the recent outbound history we sent them, and a curated knowledge base of operational answers admins can edit. The SuperAdmin always reviews and clicks Send — no auto-send, no scheduled AI replies. Per the user's direction, the SuperAdmin remains the single approval point for every outbound reply, and the AI's job is to make that approval fast.

Done looks like

  • Inside any conversation thread (from Arc 2), a "Draft a reply" button sits next to the composer. Clicking it calls the AI, which streams a draft reply into the composer field within ~5 seconds.
  • The draft is pre-filled — the SuperAdmin can edit any character, can discard and start over, can ignore the AI entirely and type from scratch. Nothing is sent without an explicit Send click.
  • Alongside the draft, a small "Sources used" panel shows the knowledge-base entries the AI drew from (with titles linked to the entry, so the admin can verify or edit the underlying entry if the answer was wrong).
  • A "Knowledge base" tab is added to the Communication Hub. SuperAdmin can create/edit/delete FAQ-style entries: title, body (markdown), tags, and an optional "applies to" filter (e.g. "registration", "billing", "schedule"). These entries are the AI's primary reference material.
  • The AI is given context for each draft: the inbound message text, the sender's linked person record (family + registered athletes + current teams + upcoming events) when recognized, the last 3 outbound messages we sent them (subject + body excerpts pulled from ), and the top-K most relevant knowledge-base entries (selected by simple keyword match in v1, no embeddings).
  • The AI is constrained by a system prompt to: stay in the academy's voice, never invent facts (especially names/dates/dollar amounts/policies), explicitly say "I don't have that information — let me check and get back to you" when the knowledge base doesn't cover the question, never promise refunds or schedule changes, and always sign off as the SuperAdmin or the academy by name.
  • A simple usage telemetry counter on the Knowledge Base tab shows: drafts generated this week, drafts sent unmodified, drafts edited then sent, drafts discarded. Lets the SuperAdmin see whether the assistant is helping and which knowledge entries are getting used.
  • Per-SuperAdmin rate limit (~30 draft generations per hour) prevents accidental cost runaways. Limit overage shows a clear toast.

Out of scope

  • Auto-sending any reply. Every send remains a manual click. (Per user.)
  • Embedding-based retrieval over the knowledge base. v1 uses keyword/tag match — fast, debuggable, free. Embeddings are a follow-up if recall becomes the bottleneck.
  • Multi-language drafts. v1 is English. The model can handle other languages if the inbound is in another language, but no UI surface for forced translation.
  • Letting non-SuperAdmin roles see drafts or use the Knowledge Base editor.
  • Bulk-replying to multiple threads with one click.
  • Auto-classifying threads by topic. The composer always offers a draft regardless; the admin decides whether to use it.

Steps

1. Schema — Add a table (title, body, tags array, applies-to optional, created/updated timestamps, author user id) and an table (one row per draft generation: thread id, draft text, sources cited as ids, generated-at, terminal-state which is one of ///, sent-email-log-id when sent). Use existing Drizzle conventions. No primary-key type changes. 2. Knowledge base CRUD — SuperAdmin-only POST/GET/PATCH/DELETE for entries. List endpoint supports tag filtering. UI is a simple table with inline edit modal; no fancy editor — markdown textarea with a live preview is sufficient. 3. Context builder — A server-side helper that, given a thread id, returns the structured context the AI needs: the inbound message, sender person record + relations (family, athletes, teams, events), last 3 outbound messages, top 5 knowledge-base entries by keyword match against the inbound subject + body. Capped at a generous but bounded total token estimate so a single chatty thread can't blow the prompt size. 4. AI draft endpoint — SuperAdmin-only POST that takes a thread id, builds the context via the helper, calls the configured AI provider with a versioned system prompt that encodes the constraints listed in "Done looks like", returns the draft text plus the list of source ids cited. Records an row in state. Uses the existing rate-limit pattern (advisory lock + count) for the 30/hour cap. 5. Composer integration — The Conversations thread view gets a "Draft a reply" button. Clicking it streams the response into the composer (or a non-streamed swap if streaming adds disproportionate complexity for v1; both acceptable). The "Sources used" panel renders alongside. 6. Outcome tracking — When the SuperAdmin clicks Send from a draft-prefilled composer, the existing reply-send endpoint marks the latest pending draft for that thread as if the body matches the original draft byte-for-byte, otherwise, and links the sent id. Discarding (clearing the composer or generating a new draft over an old one) marks the prior draft as . 7. Telemetry view — A small panel on the Knowledge Base tab shows the four counts for the trailing 7 days, plus a top-5 list of most-cited knowledge entries. 8. Tests — Vitest for: rate limit, context builder picks the right knowledge entries by tag and keyword, system prompt is included verbatim in the AI call, outcome tracking correctly classifies vs , knowledge base CRUD enforces SuperAdmin. RTL test for the composer integration showing draft text appearing, sources rendering, and Send marking the draft outcome correctly. 9. Docs — Update with an "AI-drafted replies" subsection: where the system prompt lives, how to add or edit knowledge base entries, the constraint that drafts always require human Send, and a note on the rate limit + which env var holds the AI API key.

Architectural constraints:

  • The AI never sends anything. The endpoint returns text only; the existing reply-send endpoint from Arc 2 is the only outbound path. This is enforced by the endpoint having no Resend client access at all.
  • The system prompt lives in a versioned constant in the server code so it's reviewable in source. Do not load it from a database row that an admin could edit without engineering review.
  • Use the existing integration that's already installed; do not introduce a second AI SDK or provider.
  • Knowledge base entries are markdown rendered server-side into a sanitized form when shown to admins. No raw HTML execution path.
  • This task depends on Arc 2 being merged — there's no thread to draft a reply for until the Conversations inbox exists.

Relevant files

- - - - -

Volleyball Elite Academy

Reply to this email — we read every reply.

You received this because you have an account with Volleyball Elite Academy.

elitevolleyball.training

Comms Arc 2: Inbound Email + In-App Conversations Inbox

Volleyball Elite Academy development update
Volleyball Elite Academy
Comms Arc 2: Inbound Email + In-App Conversations Inbox

Comms Arc 2: Inbound Email + In-App Conversations Inbox

Volleyball Elite Academy — Development Update • April 28, 2026

Comms Arc 2: Inbound Email + In-App Conversations Inbox

What & Why

With Arc 1 done, every outbound email has one canonical sender () and invites replies. But today there's nowhere for those replies to go — they'd just sit in a mailbox no one watches. This task captures inbound email at that address, threads each reply against the original outbound that prompted it, links the sender to the family/athlete/staff record when we recognize the email address, and exposes everything as a "Conversations" view inside the Communication Hub. SuperAdmins read and reply directly in the app. Per the user's direction, v1 is app-only (no Gmail/Workspace mailbox in the loop) — part-time staff won't check a separate inbox, so the app is the single source of truth for replies. A future task can layer a Workspace mailbox on top once staff size and reply volume warrant it.

Done looks like

  • Replies sent to arrive in a new "Conversations" tab inside the Communication Hub within seconds of being sent. No external mailbox to log into.
  • Each conversation is a thread: the original outbound from the app (pulled from ) is the first message, followed by all replies in chronological order. Subsequent replies on the same thread (whether from the original recipient or a CC'd person) attach to the same thread automatically via standard email headers (, , ).
  • The thread list shows: sender (with linked family/athlete/staff name when recognized, raw email when not), subject, snippet of the latest message, last-activity timestamp, and an unread badge. Default sort is most-recent-activity-first. A filter toggles "Unread only" / "All".
  • Opening a thread shows the full message history in chronological order — outbound messages from the app rendered with the same branded layout the recipient saw, inbound replies rendered as the sender wrote them (HTML with sanitization, plain-text fallback).
  • A reply composer sits at the bottom of each thread. Sending posts through the existing Resend pipeline using the canonical sender, sets the right and headers so the recipient's mail client threads it correctly, and the new outbound is logged into both (so it shows up in the existing audit) and the conversation thread.
  • When an inbound arrives that we can match to a person (sender email matches a user, family contact, or staff record), the thread shows that person's name and a small link to their profile.
  • When we cannot match the sender, the thread still appears, labeled with just the email address; an admin can manually link it to a person in one click.
  • A small "Unread conversations" count appears on the Communication Hub nav entry so SuperAdmin sees new replies at a glance from anywhere in the app.
  • Attachments on inbound emails are detected and listed by filename + size, but not stored in v1 — the message body shows "[Attachment: filename.pdf — not stored, contact sender for a copy]". This keeps the schema simple and avoids object-storage surface area until we have a real need.
  • Inbound that fails to match any prior outbound (no header or no match found) starts a new thread rather than being dropped. Cold-start emails to are valid customer-initiated conversations.
  • An audit-trail preserved: every inbound webhook payload is stored raw (truncated to a sane size) so a future bug or a misclassified threading decision can be re-derived.

Out of scope

  • AI-drafted replies (Arc 3).
  • Storing or rendering inbound attachment bodies. v1 records the metadata only.
  • Multi-mailbox routing (e.g. vs going to different queues). One mailbox, one queue.
  • Forwarding inbound to a Workspace/Gmail mailbox in parallel. Deferred to a future task per user's preference for v1.
  • A mobile-optimized Conversations UI. Standard responsive treatment is fine; no dedicated mobile flow.
  • Search across thread bodies. v1 sorts and filters; full-text search is a follow-up.
  • Marking threads as resolved/archived/snoozed. v1 has unread/all only.
  • Notifying the SuperAdmin via email or push when a new thread arrives. v1 is the in-app badge only.

Pre-flight (admin action, not code)

  • The user configures Resend Inbound for : DNS MX records pointing inbound to Resend, and a webhook endpoint registered in the Resend dashboard pointing at the new ingest route this task adds.

Steps

1. Schema — Add an table (raw payload, parsed headers, from-email-lower, subject, text-body, html-body, message-id, in-reply-to, references, received-at, thread-id FK) and an table (subject, last-activity-at, linked-person-type/id, linked-person-display-name-cached, unread-count-for-superadmin). The first outbound message of a thread links to its row by id; subsequent inbound and outbound entries reference the same thread id. Use existing Drizzle conventions. No primary-key type changes. 2. Inbound webhook ingest — A new SuperAdmin-internal POST route that accepts Resend's inbound webhook payload, validates the signature with the inbound webhook secret env var, parses headers and bodies, threads the message (find existing thread by Message-ID/In-Reply-To match against either prior inbound message-ids or our outbound ; if no match and the inbound looks like a reply by subject prefix , fall back to subject-based matching scoped to sender; otherwise create a new thread), runs person-matching against users/family-contacts/staff, persists the inbound row, updates thread last-activity-at and unread-count, and ACKs. 3. Outbound thread linking — When the existing wrapper sends an email, it now also writes (or finds) a thread row keyed by the recipient + subject normalization, links the outbound row to that thread, and marks the thread as having app-originated activity. Replies threading back to that outbound find the same thread automatically. This also keeps the existing audit-log behavior intact — nothing about the email_log table changes. 4. Reply send — A new SuperAdmin-only POST that takes a thread id and a body, sends through the existing Resend pipeline with the canonical sender, sets to the most recent message in the thread and to the chain, logs the outbound into , and appends to the thread. 5. Conversations API — SuperAdmin-only GETs for thread list (paginated, filterable by unread, sortable by activity) and thread detail (full message history with both inbound and outbound rendered). A POST to mark a thread as read. 6. Conversations UI — New tab in the Communication Hub. Thread list on the left, thread detail on the right (or stacked on small screens). Inbound rendered through a sanitized HTML viewer (use existing sanitization utilities if present; otherwise a minimal allowlist sanitizer); outbound rendered through Arc 1's shared layout helper for visual continuity. Reply composer with send button and clear "sent as " footer. 7. Person-link UI — When a thread's sender is unmatched, show a small "Link to person" affordance that opens a search/picker tied to existing user/family/staff data. Linking persists the choice on the thread row so future inbounds from the same address auto-link. 8. Unread badge — A small numeric badge on the Communication Hub nav item, sourced from the same SuperAdmin-only endpoint that powers the thread list's unread count. 9. Tests — Vitest for: webhook signature validation rejects bad signatures, threading correctly attaches a reply to its prior outbound via Message-ID, threading falls back to subject-based matching only when scoped to the same sender, person-matching prefers exact email match in the right priority order (user > family contact > staff), reply send sets In-Reply-To and References correctly. RTL test for the thread list and thread detail rendering, inbound HTML sanitization, and reply submission. 10. Docs — Update with an "Inbound email" subsection covering the threading rules, the in-app-only architecture, and a note that the inbound webhook secret env var must be set in production via the deployment Secrets UI (never in chat).

Architectural constraints:

  • Reuse the existing pipeline for all outbound — including replies. No parallel send path. Replies must show up in exactly like any other outbound, just with the thread linkage added.
  • Webhook secret must be a separate env var from (which is for outbound delivery events) — Resend issues a different signing secret per webhook endpoint, and conflating them would silently break one of them.
  • Inbound HTML rendering must sanitize. Never render raw inbound HTML directly into the DOM. A reply that contains or styled tracking pixels must not execute or render in the admin's browser.
  • This task does not touch the existing table or the outbound webhook handler. Those keep working as-is.
  • This task depends on Arc 1 being merged so the canonical sender, reply-to, and branded layout are already in place — threading and rendering both rely on those.

Relevant files

- - - - - -

Volleyball Elite Academy

Reply to this email — we read every reply.

You received this because you have an account with Volleyball Elite Academy.

elitevolleyball.training

Comms Arc 1: Unified Sender + Branded Template Shell

Volleyball Elite Academy development update
Volleyball Elite Academy
Comms Arc 1: Unified Sender + Branded Template Shell

Comms Arc 1: Unified Sender + Branded Template Shell

Volleyball Elite Academy — Development Update • April 28, 2026

Comms Arc 1: Unified Sender + Branded Template Shell

What & Why

Today the app sends from two different addresses ( for ~18 templates and for one), every template has its own hand-rolled HTML header/footer/colors, and the brand string varies between "Canadian Elite Volleyball Academy" and other phrasings. Footers tell families "Please do not reply." That blocks every part of the bigger plan: replies can't be threaded if there's no canonical sender, families won't reply if we tell them not to, and the templates can't be made to "look the same" without a shared layout. This task collapses all of that into one consistent, branded outbound surface: every email goes from with a matching , every email is wrapped in one shared layout component, and every footer invites a reply because the inbox work in the next arc will route those replies into the app.

Done looks like

  • Every email the app sends — every transactional, every reminder, every digest, every admin-bulk send, the SOF feedback flow, the new pipeline test, all 19+ templates — uses one canonical sender: with .
  • A single shared layout helper renders every email's header (logo + brand bar), body slot, and footer (academy name, contact line, "Reply to this email — we read every one", unsubscribe link where the email type warrants one). Templates supply only their unique body content; nothing template-side controls header/footer/branding/colors.
  • Visual brand is consistent across templates: same color palette, same typography, same spacing rhythm, same call-to-action button style. Pulled from the existing app palette so emails match the in-app look.
  • Brand display name in templates standardized to "Volleyball Elite Academy" (matches local usage and the domain). The legacy sender is retired from code defaults; any code path that previously fell back to that string now falls back to the new canonical sender.
  • "This is an automated message. Please do not reply" footer copy is removed everywhere; replaced with "Reply to this email and a member of our team will get back to you" (or the unsubscribe-applicable variant for marketing-class emails).
  • The Communication Hub's "from address" field for admin bulk sends defaults to the new canonical sender; the field is still editable but the placeholder/help text reflects the new default.
  • A short rendering test renders each major template through the shared layout and asserts the header brand string, sender display name, reply-to, and footer copy are all the new canonical values — so a future hand-edit to a template can't silently bypass the layout.

Out of scope

  • Setting up inbound email or building the Conversations view (Arc 2).
  • AI-drafted replies (Arc 3).
  • Migrating any existing audience off — there's no subscriber list tied to that address; it was only a sender identity.
  • Building an unsubscribe management UI. Existing unsubscribe links continue to work; no new flow.
  • Changing scheduler timing, recipient logic, or the email-log schema.
  • Touching the domain DNS (currently points at Corsizio per the user). The retirement is purely in code defaults.

Pre-flight (admin action, not code)

  • The user verifies in the Resend dashboard with the DNS records Resend provides (SPF, DKIM x3, return-path MX, recommended DMARC). Code in this task ships independently of that verification — the From change is safe to merge before DNS is green; sends will still succeed but with weaker authentication until DNS verification completes.

Steps

1. Brand + sender constants — Introduce a single source-of-truth module exporting the canonical from-address, reply-to, brand display name, brand short name, contact email, and brand color tokens. All future code reads these constants; no string literals. 2. Shared email layout helper — A server-side function that takes a shape and returns a complete HTML document with the canonical header (logo, brand bar), body slot, and footer (academy address line, "reply to this email" invitation, unsubscribe slot when ). Use inline styles (email-client compatible) keyed off the brand color tokens. Provide a parallel plain-text helper that produces the matching plain-text version so multipart emails stay aligned. 3. Refactor every template through the layout — Walk every inlined HTML block in (and any other file that builds an outbound HTML body), extract just the unique body content, and route it through the shared layout. Strip the now-duplicate header/footer/wrapper HTML from each template. Same pass for plain-text bodies via the plain-text helper. 4. Sender + reply-to sweep — Replace every literal that defaults to the legacy sender with the new canonical sender constant. Add (the canonical reply-to constant) to every Resend send call site. The admin bulk-send field's default now reads the constant. 5. Footer copy sweep — Remove every "this is an automated message, please do not reply" string. Replace with the appropriate footer variant from the layout helper. The SOF feedback flow's existing reply-button URL keeps working but the surrounding "do not reply" copy goes. 6. Brand string sweep — Standardize the visible brand name in template body content to "Volleyball Elite Academy". Display name in From headers and layout header uses the brand-name constant so a future rename is a one-line change. 7. Tests — A rendering test that, for each of the 19 templates, asserts: layout wraps the body, From string equals the canonical sender, Reply-To equals the canonical reply-to, footer contains the new "reply to this email" line and not the old "do not reply" line, body still contains the template's unique signal content. Plus a guard test that grep-asserts no source file under contains the legacy sender literal or the legacy footer copy outside of allowed locations (e.g. comments documenting the migration). 8. Docs — Update with a "Email branding" subsection: where the constants live, where the layout helper lives, the rule that templates must go through the layout, and a note that DNS verification of happens in the Resend dashboard.

Architectural constraints:

  • The layout helper is the only producer of full email HTML/text. Templates produce body fragments only. Enforce this with the grep guard test.
  • Inline styles only. No blocks. No external CSS. Email clients (Gmail, Outlook, iOS Mail) don't reliably honor either.
  • Do not introduce a new email-rendering library or templating engine. Keep it plain TypeScript template literals routed through the layout helper — matches the existing codebase, zero new dependencies.
  • Do not change the email-log schema, recipient resolution, scheduling, or webhook handling. This task is presentation + sender identity only.

Relevant files

- - - - - -

Volleyball Elite Academy

Reply to this email — we read every reply.

You received this because you have an account with Volleyball Elite Academy.

elitevolleyball.training