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
+72
View File
@@ -531,6 +531,42 @@ export type Database = {
},
]
}
mail_server_settings: {
Row: {
id: boolean
imap_host: string
imap_port: number
imap_secure: boolean
smtp_host: string
smtp_port: number
smtp_secure: boolean
updated_at: string
updated_by: string | null
}
Insert: {
id?: boolean
imap_host: string
imap_port?: number
imap_secure?: boolean
smtp_host: string
smtp_port?: number
smtp_secure?: boolean
updated_at?: string
updated_by?: string | null
}
Update: {
id?: boolean
imap_host?: string
imap_port?: number
imap_secure?: boolean
smtp_host?: string
smtp_port?: number
smtp_secure?: boolean
updated_at?: string
updated_by?: string | null
}
Relationships: []
}
message_threads: {
Row: {
created_at: string
@@ -1298,6 +1334,42 @@ export type Database = {
},
]
}
user_mailboxes: {
Row: {
created_at: string
created_by: string | null
email: string
last_verified_at: string | null
secret_ciphertext: string
secret_iv: string
secret_tag: string
updated_at: string
user_id: string
}
Insert: {
created_at?: string
created_by?: string | null
email: string
last_verified_at?: string | null
secret_ciphertext: string
secret_iv: string
secret_tag: string
updated_at?: string
user_id: string
}
Update: {
created_at?: string
created_by?: string | null
email?: string
last_verified_at?: string | null
secret_ciphertext?: string
secret_iv?: string
secret_tag?: string
updated_at?: string
user_id?: string
}
Relationships: []
}
user_roles: {
Row: {
created_at: string