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'\1', s)
if s.startswith("# "):
out.append(f'
{s[2:]}
')
elif s.startswith("## "):
out.append(f'{s[3:]}
')
elif s.startswith("> "):
out.append(f'{s_html[2:]}
')
elif s.startswith("---"):
out.append('
')
elif s.startswith("[button:"):
m = re.match(r'\[button:(.+?)\]\((.+?)\)', s)
if m:
label, url = m.group(1), m.group(2)
out.append(f'{label} โ
')
elif s.startswith("[link:"):
m = re.match(r'\[link:(.+?)\]\((.+?)\)', s)
if m:
label, url = m.group(1), m.group(2)
out.append(f'Or copy: {url}
')
elif s == "":
out.append('')
else:
out.append(f'{s_html}
')
return "\n".join(out)
def _wrap(subject: str, body_md: str) -> str:
year = datetime.utcnow().year
body_html = _render(body_md)
return f"""
{subject}
|
๐ฉบ PedQuiz
|
|
{body_html}
|
|
PedQuiz ยท Pediatric Knowledge Platform
{settings.APP_URL}
Didn't expect this email? You can safely ignore it.
|
|
"""
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))