Problem
newsletter_emails.email is stored in plaintext, while users.emailis encrypted (routes/auth.py:477,508). Same data type, same sensitivity, two different answers. 4 rows in production.
CREATETABLEIF NOT EXISTS newsletter_emails (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email textNOT NULL UNIQUE, -- plaintext, UNIQUE, indexed
created_at timestamptzNOT NULL DEFAULT now()
);
CREATEINDEXIF NOT EXISTS newsletter_emails_email_idx ON newsletter_emails (email);
Written unencrypted at routes/newsletter.py:27 (upsert) and read/updated in routes/admin.py:466,478,494.
Why this is a decision, not a one-line fix
Encryption breaks this table's constraints.services/encryption.py uses AES-256-GCM with a fresh 12-byte random nonce per call, so the same address encrypts to different ciphertext every time. That means:
- The
UNIQUE constraint stops working — the same address could be inserted repeatedly, each time as different bytes. newsletter_emails_email_idx becomes useless; you cannot look up a subscriber by address.- The upsert at
routes/newsletter.py:27 relies on conflict detection against email, so it would silently start creating duplicates rather than updating.
This is the same reason a naive "encrypt everything" pass is wrong. There are three defensible outcomes and the issue is to pick one:
- Add a deterministic lookup column — e.g.
email_hash TEXT UNIQUE (HMAC-SHA256 of the normalised address under a server-side key), move the constraint and index there, and encrypt email. Preserves dedupe and lookup, costs a migration and a backfill. - Leave it plaintext, deliberately, and record the reasoning — a marketing subscription list is arguably lower-sensitivity than an account identity, and the table has no other user data attached.
- Stop storing it here and hand subscription off to whatever sends the newsletter.
Acceptance
Parent: see the encryption-coverage epic. Verified against origin/main on 2026-08-02.
Problem
newsletter_emails.emailis stored in plaintext, whileusers.emailis encrypted (routes/auth.py:477,508). Same data type, same sensitivity, two different answers. 4 rows in production.Written unencrypted at
routes/newsletter.py:27(upsert) and read/updated inroutes/admin.py:466,478,494.Why this is a decision, not a one-line fix
Encryption breaks this table's constraints.
services/encryption.pyuses AES-256-GCM with a fresh 12-byte random nonce per call, so the same address encrypts to different ciphertext every time. That means:UNIQUEconstraint stops working — the same address could be inserted repeatedly, each time as different bytes.newsletter_emails_email_idxbecomes useless; you cannot look up a subscriber by address.routes/newsletter.py:27relies on conflict detection againstemail, so it would silently start creating duplicates rather than updating.This is the same reason a naive "encrypt everything" pass is wrong. There are three defensible outcomes and the issue is to pick one:
email_hash TEXT UNIQUE(HMAC-SHA256 of the normalised address under a server-side key), move the constraint and index there, and encryptemail. Preserves dedupe and lookup, costs a migration and a backfill.Acceptance
docs/decisions/if option 1 or 3)newsletter.pyandadmin.pyupdated, existing 4 rows backfilledCLAUDE.mdnotes the intentional exception next to the encrypted-columns listusers.emailis no longer silentParent: see the encryption-coverage epic. Verified against
origin/mainon 2026-08-02.