SPB Git forge

spb/uqo-chat

Public
14commits 1branches 0releases
1.4 MBsize
maindefault branch
17 days agolast push
Python 64.6% TypeScript 33.7% CSS 0.8%
10.4 KB · 207 lines python
Raw Blame History
1"""Transactional e-mail: Resend (HTTP API) first, SMTP fallback, otherwise logged only.23Three messages: invitation (choose a password), password reset, legacy login link.4Batches use Resend's /emails/batch (≤100 messages per call) so a whole cohort of students5is invited with a handful of requests.6"""78from __future__ import annotations910import html11from dataclasses import dataclass12from email.message import EmailMessage1314import httpx1516from app.core.config import Settings17from app.core.logging import get_logger1819log = get_logger("mail")2021BRAND = "#0F6180"22BRAND_DARK = "#0B4A62"23GREEN = "#78B928"242526@dataclass(slots=True)27class Mail:28    to: str29    subject: str30    text: str31    html: str323334# ------------------------------------------------------------------ templates35def _layout(title: str, intro_html: str, cta_label: str, link: str, footer_html: str) -> str:36    return f"""<!doctype html>37<html lang="fr"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width">38<title>{html.escape(title)}</title></head>39<body style="margin:0;padding:0;background:#f3f6f9;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;color:#1c2b36;">40<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="background:#f3f6f9;padding:24px 12px;">41<tr><td align="center">42<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="max-width:520px;background:#ffffff;border-radius:20px;overflow:hidden;box-shadow:0 8px 30px rgba(15,97,128,.12);">43<tr><td style="background:linear-gradient(135deg,{BRAND} 0%,{BRAND_DARK} 100%);background-color:{BRAND};padding:28px 28px 22px;color:#ffffff;">44  <div style="font-size:22px;font-weight:800;letter-spacing:.2px;">UQO-Chat</div>45  <div style="font-size:13px;opacity:.85;margin-top:4px;">Tuteur IA · IMM1003 · IMM1033</div>46</td></tr>47<tr><td style="padding:28px 28px 8px;">48  <h1 style="margin:0 0 12px;font-size:20px;line-height:1.3;color:{BRAND_DARK};">{html.escape(title)}</h1>49  <div style="font-size:15px;line-height:1.55;">{intro_html}</div>50</td></tr>51<tr><td align="center" style="padding:16px 28px 8px;">52  <a href="{html.escape(link)}" style="display:inline-block;background:{BRAND};color:#ffffff;text-decoration:none;font-weight:700;font-size:15px;padding:14px 26px;border-radius:12px;">{html.escape(cta_label)}</a>53</td></tr>54<tr><td style="padding:8px 28px 24px;font-size:13px;line-height:1.5;color:#5b6b78;">55  <p style="margin:12px 0 0;">Si le bouton ne fonctionne pas, copie ce lien dans ton navigateur :<br>56  <a href="{html.escape(link)}" style="color:{BRAND};word-break:break-all;">{html.escape(link)}</a></p>57  {footer_html}58</td></tr>59<tr><td style="background:#f7f9fb;padding:14px 28px;font-size:12px;color:#7a8893;border-top:1px solid #e6ebf0;">60  Outil pédagogique de l'UQO. Ne remplace pas un évaluateur agréé (É.A.). Courriel automatique : ne pas répondre.61</td></tr>62</table>63</td></tr></table>64</body></html>"""656667def invitation_mail(to: str, link: str, display_name: str | None, ttl_days: int,68                    term: str) -> Mail:69    name = (display_name or "").strip()70    hello = f"Bonjour {html.escape(name)}," if name and "@" not in name else "Bonjour,"71    title = "Bienvenue sur UQO-Chat — choisis ton mot de passe"72    intro = (f"<p style='margin:0 0 10px'>{hello}</p>"73             "<p style='margin:0 0 10px'>Ton professeur t'a inscrit·e à <b>UQO-Chat</b>, le tuteur IA des cours "74             f"IMM1003 et IMM1033 ({html.escape(term)}). Il explique les notes de cours, corrige tes calculs "75             "d'évaluation immobilière et produit des classeurs Excel.</p>"76             f"<p style='margin:0'>Pour activer ton compte <b>{html.escape(to)}</b>, choisis un mot de passe. "77             "Tu l'utiliseras ensuite avec ton courriel à chaque connexion.</p>")78    footer = (f"<p style='margin:12px 0 0'>Ce lien est valide {ttl_days} jours et ne sert qu'une fois. "79              "S'il a expiré, clique sur « Première connexion ou mot de passe oublié » sur la page de connexion.</p>")80    text = (f"{hello.replace(',', ',')}\n\nTon professeur t'a inscrit·e à UQO-Chat, le tuteur IA des cours IMM1003 et "81            f"IMM1033 ({term}).\n\nPour activer ton compte {to}, choisis un mot de passe ici (valide {ttl_days} jours) :\n\n"82            f"{link}\n\nTu l'utiliseras ensuite avec ton courriel à chaque connexion. Si le lien a expiré, utilise "83            "« Première connexion ou mot de passe oublié » sur la page de connexion.\n\n— UQO-Chat")84    return Mail(to, title, text, _layout(title, intro, "Choisir mon mot de passe", link, footer))858687def reset_mail(to: str, link: str, ttl_minutes: int, first_time: bool) -> Mail:88    if first_time:89        title = "Choisis ton mot de passe UQO-Chat"90        intro = ("<p style='margin:0 0 10px'>Bonjour,</p>"91                 f"<p style='margin:0'>Tu as demandé à activer ton compte UQO-Chat <b>{html.escape(to)}</b>. "92                 "Choisis un mot de passe pour te connecter.</p>")93        cta = "Choisir mon mot de passe"94    else:95        title = "Réinitialisation de ton mot de passe UQO-Chat"96        intro = ("<p style='margin:0 0 10px'>Bonjour,</p>"97                 f"<p style='margin:0'>Une réinitialisation du mot de passe a été demandée pour "98                 f"<b>{html.escape(to)}</b>. Clique ci-dessous pour en choisir un nouveau.</p>")99        cta = "Choisir un nouveau mot de passe"100    footer = (f"<p style='margin:12px 0 0'>Ce lien est valide {ttl_minutes} minutes et ne sert qu'une fois. "101              "Si tu n'as rien demandé, ignore ce courriel : ton mot de passe actuel reste inchangé.</p>")102    text = (f"Bonjour,\n\n{'Tu as demandé à activer ton compte' if first_time else 'Une réinitialisation du mot de passe a été demandée pour'} "103            f"UQO-Chat {to}.\n\nChoisis ton mot de passe ici (valide {ttl_minutes} minutes) :\n\n{link}\n\n"104            "Si tu n'as rien demandé, ignore ce courriel.\n\n— UQO-Chat")105    return Mail(to, title, text, _layout(title, intro, cta, link, footer))106107108def login_link_mail(to: str, link: str) -> Mail:109    title = "Connexion à UQO-Chat"110    intro = f"<p style='margin:0'>Voici ton lien de connexion à UQO-Chat pour <b>{html.escape(to)}</b>.</p>"111    footer = "<p style='margin:12px 0 0'>Si tu n'as pas demandé ce lien, ignore ce courriel.</p>"112    text = f"Bonjour,\n\nVoici ton lien de connexion à UQO-Chat :\n\n{link}\n\nSi tu n'as pas demandé ce lien, ignore ce courriel.\n\n— UQO-Chat"113    return Mail(to, title, text, _layout(title, intro, "Me connecter", link, footer))114115116# ------------------------------------------------------------------ transport117def _resend_payload(settings: Settings, m: Mail) -> dict:118    payload = {"from": settings.MAIL_FROM, "to": [m.to], "subject": m.subject,119               "text": m.text, "html": m.html}120    if settings.MAIL_REPLY_TO:121        payload["reply_to"] = settings.MAIL_REPLY_TO122    return payload123124125def _resend_headers(settings: Settings) -> dict[str, str]:126    return {"Authorization": f"Bearer {settings.RESEND_API_KEY.get_secret_value()}",127            "Content-Type": "application/json"}128129130async def _send_smtp(settings: Settings, m: Mail) -> bool:131    import aiosmtplib132133    msg = EmailMessage()134    msg["From"] = settings.SMTP_FROM135    msg["To"] = m.to136    msg["Subject"] = m.subject137    msg.set_content(m.text)138    msg.add_alternative(m.html, subtype="html")139    await aiosmtplib.send(140        msg, hostname=settings.SMTP_HOST, port=settings.SMTP_PORT,141        username=settings.SMTP_USER or None,142        password=settings.SMTP_PASSWORD.get_secret_value() or None,143        start_tls=settings.SMTP_PORT == 587)144    return True145146147async def send(settings: Settings, m: Mail) -> bool:148    """Send one message. Returns False (and logs) when no transport is configured or it fails."""149    if settings.resend_enabled:150        try:151            async with httpx.AsyncClient(timeout=20) as client:152                r = await client.post(f"{settings.RESEND_BASE_URL}/emails",153                                      headers=_resend_headers(settings),154                                      json=_resend_payload(settings, m))155            if r.status_code < 300:156                log.info("mail_sent", provider="resend", to_domain=m.to.split("@")[-1])157                return True158            log.warning("mail_failed", provider="resend", status=r.status_code, body=r.text[:300])159        except httpx.HTTPError as exc:160            log.warning("mail_failed", provider="resend", error=str(exc))161        return False162    if settings.smtp_enabled:163        try:164            return await _send_smtp(settings, m)165        except Exception as exc:  # noqa: BLE001166            log.warning("mail_failed", provider="smtp", error=str(exc))167            return False168    log.info("mail_not_sent_no_transport", to_domain=m.to.split("@")[-1])169    return False170171172async def send_batch(settings: Settings, mails: list[Mail]) -> dict[str, bool]:173    """Send many messages; Resend batch endpoint (100/call), otherwise one by one."""174    result: dict[str, bool] = {}175    if not mails:176        return result177    if settings.resend_enabled:178        async with httpx.AsyncClient(timeout=30) as client:179            for i in range(0, len(mails), 100):180                chunk = mails[i:i + 100]181                try:182                    r = await client.post(f"{settings.RESEND_BASE_URL}/emails/batch",183                                          headers=_resend_headers(settings),184                                          json=[_resend_payload(settings, m) for m in chunk])185                    ok = r.status_code < 300186                    if not ok:187                        log.warning("mail_batch_failed", status=r.status_code, body=r.text[:300])188                except httpx.HTTPError as exc:189                    ok = False190                    log.warning("mail_batch_failed", error=str(exc))191                if ok:192                    for m in chunk:193                        result[m.to] = True194                else:  # degrade to unit sends so one bad address does not block the cohort195                    for m in chunk:196                        result[m.to] = await send(settings, m)197        log.info("mail_batch_sent", n=sum(result.values()), total=len(mails))198        return result199    for m in mails:200        result[m.to] = await send(settings, m)201    return result202203204# ------------------------------------------------------------------ convenience205async def send_magic_link(settings: Settings, to: str, link: str) -> bool:206    return await send(settings, login_link_mail(to, link))207