Skip to main content

max / makenotwork

Make the landing notify form work without JS The form had no action and no method. With JS off, submitting it did a GET to / and the address went nowhere: no row, no error, no signal that anything had been lost. It is the only email capture on the landing page, so every one of those was a signup nobody could count. Most of what the task called for already existed. The storage layer, db::email_signups, has the source column, collapses duplicates, and is read by /admin/signups, so "somewhere for the addresses to go, plus a plan for who reads them" was answered before this. What was missing was a path a browser could take on its own. POST /notify takes the form encoding, is CSRF-protected like the other public forms, and is rate limited on the auth bucket because it is unauthenticated and writes a row. It redirects back to the landing page with the outcome, and the page renders it, so a no-JS visitor is told what happened. page-index.js now posts the same route with the same encoding instead of JSON to /api/email-signup, which makes it a real enhancement (async, inline status, no reload) rather than the only path. Submitting the form verbatim also carries the CSRF token without reading it out and reattaching it by hand. /api/email-signup is gone with its handler. It duplicated the new route, appeared in no openapi document, no site-docs page and no test, and its only caller was the script above. A rejected address redirects rather than answering 422: this is the last thing on the landing page, and a sentence saying the address looked wrong beats an error code.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-05 23:58 UTC
Signed with PGP, not checked
Commit: 1f73040fe23a868e28a1b2060905f602a0e0953a
Parent: b4a5c32
7 files changed, +174 insertions, -38 deletions
@@ -1,24 +1,31 @@
1 + // Landing "notify me", the enhanced path. The form posts to /notify on its
2 + // own with JS off; this submits the same route and the same encoding over
3 + // fetch so the visitor keeps their place on the page instead of reloading it.
4 + // Posting the form verbatim also means the CSRF token rides along without
5 + // being read out and re-attached by hand.
1 6 function submitNotify(e) {
2 7 e.preventDefault();
3 8 var form = document.getElementById('notify-form');
4 9 var status = document.getElementById('notify-status');
5 - var email = form.querySelector('input[name="email"]').value;
6 10 var btn = form.querySelector('button');
7 11 btn.disabled = true;
8 12 btn.textContent = 'Sending...';
9 13 status.textContent = '';
10 - fetch('/api/email-signup', {
14 + status.className = 'notify-status';
15 + fetch(form.action, {
11 16 method: 'POST',
12 - headers: {'Content-Type': 'application/json'},
13 - body: JSON.stringify({email: email})
17 + headers: {'Accept': 'application/json'},
18 + body: new URLSearchParams(new FormData(form))
14 19 }).then(function(r) {
15 - if (r.ok) {
16 - status.textContent = 'You\'re on the list.';
17 - status.className = 'notify-status success';
18 - form.querySelector('input[name="email"]').value = '';
19 - } else {
20 - return r.json().then(function(d) { throw new Error(d.error || 'Something went wrong'); });
20 + // The route answers a redirect to /?notify=ok or /?notify=invalid.
21 + // fetch follows it, so the landed URL is what says which happened.
22 + if (!r.ok) throw new Error('Something went wrong');
23 + if (r.url.indexOf('notify=invalid') !== -1) {
24 + throw new Error('That address didn\'t look right.');
21 25 }
26 + status.textContent = 'You\'re on the list.';
27 + status.className = 'notify-status success';
28 + form.querySelector('input[name="email"]').value = '';
22 29 }).catch(function(err) {
23 30 status.textContent = err.message;
24 31 status.className = 'notify-status error';
@@ -210,11 +210,22 @@
210 210 <div class="tier-section">
211 211 <h2 class="section-label">Stay in the loop</h2>
212 212 <p class="landing-prose mb-section">Get notified when something ships.</p>
213 - <form class="notify-form" id="notify-form" data-submit="onNotifySubmit">
213 + {# Real action and method, so this works with JS off. page-index.js
214 + intercepts the submit and posts the same encoding to the same
215 + route, which makes the script an enhancement (async, inline
216 + status, no reload) rather than the only path. It had neither
217 + attribute before, so a no-JS submit did a GET to / and the
218 + address went nowhere. #}
219 + <form class="notify-form" id="notify-form" method="post" action="/notify" data-submit="onNotifySubmit">
220 + {% if let Some(token) = csrf_token %}
221 + <input type="hidden" name="_csrf" value="{{ token }}">
222 + {% endif %}
214 223 <input type="email" name="email" placeholder="you@example.com" required class="notify-input" aria-label="Email address" autocomplete="email">
215 224 <button type="submit" class="btn-primary">Notify Me</button>
216 225 </form>
217 - <p class="notify-status" id="notify-status"></p>
226 + {# The no-JS outcome, carried back on the redirect. The script
227 + writes its own text into this element instead. #}
228 + <p class="notify-status{% if let Some(ok) = notify_ok %}{% if ok %} success{% else %} error{% endif %}{% endif %}" id="notify-status">{% if let Some(ok) = notify_ok %}{% if ok %}You're on the list.{% else %}That address didn't look right.{% endif %}{% endif %}</p>
218 229 </div>
219 230
220 231 <div class="secondary-links">
@@ -490,3 +490,82 @@
490 490 );
491 491 }
492 492 }
493 +
494 + // ── Landing notify-me capture ──
495 +
496 + /// The form works with JS off.
497 + ///
498 + /// It had no action and no method, so a browser without JS submitted a GET to
499 + /// `/` and the address was dropped silently. This is the only email capture on
500 + /// the landing page, so a silent loss is a lost signup nobody can count.
501 + #[tokio::test]
502 + async fn notify_form_without_js_stores_the_address() {
503 + let mut h = TestHarness::new().await;
504 + h.client.fetch_csrf_token().await;
505 +
506 + let resp = h
507 + .client
508 + .post_form("/notify", "email=nojs@example.com")
509 + .await;
510 + assert_eq!(
511 + resp.status, 303,
512 + "expected a redirect back to the landing page"
513 + );
514 + let location = resp
515 + .headers
516 + .get("location")
517 + .and_then(|v| v.to_str().ok())
518 + .unwrap_or_default();
519 + assert!(
520 + location.starts_with("/?notify=ok"),
521 + "redirected to {location}"
522 + );
523 +
524 + let stored: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM email_signups WHERE email = $1")
525 + .bind("nojs@example.com")
526 + .fetch_one(&h.db)
527 + .await
528 + .expect("count signups");
529 + assert_eq!(stored, 1, "the address never reached storage");
530 + }
531 +
532 + /// A rejected address comes back as a sentence on the page, not a 422. This is
533 + /// the last thing on the landing page and an error code is a worse outcome
534 + /// than being told the address looked wrong.
535 + #[tokio::test]
536 + async fn notify_form_rejects_a_bad_address_without_erroring() {
537 + let mut h = TestHarness::new().await;
538 + h.client.fetch_csrf_token().await;
539 +
540 + let resp = h.client.post_form("/notify", "email=not-an-address").await;
541 + assert_eq!(resp.status, 303);
542 + assert!(
543 + resp.headers
544 + .get("location")
545 + .and_then(|v| v.to_str().ok())
546 + .unwrap_or_default()
547 + .starts_with("/?notify=invalid")
548 + );
549 +
550 + let landing = h.client.get("/?notify=invalid").await;
551 + assert!(
552 + landing.text.contains("That address didn't look right."),
553 + "the landing page said nothing about the rejection"
554 + );
555 + }
556 +
557 + /// The success message renders for a no-JS visitor coming back off the
558 + /// redirect, and not on a plain page load.
559 + #[tokio::test]
560 + async fn notify_status_renders_only_when_the_redirect_says_so() {
561 + let mut h = TestHarness::new().await;
562 +
563 + let plain = h.client.get("/").await;
564 + assert!(
565 + !plain.text.contains("You're on the list."),
566 + "status shown on a plain load"
567 + );
568 +
569 + let after = h.client.get("/?notify=ok").await;
570 + assert!(after.text.contains("You're on the list."));
571 + }
@@ -52,7 +52,7 @@
52 52 response::{IntoResponse, Response},
53 53 routing::{get, options},
54 54 };
55 - use serde::{Deserialize, Serialize};
55 + use serde::Serialize;
56 56 use serde_json::json;
57 57 use tower_governor::GovernorLayer;
58 58
@@ -154,23 +154,6 @@
154 154 Ok(Json(json!({ "data": data })))
155 155 }
156 156
157 - // ── Email signup (public, no auth) ──
158 -
159 - #[derive(Deserialize)]
160 - struct EmailSignupForm {
161 - email: String,
162 - }
163 -
164 - #[tracing::instrument(skip_all, name = "api::email_signup")]
165 - async fn email_signup(
166 - State(db): State<PgPool>,
167 - Json(form): Json<EmailSignupForm>,
168 - ) -> Result<impl IntoResponse> {
169 - let email = db::Email::new(&form.email)?;
170 - db::email_signups::insert_email_signup(&db, email.as_str(), "landing").await?;
171 - Ok(Json(json!({"success": true})))
172 - }
173 -
174 157 /// Register all JSON API routes for projects, items, links, tags, and account management.
175 158 ///
176 159 /// Routes are split into three tiers with different rate limits:
@@ -545,11 +528,6 @@
545 528 .route("/api/domains/{id}", delete_csrf(domains::remove_domain))
546 529 // Invite codes
547 530 .route("/api/invites/create", post_csrf(users::create_invite))
548 - // Email signup (public, landing page notify-me)
549 - .route(
550 - "/api/email-signup",
551 - post_csrf_skip("pre-auth landing signup, no session", email_signup),
552 - )
553 531 .route_layer(GovernorLayer::new(write_rate_limit));
554 532
555 533 // Password/code-verifying TOTP mutations, strict auth-strength rate limit
@@ -65,6 +65,11 @@
65 65 /// landing-flagged changelog post. `None` suppresses the line entirely
66 66 /// (no placeholder), same honesty rule the runway disclosure uses.
67 67 pub last_shipped: Option<LandingVelocity>,
68 + /// Result of a no-JS notify submission, carried back through the redirect:
69 + /// `Some(true)` subscribed, `Some(false)` the address was rejected, `None`
70 + /// a plain page load. The JS path renders its status inline instead and
71 + /// leaves this `None`.
72 + pub notify_ok: Option<bool>,
68 73 }
69 74
70 75 /// One-line "Last shipped" velocity signal for the landing page.
@@ -2,7 +2,8 @@
2 2
3 3 use crate::extractors::ValidatedQuery;
4 4 use axum::{
5 - extract::State,
5 + Form,
6 + extract::{Query, State},
6 7 http::HeaderMap,
7 8 response::{IntoResponse, Redirect, Response},
8 9 };
@@ -33,6 +34,16 @@
33 34 /// If the Host header belongs to a verified custom domain, renders that user's
34 35 /// profile instead (the fallback handler only catches paths that don't match
35 36 /// any named route, so `/` needs to be handled here).
37 + /// Outcome of a no-JS notify submission, round-tripped through the redirect.
38 + ///
39 + /// The JS path renders its own status inline and never sets this; it exists so
40 + /// a visitor without JS gets told what happened instead of landing back on an
41 + /// apparently unchanged page.
42 + #[derive(Deserialize)]
43 + pub(super) struct IndexQuery {
44 + notify: Option<String>,
45 + }
46 +
36 47 #[tracing::instrument(skip_all, name = "landing::index")]
37 48 #[allow(clippy::too_many_arguments)]
38 49 pub(super) async fn index(
@@ -41,6 +52,7 @@
41 52 State(integrations): State<Integrations>,
42 53 State(config): State<Config>,
43 54 State(billing): State<Billing>,
55 + Query(q): Query<IndexQuery>,
44 56 headers: HeaderMap,
45 57 session: Session,
46 58 MaybeUserUnverified(maybe_user): MaybeUserUnverified,
@@ -128,6 +140,11 @@
128 140 tier_prices: billing.tier_prices.clone(),
129 141 landing_carousel,
130 142 last_shipped,
143 + notify_ok: match q.notify.as_deref() {
144 + Some("ok") => Some(true),
145 + Some("invalid") => Some(false),
146 + _ => None,
147 + },
131 148 }
132 149 .into_response())
133 150 }
@@ -488,6 +505,39 @@
488 505 }
489 506 }
490 507
508 + #[derive(Deserialize)]
509 + pub(super) struct NotifyForm {
510 + email: String,
511 + }
512 +
513 + /// POST /notify: the landing page's "notify me" capture.
514 + ///
515 + /// The form posts here directly, so it works with JS off. The page script
516 + /// intercepts the submit and posts the same form encoding to the same route,
517 + /// which makes it a genuine enhancement (async, inline status, no reload)
518 + /// rather than the only path. Before this the form had no action and no
519 + /// method, so a browser without JS submitted a GET to `/` and the address was
520 + /// dropped without a word to anyone.
521 + ///
522 + /// Storage is `email_signups`, which already existed for exactly this with a
523 + /// `source` column, duplicate collapsing, and an admin view at
524 + /// /admin/signups, so nobody has to be told where the addresses went.
525 + ///
526 + /// A rejected address redirects rather than erroring: this is the last thing
527 + /// on the landing page, and a 422 on a marketing form is a worse outcome than
528 + /// a sentence saying the address looked wrong.
529 + #[tracing::instrument(skip_all, name = "landing::notify")]
530 + pub(super) async fn notify(
531 + State(db): State<PgPool>,
532 + Form(form): Form<NotifyForm>,
533 + ) -> Result<impl IntoResponse> {
534 + let Ok(email) = db::Email::new(&form.email) else {
535 + return Ok(Redirect::to("/?notify=invalid#notify-form"));
536 + };
537 + db::email_signups::insert_email_signup(&db, email.as_str(), "landing").await?;
538 + Ok(Redirect::to("/?notify=ok#notify-form"))
539 + }
540 +
491 541 /// Render a dial value for an input box: no trailing zeros on a whole number,
492 542 /// at most two decimals otherwise. `12.6`, `0.30` and `25` all read as typed.
493 543 fn fmt_dial(v: f64) -> String {
@@ -24,7 +24,7 @@
24 24 AppState, Billing,
25 25 auth::MaybeUserUnverified,
26 26 constants,
27 - csrf::{CsrfRouter, post_csrf_skip, with_csrf_skip},
27 + csrf::{CsrfRouter, post_csrf, post_csrf_skip, with_csrf_skip},
28 28 db,
29 29 error::Result,
30 30 helpers::get_csrf_token,
@@ -99,7 +99,7 @@
99 99 "join-wizard step 1: pre-auth signup",
100 100 join_wizard::step_account_create,
101 101 )
102 - .layer(GovernorLayer::new(join_rate_limit)),
102 + .layer(GovernorLayer::new(join_rate_limit.clone())),
103 103 )
104 104 .route(
105 105 "/join/step/{step}",
@@ -142,6 +142,12 @@
142 142 .route_get("/team", get(landing::team_page))
143 143 .route_get("/policy", get(landing::policy_page))
144 144 .route_get("/fan-plus", get(landing::fan_plus_page))
145 + // Landing "notify me". CSRF-protected like the other public forms, and
146 + // rate limited because it is unauthenticated and writes a row.
147 + .route(
148 + "/notify",
149 + post_csrf(landing::notify).layer(GovernorLayer::new(join_rate_limit.clone())),
150 + )
145 151 .route_get("/creators", get(creators_page))
146 152 .route_get("/docs", get(docs::docs_index))
147 153 .route_get("/docs/search.json", get(docs::docs_search_index))