""" 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 collections.abc import Callable from dataclasses import dataclass from datetime import date, datetime from email.message import EmailMessage from html import escape as _esc from zoneinfo import ZoneInfo from .config import settings _OSLO = ZoneInfo("Europe/Oslo") _NORWEGIAN_MONTHS = [ "januar", "februar", "mars", "april", "mai", "juni", "juli", "august", "september", "oktober", "november", "desember", ] # Glyffer for de to 5-veis retningsfeltene (2026-08-13, ADR-065 "fullt # scorekort"-utvidelsen -- bruker ba eksplisitt om at e-postens tabell skal # se ut som selve Scorekort-siden, ikke bare inneholde samme tall). Samme # betydning som FairwayIcon/ApproachIcon i frontend/components/round- # scorecard.tsx, portert til tekst-glyffer + inline farge siden e-post- # klienter ikke kan antas å vise SVG-er pent. (glyff, farge)-par. _GREEN = "#1f6b08" _ORANGE = "#ff5722" _MUTED = "#9aa39a" _FAIRWAY_GLYPHS = {"fairway": ("◎", _GREEN), "left": ("↖", _ORANGE), "right": ("↗", _ORANGE)} _APPROACH_GLYPHS = { "hit": ("◎", _GREEN), "long": ("↑", _ORANGE), "short": ("↓", _ORANGE), "left": ("←", _ORANGE), "right": ("→", _ORANGE), } def _signed(value: int) -> str: if value == 0: return "E" return f"+{value}" if value > 0 else str(value) def _classify(diff: int) -> str: if diff <= -2: return "eagle" if diff == -1: return "birdie" if diff == 0: return "par" if diff == 1: return "bogey" return "double" def _format_round_datetime(played_at: date, started_at: datetime | None) -> str: """`round.played_at` er kun en DATO i skjemaet (ingen klokkeslett lagret -- "Utslagstid" i ny-runde-veiviseren fanges opp av wizard-state, men sendes reelt aldri til backend, oppdaget 2026-08-13 under denne utvidelsen -- egen, separat feil, ikke rettet her). `started_at` (tidspunktet runden faktisk ble startet, timestamptz) er derfor eneste ekte klokkeslett å vise -- utelates helt når det mangler (f.eks. en runde som aldri ble startet ennå) i stedet for å dikte opp et.""" today = datetime.now(_OSLO).date() date_part = "i dag" if played_at == today else f"{played_at.day}. {_NORWEGIAN_MONTHS[played_at.month - 1]} {played_at.year}" if started_at is None: return date_part return f"{date_part} klokken {started_at.astimezone(_OSLO).strftime('%H:%M')}" def _format_duration(total_minutes: int) -> str: hours, minutes = divmod(total_minutes, 60) return f"{minutes} min" if hours == 0 else f"{hours}t {minutes}min" def _send_sync(to_email: str, subject: str, body: str, html_body: str | None = None) -> None: msg = EmailMessage() msg["Subject"] = subject msg["From"] = settings.FROM_EMAIL msg["To"] = to_email msg.set_content(body) # 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") 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) _ACCOUNT_MERGE_TEMPLATES = { "nb": { "subject": "Bekreft kontosammenslåing på TeeCup", "body": ( "Hei,\n\n" "{initiator_name} ({initiator_email}) har bedt om å slå SIN " "TeeCup-konto sammen med DENNE kontoen. Alt fra denne kontoen " "(organisasjoner, spiller-koblinger, runder, venner) flyttes " "over til den andre kontoen, og denne kontoen slettes deretter " "-- dette kan IKKE angres.\n\n" "Åpne lenken under innen {minutes} minutter for å se nøyaktig " "hva som slås sammen og bekrefte:\n\n" "{link}\n\n" "Ba du (eller noen andre) ikke om dette selv, kan du se bort " "fra e-posten -- ingenting slås sammen uten at lenken åpnes OG " "bekreftes eksplisitt.\n" ), }, "en": { "subject": "Confirm account merge on TeeCup", "body": ( "Hi,\n\n" "{initiator_name} ({initiator_email}) has requested to merge " "THEIR TeeCup account with THIS account. Everything from this " "account (organizations, player links, rounds, friends) will " "move to the other account, and this account will then be " "deleted -- this CANNOT be undone.\n\n" "Open the link below within {minutes} minutes to see exactly " "what will be merged and confirm:\n\n" "{link}\n\n" "If you (or someone else) didn't request this yourself, you " "can ignore this email -- nothing is merged unless the link is " "opened AND explicitly confirmed.\n" ), }, } async def send_account_merge_confirmation( to_email: str, raw_token: str, initiator_name: str, initiator_email: str, locale: str = "nb" ) -> None: """Kontosammenslåing ("Del 2" av flere-e-postadresser, ADR-080) -- sendes til MÅL-kontoens (taperens) e-post, beviser eierskap av DEN kontoen (ikke bare at initiativtakeren fortsatt er innlogget), samme prinsipp som send_email_change_confirmation. Selve utførelsen skjer IKKE ved lenke-åpning alene -- kun en forhåndsvisning vises, en eksplisitt bekreftelse kreves i tillegg (se app/account_merge.py).""" template = _ACCOUNT_MERGE_TEMPLATES.get(locale, _ACCOUNT_MERGE_TEMPLATES["nb"]) link = f"{settings.PUBLIC_BASE_URL}/kontosammenslaing/{raw_token}" body = template["body"].format( minutes=settings.MAGIC_LINK_MAX_AGE_MINUTES, link=link, initiator_name=initiator_name, initiator_email=initiator_email, ) 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) @dataclass(frozen=True) class RoundSummaryHole: hole_number: int par: int stroke_index: int score: int | None picked_up: bool net: int | None # Stableford (2 poeng for netto par, +/-1 per slag, gulvet på 0) -- # samme formel som frontend/components/round-scorecard.tsx sin # stablefordPoints(), portert til Python siden e-posten bygges # server-side. None når netto ikke er kjent (samme betingelse som net). points: int | None # Feltene under (2026-08-13, ADR-065 "fullt scorekort"-utvidelsen) er # ALLE valgfrie og graderer seg naturlig -- None for delt-ball-hull # (ADR-039 Beslutning C, ingen individuell detalj der) og for felt # spilleren ikke sporet (stat_level). send_round_summary_email viser # kun en kolonne når MINST ett hull faktisk har data for den, akkurat # som round-scorecard.tsx sine show*-flagg. putts: int | None tee_shot_result: str | None approach_result: str | None chip_count: int | None bunker_shot_count: int | None penalty_strokes: int | None anyway_strokes: int | None async def send_round_summary_email( to_email: str, guest_first_name: str, round_label: str, played_at: date, started_at: datetime | None, duration_minutes: int | None, other_players_count: int, start_hole: int, holes: list[RoundSummaryHole], stat_lines: list[tuple[str, str]], raw_token: str | None, is_linked_account: bool = False, round_id: str | None = None, ) -> None: """Rundeoppsummering til en spiller med registrert e-post, sendt UMIDDELBART når DENNE spilleren avsluttes (app/routers/rounds.py sin update_participant, migrasjon 066, 2026-08-13 -- opprinnelig kun ved hele rundens fullføring, 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. To mottakertyper (migrasjon 066 utvidet fra kun gjester): - Gjest (`is_linked_account=False`): `raw_token` er et ekte magic-link- innloggingstoken (samme mønster som tournaments.py sin send_scorecard_invitations) -- ingen konto finnes ennå, e-posten pitcher én. - Lenket bruker (`is_linked_account=True`): `raw_token` er None, `round_id` MÅ være satt -- lenken peker rett på rundesiden (vanlig innlogging gjelder), ingen konto-pitch (feil for noen som allerede har en). 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. `holes` graderer seg på samme vis kolonne for kolonne (2026-08-13, ADR-065 "fullt scorekort"-utvidelsen, se RoundSummaryHole). `played_at` (dato) + `started_at` (klokkeslett, valgfritt) brukes til hilsen-linjens dato/klokkeslett, formatert til Europe/Oslo -- appen er norsk-only, ingen lagret tidssone-preferanse å slå opp her. Se _format_round_datetime for hvorfor disse er to separate felt. `duration_minutes` er None når verken denne deltakeren eller runden har en kjent sluttid ennå (kalleren avgjør -- se _send_participant_round_summary i rounds.py). `other_players_count` er resten av flighten, brukt i "sammen med N andre"-leddet (utelatt helt hvis 0). `start_hole` avgjør Ut/Inn- rekkefølgen i HTML-tabellen (spillerekkefølge, ikke rå hole_number -- matcher round-scorecard.tsx sin holeOrder-logikk). Alltid norsk, samme presedens som send_scorecard_invitations (verken en gjest eller en spiller midt i en runde har nødvendigvis en lagret språkpreferanse tilgjengelig her).""" if is_linked_account: link = f"{settings.PUBLIC_BASE_URL}/my-rounds/{round_id}" else: link = f"{settings.PUBLIC_BASE_URL}/verify?token={raw_token}" subject = f"Scorekortet ditt fra {round_label}" holes_played = sum(1 for h in holes if h.score is not None) detail_bits = [f"du spilte {holes_played} hull"] if duration_minutes is not None: detail_bits[0] += f" på {_format_duration(duration_minutes)}" if other_players_count > 0: detail_bits.append(f"sammen med {other_players_count} andre") datetime_phrase = _format_round_datetime(played_at, started_at) detail_txt = ", ".join(detail_bits) greeting_line = f"Her er scorekortet ditt fra {round_label}, {datetime_phrase} ({detail_txt}):" greeting_html = ( f"Her er scorekortet ditt fra {_esc(round_label)}, {_esc(datetime_phrase)} " f"({_esc(detail_txt)}):" ) # Kolonnesettet graderer seg -- en kolonne tas kun med når MINST ett # hull faktisk har data for den, akkurat som show*-flaggene i # frontend/components/round-scorecard.tsx. Samme spesifikasjon brukes # til BÅDE tekst- og HTML-varianten under, så de aldri kan divergere. def _gir(h: RoundSummaryHole) -> bool: return h.score is not None and h.putts is not None and h.score - h.putts <= h.par - 2 columns: list[tuple[str, Callable[[RoundSummaryHole], str]]] = [ ("Hcp", lambda h: str(h.stroke_index)), ("Par", lambda h: str(h.par)), ("Slag", lambda h: "PU" if h.picked_up else (str(h.score) if h.score is not None else "–")), ] if any(h.net is not None for h in holes): columns.append(("Netto", lambda h: str(h.net) if h.net is not None else "–")) if any(h.points is not None for h in holes): columns.append(("Poeng", lambda h: str(h.points) if h.points is not None else "–")) if any(h.tee_shot_result is not None for h in holes): columns.append(("Fw", lambda h: _FAIRWAY_GLYPHS[h.tee_shot_result][0] if h.par != 3 and h.tee_shot_result is not None else "–")) if any(h.putts is not None for h in holes): columns.append(("Putt", lambda h: str(h.putts) if h.putts is not None else "–")) if any(h.score is not None and h.putts is not None for h in holes): columns.append(("GIR", lambda h: "Ja" if _gir(h) else "–")) if any(h.approach_result is not None for h in holes): columns.append(("Innspill", lambda h: _APPROACH_GLYPHS[h.approach_result][0] if h.approach_result is not None else "–")) if any(h.chip_count is not None for h in holes): columns.append(("Chip", lambda h: str(h.chip_count) if h.chip_count is not None else "–")) if any(h.bunker_shot_count is not None for h in holes): columns.append(("Bunker", lambda h: str(h.bunker_shot_count) if h.bunker_shot_count is not None else "–")) if any(h.penalty_strokes is not None for h in holes): columns.append(("Straff", lambda h: str(h.penalty_strokes) if h.penalty_strokes is not None else "–")) if any(h.anyway_strokes is not None for h in holes): columns.append(("Anyway", lambda h: str(h.anyway_strokes) if h.anyway_strokes is not None else "–")) text_lines = [ f"Hei {guest_first_name},", "", greeting_line, "", ] for h in holes: cell_txt = ", ".join(f"{label} {fn(h)}" for label, fn in columns) text_lines.append(f"Hull {h.hole_number}: {cell_txt}") text_lines.append("") for label, value in stat_lines: text_lines.append(f"{label}: {value}") if is_linked_account: text_lines += [ "", "Se hele runden:", link, ] else: 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) # HTML-tabellen (2026-08-13, samme dag som ovenstående -- bruker sendte # et skjermbilde av selve Scorekort-siden i appen og ba om at e-posten # skal SE UT SOM DEN, ikke bare inneholde de samme tallene): hull som # KOLONNER (ikke rader, ulikt tekst-fallbacken over) i to 9-hulls- # blokker (Ut/Inn) for en 18-hullsrunde, matcher `ScoreBlock` i # frontend/components/round-scorecard.tsx radrekkefølge/-utvalg # nøyaktig -- samme farger/glyffer/UT-INN-oppsummeringskolonne. show_net = any(h.net is not None for h in holes) show_points = any(h.points is not None for h in holes) show_fairway = any(h.tee_shot_result is not None for h in holes) show_putts = any(h.putts is not None for h in holes) show_gir = any(h.score is not None and h.putts is not None for h in holes) show_approach = any(h.approach_result is not None for h in holes) show_chip = any(h.chip_count is not None for h in holes) show_bunker = any(h.bunker_shot_count is not None for h in holes) show_penalty = any(h.penalty_strokes is not None for h in holes) show_anyway = any(h.anyway_strokes is not None for h in holes) _label_style = ( "padding:5px 6px;border:1px solid #e4ebe3;text-align:left;font-size:10px;" "font-weight:800;color:#5b665b;white-space:nowrap;background-color:#f6f8f5;" ) _cell_style = "padding:5px 3px;border:1px solid #e4ebe3;text-align:center;font-size:11px;" _sum_style = _cell_style + "font-weight:800;background-color:#eef4ec;" def _row_html(label: str, cell_htmls: list[str], summary_html: str) -> str: cells = "".join(f'
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.
""" cta_label = "Logg inn og se hele runden" footer_html = f"""Lenken er gyldig i {settings.MAGIC_LINK_MAX_AGE_MINUTES} minutter. Ikke interessert? Ignorer denne e-posten -- den brukes ikke til noe annet.
""" html_body = f"""\