Skip to content

feat(app): blog email subscription — subscribe, confirm and unsubscribe links (#89) - #127

Merged
Adron merged 1 commit into
parity/queuefrom
issue/89-blog-subscribe
Sep 16, 2026
Merged

Adron merged 1 commit into
parity/queuefrom
issue/89-blog-subscribe

Conversation

@Adron

@Adron Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member

Closes #89. Part of epic #87.

The link URLs were established, not guessed

Probed the way #72 did:

URL Result
GET /api/blog/subscribe/confirm?token=probe 307 → /blog?subscription=invalid
GET /api/blog/unsubscribe?token=probe 307 → /blog?subscription=invalid
/blog/confirm, /blog/subscribe/confirm, /blog/unsubscribe, /unsubscribe, /confirm-subscription all 404

The emails point straight at the API routes, so those two paths are what the intent filter claims.
(Independently re-verified: the confirm probe returns 307 to …/blog?subscription=invalid.)

POST /api/blog/subscribe {"email"} → 200 {ok, message:"Check your email to confirm your subscription."} for any valid address regardless of state (enumeration-safe);
400 {"error":"A valid email is required","code":"bad_request"} otherwise.

Double opt-in is stated before and after

A successful subscribe lands in CHECK_YOUR_EMAIL, never a "subscribed" state: "We've sent a
confirmation link to address. You are not subscribed yet… Nothing will arrive until you do." There
is deliberately no success state that looks finished — that is the failure mode the issue warns about.

Both emailed links are handled in-app: the app calls the endpoint and shows the outcome. The token
endpoints go through an OkHttp client with followRedirects(false), because the 307's Location
(subscription=confirmed|unsubscribed|invalid) is the entire result — following it would fetch
the blog's HTML and throw the answer away. An unrecognised redirect maps to invalid, never to success.

BlogLink was extended, not duplicated. A recognised link with a blank or missing token still
parses and is reported as unusable without a network call — the app claims those URLs, so silence
would be the worst outcome.

One thing that could not be done as literally specified

There is no address-only unsubscribe. Item 3 asks for POST /api/blog/unsubscribe and item 5 says
not to implement RFC-8058 — live, those are the same endpoint. It was probed six ways (?token=,
{"token"}, {"email"}, form-encoded, malformed JSON, List-Unsubscribe=One-Click, and bare):
every one returns a bare 200 with no body. It acts only on a valid emailed token and never reports
anything. A button wired to it could only pretend to work.

So the form points at the unsubscribe link in any blog email — which the app now opens itself — and
the unsubscribe path uses the informative GET. One-click unsubscribe stays a mail-client concern.

Verification

./gradlew clean :app:assembleDebug testDebugUnitTest → BUILD SUCCESSFUL, 1474 tests, 0 failures
(40 new in :app). Coverage: deep-link parsing including near misses (/api/blog/subscribe, a deeper
path, /unsubscribed, /unsubscribe-all, look-alike hosts, the un-prefixed 404 paths, garbage); route
mapping and token percent-encoding; a MockWebServer round trip asserting the exact body
{"email":"reader@example.com"}, the 400 and 500 paths, that the redirect is not followed
(requestCount == 1), and that a rejected token surfaces as invalid.

No real subscribe request was sent for a valid address — nothing was changed on the live mailing
list; only 400-ing and token-rejecting probes were issued.

⚠️ Pre-existing infrastructure gap found — filed separately

https://interlinedlist.com/.well-known/assetlinks.json returns 404 (verified). App Links
verification therefore cannot succeed, so the pre-existing autoVerify https intent filter — which
already carries /reset-password, /verify-email and the email-change paths — will not auto-open
for any of them on Android 12+
. The two blog paths inherit that. Not introduced here and not fixed
here; the interlinedlist:// custom-scheme equivalents work regardless.

Other notes

  • Not covered by an automated test: navigating to the bare blog/subscription route with both
    optional query args omitted. It is Navigation's documented optional-argument mechanism (mirroring
    AuthRoutes.RESET/EMAIL_CHANGE), but no existing route in this repo is navigated with its args
    omitted, and there is no emulator. Worth one tap when someone next runs the app.
  • No Compose case was added to :feature:profile's ProfileScreenTest.kt for the new menu row (Blog: reach the blog from the app #88
    added one for its row) — a second file in that module, outside scope, and androidTest cannot run here.

…lainly

The blog mailing list is the one part of the blog with a real JSON API, so unlike
the reader (#88) it is handled in-app rather than handed to a Custom Tab.

- Account hub gains a "Blog emails" row opening an in-app screen: an address
  prefilled from GET /api/user, and POST /api/blog/subscribe { email }.
- A successful subscribe reports CHECK_YOUR_EMAIL, never "subscribed". The
  endpoint is enumeration-safe — it answers identically for a new, pending,
  confirmed or rate-limited address — so the only truthful thing it licenses is
  "we've asked for a confirmation email". The screen says the address is not
  subscribed yet and that nothing will arrive until the emailed link is opened,
  both before subscribing and after.
- The emailed confirm and unsubscribe links are handled as deep links: the app
  calls the endpoint itself and shows the outcome. BlogLink was extended rather
  than a second parser written, and the token endpoints go through an OkHttp
  client with redirects disabled because the 307's Location — .../blog?
  subscription=confirmed|unsubscribed|invalid — is the entire result. An
  unrecognised redirect is reported as invalid; it is never read as success.

The link URLs were established against the live site, not guessed. GET on
/api/blog/subscribe/confirm?token=probe and /api/blog/unsubscribe?token=probe
both answer 307 to /blog?subscription=invalid, while /blog/confirm,
/blog/subscribe/confirm, /blog/unsubscribe, /unsubscribe and
/confirm-subscription all 404. Those two paths are what the intent filter claims,
alongside interlinedlist:// equivalents.

No address-only unsubscribe button: POST /api/blog/unsubscribe is the RFC-8058
one-click target keyed on the emailed token and answers a bare 200 to anything
(no token, no body, malformed JSON — all probed), so a button wired to it could
only pretend to work. The form points at the unsubscribe link in any blog email
instead, which this app now opens itself. One-click unsubscribe stays a mail
client's job.

A recognised link with a missing or blank token still parses, and is reported as
an unusable link without a network call: the app claims those URLs, so it owes
the user an explanation rather than silence.

Tests: 40 JVM unit tests — deep-link parsing for both links including the
/api/blog/subscribe, deeper-path and /unsubscribed near misses, look-alike hosts
and garbage; route mapping and token encoding; a MockWebServer round trip
asserting the exact POST body, the 400 and 500 paths, that the redirect is not
followed, and that a rejected token surfaces as invalid; and the ViewModel plus
the screen copy, pinning "Check your email" rather than "subscribed" after a
successful subscribe.

Closes #89
@Adron

Adron commented Sep 16, 2026

Copy link
Copy Markdown
Member Author

Merged into parity/queue. Three-file conflict with #29 (tag feed) resolved on the way in — MainActivity and InterlinedListNavHost each gained a new deep-link route, and both branches rewrote the manifest's App Links comment. All routes kept; MainShell's notificationRoute parameter had already been folded into pendingRoute by #29, so this PR's onOpenBlogEmails was grafted onto that signature. Re-verified with a full --rerun-tasks build: :app:assembleDebug testDebugUnitTest green, and the merged manifest carries all nine data paths.

@Adron
Adron merged commit ba0ded3 into parity/queue Sep 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant