System logic

Email

Email

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.

GroupTypeToSent when
Account & accessWelcome — schoolschoola school is created
Welcome — vendorvendora vendor is listed
Welcome — school staffschoolsomeone is given portal access (also used for platform admins)
Password reset link *(always on)*anyonesomeone asks to reset a forgotten password
Sign-in link, passwordless *(always on)*anyonesomeone asks to sign in without a password
Password changedanyonea password is changed or reset
Single sign-on switched onschoola school switches SSO on — sent to every member of staff, not only admins
PipelineNew lead — vendorvendora school opens a conversation
New message on a leadvendorthe other side writes
Vetting decisionvendoran application is scored and decided
ChallengesChallenge awaiting approvaladmina school posts one and approval is required
Challenge publishedschoolit goes live to vendors
Challenge needs changesschoolit is sent back, with the reason
New challenge for youvendora matching challenge is published
Proposal receivedschoola vendor responds
Parent questionsGuardian questionschoola guardian asks something
School answeredguardianthe school resolves it
PlatformNew enquiryadminthe public contact form is used
Enquiry acknowledgementthe sendersame moment
Escalation alertadminsomething passes its threshold (§7)
Support session startedschoola platform admin opens their portal
Trial credits adjustedschoolEduSpaze 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:

StoredNot stored
the type, the template version, the provider, the outcomethe rendered body
the recipient maskedj*@g*.comthe address itself
a salted SHA-256 of the address, so an exact address can still be looked upanything reversible
the subject with addresses, long digit runs and known personal values maskedthe 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:

ModeBehaviour
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 immediatelyStraight to matched vendors.
Let AI screen, escalate the doubtfulThe 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.

CheckDefault
Parent question unanswered5 days
Challenge awaiting approval2 days
Lead untouched (still NEW)7 days
Vetting application unscored10 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.

Source: docs/email.md in the repository. Last updated with the code it describes.

Email — EduSpaze