TRANSACTIONAL_EMAIL_IA.md β h.work
Canonical information-architecture reference for every transactional email h.work sends. Catalogs each
send*Emailmethod, maps it to its audience (Client / Expert / AM-internal / System), and defines the intent, tone, subject pattern, body skeleton, CTA, brand treatment, and plain-text fallback per audience.This file is the single source of truth referenced by #1254 (copy rewrite) and #1255 (template refactor). Part of epic #1258.
Cross-reference: branding.md, DESIGN_PRINCIPLES.md, INFORMATION_ARCHITECTURE.md,
api/src/email/email.service.ts.Source of truth for the catalog is the running code β
email.service.ts(2,462 lines as of this writing). Where this doc and the code disagree, re-read the code and update this doc.Last updated: 2026-06-25 (#1257 β per-audience sender identity, reply-to, footer/unsubscribe policy + CAN-SPAM postal address implemented in
EmailService).
0. Purposeβ
E (2026-06-01): "The Invite emails on hwork are completely bland, off brand, and not thoughtful. Really thoroughly think through the proper email experience for clients, experts, and account manager communications, and make sure the branding/templates are consistent."
This document is the IA & audience layer of that work. It does three things:
- Inventory β catalogs every transactional email the platform can send, with its real trigger, caller, subject, opening line, CTA, and wiring status (Β§3).
- Audience IA β defines the three external/internal audiences and the per-audience treatment each email family should follow (Β§2).
- Per-email spec β one row per email giving audience, intent, tone, subject pattern, body skeleton, CTA, brand treatment, and plain-text notes (Β§4), plus the gaps (Β§5) and the concrete bugs/cleanups the rewrite must fix (Β§6).
It does not write the final copy (that is #1254) or build templates (that is #1255). It tells those issues what each email is for and who it is talking to.
What changed since the issue was filedβ
#1253 and the parent epic both say "11+ emails". The service has since grown to 22 distinct send methods β the Expert lifecycle (#1083 interview flow, #1554 onboarding flow) landed in between. This doc catalogs all 22, which is what the acceptance criterion ("every method in email.service.ts is cataloged with an audience tag") actually requires today.
1. The problem this fixesβ
Every transactional email is hand-rolled HTML inlined into email.service.ts. There is no shared template, no design system, no audience-aware variant, and no preview tooling. Three concrete symptoms:
-
The "h.work invites you to join h.work" bug.
sendInviteEmail(to, link, orgName)rendersorgNameverbatim into the subject and body. When the invitee is platform staff (an Expert or AM joining the internal HP org), the caller has no org, so it falls back to the string'h.work'βadmin.service.ts:470,524setconst subjectOrgName = org?.name ?? 'h.work', which is then passed tosendInviteEmailat:476,528(the call sites the Β§3 inventory cites). The result: Subject: "You've been invited to join h.work on h.work", body "You've been invited to join h.work on h.work." This is the screenshot E reported. It is a symptom of the missing audience split β a Client invite and a staff invite are the same template. -
No brand. None of the 22 emails use the canonical brand palette (H red
#7E1C1E, Bright work#F4F0E6, Dark Job#1A1611, Manrope / Source Serif 4 β see branding.md). They all use an ad-hoc slate system (#0f172aheader,#111827button,#F9FAFBcanvas,system-ui). Two of them (sendExpertAssignmentEmail,sendExpertOnboardingCompletion) ship a π emoji, which violates the "no emojis" rule in DESIGN_PRINCIPLES.md / branding.md. -
No IA. Subjects, openers, and signoffs are inconsistent within the same audience β some say "Hi," some "Hi there,", some "Hi {name},"; some sign "The h.work Team", some just footer "h.work", some nothing. A Client onboarding email and an internal ops alert are visually 90% identical.
2. Audiencesβ
Four audiences. The first three are the IA buckets E named; the fourth (System) exists because identity/security mail is genuinely audience-neutral and should read that way.
| Tag | Audience | Platform/DB roles | What they are doing when they get mail |
|---|---|---|---|
| CLIENT | The client (an employee at a client org) | org_admin, org_member, client_admin, client_member (workspace roles owner / admin / member / billing) | Receiving a managed service. Mostly passive recipients β onboarding, billing, scheduled check-ins. Low product literacy assumed. |
| EXPERT | HP Expert (human reviewer) and Expert applicants in the recruiting funnel | expert platform role; pre-account applicants have no role yet | Applying to, onboarding into, or working the Expert Network. Peer-to-peer professionals. High context, want terse status. |
| AM | HP Account Manager / SuperAdmin (internal ops) | account_manager, superadmin | Running the platform. Want operational signal, not hand-holding; links into /ops/*. |
| SYSTEM | Whoever owns the email address β any role | any | Proving identity (OTP) or recovering access (password reset). Audience-neutral by design. |
Why Expert applicants live under EXPERT, not a fifth bucket. The recruiting funnel (apply β waitlist/qualify β interview β onboard β assign) is the front of the Expert relationship. Tone is continuous: an applicant who becomes an Expert should not feel a brand whiplash. We treat the whole funnel as EXPERT with a "pre-account / post-account" sub-distinction noted per email.
2.1 Per-audience treatmentβ
This is the IA contract. Every email in Β§4 inherits its row here unless it explicitly overrides.
| Dimension | CLIENT | EXPERT | AM (internal) | SYSTEM |
|---|---|---|---|---|
| Voice | Warm-professional. "You've joined a service." Light explanatory copy, never jargon. | Peer-to-peer professional. "Your queue", "your application". Terse, status-dense, respects their time. | Operational. Dense, scannable, data-first. No reassurance copy. | Neutral, security-conscious, minimal. |
From-name (implemented in #1257 β EmailService.senderFor()) | h.work <onboarding@β¦>, reply-to support@ β or, post-launch, the AM as a person ({AM} at h.work) for onboarding touches. | h.work Expert Network <experts@β¦>, reply-to support@ | h.work Ops <ops@β¦>, no reply-to | h.work <no-reply@β¦>, no reply-to |
| Header treatment | Brand header, claret accent, calm. | Brand header, claret accent. Urgency states (expiry/final reminder) may use a warning band. | Distinct ops eyebrow + denser header; visually separable from client-facing mail at a glance. | Minimal brand header; no marketing. |
| CTA | One prominent primary button. | One primary button; status emails may be button-light (a link is fine). | Button links straight into the relevant /ops/* surface. | Usually no CTA β the code/value is the payload. Never a flashy button. |
| Body density | Explain the "why" in one short paragraph, then the CTA. | Lead with the state change, then the action. Skip the why. | Tables/key-values over prose. | One sentence + the code/instruction. |
| Signoff | "β the h.work team" (sentence case). | "β the h.work Expert Network team". | None, or "h.work Ops". | None. |
| Footer scope | Address + transactional notice. No unsubscribe on strictly-transactional (invite, OTP, onboarding); unsubscribe only on optional/recurring (trial, billing, check-in, recruiting nurture). | Same rule: unsubscribe on recruiting-/onboarding-nurture (waitlist, reminders), not on decision/action mail (qualified, interview-invite, decline, kickoff, completion, assignment). Current code attaches it to all Expert mail β Β§6.9 reconciles (#1257 owns the call). | No unsubscribe (internal operational mail). | No unsubscribe. |
| Reply-to | Routable to support / AM. | support@ (the 72h reminder already does this β generalize it). | n/a or ops alias. | no-reply acceptable. |
Footer / unsubscribe rule of thumb (CAN-SPAM / GDPR baseline, owned by #1257): if a reasonable user could want to stop a given email and still use the product, it needs a one-click unsubscribe (
List-Unsubscribe+ visible link). If turning it off would break the product or their security (invite, OTP, password reset, "your workspace is active"), it is strictly transactional and must not carry unsubscribe. The current code already gets this mostly right viaunsubscribeHeaders()/unsubscribeFooterHtml()β Β§3 marks which emails carry it.
3. Full inventoryβ
Every public send method in email.service.ts, in source order. Audience is the required tag. Wired = a non-test caller exists in api/src today (verified by call-site search 2026-06-22); methods marked β are implemented but not connected to any trigger and send nothing in production.
| # | Method | Trigger event | Caller(s) | Audience | Current subject | Current opening line | Primary CTA | Unsub? | Wired | Est. volume |
|---|---|---|---|---|---|---|---|---|---|---|
| 1 | sendInviteEmail | A member/staff invite is created or resent | auth.service.ts:1012,1664, admin.service.ts:476,528 | CLIENT (also EXPERT/AM when orgId null β the bug) | You've been invited to join {orgName} on h.work | "You've been invited to join {orgName} on h.work." | Accept invitation | no | β | High |
| 2 | sendOnboardingInviteEmail | AM finishes Setup Wizard and invites the client admin | organizations.service.ts:1779,1939 | CLIENT (client_admin) | Complete your {orgName} workspace setup β h.work | "{amName} has prepared your {orgName} workspace on h.work." | Complete setup | no | β | Med |
| 3 | sendWelcomeEmail | Org onboarding completes; workspace activated | onboarding.service.ts:746 | CLIENT | Your {orgName} workspace is active β h.work | "Your {orgName} workspace has been activated and is ready to use." | Go to your workspace | no | β | Med |
| 4 | sendAmCompletionNotification | Client admin completes onboarding setup | onboarding.service.ts:775 | AM | {orgName} completed onboarding | "{clientAdminName} has completed the onboarding setup for {orgName}." | View in dashboard | no | β | Med |
| 5 | sendOtpEmail | Email verification (signup, invite-accept, AM org onboarding) | auth.service.ts:1268,1485,1718, onboarding.service.ts:163 | SYSTEM | Your h.work verification code: {otp} | "Use this code to complete joining {orgName}:" | (code, no button) | no | β | High |
| 6 | sendAdminInitiatedPasswordResetEmail | SuperAdmin initiates a reset for another user | auth.service.ts:1792 | SYSTEM | Your h.work password reset code | "A platform administrator has initiated a password reset for your account." | (code, no button) | no | β | Low |
| 7 | sendTrialEndingEmail | Trial countdown (billing cron) | β none β | CLIENT (billing) | Your h.work trial ends in {n} day(s) / β¦has ended | "Your h.work trial for {orgName} ends in {n} days." | Add a payment method | yes | β | (would be Med) |
| 8 | sendPaymentFailedEmail | Stripe payment failure | β none β (see note) | CLIENT (billing) | Action needed: payment failed for {orgName} | "Your most recent payment for {orgName} didn't go through." | Update payment method | yes | β | (would be Low) |
| 9 | sendWeeklyCallScheduledEmail | Weekly-call scheduler creates a scheduled call (#2059) | weekly-call-scheduler.service.ts:172 | CLIENT | Your weekly check-in with {specialist} is scheduled | "Your weekly check-in with {specialist} has been scheduled for next week." | Join your check-in | yes | β | Med |
| 10 | sendExpertWaitlistEmail | Expert application set to waitlisted | marketing.service.ts:543,760 | EXPERT (applicant) | You're on the h.work Expert waitlist | "Thanks for applying to h.work's Expert Network." | (LinkedIn link) | yes | β | Med |
| 11 | sendExpertQualifiedEmail | AI screening qualifies an applicant | marketing.service.ts:531 | EXPERT (applicant) | You've been selected for h.work | "Great news β your application to the h.work Expert Network has been approved." | (LinkedIn link) | yes | β | Med |
| 12 | sendExpertInterviewInviteEmail | Qualified applicant gets Tavus interview (#1083) | marketing.service.ts:861,1102 | EXPERT (applicant) | Your h.work Expert interview is ready | "Your application stood out β you're invited to complete your h.work Expert Network interview." | Start your interview | yes | β | Med |
| 13 | sendExpertInterviewReminderEmail | Interview still incomplete near expiry (#1083) | marketing.service.ts:1189 | EXPERT (applicant) | Your h.work interview expires in 2 days | "Your h.work interview link expires in 2 days." | Complete your interview | yes | β | Low |
| 14 | sendExpertOnboardingKickoffEmail (#1083) | "SuperAdmin approves expert after interview" β placeholder | β none β | EXPERT | Welcome to h.work β Next Steps | "Congratulations β your interview was excellentβ¦" | (LinkedIn link) | yes | β | β (dead; see #6.4) |
| 15 | sendExpertDeclineEmail | SuperAdmin declines a waitlisted applicant | marketing.service.ts:833 | EXPERT (applicant) | An update on your h.work application | "Thank you for your time. After careful review, we're not moving forward right now." | Join our waitlist | yes | β | Low |
| 16 | sendExpertAssignmentEmail | AM/SA assigns an Expert to an Org (#1087) | admin.service.ts:1046 | EXPERT | You've been matched β welcome to {orgName} | "Great news β you've been matched with {orgName}." | Go to your workspace | yes | β | Med |
| 17 | sendExpertOnboardingKickoff (#1554) | Expert onboarding initialized (application approved) | marketing.service.ts:802 | EXPERT | Welcome to h.work - Let's get you onboarded | "Congratulations β your application to the h.work Expert Network has been approved! Let's get you onboarded." | Start onboarding | yes | β | Med |
| 18 | sendExpertOnboardingReminder24h (#1554) | Onboarding incomplete after 24h | β none β | EXPERT | Continue your h.work onboarding | "You're making great progress β {n}% complete." | Resume onboarding | yes | β | (would be Low) |
| 19 | sendExpertOnboardingReminder72h (#1554) | Onboarding incomplete after 72h | β none β | EXPERT | Final reminder: Complete your h.work onboarding | "We noticed you haven't completed your h.work onboarding yet." | Complete onboarding | yes | β | (would be Low) |
| 20 | sendKycFailedToOps (#1554) | Expert KYC verification fails | β none β | AM (Ops) | KYC Failed - Expert requires manual review | "An Expert's KYC verification has failed and requires manual review." | Review in dashboard | no | β | (would be Rare) |
| 21 | sendExpertOnboardingCompletion (#1554) | Expert finishes all onboarding steps | β none β | EXPERT | You're in! Welcome to h.work | "Welcome to the h.work Expert Network! You've completed onboardingβ¦" | Go to dashboard | yes | β | (would be Med) |
| 22 | sendOnboardingCallReminderEmail | Client missed/skipped the Specialist intro call (step 6) | admin.service.ts:578 | CLIENT | Complete your onboarding call β {orgName} | "Your account at {orgName} on h.work is set up and ready, but the onboarding call β¦ hasn't been completed yet." | Start onboarding call | no | β | Low |
Notes on this table
- Wiring (β = not triggered). 7 methods are implemented but have no production caller: #7, #8, #14, #18, #19, #20, #21. They are real liabilities β copy/brand work on them ships nothing until they're wired (#1255 / billing / #1554 follow-ups). Each is called out in Β§6.4.
- #8
sendPaymentFailedEmail: the billing payment-failure path (BillingPaymentFailedNotifier, #1433) deliberately fires in-app notifications only ("Email is a deliberate follow-up"). The email method exists but is not invited into that flow. Decide in #1254/#1257 whether the follow-up email is in or out. - Volume is a qualitative estimate (High / Med / Low / Rare) keyed to trigger frequency β there is no per-template send instrumentation today. Wire send-count metrics (#1256 territory) before treating these as numbers. High β every signup/invite (OTP, invite); Med β every onboarding/assignment/weekly cycle; Low/Rare β exception paths.
Unsub?= current code, not target policy. The column records whether the method attachesList-Unsubscribetoday (every Expert applicant/onboarding email does, via theisEmailSuppressedguard). The Β§2.1 footer contract is the target; for decision/action Expert mail the two diverge β Β§6.9 reconciles them and #1257 owns the final call. Don't read the target policy off this column.- Helper methods are not emails and are excluded from the catalog:
isEmailSuppressed,buildUnsubscribeUrl,unsubscribeHeaders,unsubscribeFooterHtml,unsubscribeFooterText,suppressByEmail,captureEmailFailure,looksLikeStagingKey,generateUnsubscribeTokenForEmail.
3.1 Audience tallyβ
| Audience | Count | Methods |
|---|---|---|
| CLIENT | 7 | 1, 2, 3, 7, 8, 9, 22 |
| EXPERT | 11 | 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 21 |
| AM | 2 | 4, 20 |
| SYSTEM | 2 | 5, 6 |
| CLIENT and EXPERT/AM (must split) | 1 | 1 (sendInviteEmail β see Β§6.1) |
Expert mail dominates (half the catalog) and is the most lifecycle-staged; Client mail is the highest volume; AM mail is thin (only two, both notifications). That imbalance is itself an IA finding: we over-serve the recruiting funnel and under-serve in-life Client and AM comms (see gaps, Β§5).
4. Per-email specsβ
Each spec gives intent Β· tone Β· subject pattern Β· body skeleton Β· CTA Β· brand treatment Β· plain-text notes. Brand treatment names the target (claret brand system) and flags where the current code diverges. These are the rows #1254 writes copy against and #1255 builds templates against.
CLIENTβ
4.1 sendInviteEmail β Member invitation [CLIENT] (split required, see Β§6.1)β
- Intent: Get an invited client employee to accept and create their account.
- Tone: Warm, short, low-friction. The recipient may not know what h.work is.
- Subject pattern:
{Inviter or Org} invited you to {Org} on h.workβ must name the inviting org, never "h.work on h.work". - Body skeleton: who invited you + to what β one-line "what h.work is for you" β primary CTA β expiry + ignore-if-unexpected line.
- CTA:
Accept invitationβ/accept-invite?token=β¦. - Brand: CLIENT header (claret), one primary button. Today: slate
#0f172aheader, black button β rebrand. - Security: the current template interpolates
orgNameandinviteLinkunescaped into the subject, title, heading, body, and anchorhrefβ nohtmlEscape, no URL guard (unlike the Expert templates, which already escape). Org names are admin-controlled, so the refactor musthtmlEscapeevery display field and validate the accept-invite URL against anhttps://allowlist. See Β§6.1. - Plain-text: mirror subject, the org name, the accept URL, the 7-day expiry. (Already present.)
4.2 sendOnboardingInviteEmail β Workspace setup invite [CLIENT]β
- Intent: Client admin completes account setup and activates the workspace the AM prepared.
- Tone: Warm-professional, "your AM has done the prep, you finish it." Personalized by AM (name + avatar).
- Subject pattern:
Complete your {Org} workspace setup. - Body skeleton: "Setup by {AM}" attribution block β one line of context β portal URL panel β primary CTA β dated expiry.
- CTA:
Complete setupβhttps://{slug}.{domain}/onboarding?token=β¦. - Brand: CLIENT header; keep the AM attribution + portal-URL panel (good patterns). Rebrand colors/type.
- Plain-text: include portal URL and CTA URL separately, plus the human-readable expiry date. (Present.)
4.3 sendWelcomeEmail β Workspace active [CLIENT]β
- Intent: Confirm the workspace is live and push the first action (message the Specialist).
- Tone: Warm, celebratory-but-calm, action-oriented.
- Subject pattern:
Your {Org} workspace is active. - Body skeleton: activation confirmation β portal URL panel β Specialist card (name, email, avatar) β "next steps: message your Specialist" β primary CTA.
- CTA:
Go to your workspace. - Brand: CLIENT header; the Specialist card is the signature element β keep it, restyle to brand. Strictly transactional β no unsubscribe.
- Plain-text: Specialist name + email, portal URL, workspace URL. (Present.)
4.4 sendWeeklyCallScheduledEmail β Weekly check-in scheduled [CLIENT]β
- Intent: Tell the client their recurring check-in is booked and how to join.
- Tone: Warm, light-touch, reassuring.
- Subject pattern:
Your weekly check-in with {Specialist} is scheduled. - Body skeleton: what's scheduled + with whom β join CTA β "what this call is for" reassurance β unsubscribe footer.
- CTA:
Join your check-inβ client portal URL. - Brand: CLIENT header. Recurring β unsubscribe required (present). Today uses bare
<div>HTML, not even the card chrome β bring into the system. - Plain-text: specialist + join URL + unsubscribe. (Present.)
4.5 sendOnboardingCallReminderEmail β Onboarding call reminder [CLIENT]β
- Intent: Recover a client who skipped the Specialist intro call (step 6).
- Tone: Warm, helpful, gently nudging; explains the value of the call.
- Subject pattern:
Complete your onboarding call β {Org}. - Body skeleton: status ("set up, but the call isn't done") β what the call is for (15β30 min, helps the Specialist) β CTA β "reply or contact your AM".
- CTA:
Start onboarding callβ call page URL. - Brand: CLIENT header. Manually triggered by AM/SA β transactional, no unsubscribe.
- Plain-text: present.
4.6 sendTrialEndingEmail β Trial ending [CLIENT Β· billing] (not wired β Β§6.4)β
- Intent: Convert before the trial lapses; keep the Specialist online.
- Tone: Warm, low-pressure, "one-minute task". Day-0 ("has ended") variant is firmer.
- Subject pattern:
Your h.work trial ends in {n} day(s)β on/after expiryYour h.work trial for {Org} has ended. - Body skeleton: countdown headline β consequence ("keep your Specialist online") β CTA β "ignore if already added" β unsubscribe.
- CTA:
Add a payment methodβ billing URL. - Brand: CLIENT header. Recurring/optional β unsubscribe required (present, plus
List-Unsubscribe). Today uses a blue (#2563eb) button β re-token. HonorsisEmailSuppressed. - Plain-text: headline + billing URL + unsubscribe. (Present.)
4.7 sendPaymentFailedEmail β Payment failed [CLIENT Β· billing] (not wired β Β§6.4)β
- Intent: Recover a failed charge before service is paused.
- Tone: Calm, non-alarming, specific ("expired card / address / hold"), supportive.
- Subject pattern:
Action needed: payment failed for {Org}. - Body skeleton: what happened β likely-benign reasons β CTA β retry/pause consequence + offer to help β unsubscribe.
- CTA:
Update payment methodβ billing URL. - Brand: CLIENT header. Uses a red (
#dc2626) button today β re-token to a brand "attention" treatment, not raw red. Decide in-vs-out vs the in-app-only notifier (#1433). HonorsisEmailSuppressed. - Plain-text: present.
EXPERT β recruiting funnel (pre-account applicant)β
4.8 sendExpertWaitlistEmail β Waitlisted [EXPERT Β· applicant]β
- Intent: Keep a strong-but-unmatched applicant warm.
- Tone: "You're strong β we just need the right match." Respectful, encouraging, never a soft rejection.
- Subject pattern:
You're on the h.work Expert waitlist. - Body skeleton: thanks for applying β genuine-strength affirmation + (domain-aware) match line β LinkedIn nurture β "the h.work Team".
- CTA: soft β LinkedIn follow (no button is acceptable for EXPERT status mail).
- Brand: EXPERT header. Nurture β unsubscribe (present). Escapes
name/domain(XSS-safe). - Plain-text: present, with domain-aware line.
4.9 sendExpertQualifiedEmail β Qualified / selected [EXPERT Β· applicant]β
- Intent: Tell a screened-in applicant they passed and set the expectation of next steps.
- Tone: Congratulatory, clear, professional.
- Subject pattern:
You've been selected for h.work. - Body skeleton: approval β domain-aware "exactly what clients need" β "we'll reach out shortly" β LinkedIn β team signoff.
- CTA: soft (LinkedIn). When #1083 is fully live this should carry the interview link instead β see Β§6.4 overlap with #4.10.
- Brand: EXPERT header; unsubscribe (present).
- Plain-text: present.
4.10 sendExpertInterviewInviteEmail β Interview invite [EXPERT Β· applicant]β
- Intent: Drive the applicant to start the async Tavus video interview.
- Tone: Encouraging, low-stakes ("a conversation, not a test"), logistically clear.
- Subject pattern:
Your h.work Expert interview is ready. - Body skeleton: "you stood out" β what the interview is (15β20 min, async, anywhere) β primary CTA β 7-day validity + setup tips β team signoff.
- CTA:
Start your interviewβ Tavus conversation URL. - Brand: EXPERT header; unsubscribe (present). Escapes URL/name.
- Plain-text: present.
4.11 sendExpertInterviewReminderEmail β Interview reminder [EXPERT Β· applicant]β
- Intent: Recover an applicant who hasn't completed the interview before the link expires.
- Tone: Urgent-but-warm; "don't miss your spot".
- Subject pattern:
Your h.work interview expires in {n} days. - Body skeleton: expiry warning β CTA β quick reassurance (15β20 min) β team signoff.
- CTA:
Complete your interview. - Brand: EXPERT urgency variant β this is the one email that intentionally uses a warning header (
#dc2626) today. Keep the urgency state in the brand system (a claret/amber warning band), not raw red. Unsubscribe present. - Plain-text: present.
4.12 sendExpertDeclineEmail β Application declined [EXPERT Β· applicant]β
- Intent: Respectfully decline, leave the door open to re-apply.
- Tone: Respectful, brief, non-defeating.
- Subject pattern:
An update on your h.work application. - Body skeleton: thanks β "not moving forward right now" β "keep you on our radar" + rejoin-waitlist CTA β team signoff.
- CTA:
Join our waitlistβ/apply/rejoin-waitlist?applicationId=β¦(or/apply). - Brand: EXPERT header, restrained. Unsubscribe present. Uses canonical
getAppBaseUrl()(post-#1779). - Plain-text: present.
EXPERT β onboarding & in-life (post-decision)β
4.13 sendExpertOnboardingKickoff (#1554) β Onboarding kickoff [EXPERT]β
- Intent: Move an approved Expert into the 5-step onboarding flow.
- Tone: Welcoming, concrete, time-boxed ("~20 min, pause anytime").
- Subject pattern:
Let's get you onboarded(#1254: dropped "Welcome to h.work β"; em dash trimmed for voice, "Welcome to h.work" already leads the H1). - Body skeleton: approval β "what to expect" panel (5 steps / 20 min / pausable) β primary CTA β "this link is unique to you" β team signoff.
- CTA:
Start onboardingβ onboarding URL (validatedhttps://). - Brand: EXPERT header; unsubscribe present.
- Plain-text: present. Note: this is the live kickoff; the #1083
β¦KickoffEmail(4.16) is the dead duplicate to delete.
4.14 sendExpertOnboardingReminder24h (#1554) β 24h reminder [EXPERT] (not wired β Β§6.4)β
- Intent: Nudge an Expert who paused onboarding; show progress.
- Tone: Encouraging, momentum-framed.
- Subject pattern:
Continue your h.work onboarding. - Body skeleton: "{n}% complete, on the {step} step" β progress bar β "few more minutes" β CTA.
- CTA:
Resume onboardingβ resume URL. - Brand: EXPERT header; progress bar is a keeper. Unsubscribe present.
- Plain-text: present (percent + step + resume URL).
4.15 sendExpertOnboardingReminder72h (#1554) β Final reminder [EXPERT] (not wired β Β§6.4)β
- Intent: Last automated nudge before the Expert lapses.
- Tone: Direct, "final reminder", with a help offer.
- Subject pattern:
Final reminder: complete your h.work onboarding(sentence case). - Body skeleton: "haven't completed" β help callout (reply for support) β CTA β "final automated reminder".
- CTA:
Complete onboardingβ resume URL. - Brand: EXPERT header; sets
replyTo: support@(generalize this reply-to pattern to all EXPERT mail). Unsubscribe present. - Plain-text: present.
4.16 sendExpertOnboardingKickoffEmail (#1083) β legacy kickoff [EXPERT] (dead β delete, Β§6.4)β
- Status: Placeholder ("TODO #1085"), no caller, superseded by 4.13. Catalogued for completeness; the rewrite should delete it, not restyle it.
4.17 sendExpertOnboardingCompletion (#1554) β Onboarding complete [EXPERT] (not wired β Β§6.4)β
- Intent: Celebrate completion and set the "what's next" expectation (matching in 1β2 weeks).
- Tone: Warm, congratulatory, forward-looking.
- Subject pattern:
Welcome to h.work, you're in(#1254: emoji dropped per Β§6.2; em dash trimmed for voice β comma reads cleaner in a subject). - Body skeleton: welcome β "what happens next" panel (matched in 1β2 weeks / AM reaches out / dashboard access) β CTA β team signoff.
- CTA:
Go to dashboard. - Brand: EXPERT header. Remove the π in the H1. Unsubscribe present.
- Plain-text: present.
4.18 sendExpertAssignmentEmail β Matched to an org [EXPERT]β
- Intent: Tell an Expert they've been matched to a specific client org.
- Tone: Energizing, brief; "your AM will kick things off".
- Subject pattern:
You've been matched β welcome to {Org}. - Body skeleton: match announcement β "workspace active for this org, AM in touch" β CTA β team signoff.
- CTA:
Go to your workspaceβ workspace URL (validatedhttps://, else#). - Brand: EXPERT header. Remove the π in the H1 (Β§6.2). Unsubscribe present. Escapes name/org.
- Plain-text: present.
AM / internalβ
4.19 sendAmCompletionNotification β Client finished onboarding [AM]β
- Intent: Tell the AM their client completed setup and the workspace is active.
- Tone: Operational, brief, scannable. No reassurance copy.
- Subject pattern:
{Org} completed onboarding. - Body skeleton: "{client admin} completed setup for {Org}" β key-value panel (workspace URL, client admin) β CTA into ops.
- CTA:
View in dashboardβ portal/ops URL. - Brand: AM/Ops header treatment β must be visually distinct from client mail (today it's identical to client onboarding mail, which is an IA failure). Internal β no unsubscribe.
- Plain-text: present.
4.20 sendKycFailedToOps (#1554) β KYC failure alert [AM Β· Ops] (not wired β Β§6.4)β
- Intent: Alert ops that an Expert's KYC failed and needs manual review.
- Tone: Operational alert. Data-first, no warmth.
- Subject pattern:
KYC failed β Expert requires manual review(sentence case). - Body skeleton: alert line β Expert details panel (name, email, failure reason) β CTA β "use manual override only when appropriate".
- CTA:
Review in dashboardβ manual-review URL. - Brand: the only email with an explicit ops identity today ("h.work Ops Alert" eyebrow, dark-red header, "h.work Ops" footer). Make this the canonical AM/Ops alert template the other internal mail (4.19) adopts. No unsubscribe. Escapes all fields.
- Plain-text: present.
SYSTEMβ
4.21 sendOtpEmail β Email verification code [SYSTEM]β
- Intent: Deliver a 6-digit verification code.
- Tone: Neutral, minimal, security-aware.
- Subject pattern:
Your h.work verification code: {code}(code-in-subject aids autofill; acceptable for OTP). - Body skeleton: one-line context β large monospace code β "expires in 10 min, don't share".
- CTA: none β the code is the payload.
- Brand: minimal SYSTEM header. Copy fix: the body hardcodes "complete joining {orgName}", which is wrong for login OTPs (the user isn't joining). Make the context line trigger-aware (verify / join / sign in).
- Plain-text: present.
4.22 sendAdminInitiatedPasswordResetEmail β Admin-initiated reset [SYSTEM]β
- Intent: Give a user a reset code an admin initiated on their behalf.
- Tone: Neutral, security-conscious; names the initiator so the user can detect abuse.
- Subject pattern:
Your h.work password reset code. - Body skeleton: "an administrator initiated a reset" β code β "if you don't recognize {initiator}, contact your admin" β "expires in 10 min, one-time".
- CTA: none.
- Brand: minimal SYSTEM header. Escapes initiator email. No HTML-less plain-text today β add a
text:fallback (Β§6.3). - Plain-text: missing β add it.
5. Gaps β emails we should send but don'tβ
Candidate emails with no method today. Prioritized for #1254/#1255 grooming; not all are in-scope for launch.
| Gap | Audience | Why it matters | Priority |
|---|---|---|---|
| Unread Specialist reply ("you have a new reply from {Specialist}") | CLIENT | The 2026-05-22 channels audit found no email notifies a client of a Specialist reply β the core service loop has no email touchpoint. | High |
| Invite accepted confirmation | CLIENT + inviter (AM) | Close the loop after Β§4.1; today the inviter never hears it landed. | Med |
| Dedicated password-reset template | SYSTEM | Self-service reset already sends β requestPasswordReset reuses the generic sendOtpEmail(email, code, "h.work") (auth.service.ts:1718). So this is not a missing send path: the gap is a purpose-built reset template, because the reused OTP body reads "complete joining h.work" (Β§6.7), which misframes a reset as a signup. Give reset its own subject/copy, or make the OTP context trigger-aware. Do not build a second reset send path. | Med |
| Expert application received (pre-screening ack) | EXPERT (applicant) | First touch is currently waitlist/qualify after screening; applicants get silence in between. | Med |
| Payment receipt / payment succeeded | CLIENT (billing) | We warn on failure (4.7) but never confirm success β standard billing hygiene. | Med |
| Channel connected / disconnected confirmation | CLIENT | Security-relevant state change with no email today. | Low |
| Expert: new queue item / daily digest | EXPERT | No in-life operational email for Experts ("your queue") β the whole EXPERT set is lifecycle, not work. | Low (post-launch) |
| AM: weekly client-health digest | AM | AM mail is just two notifications; no proactive operational digest. | Low (post-launch) |
| Trial / payment-failed (wire the existing methods) | CLIENT | 4.6/4.7 exist but never fire β a wiring gap, not an authoring one. | Med |
6. Bugs & inconsistencies the rewrite must fixβ
Concrete, code-level fixes for #1254 (copy) and #1255 (refactor). This is the punch list.
6.1 The "h.work invites you to join h.work" bug β split sendInviteEmail by audienceβ
sendInviteEmail(to, link, orgName) is one template for two audiences. For platform-staff invites the caller passes orgName = org?.name ?? 'h.work', producing "join h.work on h.work". Fix: split into a CLIENT invite (names the inviting org: "Join {Org} on h.work") and an EXPERT/AM staff invite (names the team: "You've been invited to the h.work team" / "β¦the Humanity Protocol Expert Network"). The template must never interpolate the platform name into the "{org}" slot. Harden the template while splitting it: today sendInviteEmail interpolates orgName and inviteLink unescaped into the subject, title, heading, body, and anchor href (no htmlEscape, no URL validation β unlike the Expert templates). The refactor must htmlEscape all display fields and validate the accept-invite URL (https:// allowlist) so the split closes the markup-injection / phishing-link vector too, not only the audience bug. This is the headline deliverable of the epic.
6.2 Remove emojis (brand rule)β
sendExpertAssignmentEmail (H1 "You've been matched π") and sendExpertOnboardingCompletion (H1 "You're in! π") ship emoji in the heading. branding.md / DESIGN_PRINCIPLES.md: no emojis. Strip both; carry celebration via copy and the claret accent, not a glyph.
6.3 Plain-text parityβ
Every email must ship a text: part (deliverability + accessibility). Today sendAdminInitiatedPasswordResetEmail sends HTML only β add a plain-text fallback (it's a security email, the highest-stakes one to land in a text-only client). Audit all 22 in #1255 for parity; the rest currently have text:.
6.4 Dead / unwired methods β decide wire-or-deleteβ
Seven methods have no production caller (verified 2026-06-22):
| Method | Disposition |
|---|---|
sendExpertOnboardingKickoffEmail (#1083, 4.16) | Delete β placeholder, superseded by sendExpertOnboardingKickoff (#1554, 4.13). Two "kickoff" methods is a trap. |
sendExpertOnboardingReminder24h / Reminder72h (4.14/4.15) | Wire to the #1554 onboarding scheduler, or mark explicitly deferred. Don't ship copy for mail that never sends. |
sendExpertOnboardingCompletion (4.17) | Wire to onboarding-complete, or defer. |
sendKycFailedToOps (4.20) | Wire to the KYC-failure path, or defer. |
sendTrialEndingEmail / sendPaymentFailedEmail (4.6/4.7) | Decide with billing: wire to the trial cron / BillingPaymentFailedNotifier (#1433, currently in-app only), or formally mark out-of-scope. |
6.5 Standardize greeting, signoff, and from-name per audienceβ
Current state is inconsistent within audiences: "Hi," vs "Hi there," vs "Hi {name},"; "The h.work Team" vs bare "h.work" vs nothing. Adopt the Β§2.1 table verbatim β CLIENT "β the h.work team", EXPERT "β the h.work Expert Network team", AM none/"h.work Ops", SYSTEM none. From-name per Β§2.1 (impl in #1257).
6.6 Rebrand off the ad-hoc slate paletteβ
All 22 use #0f172a headers / #111827 buttons / system-ui / #F9FAFB canvas β none use the brand tokens (H red #7E1C1E, Bright work #F4F0E6, Dark Job #1A1611, Manrope / Source Serif 4). Billing variants additionally use off-system blue (#2563eb) and red (#dc2626) buttons. The design-system primitives (#1252) define the target; this catalog says which treatment each email takes (CLIENT / EXPERT / EXPERT-urgency / AM-Ops / SYSTEM). Keep the genuinely good structural patterns when restyling: the Specialist card (4.3), the AM attribution + portal-URL panel (4.2), the progress bar (4.14), and the Ops-alert identity (4.20).
6.7 OTP copy is join-specificβ
sendOtpEmail hardcodes "complete joining {orgName}" but is reused for login/verification and the self-service password-reset flow (requestPasswordReset β sendOtpEmail(β¦, "h.work"), auth.service.ts:1718), where the user isn't joining anything. Make the context line trigger-aware (verify / join / sign in / reset). This is the same work as the Β§5 "dedicated password-reset template" gap β fix once, here.
6.8 Make the AM/internal treatment visually distinctβ
sendAmCompletionNotification (AM) is pixel-identical to the client onboarding emails. Internal ops mail should be recognizable as internal at a glance β adopt the Ops-alert identity from sendKycFailedToOps (4.20) as the AM/Ops base.
6.9 Reconcile the Expert unsubscribe policyβ
Current code attaches List-Unsubscribe + footer to every Expert applicant/onboarding email (all guarded by isEmailSuppressed). The Β§2.1 contract is stricter: unsubscribe belongs on nurture mail, not decision/action mail. The Β§3 Unsub? column and the Β§4 "Unsubscribe present" notes describe the current (broad) behavior; this table is the target the refactor builds to:
| Class | Emails | Unsubscribe (target) |
|---|---|---|
| Nurture (repeatable nudges) | 4.8 waitlist, 4.11 interview reminder, 4.14 24h reminder, 4.15 72h reminder | Keep (with List-Unsubscribe) |
| Decision / action (one-shot; recipient needs it) | 4.9 qualified, 4.10 interview invite, 4.12 decline, 4.13 kickoff, 4.17 completion, 4.18 assignment | Drop (transactional) |
Decline (4.12) is a one-shot rejection β unsubscribe is moot; drop it for consistency. Implemented in #1257: the decision/action row above is now reclassified as strictly transactional in EmailService β those six methods no longer attach List-Unsubscribe/footer and are no longer gated by the isEmailSuppressed opt-out (so an Expert always learns of a qualify/invite/kickoff/completion/assignment/decline, even after opting out of nurture mail). The nurture row keeps List-Unsubscribe. Treat the Β§4 "Unsubscribe present" notes as pre-#1257 state.
7. Brand treatment referenceβ
Five treatments. All inherit the brand tokens in branding.md; they differ in header band, accent, and CTA emphasis. (Targets β the current code does not yet use these tokens; #1252 ships the primitives.)
| Treatment | Used by | Header band | Accent / CTA | Notes |
|---|---|---|---|---|
| CLIENT | 4.1β4.7 | Calm brand header, Bright-work canvas | Claret primary button | Warmest; one prominent CTA. |
| EXPERT | 4.8β4.10, 4.12β4.14, 4.17β4.18 | Brand header | Claret primary button | Peer tone; button optional on pure-status mail. |
| EXPERT β urgency | 4.11, 4.15 | Warning band (claret/amber, not raw red) | Claret button | Expiry / final-reminder states only. |
| AM / Ops | 4.19, 4.20 | Distinct ops eyebrow + denser header | Link-into-/ops/* button | Must be unmistakably internal. Base = today's sendKycFailedToOps identity. |
| SYSTEM | 4.21, 4.22 | Minimal brand header | No CTA (code is the payload) | Security-neutral; no marketing, no unsubscribe. |
8. Sign-offβ
Per the issue's acceptance criteria, this IA must be signed off by stakeholders before #1254 (copy) begins.
| Stakeholder | Role | Status |
|---|---|---|
| E | Product / requester | β pending |
| AM lead | Account-management voice (CLIENT + AM mail) | β pending |
| Expert ops | Expert-network voice (EXPERT mail) | β pending |
Reviewers: validate the audience tags in Β§3, the split decisions in Β§6.1, the wire-or-delete calls in Β§6.4, and the gap priorities in Β§5. Once signed off, #1254 writes copy against Β§4 and #1255 builds templates against Β§4 + Β§7.
Appendix A β Method β audience quick mapβ
CLIENT sendInviteEmail*, sendOnboardingInviteEmail, sendWelcomeEmail,
sendWeeklyCallScheduledEmail, sendOnboardingCallReminderEmail,
sendTrialEndingEmailβ‘, sendPaymentFailedEmailβ‘
EXPERT sendExpertWaitlistEmail, sendExpertQualifiedEmail,
sendExpertInterviewInviteEmail, sendExpertInterviewReminderEmail,
sendExpertDeclineEmail, sendExpertAssignmentEmail,
sendExpertOnboardingKickoff, sendExpertOnboardingReminder24hβ‘,
sendExpertOnboardingReminder72hβ‘, sendExpertOnboardingCompletionβ‘,
sendExpertOnboardingKickoffEmailβ‘β(delete)
AM sendAmCompletionNotification, sendKycFailedToOpsβ‘
SYSTEM sendOtpEmail, sendAdminInitiatedPasswordResetEmail
* also rendered for EXPERT/AM staff invites today β must split (Β§6.1)
β‘ implemented but not wired to a trigger (Β§6.4)
β dead/duplicate β delete (Β§6.4)