fix(graph): response-body IOException escapes raw instead of GraphTransportException(mayHaveSent=true) #490

Open
opened 2026-07-10 19:14:46 +00:00 by JMR-dev · 0 comments
JMR-dev commented 2026-07-10 19:14:46 +00:00 (Migrated from github.com)

Verified finding(s) from the 2026-07-09 whole-repo multi-agent review (independent finder, then adversarial verifier; verdict CONFIRMED).

Triage: above the cut — fix dispatched immediately; this issue tracks the fix to Done.

app/src/main/kotlin/org/libremail/mail/graph/GraphHttp.kt:87 — high

An IOException while reading the response body escapes as a raw exception instead of GraphTransportException(mayHaveSent=true), breaking the no-duplicate-send contract.

Failure scenario: Outlook account sends mail via Graph. The sendMail request is fully transmitted and readStatus() successfully reads a 202 (Graph accepted and will deliver), but the connection resets or read-times-out while stream?.bufferedReader()?.use { it.readText() } reads the body. The raw SocketException/SocketTimeoutException propagates — writeBody and readStatus are guarded, this line is not — so GraphSender's catch (e: GraphTransportException) misses it and SendWorker.sendOutlook's generic catch (e: Exception) branch ('Graph was never reached') falls back to SMTP, sending the message a second time. Recipients receive a duplicate — the exact failure the mayHaveSent machinery exists to prevent.

Verifier justification (CONFIRMED): The unguarded line is real and the full failure chain is verifiable in code. GraphHttp.execute guards writeBody and readStatus with IOException→GraphTransportException mappings, but the body read at line 87 has no catch, so a SocketException/SocketTimeoutException after a successfully-read 202 status propagates raw — violating the method's own KDoc contract ('Throws GraphTransportException when there was no response ... preserving the send path's no-duplicate guarantee'). GraphThrottle.execute (GraphThrottle.kt:56) does not catch it; GraphSender.send catches only GraphTransportException (GraphSender.kt:69), so no GraphSendException(mayHaveSent=true) is produced; SendWorker.sendOutlook's generic catch (SendWorker.kt:180-188, commented 'Graph was never reached ... SMTP cannot duplicate it') then unconditionally falls back to SMTP. Concrete trigger: Outlook send, Graph accepts sendMail and returns the 202 status line/headers, then the connection resets or the 15s readTimeout fires while readText() drains the body → the message is delivered twice (once by Graph, once via SMTP fallback). Nothing in tests or docs marks this as intentional; the KDoc says the opposite.

Defective line: val body = stream?.bufferedReader()?.use { it.readText() }.orEmpty()

Fix hint: In GraphHttpClient.execute, wrap the body read (and the Retry-After header read) at GraphHttp.kt:87-89 in try/catch(IOException). Since the status was already received, either return GraphResponse(status, body = "") (the server did answer, and for sendMail the 202 status alone proves acceptance) or throw GraphTransportException(mayHaveSent = true, cause = e); add a unit test with a fake connection whose input stream throws mid-read after yielding a 2xx status.

Verified finding(s) from the 2026-07-09 whole-repo multi-agent review (independent finder, then adversarial verifier; verdict **CONFIRMED**). **Triage: above the cut — fix dispatched immediately; this issue tracks the fix to Done.** ## `app/src/main/kotlin/org/libremail/mail/graph/GraphHttp.kt:87` — high An IOException while reading the response body escapes as a raw exception instead of GraphTransportException(mayHaveSent=true), breaking the no-duplicate-send contract. **Failure scenario:** Outlook account sends mail via Graph. The sendMail request is fully transmitted and readStatus() successfully reads a 202 (Graph accepted and will deliver), but the connection resets or read-times-out while `stream?.bufferedReader()?.use { it.readText() }` reads the body. The raw SocketException/SocketTimeoutException propagates — writeBody and readStatus are guarded, this line is not — so GraphSender's `catch (e: GraphTransportException)` misses it and SendWorker.sendOutlook's generic `catch (e: Exception)` branch ('Graph was never reached') falls back to SMTP, sending the message a second time. Recipients receive a duplicate — the exact failure the mayHaveSent machinery exists to prevent. **Verifier justification (CONFIRMED):** The unguarded line is real and the full failure chain is verifiable in code. GraphHttp.execute guards writeBody and readStatus with IOException→GraphTransportException mappings, but the body read at line 87 has no catch, so a SocketException/SocketTimeoutException after a successfully-read 202 status propagates raw — violating the method's own KDoc contract ('Throws GraphTransportException when there was no response ... preserving the send path's no-duplicate guarantee'). GraphThrottle.execute (GraphThrottle.kt:56) does not catch it; GraphSender.send catches only GraphTransportException (GraphSender.kt:69), so no GraphSendException(mayHaveSent=true) is produced; SendWorker.sendOutlook's generic catch (SendWorker.kt:180-188, commented 'Graph was never reached ... SMTP cannot duplicate it') then unconditionally falls back to SMTP. Concrete trigger: Outlook send, Graph accepts sendMail and returns the 202 status line/headers, then the connection resets or the 15s readTimeout fires while readText() drains the body → the message is delivered twice (once by Graph, once via SMTP fallback). Nothing in tests or docs marks this as intentional; the KDoc says the opposite. **Defective line:** `val body = stream?.bufferedReader()?.use { it.readText() }.orEmpty()` **Fix hint:** In GraphHttpClient.execute, wrap the body read (and the Retry-After header read) at GraphHttp.kt:87-89 in try/catch(IOException). Since the status was already received, either return GraphResponse(status, body = "") (the server did answer, and for sendMail the 202 status alone proves acceptance) or throw GraphTransportException(mayHaveSent = true, cause = e); add a unit test with a fake connection whose input stream throws mid-read after yielding a 2xx status.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: JMR-dev/LibreMail#490