Python 64.6%
TypeScript 33.7%
CSS 0.8%
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