diff --git a/app/routers/auth.py b/app/routers/auth.py
index e567aa4..28a7d4d 100644
--- a/app/routers/auth.py
+++ b/app/routers/auth.py
@@ -37,7 +37,7 @@ from app.db import get_session, utcnow
from app.logging import get_logger
from app.services.auth_service import AuthError, get_or_create_user, get_user
from app.services import otp_service, referral_service
-from app.services.email_service import EmailSendError, send_otp
+from app.services.email_service import EmailSendError, send_otp, send_welcome_email
from app.templates_env import templates
@@ -216,7 +216,6 @@ async def verify_page(request: Request, error: str | None = None, sent: str | No
async def verify_submit(
request: Request,
code: str = Form(...),
- subscribe_to_digests: str | None = Form(default=None),
session: AsyncSession = Depends(get_session),
):
cookie = request.cookies.get(PENDING_COOKIE_NAME)
@@ -242,15 +241,24 @@ async def verify_submit(
return RedirectResponse(url="/login", status_code=303)
is_first_login = user.last_login_at is None
user.last_login_at = utcnow()
- # Apply the verify-page subscribe checkbox ONLY at first sign-up. After
- # that, Settings (and the one-click unsubscribe link) own the preference
- # — re-applying on every login would silently re-subscribe users who
- # explicitly opted out.
- if is_first_login:
- user.email_digest_opt_in = subscribe_to_digests is not None
+ # Default opt-in is set on User row creation; we don't touch it here.
+ # The one-time welcome email below explains the digest and the Settings
+ # opt-out path — re-applying a checkbox state on every login would
+ # silently re-subscribe users who explicitly opted out later.
await session.commit()
log.info("user.login", user_id=user.id, email=email)
+ # First-login welcome email — best effort. SMTP failure must not block
+ # the login itself; we log and continue. Idempotent because we commit
+ # last_login_at above before this point, so a retried verify won't
+ # re-trigger send.
+ if is_first_login:
+ try:
+ await send_welcome_email(email)
+ except Exception as e: # noqa: BLE001
+ log.warning("welcome_email.send_failed",
+ user_id=user.id, error=str(e)[:200])
+
resp = RedirectResponse(url="/", status_code=303)
_set_session_cookie(resp, user.id)
_clear_pending_cookie(resp)
diff --git a/app/services/email_service.py b/app/services/email_service.py
index 8546182..d3ed9f7 100644
--- a/app/services/email_service.py
+++ b/app/services/email_service.py
@@ -200,6 +200,130 @@ async def send_otp(to: str, code: str, ttl_minutes: int) -> None:
await send_email(to, subject, text, html_body=html)
+# ---------------------------------------------------------------------------
+# Welcome email — sent once on first successful login.
+# ---------------------------------------------------------------------------
+
+
+_WELCOME_HTML_TEMPLATE = """\
+
+
+
+
+
+
+
+ Welcome to {brand}
+
+
+
+
+ |
+
+ ▰ {brand_upper}
+
+
+
+ Welcome to {brand}.
+
+
+
+ You’re signed in. The dashboard is at
+ {app_url_short} —
+ a rolling news feed, cross-asset indicator panels, and a written
+ strategic read of the session, all updated through the day.
+
+
+
+
+
+ About the email digest
+
+
+
+ We send one Sunday digest to every account — the week behind +
+ the week ahead. Paid subscribers also get a short daily digest
+ (Mon–Sat), each a ~600-word read of the session.
+ You’re opted in by default; you can switch the digest off
+ at any time on the
+ Settings page,
+ or use the one-click unsubscribe link in every digest email.
+
+
+
+
+
+ Sent automatically by {brand} · do not reply
+
+ |
+
+
+
+"""
+
+
+_WELCOME_TEXT_TEMPLATE = """\
+{brand_upper} — welcome
+
+You're signed in. The dashboard is at {app_url}: a rolling news feed,
+cross-asset indicator panels, and a written strategic read of the
+session, all updated through the day.
+
+About the email digest
+----------------------
+We send one Sunday digest to every account (the week behind + the
+week ahead). Paid subscribers also get a short daily digest (Mon-Sat),
+each a ~600-word read of the session.
+
+You're opted in by default; switch it off any time at {settings_url},
+or use the one-click unsubscribe link in every digest email.
+
+—
+Sent automatically by {brand} · do not reply
+"""
+
+
+def render_welcome_email() -> tuple[str, str, str]:
+ """Returns (subject, text_body, html_body) for the post-signup welcome.
+
+ Single-shot email, sent the first time a user successfully verifies
+ an OTP. Explains the digest (which is opt-in by default) and how to
+ turn it off — replaces the old verify-page checkbox which appeared
+ on every login and was misleading."""
+ subject = f"Welcome to {branding.BRAND_NAME}"
+ fmt = dict(
+ brand=branding.BRAND_NAME,
+ brand_upper=branding.BRAND_NAME.upper(),
+ app_url=branding.APP_URL,
+ app_url_short=branding.APP_URL.replace("https://", "").replace("http://", ""),
+ settings_url=f"{branding.APP_URL}/settings",
+ 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()},
+ )
+ text = _WELCOME_TEXT_TEMPLATE.format(**fmt)
+ html = _WELCOME_HTML_TEMPLATE.format(**fmt)
+ return subject, text, html
+
+
+async def send_welcome_email(to: str) -> None:
+ subject, text, html = render_welcome_email()
+ await send_email(to, subject, text, html_body=html)
+
+
# ---------------------------------------------------------------------------
# Digest email rendering
# ---------------------------------------------------------------------------
diff --git a/app/static/css/cassandra.css b/app/static/css/cassandra.css
index 128fcad..a8a75eb 100644
--- a/app/static/css/cassandra.css
+++ b/app/static/css/cassandra.css
@@ -566,6 +566,53 @@ table.dense tr.row-stale td { color: var(--dim); }
.pf-actions button:disabled { opacity: 0.5; cursor: not-allowed; }
.pf-actions .pf-secondary { color: var(--muted); }
.pf-actions .pf-secondary:hover { color: var(--negative); border-color: var(--negative); }
+
+/* Settings-page action button — same visual language as .pf-actions
+ button so buttons across /settings (Manage subscription, future
+ actions) read as one family. Standalone class (not nested under a
+ parent) so it can be dropped onto any button anywhere on the page. */
+.settings-btn {
+ font-family: var(--font-mono);
+ font-size: 11px;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ background: var(--surface-2);
+ color: var(--accent);
+ border: 1px solid var(--border);
+ padding: 7px 14px;
+ cursor: pointer;
+ border-radius: 2px;
+ text-decoration: none;
+ display: inline-block;
+}
+.settings-btn:hover { border-color: var(--accent); }
+.settings-btn:disabled { opacity: 0.5; cursor: not-allowed; }
+
+/* Icon-button variant for inline row actions (e.g. Manage subscription
+ gear in the Tier row). Square hit area, accent on hover, tooltip via
+ title attribute. */
+.settings-icon-btn {
+ background: transparent;
+ border: 1px solid transparent;
+ color: var(--muted);
+ width: 32px;
+ height: 32px;
+ padding: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ border-radius: 3px;
+ flex-shrink: 0;
+ transition: color 80ms linear, border-color 80ms linear, background 80ms linear;
+}
+.settings-icon-btn:hover {
+ color: var(--accent);
+ border-color: var(--border);
+ background: var(--surface-2);
+}
+.settings-icon-btn:disabled { opacity: 0.5; cursor: not-allowed; }
+.settings-icon-btn svg { display: block; }
.pf-analysis {
margin-top: 14px;
background: var(--surface-2);
@@ -859,16 +906,46 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; }
letter-spacing: 0.06em;
gap: 4px;
}
-.auth-card input[type="email"], .auth-card input[type="password"] {
+.auth-card input[type="email"],
+.auth-card input[type="password"],
+.auth-card input[type="text"] {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
font-family: var(--font-mono);
- font-size: 13px;
- padding: 8px 10px;
+ font-size: 16px;
+ padding: 12px 14px;
outline: none;
+ border-radius: 3px;
+}
+/* The 6-digit OTP input wants to be visually loud — it's the only
+ thing the user is doing on that page. Bigger, more spacing, taller. */
+.auth-card input[name="code"] {
+ font-size: 24px;
+ padding: 16px 14px;
+ letter-spacing: 0.5em;
+ text-align: center;
}
.auth-card input:focus { border-color: var(--accent); }
+
+/* --- Modal text inputs (cloud-sync PIN modal, etc.) ---------------- */
+/* Same visual treatment as auth-card so prompts read as a coherent
+ family. Replaces the inline `style="padding:8px"` that left these
+ inputs feeling cramped. */
+.modal-input {
+ width: 100%;
+ background: var(--bg);
+ border: 1px solid var(--border);
+ color: var(--text);
+ font-family: var(--font-mono);
+ font-size: 16px;
+ padding: 12px 14px;
+ margin-bottom: 12px;
+ outline: none;
+ border-radius: 3px;
+ box-sizing: border-box;
+}
+.modal-input:focus { border-color: var(--accent); }
.auth-card button {
margin-top: 8px;
background: transparent;
@@ -943,7 +1020,13 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; }
margin-left: 8px;
}
-.settings-section { margin-top: 22px; }
+/* Sections are elements — collapsed by default to keep the
+ settings page scannable. Click the summary to expand. */
+.settings-section {
+ margin-top: 14px;
+ border-top: 1px solid var(--surface-2);
+ padding-top: 14px;
+}
.settings-section__head {
font-family: var(--font-mono);
font-size: 11px;
@@ -951,8 +1034,30 @@ details[open] .pf-analysis__head-left::before { content: "▾ "; }
text-transform: uppercase;
color: var(--accent);
margin-bottom: 6px;
+ cursor: pointer;
+ list-style: none;
+ user-select: none;
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ padding: 4px 0;
}
-.settings-section__head::before { content: "▸ "; color: var(--accent); }
+/* Suppress the native disclosure marker (Webkit + Firefox). */
+.settings-section__head::-webkit-details-marker { display: none; }
+.settings-section__head::marker { content: ""; }
+.settings-section__head::before {
+ content: "▸";
+ color: var(--accent);
+ display: inline-block;
+ transition: transform 120ms ease-out;
+ font-size: 10px;
+}
+.settings-section[open] > .settings-section__head::before {
+ transform: rotate(90deg);
+}
+.settings-section[open] > .settings-section__head { margin-bottom: 10px; }
+.settings-section__head:hover { color: var(--text); }
+.settings-section__head:hover::before { color: var(--text); }
.settings-section__lede {
color: var(--muted);
font-size: 12.5px;
diff --git a/app/static/js/portfolio.js b/app/static/js/portfolio.js
index bedea08..742d748 100644
--- a/app/static/js/portfolio.js
+++ b/app/static/js/portfolio.js
@@ -221,10 +221,10 @@
'A synced portfolio is available for this account (last synced ' +
esc(lastSynced) + '). Enter your PIN to load it on this browser.' +
'' +
- '
{# --- Email digests block ------------------------------------------ #}
-
-
Email digests
+
+ Email digests
Editorial commentary delivered to your inbox. Daily for paid (Mon–Sat) plus the Sunday recap; free tier gets the Sunday recap.
@@ -188,7 +192,7 @@
-
+
{# --- Cloud sync block --------------------------------------------- #}
-
-
Cloud sync (encrypted)
+
+ Cloud sync (encrypted)
Manage the encrypted server-side copy of your portfolio. Sync is
opted-in per import (see the Import section above).
@@ -235,7 +239,7 @@
above to enable cloud sync.
{% endif %}
-
+
{# Future: Paddle subscription block, AI-spend ledger summary, etc. #}
@@ -261,10 +265,10 @@