teecup/app/email.py
Erol Haagenrud baa1c42d24 Per-deltaker-fullføring av runder + scorekort på e-post; Larvik/Nesbyen via GolfAPI
Backend (ADR-065): round_participant.completed_at (migrasjon 066),
flight-styrt PATCH-tilgang, scorekort-e-post til gjest/lenket bruker
ved individuell fullføring og ved rundefullføring, samt et nytt
on-demand send-scorecard-endepunkt som virker uansett fullføringsstatus.
Frontend-UI kommer i egen runde.

Nye baner Larvik (Seasidebanen) og Nesbyen (manuelt GPS-registrert,
124 punkter) importert til produksjon. Fant og fikset en reell
GolfAPI-datakvalitetsbug underveis: tomme strenger i stedet for
null/fraværende på course rating/slope for begge kjønn (migrasjon
067/068, defensiv parsing + import-endepunkter).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 21:24:25 +02:00

498 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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 dataclasses import dataclass
from email.message import EmailMessage
from html import escape as _esc
from .config import settings
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)
_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
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,
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.
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}"
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}")
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)
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
)
if is_linked_account:
pitch_html = ""
cta_label = "Se hele runden"
footer_html = ""
else:
pitch_html = f"""<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>"""
cta_label = "Logg inn og se hele runden"
footer_html = f"""<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>"""
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>
{pitch_html}
<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;">{cta_label}</a>
</td></tr></table>
{footer_html}
</td></tr>
</table>
</td></tr>
</table>
</body></html>
"""
await to_thread(_send_sync, to_email, subject, body, html_body)