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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user