| 1 |
1 |
"""Transactional e-mail through the Resend HTTP API (httpx). |
| 2 |
2 |
|
| 3 |
3 |
`send(kind, to, **ctx)` renders one of the English templates (plain text + minimal HTML) and posts it to |
| 4 |
|
−Resend when `settings.resend_api_key` is set. Without a key the message is logged and `delivered=False` is |
| 5 |
|
−returned, so the caller stores the action link in `email_tokens.meta` for the admin UI / CLI to print. |
| 6 |
|
−API keys are NEVER included in an e-mail. |
|
4 |
+Resend when `settings.resend_api_key` is set. Without a key the message is logged (type + masked recipient, |
|
5 |
+never the link) and `delivered=False` is returned, so the caller stores the action link in |
|
6 |
+`email_tokens.meta` for the admin UI / CLI to print. API keys are NEVER included in an e-mail. |
|
7 |
+ |
|
8 |
+`queue(session, kind, to, **ctx)` defers the delivery until the SQLAlchemy session **commits** (outbox in |
|
9 |
+`session.info`, flushed by the `after_commit` hook to a small thread pool) so the 10-second Resend call |
|
10 |
+never runs inside a SQLite transaction and a rolled-back signup sends nothing. `on_failure(kind, to, ctx)` |
|
11 |
+callbacks let the caller persist the link when the provider rejects the message. |
| 7 |
12 |
|
| 8 |
13 |
Author: Simon-Pierre Boucher <contact@spboucher.ai> |
| 9 |
14 |
""" |
| 11 |
16 |
|
| 12 |
17 |
import html |
| 13 |
18 |
import logging |
| 14 |
|
−from dataclasses import dataclass |
|
19 |
+from collections.abc import Callable |
|
20 |
+from concurrent.futures import ThreadPoolExecutor |
|
21 |
+from dataclasses import dataclass, field |
|
22 |
+from typing import Any |
| 15 |
23 |
|
| 16 |
24 |
import httpx |
|
25 |
+from sqlalchemy import event |
|
26 |
+from sqlalchemy.orm import Session |
| 17 |
27 |
|
| 18 |
28 |
from core.config import settings |
|
29 |
+from core.db import SessionLocal |
| 19 |
30 |
|
| 20 |
31 |
log = logging.getLogger("hfmarketdata.mailer") |
| 21 |
32 |
|
| 22 |
33 |
RESEND_URL = "https://api.resend.com/emails" |
| 23 |
34 |
TIMEOUT_S = 10.0 |
|
35 |
+OUTBOX_KEY = "hfmd_mail_outbox" |
|
36 |
+_pool = ThreadPoolExecutor(max_workers=2, thread_name_prefix="hfmd-mail") |
| 24 |
37 |
|
| 25 |
38 |
|
| 26 |
39 |
@dataclass(frozen=True) |
| 27 |
40 |
class Delivery: |
| 28 |
41 |
delivered: bool |
| 29 |
42 |
provider_id: str | None = None |
| 30 |
|
− error: str | None = None |
|
43 |
+ error: str | None = None # no_provider | queued | resend_<status> | <exception> |
|
44 |
+ |
|
45 |
+ @property |
|
46 |
+ def queued(self) -> bool: |
|
47 |
+ return self.error == "queued" |
|
48 |
+ |
|
49 |
+ |
|
50 |
+@dataclass |
|
51 |
+class Outgoing: |
|
52 |
+ kind: str |
|
53 |
+ to: str |
|
54 |
+ ctx: dict[str, Any] |
|
55 |
+ on_failure: Callable[[str, str, dict[str, Any], Delivery], None] | None = None |
|
56 |
+ on_success: Callable[[str, str, dict[str, Any], Delivery], None] | None = field(default=None) |
|
57 |
+ |
|
58 |
+ |
|
59 |
+def mask(email: str) -> str: |
|
60 |
+ """ada@example.com → a***@example.com (for logs).""" |
|
61 |
+ if not email or "@" not in email: |
|
62 |
+ return "***" |
|
63 |
+ local, _, domain = email.partition("@") |
|
64 |
+ return f"{local[:1]}***@{domain}" |
|
65 |
+ |
|
66 |
+ |
|
67 |
+def provider_configured() -> bool: |
|
68 |
+ return bool(settings.resend_api_key) |
| 31 |
69 |
|
| 32 |
70 |
|
| 33 |
71 |
def _template(kind: str, ctx: dict) -> tuple[str, str]: |
| 34 |
|
− """Return (subject, body_text) for a template. `ctx` keys: name, link, key_name, key_prefix, expires.""" |
|
72 |
+ """Return (subject, body_text) for a template. `ctx` keys: name, link, key_name, key_prefix, expires, …""" |
| 35 |
73 |
name = ctx.get("name") or "there" |
| 36 |
74 |
link = ctx.get("link", "") |
| 37 |
75 |
brand = "HF Market Data" |
| 38 |
76 |
footer = (f"\n\n— {brand} · {settings.public_url}\nQuestions? Reply to this e-mail or write to " |
| 39 |
77 |
f"{settings.contact_email}.") |
|
78 |
+ keys_url = f"{settings.public_url}/dashboard/keys" |
|
79 |
+ account_url = f"{settings.public_url}/dashboard/account" |
| 40 |
80 |
if kind == "verify": |
| 41 |
81 |
return (f"Verify your e-mail for {brand}", |
| 42 |
82 |
f"Hi {name},\n\nConfirm your e-mail address to activate your {brand} account and create your " |
| 54 |
94 |
f"Hi {name},\n\nSomeone asked to reset the password of your {brand} account. If it was you, " |
| 55 |
95 |
f"choose a new password here:\n\n{link}\n\nThe link is valid for {ctx.get('expires', '1 hour')}. " |
| 56 |
96 |
f"Otherwise ignore this e-mail — your password stays unchanged.{footer}") |
|
97 |
+ if kind == "already_registered": |
|
98 |
+ return (f"You already have a {brand} account", |
|
99 |
+ f"Hi {name},\n\nSomeone (probably you) just tried to sign up on {brand} with this e-mail address, " |
|
100 |
+ f"but an account already exists. Nothing was changed.\n\nSign in at {settings.public_url}/signin, or " |
|
101 |
+ f"choose a new password if you forgot it:\n\n{settings.public_url}/reset\n\nIf this was not you, " |
|
102 |
+ f"you can ignore this message.{footer}") |
|
103 |
+ if kind == "email_change": |
|
104 |
+ return (f"Confirm your new e-mail address for {brand}", |
|
105 |
+ f"Hi {name},\n\nConfirm that {ctx.get('new_email', 'this address')} is now the e-mail of your " |
|
106 |
+ f"{brand} account:\n\n{link}\n\nThe link is valid for {ctx.get('expires', '48 hours')}. Until you " |
|
107 |
+ f"confirm, the account keeps its current address. If you did not request this, ignore this " |
|
108 |
+ f"message.{footer}") |
|
109 |
+ if kind == "email_changed": |
|
110 |
+ return (f"Your {brand} e-mail address was changed", |
|
111 |
+ f"Hi {name},\n\nThe e-mail address of your {brand} account was changed to " |
|
112 |
+ f"{ctx.get('new_email', '(new address)')}. Every browser session was signed out.\n\nIf this was not " |
|
113 |
+ f"you, contact {settings.contact_email} right away.{footer}") |
|
114 |
+ if kind == "password_changed": |
|
115 |
+ return (f"Your {brand} password was changed", |
|
116 |
+ f"Hi {name},\n\nThe password of your {brand} account was just changed and every other browser " |
|
117 |
+ f"session was signed out.\n\nIf this was not you, reset your password now at " |
|
118 |
+ f"{settings.public_url}/reset and contact {settings.contact_email}.{footer}") |
|
119 |
+ if kind == "suspicious_login": |
|
120 |
+ return (f"Suspicious sign-in attempts on your {brand} account", |
|
121 |
+ f"Hi {name},\n\nWe blocked repeated failed sign-in attempts on your {brand} account " |
|
122 |
+ f"({ctx.get('attempts', 10)} wrong passwords in a row). The account is temporarily locked; it " |
|
123 |
+ f"unlocks by itself.\n\nIf this was not you, we recommend choosing a new password at " |
|
124 |
+ f"{settings.public_url}/reset and reviewing your API keys at {keys_url}.{footer}") |
| 57 |
125 |
if kind == "key_created": |
| 58 |
126 |
return (f"New API key on your {brand} account", |
| 59 |
127 |
f"Hi {name},\n\nA new API key named “{ctx.get('key_name', 'default')}” " |
| 60 |
128 |
f"(prefix {ctx.get('key_prefix', '')}…) was created on your account. For security the key " |
| 61 |
129 |
f"itself is only shown once, in the dashboard.\n\nIf this was not you, revoke it now: " |
| 62 |
|
− f"{settings.public_url}/dashboard/keys{footer}") |
|
130 |
+ f"{keys_url}{footer}") |
|
131 |
+ if kind == "key_revoked": |
|
132 |
+ return (f"API key revoked on your {brand} account", |
|
133 |
+ f"Hi {name},\n\nThe API key “{ctx.get('key_name', 'default')}” (prefix {ctx.get('key_prefix', '')}…) " |
|
134 |
+ f"was revoked. Requests using it now get 401 INVALID_API_KEY.\n\nIf this was not you, sign in and " |
|
135 |
+ f"check your account: {keys_url}{footer}") |
|
136 |
+ if kind == "key_rotated": |
|
137 |
+ return (f"API key rotated on your {brand} account", |
|
138 |
+ f"Hi {name},\n\nThe API key “{ctx.get('key_name', 'default')}” (prefix {ctx.get('key_prefix', '')}…) " |
|
139 |
+ f"was rotated: the old key is revoked and a new one (prefix {ctx.get('new_prefix', '')}…) replaces " |
|
140 |
+ f"it. The new key was shown once in the dashboard.\n\nIf this was not you, revoke it now: " |
|
141 |
+ f"{keys_url}{footer}") |
|
142 |
+ if kind == "account_deleted": |
|
143 |
+ return (f"Your {brand} account was deleted", |
|
144 |
+ f"Hi {name},\n\nYour {brand} account was deleted as requested: every API key is revoked and every " |
|
145 |
+ f"session signed out. Aggregated usage counters are kept anonymously.\n\nThank you for using " |
|
146 |
+ f"{brand}.{footer}") |
|
147 |
+ if kind == "quota_alert": |
|
148 |
+ what = ctx.get("what", "quota") |
|
149 |
+ return (f"{brand}: {what}", |
|
150 |
+ f"Hi {name},\n\n{ctx.get('detail', '')}\n\nSee your live counters at {settings.public_url}/dashboard " |
|
151 |
+ f"and the tier table at {settings.public_url}/limits. Higher limits are free on request: " |
|
152 |
+ f"{settings.contact_email}.\n\nYou receive at most one alert per day; turn them off in {account_url}." |
|
153 |
+ f"{footer}") |
| 63 |
154 |
raise ValueError(f"unknown mail template {kind!r}") |
| 64 |
155 |
|
| 65 |
156 |
|
| 80 |
171 |
|
| 81 |
172 |
|
| 82 |
173 |
def send(kind: str, to: str, **ctx) -> Delivery: |
|
174 |
+ """Synchronous delivery. Logs never contain the action link or the full recipient.""" |
| 83 |
175 |
subject, text, body_html = render(kind, **ctx) |
| 84 |
|
− if not settings.resend_api_key: |
| 85 |
|
− log.info("mail not delivered (no HFMD_RESEND_API_KEY) kind=%s to=%s subject=%r link=%s", |
| 86 |
|
− kind, to, subject, ctx.get("link")) |
|
176 |
+ if not provider_configured(): |
|
177 |
+ log.info("mail not delivered (no HFMD_RESEND_API_KEY) kind=%s to=%s", kind, mask(to)) |
| 87 |
178 |
return Delivery(False, error="no_provider") |
| 88 |
179 |
payload = {"from": settings.mail_from, "to": [to], "subject": subject, "text": text, "html": body_html} |
| 89 |
180 |
try: |
| 90 |
181 |
r = httpx.post(RESEND_URL, json=payload, timeout=TIMEOUT_S, |
| 91 |
182 |
headers={"Authorization": f"Bearer {settings.resend_api_key}"}) |
| 92 |
183 |
if r.status_code >= 300: |
| 93 |
|
− log.warning("resend error %s for %s: %s", r.status_code, to, r.text[:300]) |
|
184 |
+ log.warning("resend error %s kind=%s to=%s: %s", r.status_code, kind, mask(to), r.text[:300]) |
| 94 |
185 |
return Delivery(False, error=f"resend_{r.status_code}") |
| 95 |
186 |
return Delivery(True, provider_id=(r.json() or {}).get("id")) |
| 96 |
187 |
except Exception as exc: |
| 97 |
|
− log.warning("resend unreachable for %s: %s", to, exc) |
|
188 |
+ log.warning("resend unreachable kind=%s to=%s: %s", kind, mask(to), type(exc).__name__) |
| 98 |
189 |
return Delivery(False, error=str(exc)[:200]) |
|
190 |
+ |
|
191 |
+ |
|
192 |
+# ------------------------------------------------------------------------------------- deferred sending |
|
193 |
+ |
|
194 |
+def _deliver(msg: Outgoing) -> None: |
|
195 |
+ try: |
|
196 |
+ d = send(msg.kind, msg.to, **msg.ctx) |
|
197 |
+ cb = msg.on_success if d.delivered else msg.on_failure |
|
198 |
+ if cb is not None: |
|
199 |
+ cb(msg.kind, msg.to, msg.ctx, d) |
|
200 |
+ except Exception as exc: # pragma: no cover — never crash the pool |
|
201 |
+ log.warning("deferred mail %s to %s failed: %s", msg.kind, mask(msg.to), exc) |
|
202 |
+ |
|
203 |
+ |
|
204 |
+def dispatch(msg: Outgoing) -> None: |
|
205 |
+ """Hand a message to the background pool (after commit). Patched by tests to run inline.""" |
|
206 |
+ _pool.submit(_deliver, msg) |
|
207 |
+ |
|
208 |
+ |
|
209 |
+def queue(s: Session, kind: str, to: str, *, on_failure=None, on_success=None, **ctx) -> Delivery: |
|
210 |
+ """Queue a message to be sent once `s` commits. Returns `no_provider` immediately when Resend is not |
|
211 |
+ configured (nothing will be sent — the caller keeps the link), `queued` otherwise.""" |
|
212 |
+ if not provider_configured(): |
|
213 |
+ log.info("mail not delivered (no HFMD_RESEND_API_KEY) kind=%s to=%s", kind, mask(to)) |
|
214 |
+ return Delivery(False, error="no_provider") |
|
215 |
+ s.info.setdefault(OUTBOX_KEY, []).append(Outgoing(kind, to, dict(ctx), on_failure, on_success)) |
|
216 |
+ return Delivery(False, error="queued") |
|
217 |
+ |
|
218 |
+ |
|
219 |
+@event.listens_for(SessionLocal, "after_commit") |
|
220 |
+def _flush_outbox(session: Session) -> None: |
|
221 |
+ for msg in session.info.pop(OUTBOX_KEY, []) or []: |
|
222 |
+ dispatch(msg) |
|
223 |
+ |
|
224 |
+ |
|
225 |
+@event.listens_for(SessionLocal, "after_rollback") |
|
226 |
+def _drop_outbox(session: Session) -> None: |
|
227 |
+ session.info.pop(OUTBOX_KEY, None) |