Add embedded IMAP/SMTP mail for staff

Admins configure one mail server; each staff member gets their own mailbox
login. Read, reply and compose against real IMAP/SMTP.

IMAP and SMTP are raw TCP, so none of this can run in a browser — every
operation is a TanStack Start server function. mail.functions.ts ships to
the client bundle, so imapflow/nodemailer/mailparser and the crypto
helpers are imported inside handlers, never at the top level. Verified
that Nitro inlines all three into .output/server/_libs, since the Docker
runner stage copies only .output and has no node_modules.

Note this ties the app to the Node deployment: the default local build
targets Cloudflare Workers, which cannot open IMAP sockets.

Credential handling, since a mailbox password grants full read and send
access to someone's mail:

- user_mailboxes has RLS enabled, no policies, and SELECT revoked from
  anon and authenticated. Verified: teacher and admin both see zero rows
  and no ciphertext; only service_role can read it. The revoke is belt and
  braces — Supabase's default privileges had granted SELECT, leaving the
  table one stray policy away from leaking.
- Passwords are sealed with AES-256-GCM using MAIL_CRED_KEY from
  .env.secret, so a database dump alone opens nothing. GCM also makes
  tampering fail the auth tag instead of decrypting to garbage.
- Provisioning verifies credentials against the live IMAP server before
  storing them, so typos surface at setup rather than as a broken inbox.

Message bodies render as plain text; sender HTML is never injected, which
would execute sender-controlled markup and leak read receipts via
tracking pixels.

Mailboxes are limited to admins and teachers. Students are excluded
deliberately — external mail for minors carries archiving, monitoring and
consent obligations that should be chosen, not inherited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 10:46:47 -04:00
co-authored by Claude Opus 5
parent 53ab6b92b5
commit d2d4e49fa6
9 changed files with 1444 additions and 1 deletions
+21
View File
@@ -17,6 +17,7 @@ import { Route as AuthenticatedStudentsRouteImport } from './routes/_authenticat
import { Route as AuthenticatedReportsRouteImport } from './routes/_authenticated/reports'
import { Route as AuthenticatedPlansRouteImport } from './routes/_authenticated/plans'
import { Route as AuthenticatedMessagesRouteImport } from './routes/_authenticated/messages'
import { Route as AuthenticatedMailRouteImport } from './routes/_authenticated/mail'
import { Route as AuthenticatedLedgerRouteImport } from './routes/_authenticated/ledger'
import { Route as AuthenticatedFormsRouteImport } from './routes/_authenticated/forms'
import { Route as AuthenticatedDashboardRouteImport } from './routes/_authenticated/dashboard'
@@ -70,6 +71,11 @@ const AuthenticatedMessagesRoute = AuthenticatedMessagesRouteImport.update({
path: '/messages',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedMailRoute = AuthenticatedMailRouteImport.update({
id: '/mail',
path: '/mail',
getParentRoute: () => AuthenticatedRouteRoute,
} as any)
const AuthenticatedLedgerRoute = AuthenticatedLedgerRouteImport.update({
id: '/ledger',
path: '/ledger',
@@ -149,6 +155,7 @@ export interface FileRoutesByFullPath {
'/dashboard': typeof AuthenticatedDashboardRoute
'/forms': typeof AuthenticatedFormsRoute
'/ledger': typeof AuthenticatedLedgerRoute
'/mail': typeof AuthenticatedMailRoute
'/messages': typeof AuthenticatedMessagesRoute
'/plans': typeof AuthenticatedPlansRoute
'/reports': typeof AuthenticatedReportsRoute
@@ -170,6 +177,7 @@ export interface FileRoutesByTo {
'/dashboard': typeof AuthenticatedDashboardRoute
'/forms': typeof AuthenticatedFormsRoute
'/ledger': typeof AuthenticatedLedgerRoute
'/mail': typeof AuthenticatedMailRoute
'/messages': typeof AuthenticatedMessagesRoute
'/plans': typeof AuthenticatedPlansRoute
'/reports': typeof AuthenticatedReportsRoute
@@ -193,6 +201,7 @@ export interface FileRoutesById {
'/_authenticated/dashboard': typeof AuthenticatedDashboardRoute
'/_authenticated/forms': typeof AuthenticatedFormsRoute
'/_authenticated/ledger': typeof AuthenticatedLedgerRoute
'/_authenticated/mail': typeof AuthenticatedMailRoute
'/_authenticated/messages': typeof AuthenticatedMessagesRoute
'/_authenticated/plans': typeof AuthenticatedPlansRoute
'/_authenticated/reports': typeof AuthenticatedReportsRoute
@@ -217,6 +226,7 @@ export interface FileRouteTypes {
| '/dashboard'
| '/forms'
| '/ledger'
| '/mail'
| '/messages'
| '/plans'
| '/reports'
@@ -238,6 +248,7 @@ export interface FileRouteTypes {
| '/dashboard'
| '/forms'
| '/ledger'
| '/mail'
| '/messages'
| '/plans'
| '/reports'
@@ -260,6 +271,7 @@ export interface FileRouteTypes {
| '/_authenticated/dashboard'
| '/_authenticated/forms'
| '/_authenticated/ledger'
| '/_authenticated/mail'
| '/_authenticated/messages'
| '/_authenticated/plans'
| '/_authenticated/reports'
@@ -338,6 +350,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedMessagesRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/mail': {
id: '/_authenticated/mail'
path: '/mail'
fullPath: '/mail'
preLoaderRoute: typeof AuthenticatedMailRouteImport
parentRoute: typeof AuthenticatedRouteRoute
}
'/_authenticated/ledger': {
id: '/_authenticated/ledger'
path: '/ledger'
@@ -470,6 +489,7 @@ interface AuthenticatedRouteRouteChildren {
AuthenticatedDashboardRoute: typeof AuthenticatedDashboardRoute
AuthenticatedFormsRoute: typeof AuthenticatedFormsRoute
AuthenticatedLedgerRoute: typeof AuthenticatedLedgerRoute
AuthenticatedMailRoute: typeof AuthenticatedMailRoute
AuthenticatedMessagesRoute: typeof AuthenticatedMessagesRoute
AuthenticatedPlansRoute: typeof AuthenticatedPlansRoute
AuthenticatedReportsRoute: typeof AuthenticatedReportsRoute
@@ -485,6 +505,7 @@ const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = {
AuthenticatedDashboardRoute: AuthenticatedDashboardRoute,
AuthenticatedFormsRoute: AuthenticatedFormsRoute,
AuthenticatedLedgerRoute: AuthenticatedLedgerRoute,
AuthenticatedMailRoute: AuthenticatedMailRoute,
AuthenticatedMessagesRoute: AuthenticatedMessagesRoute,
AuthenticatedPlansRoute: AuthenticatedPlansRoute,
AuthenticatedReportsRoute: AuthenticatedReportsRoute,