ui: collapsible settings sections + welcome-email + larger auth inputs

Settings page tidy-up driven by user feedback that it had grown too busy:

  - Each section (Import, Invite, Email digests, Cloud sync) is now a
    native <details>/<summary> accordion. Import stays open by default
    because /settings#import is the deep-link target from the dashboard
    CTA; the others collapse so the page lands quiet.
  - Manage subscription is a right-aligned gear-icon button instead of
    a rectangular text button — the descriptive copy moves into the
    tooltip. Frees up the Tier row of visual weight.

Auth + modal inputs were too small (verify code box, portfolio restore
PIN): the auth-card selector now covers text inputs as well, and a new
.modal-input class standardises 16px / 12px-padding fields used in the
cloud-sync enable modal and the portfolio restore prompt.

The verify page no longer carries the "Email me the digest" checkbox —
it was misleading on repeat logins (server-side it only applied on
first sign-up but rendered every time). Default-opt-in lives in the
User row at creation; per-user changes happen on /settings. First
successful verify now triggers a one-shot welcome email explaining the
digest cadence and pointing at /settings for opt-out; SMTP failure is
logged but does not block the login.

Tests rewritten to cover the new welcome-email path:
  - first login sends exactly one welcome email
  - returning user gets none
  - SMTP failure does not break the redirect
  - regression guard: returning user who opted out stays opted out

Also lands the paddle merchant-summary doc that was written earlier
during the Paddle → Polar → Stripe onboarding pivot.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Giorgio Gilestro 2026-05-26 22:32:59 +02:00
parent a07fd144ea
commit 00211fec02
8 changed files with 553 additions and 124 deletions

View file

@ -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)

View file

@ -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 = """\
<!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>Welcome to {brand}</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; }}
.divider {{ border-color:{D_border} !important; }}
a {{ color:{D_accent} !important; }}
}}
@media (max-width: 540px) {{
.card {{ padding:24px 18px !important; }}
}}
</style>
</head>
<body style="margin:0; padding:24px 12px; background:{L_bg}; font-family:{FONT_MONO}; color:{L_text}; -webkit-font-smoothing:antialiased;">
<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;">
&#9648;&nbsp;{brand_upper}
</div>
<div style="height:22px; line-height:22px; font-size:0;">&nbsp;</div>
<div class="h1" style="font-size:17px; font-weight:normal; color:{L_text}; letter-spacing:0.02em;">
Welcome to {brand}.
</div>
<div style="height:14px; line-height:14px; font-size:0;">&nbsp;</div>
<div class="lead muted" style="font-size:13px; color:{L_muted}; line-height:1.65;">
You&rsquo;re signed in. The dashboard is at
<a href="{app_url}" style="color:{L_accent}; text-decoration:none;">{app_url_short}</a> &mdash;
a rolling news feed, cross-asset indicator panels, and a written
strategic read of the session, all updated through the day.
</div>
<div style="height:20px; line-height:20px; font-size:0;">&nbsp;</div>
<div class="divider" style="border-top:1px solid {L_border};"></div>
<div style="height:18px; line-height:18px; font-size:0;">&nbsp;</div>
<div class="h1" style="font-size:14px; font-weight:normal; color:{L_text};">
About the email digest
</div>
<div style="height:10px; line-height:10px; font-size:0;">&nbsp;</div>
<div class="lead muted" style="font-size:13px; color:{L_muted}; line-height:1.65;">
We send one Sunday digest to every account &mdash; the week behind +
the week ahead. Paid subscribers also get a short daily digest
(Mon&ndash;Sat), each a ~600-word read of the session.
You&rsquo;re opted in by default; you can switch the digest off
at any time on the
<a href="{settings_url}" style="color:{L_accent}; text-decoration:none;">Settings page</a>,
or use the one-click unsubscribe link in every digest email.
</div>
<div style="height:20px; line-height:20px; font-size:0;">&nbsp;</div>
<div class="divider" style="border-top:1px solid {L_border};"></div>
<div style="height:14px; line-height:14px; font-size:0;">&nbsp;</div>
<div class="muted" style="font-size:10.5px; color:{L_dim}; letter-spacing:0.06em;">
Sent automatically by {brand} &middot; do not reply
</div>
</td></tr>
</table>
</body>
</html>
"""
_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
# ---------------------------------------------------------------------------

View file

@ -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 <details> 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;

View file

@ -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.' +
'</div>' +
'<form id="pf-restore-form" style="display:flex; gap:8px; align-items:center;">' +
'<form id="pf-restore-form" style="display:flex; gap:10px; align-items:center;">' +
'<input id="pf-restore-pin" type="password" inputmode="numeric" ' +
'autocomplete="off" placeholder="PIN" ' +
'style="flex:0 0 140px;">' +
'class="modal-input" style="flex:0 0 200px; margin-bottom:0;">' +
'<button type="submit">Restore</button>' +
'<a href="/settings#import" class="settings-row__hint" style="margin-left:auto;">' +
'or import a new CSV →</a>' +

View file

@ -20,7 +20,8 @@
<div class="settings-row">
<div class="settings-row__label">Tier</div>
<div class="settings-row__value">
<div class="settings-row__value" style="display:flex; align-items:flex-start; gap:10px; flex:1;">
<div style="flex:1; min-width:0;">
<span class="badge {% if paid and paid.active %}badge--ok{% else %}badge--ver{% endif %}">
{{ user.tier }}{% if paid and paid.active and paid.source == "credit" %} · credit{% endif %}
</span>
@ -40,16 +41,6 @@
{% else %}
<span class="settings-row__hint">Paid subscription active.</span>
{% endif %}
{% if user.stripe_customer_id %}
<button type="button" id="stripe-portal-btn"
class="btn-secondary"
style="margin-left:10px; padding:6px 14px; font-size:12px;">
Manage subscription
</button>
<div class="settings-row__hint" style="margin-top:6px;">
Update payment method, view invoices, switch monthly &harr; annual, or cancel any time. Opens the Stripe-hosted billing portal.
</div>
{% endif %}
{% endif %}
{% else %}
<span class="settings-row__hint">
@ -57,6 +48,16 @@
</span>
{% endif %}
</div>
{% if paid and paid.active and paid.source != "credit" and user.stripe_customer_id %}
<button type="button" id="stripe-portal-btn" class="settings-icon-btn"
title="Manage subscription — payment method, invoices, plan, cancel"
aria-label="Manage subscription">
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M19.14,12.94c0.04-0.3,0.06-0.61,0.06-0.94c0-0.32-0.02-0.64-0.07-0.94l2.03-1.58c0.18-0.14,0.23-0.41,0.12-0.61l-1.92-3.32c-0.12-0.22-0.37-0.29-0.59-0.22l-2.39,0.96c-0.5-0.38-1.03-0.7-1.62-0.94L14.4,2.81c-0.04-0.24-0.24-0.41-0.48-0.41h-3.84c-0.24,0-0.43,0.17-0.47,0.41L9.25,5.35C8.66,5.59,8.12,5.92,7.63,6.29L5.24,5.33c-0.22-0.08-0.47,0-0.59,0.22L2.74,8.87C2.62,9.08,2.66,9.34,2.86,9.48l2.03,1.58C4.84,11.36,4.8,11.69,4.8,12s0.02,0.64,0.07,0.94l-2.03,1.58c-0.18,0.14-0.23,0.41-0.12,0.61l1.92,3.32c0.12,0.22,0.37,0.29,0.59,0.22l2.39-0.96c0.5,0.38,1.03,0.7,1.62,0.94l0.36,2.54c0.05,0.24,0.24,0.41,0.48,0.41h3.84c0.24,0,0.44-0.17,0.47-0.41l0.36-2.54c0.59-0.24,1.13-0.56,1.62-0.94l2.39,0.96c0.22,0.08,0.47,0,0.59-0.22l1.92-3.32c0.12-0.22,0.07-0.47-0.12-0.61L19.14,12.94z M12,15.6c-1.98,0-3.6-1.62-3.6-3.6s1.62-3.6,3.6-3.6s3.6,1.62,3.6,3.6S13.98,15.6,12,15.6z"/>
</svg>
</button>
{% endif %}
</div>
</div>
{% if paid and paid.active and paid.source != "credit" and user.stripe_customer_id %}
@ -91,8 +92,11 @@
{% endif %}
{# --- Import portfolio --------------------------------------------- #}
<div class="settings-section" id="import">
<div class="settings-section__head">Import portfolio (Trading 212 CSV)</div>
{# Open by default because /settings#import is the deep-link target
from the dashboard's "import a portfolio" CTA — if you arrive via
that link the section should already be expanded. #}
<details class="settings-section" id="import" open>
<summary class="settings-section__head">Import portfolio (Trading 212 CSV)</summary>
<p class="settings-section__lede">
Export your pie from T212
(<span class="neu">Investing &rarr; Your Pie &rarr; &middot;&middot;&middot; &rarr; Export</span>)
@ -110,11 +114,11 @@
<div id="import-preview" hidden style="margin-top:14px;"></div>
<div id="import-result" class="result" hidden style="margin-top:14px;"></div>
</div>
</details>
{# --- Referral block ---------------------------------------------- #}
<div class="settings-section">
<div class="settings-section__head">Invite a friend</div>
<details class="settings-section">
<summary class="settings-section__head">Invite a friend</summary>
<p class="settings-section__lede">
Share your invite link. When your friend subscribes, you and
they each get <strong>50% off for 3 months</strong>.
@ -145,11 +149,11 @@
<div class="invite-stats__value settings-row__hint">— (D.3)</div>
</div>
</div>
</div>
</details>
{# --- Email digests block ------------------------------------------ #}
<div class="settings-section">
<div class="settings-section__head">Email digests</div>
<details class="settings-section">
<summary class="settings-section__head">Email digests</summary>
<p class="settings-section__lede">
Editorial commentary delivered to your inbox. Daily for paid (Mon&ndash;Sat) plus the Sunday recap; free tier gets the Sunday recap.
</p>
@ -188,7 +192,7 @@
</div>
<div id="digest-feedback" class="settings-row__hint" style="margin-top:6px;"></div>
</div>
</details>
<script>
(function () {
@ -213,8 +217,8 @@
</script>
{# --- Cloud sync block --------------------------------------------- #}
<div class="settings-section">
<div class="settings-section__head">Cloud sync (encrypted)</div>
<details class="settings-section">
<summary class="settings-section__head">Cloud sync (encrypted)</summary>
<p class="settings-section__lede">
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.
</p>
{% endif %}
</div>
</details>
{# Future: Paddle subscription block, AI-spend ledger summary, etc. #}
@ -261,10 +265,10 @@
<form id="sync-modal-form" autocomplete="off">
<label style="display:block;margin-bottom:6px;font-size:12px;">PIN</label>
<input id="sync-pin1" type="password" inputmode="numeric"
style="width:100%;padding:8px;margin-bottom:10px;" required>
class="modal-input" required>
<label style="display:block;margin-bottom:6px;font-size:12px;">Confirm PIN</label>
<input id="sync-pin2" type="password" inputmode="numeric"
style="width:100%;padding:8px;margin-bottom:10px;" required>
class="modal-input" required>
<label style="display:flex;align-items:flex-start;gap:8px;
font-size:12px;color:var(--muted,#666);margin:10px 0 16px;">
<input id="sync-ack" type="checkbox" required>

View file

@ -29,14 +29,7 @@
<form method="post" action="/verify" autocomplete="off">
<label>Verification code
<input type="text" name="code" inputmode="numeric" pattern="[0-9]{6}"
minlength="6" maxlength="6" required autofocus
style="font-family:var(--font-mono); letter-spacing:0.4em; text-align:center;">
</label>
<label style="display:block; margin:14px 0 0; font-size:12.5px; color:var(--muted); line-height:1.55;">
<input type="checkbox" name="subscribe_to_digests" value="on" checked
style="vertical-align:middle; margin-right:6px;">
Email me the digest — daily for paid, Sunday for everyone.
One-click unsubscribe in every email.
minlength="6" maxlength="6" required autofocus>
</label>
<button type="submit">Verify</button>
</form>

View file

@ -0,0 +1,146 @@
# Paddle merchant onboarding — Read the Markets
Use this when filling Paddle's seller-application / business-description
fields. Framing is deliberately **media / publishing**, not financial
services — "trading", "signals", "advice" wording triggers rejection or
sends the application to extra compliance review.
---
## Business description (one paragraph)
**Read the Markets** is a UK-based subscription publishing service for
retail investors who want to *understand* what's moving in markets
without acting on tips or signals. The site aggregates public market
data (prices via Yahoo Finance) and public RSS news feeds, then
generates plain-English written commentary using a large language model.
Subscribers read; the service does not trade, hold client funds, or give
personal financial advice. Operated by Giorgio Gilestro, ICO-registered
as ZC098928.
## What we sell
A single B2C subscription that unlocks extended access to our written
market commentary and personal-portfolio analysis features. There is
one product, two billing cadences:
- **Read the Markets — Paid plan, Monthly** — £7 GBP / month
- **Read the Markets — Paid plan, Annual** — £70 GBP / year
A free tier exists indefinitely (no card required) and gives access to
the core editorial at reduced refresh cadence. Pricing in GBP; VAT
handled by Paddle as merchant of record.
## What subscribers get on the Paid plan
- 24-hour news headline window (free: 6 hours)
- Strategic interpretation log refreshed every hour during market hours
(free: every six hours)
- Daily written digest emailed MondaySaturday
- The ability to ask follow-up questions to the AI about any past
published interpretation
- Optional upload of a personal portfolio CSV (currently Trading 212
export) for an AI commentary on diversification and macro-regime fit
— purely descriptive, no buy/sell calls
- Optional end-to-end encrypted cloud sync of the portfolio file
## What we explicitly do **not** do (regulatory framing)
- **Not a financial-advice service.** We do not produce personalised
recommendations or consider a user's wider finances, debts, tax
position, or objectives.
- **No buy/sell/hold signals.** Output is editorial commentary on
public data.
- **No brokerage.** We never execute trades, hold client money, or
custody assets.
- **Not regulated under FSMA / FCA COBS.** This is explicitly stated on
the site disclaimer and in the portfolio-analysis feature
description.
- **No crypto trading, no margin/leverage products, no copy-trading,
no managed accounts.**
- **No tipster service.** All copy emphasises the difference between
"understanding markets" and "gambling on them."
## Audience
Retail readers in the UK and EU who want a daily macro briefing in
plain English. Comparable to a paid newsletter (e.g. Substack finance
writers) or a personal-finance magazine subscription, delivered as a
web app + email.
## Refund & cancellation policy
Published at <https://read.markets/terms> §6: 14-day statutory
cooling-off (Consumer Contracts Regulations 2013), cancel-any-time
taking effect at end of billing period, pro-rata refund if we terminate
service through no fault of the user. Refund requests handled by email
at <hello@read.markets>.
---
## Comprehensive product overview (single-field answer)
> Use this when Paddle asks **"Could you provide a comprehensive
> overview of your product?"** — one self-contained block, ~400
> words, designed so the reviewer hits the "not a financial product"
> framing within the first two sentences.
**Read the Markets** (<https://read.markets>) is a UK-based
subscription publishing service that helps retail readers *understand*
what is moving in financial markets — through plain-English written
commentary, not through trading signals, advice, or recommendations.
It is best understood as a digital newsletter / news-and-media
subscription product, with an AI-content layer, comparable to a paid
financial newsletter on Substack or a digital news magazine. We do not
execute trades, hold client funds, custody any assets, or operate as a
broker or investment adviser. We are not authorised by the FCA and we
are not a regulated financial service. This positioning is explicit on
the live site disclaimer at <https://read.markets/disclaimer> and in
the body copy of every paid feature.
The product works as follows. We aggregate public market data (stock,
FX, commodity, and rate quotes via Yahoo Finance) and public RSS news
feeds across the macro universe. A large language model (via
OpenRouter) then writes a short editorial interpretation of what the
underlying public data appears to be saying. The output is a written
article — a "strategic log" — refreshed through the trading day, plus
per-asset-class commentary panels and a daily / weekly written digest
delivered by email. Subscribers can also optionally upload a personal
portfolio holdings CSV (currently exported from Trading 212) to receive
a written sense-check of diversification, currency exposure, and
macro-regime fit on those holdings; this output is purely descriptive
and contains no buy, sell, or hold recommendations.
A free tier exists indefinitely (no card required) and serves the core
editorial at a reduced refresh cadence (6-hour news window, strategic
log refreshed every six hours, weekly Sunday digest only). The Paid
plan extends those to a 24-hour news window, hourly strategic log
refresh, daily MonSat email digest, the optional portfolio upload +
AI commentary, an interactive follow-up chat against any past
published article, and optional encrypted cloud sync of the portfolio
file.
Pricing is in GBP, with Paddle as merchant of record handling VAT:
**£7 / month** or **£70 / year** (two months free). Subscribers can
cancel any time, taking effect at the end of the current billing
period. A 14-day statutory cooling-off period applies under the UK
Consumer Contracts Regulations 2013, plus pro-rata refunds where we
terminate service through no fault of the user — full refund policy at
<https://read.markets/terms> §6. Operated by Giorgio Gilestro,
ICO-registered as ZC098928, contact <hello@read.markets>.
---
## Practical tips when completing the Paddle form
- **Category dropdown:** pick **"Software / SaaS — Content &
publishing"** or **"Digital subscription — News & media"** if those
options exist. Avoid anything containing the words *financial
services*, *trading*, *investing tools*, or *fintech*.
- **Self-declaration:** describe the product as **media / publishing
with an AI-content angle** — not a financial service.
- **Linkable references for the reviewer:**
- Pricing & tier breakdown: <https://read.markets/pricing>
- Disclaimer (the legal "not advice" statement): <https://read.markets/disclaimer>
- Terms & Conditions (incl. §6 Refunds): <https://read.markets/terms>
- Privacy notice (ICO ZC098928 surfaced here): <https://read.markets/privacy>

View file

@ -1,4 +1,10 @@
"""Verify-POST persists the subscribe_to_digests form field."""
"""/verify behaviour: returning users keep their digest preference, and
first-login triggers the welcome email exactly once.
The verify page used to carry a "Email me the digest" checkbox; that
was removed (it was misleading on repeat logins). Default-opt-in lives
in the User row at creation; per-user changes happen on /settings.
"""
from __future__ import annotations
import asyncio
@ -35,60 +41,11 @@ def _build(tmp_path):
return TestClient(app), pending
def test_verify_with_unchecked_subscribe_disables_opt_in(tmp_path, monkeypatch):
from app.services import otp_service
async def _ok(*args, **kwargs):
return None
monkeypatch.setattr(otp_service, "verify", _ok)
client, pending = _build(tmp_path)
r = client.post(
"/verify",
data={"code": "000000"}, # subscribe_to_digests omitted = unchecked
cookies={"cassandra_pending": pending},
follow_redirects=False,
)
assert r.status_code == 303, r.text
async def _check():
from app import db as db_mod
from app.models import User
async with db_mod._session_factory() as s:
u = await s.get(User, 10)
assert u.email_digest_opt_in is False
asyncio.run(_check())
def test_verify_with_checked_subscribe_keeps_opt_in(tmp_path, monkeypatch):
from app.services import otp_service
async def _ok(*args, **kwargs):
return None
monkeypatch.setattr(otp_service, "verify", _ok)
client, pending = _build(tmp_path)
r = client.post(
"/verify",
data={"code": "000000", "subscribe_to_digests": "on"},
cookies={"cassandra_pending": pending},
follow_redirects=False,
)
assert r.status_code == 303
async def _check():
from app import db as db_mod
from app.models import User
async with db_mod._session_factory() as s:
u = await s.get(User, 10)
assert u.email_digest_opt_in is True
asyncio.run(_check())
def test_returning_user_login_preserves_unsubscribe(tmp_path, monkeypatch):
"""A user who unsubscribed (via Settings or the one-click link) must
not be silently re-enrolled when they log in again, even if the verify
page's default-checked checkbox gets submitted."""
not be silently re-enrolled when they log in again. The handler now
never touches email_digest_opt_in, so this is a regression guard
against accidentally adding that back."""
from datetime import datetime, timezone
from app.services import otp_service
@ -110,12 +67,9 @@ def test_returning_user_login_preserves_unsubscribe(tmp_path, monkeypatch):
await s.commit()
asyncio.run(_make_returning())
# They log in again. The verify checkbox is default-checked in the
# template, so the form will submit "on" — but the handler must NOT
# apply that to a returning user.
r = client.post(
"/verify",
data={"code": "000000", "subscribe_to_digests": "on"},
data={"code": "000000"},
cookies={"cassandra_pending": pending},
follow_redirects=False,
)
@ -128,3 +82,98 @@ def test_returning_user_login_preserves_unsubscribe(tmp_path, monkeypatch):
u = await s.get(User, 10)
assert u.email_digest_opt_in is False, "returning user re-enrolled"
asyncio.run(_check())
def test_first_login_triggers_welcome_email(tmp_path, monkeypatch):
"""A user signing in for the first time gets exactly one welcome
email. The send is best-effort failure must not block login."""
from unittest.mock import AsyncMock
from app.services import otp_service
from app.routers import auth as auth_router
async def _ok(*args, **kwargs):
return None
monkeypatch.setattr(otp_service, "verify", _ok)
send_mock = AsyncMock()
monkeypatch.setattr(auth_router, "send_welcome_email", send_mock)
client, pending = _build(tmp_path)
r = client.post(
"/verify",
data={"code": "000000"},
cookies={"cassandra_pending": pending},
follow_redirects=False,
)
assert r.status_code == 303
assert send_mock.await_count == 1, "first login should send a welcome email"
assert send_mock.await_args.args == ("newbie@x",)
def test_returning_user_login_does_not_resend_welcome(tmp_path, monkeypatch):
"""The welcome email is one-shot: a returning user (last_login_at
is not None) must not get a second copy."""
from datetime import datetime, timezone
from unittest.mock import AsyncMock
from app.services import otp_service
from app.routers import auth as auth_router
async def _ok(*args, **kwargs):
return None
monkeypatch.setattr(otp_service, "verify", _ok)
send_mock = AsyncMock()
monkeypatch.setattr(auth_router, "send_welcome_email", send_mock)
client, pending = _build(tmp_path)
# Mark the user as already-known.
async def _make_returning():
from app import db as db_mod
from app.models import User
async with db_mod._session_factory() as s:
u = await s.get(User, 10)
u.last_login_at = datetime(2026, 5, 20, 12, 0, tzinfo=timezone.utc)
await s.commit()
asyncio.run(_make_returning())
r = client.post(
"/verify",
data={"code": "000000"},
cookies={"cassandra_pending": pending},
follow_redirects=False,
)
assert r.status_code == 303
assert send_mock.await_count == 0, "returning user should not re-get welcome"
def test_welcome_email_failure_does_not_block_login(tmp_path, monkeypatch):
"""SMTP errors are best-effort — the user still gets a session cookie
and lands on /. We rely on a log line for operational visibility."""
from unittest.mock import AsyncMock
from app.services import otp_service
from app.routers import auth as auth_router
async def _ok(*args, **kwargs):
return None
monkeypatch.setattr(otp_service, "verify", _ok)
async def _boom(*args, **kwargs):
raise RuntimeError("SMTP down")
monkeypatch.setattr(auth_router, "send_welcome_email",
AsyncMock(side_effect=_boom))
client, pending = _build(tmp_path)
r = client.post(
"/verify",
data={"code": "000000"},
cookies={"cassandra_pending": pending},
follow_redirects=False,
)
# Login still succeeds; redirect to dashboard, session cookie set.
assert r.status_code == 303, r.text
assert r.headers.get("location") == "/"