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
@@ -0,0 +1,63 @@
-- Embedded mail: one shared IMAP/SMTP server configured by admins, with a
-- per-user mailbox login.
--
-- Threat model drives the shape here. A mailbox password grants full read and
-- send access to someone's email, so:
--
-- * user_mailboxes has RLS enabled and NO policies at all. Nothing reachable
-- from a browser can read it — not even the mailbox owner, and not admins.
-- Every access goes through server functions using the service role, the
-- same pattern intake_tokens uses.
-- * The password is never stored as plaintext. It is sealed with AES-256-GCM
-- using MAIL_CRED_KEY from .env.secret, so a database dump alone is not
-- enough to open anyone's mail.
-- * The IMAP/SMTP *host* settings are not secret, so admins may read those
-- directly to populate the setup form.
CREATE TABLE IF NOT EXISTS public.mail_server_settings (
-- Single-row table: the school has one mail server.
id BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (id = TRUE),
imap_host TEXT NOT NULL,
imap_port INT NOT NULL DEFAULT 993,
imap_secure BOOLEAN NOT NULL DEFAULT TRUE, -- implicit TLS on connect
smtp_host TEXT NOT NULL,
smtp_port INT NOT NULL DEFAULT 587,
smtp_secure BOOLEAN NOT NULL DEFAULT FALSE, -- false = STARTTLS upgrade
updated_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
GRANT SELECT ON public.mail_server_settings TO authenticated;
GRANT ALL ON public.mail_server_settings TO service_role;
ALTER TABLE public.mail_server_settings ENABLE ROW LEVEL SECURITY;
-- Host/port are operational config, not secrets. Admins read them to fill in
-- the setup form; writes go through a server function so they can be validated
-- against a live login first.
CREATE POLICY "mail settings admin read" ON public.mail_server_settings FOR SELECT TO authenticated
USING (public.current_user_has_role('admin'));
CREATE TABLE IF NOT EXISTS public.user_mailboxes (
user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
email TEXT NOT NULL,
-- AES-256-GCM sealed mailbox password. Never returned to any client.
secret_ciphertext TEXT NOT NULL,
secret_iv TEXT NOT NULL,
secret_tag TEXT NOT NULL,
last_verified_at TIMESTAMPTZ,
created_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_user_mailboxes_email ON public.user_mailboxes(email);
GRANT ALL ON public.user_mailboxes TO service_role;
ALTER TABLE public.user_mailboxes ENABLE ROW LEVEL SECURITY;
-- Deliberately no policies and no grant to `authenticated`: credentials must
-- never be reachable from the browser under any role.
DROP TRIGGER IF EXISTS trg_mail_settings_upd ON public.mail_server_settings;
CREATE TRIGGER trg_mail_settings_upd BEFORE UPDATE ON public.mail_server_settings
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();
DROP TRIGGER IF EXISTS trg_user_mailboxes_upd ON public.user_mailboxes;
CREATE TRIGGER trg_user_mailboxes_upd BEFORE UPDATE ON public.user_mailboxes
FOR EACH ROW EXECUTE FUNCTION public.set_updated_at();