Accounts Phase 1 foundation: schema + WAL, auth core, email sender
Groundwork for self-hosted accounts (magic link + Google later), no third parties. - db: account tables (users, identities, login_tokens, sessions, saved_articles, user_history, user_prefs); identities link multiple sign-in methods to one user by verified email. connect() now enables WAL + busy_timeout so the API can write account data alongside the host ingestion cycle. - auth.py: users/identities (find-or-create + link), single-use magic-link tokens, opaque sessions — all secrets stored only as SHA-256 hashes. - email_send.py: minimal STARTTLS SMTP sender + the magic-link email. Secrets (SMTP, Google, session) live in the API container's env_file, not git. API endpoints + sign-in UI come next. 105 tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
"""Minimal transactional email over SMTP (magic-link sign-in).
|
||||
|
||||
Config comes from the environment (GOODNEWS_SMTP_*), supplied to the API
|
||||
container via its env_file. STARTTLS submission; plain-text with a simple HTML
|
||||
alternative. No third-party email API — just the configured relay.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import smtplib
|
||||
import ssl
|
||||
from email.message import EmailMessage
|
||||
from html import escape
|
||||
|
||||
|
||||
def smtp_configured() -> bool:
|
||||
return bool(os.environ.get("GOODNEWS_SMTP_HOST"))
|
||||
|
||||
|
||||
def _cfg() -> dict:
|
||||
return {
|
||||
"host": os.environ.get("GOODNEWS_SMTP_HOST", ""),
|
||||
"port": int(os.environ.get("GOODNEWS_SMTP_PORT", "587")),
|
||||
"user": os.environ.get("GOODNEWS_SMTP_USER", ""),
|
||||
"password": os.environ.get("GOODNEWS_SMTP_PASSWORD", ""),
|
||||
"sender": os.environ.get("GOODNEWS_SMTP_FROM", "Upbeat Bytes <hello@upbeatbytes.com>"),
|
||||
}
|
||||
|
||||
|
||||
def send_email(to: str, subject: str, text: str, html: str | None = None) -> None:
|
||||
"""Send one message. Raises on failure (caller decides how loud to be)."""
|
||||
cfg = _cfg()
|
||||
if not cfg["host"]:
|
||||
raise RuntimeError("SMTP not configured (set GOODNEWS_SMTP_HOST)")
|
||||
msg = EmailMessage()
|
||||
msg["From"] = cfg["sender"]
|
||||
msg["To"] = to
|
||||
msg["Subject"] = subject
|
||||
msg.set_content(text)
|
||||
if html:
|
||||
msg.add_alternative(html, subtype="html")
|
||||
context = ssl.create_default_context()
|
||||
with smtplib.SMTP(cfg["host"], cfg["port"], timeout=20) as server:
|
||||
server.ehlo()
|
||||
server.starttls(context=context)
|
||||
server.ehlo()
|
||||
if cfg["user"]:
|
||||
server.login(cfg["user"], cfg["password"])
|
||||
server.send_message(msg)
|
||||
|
||||
|
||||
def send_magic_link(to: str, link: str) -> None:
|
||||
"""Send a calm, single-purpose sign-in email."""
|
||||
subject = "Your Upbeat Bytes sign-in link"
|
||||
text = (
|
||||
"Welcome back to Upbeat Bytes.\n\n"
|
||||
f"Tap to sign in:\n{link}\n\n"
|
||||
"This link works once and expires in 15 minutes.\n"
|
||||
"If you didn't request it, you can safely ignore this email."
|
||||
)
|
||||
safe = escape(link, quote=True)
|
||||
html = (
|
||||
'<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;'
|
||||
'color:#16263a;line-height:1.6">'
|
||||
"<p>Welcome back to <strong>Upbeat Bytes</strong>.</p>"
|
||||
f'<p><a href="{safe}" style="display:inline-block;background:#0083ad;color:#fff;'
|
||||
'text-decoration:none;padding:11px 20px;border-radius:999px;font-weight:600">'
|
||||
"Sign in</a></p>"
|
||||
'<p style="color:#5d6b78;font-size:14px">This link works once and expires in 15 minutes. '
|
||||
"If you didn't request it, you can safely ignore this email.</p>"
|
||||
"</div>"
|
||||
)
|
||||
send_email(to, subject, text, html)
|
||||
Reference in New Issue
Block a user