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'\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("[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'{m.group(1)}
')
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:
"""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 = (''
"Didn't expect this email? You can safely ignore it.
"
) if footer_ignore else ""
return f"""
{subject}
|
๐ฅ PedsHub
|
|
{body_html}
|
|
PedsHub ยท Pediatric Knowledge Platform
{settings.APP_URL}
{ignore_line}
|
|
"""
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))