diff --git a/033_notification_email_prefs.sql b/033_notification_email_prefs.sql
new file mode 100644
index 0000000..cdc8c42
--- /dev/null
+++ b/033_notification_email_prefs.sql
@@ -0,0 +1,21 @@
+-- =====================================================================
+-- TeeCup — e-post-fallback for varsler, per type (migrasjon 033,
+-- 2026-07-28 oppfølging av varslingssenteret)
+-- =====================================================================
+-- Trygg standard: en bruker som ikke har gjort noe valg får ALDRI e-post
+-- (samme "se ingenting"-filosofi som resten av appen, ADR-018/036) --
+-- raden i user_notification_email_pref må EKSPLISITT finnes for at
+-- create_notification() (app/routers/notifications.py) skal sende e-post
+-- for akkurat den typen. Samme "tilstedeværelse = valgt"-mønster som
+-- round_visible_category (032) og friend_categorization (025) -- ingen
+-- egen boolean-kolonne per type, en rad per (bruker, type) i stedet.
+\set ON_ERROR_STOP on
+
+CREATE TABLE user_notification_email_pref (
+ user_id uuid NOT NULL REFERENCES app_user(id) ON DELETE CASCADE,
+ -- Samme faste sett som notification.type (026_notifications.sql).
+ type text NOT NULL CHECK (type IN ('friend', 'tournament', 'round', 'result')),
+ PRIMARY KEY (user_id, type)
+);
+
+GRANT SELECT, INSERT, UPDATE, DELETE ON user_notification_email_pref TO teecup_app;
diff --git a/CLAUDE.md b/CLAUDE.md
index 1936cc9..905e50c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4667,6 +4667,65 @@ Ferdig og verifisert:
(`Application startup complete`), `/health`/`/dashboard` → 200,
`teeoff.no` upåvirket.
+- **E-post-fallback for varsler, per type, BYGGET OG SCRATCH-VERIFISERT
+ (2026-07-28), samme dag, rett etter rundevarsel-runden:** brukeren ba
+ eksplisitt om at mottakeren selv skal kunne velge HVILKE varseltyper som
+ skal utløse e-post — ikke en enkelt global av/på-bryter. Ny migrasjon
+ `033_notification_email_prefs.sql`: `user_notification_email_pref`
+ (`user_id`, `type`, samme CHECK-sett som `notification.type`) — samme
+ "tilstedeværelse = valgt"-mønster som `round_visible_category`/
+ `friend_categorization`, TRYGG STANDARD ingen rad = ingen e-post for
+ noen type (samme "se ingenting til noen har valgt"-filosofi som resten
+ av appen).
+ **Bevisst ÉN felles innsnevring, ikke ett kallsted per varseltrigger:**
+ e-post-utsendingen ligger INNI `create_notification()` selv (etter selve
+ INSERT-en) — modul-docstringen kalte den allerede "den eneste
+ skrivevegen inn", så alle nåværende OG fremtidige varsel-triggere (i dag
+ 2 i friends.py, 4 i rounds.py) får e-post-støtte helt uten å røres,
+ ingen risiko for at et fremtidig kallsted glemmer det. Samme
+ `SMTP_CONFIGURED`-sjekk + try/except + `traceback.print_exc()`-mønster
+ som all annen e-postutsending i appen (routers/auth.py,
+ organizations.py) — en driftsfeil i selve SMTP-en skal ALDRI hindre at
+ in-app-varselet (allerede skrevet FØR e-post-forsøket) består.
+ Ny `send_notification_email()` i `app/email.py` — bevisst tospråklig KUN
+ i ramme-teksten (emne/hilsen/lenkeforklaring); selve `message`-teksten
+ er allerede en ferdig norsk snapshot-tekst (samme prinsipp som
+ in-app-varselet), ingen full i18n av selve varselinnholdet i denne
+ runden.
+ Nye `GET`/`PUT /notifications/email-prefs` — PUT er en FULL erstatning
+ (samme kontrakt som `PUT /friends/{id}/categories`), ikke en delvis
+ PATCH.
+ **Liten, men reell ryddejobb funnet FØR e-post-laget ble lagt på:**
+ forrige rundes `add_participant`-varsel (medspiller lagt til) lå INNI
+ den åpne DB-transaksjonen for selve deltaker-innsettingen — flyttet til
+ RETT ETTER (samme mønster som `create_round`/`complete_round` allerede
+ fulgte), slik at en fremtidig blokkerende SMTP-utsending aldri skjer
+ mens en transaksjon holder låser.
+ **Frontend:** ny seksjon "Varsler på e-post" i `/account`
+ (`NotificationEmailPrefsSection`, `account-settings.tsx`) — fire
+ avkrysningsbokser (Venneforespørsler/Runder/Resultater/Turneringer, sistnevnte
+ reservert for fremtidig bruk), samme avkrysningsboks-mønster som
+ kølle-bag-listen lenger opp i samme fil, lagrer umiddelbart ved hvert
+ klikk (ingen egen "lagre"-knapp).
+ **Scratch-verifisert grundig, 19/19 nye sjekker** (isolert
+ `teecup_app_scratch`-rolle + isolert scratch-MinIO + engangs API-
+ container, alle 33 migrasjoner kjørt friskt): trygg standard bekreftet
+ (ingen typer valgt fra start), full-erstatning bekreftet (PUT med ett
+ sett fjerner det forrige, legger ikke til), e-post-fallback FAKTISK
+ logget (dev-log-varianten av `SMTP_CONFIGURED`-grenen) for en bruker med
+ `round`/`result` valgt inn — BÅDE ved medspiller-tilføyelse og ved
+ fullføring — INGEN e-post til brukeren som selv utførte handlingen,
+ INGEN e-post til en bruker (eieren) som ikke har valgt inn NOE, og etter
+ å ha slått AV `result` igjen: ingen ny e-post ved neste fullføring MENS
+ in-app-varselet fortsatt opprettes helt uendret (de to er reelt
+ atskilte, ikke koblet). PLUSS full regresjon av fire eksisterende
+ testsuiter (rundevarsler 28/28, co-player-flyt 35/35, spillformat-flyt
+ 122/122, ADR-036 fase 2-synlighetsmatrise 34/34) — alle fortsatt 100 %
+ grønne, ingen bivirkning av at `add_participant`s varsel flyttet utenfor
+ transaksjonen. `test_isolation.sql` 12/12 uendret. Ekte typesjekket
+ produksjonsbuild (samme `Dockerfile` som deployes) kompilerte rent,
+ alle 24 ruter listet uendret.
+
Neste steg:
0a. **Spillerliste-redesign — nå FAKTISK nettleser-bekreftet
(2026-07-27, full 22-skjerms gjennomgang):** rendrer korrekt, ingen
diff --git a/app/email.py b/app/email.py
index d87e089..e53ac4e 100644
--- a/app/email.py
+++ b/app/email.py
@@ -222,3 +222,44 @@ async def send_secondary_email_verification(to_email: str, raw_token: str, local
link = f"{settings.PUBLIC_BASE_URL}/verify-email?token={raw_token}&kind=secondary"
body = template["body"].format(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES, link=link)
await to_thread(_send_sync, to_email, template["subject"], body)
+
+
+_NOTIFICATION_TEMPLATES = {
+ "nb": {
+ "subject": "Nytt varsel fra TeeCup",
+ "body": (
+ "Hei,\n\n"
+ "{message}\n\n"
+ "Se det i TeeCup:\n{link}\n\n"
+ "Du får denne e-posten fordi du har valgt å motta e-post for "
+ "denne typen varsler. Du kan endre dette når som helst under "
+ "Konto i TeeCup.\n"
+ ),
+ },
+ "en": {
+ "subject": "New notification from TeeCup",
+ "body": (
+ "Hi,\n\n"
+ "{message}\n\n"
+ "View it in TeeCup:\n{link}\n\n"
+ "You're receiving this because you opted in to email for this "
+ "notification type. You can change this anytime under Account "
+ "in TeeCup.\n"
+ ),
+ },
+}
+
+
+async def send_notification_email(to_email: str, message: str, link_path: str, locale: str = "nb") -> None:
+ """E-post-fallback for det in-app varslingssenteret (FEATURE_BACKLOG.md,
+ 2026-07-28 oppfølging) -- kun sendt når mottakeren selv har valgt inn
+ for akkurat DENNE varseltypen (`user_notification_email_pref`,
+ migrasjon 033), se `create_notification()` i
+ `app/routers/notifications.py`. `message` er allerede en ferdig, norsk
+ tekst (samme snapshot-prinsipp som selve in-app-varselet) -- kun
+ ramme-teksten (emne/hilsen/lenkeforklaring) er tospråklig her, ikke
+ selve budskapet."""
+ template = _NOTIFICATION_TEMPLATES.get(locale, _NOTIFICATION_TEMPLATES["nb"])
+ link = f"{settings.PUBLIC_BASE_URL}{link_path}"
+ body = template["body"].format(message=message, link=link)
+ await to_thread(_send_sync, to_email, template["subject"], body)
diff --git a/app/routers/notifications.py b/app/routers/notifications.py
index cb89b18..033ef5d 100644
--- a/app/routers/notifications.py
+++ b/app/routers/notifications.py
@@ -1,30 +1,47 @@
"""
-In-app varslingssenter (FEATURE_BACKLOG.md "Varsler"-runden, 2026-07-25).
+In-app varslingssenter (FEATURE_BACKLOG.md "Varsler"-runden, 2026-07-25) +
+e-post-fallback per type (2026-07-28 oppfølging, migrasjon 033).
Eid av BRUKER (mottaker), ikke organisasjon -- plain_connection(), samme
mønster som personlig profil/HCP-historikk/venner. `create_notification()`
-er den eneste skrivevegen inn -- kalt fra andre routere (i dag kun
-friends.py) ved konkrete hendelser, ikke noe generisk event-system.
+er den eneste skrivevegen inn -- kalt fra andre routere (friends.py,
+rounds.py) ved konkrete hendelser, ikke noe generisk event-system.
+
+Trygg standard (samme filosofi som visibility_mode='private' ellers i
+appen): en bruker som ikke har gjort noe valg får ALDRI e-post for noen
+type -- kun `user_notification_email_pref`-raden EN BRUKER selv setter
+(via PUT /notifications/email-prefs) slår e-post PÅ for akkurat den typen.
+E-post-utsendingen skjer INNI `create_notification()` selv (ikke i hvert
+enkelt kallsted) -- driftsfeil i utsendingen skal ALDRI hindre selve
+in-app-varselet fra å bli opprettet, samme mønster (SMTP_CONFIGURED-sjekk
++ try/except + traceback.print_exc()) som all annen e-post i appen
+(app/routers/auth.py, organizations.py).
"""
from __future__ import annotations
+import traceback
from typing import Literal
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from ..auth import CurrentUser, get_current_user
+from ..config import settings
from ..db import plain_connection
+from ..email import send_notification_email
router = APIRouter(tags=["notifications"])
NotificationType = Literal["friend", "tournament", "round", "result"]
+_NOTIFICATION_TYPES: tuple[NotificationType, ...] = ("friend", "tournament", "round", "result")
async def create_notification(conn, *, user_id: str, type: NotificationType, message: str, link_path: str) -> None:
"""Kalles fra andre routere sin egen `plain_connection()`/transaksjon --
- tar en allerede-åpen `conn`, åpner ikke en egen."""
+ tar en allerede-åpen `conn`, åpner ikke en egen. Sender i tillegg en
+ e-post-fallback hvis (og kun hvis) mottakeren selv har valgt inn for
+ akkurat DENNE typen."""
await conn.execute(
"INSERT INTO notification (user_id, type, message, link_path) VALUES ($1, $2, $3, $4)",
user_id,
@@ -33,6 +50,65 @@ async def create_notification(conn, *, user_id: str, type: NotificationType, mes
link_path,
)
+ wants_email = await conn.fetchval(
+ "SELECT EXISTS(SELECT 1 FROM user_notification_email_pref WHERE user_id = $1 AND type = $2)",
+ user_id,
+ type,
+ )
+ if not wants_email:
+ return
+ recipient = await conn.fetchrow(
+ "SELECT email, preferred_locale FROM app_user WHERE id = $1 AND email IS NOT NULL",
+ user_id,
+ )
+ if recipient is None:
+ return
+ if settings.SMTP_CONFIGURED:
+ try:
+ await send_notification_email(recipient["email"], message, link_path, recipient["preferred_locale"])
+ except Exception:
+ # Se modul-docstring: driftsfeil i utsendingen skal aldri
+ # hindre selve in-app-varselet, som allerede er skrevet over.
+ traceback.print_exc()
+ elif settings.DEV_LOG_MAGIC_LINKS:
+ print(f"[DEV] Varsel-e-post til {recipient['email']} ({type}): {message}", flush=True)
+
+
+class NotificationEmailPrefsOut(BaseModel):
+ types: list[str]
+
+
+class NotificationEmailPrefsUpdate(BaseModel):
+ types: list[NotificationType]
+
+
+@router.get("/notifications/email-prefs", response_model=NotificationEmailPrefsOut)
+async def get_email_prefs(user: CurrentUser = Depends(get_current_user)) -> NotificationEmailPrefsOut:
+ async with plain_connection() as conn:
+ rows = await conn.fetch(
+ "SELECT type FROM user_notification_email_pref WHERE user_id = $1",
+ user.user_id,
+ )
+ return NotificationEmailPrefsOut(types=[r["type"] for r in rows])
+
+
+@router.put("/notifications/email-prefs", response_model=NotificationEmailPrefsOut)
+async def set_email_prefs(
+ body: NotificationEmailPrefsUpdate, user: CurrentUser = Depends(get_current_user)
+) -> NotificationEmailPrefsOut:
+ """Full-erstatning, samme mønster som PUT /friends/{id}/categories --
+ sender med en tom liste slår e-post AV for alle typer igjen."""
+ types = sorted(set(body.types))
+ async with plain_connection() as conn, conn.transaction():
+ await conn.execute("DELETE FROM user_notification_email_pref WHERE user_id = $1", user.user_id)
+ for t in types:
+ await conn.execute(
+ "INSERT INTO user_notification_email_pref (user_id, type) VALUES ($1, $2)",
+ user.user_id,
+ t,
+ )
+ return NotificationEmailPrefsOut(types=types)
+
class NotificationOut(BaseModel):
id: str
diff --git a/app/routers/rounds.py b/app/routers/rounds.py
index 912c2af..016c737 100644
--- a/app/routers/rounds.py
+++ b/app/routers/rounds.py
@@ -1803,16 +1803,20 @@ async def add_participant(
if body.round_side_id is not None:
await _recompute_side_handicaps(conn, round_id, round_row["play_format"])
- if linked_user_id is not None:
- owner_name = await conn.fetchval("SELECT display_name FROM app_user WHERE id = $1", user.user_id)
- round_label = (round_row["name"] or "").strip() or round_row["course_name_snapshot"]
- await create_notification(
- conn,
- user_id=linked_user_id,
- type="round",
- message=f"{owner_name} la deg til som medspiller på en runde ({round_label}).",
- link_path=f"/my-rounds/{round_id}",
- )
+ # Rundevarsel (2026-07-28) -- bevisst ETTER at transaksjonen er
+ # begått, samme mønster som create_round/complete_round (en
+ # e-post-utsending, se create_notification, skal aldri skje mens
+ # en DB-transaksjon holder låser).
+ if linked_user_id is not None:
+ owner_name = await conn.fetchval("SELECT display_name FROM app_user WHERE id = $1", user.user_id)
+ round_label = (round_row["name"] or "").strip() or round_row["course_name_snapshot"]
+ await create_notification(
+ conn,
+ user_id=linked_user_id,
+ type="round",
+ message=f"{owner_name} la deg til som medspiller på en runde ({round_label}).",
+ link_path=f"/my-rounds/{round_id}",
+ )
row = await conn.fetchrow(
"""
SELECT rp.id::text AS id, rp.user_id::text AS user_id, rp.guest_name, rp.guest_email,
diff --git a/frontend/components/account-settings.tsx b/frontend/components/account-settings.tsx
index ede3976..8fbcbd3 100644
--- a/frontend/components/account-settings.tsx
+++ b/frontend/components/account-settings.tsx
@@ -11,6 +11,7 @@ import Link from "next/link"
import { useRouter } from "next/navigation"
import {
ArrowLeft,
+ Bell,
Camera,
KeyRound,
Lock,
@@ -277,6 +278,8 @@ export function AccountSettings() {
+
+
@@ -1315,3 +1318,101 @@ function PasswordSection({ hasPassword, onChanged }: { hasPassword: boolean; onC
)
}
+
+// --- E-post-fallback for varsler (2026-07-28 oppfølging) --------------------
+// Trygg standard: ingen typer valgt inntil brukeren selv velger inn --
+// PUT /notifications/email-prefs er en FULL erstatning (samme mønster som
+// venne-kategoriseringen), ikke en delvis PATCH.
+const NOTIFICATION_TYPE_OPTIONS: { value: string; label: string; description: string }[] = [
+ { value: "friend", label: "Venneforespørsler", description: "Noen sender deg en venneforespørsel, eller godtar din." },
+ { value: "round", label: "Runder", description: "Du blir lagt til som medspiller, eller en venn starter en runde du kan følge." },
+ { value: "result", label: "Resultater", description: "En runde du er koblet til blir fullført." },
+ { value: "tournament", label: "Turneringer", description: "Reservert for fremtidige turnering-varsler." },
+]
+
+function NotificationEmailPrefsSection() {
+ const [types, setTypes] = useState([])
+ const [loaded, setLoaded] = useState(false)
+ const [saving, setSaving] = useState(false)
+ const [error, setError] = useState(null)
+ const [success, setSuccess] = useState(false)
+
+ useEffect(() => {
+ fetch("/notifications/email-prefs", { credentials: "include" })
+ .then((res) => (res.ok ? res.json() : null))
+ .then((data: { types: string[] } | null) => {
+ if (data) setTypes(data.types)
+ setLoaded(true)
+ })
+ .catch(() => setLoaded(true))
+ }, [])
+
+ async function save(next: string[]) {
+ setTypes(next)
+ setSaving(true)
+ setError(null)
+ setSuccess(false)
+ try {
+ const res = await fetch("/notifications/email-prefs", {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ credentials: "include",
+ body: JSON.stringify({ types: next }),
+ })
+ if (!res.ok) throw new Error("Klarte ikke å lagre varselinnstillingene.")
+ setSuccess(true)
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Noe gikk galt. Prøv igjen.")
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ return (
+
+
+
+
+
+
Varsler på e-post
+
+
+ Du får alltid varsler i appen (klokke-ikonet). Velg her hvilke typer du i tillegg vil ha på
+ e-post -- ingen sendes med mindre du krysser av.
+