Every email the platform sends: what it is, who can switch it off, how the wording is edited and versioned, and what is written down about a message that has gone out. Last updated 2026-09-05.
1. Shape of the system
action (someone did something)
│
▼
sendEmail({ kind, to, vars }) lib/email/index.ts
│
├─ template: stored row, else the shipped default lib/email/registry.ts
├─ switched off? → logged SUPPRESSED, nothing delivered
├─ render markdown + {{variables}} lib/email/render.ts
├─ provider: Resend / SMTP / simulated lib/email/provider.ts
└─ log (masked) + audit event lib/email/mask.ts
A failed email never fails the action that triggered it. sendEmail swallows its own errors and records them; the school still gets its account, the lead is still created.
2. The emails
Configured at Admin → Settings → Email. Sign-in and password emails are marked *always on*, because switching them off would lock people out.
| Group | Type | To | Sent when |
|---|---|---|---|
| Account & access | Welcome — school | school | a school is created |
| Welcome — vendor | vendor | a vendor is listed | |
| Welcome — school staff | school | someone is given portal access (also used for platform admins) | |
| Password reset link *(always on)* | anyone | someone asks to reset a forgotten password | |
| Sign-in link, passwordless *(always on)* | anyone | someone asks to sign in without a password | |
| Password changed | anyone | a password is changed or reset | |
| Single sign-on switched on | school | a school switches SSO on — sent to every member of staff, not only admins | |
| Pipeline | New lead — vendor | vendor | a school opens a conversation |
| New message on a lead | vendor | the other side writes | |
| Vetting decision | vendor | an application is scored and decided | |
| Challenges | Challenge awaiting approval | admin | a school posts one and approval is required |
| Challenge published | school | it goes live to vendors | |
| Challenge needs changes | school | it is sent back, with the reason | |
| New challenge for you | vendor | a matching challenge is published | |
| Proposal received | school | a vendor responds | |
| Parent questions | Guardian question | school | a guardian asks something |
| School answered | guardian | the school resolves it | |
| Platform | New enquiry | admin | the public contact form is used |
| Enquiry acknowledgement | the sender | same moment | |
| Escalation alert | admin | something passes its threshold (§7) | |
| Support session started | school | a platform admin opens their portal | |
| Trial credits adjusted | school | EduSpaze adds or removes credits |
Adding one is an entry in lib/email/registry.ts plus the sendEmail call site. The settings page, the editor and the preview all render from the registry.
3. Password reset and passwordless sign-in
Both issue a single-use token (lib/auth-tokens.ts). Only a SHA-256 hash is stored, so a copy of the database cannot be used to sign in as anyone; the token itself exists only in the email. Reset links last 60 minutes, sign-in links 15, and issuing a new one spends the account's earlier unused tokens for that purpose.
Both forms answer identically whether or not the address has an account, so neither can be used to find out who is on the platform. Pupil launcher accounts are excluded: they have no email, and their PINs are reset by their school.
4. Templates and version control
The wording lives in EmailTemplate; the shipped default in the registry is what a type uses until someone edits it.
- Every save writes an
EmailTemplateVersion. The first edit of a type records the shipped default as version 1 so the history starts from what actually went out. - The version list shows the subject, the body, who changed it, when, and the note they left. Any earlier version can be viewed in place and restored.
- Restoring is a save, not a rewind: it writes the old wording as a new version, so history only ever grows and the audit trail stays honest.
- A template may only use the placeholders its type declares. Anything else is refused with the list of what is available, so a broken template cannot ship.
5. PDPA and what is written down
An email carries a person's name, address, and often the reason we are writing. None of that belongs in a log that many people can read, but "did we email them, when, and which version?" has to be answerable. So for every message we store:
| Stored | Not stored |
|---|---|
| the type, the template version, the provider, the outcome | the rendered body |
the recipient masked — j*@g*.com | the address itself |
| a salted SHA-256 of the address, so an exact address can still be looked up | anything reversible |
| the subject with addresses, long digit runs and known personal values masked | the subject verbatim |
The same masked facts go to the audit trail as an email.sent / email.suppressed / email.failed domain event, so a delivery appears in Admin → Audit beside everything else that happened, and a school's own log shows the mail sent about that school.
Emails that would otherwise carry personal data say so instead of including it: a guardian's question and a school's answer are described in the email and read in the portal.
6. Challenge publishing
A challenge is only visible to vendors once approved. How that happens is Admin → Settings → Modules → Challenge publishing:
| Mode | Behaviour |
|---|---|
| Approve before publishing (default) | Every challenge waits for a platform admin. The team is emailed; the school is told when it goes live or is sent back with a reason. |
| Publish immediately | Straight to matched vendors. |
| Let AI screen, escalate the doubtful | The model checks the statement is clear and names nobody. Clear ones publish themselves; anything doubtful waits for a person. |
The AI never rejects on its own — the worst outcome is a delay, not a lost challenge — and a regex backstop holds anything containing an address, a long digit run or a named child regardless of what the model says. With no API key the cautious rule applies: publish only short, clean statements, hold everything else.
7. Escalations
lib/email/escalations.ts gathers what has waited too long and sends one email per kind rather than one per item. Thresholds are settings.
| Check | Default |
|---|---|
| Parent question unanswered | 5 days |
| Challenge awaiting approval | 2 days |
| Lead untouched (still NEW) | 7 days |
| Vetting application unscored | 10 days |
Run it from Admin → Settings → Modules, or on a schedule alongside the audit outbox trim.
8. Delivery
No provider is configured by default: emails are rendered, logged and visible in the admin portal but not handed to a mail server, and the log records them as SIMULATED. Set RESEND_API_KEY (or SMTP_URL) and EMAIL_FROM to deliver for real. Everything else — templates, versions, suppression, masking, the audit trail — behaves the same either way, so switching a provider on changes delivery and nothing else.
