2026-07-16 20:59:59 +02:00
|
|
|
|
"""
|
|
|
|
|
|
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, så 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 på
|
|
|
|
|
|
forhånd: implisitt TLS (SMTP_SSL, typisk port 465) eller STARTTLS på en
|
|
|
|
|
|
vanlig tilkobling (typisk port 587/25).
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import smtplib
|
|
|
|
|
|
from asyncio import to_thread
|
2026-08-03 06:24:57 +02:00
|
|
|
|
from dataclasses import dataclass
|
2026-07-16 20:59:59 +02:00
|
|
|
|
from email.message import EmailMessage
|
2026-08-03 06:24:57 +02:00
|
|
|
|
from html import escape as _esc
|
2026-07-16 20:59:59 +02:00
|
|
|
|
|
|
|
|
|
|
from .config import settings
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-03 06:24:57 +02:00
|
|
|
|
def _send_sync(to_email: str, subject: str, body: str, html_body: str | None = None) -> None:
|
2026-07-16 20:59:59 +02:00
|
|
|
|
msg = EmailMessage()
|
|
|
|
|
|
msg["Subject"] = subject
|
|
|
|
|
|
msg["From"] = settings.FROM_EMAIL
|
|
|
|
|
|
msg["To"] = to_email
|
|
|
|
|
|
msg.set_content(body)
|
2026-08-03 06:24:57 +02:00
|
|
|
|
# html_body er valgfritt (2026-08-03, rundeoppsummering til midlertidige
|
|
|
|
|
|
# spillere -- FØRSTE HTML-e-post i appen). add_alternative() gjør
|
|
|
|
|
|
# meldingen multipart/alternative: body over forblir en ekte tekst-
|
|
|
|
|
|
# fallback for klienter uten HTML-støtte, html_body er det klienter
|
|
|
|
|
|
# faktisk viser når de kan.
|
|
|
|
|
|
if html_body is not None:
|
|
|
|
|
|
msg.add_alternative(html_body, subtype="html")
|
2026-07-16 20:59:59 +02:00
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-17 21:40:42 +02:00
|
|
|
|
_TEMPLATES = {
|
|
|
|
|
|
"nb": {
|
|
|
|
|
|
"subject": "Logg inn på TeeCup",
|
|
|
|
|
|
"body": (
|
|
|
|
|
|
"Hei,\n\n"
|
Alt rent, ingen byggeartefakter liggende igjen, dokumentasjonen oppdatert. Oppsummering:
Live og verifisert ende-til-ende:
/verify-siden bygget og live, e-postmalen sender nå en ekte klikkbar lenke (app/email.py/app/config.py)
Frontend containerisert (frontend/Dockerfile, standalone Next.js) og rullet ut som egen teecup_frontend-tjeneste
Caddy peker nå teecup.teeoff.no på frontend-en, som selv proxyer API-kall server-side — same-origin, ingen CORS, cookie uendret (dokumentert som ny ADR-016)
Fant og fikset en reell fallgruve: Next.js sin rewrites() bakes inn ved build-tid for standalone-output, ikke lest ved kjøretid — løst med en Docker build-time ARG
Ekte e-post sendt, ekte lenke klikket, sesjon opprettet — bekreftet av deg
Én ting du må huske: Caddy-endringen ligger i det separate /opt/teeoff-repoet (deploy/Caddyfile), ikke i teecup-repoet — den er ikke committet ennå. Lett å glemme siden resten av denne økten kun har jobbet i /opt/teecup. Vil du at jeg minner deg, eller committer du den nå selv i teeoff-repoet?
CLAUDE.md, FEATURE_BACKLOG.md og ARCHITECTURE_DECISIONS.md (ny ADR-016) er oppdatert. Klar for commit i teecup-repoet når du vil.
2026-07-17 22:28:43 +02:00
|
|
|
|
"Åpne denne lenken for å logge inn på TeeCup innen {minutes} minutter:\n\n"
|
|
|
|
|
|
"{link}\n\n"
|
|
|
|
|
|
"Fungerer ikke lenken? Lim inn denne koden manuelt i stedet:\n"
|
2026-07-17 21:40:42 +02:00
|
|
|
|
"{token}\n\n"
|
|
|
|
|
|
"Ba du ikke om dette, kan du se bort fra e-posten.\n"
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
"en": {
|
|
|
|
|
|
"subject": "Log in to TeeCup",
|
|
|
|
|
|
"body": (
|
|
|
|
|
|
"Hi,\n\n"
|
Alt rent, ingen byggeartefakter liggende igjen, dokumentasjonen oppdatert. Oppsummering:
Live og verifisert ende-til-ende:
/verify-siden bygget og live, e-postmalen sender nå en ekte klikkbar lenke (app/email.py/app/config.py)
Frontend containerisert (frontend/Dockerfile, standalone Next.js) og rullet ut som egen teecup_frontend-tjeneste
Caddy peker nå teecup.teeoff.no på frontend-en, som selv proxyer API-kall server-side — same-origin, ingen CORS, cookie uendret (dokumentert som ny ADR-016)
Fant og fikset en reell fallgruve: Next.js sin rewrites() bakes inn ved build-tid for standalone-output, ikke lest ved kjøretid — løst med en Docker build-time ARG
Ekte e-post sendt, ekte lenke klikket, sesjon opprettet — bekreftet av deg
Én ting du må huske: Caddy-endringen ligger i det separate /opt/teeoff-repoet (deploy/Caddyfile), ikke i teecup-repoet — den er ikke committet ennå. Lett å glemme siden resten av denne økten kun har jobbet i /opt/teecup. Vil du at jeg minner deg, eller committer du den nå selv i teeoff-repoet?
CLAUDE.md, FEATURE_BACKLOG.md og ARCHITECTURE_DECISIONS.md (ny ADR-016) er oppdatert. Klar for commit i teecup-repoet når du vil.
2026-07-17 22:28:43 +02:00
|
|
|
|
"Open this link to log in to TeeCup within {minutes} minutes:\n\n"
|
|
|
|
|
|
"{link}\n\n"
|
|
|
|
|
|
"Link not working? Paste this code in manually instead:\n"
|
2026-07-17 21:40:42 +02:00
|
|
|
|
"{token}\n\n"
|
|
|
|
|
|
"If you didn't request this, you can ignore this email.\n"
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def send_magic_link_email(to_email: str, raw_token: str, locale: str = "nb") -> None:
|
Alt rent, ingen byggeartefakter liggende igjen, dokumentasjonen oppdatert. Oppsummering:
Live og verifisert ende-til-ende:
/verify-siden bygget og live, e-postmalen sender nå en ekte klikkbar lenke (app/email.py/app/config.py)
Frontend containerisert (frontend/Dockerfile, standalone Next.js) og rullet ut som egen teecup_frontend-tjeneste
Caddy peker nå teecup.teeoff.no på frontend-en, som selv proxyer API-kall server-side — same-origin, ingen CORS, cookie uendret (dokumentert som ny ADR-016)
Fant og fikset en reell fallgruve: Next.js sin rewrites() bakes inn ved build-tid for standalone-output, ikke lest ved kjøretid — løst med en Docker build-time ARG
Ekte e-post sendt, ekte lenke klikket, sesjon opprettet — bekreftet av deg
Én ting du må huske: Caddy-endringen ligger i det separate /opt/teeoff-repoet (deploy/Caddyfile), ikke i teecup-repoet — den er ikke committet ennå. Lett å glemme siden resten av denne økten kun har jobbet i /opt/teecup. Vil du at jeg minner deg, eller committer du den nå selv i teeoff-repoet?
CLAUDE.md, FEATURE_BACKLOG.md og ARCHITECTURE_DECISIONS.md (ny ADR-016) er oppdatert. Klar for commit i teecup-repoet når du vil.
2026-07-17 22:28:43 +02:00
|
|
|
|
"""Sender en klikkbar innloggingslenke (pluss selve koden som fallback).
|
2026-07-16 20:59:59 +02:00
|
|
|
|
|
2026-07-17 21:40:42 +02:00
|
|
|
|
`locale` valideres allerede av Pydantic (Literal["nb","en"]) på vei inn i
|
|
|
|
|
|
routeren, men faller trygt tilbake til "nb" her også hvis den skulle
|
|
|
|
|
|
komme fra et annet kall uten den garantien.
|
2026-07-16 20:59:59 +02:00
|
|
|
|
"""
|
2026-07-17 21:40:42 +02:00
|
|
|
|
template = _TEMPLATES.get(locale, _TEMPLATES["nb"])
|
|
|
|
|
|
subject = template["subject"]
|
Alt rent, ingen byggeartefakter liggende igjen, dokumentasjonen oppdatert. Oppsummering:
Live og verifisert ende-til-ende:
/verify-siden bygget og live, e-postmalen sender nå en ekte klikkbar lenke (app/email.py/app/config.py)
Frontend containerisert (frontend/Dockerfile, standalone Next.js) og rullet ut som egen teecup_frontend-tjeneste
Caddy peker nå teecup.teeoff.no på frontend-en, som selv proxyer API-kall server-side — same-origin, ingen CORS, cookie uendret (dokumentert som ny ADR-016)
Fant og fikset en reell fallgruve: Next.js sin rewrites() bakes inn ved build-tid for standalone-output, ikke lest ved kjøretid — løst med en Docker build-time ARG
Ekte e-post sendt, ekte lenke klikket, sesjon opprettet — bekreftet av deg
Én ting du må huske: Caddy-endringen ligger i det separate /opt/teeoff-repoet (deploy/Caddyfile), ikke i teecup-repoet — den er ikke committet ennå. Lett å glemme siden resten av denne økten kun har jobbet i /opt/teecup. Vil du at jeg minner deg, eller committer du den nå selv i teeoff-repoet?
CLAUDE.md, FEATURE_BACKLOG.md og ARCHITECTURE_DECISIONS.md (ny ADR-016) er oppdatert. Klar for commit i teecup-repoet når du vil.
2026-07-17 22:28:43 +02:00
|
|
|
|
link = f"{settings.PUBLIC_BASE_URL}/verify?token={raw_token}"
|
|
|
|
|
|
body = template["body"].format(
|
|
|
|
|
|
minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES, token=raw_token, link=link
|
|
|
|
|
|
)
|
2026-07-16 20:59:59 +02:00
|
|
|
|
await to_thread(_send_sync, to_email, subject, body)
|
Update Todos
Legge til argon2-cffi, pyotp, qrcode i requirements.txt
Skrive migrasjon 012 (passord, 2FA, superadmin, org-invitasjoner)
app/auth.py: sesjonsstadier, passord-hashing, TOTP-hjelpere
app/routers/auth.py: passord-innlogging, 2FA-oppsett/verifisering
app/email.py: 2FA-kode og invitasjons-maler
app/routers/organizations.py: invitasjoner, medlemskapsstyring, superadmin-sti
Frontend: login-form passord-modus + 2FA-skjermer
Frontend: kontoinnstillinger + org-medlemsstyring-skjerm
Ekte typesjekket frontend-build
Scratch-verifisere hele auth-løpet grundig (backend)
Deploy mot ekte teecup_db/containere + oppdatere .md-filer
Backend og frontend er grundig scratch-verifisert — inkludert tre reelle bugs jeg fant og fikset underveis (en UUID-serialiseringsfeil i magic-link-innlogging, og to tilfeller av en uendelig 2FA-løkke der en nettopp bekreftet kode ble sjekket på nytt). Alle sikkerhetsvern testet eksplisitt: siste-eier-vern, blokkert selv-forfremmelse, admin kan ikke gi eierskap, superadmin fungerer/avvises riktig, tvungen 2FA for nye eiere, passord med spesialtegn/mellomrom, og bakoverkompatibilitet med eksisterende magic-link-flyt.
Klar til utrulling mot ekte systemer:
Migrasjon: 012_password_2fa_and_org_invitations.sql mot ekte teecup_db
Redeploy: både teecup_api og teecup_frontend
AskUserQuestion
2026-07-19 10:36:50 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_TWO_FACTOR_TEMPLATES = {
|
|
|
|
|
|
"nb": {
|
|
|
|
|
|
"subject": "Din engangskode for TeeCup",
|
|
|
|
|
|
"body": (
|
|
|
|
|
|
"Hei,\n\n"
|
|
|
|
|
|
"Engangskoden din er: {code}\n\n"
|
|
|
|
|
|
"Den er gyldig i 5 minutter. Ba du ikke om dette, kan du se bort fra e-posten.\n"
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
"en": {
|
|
|
|
|
|
"subject": "Your TeeCup one-time code",
|
|
|
|
|
|
"body": (
|
|
|
|
|
|
"Hi,\n\n"
|
|
|
|
|
|
"Your one-time code is: {code}\n\n"
|
|
|
|
|
|
"It is valid for 5 minutes. If you didn't request this, you can ignore this email.\n"
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def send_two_factor_code_email(to_email: str, code: str, locale: str = "nb") -> None:
|
|
|
|
|
|
"""ADR-021 Beslutning C: e-post-2FA-engangskode. Samme leveringsmønster
|
|
|
|
|
|
som magic-link (smtplib i egen tråd)."""
|
|
|
|
|
|
template = _TWO_FACTOR_TEMPLATES.get(locale, _TWO_FACTOR_TEMPLATES["nb"])
|
|
|
|
|
|
body = template["body"].format(code=code)
|
|
|
|
|
|
await to_thread(_send_sync, to_email, template["subject"], body)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_INVITATION_TEMPLATES = {
|
|
|
|
|
|
"nb": {
|
|
|
|
|
|
"subject": "Du er invitert til {org} på TeeCup",
|
|
|
|
|
|
"body": (
|
|
|
|
|
|
"Hei,\n\n"
|
|
|
|
|
|
"{inviter} har invitert deg til å bli {role} i {org} på TeeCup.\n\n"
|
|
|
|
|
|
"Logg inn med denne e-postadressen på {link} for å godta invitasjonen "
|
|
|
|
|
|
"-- den kobles automatisk til kontoen din ved innlogging.\n"
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
"en": {
|
|
|
|
|
|
"subject": "You're invited to {org} on TeeCup",
|
|
|
|
|
|
"body": (
|
|
|
|
|
|
"Hi,\n\n"
|
|
|
|
|
|
"{inviter} has invited you to become {role} of {org} on TeeCup.\n\n"
|
|
|
|
|
|
"Log in with this email address at {link} to accept the invitation "
|
|
|
|
|
|
"-- it's linked to your account automatically on login.\n"
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
_ROLE_LABELS_NB = {"owner": "eier", "admin": "administrator", "member": "medlem"}
|
|
|
|
|
|
_ROLE_LABELS_EN = {"owner": "an owner", "admin": "an administrator", "member": "a member"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def send_organization_invitation_email(
|
|
|
|
|
|
to_email: str, org_name: str, role: str, inviter_name: str, locale: str = "nb"
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""ADR-022 Beslutning B: ingen klikkbar aksept-lenke -- invitasjonen
|
|
|
|
|
|
godtas automatisk ved neste innlogging med matchende e-post (samme
|
|
|
|
|
|
mønster som spiller-e-post-kobling, ADR-017)."""
|
|
|
|
|
|
template = _INVITATION_TEMPLATES.get(locale, _INVITATION_TEMPLATES["nb"])
|
|
|
|
|
|
role_label = (_ROLE_LABELS_NB if locale == "nb" else _ROLE_LABELS_EN).get(role, role)
|
|
|
|
|
|
subject = template["subject"].format(org=org_name)
|
|
|
|
|
|
body = template["body"].format(
|
|
|
|
|
|
inviter=inviter_name, role=role_label, org=org_name, link=settings.PUBLIC_BASE_URL
|
|
|
|
|
|
)
|
|
|
|
|
|
await to_thread(_send_sync, to_email, subject, body)
|
2026-07-20 11:32:10 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_EMAIL_CHANGE_TEMPLATES = {
|
|
|
|
|
|
"nb": {
|
|
|
|
|
|
"subject": "Bekreft ny e-postadresse for TeeCup",
|
|
|
|
|
|
"body": (
|
|
|
|
|
|
"Hei,\n\n"
|
|
|
|
|
|
"Noen ba om å endre TeeCup-kontoens e-postadresse til denne adressen. "
|
|
|
|
|
|
"Åpne lenken under innen {minutes} minutter for å bekrefte:\n\n"
|
|
|
|
|
|
"{link}\n\n"
|
|
|
|
|
|
"Ba du ikke om dette selv, kan du se bort fra e-posten -- ingenting "
|
|
|
|
|
|
"endres uten at lenken åpnes.\n"
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
"en": {
|
|
|
|
|
|
"subject": "Confirm your new TeeCup email address",
|
|
|
|
|
|
"body": (
|
|
|
|
|
|
"Hi,\n\n"
|
|
|
|
|
|
"Someone requested changing this TeeCup account's email address to this "
|
|
|
|
|
|
"address. Open the link below within {minutes} minutes to confirm:\n\n"
|
|
|
|
|
|
"{link}\n\n"
|
|
|
|
|
|
"If you didn't request this yourself, you can ignore this email -- "
|
|
|
|
|
|
"nothing changes unless the link is opened.\n"
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def send_email_change_confirmation(to_email: str, raw_token: str, locale: str = "nb") -> None:
|
|
|
|
|
|
"""ADR-031-oppfølging: e-post er innloggings-identifikatoren, kan derfor
|
|
|
|
|
|
ikke endres med en enkel PATCH -- den NYE adressen må bevise eierskap
|
|
|
|
|
|
FØR selve kontoen endres. Samme token-i-lenke-mønster som magic-link."""
|
|
|
|
|
|
template = _EMAIL_CHANGE_TEMPLATES.get(locale, _EMAIL_CHANGE_TEMPLATES["nb"])
|
|
|
|
|
|
link = f"{settings.PUBLIC_BASE_URL}/verify-email?token={raw_token}"
|
|
|
|
|
|
body = template["body"].format(minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES, link=link)
|
|
|
|
|
|
await to_thread(_send_sync, to_email, template["subject"], body)
|
Update Todos
Flytte is_participant-logikk til team_authz.py (unngå sirkulær import)
Widen scoring.py: get_scorecard/submit_hole_score/submit_hole_result/concede_match
Widen matches.py: list_matches/add_participant/remove_participant/lock_lineup
Widen tournaments.py: list_sessions/list_teams/concede_tournament
Widen courses.py: list_holes
Widen messaging.py: team chat REST-endepunkter (list/send/delete)
Legge til my_session_id/my_match_id i /auth/me sin my_tournaments
Frontend: legge til lenker til lag-chat/scorekort fra «Mine runder»
Scratch-verifisere hele deltaker-tilgang-runden (positive + negative kontroller)
Ekte typesjekket frontend-produksjonsbuild + backend py_compile på nytt før deploy-forslag
Presentere plan, deploye og oppdatere .md-filer for deltaker-tilgang-runden
Bygge og scratch-verifisere sekundær e-postadresse (migrasjon 017)
Bygge HCP-historikk over tid
Sekundær e-postadresse (det enkle tilfellet) er bygget og scratch-verifisert (20/20 sjekker), test_isolation.sql fortsatt 12/12.
Hva er bygget:
Migrasjon 017_secondary_email.sql — to nye tabeller (secondary_email_token, user_secondary_email), samme bevis-eierskap-token-mønster som ADR-032.
Backend: POST /auth/secondary-email (send bekreftelseslenke), POST /auth/secondary-email/confirm (bekreft, ingen sesjon påkrevd), DELETE /auth/secondary-email/{id}. Viktigst: verify_magic_link og login_with_password løser nå en sekundær adresse til den eksisterende eierens konto i stedet for å opprette en ny, separat konto — dette er selve poenget med funksjonen.
Frontend: ny seksjon "Andre e-postadresser" i /account (ikke dashbordet — se merknad under), /verify-email håndterer nå både e-postbytte og ny-sekundær-adresse via en ?kind=secondary-parameter.
Én bevisst plasseringsavgjørelse jeg tok uten å spørre: du sa opprinnelig at hele multi-e-post-flyten burde skje "fra dashboard-siden". Jeg la likevel dette i /account, fordi jeg her kun bygger det enkle tilfellet (fri adresse) — konsistent med hvor e-postbytte (ADR-032) allerede ligger. Når/hvis den harde saken (ekte konto-sammenslåing, der data faktisk "dukker opp") bygges senere, er dashbordet trolig riktigere siden gevinsten vises der. Si fra hvis du vil at den skal flyttes allerede nå.
Verifisert grundig: ny sekundær-adresse legges IKKE til før bekreftet; token kan ikke gjenbrukes; adresse som allerede er en annens hovedadresse ELLER en annens sekundæradresse avvises tydelig; innlogging (magic-link OG passord) via sekundæradressen løses korrekt til samme, eksisterende konto; en fremmed kan ikke slette andres sekundæradresse; og — kritisk — etter sletting oppretter en ny innlogging på den adressen en helt ny, separat konto (beviser fjerningen er reell).
Ingen migrasjon kjørt mot ekte teecup_db ennå.
2026-07-22 06:14:31 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_SECONDARY_EMAIL_TEMPLATES = {
|
|
|
|
|
|
"nb": {
|
|
|
|
|
|
"subject": "Bekreft ekstra e-postadresse for TeeCup",
|
|
|
|
|
|
"body": (
|
|
|
|
|
|
"Hei,\n\n"
|
|
|
|
|
|
"Noen la til denne adressen som en EKSTRA (sekundær) e-postadresse på "
|
|
|
|
|
|
"en TeeCup-konto -- kontoens hovedadresse endres IKKE. Åpne lenken "
|
|
|
|
|
|
"under innen {minutes} minutter for å bekrefte at du eier denne "
|
|
|
|
|
|
"adressen:\n\n"
|
|
|
|
|
|
"{link}\n\n"
|
|
|
|
|
|
"Ba du ikke om dette selv, kan du se bort fra e-posten -- ingenting "
|
|
|
|
|
|
"legges til uten at lenken åpnes.\n"
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
"en": {
|
|
|
|
|
|
"subject": "Confirm an additional TeeCup email address",
|
|
|
|
|
|
"body": (
|
|
|
|
|
|
"Hi,\n\n"
|
|
|
|
|
|
"Someone added this address as an ADDITIONAL (secondary) email on a "
|
|
|
|
|
|
"TeeCup account -- the account's primary address is NOT changed. Open "
|
|
|
|
|
|
"the link below within {minutes} minutes to confirm you own this "
|
|
|
|
|
|
"address:\n\n"
|
|
|
|
|
|
"{link}\n\n"
|
|
|
|
|
|
"If you didn't request this yourself, you can ignore this email -- "
|
|
|
|
|
|
"nothing is added unless the link is opened.\n"
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def send_secondary_email_verification(to_email: str, raw_token: str, locale: str = "nb") -> None:
|
|
|
|
|
|
"""Én person, flere e-postadresser (notert i FEATURE_BACKLOG.md) --
|
|
|
|
|
|
kun det enkle tilfellet (fri, ukrevd adresse). Samme
|
|
|
|
|
|
token-i-lenke-mønster som send_email_change_confirmation, egen mal
|
|
|
|
|
|
siden budskapet er reelt forskjellig ("legges til" vs. "endres til")."""
|
|
|
|
|
|
template = _SECONDARY_EMAIL_TEMPLATES.get(locale, _SECONDARY_EMAIL_TEMPLATES["nb"])
|
|
|
|
|
|
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)
|
2026-07-28 20:09:21 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_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)
|
Update Todos
Undersøk kode-kontekst for alle tre byggeoppgavene
Avklar åpne designspørsmål med bruker
Bygg: flytte spiller mellom lag (backend + frontend)
Bygg: individuell rangering i turnering (backend + frontend)
Bygg: midlertidige spillere + etter-runde-e-post (backend + frontend)
Typesjekket produksjonsbuild av frontend
Scratch-verifiser alt tre + full regresjon
Browser-verifiser alt tre (Chrome DevTools)
Oppdater CLAUDE.md/FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md
Rull ut mot ekte systemer (med bekreftelse)
Alle tre er live. Oppsummert:
Flytte spiller mellom lag — ny "Flytt til {annet lag}"-handling i lag-oversikten, atomisk, blokkert hvis spilleren allerede er lagt til i en match.
Individuell rangering per økt — ny brutto/netto/poeng-rangering for singles/fourball-slagspill-økter, ved siden av det eksisterende lag-poeng-leaderboardet, lenket fra blind draw-skjermen.
Midlertidige spillere: etter-runde-e-post — ny knapp som sender scorekort + ekte innloggingslenke til spillere uten konto, med dobbel-utsendingssperre.
Alt verifisert grundig: 54/54 nye scratch-sjekker + full regresjon (5+28+19 eksisterende sjekker), typesjekket produksjonsbuild, og en fullstendig gjennomgang i ekte nettleser (Chrome DevTools) av alle tre — ingen konsollfeil. Migrasjon 034 kjørt mot ekte database, begge containere redeployet, teeoff.no upåvirket. Samtidig rettet jeg opp noen steder der FEATURE_BACKLOG.md/ARCHITECTURE_DECISIONS.md hadde blitt hengende etter den faktiske, allerede leverte tilstanden (ADR-039-utrulling, PWA-offline-browsertesting).
2026-07-28 20:56:08 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
_SESSION_INVITATION_TEMPLATES = {
|
|
|
|
|
|
"nb": {
|
|
|
|
|
|
"subject": "Din runde i {tournament} er registrert i TeeCup",
|
|
|
|
|
|
"body": (
|
|
|
|
|
|
"Hei {name},\n\n"
|
|
|
|
|
|
"{summary}\n\n"
|
|
|
|
|
|
"Åpne lenken under innen {minutes} minutter for å logge deg rett inn og se mer, "
|
|
|
|
|
|
"eller følge med videre:\n\n"
|
|
|
|
|
|
"{link}\n\n"
|
|
|
|
|
|
"Fungerer ikke lenken? Lim inn denne koden manuelt i stedet:\n"
|
|
|
|
|
|
"{token}\n\n"
|
|
|
|
|
|
"Fikk du denne uventet? Arrangøren av turneringen har registrert deg som spiller "
|
|
|
|
|
|
"i TeeCup -- se bort fra e-posten hvis dette ikke stemmer.\n"
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
"en": {
|
|
|
|
|
|
"subject": "Your round in {tournament} has been recorded in TeeCup",
|
|
|
|
|
|
"body": (
|
|
|
|
|
|
"Hi {name},\n\n"
|
|
|
|
|
|
"{summary}\n\n"
|
|
|
|
|
|
"Open the link below within {minutes} minutes to log straight in and see more, "
|
|
|
|
|
|
"or keep following along:\n\n"
|
|
|
|
|
|
"{link}\n\n"
|
|
|
|
|
|
"Link not working? Paste this code in manually instead:\n"
|
|
|
|
|
|
"{token}\n\n"
|
|
|
|
|
|
"Got this unexpectedly? The tournament organizer registered you as a player in "
|
|
|
|
|
|
"TeeCup -- ignore this email if that's not right.\n"
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def send_session_result_email(
|
|
|
|
|
|
to_email: str, player_name: str, tournament_name: str, summary: str, raw_token: str, locale: str = "nb"
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""FEATURE_BACKLOG.md "Midlertidige spillere + automatisk etter-runde-
|
|
|
|
|
|
invitasjon" (avklart 2026-07-28, økt-nivå) -- en organisator sender
|
|
|
|
|
|
dette til spillere UTEN egen konto ennå (player.user_id IS NULL) etter
|
|
|
|
|
|
en økt. `summary` er en allerede ferdig, norsk tekst (samme snapshot-
|
|
|
|
|
|
prinsipp som varsel-meldinger) som beskriver matchresultatet -- bygget
|
|
|
|
|
|
av kalleren (app/routers/tournaments.py), ikke her. Samme ekte
|
|
|
|
|
|
klikkbare magic-link-mønster som send_magic_link_email (ikke bare en
|
|
|
|
|
|
"logg inn senere"-henvisning som organisasjonsinvitasjonen, siden
|
|
|
|
|
|
dette er en engangs, direkte innloggingslenke)."""
|
|
|
|
|
|
template = _SESSION_INVITATION_TEMPLATES.get(locale, _SESSION_INVITATION_TEMPLATES["nb"])
|
|
|
|
|
|
link = f"{settings.PUBLIC_BASE_URL}/verify?token={raw_token}"
|
|
|
|
|
|
subject = template["subject"].format(tournament=tournament_name)
|
|
|
|
|
|
body = template["body"].format(
|
|
|
|
|
|
name=player_name,
|
|
|
|
|
|
summary=summary,
|
|
|
|
|
|
minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES,
|
|
|
|
|
|
link=link,
|
|
|
|
|
|
token=raw_token,
|
|
|
|
|
|
)
|
|
|
|
|
|
await to_thread(_send_sync, to_email, subject, body)
|
Update Todos
Chapman: format-valg i new-round.tsx + tournament-program.tsx (session)
Nassau: nytt vindu-resultatvisning i round-detail.tsx + session-scorecard.tsx
Københavner: format-valg + poengtabell-visning i individual-tournament-detail.tsx
Bingo Bango Bongo: format-valg + per-hull picker + poengtabell
Flaggturnering: format-valg + nedtelling/resultatvisning
Shamble: format-valg + best_n + lagvisning (frittstående + org-lag)
Money Ball: format-valg + lineup_order + lagvisning (frittstående + org-lag)
High-low-high: format-valg + løpende poeng-resultatvisning (alle 3 flater)
Ekte produksjonsbuild -- FERDIG, kompilerte rent (27 ruter)
Browserverifisert alle åtte formatene -- fant og fikset ekte backend-bug (side-handicap-recompute)
Rullet ut mot ekte teecup_api/teecup_frontend -- FERDIG, health checks grønne, teeoff.no upåvirket
Rullet ut live 2026-07-30. Frontend for alle åtte nye turneringsformatene (Chapman, Nassau, Københavner, Bingo Bango Bongo, Flaggturnering, Shamble, Money Ball, High-low-high) er nå bygget, browserverifisert og live — dekker alle tre flatene (frittstående runder, org-lagturneringer, org-individuelle turneringer). Fant og fikset én reell backend-bug underveis (side-handicap ble ikke regnet på nytt når en deltakers HCP ble satt/endret etter at de allerede var tildelt en side). Ingen migrasjon i denne runden, teeoff.no upåvirket. ADR-039/åtte-formater-arbeidet er dermed helt ferdig, backend og frontend.
2026-08-01 06:51:53 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def send_new_account_alert_email(to_email: str, new_user_email: str, display_name: str) -> None:
|
|
|
|
|
|
"""Driftsvarsel til appens eier (2026-07-30) hver gang en HELT NY
|
|
|
|
|
|
TeeCup-konto opprettes (app/routers/auth.py sin verify_magic_link --
|
|
|
|
|
|
den ENESTE plassen en app_user-rad noensinne settes inn). Alltid
|
|
|
|
|
|
norsk -- et internt driftsvarsel til én fast, kjent mottaker, ikke en
|
|
|
|
|
|
brukervendt i18n-tekst."""
|
|
|
|
|
|
subject = "Ny TeeCup-konto opprettet"
|
|
|
|
|
|
body = (
|
|
|
|
|
|
f"Hei,\n\n"
|
|
|
|
|
|
f"En ny konto ble nettopp opprettet i TeeCup:\n\n"
|
|
|
|
|
|
f"E-post: {new_user_email}\n"
|
|
|
|
|
|
f"Navn: {display_name}\n"
|
|
|
|
|
|
)
|
|
|
|
|
|
await to_thread(_send_sync, to_email, subject, body)
|
2026-08-03 06:24:57 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
|
|
|
|
class RoundSummaryHole:
|
|
|
|
|
|
hole_number: int
|
|
|
|
|
|
par: int
|
|
|
|
|
|
score: int | None
|
|
|
|
|
|
net: int | None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async def send_round_summary_email(
|
|
|
|
|
|
to_email: str,
|
|
|
|
|
|
guest_first_name: str,
|
|
|
|
|
|
round_label: str,
|
|
|
|
|
|
holes: list[RoundSummaryHole],
|
|
|
|
|
|
stat_lines: list[tuple[str, str]],
|
|
|
|
|
|
raw_token: str,
|
|
|
|
|
|
) -> None:
|
|
|
|
|
|
"""Rundeoppsummering til en midlertidig spiller (gjest) med registrert
|
|
|
|
|
|
e-post, sendt ved fullføring (app/routers/rounds.py sin complete_round,
|
|
|
|
|
|
2026-08-03). FØRSTE HTML-e-post i appen -- multipart/alternative via
|
|
|
|
|
|
_send_sync sin nye html_body-parameter, med en ekte tekst-fallback.
|
|
|
|
|
|
|
|
|
|
|
|
Navneformat (CLAUDE.md, ufravikelig): dette ER direkte adressering til
|
|
|
|
|
|
mottakeren -- KUN fornavn i hilsenen, aldri fullt navn.
|
|
|
|
|
|
|
|
|
|
|
|
`stat_lines` er allerede ferdig utledet og formatert av kalleren (samme
|
|
|
|
|
|
"bygg teksten i routeren, ikke i e-post-modulen"-mønster som
|
|
|
|
|
|
send_scorecard_invitations/send_session_result_email) -- graderer seg
|
|
|
|
|
|
naturlig etter hva som faktisk ble spilt (stat_level, individuell-ball
|
|
|
|
|
|
vs. delt-ball) siden kalleren rett og slett utelater linjer det ikke
|
|
|
|
|
|
finnes data for.
|
|
|
|
|
|
|
|
|
|
|
|
Alltid norsk, samme presedens som send_scorecard_invitations (gjesten
|
|
|
|
|
|
har ingen lagret språkpreferanse -- ingen konto ennå)."""
|
|
|
|
|
|
link = f"{settings.PUBLIC_BASE_URL}/verify?token={raw_token}"
|
|
|
|
|
|
subject = f"Scorekortet ditt fra {round_label}"
|
|
|
|
|
|
|
|
|
|
|
|
text_lines = [
|
|
|
|
|
|
f"Hei {guest_first_name},",
|
|
|
|
|
|
"",
|
|
|
|
|
|
f"Her er scorekortet ditt fra {round_label}:",
|
|
|
|
|
|
"",
|
|
|
|
|
|
]
|
|
|
|
|
|
for h in holes:
|
|
|
|
|
|
score_txt = str(h.score) if h.score is not None else "–"
|
|
|
|
|
|
net_txt = str(h.net) if h.net is not None else "–"
|
|
|
|
|
|
text_lines.append(f"Hull {h.hole_number} (par {h.par}): {score_txt} slag (netto {net_txt})")
|
|
|
|
|
|
text_lines.append("")
|
|
|
|
|
|
for label, value in stat_lines:
|
|
|
|
|
|
text_lines.append(f"{label}: {value}")
|
|
|
|
|
|
text_lines += [
|
|
|
|
|
|
"",
|
|
|
|
|
|
"Noen registrerte deg som spiller i TeeCup med denne e-postadressen -- "
|
|
|
|
|
|
"du trenger ingen konto for å se dette scorekortet, men med én kan du "
|
|
|
|
|
|
"føre score selv, følge egen statistikk og handicap-utvikling over tid.",
|
|
|
|
|
|
"",
|
|
|
|
|
|
f"Logg inn og se hele runden ({settings.MAGIC_LINK_MAX_AGE_MINUTES} minutter):",
|
|
|
|
|
|
link,
|
|
|
|
|
|
"",
|
|
|
|
|
|
"Ikke interessert? Ignorer denne e-posten -- den brukes ikke til noe annet.",
|
|
|
|
|
|
]
|
|
|
|
|
|
body = "\n".join(text_lines)
|
|
|
|
|
|
|
|
|
|
|
|
hole_rows_html = "\n".join(
|
|
|
|
|
|
f'<tr><td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;">{h.hole_number}</td>'
|
|
|
|
|
|
f'<td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;">{h.par}</td>'
|
|
|
|
|
|
f'<td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;">{h.score if h.score is not None else "–"}</td>'
|
|
|
|
|
|
f'<td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;">{h.net if h.net is not None else "–"}</td></tr>'
|
|
|
|
|
|
for h in holes
|
|
|
|
|
|
)
|
|
|
|
|
|
stat_rows_html = "\n".join(
|
|
|
|
|
|
f'<tr><td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;color:#424941;">{_esc(label)}</td>'
|
|
|
|
|
|
f'<td style="padding:6px 10px;border-bottom:1px solid #e4ebe3;font-weight:700;">{_esc(value)}</td></tr>'
|
|
|
|
|
|
for label, value in stat_lines
|
|
|
|
|
|
)
|
|
|
|
|
|
html_body = f"""\
|
|
|
|
|
|
<!doctype html>
|
|
|
|
|
|
<html><body style="margin:0;padding:0;background-color:#f3f6f2;font-family:Arial,Helvetica,sans-serif;color:#012c11;">
|
|
|
|
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background-color:#f3f6f2;padding:24px 0;">
|
|
|
|
|
|
<tr><td align="center">
|
|
|
|
|
|
<table role="presentation" width="100%" style="max-width:480px;background-color:#ffffff;border-radius:16px;overflow:hidden;">
|
|
|
|
|
|
<tr><td style="background-color:#1a4325;padding:20px 24px;">
|
|
|
|
|
|
<span style="color:#ffffff;font-size:20px;font-weight:800;">TeeCup</span>
|
|
|
|
|
|
</td></tr>
|
|
|
|
|
|
<tr><td style="padding:24px;">
|
|
|
|
|
|
<p style="margin:0 0 16px;font-size:16px;">Hei {_esc(guest_first_name)},</p>
|
|
|
|
|
|
<p style="margin:0 0 20px;font-size:16px;">Her er scorekortet ditt fra <strong>{_esc(round_label)}</strong>:</p>
|
|
|
|
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="font-size:14px;margin-bottom:20px;">
|
|
|
|
|
|
<tr style="background-color:#edf3ef;font-weight:700;">
|
|
|
|
|
|
<td style="padding:6px 10px;">Hull</td><td style="padding:6px 10px;">Par</td>
|
|
|
|
|
|
<td style="padding:6px 10px;">Slag</td><td style="padding:6px 10px;">Netto</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
{hole_rows_html}
|
|
|
|
|
|
</table>
|
|
|
|
|
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="font-size:14px;margin-bottom:24px;">
|
|
|
|
|
|
{stat_rows_html}
|
|
|
|
|
|
</table>
|
|
|
|
|
|
<p style="margin:0 0 20px;font-size:14px;color:#424941;">
|
|
|
|
|
|
Noen registrerte deg som spiller i TeeCup med denne e-postadressen -- du trenger ingen
|
|
|
|
|
|
konto for å se dette scorekortet, men med én kan du føre score selv, følge egen
|
|
|
|
|
|
statistikk og handicap-utvikling over tid.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
<table role="presentation" cellpadding="0" cellspacing="0"><tr><td style="border-radius:12px;background-color:#1f6b08;">
|
|
|
|
|
|
<a href="{link}" style="display:inline-block;padding:14px 28px;color:#ffffff;font-size:16px;
|
|
|
|
|
|
font-weight:700;text-decoration:none;">Logg inn og se hele runden</a>
|
|
|
|
|
|
</td></tr></table>
|
|
|
|
|
|
<p style="margin:20px 0 0;font-size:12px;color:#767f75;">
|
|
|
|
|
|
Lenken er gyldig i {settings.MAGIC_LINK_MAX_AGE_MINUTES} minutter. Ikke interessert? Ignorer denne
|
|
|
|
|
|
e-posten -- den brukes ikke til noe annet.
|
|
|
|
|
|
</p>
|
|
|
|
|
|
</td></tr>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</td></tr>
|
|
|
|
|
|
</table>
|
|
|
|
|
|
</body></html>
|
|
|
|
|
|
"""
|
|
|
|
|
|
await to_thread(_send_sync, to_email, subject, body, html_body)
|