Tell somebody their address needs confirming, and let them do it #37

Merged
paulhitt merged 1 commits from feat/email-verification-ui into main 2026-09-02 19:47:20 +00:00
4 changed files with 137 additions and 0 deletions
@@ -90,6 +90,17 @@ class RelayAccounts(
post("account/reset/request", JSONObject().put("handle", handle))
}
/**
* Spend an email verification code.
*
* ⚠️ Unauthenticated, exactly like reset confirm: the code IS the proof.
* Requiring a signed-in session would mean somebody kept out *because* they
* are unverified could never verify.
*/
fun verifyEmail(token: String) {
post("account/verify", JSONObject().put("token", token))
}
fun confirmPasswordReset(handle: String, code: String, newPassword: String) {
post("account/reset/confirm", JSONObject()
.put("handle", handle)
@@ -796,6 +796,15 @@ class RelayClient(
throw when (code) {
401 -> RelayException.Unauthorized(readable)
// ⚠️ The relay's own code, not the status. A 403 is also "that
// device belongs to another account", and telling somebody to check
// their email for that would send them looking for a message that
// was never sent.
403 -> {
val relayCode = runCatching { JSONObject(body).optString("code") }.getOrNull()
if (relayCode == "email_unverified") RelayException.EmailUnverified(readable)
else RelayException.Http(code, readable)
}
404 -> RelayException.NotFound(path)
429 -> RelayException.RateLimited(response.header("Retry-After")?.toIntOrNull())
// The geo-gate, not the relay. Needs a sentence, not a number.
@@ -42,6 +42,24 @@ sealed class RelayException(message: String, cause: Throwable? = null) : Excepti
"This isn't a problem with your account or your connection.")
/** Any other non-2xx. The body is kept because relays put the reason in it. */
/**
* The account exists and the credential is right; the address has not been
* proved reachable yet.
*
* 🔴 **Its own type because everything else about it looks like a
* credential failure.** Without this it arrives as a bare 403 and
* [RelayAuth] logs "minting failed" before falling back to Basic — which
* the e2e routes refuse — so the person sees calls not working and an inbox
* that does nothing, and nothing anywhere says the remedy is a code sitting
* in their email.
*
* ⚠️ Do not write the e2e route glob in a KDoc here: Kotlin NESTS block
* comments, so a slash-star inside one opens a comment that never closes
* and takes the whole module down with ninety unrelated errors.
*/
class EmailUnverified(val body: String) : RelayException(
"this account's email address has not been confirmed yet")
class Http(val status: Int, val body: String) : RelayException("relay returned $status: $body")
/** A 2xx whose body was not what the route promised. */
@@ -0,0 +1,99 @@
package io.flamenet.client
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import kotlin.test.AfterTest
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertTrue
/**
* A 403 that means "check your email", told apart from every other 403.
*
* 🔴 **Without this it is indistinguishable from a credential problem.** An
* unverified account arrives as a bare 403; `RelayAuth` logs "minting failed"
* and falls back to Basic, which the e2e routes refuse. The person then sees
* calls that do not work and an inbox that does nothing, and nothing anywhere
* says the remedy is a code sitting in their inbox.
*
* ⚠️ The status alone is not enough: 403 is also "that device belongs to
* another account". Telling somebody to check their email for that would send
* them looking for a message that was never sent — so the relay's own `code` is
* what decides, not the number.
*/
class EmailUnverifiedTest {
private lateinit var server: MockWebServer
@BeforeTest fun start() {
server = MockWebServer()
server.start()
}
@AfterTest fun stop() {
server.shutdown()
}
private fun relay() = RelayClient(
server.url("/").toString(), username = "ada", appPassword = "credential")
private fun refuse(code: String?, status: Int = 403) {
val body = if (code == null) """{"message":"no"}"""
else """{"code":"$code","message":"Check your email and enter the code."}"""
server.enqueue(
MockResponse().setResponseCode(status)
.setHeader("Content-Type", "application/json")
.setBody(body)
)
}
@Test
fun `an unverified account is its own failure, not a generic 403`() {
refuse("email_unverified")
val failure = assertFailsWith<RelayException.EmailUnverified> {
relay().listDevices(1)
}
assertTrue(
failure.body.contains("code"),
"the relay's sentence should survive: ${failure.body}"
)
}
/**
* 🔴 The distinction that matters. A device conflict is also a 403 and must
* NOT be reported as an unverified address.
*/
@Test
fun `another 403 is not mistaken for an unverified address`() {
refuse("forbidden")
val failure = assertFailsWith<RelayException.Http> { relay().listDevices(1) }
assertEquals(403, failure.status)
}
/** ⚠️ A 403 with no code at all — an old relay, or a proxy — stays generic. */
@Test
fun `a 403 with no code stays generic`() {
refuse(null)
assertFailsWith<RelayException.Http> { relay().listDevices(1) }
}
/** ⚠️ And a body that is not JSON must not throw a parse error. */
@Test
fun `an unparseable 403 body does not crash`() {
server.enqueue(
MockResponse().setResponseCode(403)
.setHeader("Content-Type", "text/html")
.setBody("<html>a proxy wrote this</html>")
)
assertFailsWith<RelayException.Http> { relay().listDevices(1) }
}
/** The code is only meaningful on a 403 — the same string on a 500 is not. */
@Test
fun `the code does not change the meaning of other statuses`() {
refuse("email_unverified", status = 500)
assertFailsWith<RelayException.Http> { relay().listDevices(1) }
}
}