pdf-quiz-generator/backend/app/services/email_service.py
Daniel febb14490c
Some checks failed
Tests / backend (push) Failing after 15s
Tests / frontend (push) Successful in 28s
Tests / e2e (push) Failing after 26s
feat: ground AI drafts in the library and PubMed, and mend the card system
**Two sources an AI draft can draw on**, both off until an administrator turns
them on, both appended to the prompt as extra material rather than woven into
it — so a draft with nothing to draw on is byte-for-byte the draft that has
been working well.

- *The clinical library.* The indexed shelf the clinical assistant already
  searches, over MCP on the internal network. Ported from ped-ai: sessions are
  reused, a dead one is reopened once, and a library that cannot be reached
  never fails the article — it just means the educator is writing without it,
  and the progress line says so.
- *PubMed.* NCBI's E-utilities, no key required. Ported whole, including the
  two lessons that cost somebody an afternoon over there: PubMed ANDs every
  term, so "bronchiolitis management in infants" can find nothing where
  "bronchiolitis management" finds six — hence the query ladder — and three
  esearch calls in a row will trip the rate limit, hence the spacing. The
  reference list is written from the records rather than by the model, so every
  line is a paper that exists with a PMID somebody can look up.

Measured on the live stack: 24 excerpts, 6 papers, 6 references, 6 in-text
citations, in one draft.

**The card system, which turned out to be half-built:**

- There was no way to make a deck by hand, and no way to edit a card at all —
  you could browse, view and delete. Both are there now, the editor taking
  front, back and a picture.
- Filing, writing, sharing and deleting are all educator work now, behind one
  named gate rather than four scattered checks. A learner studies.
- A deck generated from an article inherits that article's category instead of
  landing in Uncategorized for somebody to file by hand.
- A link inside a card previewed instead of going. A card is a box a few lines
  tall, often inside a flipping panel, and a hover card anchored in one is
  clipped by it — so the link read as broken because clicking it did nothing.
  Where there is no room to preview, the honest behaviour is to take you there.

**An AI draft belonged to no editorial queue.** Nothing set `generated_by`, so
a drafted article was neither "generated, unread" nor anything else: the tile
counted it and there was nowhere to click. Drafts are stamped with the model
that wrote them, and there is now a plain Drafts queue that cannot be fallen
through.

**The sign-in code email** is laid out rather than written: the code is the
biggest thing on the screen, then which account it signs into, then a way back
to the page, then permission to ignore the whole thing.

Also: a back link out of a deck, in the same words as the rest of the app.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-13 02:44:42 +02:00

218 lines
9.1 KiB
Python

import logging
import re
from datetime import datetime
from fastapi_mail import FastMail, MessageSchema, MessageType, ConnectionConfig
from app.config import settings
logger = logging.getLogger(__name__)
def get_mail_config() -> ConnectionConfig:
return ConnectionConfig(
MAIL_USERNAME=settings.MAIL_USERNAME,
MAIL_PASSWORD=settings.MAIL_PASSWORD,
MAIL_FROM=settings.MAIL_FROM,
MAIL_FROM_NAME="PedsHub",
MAIL_PORT=settings.MAIL_PORT,
MAIL_SERVER=settings.MAIL_SERVER,
MAIL_STARTTLS=settings.MAIL_STARTTLS,
MAIL_SSL_TLS=settings.MAIL_SSL_TLS,
USE_CREDENTIALS=bool(settings.MAIL_USERNAME and settings.MAIL_PASSWORD),
)
def _render(md: str) -> str:
"""
Minimal markdown → inline HTML for email.
Resend-style: white background, dark text, clean sans-serif, one column.
"""
lines, out = md.strip().split("\n"), []
for line in lines:
s = line.strip()
# Bold inline
s_html = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', s)
if s.startswith("# "):
out.append(f'<h1 style="margin:0 0 16px;font-size:24px;font-weight:700;color:#09090b;letter-spacing:-0.4px;line-height:1.2;">{s[2:]}</h1>')
elif s.startswith("## "):
out.append(f'<h2 style="margin:24px 0 8px;font-size:15px;font-weight:600;color:#18181b;text-transform:uppercase;letter-spacing:0.05em;">{s[3:]}</h2>')
elif s.startswith("> "):
out.append(f'<blockquote style="margin:16px 0;padding:12px 16px;background:#f4f4f5;border-left:3px solid #d4d4d8;border-radius:0 6px 6px 0;font-size:13px;color:#71717a;line-height:1.6;">{s_html[2:]}</blockquote>')
elif s.startswith("---"):
out.append('<hr style="border:none;border-top:1px solid #f4f4f5;margin:28px 0;"/>')
elif s.startswith("[button:"):
m = re.match(r'\[button:(.+?)\]\((.+?)\)', s)
if m:
label, url = m.group(1), m.group(2)
out.append(f'<p style="margin:28px 0;"><a href="{url}" style="display:inline-block;background:#09090b;color:#fafafa;font-size:14px;font-weight:500;padding:10px 24px;border-radius:6px;text-decoration:none;letter-spacing:0.01em;">{label} →</a></p>')
elif s.startswith("[code:"):
# A thing to read off the screen and type, so it is set large,
# spaced and monospaced rather than left to look like a heading.
m = re.match(r'\[code:(.+?)\]', s)
if m:
out.append(f'<p style="margin:24px 0;"><span style="display:inline-block;background:#f4f4f5;border:1px solid #e4e4e7;border-radius:8px;padding:14px 20px 14px 24px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:28px;font-weight:600;letter-spacing:0.22em;color:#09090b;">{m.group(1)}</span></p>')
elif s.startswith("[link:"):
m = re.match(r'\[link:(.+?)\]\((.+?)\)', s)
if m:
label, url = m.group(1), m.group(2)
out.append(f'<p style="margin:4px 0;font-size:12px;color:#a1a1aa;">Or copy: <a href="{url}" style="color:#71717a;text-decoration:underline;word-break:break-all;">{url}</a></p>')
elif s == "":
out.append('<div style="height:10px;"></div>')
else:
out.append(f'<p style="margin:0 0 12px;font-size:14px;color:#3f3f46;line-height:1.7;">{s_html}</p>')
return "\n".join(out)
def _wrap(subject: str, body_md: str) -> str:
"""The shell, around a body written in our little markdown."""
return _wrap_html(subject, _render(body_md))
def _wrap_html(subject: str, body_html: str, *, footer_ignore: bool = True) -> str:
"""The same shell, around a body that is already HTML.
One message — the sign-in code — is laid out rather than written, and
putting it through the prose renderer would wrap each of its lines in a
left-aligned paragraph.
"""
year = datetime.utcnow().year
# Said once. The sign-in code says it in its own words directly under the
# button, and hearing it twice in eleven-point grey reads as boilerplate.
ignore_line = ('<p style="margin:8px 0 0;font-size:11px;color:#d4d4d8;">'
"Didn't expect this email? You can safely ignore it.</p>"
) if footer_ignore else ""
return f"""<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>{subject}</title></head>
<body style="margin:0;padding:0;background:#fafafa;font-family:ui-sans-serif,system-ui,-apple-system,sans-serif;-webkit-font-smoothing:antialiased;">
<table width="100%" cellpadding="0" cellspacing="0">
<tr><td align="center" style="padding:48px 20px 32px;">
<table width="540" cellpadding="0" cellspacing="0" style="max-width:540px;width:100%;">
<!-- Logo -->
<tr><td style="padding-bottom:36px;">
<span style="font-size:15px;font-weight:600;color:#09090b;letter-spacing:-0.2px;">🏥 PedsHub</span>
</td></tr>
<!-- Card -->
<tr><td style="background:#ffffff;border:1px solid #e4e4e7;border-radius:8px;padding:40px;">
{body_html}
</td></tr>
<!-- Footer -->
<tr><td style="padding:24px 0 0;">
<p style="margin:0;font-size:12px;color:#a1a1aa;line-height:1.6;">
PedsHub · Pediatric Knowledge Platform<br/>
<a href="{settings.APP_URL}" style="color:#a1a1aa;text-decoration:underline;">{settings.APP_URL}</a>
</p>
{ignore_line}
</td></tr>
</table>
</td></tr>
</table>
</body></html>"""
async def _send(to_email: str, subject: str, html: str):
if not settings.MAIL_USERNAME or not settings.MAIL_FROM:
logger.info(f"[EMAIL not configured] To:{to_email} Subject:{subject}")
return
try:
fm = FastMail(get_mail_config())
await fm.send_message(MessageSchema(subject=subject, recipients=[to_email], body=html, subtype=MessageType.html))
logger.info(f"Email sent → {to_email}: {subject}")
except Exception as e:
logger.error(f"Email failed → {to_email}: {e}")
async def send_verification_email(to_email: str, name: str, token: str):
url = f"{settings.APP_URL}/verify-email?token={token}"
subject = "Verify your PedsHub email"
md = f"""# Verify your email
Hi **{name}**,
Welcome to PedsHub. Click below to verify your email address and activate your account.
[button:Verify Email Address]({url})
> This link expires in **24 hours**.
---
[link:Or copy this link]({url})
"""
await _send(to_email, subject, _wrap(subject, md))
async def send_password_reset_email(to_email: str, name: str, token: str):
url = f"{settings.APP_URL}/reset-password?token={token}"
subject = "Reset your PedsHub password"
md = f"""# Reset your password
Hi **{name}**,
We received a request to reset your password. Click below to choose a new one.
[button:Reset Password]({url})
> Expires in **1 hour** · Single use only. If you didn't request this, ignore this email — your account is safe.
---
[link:Or copy this link]({url})
"""
await _send(to_email, subject, _wrap(subject, md))
async def send_login_code_email(to_email: str, name: str, code: str):
"""The code, and as little else as possible.
One thing is being asked of the reader — read six characters and type them
— so the code is the biggest thing on the screen and everything else is
underneath it in the order it matters: which account this signs into, a way
back to the page, and permission to ignore the whole thing.
Written as its own centred block rather than through the markdown renderer:
the renderer lays out prose left to right, which is right for every other
message we send and wrong for this one.
"""
#: The code arrives here already grouped for reading; any shape it is typed
#: back in is normalised before it is compared.
subject = "Sign in to PedsHub"
body = f"""
<div style="text-align:center;">
<h1 style="margin:0 0 28px;font-size:26px;font-weight:600;color:#09090b;letter-spacing:-0.3px;">
Sign in to PedsHub
</h1>
<div style="display:inline-block;background:#f8fafc;border:1px solid #2563eb;border-radius:10px;
padding:18px 30px;margin-bottom:22px;">
<span style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;font-size:30px;
font-weight:700;letter-spacing:0.30em;color:#2563eb;">{code}</span>
</div>
<p style="margin:0 0 26px;font-size:15px;color:#3f3f46;line-height:1.6;">
Enter this code to sign in as<br/>
<a href="mailto:{to_email}" style="color:#2563eb;font-weight:600;text-decoration:underline;">{to_email}</a>
</p>
<p style="margin:0 0 26px;">
<a href="{settings.APP_URL}/login"
style="display:inline-block;border:1px solid #2563eb;border-radius:8px;padding:12px 28px;
font-size:15px;color:#2563eb;text-decoration:none;letter-spacing:0.02em;">
Sign in to PedsHub
</a>
</p>
<p style="margin:0;font-size:13px;color:#a1a1aa;line-height:1.6;">
The code works once and expires in 15 minutes.<br/>
If you didn't try to log in, you can ignore this email.
</p>
</div>
"""
await _send(to_email, subject, _wrap_html(subject, body, footer_ignore=False))