Server no longer holds portfolios. Holdings live in the browser (localStorage); the server publishes an anonymous ticker_universe and a gzipped /api/universe payload identical for every authenticated user, so access patterns can't betray which tickers a user holds. AI commentary is generated ephemerally from the browser-supplied pie and the cost ledger row records no positions. Migrations 0009-0011 added the universe table and dropped positions / portfolio_snapshots / portfolios. Authentication is now e-mail OTP only. Migration 0010 dropped password_hash and email_verified (every active session is by construction proof of email control). The /signup endpoint is gone; signup and login share a single email-entry page. Email rendering is HTML+plain-text multipart with a shared brand palette (app/branding.py) asserted in sync with the CSS by a drift-detection test. LLM provider defaults to DeepSeek-direct (cheaper, api.deepseek.com) with OpenRouter as automatic fallback if DeepSeek fails. ai_log_job and indicator_summary_job now iterate the two tones (NOVICE, INTERMEDIATE) per cycle so the dashboard's tone toggle is instant; PROMPT_VERSION bumped to 6 with an educational anti-TA / anti-gambling stance baked into _CORE. NOVICE mode renders a curated glossary inline (CBOE VIX, yield curve, HY OAS, etc.) with JS-positioned tooltips that survive viewport edges and sticky bars. Model name and tokens hidden from the user UI; still recorded in StrategicLog.model and AICall for admin. Layout adds a sticky top nav, a sticky bottom markets bar (one chip per exchange with status LED + headline index + 1d change), and Phase H feedback reporting is queued in tasks/todo.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
191 lines
7.2 KiB
Python
191 lines
7.2 KiB
Python
"""SMTP-backed transactional email.
|
|
|
|
Sends multipart/alternative: a plain-text body for accessibility / minimal
|
|
clients and an HTML body for richer rendering. Designed for cross-client
|
|
robustness:
|
|
|
|
- Inline styles on every element (Outlook desktop ignores <style> blocks).
|
|
- `<style>` block in <head> only carries the prefers-color-scheme media
|
|
query for clients that respect it (Apple Mail, Gmail web, Outlook.com).
|
|
- Light background by default — dark backgrounds are inconsistently
|
|
rendered across clients.
|
|
- 520-px max width, monospace stack with fallbacks, no external assets
|
|
(no remote images, no web fonts), so the email opens identically with
|
|
network off.
|
|
|
|
When SMTP_SERVER is empty, falls back to writing the payload to stdout —
|
|
convenient for local dev that doesn't want a mail server configured.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from email.message import EmailMessage
|
|
|
|
import aiosmtplib
|
|
|
|
from app import branding
|
|
from app.config import get_settings
|
|
from app.logging import get_logger
|
|
|
|
|
|
log = get_logger("email")
|
|
|
|
|
|
class EmailSendError(RuntimeError):
|
|
"""Raised when SMTP submission fails. The caller should surface a
|
|
generic error to the user rather than the SMTP details."""
|
|
|
|
|
|
async def send_email(
|
|
to: str,
|
|
subject: str,
|
|
text_body: str,
|
|
html_body: str | None = None,
|
|
) -> None:
|
|
"""Send a (potentially multipart) email. `text_body` is required —
|
|
it's the fallback for clients that can't or won't render HTML."""
|
|
s = get_settings()
|
|
sender = s.SMTP_FROM or s.SMTP_USER or "noreply@cassandra.local"
|
|
|
|
msg = EmailMessage()
|
|
msg["From"] = sender
|
|
msg["To"] = to
|
|
msg["Subject"] = subject
|
|
msg.set_content(text_body)
|
|
if html_body:
|
|
msg.add_alternative(html_body, subtype="html")
|
|
|
|
if not s.SMTP_SERVER:
|
|
log.info(
|
|
"email.stdout_fallback", to=to, subject=subject,
|
|
text_preview=text_body[:120],
|
|
multipart=bool(html_body),
|
|
)
|
|
return
|
|
|
|
try:
|
|
await aiosmtplib.send(
|
|
msg,
|
|
hostname=s.SMTP_SERVER,
|
|
port=s.SMTP_PORT,
|
|
username=s.SMTP_USER or None,
|
|
password=s.SMTP_PASSWORD or None,
|
|
start_tls=s.SMTP_USE_TLS,
|
|
timeout=20,
|
|
)
|
|
log.info("email.sent", to=to, subject=subject)
|
|
except Exception as e:
|
|
log.error("email.send_failed", to=to, error=str(e)[:200])
|
|
raise EmailSendError(f"Failed to send email: {e}") from e
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# OTP email rendering
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
_OTP_HTML_TEMPLATE = """\
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<meta name="color-scheme" content="light dark">
|
|
<meta name="supported-color-schemes" content="light dark">
|
|
<title>Your Cassandra sign-in code</title>
|
|
<style>
|
|
@media (prefers-color-scheme: dark) {{
|
|
body {{ background:{D_bg} !important; }}
|
|
.card {{ background:{D_surface} !important; border-color:{D_border} !important; }}
|
|
.h1 {{ color:{D_text} !important; }}
|
|
.muted {{ color:{D_muted} !important; }}
|
|
.lead {{ color:{D_text} !important; }}
|
|
.code {{ background:{D_bg} !important; color:{D_accent} !important;
|
|
border-color:{D_accent} !important; }}
|
|
.divider {{ border-color:{D_border} !important; }}
|
|
}}
|
|
@media (max-width: 540px) {{
|
|
.card {{ padding:24px 18px !important; }}
|
|
.code {{ font-size:26px !important; letter-spacing:0.3em !important; }}
|
|
}}
|
|
</style>
|
|
</head>
|
|
<body style="margin:0; padding:24px 12px; background:{L_bg}; font-family:{FONT_MONO}; color:{L_text}; -webkit-font-smoothing:antialiased;">
|
|
<div style="display:none; max-height:0; overflow:hidden; mso-hide:all; font-size:1px; line-height:1px; color:{L_bg};">
|
|
Your Cassandra sign-in code — {code} — expires in {ttl_minutes} minutes.
|
|
</div>
|
|
<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" width="100%" style="max-width:520px; margin:0 auto; border-collapse:separate;">
|
|
<tr><td class="card" style="background:{L_surface}; border:1px solid {L_border}; padding:32px 28px;">
|
|
<div class="muted" style="font-size:11px; letter-spacing:0.32em; color:{L_muted}; text-transform:uppercase;">
|
|
▰ CASSANDRA
|
|
</div>
|
|
<div style="height:22px; line-height:22px; font-size:0;"> </div>
|
|
<div class="h1" style="font-size:17px; font-weight:normal; color:{L_text}; letter-spacing:0.02em;">
|
|
Your sign-in code
|
|
</div>
|
|
<div style="height:16px; line-height:16px; font-size:0;"> </div>
|
|
<div class="code" style="font-family:{FONT_MONO}; font-size:32px; letter-spacing:0.4em; text-align:center; padding:20px 12px; border:1px solid {L_accent}; background:{L_surface}; color:{L_accent}; user-select:all;">
|
|
{code}
|
|
</div>
|
|
<div style="height:18px; line-height:18px; font-size:0;"> </div>
|
|
<div class="lead muted" style="font-size:13px; color:{L_muted}; line-height:1.6;">
|
|
This code expires in <span style="color:{L_text};">{ttl_minutes} minutes</span>.
|
|
If you didn’t request it, you can safely ignore this email — no changes
|
|
will be made to any account.
|
|
</div>
|
|
<div style="height:26px; line-height:26px; font-size:0;"> </div>
|
|
<div class="divider" style="border-top:1px solid {L_border};"></div>
|
|
<div style="height:14px; line-height:14px; font-size:0;"> </div>
|
|
<div class="muted" style="font-size:10.5px; color:{L_dim}; letter-spacing:0.06em;">
|
|
Sent automatically by Cassandra · do not reply
|
|
</div>
|
|
</td></tr>
|
|
</table>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
|
|
def _html_template_filled(code: str, ttl_minutes: int) -> str:
|
|
"""Substitute palette + content into the OTP HTML template."""
|
|
return _OTP_HTML_TEMPLATE.format(
|
|
code=code,
|
|
ttl_minutes=ttl_minutes,
|
|
FONT_MONO=branding.FONT_MONO,
|
|
**{f"L_{k.replace('-', '_')}": v for k, v in branding.LIGHT.items()},
|
|
**{f"D_{k.replace('-', '_')}": v for k, v in branding.DARK.items()},
|
|
)
|
|
|
|
|
|
_OTP_TEXT_TEMPLATE = """\
|
|
CASSANDRA — sign in
|
|
|
|
Your verification code:
|
|
|
|
{code}
|
|
|
|
This code expires in {ttl_minutes} minutes.
|
|
If you didn't request it, you can safely ignore this email — no changes
|
|
will be made to any account.
|
|
|
|
—
|
|
Sent automatically by Cassandra · do not reply
|
|
"""
|
|
|
|
|
|
def render_otp_email(code: str, ttl_minutes: int) -> tuple[str, str, str]:
|
|
"""Returns (subject, text_body, html_body).
|
|
|
|
Subject embeds the code so users can read it directly from the inbox
|
|
list without opening the message — common practice for OTP emails
|
|
(Notion, Substack). The lock-screen exposure tradeoff is minimal:
|
|
anyone with phone access who could see the notification could also
|
|
open the email."""
|
|
subject = f"Cassandra sign-in: {code}"
|
|
text = _OTP_TEXT_TEMPLATE.format(code=code, ttl_minutes=ttl_minutes)
|
|
html = _html_template_filled(code=code, ttl_minutes=ttl_minutes)
|
|
return subject, text, html
|
|
|
|
|
|
async def send_otp(to: str, code: str, ttl_minutes: int) -> None:
|
|
subject, text, html = render_otp_email(code, ttl_minutes)
|
|
await send_email(to, subject, text, html_body=html)
|