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>
The database already permitted all three (forms admin manage is FOR ALL);
this was purely a missing UI. Create and edit now share one editor, since
the only differences are the initial values and insert vs update.
Three hazards in the existing data model shaped this rather than just
adding buttons:
- form_responses.form_id is ON DELETE CASCADE, so deleting a form
permanently destroys its submissions. The confirmation names the
response count and points to deactivating instead. Verified: deleting a
form with two responses leaves zero.
- The list filtered active = true for everyone, so deactivating a form
hid it from the only people who could reactivate it. Admins now see
inactive forms with a badge; families still see only active ones.
- Responses key their answers by field *label* in data_json, so renaming
a field strands existing answers under the old key. The editor warns
when the form already has responses. Deliberately not migrating old
answers — guessing which old label maps to which new one would risk
silently rewriting submitted data.
Also strips fields with blank labels on save, shows a response count per
form, and gives the response list an empty state.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>