teecup/app/email.py
Erol Haagenrud 968ea79184 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

338 lines
14 KiB
Python

"""
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
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)
_TEMPLATES = {
"nb": {
"subject": "Logg inn på TeeCup",
"body": (
"Hei,\n\n"
"Å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"
"{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"
"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"
"{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:
"""Sender en klikkbar innloggingslenke (pluss selve koden som fallback).
`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.
"""
template = _TEMPLATES.get(locale, _TEMPLATES["nb"])
subject = template["subject"]
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
)
await to_thread(_send_sync, to_email, subject, body)
_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)
_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)
_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)
_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)
_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)
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)