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

79 lines
2.8 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)