""" 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)