A password is a thing to remember and a thing to lose. Somebody who can read
their own mail can now sign in without one: ask, receive six characters, type
them into the page that is already open.
A code rather than a link, and the difference is not cosmetic. The token in a
link was 256 bits, unguessable however long it lived, so its length, its expiry
and its rate limit were three independent decisions. Six characters is 2^30,
and the three stop being independent — so they are argued together:
* six characters of the invite alphabet, imported rather than copied, because
there should be one answer to which characters a person may be asked to
retype and that one already drops O/0 and I/1;
* a code answers five guesses and is then retired, not slowed — whoever is
typing has lost the mail or does not own it, and both are one click from a
new one;
* one code live per person, since several would mean one guess tested against
all of them;
* ten verify attempts per address per fifteen minutes, so nobody buys five
fresh guesses at a time by asking again.
Tens of guesses an hour against a billion, and the victim gets a mail for every
code burned. Eight characters would buy a thousandfold against an attack the
guess budget has already ended, and cost every person two more characters.
The attempt count lives in the row, not the cache. The Redis limiter fails open
when Redis is down, which is right for what it usually guards and wrong for the
only thing standing between a patient stranger and six characters.
Verifying is scoped to the address. A short code looked up on its own would be
tried against every code live on the site at once — the short code's one real
weakness, closed by knowing whose code it should be before comparing.
Fifteen minutes, because a first mail between strangers is routinely greylisted
five to ten and a code that expires before it arrives is not a sign-in method.
Shortening it buys nothing: one code is live and it answers five guesses
however long it sits there.
Nothing distinguishes an address with an account from one without — same
message, same status, same duration, and both rate limits counted before the
account is looked up, so a 429 cannot become the tell. Redis keys are
fingerprints, and the table holds a fingerprint rather than the code.
SSO stays first where it is configured, and a password is still one click away
for anybody who has one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
175 lines
7.1 KiB
Python
175 lines
7.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:
|
|
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;">🏥 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>
|
|
<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 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 arrives here already grouped for reading; any shape it is typed
|
|
#: back in is normalised before it is compared.
|
|
subject = "Your PedsHub sign-in code"
|
|
md = f"""# Your sign-in code
|
|
|
|
Hi **{name}**,
|
|
|
|
Enter this code on the sign-in page you just came from.
|
|
|
|
[code:{code}]
|
|
|
|
> Expires in **15 minutes** · Works once. If you asked more than once, only the newest code works.
|
|
|
|
Nobody from PedsHub will ever ask you to read this code out, on the phone or anywhere else. If somebody has, they are not us. If you didn't ask for this code, ignore this email — nobody can get in without it.
|
|
"""
|
|
await _send(to_email, subject, _wrap(subject, md))
|