Features: - Hybrid semantic + keyword quiz search (pgvector HNSW + PostgreSQL ILIKE) - AWS Bedrock Titan Embed V2 embeddings via LiteLLM proxy (0.71 cosine sim) - Multi-provider TTS: OpenAI, AWS Polly (neural), ElevenLabs, Google Cloud TTS - Unified Settings page (profile, theme, Nextcloud integration, admin shortcuts) - Good morning/afternoon greeting on dashboard - manage.py CLI: reset-password, list-users, reembed - Email verification enforced: register no longer returns JWT for unverified users - Quiz search with debounced input, semantic/keyword/title modes, highlighted snippets - TTS button: loading/playing states, voice selector locked during playback - TTS auto-stops when navigating between questions - Footer added; mobile quiz nav overflow fixed; markdown theme body selector fixed - OpenAI Alloy as default TTS voice; favicon added - SMTP configured via smtp2go; password reset rate limiting (3/hour) - PostgreSQL upgraded to pgvector/pgvector:pg16 Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
174 lines
6.6 KiB
Python
174 lines
6.6 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="PedQuiz",
|
|
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("[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:
|
|
year = datetime.utcnow().year
|
|
body_html = _render(body_md)
|
|
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;">🩺 PedQuiz</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;">
|
|
PedQuiz · Pediatric Knowledge Platform<br/>
|
|
<a href="{settings.APP_URL}" style="color:#a1a1aa;text-decoration:underline;">{settings.APP_URL}</a>
|
|
</p>
|
|
<p style="margin:8px 0 0;font-size:11px;color:#d4d4d8;">
|
|
Didn't expect this email? You can safely ignore it.
|
|
</p>
|
|
</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 PedQuiz email"
|
|
md = f"""# Verify your email
|
|
|
|
Hi **{name}**,
|
|
|
|
Welcome to PedQuiz. 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 PedQuiz 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_reminder_email(email: str, user_name: str, quiz_title: str, score: float, next_date: str):
|
|
subject = f"Time to review: {quiz_title}"
|
|
note = "Great work — keep the streak going! 🌟" if score >= 75 else "A bit more practice will get you there. 📚"
|
|
md = f"""# Time to review
|
|
|
|
Hi **{user_name}**,
|
|
|
|
Your spaced repetition schedule says it's time to revisit **{quiz_title}**.
|
|
|
|
## Last score
|
|
|
|
**{score:.0f}%** — {note}
|
|
|
|
Reviewing at spaced intervals is one of the most effective ways to retain medical knowledge long-term. Each session strengthens the memory trace.
|
|
|
|
[button:Take Quiz Now]({settings.APP_URL}/quizzes)
|
|
|
|
---
|
|
|
|
You're receiving this because you have active quiz reminders. To stop, disable reminders in your account settings.
|
|
"""
|
|
await _send(email, subject, _wrap(subject, md))
|