From 0b37b4b1224c65f540472645b162e3f3051e5cdc Mon Sep 17 00:00:00 2001 From: Danial Beg Date: Sun, 27 Sep 2026 20:27:24 -0700 Subject: [PATCH] Stop promising an email nobody is sending MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings switch defaulted to on, and the digest function is a deliberate no-op without EMAIL_PROVIDER_KEY / EMAIL_FROM. So a student who never opened Settings had been told they would be emailed when a deadline was within a week, and nothing ever was — the one promise a deadline tool most needs to keep, and the highest-severity item in the readiness review. It is off until mail is live. The two sides also disagreed about what "unset" meant, which would have bitten at exactly the wrong moment. The web app read no stored value as on; the function skipped only an explicit false. Adding a provider key would therefore have mailed every student who never opened Settings — people who had been shown a switch already on but never chose it. Both now require an explicit true, so only a deliberate opt-in gets mail. Flip DEFAULT_PREFERENCES.email_reminders back once a provider is configured; that governs new accounts, and anyone who already chose keeps their choice. --- supabase/functions/deadline-digest/README.md | 11 +++++++++++ supabase/functions/deadline-digest/index.ts | 7 +++++-- web/src/lib/preferences.test.ts | 18 +++++++++++++++++- web/src/lib/preferences.ts | 7 ++++++- 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/supabase/functions/deadline-digest/README.md b/supabase/functions/deadline-digest/README.md index b81e09a..0a30dc4 100644 --- a/supabase/functions/deadline-digest/README.md +++ b/supabase/functions/deadline-digest/README.md @@ -69,3 +69,14 @@ The cron job fires daily at 13:00 UTC. The function refuses to mail the same student twice within 20 hours, stamped in `profiles.settings.digest_last_sent_at` only after a successful send — so a provider outage retries tomorrow instead of skipping that student for the day. + +## Who gets mail + +Only students whose `preferences.email_reminders` is explicitly `true`. An +unset preference means no mail: the web app now defaults the switch off until +a provider is configured, and the two have to agree. Treating unset as "send" +would mail everyone who never opened Settings the moment a key was added. + +To make it the default for new accounts once mail is live, flip +`DEFAULT_PREFERENCES.email_reminders` in `web/src/lib/preferences.ts`. That +governs new users; anyone who already saved a preference keeps theirs. diff --git a/supabase/functions/deadline-digest/index.ts b/supabase/functions/deadline-digest/index.ts index ad1c864..7570ca3 100644 --- a/supabase/functions/deadline-digest/index.ts +++ b/supabase/functions/deadline-digest/index.ts @@ -185,8 +185,11 @@ Deno.serve(async (req) => { for (const profile of profiles ?? []) { const settings = (profile.settings ?? {}) as Record const prefs = (settings.preferences ?? {}) as Record - // Unset means on, matching DEFAULT_PREFERENCES in the web app. - if (prefs.email_reminders === false) { skipped++; continue } + // Only an explicit opt-in gets mail, matching DEFAULT_PREFERENCES in the + // web app. Unset used to mean "send", which would have mailed every + // student who never opened Settings the moment a provider key was added — + // people who had been shown the switch was on but never chose it. + if (prefs.email_reminders !== true) { skipped++; continue } if (typeof profile.email !== "string" || !profile.email) { skipped++; continue } const lastSent = (settings.digest_last_sent_at ?? null) as string | null diff --git a/web/src/lib/preferences.test.ts b/web/src/lib/preferences.test.ts index 7f23044..64fd4c9 100644 --- a/web/src/lib/preferences.test.ts +++ b/web/src/lib/preferences.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest' -import { resolveDeadlinePreferences, URGENT_WINDOWS } from './preferences' +import { resolveDeadlinePreferences, resolvePreferences, DEFAULT_PREFERENCES, URGENT_WINDOWS } from './preferences' import type { UserSettings } from '../types/user' const settings = (preferences: Record) => @@ -63,3 +63,19 @@ describe('resolveDeadlinePreferences', () => { expect(resolveDeadlinePreferences(settings({ deadline_modules: 'all' })).modules).toEqual([]) }) }) + +describe('email reminders are opt-in', () => { + it('is off for a student who has never opened Settings', () => { + // The digest function is a deliberate no-op without a mail provider, so + // defaulting this on told every new student they would be emailed before + // a deadline and then sent nothing. It also has to stay in step with + // deadline-digest, which mails only an explicit `true`: were they to + // disagree, adding a provider key would mail everyone who never chose it. + expect(DEFAULT_PREFERENCES.email_reminders).toBe(false) + }) + + it('keeps a student\'s own choice', () => { + expect(resolvePreferences(settings({ email_reminders: true })).email_reminders).toBe(true) + expect(resolvePreferences(settings({ email_reminders: false })).email_reminders).toBe(false) + }) +}) diff --git a/web/src/lib/preferences.ts b/web/src/lib/preferences.ts index 7cf3c8b..066359c 100644 --- a/web/src/lib/preferences.ts +++ b/web/src/lib/preferences.ts @@ -38,7 +38,12 @@ export function enabledDeadlineModules(prefs: UserPreferences): string[] { } export const DEFAULT_PREFERENCES: Required = { - email_reminders: true, + // Off until a mail provider is configured. The digest function is a + // deliberate no-op without EMAIL_PROVIDER_KEY / EMAIL_FROM, so defaulting + // this on tells every student who never opens Settings that they will be + // emailed before a deadline, and then sends nothing — the one promise a + // deadline tool most needs to keep. Flip it back once mail is live. + email_reminders: false, theme: 'light', timeline_show_completed: true, timeline_auto_advance: true,