Digest polish: honest on-site wording, one-tap opt-in after sign-in, List-Unsubscribe
* On-site end-cap now says "You're caught up for now." — honest, since Highlights refreshes through the day (the email keeps the daily "see you tomorrow"). * Anonymous "Get tomorrow's brief by email" now honors the one-tap promise: sets a pending flag, opens sign-in, and auto-enables once auth resolves. * Email compliance (RFC 2369/8058): send_email takes optional headers; the digest sets List-Unsubscribe + List-Unsubscribe-Post=One-Click, and a POST /api/digest/unsubscribe handles native one-click (GET still serves the page). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -305,14 +305,22 @@
|
||||
|
||||
// The finite-ending's gentle nudge: one tap to get tomorrow's brief by email.
|
||||
let digestBusy = $state(false);
|
||||
let pendingDigestOptIn = $state(false);
|
||||
async function subscribeDigest() {
|
||||
if (!auth.user) { showSignIn = true; return; } // sign in, then enable below
|
||||
if (!auth.user) { pendingDigestOptIn = true; showSignIn = true; return; } // sign in → auto-enable
|
||||
if (auth.user.digest_enabled || digestBusy) return;
|
||||
digestBusy = true;
|
||||
try { await postJSON('/api/account/digest', { enabled: true }); await refreshAuth(); }
|
||||
catch { /* leave as-is */ }
|
||||
finally { digestBusy = false; }
|
||||
}
|
||||
// Keep the one-tap promise: if they tapped while signed out, enable right after sign-in.
|
||||
$effect(() => {
|
||||
if (pendingDigestOptIn && auth.user) {
|
||||
pendingDigestOptIn = false;
|
||||
if (!auth.user.digest_enabled) subscribeDigest();
|
||||
}
|
||||
});
|
||||
|
||||
const MIX_EVENT = { notToday: 'not_today', lessLikeThis: 'less_like_this', alwaysHide: 'hide_topic' };
|
||||
function applyAction(kind, value) {
|
||||
@@ -443,7 +451,7 @@
|
||||
</section>
|
||||
<div class="endcap rise">
|
||||
<p class="endmark">✦ that's the good news for today ✦</p>
|
||||
<p class="endsub">You're caught up — see you tomorrow.</p>
|
||||
<p class="endsub">You're caught up for now.</p>
|
||||
{#if auth.user?.digest_enabled}
|
||||
<p class="digestnote">Tomorrow's brief is headed to your inbox ☕</p>
|
||||
{:else}
|
||||
|
||||
+14
-5
@@ -528,16 +528,25 @@ def create_app() -> FastAPI:
|
||||
conn.commit()
|
||||
return {"ok": True, "digest_enabled": body.enabled}
|
||||
|
||||
@app.get("/api/digest/unsubscribe", response_class=HTMLResponse)
|
||||
def digest_unsubscribe(u: int = Query(...), t: str = Query(...)) -> HTMLResponse:
|
||||
# One-click, no login: match the per-user token, then turn the digest off.
|
||||
ok = False
|
||||
def _do_unsubscribe(u: int, t: str) -> bool:
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT digest_unsub_token FROM users WHERE id = ?", (u,)).fetchone()
|
||||
if row and row["digest_unsub_token"] and hmac.compare_digest(row["digest_unsub_token"], t):
|
||||
conn.execute("UPDATE users SET digest_enabled = 0 WHERE id = ?", (u,))
|
||||
conn.commit()
|
||||
ok = True
|
||||
return True
|
||||
return False
|
||||
|
||||
@app.post("/api/digest/unsubscribe")
|
||||
def digest_unsubscribe_oneclick(u: int = Query(...), t: str = Query(...)) -> dict:
|
||||
# RFC 8058 one-click: the mailbox provider POSTs here; just do it + 200.
|
||||
_do_unsubscribe(u, t)
|
||||
return {"ok": True}
|
||||
|
||||
@app.get("/api/digest/unsubscribe", response_class=HTMLResponse)
|
||||
def digest_unsubscribe(u: int = Query(...), t: str = Query(...)) -> HTMLResponse:
|
||||
# One-click, no login: match the per-user token, then turn the digest off.
|
||||
ok = _do_unsubscribe(u, t)
|
||||
msg = (
|
||||
"You’re unsubscribed from the daily digest. No hard feelings — "
|
||||
"Upbeat Bytes is always here when you want it."
|
||||
|
||||
+5
-2
@@ -148,9 +148,12 @@ def send_due_digests(conn: sqlite3.Connection, force: bool = False, base: str |
|
||||
conn.execute("UPDATE users SET digest_unsub_token = ? WHERE id = ?", (token, user["id"]))
|
||||
conn.commit()
|
||||
user["digest_unsub_token"] = token
|
||||
subject, text, html = build_digest(items, brief_date, unsub_url(user, base), base)
|
||||
link = unsub_url(user, base)
|
||||
subject, text, html = build_digest(items, brief_date, link, base)
|
||||
# RFC 2369 / 8058: let inboxes offer native one-click unsubscribe.
|
||||
headers = {"List-Unsubscribe": f"<{link}>", "List-Unsubscribe-Post": "List-Unsubscribe=One-Click"}
|
||||
try:
|
||||
email_send.send_email(user["email"], subject, text, html=html)
|
||||
email_send.send_email(user["email"], subject, text, html=html, headers=headers)
|
||||
except Exception: # noqa: BLE001 — one bad send shouldn't stop the rest; retry next window
|
||||
continue
|
||||
conn.execute(
|
||||
|
||||
@@ -30,7 +30,8 @@ def _cfg() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def send_email(to: str, subject: str, text: str, html: str | None = None, reply_to: str | None = None) -> None:
|
||||
def send_email(to: str, subject: str, text: str, html: str | None = None, reply_to: str | None = None,
|
||||
headers: dict | None = None) -> None:
|
||||
"""Send one message. Raises on failure (caller decides how loud to be)."""
|
||||
cfg = _cfg()
|
||||
if not cfg["host"]:
|
||||
@@ -40,6 +41,8 @@ def send_email(to: str, subject: str, text: str, html: str | None = None, reply_
|
||||
msg["To"] = to
|
||||
if reply_to:
|
||||
msg["Reply-To"] = reply_to
|
||||
for key, value in (headers or {}).items():
|
||||
msg[key] = value
|
||||
msg["Subject"] = subject
|
||||
msg.set_content(text)
|
||||
if html:
|
||||
|
||||
@@ -230,4 +230,8 @@ def test_digest_toggle_and_unsubscribe(tmp_path, monkeypatch):
|
||||
assert "invalid" in TestClient(app).get(f"/api/digest/unsubscribe?u={uid}&t=nope").text.lower()
|
||||
assert "unsubscribed" in TestClient(app).get(f"/api/digest/unsubscribe?u={uid}&t={tok}").text.lower()
|
||||
assert tc.get("/api/auth/me").json()["digest_enabled"] is False
|
||||
# RFC 8058 one-click POST also disables
|
||||
tc.post("/api/account/digest", json={"enabled": True})
|
||||
assert TestClient(app).post(f"/api/digest/unsubscribe?u={uid}&t={tok}").json() == {"ok": True}
|
||||
assert tc.get("/api/auth/me").json()["digest_enabled"] is False
|
||||
assert TestClient(app).post("/api/account/digest", json={"enabled": True}).status_code == 401 # gated
|
||||
|
||||
@@ -28,13 +28,16 @@ def test_build_digest_is_calm_and_dated():
|
||||
|
||||
def test_send_due_digests_sends_dedupes_and_skips(monkeypatch, tmp_path):
|
||||
sent = []
|
||||
monkeypatch.setattr(email_send, "send_email", lambda to, subj, text, html=None: sent.append(to))
|
||||
monkeypatch.setattr(email_send, "send_email",
|
||||
lambda to, subj, text, html=None, headers=None, **k: sent.append((to, headers)))
|
||||
c = connect(str(tmp_path / "d.db")); init_db(c)
|
||||
date = _seed(c, n=5)
|
||||
monkeypatch.setattr(digest, "local_today", lambda: date)
|
||||
# force=True bypasses the morning window
|
||||
assert digest.send_due_digests(c, force=True) == 1
|
||||
assert sent == ["reader@x.com"]
|
||||
assert sent[0][0] == "reader@x.com"
|
||||
hdrs = sent[0][1] # RFC 8058 one-click unsubscribe headers present
|
||||
assert "List-Unsubscribe" in hdrs and hdrs["List-Unsubscribe-Post"] == "List-Unsubscribe=One-Click"
|
||||
assert c.execute("SELECT item_count FROM digest_sends WHERE user_id=1").fetchone()[0] == 5
|
||||
# dedupe: a second run sends nothing
|
||||
assert digest.send_due_digests(c, force=True) == 0
|
||||
|
||||
Reference in New Issue
Block a user