Last week PubTrivia started being able to send product update emails. Everything a developer thinks of as the work was small. The database change is one line:
-- utils/db/migrations/0014_panoramic_joseph.sql
ALTER TABLE "users_table" ADD COLUMN "email_updates" boolean DEFAULT true NOT NULL;
Eighty-three bytes, the fifteenth migration in the project. Nothing in the build runs it: db:migrate is an npm script somebody types, and next build has no idea the migrations folder exists, so the column exists in TypeScript before it exists in Postgres and somebody has to close that gap on purpose. That ordering is deliberate. A deploy that silently alters a table is a deploy you cannot review.
The expensive part of that line is the fifth word.
DEFAULT true is the decision
DEFAULT true means every account that already existed was opted in to marketing email by a DDL statement, without being asked. DEFAULT false means launching with a list of nobody and emailing everybody once to ask them to join it, which, since the only channel you have for that request is the email they have not agreed to receive, is a chicken and egg problem with a legal flavour.
Under UK GDPR these are two different lawful bases, not two defaults. DEFAULT false and a tick box is consent, Article 6(1)(a). DEFAULT true for people who are already customers is legitimate interest, Article 6(1)(f), and it is only defensible if three other things are true:
- every message carries an unsubscribe,
- the preference is switchable inside the product, not only by replying to an email,
- the privacy notice says this is happening and on what basis.
So the one-line migration pulled in a React component, a server action, a public page, and an edit to a legal document. The code and the document shipped in the same commit, which is the only part of this I would call a technique.
The paragraph
Our privacy policy is not a CMS entry. It is app/legal/privacy/page.tsx, a page like any other, with a constant at the top:
const LAST_UPDATED = "4 November 2026"
The diff of the commit that added the column changes that constant from "3 October 2026" and inserts this, which you can read on the live page under section 2.1:
We also use your name and email address to send occasional product update emails about new features and question packs. Every one of these has an unsubscribe link, and you can switch them off at any time under Settings, Email. Switching them off does not stop the emails your account needs, such as sign-in links and receipts. The legal basis for these updates is our legitimate interest (Article 6(1)(f) UK GDPR) in telling customers about the service they use.
Three sentences, each of which is a promise about code: a link in every send, a control in the product, and a boundary between marketing mail and account mail. The fourth sentence names the basis, which is the bit most privacy pages leave to the imagination.
Keeping the policy in the repository is why the date moved at all. When the document lives somewhere a developer never opens, the column ships on Tuesday and the paragraph arrives when somebody remembers, which in practice means after a complaint.
Where the flag is actually enforced
Three places read the column, and only one of them matters:
/** Opted-in recipients, de-duplicated by address. */
export async function getMailingRecipients() {
const rows = await db
.select({ name: usersTable.name, email: usersTable.email })
.from(usersTable)
.where(eq(usersTable.email_updates, true))
.orderBy(asc(usersTable.email))
// ... then a Set that drops duplicates after trimming and lowercasing
}
The opt-out is enforced in the one query that can produce a recipient list. There is no code path that builds an audience any other way, so there is no per-message filter anybody can forget to apply. The second reader is an admin count, which is the same predicate as an aggregate filter rather than a second definition of "opted in":
count(*) filter (where email_updates)::int
The third is the settings page, and it introduces the only duplication worth flagging. The default true is now written three times: once in the migration, once in the Drizzle column definition, and once here, as a fallback for a user row that has not loaded:
<EmailUpdatesForm enabled={userRecord?.email_updates ?? true} />
Three statements of one fact. The migration and the schema will not disagree, because Drizzle generated the first from the second. The ?? true is a hand-written copy of a database default, and if the default ever flips, that is where it will be wrong.
The switch, and the sentence it repeats
The control itself is 64 lines, optimistic, and rolls back when the action fails:
function toggle() {
const next = !enabled
setEnabled(next)
startTransition(async () => {
const result = await updateEmailUpdates(next)
if (!result.success) {
setEnabled(!next)
setError(result.message)
}
})
}
It is a role="switch" with aria-checked and an aria-labelledby pointing at its own label, because a div that toggles is not a switch to a screen reader. And its description says:
An occasional email about new features and question packs. Emails about your account, billing and team invites still arrive either way.
Which is the same promise as the third sentence of the privacy paragraph, written again, in a different file, in different words, by the same person on the same day. Nothing connects them. If someone later decides receipts should be switchable too, there are two places that say otherwise and no test that knows it.
I do not have a clean fix for that. Importing legal copy into a UI component is worse than the duplication. The honest version is to accept that a product has two audiences for the same commitment and to put the pair in a checklist rather than in a type system.
Check it yourself
Read the privacy page: section 2.1 is the paragraph above, section 2.2 is the separate question of what a quiz player's data is, and section 4 says how long each category is kept. All of it is a page in the repository, so its "last updated" line is only ever as honest as a commit.
The other end of the column is public too. The unsubscribe page needs no account, because the link in an email has to work for somebody who is not signed in. Visit it with nothing attached and it tells you the link is incomplete. Visit it with made-up parameters:
https://pub-trivia.app/unsubscribe?uid=abc&token=deadbeef
and it renders the confirm button, because the page does not pretend to know whether a link is genuine until you ask it to act. Press the button and it refuses: the token is a signature, not an identifier, and abc is not an account. Nothing happens to anybody's row, and that is the demonstration.
One column, five files, one paragraph, and a default that was never really about SQL.
Top comments (0)