Ekte SMTP-utsending er bygget og verifisert — med faktisk levering, ikke bare kodegjennomgang.

Bygget: ny app/email.py (send_magic_link_email, kjører smtplib via asyncio.to_thread siden det er synkront, håndterer både implisitt TLS (port 465) og STARTTLS dynamisk siden jeg bevisst ikke leste TEECUP_SMTP_PORT-verdien). app/config.py fikk nye, valgfrie innstillinger — SMTP_CONFIGURED er IKKE _required, så scratch-/dev-testing fortsatt fungerer uendret uten SMTP satt opp.

Sikkerhetsdesign: en driftsfeil i selve utsendingen (feil passord, SMTP nede, eller ingenting konfigurert) logges kun server-side og endrer aldri klientresponsen — bevarer request-link sitt anti-enumereringsvern.

Verifisert i to trinn:

Dev-log-flyten uendret uten SMTP satt (regresjonstest).
Én ekte test-e-post sendt til hei@erol.no, med credentials videreført fra .env til scratch-containeren uten at jeg noensinne leste verdiene — du bekreftet mottak.
Dette er første gang noe i prosjektet er bevist ved ekte, ekstern levering fremfor bare curl/scratch-container. CLAUDE.md/FEATURE_BACKLOG.md oppdatert.
This commit is contained in:
Erol Haagenrud 2026-07-16 20:59:59 +02:00
parent 62017a1b0d
commit c314d4866e
6 changed files with 138 additions and 11 deletions

View file

@ -106,7 +106,11 @@
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -b /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/cookies_org.txt http://127.0.0.1:8099/orgs/cd3258e2-7e3a-422b-bcba-d2e81fe2cc69/tournaments)", "Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -b /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/cookies_org.txt http://127.0.0.1:8099/orgs/cd3258e2-7e3a-422b-bcba-d2e81fe2cc69/tournaments)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -b /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/cookies_org.txt -X POST -H 'Content-Type: application/json' -d '{\"name\":\"Høstcupen\"}' http://127.0.0.1:8099/orgs/cd3258e2-7e3a-422b-bcba-d2e81fe2cc69/tournaments)", "Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -b /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/cookies_org.txt -X POST -H 'Content-Type: application/json' -d '{\"name\":\"Høstcupen\"}' http://127.0.0.1:8099/orgs/cd3258e2-7e3a-422b-bcba-d2e81fe2cc69/tournaments)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -b /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/cookies_org.txt http://127.0.0.1:8099/auth/me)", "Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -b /tmp/claude-1000/-opt-teecup/a8bd2fc3-4b9c-4682-a2be-cf36e143de78/scratchpad/cookies_org.txt http://127.0.0.1:8099/auth/me)",
"Bash(rm -f __TRACKED_VAR__/002_scratch.sql __TRACKED_VAR__/002_wrapper.sql __TRACKED_VAR__/cookies_org.txt __TRACKED_VAR__/cookies_org2.txt)" "Bash(rm -f __TRACKED_VAR__/002_scratch.sql __TRACKED_VAR__/002_wrapper.sql __TRACKED_VAR__/cookies_org.txt __TRACKED_VAR__/cookies_org2.txt)",
"Bash(python3 -m py_compile /opt/teecup/app/config.py /opt/teecup/app/email.py /opt/teecup/app/routers/auth.py)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'Content-Type: application/json' -d '{\"email\":\"devlog@test.no\"}' http://127.0.0.1:8099/auth/request-link)",
"Bash(curl -s -w '\\\\n[HTTP %{http_code}]\\\\n' -X POST -H 'Content-Type: application/json' -d '{\"email\":\"hei@erol.no\"}' http://127.0.0.1:8099/auth/request-link)",
"Bash(rm -f __TRACKED_VAR__/002_scratch.sql __TRACKED_VAR__/002_wrapper.sql)"
] ]
} }
} }

View file

@ -135,18 +135,27 @@ Ferdig og verifisert:
antydet). Verifisert med 5 tester inkl. en negativ kontroll (mismatchende antydet). Verifisert med 5 tester inkl. en negativ kontroll (mismatchende
id avvist med `insufficient_privilege`) og full kryss-org-isolasjon mellom id avvist med `insufficient_privilege`) og full kryss-org-isolasjon mellom
to uavhengig opprettede organisasjoner. to uavhengig opprettede organisasjoner.
- **Ekte SMTP-utsending bygget og verifisert (2026-07-16):** ny `app/email.py`
(`send_magic_link_email`, `smtplib` via `asyncio.to_thread`, håndterer
både implisitt TLS/port 465 og STARTTLS dynamisk). Brukeren la egne
SMTP-credentials i `.env` (`TEECUP_SMTP_*`, `TEECUP_FROM_EMAIL` — ADR-009,
ikke delt med teeoff); jeg leste kun nøkkelnavnene for å bekrefte de
fantes, aldri verdiene. `app/config.py` sin `SMTP_CONFIGURED` er valgfri
(ikke `_required`) — dev-only logging (`TEECUP_DEV_LOG_MAGIC_LINKS`)
fortsatt fungerer uendret når SMTP ikke er satt opp. Driftsfeil i
utsendingen lekker aldri til klientresponsen (bevarer anti-enumerering).
**Verifisert med faktisk levering:** sendte én ekte test-e-post til en
adresse brukeren oppga — brukeren bekreftet mottak. Første gang noe i
prosjektet er bevist ved ekte levering, ikke bare curl/scratch.
Neste steg: Neste steg:
1. Ekte SMTP-utsending av magic-link (i dag: dev-only logging bak 1. Containerisere TeeCup-API-et (Dockerfile + compose-tjeneste), koble mot
`TEECUP_DEV_LOG_MAGIC_LINKS`) — krever ekte e-post-credentials, ikke noe
som kan scratch-testes uten dem.
2. Containerisere TeeCup-API-et (Dockerfile + compose-tjeneste), koble mot
`teecup_db` med `teecup_app`, rute via eksisterende Caddy til `teecup_db` med `teecup_app`, rute via eksisterende Caddy til
`teecup.teeoff.no`. (Under scratch-verifisering måtte hele `/opt/teecup` `teecup.teeoff.no`. (Under scratch-verifisering måtte hele `/opt/teecup`
monteres, ikke bare `app/`, fordi `handicap_engine.py` er et monteres, ikke bare `app/`, fordi `handicap_engine.py` er et
toppnivå-søskenmodul til `app`-pakken — Dockerfilen må `COPY` begge inn toppnivå-søskenmodul til `app`-pakken — Dockerfilen må `COPY` begge inn
med samme relative plassering.) med samme relative plassering.)
3. Deretter frontend (PWA, offline-first) og kommunikasjon (migrasjon 006, 2. Deretter frontend (PWA, offline-first) og kommunikasjon (migrasjon 006,
siden 004/005 nå er tatt av auth og RLS-fiksen). Frontend er fortsatt IKKE siden 004/005 nå er tatt av auth og RLS-fiksen). Frontend er fortsatt IKKE
startet (⬜ i utviklingsplanen i ARCHITECTURE_DECISIONS.md) — API-et alene startet (⬜ i utviklingsplanen i ARCHITECTURE_DECISIONS.md) — API-et alene
er ikke en brukbar nettside. er ikke en brukbar nettside.

View file

@ -27,9 +27,39 @@
| API: scoring (hole_score/match_hole_result, matchstatus, handicap-beregning) | ✅ | ADR-012/014. Verifisert for ekte, inkl. fourball better-ball og race-sikker recompute. | | API: scoring (hole_score/match_hole_result, matchstatus, handicap-beregning) | ✅ | ADR-012/014. Verifisert for ekte, inkl. fourball better-ball og race-sikker recompute. |
| Banedata fra teeoff via API | 🔀 | ADR-004. Endret fra Geminis «delt database direkte». | | Banedata fra teeoff via API | 🔀 | ADR-004. Endret fra Geminis «delt database direkte». |
| Konfigurerbar handicap-pipeline (4 brytere) | ✅ | ADR-014. Bygget i `app/handicap.py`, brukt av scoring-runden. | | Konfigurerbar handicap-pipeline (4 brytere) | ✅ | ADR-014. Bygget i `app/handicap.py`, brukt av scoring-runden. |
| Ekte autentisering (magic-link + JWT-sesjon) | ✅ | ADR-009. `app/routers/auth.py` + migrasjon `004_auth.sql`. `X-Debug-User-Id`-stubben er helt fjernet. Ekte SMTP-utsending gjenstår (i dag: dev-only logging). | | Ekte autentisering (magic-link + JWT-sesjon) | ✅ | ADR-009. `app/routers/auth.py` + migrasjon `004_auth.sql`. `X-Debug-User-Id`-stubben er helt fjernet. |
| RLS-tomstreng-fiks (`app_current_org()`) | ✅ | Migrasjon `005_rls_null_guard.sql`. Se detaljer under. | | RLS-tomstreng-fiks (`app_current_org()`) | ✅ | Migrasjon `005_rls_null_guard.sql`. Se detaljer under. |
| Organisasjon-bootstrap (opprette ny org via API) | ✅ | `POST /orgs`, `app/routers/organizations.py`. Se detaljer under. | | Organisasjon-bootstrap (opprette ny org via API) | ✅ | `POST /orgs`, `app/routers/organizations.py`. Se detaljer under. |
| Ekte SMTP-utsending av magic-link | ✅ | `app/email.py`. Se detaljer under. |
---
### Ekte SMTP-utsending — ✅ BYGGET OG VERIFISERT 2026-07-16
- Brukeren la inn egne, uavhengige SMTP-credentials i `.env`
(`TEECUP_SMTP_SERVER/PORT/USER/PASS`, `TEECUP_FROM_EMAIL` — ADR-009, ikke
delt med teeoff). Kun nøkkelnavn ble lest for å bekrefte de fantes, ALDRI
verdiene (CLAUDE.md sin sikkerhetsregel).
- **Løsning:** ny `app/email.py` (`send_magic_link_email`, `smtplib` kjørt via
`asyncio.to_thread` siden det er et synkront bibliotek — samme mønster som
teeoffs egen fungerende utsending). Håndterer BEGGE vanlige
SMTP-tilkoblingsmåter dynamisk (implisitt TLS på port 465 vs. STARTTLS på
andre porter) siden porten bevisst ikke ble lest under planlegging.
`app/config.py` fikk nye, valgfrie innstillinger (`SMTP_CONFIGURED` avledet
fra at alle fem er satt) — IKKE `_required`, så scratch-/dev-testing
fungerer fortsatt uten SMTP satt opp, via `TEECUP_DEV_LOG_MAGIC_LINKS`.
- **Bevisst designvalg:** en driftsfeil i selve utsendingen (feil passord,
SMTP nede, eller ingen leveringsmåte konfigurert i det hele tatt) logges
kun server-side og endrer ALDRI klientens respons — alt annet ville brutt
anti-enumereringsgarantien i `request-link` (klienten skal ikke kunne
skille "e-posten finnes ikke" fra "e-posten finnes men utsendingen
feilet").
- **Verifisert i to trinn:** (1) dev-log-flyten uendret uten SMTP satt
(regresjonstest av eksisterende scratch-løype), (2) én ekte test-e-post
sendt til en adresse brukeren oppga, med de ekte credentials videreført fra
`.env` til scratch-containeren uten at jeg noensinne leste verdiene selv —
**brukeren bekreftet mottak** av en e-post med innloggingskode. Dette er
første gang noe i dette prosjektet er verifisert ved faktisk levering til
en ekte, ekstern mottaker, ikke bare via curl/scratch-container.
--- ---

View file

@ -48,5 +48,17 @@ class Settings:
"yes", "yes",
) )
# Ekte e-postutsending for magic-link (ADR-009: egne credentials, ikke
# delt med teeoff). Valgfritt (ikke _required) -- scratch-/dev-testing
# skal fortsatt fungere uten SMTP satt, via DEV_LOG_MAGIC_LINKS over.
SMTP_SERVER: str | None = os.environ.get("TEECUP_SMTP_SERVER", "").strip() or None
SMTP_PORT: int | None = (
int(os.environ["TEECUP_SMTP_PORT"]) if os.environ.get("TEECUP_SMTP_PORT", "").strip() else None
)
SMTP_USER: str | None = os.environ.get("TEECUP_SMTP_USER", "").strip() or None
SMTP_PASS: str | None = os.environ.get("TEECUP_SMTP_PASS", "").strip() or None
FROM_EMAIL: str | None = os.environ.get("TEECUP_FROM_EMAIL", "").strip() or None
SMTP_CONFIGURED: bool = bool(SMTP_SERVER and SMTP_PORT and SMTP_USER and SMTP_PASS and FROM_EMAIL)
settings = Settings() settings = Settings()

54
app/email.py Normal file
View file

@ -0,0 +1,54 @@
"""
E-postutsending for TeeCup. I dag kun magic-link-innlogging.
Egne, uavhengige SMTP-credentials fra teeoff (ADR-009) -- se .env
(TEECUP_SMTP_SERVER/PORT/USER/PASS, TEECUP_FROM_EMAIL). `smtplib` er
synkron, selve sendingen kjøres i en tråd (asyncio.to_thread) for ikke å
blokkere event loop-en -- samme mønster som teeoffs egen, fungerende
magic-link-utsending.
Håndterer BEGGE vanlige SMTP-tilkoblingsmåter siden porten ikke er antatt
forhånd: implisitt TLS (SMTP_SSL, typisk port 465) eller STARTTLS en
vanlig tilkobling (typisk port 587/25).
"""
import smtplib
from asyncio import to_thread
from email.message import EmailMessage
from .config import settings
def _send_sync(to_email: str, subject: str, body: str) -> None:
msg = EmailMessage()
msg["Subject"] = subject
msg["From"] = settings.FROM_EMAIL
msg["To"] = to_email
msg.set_content(body)
if settings.SMTP_PORT == 465:
with smtplib.SMTP_SSL(settings.SMTP_SERVER, settings.SMTP_PORT) as smtp:
smtp.login(settings.SMTP_USER, settings.SMTP_PASS)
smtp.send_message(msg)
else:
with smtplib.SMTP(settings.SMTP_SERVER, settings.SMTP_PORT) as smtp:
smtp.starttls()
smtp.login(settings.SMTP_USER, settings.SMTP_PASS)
smtp.send_message(msg)
async def send_magic_link_email(to_email: str, raw_token: str) -> None:
"""Sender selve magic-link-tokenet som klartekst.
MERK: ingen klikkbar lenke ennå -- TeeCup har ikke noe frontend å peke
(se plan). Når frontend bygges, oppdater malen til en ekte URL.
"""
subject = "Logg inn på TeeCup"
body = (
"Hei,\n\n"
f"Bruk denne koden for å logge inn på TeeCup innen "
f"{settings.MAGIC_LINK_MAX_AGE_MINUTES} minutter:\n\n"
f"{raw_token}\n\n"
"Ba du ikke om dette, kan du se bort fra e-posten.\n"
)
await to_thread(_send_sync, to_email, subject, body)

View file

@ -11,14 +11,17 @@ Sikkerhetsmønster (se plan/ADR-009):
av kontoer for e-poster man ikke eier). av kontoer for e-poster man ikke eier).
- Token-forbruk er ÉN atomisk UPDATE ... RETURNING (ikke les-sjekk-skriv) - Token-forbruk er ÉN atomisk UPDATE ... RETURNING (ikke les-sjekk-skriv)
for å hindre at to samtidige forsøk med samme lenke begge lykkes. for å hindre at to samtidige forsøk med samme lenke begge lykkes.
- Dev-modus: siden ingen ekte SMTP-oppsett finnes for TeeCup ennå, logges - Levering: ekte SMTP-utsending (app/email.py) hvis settings.SMTP_CONFIGURED,
token server-side i stedet for å sendes e-post -- KUN når ellers dev-only konsoll-logging bak settings.DEV_LOG_MAGIC_LINKS (aldri
settings.DEV_LOG_MAGIC_LINKS er eksplisitt satt (aldri som standard). som standard). En driftsfeil i selve utsendingen (feil passord, SMTP nede,
# TODO: erstatt med ekte SMTP-utsending før produksjon. eller ingen av delene konfigurert) logges server-side og lekker ALDRI til
klienten som en annen respons -- det ville brutt enumereringsvernet over
(klienten skal ikke kunne skille "finnes ikke" fra "utsending feilet").
""" """
import hashlib import hashlib
import secrets import secrets
import traceback
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from fastapi import APIRouter, Depends, HTTPException, Request, Response, status from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
@ -27,6 +30,7 @@ from pydantic import BaseModel, EmailStr
from ..auth import CurrentUser, SESSION_COOKIE_NAME, create_session_token, get_current_user, should_use_secure_cookies from ..auth import CurrentUser, SESSION_COOKIE_NAME, create_session_token, get_current_user, should_use_secure_cookies
from ..config import settings from ..config import settings
from ..db import org_connection, plain_connection from ..db import org_connection, plain_connection
from ..email import send_magic_link_email
router = APIRouter(prefix="/auth", tags=["auth"]) router = APIRouter(prefix="/auth", tags=["auth"])
@ -78,6 +82,20 @@ async def request_magic_link(body: MagicLinkRequest) -> dict:
) )
if settings.DEV_LOG_MAGIC_LINKS: if settings.DEV_LOG_MAGIC_LINKS:
print(f"[DEV] Magic link for {email}: {raw_token}", flush=True) print(f"[DEV] Magic link for {email}: {raw_token}", flush=True)
elif settings.SMTP_CONFIGURED:
try:
await send_magic_link_email(email, raw_token)
except Exception:
# Se modul-docstring: driftsfeil skal ALDRI endre klientens
# respons. Token-raden består og utløper naturlig hvis den
# aldri når frem.
traceback.print_exc()
else:
print(
f"[ADVARSEL] Verken DEV_LOG_MAGIC_LINKS eller SMTP er konfigurert "
f"-- ingen lenke ble sendt for {email}.",
flush=True,
)
return _GENERIC_RESPONSE return _GENERIC_RESPONSE