Repository navigation
feat(app): blog email subscription — subscribe, confirm and unsubscribe links (#89) - #127
Merged
Merged
Conversation
…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
Member
Author
|
Merged into |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #89. Part of epic #87.
The link URLs were established, not guessed
Probed the way #72 did:
GET /api/blog/subscribe/confirm?token=probe/blog?subscription=invalidGET /api/blog/unsubscribe?token=probe/blog?subscription=invalid/blog/confirm,/blog/subscribe/confirm,/blog/unsubscribe,/unsubscribe,/confirm-subscriptionThe 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 aconfirmation 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'sLocation(
subscription=confirmed|unsubscribed|invalid) is the entire result — following it would fetchthe blog's HTML and throw the answer away. An unrecognised redirect maps to invalid, never to success.
BlogLinkwas extended, not duplicated. A recognised link with a blank or missing token stillparses 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/unsubscribeand item 5 saysnot 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 deeperpath,
/unsubscribed,/unsubscribe-all, look-alike hosts, the un-prefixed 404 paths, garbage); routemapping 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.
https://interlinedlist.com/.well-known/assetlinks.jsonreturns 404 (verified). App Linksverification therefore cannot succeed, so the pre-existing
autoVerifyhttps intent filter — whichalready carries
/reset-password,/verify-emailand the email-change paths — will not auto-openfor 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
blog/subscriptionroute with bothoptional 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 argsomitted, and there is no emulator. Worth one tap when someone next runs the app.
:feature:profile'sProfileScreenTest.ktfor the new menu row (Blog: reach the blog from the app #88added one for its row) — a second file in that module, outside scope, and androidTest cannot run here.